Initialer Laravel Commit für BetiX
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

This commit is contained in:
2026-04-04 18:01:50 +02:00
commit 0280278978
374 changed files with 65210 additions and 0 deletions

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