Initialer Laravel Commit für BetiX
This commit is contained in:
273
app/Services/DepositService.php
Normal file
273
app/Services/DepositService.php
Normal file
@@ -0,0 +1,273 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user