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

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

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

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

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

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

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

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