274 lines
11 KiB
PHP
274 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\AppSetting;
|
|
use App\Models\CryptoPayment;
|
|
use App\Models\User;
|
|
use App\Models\WalletTransfer;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Http\Request;
|
|
|
|
class DepositService
|
|
{
|
|
public function __construct(private readonly NowPaymentsClient $np)
|
|
{
|
|
}
|
|
|
|
public function getSettings(): array
|
|
{
|
|
$defaults = [
|
|
'mode' => config('services.nowpayments.mode', 'sandbox'),
|
|
'enabled_currencies' => ['BTC','ETH','LTC','SOL','USDT_ERC20','USDT_TRC20'], // Fallback
|
|
'global_min_usd' => 10.0,
|
|
'global_max_usd' => 10000.0,
|
|
'btx_per_usd' => 1.0,
|
|
'per_currency_overrides' => [],
|
|
'success_url' => url('/wallet?deposit=success'),
|
|
'cancel_url' => url('/wallet?deposit=cancel'),
|
|
'address_mode' => 'per_payment', // per_payment | per_user
|
|
];
|
|
$row = AppSetting::get('payments.nowpayments', []);
|
|
if (!is_array($row)) $row = [];
|
|
return array_replace($defaults, $row);
|
|
}
|
|
|
|
public function currenciesForUser(): array
|
|
{
|
|
$s = $this->getSettings();
|
|
|
|
// Fetch the live list of enabled currencies from the NOWPayments API
|
|
$apiCoins = $this->np->getAvailableCoins();
|
|
|
|
// Use API coins if successful, otherwise fallback to local settings
|
|
$enabled = !empty($apiCoins) ? array_map('strtoupper', $apiCoins) : $s['enabled_currencies'];
|
|
|
|
return [
|
|
'mode' => $s['mode'],
|
|
'enabled' => $enabled,
|
|
'limits' => [
|
|
'global_min_usd' => $s['global_min_usd'],
|
|
'global_max_usd' => $s['global_max_usd'],
|
|
],
|
|
'overrides' => $s['per_currency_overrides'],
|
|
'btx_per_usd' => $s['btx_per_usd'],
|
|
];
|
|
}
|
|
|
|
public function startDeposit(User $user, string $payCurrency, float $priceAmountUsd): ?array
|
|
{
|
|
$s = $this->getSettings();
|
|
$payCurrency = strtoupper($payCurrency);
|
|
$addressMode = (string) ($s['address_mode'] ?? 'per_payment');
|
|
|
|
// Fetch live allowed coins to validate
|
|
$apiCoins = $this->np->getAvailableCoins();
|
|
$allowedCoins = !empty($apiCoins) ? array_map('strtoupper', $apiCoins) : $s['enabled_currencies'];
|
|
|
|
// Validate currency against live list
|
|
if (!in_array($payCurrency, $allowedCoins, true)) {
|
|
return ['error' => 'currency_not_allowed'];
|
|
}
|
|
|
|
// Validate limits (override per currency if provided)
|
|
$min = (float) ($s['per_currency_overrides'][$payCurrency]['min_usd'] ?? $s['global_min_usd']);
|
|
$max = (float) ($s['per_currency_overrides'][$payCurrency]['max_usd'] ?? $s['global_max_usd']);
|
|
if ($priceAmountUsd < $min || $priceAmountUsd > $max) {
|
|
return ['error' => 'amount_out_of_bounds', 'min_usd' => $min, 'max_usd' => $max];
|
|
}
|
|
|
|
$orderId = (string) Str::uuid();
|
|
|
|
// Get an estimate for the crypto amount
|
|
$estimate = $this->np->estimate($priceAmountUsd, 'usd', $payCurrency);
|
|
$payAmount = $estimate['estimated_amount'] ?? null;
|
|
|
|
$payload = [
|
|
'price_amount' => $priceAmountUsd,
|
|
'price_currency' => 'USD',
|
|
'pay_currency' => $payCurrency,
|
|
'order_id' => $orderId, // We use our local order ID here, so it ties back to the user
|
|
'order_description' => 'Deposit for user #' . $user->id,
|
|
'success_url' => $s['success_url'],
|
|
'cancel_url' => $s['cancel_url'],
|
|
'ipn_callback_url' => url('/api/webhooks/nowpayments'),
|
|
];
|
|
|
|
$invoice = $this->np->createInvoice($payload);
|
|
if (!$invoice || empty($invoice['id'])) {
|
|
Log::error('Failed to create NOWPayments invoice', ['payload' => $payload, 'invoice' => $invoice]);
|
|
return ['error' => 'invoice_create_failed'];
|
|
}
|
|
|
|
// Some responses embed payment_id/address at creation; store what we have
|
|
$paymentId = (string) ($invoice['payment_id'] ?? ($invoice['paymentId'] ?? '')); // resilient to casing
|
|
$payAddress = (string) ($invoice['pay_address'] ?? ($invoice['payAddress'] ?? ''));
|
|
|
|
$cp = CryptoPayment::create([
|
|
'user_id' => $user->id,
|
|
'order_id' => $orderId,
|
|
'invoice_id' => (string) $invoice['id'],
|
|
'payment_id' => $paymentId ?: ('inv_' . $invoice['id']),
|
|
'pay_currency' => $payCurrency,
|
|
'pay_amount' => $payAmount,
|
|
'actually_paid' => null,
|
|
'pay_address' => $payAddress ?: null,
|
|
'price_amount' => $priceAmountUsd,
|
|
'price_currency' => 'USD',
|
|
'status' => 'waiting',
|
|
'raw_payload' => $invoice,
|
|
]);
|
|
|
|
return [
|
|
'order_id' => $orderId,
|
|
'invoice_id' => (string) $invoice['id'],
|
|
'payment_id' => $cp->payment_id,
|
|
'pay_currency' => $payCurrency,
|
|
'price_amount' => $priceAmountUsd,
|
|
'price_currency' => 'USD',
|
|
'pay_address' => $payAddress ?: null,
|
|
'redirect_url' => $invoice['invoice_url'] ?? ($invoice['url'] ?? null),
|
|
];
|
|
}
|
|
|
|
public function getUserHistory(User $user, int $limit = 10): array
|
|
{
|
|
$payments = CryptoPayment::where('user_id', $user->id)
|
|
->orderByDesc('created_at')
|
|
->limit($limit)
|
|
->get();
|
|
|
|
$history = [];
|
|
foreach ($payments as $p) {
|
|
$history[] = [
|
|
'order_id' => $p->order_id,
|
|
'payment_id' => $p->payment_id,
|
|
'status' => $p->status,
|
|
'pay_currency' => strtoupper($p->pay_currency),
|
|
'pay_amount' => $p->pay_amount,
|
|
'price_amount' => $p->price_amount,
|
|
'actually_paid' => $p->actually_paid,
|
|
'credited_btx' => $p->credited_btx,
|
|
'created_at' => $p->created_at?->toIso8601String(),
|
|
];
|
|
}
|
|
|
|
return $history;
|
|
}
|
|
|
|
public function handleIpn(Request $request): array
|
|
{
|
|
$cfg = config('services.nowpayments');
|
|
$dbSettings = AppSetting::get('payments.nowpayments', []);
|
|
$secret = (string) ($dbSettings['ipn_secret'] ?? $cfg['ipn_secret'] ?? '');
|
|
$body = $request->getContent();
|
|
$sig = (string) $request->header('x-nowpayments-sig', '');
|
|
|
|
if (!$this->verifyHmac($body, $sig, $secret)) {
|
|
return ['ok' => false, 'status' => 401, 'message' => 'Invalid signature'];
|
|
}
|
|
|
|
$data = $request->json()->all();
|
|
// Identify by payment_id or order_id
|
|
$paymentId = (string) ($data['payment_id'] ?? ($data['paymentId'] ?? ''));
|
|
$orderId = (string) ($data['order_id'] ?? ($data['orderId'] ?? ''));
|
|
$status = (string) ($data['payment_status'] ?? ($data['status'] ?? ''));
|
|
|
|
if (!$paymentId && !$orderId) {
|
|
return ['ok' => false, 'status' => 422, 'message' => 'Missing identifiers'];
|
|
}
|
|
|
|
$cp = CryptoPayment::query()
|
|
->when($paymentId, fn($q) => $q->where('payment_id', $paymentId))
|
|
->when(!$paymentId && $orderId, fn($q) => $q->where('order_id', $orderId))
|
|
->first();
|
|
|
|
if (!$cp) {
|
|
// Create minimal row if missing (idempotent handling)
|
|
Log::warning('IPN for unknown payment', ['payment_id' => $paymentId, 'order_id' => $orderId]);
|
|
return ['ok' => true, 'status' => 200, 'message' => 'Ignored'];
|
|
}
|
|
|
|
// Update raw payload & status mirror
|
|
$cp->status = $status ?: ($cp->status ?? '');
|
|
$cp->raw_payload = $data;
|
|
$cp->pay_amount = $data['pay_amount'] ?? $cp->pay_amount;
|
|
$cp->actually_paid = $data['actually_paid'] ?? $cp->actually_paid;
|
|
$cp->pay_address = $data['pay_address'] ?? $cp->pay_address;
|
|
$cp->confirmations = $data['confirmations'] ?? $cp->confirmations;
|
|
$cp->tx_hash = $data['payin_hash'] ?? ($data['tx_hash'] ?? $cp->tx_hash);
|
|
|
|
// Idempotency: if already credited, do nothing
|
|
if ($cp->credited_btx !== null) {
|
|
$cp->save();
|
|
return ['ok' => true, 'status' => 200, 'message' => 'Already processed'];
|
|
}
|
|
|
|
// Only credit on finished
|
|
if (strtolower($cp->status) !== 'finished') {
|
|
$cp->save();
|
|
return ['ok' => true, 'status' => 200, 'message' => 'No credit (status=' . $cp->status . ')'];
|
|
}
|
|
|
|
// Compute BTX amount: use final USD if present
|
|
$settings = $this->getSettings();
|
|
$btxPerUsd = (float) ($settings['per_currency_overrides'][$cp->pay_currency]['btx_per_usd'] ?? $settings['btx_per_usd']);
|
|
$usdFinal = (float) ($data['actually_paid_in_fiat'] ?? ($cp->price_amount ?? 0));
|
|
if ($usdFinal <= 0) {
|
|
$usdFinal = (float) ($cp->price_amount ?? 0);
|
|
}
|
|
$creditBtx = round($usdFinal * $btxPerUsd, 8);
|
|
|
|
DB::transaction(function () use ($cp, $creditBtx) {
|
|
// Row lock user
|
|
$user = User::query()->where('id', $cp->user_id)->lockForUpdate()->first();
|
|
if (!$user) {
|
|
throw new \RuntimeException('User not found for payment');
|
|
}
|
|
|
|
$balanceBefore = (float) ($user->balance ?? 0);
|
|
$vaultBefore = (float) ($user->vault_balance ?? 0);
|
|
$balanceAfter = $balanceBefore + (float) $creditBtx;
|
|
$vaultAfter = $vaultBefore; // vault not affected by external deposit
|
|
|
|
$user->balance = $balanceAfter;
|
|
$user->save();
|
|
|
|
// Log into wallet_transfers if available
|
|
if (class_exists(WalletTransfer::class)) {
|
|
WalletTransfer::create([
|
|
'user_id' => $user->id,
|
|
'type' => 'deposit',
|
|
'amount' => $creditBtx,
|
|
'balance_before' => $balanceBefore,
|
|
'balance_after' => $balanceAfter,
|
|
'vault_before' => $vaultBefore,
|
|
'vault_after' => $vaultAfter,
|
|
'currency' => 'BTX',
|
|
'idempotency_key' => 'np:' . $cp->payment_id,
|
|
'meta' => [
|
|
'source' => 'nowpayments',
|
|
'payment_id' => $cp->payment_id,
|
|
'invoice_id' => $cp->invoice_id,
|
|
],
|
|
]);
|
|
}
|
|
|
|
$cp->credited_btx = $creditBtx;
|
|
$cp->credited_at = now();
|
|
$cp->save();
|
|
});
|
|
|
|
return ['ok' => true, 'status' => 200, 'message' => 'Credited'];
|
|
}
|
|
|
|
private function verifyHmac(string $rawBody, string $sigHeader, string $secret): bool
|
|
{
|
|
if ($secret === '' || $sigHeader === '') return false;
|
|
$calc = hash_hmac('sha512', $rawBody, $secret);
|
|
return hash_equals($calc, $sigHeader);
|
|
}
|
|
}
|