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); } }