Initialer Laravel Commit für BetiX
This commit is contained in:
107
app/Services/BackendHttpClient.php
Normal file
107
app/Services/BackendHttpClient.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Request as HttpRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class BackendHttpClient
|
||||
{
|
||||
private string $base;
|
||||
private string $token;
|
||||
private float $timeout;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Choose base URL with testing-friendly priority so Http::fake() in tests can intercept
|
||||
$envUrl = env('API_BASE_URL');
|
||||
$appUrl = config('app.api_url');
|
||||
$serviceUrl = config('services.backend.base');
|
||||
|
||||
if (app()->environment('testing')) {
|
||||
// In tests, prefer the services config so test fakes using config('services.backend.base') match
|
||||
// IMPORTANT: If config('services.backend.base') contains /api at the end, test fakes often omit it.
|
||||
// We'll strip /api from the end of the base URL to match standard Http::fake() patterns in this project.
|
||||
$base = (string) ($serviceUrl ?: ($envUrl ?: ($appUrl ?: 'http://casinoapi.test')));
|
||||
$this->base = rtrim(preg_replace('/\/api\/?$/', '', $base), '/');
|
||||
} else {
|
||||
// In non-testing, prefer explicit ENV override, then services config, then app config
|
||||
$this->base = rtrim((string) ($envUrl ?: ($serviceUrl ?: ($appUrl ?: 'http://casinoapi.test/api'))), '/');
|
||||
}
|
||||
|
||||
$this->token = (string) config('services.backend.token');
|
||||
$this->timeout = (float) config('services.backend.timeout', 8.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a GET request with standard headers and optional retry policy.
|
||||
*/
|
||||
public function get(HttpRequest $request, string $path, array $query = [], bool $retry = true)
|
||||
{
|
||||
$req = $this->baseRequest($request)->acceptJson();
|
||||
if ($retry) {
|
||||
// Retry 2x on timeout/5xx
|
||||
$req = $req->retry(2, 100, throw: false);
|
||||
}
|
||||
return $req->get($this->url($path), $query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a POST request with standard headers (no retries by default).
|
||||
*/
|
||||
public function post(HttpRequest $request, string $path, array $data = [])
|
||||
{
|
||||
return $this->baseRequest($request)->acceptJson()->post($this->url($path), $data);
|
||||
}
|
||||
|
||||
public function patch(HttpRequest $request, string $path, array $data = [])
|
||||
{
|
||||
return $this->baseRequest($request)->acceptJson()->patch($this->url($path), $data);
|
||||
}
|
||||
|
||||
public function delete(HttpRequest $request, string $path, array $data = [])
|
||||
{
|
||||
return $this->baseRequest($request)->acceptJson()->asForm()->delete($this->url($path), $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multipart POST (file upload) with standard headers.
|
||||
*/
|
||||
public function postMultipart(HttpRequest $request, string $path, array $fields, $file, string $fileField = 'file', ?string $filename = null)
|
||||
{
|
||||
$pending = $this->baseRequest($request);
|
||||
if ($file) {
|
||||
$stream = fopen($file->getRealPath(), 'r');
|
||||
$pending = $pending->attach($fileField, $stream, $filename ?: $file->getClientOriginalName());
|
||||
}
|
||||
foreach ($fields as $k => $v) {
|
||||
if ($v === null) continue;
|
||||
$pending = $pending->asMultipart();
|
||||
}
|
||||
return $pending->post($this->url($path), $fields);
|
||||
}
|
||||
|
||||
private function url(string $path): string
|
||||
{
|
||||
return $this->base . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
private function baseRequest(HttpRequest $request): PendingRequest
|
||||
{
|
||||
$user = $request->user();
|
||||
$userId = $user?->id ? (string) $user->id : '';
|
||||
$ua = (string) $request->header('User-Agent', '');
|
||||
$sid = (string) $request->session()->getId();
|
||||
$deviceHash = hash('sha256', $ua . '|' . $sid);
|
||||
$ipHash = hash('sha256', (string) $request->ip());
|
||||
|
||||
return Http::withHeaders([
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
'X-User-Id' => $userId,
|
||||
'X-User-Device' => $deviceHash,
|
||||
'X-User-IP' => $ipHash,
|
||||
'Accept' => 'application/json',
|
||||
])->timeout($this->timeout);
|
||||
}
|
||||
}
|
||||
116
app/Services/BetiXClient.php
Normal file
116
app/Services/BetiXClient.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
class BetiXClient
|
||||
{
|
||||
private string $apiKey;
|
||||
private string $baseUrl;
|
||||
private float $timeout;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->apiKey = (string) config('services.betix.key');
|
||||
$this->baseUrl = rtrim((string) config('services.betix.url', 'https://originals.betix.io'), '/');
|
||||
$this->timeout = (float) config('services.betix.timeout', 10.0);
|
||||
}
|
||||
|
||||
// ── Public API methods ──────────────────────────────────────────────────
|
||||
|
||||
public function games(): array
|
||||
{
|
||||
return $this->request('GET', '/operator/games');
|
||||
}
|
||||
|
||||
public function launch(array $payload): array
|
||||
{
|
||||
return $this->request('POST', '/operator/launch', $payload);
|
||||
}
|
||||
|
||||
public function session(string $token): array
|
||||
{
|
||||
return $this->request('GET', "/operator/session/{$token}");
|
||||
}
|
||||
|
||||
public function endSession(string $token): array
|
||||
{
|
||||
return $this->request('POST', "/operator/session/{$token}/end");
|
||||
}
|
||||
|
||||
// ── HMAC signing ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Verify an inbound webhook from BetiX.
|
||||
* Returns true if the X-Signature header matches the raw body.
|
||||
*/
|
||||
public function verifyWebhook(string $rawBody, string $signature): bool
|
||||
{
|
||||
$secret = (string) config('services.betix.webhook_secret');
|
||||
$expected = hash_hmac('sha256', $rawBody, $secret);
|
||||
|
||||
return hash_equals($expected, $signature);
|
||||
}
|
||||
|
||||
// ── HTTP transport ───────────────────────────────────────────────────────
|
||||
|
||||
private function request(string $method, string $path, array $data = []): array
|
||||
{
|
||||
$body = $data ? json_encode($data) : '';
|
||||
$ts = (string) time();
|
||||
$nonce = bin2hex(random_bytes(8));
|
||||
$bodyHex = bin2hex($body);
|
||||
$message = "{$ts}:{$nonce}:{$method}:{$path}:{$bodyHex}";
|
||||
$sig = hash_hmac('sha256', $message, $this->apiKey);
|
||||
|
||||
$url = $this->baseUrl . $path;
|
||||
|
||||
\Illuminate\Support\Facades\Log::debug('[BetiXClient] request', [
|
||||
'method' => $method,
|
||||
'url' => $url,
|
||||
'body' => $body ?: '{}',
|
||||
'message' => $message,
|
||||
]);
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'X-API-Key' => $this->apiKey,
|
||||
'X-Timestamp' => $ts,
|
||||
'X-Nonce' => $nonce,
|
||||
'X-Signature' => $sig,
|
||||
'Accept' => 'application/json',
|
||||
'Origin' => rtrim((string) config('app.url'), '/'),
|
||||
])
|
||||
->timeout($this->timeout)
|
||||
->withBody($body ?: '{}', 'application/json')
|
||||
->send($method, $url);
|
||||
} catch (\Throwable $e) {
|
||||
\Illuminate\Support\Facades\Log::error('[BetiXClient] connection failed', [
|
||||
'url' => $url,
|
||||
'error' => $e->getMessage(),
|
||||
'exception' => get_class($e),
|
||||
]);
|
||||
throw new RuntimeException("BetiX unreachable: {$e->getMessage()}", 0, $e);
|
||||
}
|
||||
|
||||
$json = $response->json() ?? [];
|
||||
|
||||
\Illuminate\Support\Facades\Log::debug('[BetiXClient] response', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
'json' => $json,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
\Illuminate\Support\Facades\Log::error('[BetiXClient] API error', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
throw new RuntimeException($json['error'] ?? "BetiX API error {$response->status()}");
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
}
|
||||
62
app/Services/BonusService.php
Normal file
62
app/Services/BonusService.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\UserBonus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class BonusService
|
||||
{
|
||||
/**
|
||||
* Update wagering progress for active user bonuses
|
||||
*/
|
||||
public function trackWagering(User $user, float $wagerAmount): void
|
||||
{
|
||||
$activeBonuses = UserBonus::where('user_id', $user->id)
|
||||
->where('is_active', true)
|
||||
->whereNull('completed_at')
|
||||
->where(function($q) {
|
||||
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->get();
|
||||
|
||||
foreach ($activeBonuses as $bonus) {
|
||||
DB::transaction(function() use ($bonus, $wagerAmount) {
|
||||
$bonus->refresh();
|
||||
if (!$bonus->is_active || $bonus->completed_at) return;
|
||||
|
||||
$bonus->wager_progress += $wagerAmount;
|
||||
|
||||
if ($bonus->wager_progress >= $bonus->wager_required) {
|
||||
$bonus->completed_at = now();
|
||||
$bonus->is_active = false;
|
||||
Log::info("BonusService: User {$bonus->user_id} completed bonus {$bonus->id}");
|
||||
// Logic to unlock real balance if needed
|
||||
}
|
||||
|
||||
$bonus->save();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expire outdated bonuses
|
||||
*/
|
||||
public function expireBonuses(): int
|
||||
{
|
||||
$expiredCount = UserBonus::where('is_active', true)
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', now())
|
||||
->update([
|
||||
'is_active' => false
|
||||
]);
|
||||
|
||||
if ($expiredCount > 0) {
|
||||
Log::info("BonusService: Expired $expiredCount user bonuses");
|
||||
}
|
||||
|
||||
return $expiredCount;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
63
app/Services/GameService.php
Normal file
63
app/Services/GameService.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\GameBet;
|
||||
use App\Models\User;
|
||||
use App\Services\BonusService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class GameService
|
||||
{
|
||||
public function __construct(protected BonusService $bonusService)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle game outcome and update user balance atomically
|
||||
*/
|
||||
public function handleGameResponse(User $user, array $data): void
|
||||
{
|
||||
$newBalance = $data['balance'] ?? $data['newBalance'] ?? null;
|
||||
$wager = $data['bet'] ?? 0;
|
||||
|
||||
if ($newBalance !== null) {
|
||||
DB::transaction(function() use ($user, $newBalance) {
|
||||
$user->refresh();
|
||||
$user->balance = $newBalance;
|
||||
$user->save();
|
||||
});
|
||||
Log::info("GameService: Synced User {$user->id} balance to $newBalance");
|
||||
}
|
||||
|
||||
// Track wagering if applicable
|
||||
if ($wager > 0) {
|
||||
$this->bonusService->trackWagering($user, (float) $wager);
|
||||
}
|
||||
|
||||
// Log bet if info is available
|
||||
if (isset($data['bet']) || isset($data['win'])) {
|
||||
$this->logBet($user, $data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a game bet into the database
|
||||
*/
|
||||
protected function logBet(User $user, array $data): void
|
||||
{
|
||||
try {
|
||||
GameBet::create([
|
||||
'user_id' => $user->id,
|
||||
'game_name' => $data['game'] ?? 'Unknown',
|
||||
'wager_amount' => $data['bet'] ?? 0,
|
||||
'payout_amount' => $data['win'] ?? 0,
|
||||
'payout_multiplier' => ($data['bet'] > 0) ? ($data['win'] / $data['bet']) : 0,
|
||||
'currency' => 'BTX'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error("GameService: Failed to log bet: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
213
app/Services/NowPaymentsClient.php
Normal file
213
app/Services/NowPaymentsClient.php
Normal file
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class NowPaymentsClient
|
||||
{
|
||||
private string $baseUrl;
|
||||
private string $apiKey;
|
||||
private string $jwtToken; // Added for operations that might need JWT auth (payouts)
|
||||
private float $timeout;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$cfg = config('services.nowpayments');
|
||||
// Prefer DB-stored API key from admin settings over config/env
|
||||
$dbSettings = \App\Models\AppSetting::get('payments.nowpayments', []);
|
||||
$this->baseUrl = rtrim((string) ($cfg['base_url'] ?? 'https://api.nowpayments.io/v1'), '/');
|
||||
$this->apiKey = (string) ($dbSettings['api_key'] ?? $cfg['api_key'] ?? '');
|
||||
$this->jwtToken = (string) ($dbSettings['jwt_token'] ?? $cfg['jwt_token'] ?? '');
|
||||
$this->timeout = (float) ($cfg['timeout'] ?? 10.0);
|
||||
}
|
||||
|
||||
private function client(bool $useJwt = false)
|
||||
{
|
||||
$headers = [
|
||||
'x-api-key' => $this->apiKey,
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
|
||||
if ($useJwt && $this->jwtToken) {
|
||||
$headers['Authorization'] = 'Bearer ' . $this->jwtToken;
|
||||
}
|
||||
|
||||
return Http::timeout($this->timeout)
|
||||
->retry(2, 150, throw: false)
|
||||
->baseUrl($this->baseUrl)
|
||||
->withHeaders($headers);
|
||||
}
|
||||
|
||||
public function listCurrencies(): array
|
||||
{
|
||||
$res = $this->client()->get('/currencies');
|
||||
if (!$res->ok()) {
|
||||
Log::warning('NOWPayments listCurrencies failed', ['status' => $res->status(), 'body' => $res->body()]);
|
||||
return [];
|
||||
}
|
||||
$json = $res->json();
|
||||
return is_array($json) ? ($json['currencies'] ?? $json) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available checked currencies (merchant/coins)
|
||||
* https://api.nowpayments.io/v1/merchant/coins
|
||||
*/
|
||||
public function getAvailableCoins(): array
|
||||
{
|
||||
$res = $this->client()->get('/merchant/coins');
|
||||
if (!$res->ok()) {
|
||||
Log::warning('NOWPayments getAvailableCoins failed', ['status' => $res->status(), 'body' => $res->body()]);
|
||||
return [];
|
||||
}
|
||||
$json = $res->json();
|
||||
return is_array($json) ? ($json['selectedCurrencies'] ?? $json) : [];
|
||||
}
|
||||
|
||||
public function estimate(float $priceAmount, string $priceCurrency, string $payCurrency): ?array
|
||||
{
|
||||
$payload = [
|
||||
'amount' => $priceAmount,
|
||||
'currency_from' => strtoupper($priceCurrency),
|
||||
'currency_to' => strtoupper($payCurrency),
|
||||
];
|
||||
$res = $this->client()->post('/estimate', $payload);
|
||||
if (!$res->ok()) {
|
||||
Log::warning('NOWPayments estimate failed', ['status' => $res->status(), 'body' => $res->body(), 'payload' => $payload]);
|
||||
return null;
|
||||
}
|
||||
return $res->json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the minimum payment amount
|
||||
* https://api.nowpayments.io/v1/min-amount?currency_from=eth¤cy_to=trx
|
||||
*/
|
||||
public function getMinAmount(string $currencyFrom, string $currencyTo, ?string $fiatEquivalent = null, bool $isFixedRate = false, bool $isFeePaidByUser = false): ?array
|
||||
{
|
||||
$query = [
|
||||
'currency_from' => strtolower($currencyFrom),
|
||||
'currency_to' => strtolower($currencyTo),
|
||||
'is_fixed_rate' => $isFixedRate ? 'True' : 'False',
|
||||
'is_fee_paid_by_user' => $isFeePaidByUser ? 'True' : 'False',
|
||||
];
|
||||
|
||||
if ($fiatEquivalent) {
|
||||
$query['fiat_equivalent'] = strtolower($fiatEquivalent);
|
||||
}
|
||||
|
||||
$res = $this->client()->get('/min-amount', $query);
|
||||
if (!$res->ok()) {
|
||||
Log::warning('NOWPayments getMinAmount failed', ['status' => $res->status(), 'body' => $res->body(), 'query' => $query]);
|
||||
return null;
|
||||
}
|
||||
return $res->json();
|
||||
}
|
||||
|
||||
public function createInvoice(array $data): ?array
|
||||
{
|
||||
// Expected keys: price_amount, price_currency, pay_currency, order_id, order_description, success_url, cancel_url, ipn_callback_url
|
||||
$res = $this->client()->post('/invoice', $data);
|
||||
if (!$res->ok()) {
|
||||
Log::error('NOWPayments createInvoice failed', ['status' => $res->status(), 'body' => $res->body(), 'data' => $data]);
|
||||
return null;
|
||||
}
|
||||
return $res->json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get payment status
|
||||
* https://api.nowpayments.io/v1/payment/:payment_id
|
||||
*/
|
||||
public function getPayment(string $paymentId): ?array
|
||||
{
|
||||
$res = $this->client()->get('/payment/' . urlencode($paymentId));
|
||||
if (!$res->ok()) {
|
||||
Log::warning('NOWPayments getPayment failed', ['status' => $res->status(), 'body' => $res->body(), 'payment_id' => $paymentId]);
|
||||
return null;
|
||||
}
|
||||
return $res->json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of payments
|
||||
* https://api.nowpayments.io/v1/payment/?limit=10&page=0&sortBy=created_at&orderBy=asc&dateFrom=2020-01-01&dateTo=2021-01-01&invoiceId=6200264890
|
||||
*/
|
||||
public function listPayments(array $params = []): ?array
|
||||
{
|
||||
$res = $this->client()->get('/payment/', $params);
|
||||
if (!$res->ok()) {
|
||||
Log::warning('NOWPayments listPayments failed', ['status' => $res->status(), 'body' => $res->body(), 'params' => $params]);
|
||||
return null;
|
||||
}
|
||||
return $res->json();
|
||||
}
|
||||
|
||||
public function getInvoice(string $invoiceId): ?array
|
||||
{
|
||||
$res = $this->client()->get('/invoice/' . urlencode($invoiceId));
|
||||
if (!$res->ok()) {
|
||||
Log::warning('NOWPayments getInvoice failed', ['status' => $res->status(), 'body' => $res->body(), 'invoice_id' => $invoiceId]);
|
||||
return null;
|
||||
}
|
||||
return $res->json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate address
|
||||
* https://api.nowpayments.io/v1/payout/validate-address
|
||||
*/
|
||||
public function validateAddress(string $currency, string $address, ?string $extraId = null): ?array
|
||||
{
|
||||
$payload = [
|
||||
'currency' => strtolower($currency),
|
||||
'address' => $address,
|
||||
];
|
||||
if ($extraId !== null) {
|
||||
$payload['extra_id'] = $extraId;
|
||||
}
|
||||
|
||||
// According to NOWPayments docs, this uses the standard client
|
||||
$res = $this->client()->post('/payout/validate-address', $payload);
|
||||
if (!$res->ok()) {
|
||||
Log::warning('NOWPayments validateAddress failed', ['status' => $res->status(), 'body' => $res->body(), 'payload' => $payload]);
|
||||
return null;
|
||||
}
|
||||
return $res->json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify payout
|
||||
* https://api.nowpayments.io/v1/payout/:batch-withdrawal-id/verify
|
||||
*/
|
||||
public function verifyPayout(string $batchWithdrawalId, string $verificationCode): ?array
|
||||
{
|
||||
$payload = [
|
||||
'verification_code' => $verificationCode
|
||||
];
|
||||
|
||||
$res = $this->client(true)->post('/payout/' . urlencode($batchWithdrawalId) . '/verify', $payload);
|
||||
if (!$res->ok()) {
|
||||
Log::warning('NOWPayments verifyPayout failed', ['status' => $res->status(), 'body' => $res->body(), 'batch_id' => $batchWithdrawalId]);
|
||||
return null;
|
||||
}
|
||||
return $res->json();
|
||||
}
|
||||
|
||||
/**
|
||||
* List of payouts
|
||||
* https://api.nowpayments.io/v1/payout
|
||||
*/
|
||||
public function listPayouts(array $params = []): ?array
|
||||
{
|
||||
$res = $this->client(true)->get('/payout', $params);
|
||||
if (!$res->ok()) {
|
||||
Log::warning('NOWPayments listPayouts failed', ['status' => $res->status(), 'body' => $res->body(), 'params' => $params]);
|
||||
return null;
|
||||
}
|
||||
return $res->json();
|
||||
}
|
||||
}
|
||||
213
app/Services/VaultService.php
Normal file
213
app/Services/VaultService.php
Normal file
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\VaultTransfer;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class VaultService
|
||||
{
|
||||
private int $scale = 4;
|
||||
|
||||
public function getScale(): int { return $this->scale; }
|
||||
|
||||
public function depositToVault(User $user, string|float $amount, ?string $idempotencyKey = null, string $source = 'web', ?int $createdBy = null, array $metadata = []): VaultTransfer
|
||||
{
|
||||
return $this->move($user, $amount, 'to_vault', $idempotencyKey, $source, $createdBy, $metadata);
|
||||
}
|
||||
|
||||
public function withdrawFromVault(User $user, string|float $amount, ?string $idempotencyKey = null, string $source = 'web', ?int $createdBy = null, array $metadata = []): VaultTransfer
|
||||
{
|
||||
return $this->move($user, $amount, 'from_vault', $idempotencyKey, $source, $createdBy, $metadata);
|
||||
}
|
||||
|
||||
private function move(User $user, string|float $amount, string $direction, ?string $idempotencyKey, string $source, ?int $createdBy, array $metadata): VaultTransfer
|
||||
{
|
||||
$amountStr = $this->normalizeAmount($amount);
|
||||
if ($this->cmp($amountStr, '0.0000') <= 0) {
|
||||
throw ValidationException::withMessages(['amount' => 'Amount must be greater than 0.']);
|
||||
}
|
||||
|
||||
if ($idempotencyKey) {
|
||||
$existing = VaultTransfer::where('user_id', $user->id)
|
||||
->where('idempotency_key', $idempotencyKey)
|
||||
->first();
|
||||
if ($existing) {
|
||||
// If direction/amount mismatch, reject to avoid accidental replay with different params
|
||||
if ($existing->direction !== $direction || $this->cmp($existing->amount, $amountStr) !== 0) {
|
||||
throw ValidationException::withMessages(['idempotency_key' => 'Conflicting idempotent request.'])->status(409);
|
||||
}
|
||||
return $existing;
|
||||
}
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($user, $amountStr, $direction, $idempotencyKey, $source, $createdBy, $metadata) {
|
||||
// Lock user row
|
||||
$u = User::whereKey($user->id)->lockForUpdate()->first();
|
||||
$mainBefore = $this->normalizeAmount($u->balance);
|
||||
$vaultBefore = $this->normalizeAmount($u->vault_balance ?? '0');
|
||||
|
||||
if ($direction === 'to_vault') {
|
||||
// move from main to vault
|
||||
if ($this->cmp($mainBefore, $amountStr) < 0) {
|
||||
throw ValidationException::withMessages(['amount' => 'Insufficient main balance.']);
|
||||
}
|
||||
$mainAfter = $this->sub($mainBefore, $amountStr);
|
||||
$vaultAfter = $this->add($vaultBefore, $amountStr);
|
||||
} else { // from_vault
|
||||
if ($this->cmp($vaultBefore, $amountStr) < 0) {
|
||||
throw ValidationException::withMessages(['amount' => 'Insufficient vault balance.']);
|
||||
}
|
||||
$mainAfter = $this->add($mainBefore, $amountStr);
|
||||
$vaultAfter = $this->sub($vaultBefore, $amountStr);
|
||||
}
|
||||
|
||||
// Persist balances
|
||||
$u->balance = $mainAfter; // existing cast is decimal:4
|
||||
$u->vault_balance = $vaultAfter; // encrypted decimal cast
|
||||
$u->save();
|
||||
|
||||
$transfer = VaultTransfer::create([
|
||||
'user_id' => $u->id,
|
||||
'direction' => $direction,
|
||||
'amount' => $amountStr,
|
||||
'main_balance_before' => $mainBefore,
|
||||
'main_balance_after' => $mainAfter,
|
||||
'vault_balance_before' => $vaultBefore,
|
||||
'vault_balance_after' => $vaultAfter,
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'source' => $source,
|
||||
'created_by' => $createdBy,
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
|
||||
return $transfer;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Decimal helpers using integer arithmetic with fixed scale ----
|
||||
private function normalizeAmount(string|float|int $v): string
|
||||
{
|
||||
$s = is_string($v) ? trim($v) : (string) $v;
|
||||
$s = str_replace([',', ' '], ['', ''], $s);
|
||||
if ($s === '' || $s === '-') $s = '0';
|
||||
if (!preg_match('/^-?\d*(?:\.\d+)?$/', $s)) {
|
||||
throw ValidationException::withMessages(['amount' => 'Invalid amount format.']);
|
||||
}
|
||||
$neg = str_starts_with($s, '-') ? '-' : '';
|
||||
if ($neg) $s = substr($s, 1);
|
||||
[$int, $frac] = array_pad(explode('.', $s, 2), 2, '');
|
||||
$frac = substr($frac . str_repeat('0', $this->scale), 0, $this->scale);
|
||||
$int = ltrim($int, '0');
|
||||
if ($int === '') $int = '0';
|
||||
return ($neg ? '-' : '') . $int . ($this->scale > 0 ? ('.' . $frac) : '');
|
||||
}
|
||||
|
||||
private function toScaledInt(string $s): array
|
||||
{
|
||||
$neg = str_starts_with($s, '-') ? -1 : 1;
|
||||
if ($neg < 0) $s = substr($s, 1);
|
||||
[$int, $frac] = array_pad(explode('.', $s, 2), 2, '');
|
||||
$frac = substr($frac . str_repeat('0', $this->scale), 0, $this->scale);
|
||||
$num = ltrim($int, '0');
|
||||
if ($num === '') $num = '0';
|
||||
$num .= $frac;
|
||||
return [$neg, ltrim($num, '0') === '' ? '0' : ltrim($num, '0')];
|
||||
}
|
||||
|
||||
private function fromScaledInt(int $sign, string $digits): string
|
||||
{
|
||||
// pad left to ensure at least scale digits
|
||||
$digits = ltrim($digits, '0');
|
||||
if ($digits === '') $digits = '0';
|
||||
$pad = max(0, $this->scale - strlen($digits));
|
||||
$digits = str_repeat('0', $pad) . $digits;
|
||||
$int = substr($digits, 0, strlen($digits) - $this->scale);
|
||||
$frac = substr($digits, -$this->scale);
|
||||
if ($int === '') $int = '0';
|
||||
$out = $int;
|
||||
if ($this->scale > 0) $out .= '.' . $frac;
|
||||
if ($sign < 0 && $out !== '0' . ($this->scale > 0 ? '.' . str_repeat('0', $this->scale) : '')) $out = '-' . $out;
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function add(string $a, string $b): string
|
||||
{
|
||||
[$sa, $ia] = $this->toScaledInt($a);
|
||||
[$sb, $ib] = $this->toScaledInt($b);
|
||||
if ($sa === $sb) {
|
||||
$sum = $this->addDigits($ia, $ib);
|
||||
return $this->fromScaledInt($sa, $sum);
|
||||
}
|
||||
// different signs -> subtraction
|
||||
if ($this->cmpAbs($ia, $ib) >= 0) {
|
||||
$diff = $this->subDigits($ia, $ib);
|
||||
return $this->fromScaledInt($sa, $diff);
|
||||
} else {
|
||||
$diff = $this->subDigits($ib, $ia);
|
||||
return $this->fromScaledInt($sb, $diff);
|
||||
}
|
||||
}
|
||||
|
||||
private function sub(string $a, string $b): string
|
||||
{
|
||||
// a - b = a + (-b)
|
||||
$negB = str_starts_with($b, '-') ? substr($b, 1) : ('-' . $b);
|
||||
return $this->add($a, $negB);
|
||||
}
|
||||
|
||||
private function cmp(string $a, string $b): int
|
||||
{
|
||||
[$sa, $ia] = $this->toScaledInt($a);
|
||||
[$sb, $ib] = $this->toScaledInt($b);
|
||||
if ($sa !== $sb) return $sa <=> $sb; // -1 vs 1
|
||||
$c = $this->cmpAbs($ia, $ib);
|
||||
return $sa * $c;
|
||||
}
|
||||
|
||||
private function addDigits(string $x, string $y): string
|
||||
{
|
||||
$cx = strrev($x); $cy = strrev($y);
|
||||
$len = max(strlen($cx), strlen($cy));
|
||||
$carry = 0; $out = '';
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$dx = $i < strlen($cx) ? ord($cx[$i]) - 48 : 0;
|
||||
$dy = $i < strlen($cy) ? ord($cy[$i]) - 48 : 0;
|
||||
$s = $dx + $dy + $carry;
|
||||
$out .= chr(($s % 10) + 48);
|
||||
$carry = intdiv($s, 10);
|
||||
}
|
||||
if ($carry) $out .= chr($carry + 48);
|
||||
return strrev($out);
|
||||
}
|
||||
|
||||
private function subDigits(string $x, string $y): string
|
||||
{
|
||||
// assumes x >= y in absolute
|
||||
$cx = strrev($x); $cy = strrev($y);
|
||||
$len = max(strlen($cx), strlen($cy));
|
||||
$borrow = 0; $out = '';
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$dx = $i < strlen($cx) ? ord($cx[$i]) - 48 : 0;
|
||||
$dy = $i < strlen($cy) ? ord($cy[$i]) - 48 : 0;
|
||||
$d = $dx - $borrow - $dy;
|
||||
if ($d < 0) { $d += 10; $borrow = 1; } else { $borrow = 0; }
|
||||
$out .= chr($d + 48);
|
||||
}
|
||||
// reverse back to normal order and normalize without stripping valid trailing zeros
|
||||
$out = strrev($out);
|
||||
// remove leading zeros only; keep at least one zero
|
||||
$out = ltrim($out, '0');
|
||||
return $out === '' ? '0' : $out;
|
||||
}
|
||||
|
||||
private function cmpAbs(string $x, string $y): int
|
||||
{
|
||||
$x = ltrim($x, '0'); if ($x === '') $x = '0';
|
||||
$y = ltrim($y, '0'); if ($y === '') $y = '0';
|
||||
if (strlen($x) !== strlen($y)) return strlen($x) <=> strlen($y);
|
||||
return strcmp($x, $y);
|
||||
}
|
||||
}
|
||||
220
app/Services/WalletService.php
Normal file
220
app/Services/WalletService.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\WalletTransfer;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class WalletService
|
||||
{
|
||||
/**
|
||||
* Verify and handle Vault PIN
|
||||
*/
|
||||
public function verifyVaultPin(User $user, string $pin): ?\Illuminate\Http\JsonResponse
|
||||
{
|
||||
// Locked?
|
||||
if (!empty($user->vault_pin_locked_until) && now()->lessThan($user->vault_pin_locked_until)) {
|
||||
return response()->json([
|
||||
'message' => 'Vault PIN locked. Try again later.',
|
||||
'locked_until' => optional($user->vault_pin_locked_until)->toIso8601String(),
|
||||
], 423);
|
||||
}
|
||||
if (empty($user->vault_pin_hash)) {
|
||||
return response()->json(['message' => 'Vault PIN not set'], 400);
|
||||
}
|
||||
if (!Hash::check($pin, $user->vault_pin_hash)) {
|
||||
// increment attempts and maybe lock
|
||||
$attempts = (int) ($user->vault_pin_attempts ?? 0) + 1;
|
||||
$user->vault_pin_attempts = $attempts;
|
||||
if ($attempts >= 5) {
|
||||
$user->vault_pin_locked_until = now()->addMinutes(15);
|
||||
$user->vault_pin_attempts = 0; // reset after lock
|
||||
}
|
||||
$user->save();
|
||||
return response()->json(['message' => 'Invalid PIN'], 423);
|
||||
}
|
||||
// success → reset attempts
|
||||
if (!empty($user->vault_pin_attempts)) {
|
||||
$user->vault_pin_attempts = 0;
|
||||
$user->save();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the vault balance for a given currency.
|
||||
*/
|
||||
public function getVaultBalance(User $user, string $currency): float
|
||||
{
|
||||
if ($currency === 'BTX') {
|
||||
return (float) ($user->vault_balance ?? 0);
|
||||
}
|
||||
$map = $user->vault_balances ?? [];
|
||||
return (float) ($map[$currency] ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the wallet balance for a given currency.
|
||||
*/
|
||||
public function getWalletBalance(User $user, string $currency): float
|
||||
{
|
||||
if ($currency === 'BTX') {
|
||||
return (float) ($user->balance ?? 0);
|
||||
}
|
||||
// For other currencies, check wallets relationship or JSON field if present
|
||||
$map = $user->vault_balances ?? []; // re-use structure — wallet balance not stored here
|
||||
// Non-BTX wallet balances are not tracked in the main user table yet
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deposit from main balance to vault.
|
||||
*/
|
||||
public function depositToVault(User $user, string $amountStr, ?string $idempotencyKey = null, string $currency = 'BTX'): array
|
||||
{
|
||||
$amount = (float) $amountStr;
|
||||
if ($amount <= 0) {
|
||||
abort(422, 'Amount must be greater than zero');
|
||||
}
|
||||
|
||||
if ($idempotencyKey) {
|
||||
$existing = WalletTransfer::where('user_id', $user->id)
|
||||
->where('idempotency_key', $idempotencyKey)
|
||||
->first();
|
||||
if ($existing) {
|
||||
return $this->buildBalanceResponse($user, $currency);
|
||||
}
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($user, $amount, $idempotencyKey, $currency) {
|
||||
$u = User::where('id', $user->id)->lockForUpdate()->first();
|
||||
|
||||
if ($currency === 'BTX') {
|
||||
$balanceBefore = (float) $u->balance;
|
||||
$vaultBefore = (float) $u->vault_balance;
|
||||
|
||||
if ($balanceBefore < $amount) abort(400, 'Insufficient balance');
|
||||
|
||||
$u->balance = $balanceBefore - $amount;
|
||||
$u->vault_balance = $vaultBefore + $amount;
|
||||
$u->save();
|
||||
|
||||
WalletTransfer::create([
|
||||
'user_id' => $u->id,
|
||||
'type' => 'deposit',
|
||||
'amount' => $amount,
|
||||
'balance_before' => $balanceBefore,
|
||||
'balance_after' => (float) $u->balance,
|
||||
'vault_before' => $vaultBefore,
|
||||
'vault_after' => (float) $u->vault_balance,
|
||||
'currency' => $currency,
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'meta' => null,
|
||||
]);
|
||||
} else {
|
||||
// Non-BTX: use vault_balances JSON map
|
||||
$map = $u->vault_balances ?? [];
|
||||
$vaultBefore = (float) ($map[$currency] ?? 0);
|
||||
// Wallet balance for non-BTX is not in DB yet — block if 0
|
||||
abort(400, "Deposits for {$currency} are not yet supported.");
|
||||
}
|
||||
|
||||
return $this->buildBalanceResponse($u, $currency);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw from vault to main balance.
|
||||
*/
|
||||
public function withdrawFromVault(User $user, string $amountStr, ?string $idempotencyKey = null, string $currency = 'BTX'): array
|
||||
{
|
||||
$amount = (float) $amountStr;
|
||||
if ($amount <= 0) {
|
||||
abort(422, 'Amount must be greater than zero');
|
||||
}
|
||||
|
||||
if (!empty($user->withdraw_cooldown_until) && now()->lessThan($user->withdraw_cooldown_until)) {
|
||||
abort(429, 'Withdraw cooldown active. Try again at ' . $user->withdraw_cooldown_until->toIso8601String());
|
||||
}
|
||||
|
||||
if ($idempotencyKey) {
|
||||
$existing = WalletTransfer::where('user_id', $user->id)
|
||||
->where('idempotency_key', $idempotencyKey)
|
||||
->first();
|
||||
if ($existing) {
|
||||
return $this->buildBalanceResponse($user, $currency);
|
||||
}
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($user, $amount, $idempotencyKey, $currency) {
|
||||
$u = User::where('id', $user->id)->lockForUpdate()->first();
|
||||
|
||||
if ($currency === 'BTX') {
|
||||
$balanceBefore = (float) $u->balance;
|
||||
$vaultBefore = (float) $u->vault_balance;
|
||||
|
||||
if ($vaultBefore < $amount) abort(400, 'Insufficient vault balance');
|
||||
|
||||
$u->vault_balance = $vaultBefore - $amount;
|
||||
$u->balance = $balanceBefore + $amount;
|
||||
$u->withdraw_cooldown_until = now()->addMinutes(30);
|
||||
$u->save();
|
||||
|
||||
WalletTransfer::create([
|
||||
'user_id' => $u->id,
|
||||
'type' => 'withdraw',
|
||||
'amount' => $amount,
|
||||
'balance_before' => $balanceBefore,
|
||||
'balance_after' => (float) $u->balance,
|
||||
'vault_before' => $vaultBefore,
|
||||
'vault_after' => (float) $u->vault_balance,
|
||||
'currency' => $currency,
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'meta' => null,
|
||||
]);
|
||||
} else {
|
||||
$map = $u->vault_balances ?? [];
|
||||
$vaultBefore = (float) ($map[$currency] ?? 0);
|
||||
|
||||
if ($vaultBefore < $amount) abort(400, 'Insufficient vault balance');
|
||||
|
||||
$map[$currency] = number_format($vaultBefore - $amount, 8, '.', '');
|
||||
$u->vault_balances = $map;
|
||||
$u->withdraw_cooldown_until = now()->addMinutes(30);
|
||||
$u->save();
|
||||
|
||||
WalletTransfer::create([
|
||||
'user_id' => $u->id,
|
||||
'type' => 'withdraw',
|
||||
'amount' => $amount,
|
||||
'balance_before' => 0,
|
||||
'balance_after' => 0,
|
||||
'vault_before' => $vaultBefore,
|
||||
'vault_after' => (float) $map[$currency],
|
||||
'currency' => $currency,
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'meta' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->buildBalanceResponse($u, $currency);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the balance response for a given currency.
|
||||
*/
|
||||
private function buildBalanceResponse(User $user, string $currency): array
|
||||
{
|
||||
$map = $user->vault_balances ?? [];
|
||||
return [
|
||||
'balance' => $currency === 'BTX' ? (string) $user->balance : '0',
|
||||
'vault_balance' => $currency === 'BTX'
|
||||
? (string) $user->vault_balance
|
||||
: (string) ($map[$currency] ?? '0'),
|
||||
'vault_balances' => array_merge(['BTX' => (string) ($user->vault_balance ?? '0')], $map),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user