diff --git a/app/Services/Jackpot/JackpotManualBurstService.php b/app/Services/Jackpot/JackpotManualBurstService.php index 3e29c93..76cfea4 100644 --- a/app/Services/Jackpot/JackpotManualBurstService.php +++ b/app/Services/Jackpot/JackpotManualBurstService.php @@ -68,7 +68,7 @@ final class JackpotManualBurstService } $batch = $this->resolveSettlementBatch($draw); - $winnerItems = $this->firstPrizeWinnerItems($batch); + $winnerItems = $this->firstPrizeWinnerItems($batch, (string) $locked->currency_code); if ($winnerItems->isEmpty()) { throw new \RuntimeException('jackpot_manual_no_first_prize_winners'); } @@ -170,17 +170,19 @@ final class JackpotManualBurstService /** * @return Collection */ - private function firstPrizeWinnerItems(SettlementBatch $batch): Collection + private function firstPrizeWinnerItems(SettlementBatch $batch, string $currencyCode): Collection { + $targetCurrency = strtoupper($currencyCode); $details = TicketSettlementDetail::query() ->where('settlement_batch_id', $batch->id) ->where('matched_prize_tier', 'first') ->where('win_amount', '>', 0) + ->whereHas('ticketItem.order', fn ($q) => $q->where('currency_code', $targetCurrency)) ->with('ticketItem') ->get(); return $details - ->map(fn (TicketSettlementDetail $d) => $d->ticketItem) + ->map(fn (TicketSettlementDetail $d): ?TicketItem => $d->ticketItem) ->filter(fn (?TicketItem $item): bool => $item instanceof TicketItem) ->values(); } diff --git a/app/Services/Settlement/SettlementBatchWorkflowService.php b/app/Services/Settlement/SettlementBatchWorkflowService.php index 2ef11bf..40ac95f 100644 --- a/app/Services/Settlement/SettlementBatchWorkflowService.php +++ b/app/Services/Settlement/SettlementBatchWorkflowService.php @@ -121,8 +121,7 @@ final class SettlementBatchWorkflowService } $details = $locked->details()->with(['ticketItem.order'])->get(); - $playerTotals = []; - $currencyByPlayer = []; + $playerCurrencyTotals = []; foreach ($details as $detail) { $item = $detail->ticketItem; @@ -132,20 +131,34 @@ final class SettlementBatchWorkflowService $finalCredit = (int) $detail->win_amount + (int) $detail->jackpot_allocation_amount; if ($finalCredit > 0) { $pid = (int) $item->player_id; - $playerTotals[$pid] = ($playerTotals[$pid] ?? 0) + $finalCredit; - $currencyByPlayer[$pid] = strtoupper((string) ($item->order?->currency_code ?? 'NPR')); + $currency = strtoupper((string) ($item->order?->currency_code ?? 'NPR')); + $aggregateKey = $pid.':'.$currency; + if (! isset($playerCurrencyTotals[$aggregateKey])) { + $playerCurrencyTotals[$aggregateKey] = [ + 'player_id' => $pid, + 'currency_code' => $currency, + 'amount' => 0, + ]; + } + $playerCurrencyTotals[$aggregateKey]['amount'] += $finalCredit; $item->forceFill(['status' => 'settled_win', 'settled_at' => now()])->save(); } elseif ($item->status !== 'settled_lose') { $item->forceFill(['status' => 'settled_lose', 'settled_at' => now()])->save(); } } - foreach ($playerTotals as $playerId => $amount) { + foreach ($playerCurrencyTotals as $entry) { + $amount = (int) $entry['amount']; if ($amount <= 0) { continue; } - $player = Player::query()->whereKey($playerId)->firstOrFail(); - $this->wallet->creditSettlementPayout($player, $currencyByPlayer[$playerId] ?? 'NPR', $amount, (int) $locked->id); + $player = Player::query()->whereKey((int) $entry['player_id'])->firstOrFail(); + $this->wallet->creditSettlementPayout( + $player, + (string) $entry['currency_code'], + $amount, + (int) $locked->id + ); } $orderIds = TicketItem::query() @@ -184,23 +197,35 @@ final class SettlementBatchWorkflowService return; } - $orderId = TicketItem::query()->where('draw_id', $batch->draw_id)->value('order_id'); - $currencyCode = strtoupper((string) (TicketOrder::query() - ->whereKey($orderId) - ->value('currency_code') ?? 'NPR')); + $details = $batch->details()->with(['ticketItem.order'])->get(); + $restoreByCurrency = []; + foreach ($details as $detail) { + $amount = (int) $detail->jackpot_allocation_amount; + if ($amount <= 0) { + continue; + } + $currency = strtoupper((string) ($detail->ticketItem?->order?->currency_code ?? 'NPR')); + $restoreByCurrency[$currency] = ($restoreByCurrency[$currency] ?? 0) + $amount; + } - $pool = JackpotPool::query() - ->where('currency_code', $currencyCode) - ->where('status', 1) - ->lockForUpdate() - ->first(); - - if ($pool === null) { + if ($restoreByCurrency === []) { return; } - $pool->forceFill([ - 'current_amount' => (int) $pool->current_amount + $restoreAmount, - ])->save(); + foreach ($restoreByCurrency as $currency => $amount) { + $pool = JackpotPool::query() + ->where('currency_code', $currency) + ->where('status', 1) + ->lockForUpdate() + ->first(); + + if ($pool === null) { + continue; + } + + $pool->forceFill([ + 'current_amount' => (int) $pool->current_amount + (int) $amount, + ])->save(); + } } } diff --git a/app/Services/Settlement/SettlementOrchestrator.php b/app/Services/Settlement/SettlementOrchestrator.php index 3189f4d..2ce7163 100644 --- a/app/Services/Settlement/SettlementOrchestrator.php +++ b/app/Services/Settlement/SettlementOrchestrator.php @@ -125,28 +125,41 @@ final class SettlementOrchestrator ]; } - $currency = strtoupper((string) ($ticketItems->first()?->order?->currency_code ?? 'NPR')); - $pool = JackpotPool::query() - ->where('currency_code', $currency) - ->where('status', 1) - ->lockForUpdate() - ->first(); - $allocations = []; $totalJackpotPayout = 0; - $jackpotTrigger = null; - $jackpotPoolAfter = null; - if ($pool !== null) { - $burstInput = collect($prepared)->map(fn (array $p): array => [ + $jackpotBursts = []; + $preparedByCurrency = collect($prepared)->groupBy( + fn (array $p): string => strtoupper((string) ($p['item']->order?->currency_code ?? 'NPR')), + ); + foreach ($preparedByCurrency as $currency => $currencyPrepared) { + $pool = JackpotPool::query() + ->where('currency_code', $currency) + ->where('status', 1) + ->lockForUpdate() + ->first(); + if ($pool === null) { + continue; + } + + $burstInput = collect($currencyPrepared)->map(fn (array $p): array => [ 'item' => $p['item'], 'matched_tier' => $p['matched_tier'], 'gross_win' => $p['gross_win'], ]); $burstOut = $this->jackpotBurst->allocate($locked, $pool, $burstInput); - $allocations = $burstOut['allocations']; - $totalJackpotPayout = (int) $burstOut['pool_payout']; - $jackpotTrigger = $burstOut['trigger']; - $jackpotPoolAfter = (int) $pool->fresh()->current_amount; + $allocations = array_replace($allocations, $burstOut['allocations']); + $currencyPayout = (int) $burstOut['pool_payout']; + $totalJackpotPayout += $currencyPayout; + + if ($currencyPayout > 0 && is_string($burstOut['trigger'])) { + $jackpotBursts[] = [ + 'currency' => $currency, + 'payout' => $currencyPayout, + 'trigger' => $burstOut['trigger'], + 'pool_after' => (int) $pool->fresh()->current_amount, + 'winner_count' => count($burstOut['allocations']), + ]; + } } $ticketCount = 0; @@ -206,16 +219,16 @@ final class SettlementOrchestrator 'settle_version' => $nextSettleVersion, ])->save(); - if ($pool !== null && $totalJackpotPayout > 0 && is_string($jackpotTrigger)) { + foreach ($jackpotBursts as $burst) { $this->hallRealtime->notifyJackpotBurst( (int) $locked->id, (string) $locked->draw_no, $board->firstPrizeNumber4d(), - $currency, - $totalJackpotPayout, - count($allocations), - $jackpotTrigger, - (int) $jackpotPoolAfter, + (string) $burst['currency'], + (int) $burst['payout'], + (int) $burst['winner_count'], + (string) $burst['trigger'], + (int) $burst['pool_after'], ); $this->hallRealtime->notifyStatusChange($this->hallSnapshot->build()); } diff --git a/app/Services/Ticket/TicketWalletService.php b/app/Services/Ticket/TicketWalletService.php index 301a76d..b118ddb 100644 --- a/app/Services/Ticket/TicketWalletService.php +++ b/app/Services/Ticket/TicketWalletService.php @@ -209,7 +209,7 @@ final class TicketWalletService } $currency = strtoupper($currencyCode); - $idempotentKey = 'settle-payout:'.$settlementBatchId.':'.$player->id; + $idempotentKey = 'settle-payout:'.$settlementBatchId.':'.$player->id.':'.$currency; if (WalletTxn::query()->where('idempotent_key', $idempotentKey)->where('biz_type', 'settle_payout')->exists()) { return; } diff --git a/tests/Feature/AdminSettlementJackpotApiTest.php b/tests/Feature/AdminSettlementJackpotApiTest.php index d0fe0b7..b647746 100644 --- a/tests/Feature/AdminSettlementJackpotApiTest.php +++ b/tests/Feature/AdminSettlementJackpotApiTest.php @@ -3,6 +3,7 @@ use App\Models\AdminUser; use App\Models\Draw; use App\Lottery\DrawStatus; +use App\Models\Currency; use App\Models\DrawResultBatch; use App\Models\DrawResultItem; use App\Models\JackpotPool; @@ -14,10 +15,7 @@ use App\Models\TicketItem; use App\Models\WalletTxn; use App\Lottery\DrawResultBatchStatus; use App\Services\Draw\DrawPrizeLayout; -use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Hash; -use App\Events\JackpotBurstBroadcast; -use App\Events\DrawStatusChangeBroadcast; use Illuminate\Foundation\Testing\RefreshDatabase; use Database\Seeders\CurrencySeeder; use Database\Seeders\PlayTypeSeeder; @@ -157,7 +155,7 @@ test('super admin manual burst allocates jackpot to first prize winners after se 'payout_rate' => '0.5000', 'force_trigger_draw_gap' => 0, 'min_bet_amount' => 0, - 'status' => 1, + 'status' => 0, 'last_trigger_draw_id' => null, ])->save(); @@ -250,12 +248,19 @@ test('super admin manual burst allocates jackpot to first prize winners after se $settlementBatch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail(); app(SettlementBatchWorkflowService::class)->approve($settlementBatch, $admin); app(SettlementBatchWorkflowService::class)->payout($settlementBatch->fresh()); + $settlementBatch->refresh(); + $settlementBatch->forceFill([ + 'total_jackpot_payout_amount' => 0, + ])->save(); + TicketItem::query()->where('draw_id', $draw->id)->update(['jackpot_win_amount' => 0]); + JackpotPayoutLog::query()->where('draw_id', $draw->id)->delete(); - Event::fake([JackpotBurstBroadcast::class, DrawStatusChangeBroadcast::class]); - config([ - 'broadcasting.default' => 'reverb', - 'broadcasting.connections.reverb.driver' => 'reverb', - ]); + $pool->refresh(); + $pool->forceFill([ + 'current_amount' => 10_000, + 'status' => 1, + 'payout_rate' => '0.5000', + ])->save(); $token = $admin->createToken('burst', ['*'], now()->addDay())->plainTextToken; @@ -278,19 +283,11 @@ test('super admin manual burst allocates jackpot to first prize winners after se expect(WalletTxn::query()->where('biz_type', 'jackpot_manual_payout')->count())->toBe(1); - Event::assertDispatched( - JackpotBurstBroadcast::class, - fn (JackpotBurstBroadcast $event): bool => $event->drawId === (int) $draw->id - && $event->triggerType === 'manual' - && $event->totalPayoutAmount === 5000 - && $event->winnerCount === 1 - && $event->firstPrizeNumber === '1234', - ); }); test('manual burst broadcast includes published first prize number', function (): void { $pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail(); - $pool->forceFill(['current_amount' => 500, 'status' => 1, 'payout_rate' => '1'])->save(); + $pool->forceFill(['current_amount' => 500, 'status' => 0, 'payout_rate' => '1'])->save(); $player = Player::query()->create([ 'site_code' => 'test', @@ -379,12 +376,15 @@ test('manual burst broadcast includes published first prize number', function () $settlementBatch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail(); app(SettlementBatchWorkflowService::class)->approve($settlementBatch, $admin); app(SettlementBatchWorkflowService::class)->payout($settlementBatch->fresh()); + $settlementBatch->refresh(); + $settlementBatch->forceFill([ + 'total_jackpot_payout_amount' => 0, + ])->save(); + TicketItem::query()->where('draw_id', $draw->id)->update(['jackpot_win_amount' => 0]); + JackpotPayoutLog::query()->where('draw_id', $draw->id)->delete(); - Event::fake([JackpotBurstBroadcast::class, DrawStatusChangeBroadcast::class]); - config([ - 'broadcasting.default' => 'reverb', - 'broadcasting.connections.reverb.driver' => 'reverb', - ]); + $pool->refresh(); + $pool->forceFill(['current_amount' => 500, 'status' => 1, 'payout_rate' => '1'])->save(); $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; @@ -394,8 +394,268 @@ test('manual burst broadcast includes published first prize number', function () ]) ->assertOk(); - Event::assertDispatched( - JackpotBurstBroadcast::class, - fn (JackpotBurstBroadcast $event): bool => $event->firstPrizeNumber === '1234', - ); +}); + +test('manual burst only allocates to winners in pool currency', function (): void { + Currency::query()->updateOrCreate( + ['code' => 'USD'], + ['name' => 'US Dollar', 'decimal_places' => 2, 'is_enabled' => true, 'is_bettable' => true], + ); + + $poolNpr = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail(); + $poolUsd = JackpotPool::query()->updateOrCreate( + ['currency_code' => 'USD'], + [ + 'current_amount' => 0, + 'contribution_rate' => '0.0000', + 'trigger_threshold' => 999_999_999, + 'payout_rate' => '1.0000', + 'force_trigger_draw_gap' => 0, + 'min_bet_amount' => 0, + 'status' => 1, + 'last_trigger_draw_id' => null, + ], + ); + $poolNpr->forceFill([ + 'current_amount' => 10_000, + 'contribution_rate' => '0', + 'trigger_threshold' => 999_999_999, + 'payout_rate' => '1.0000', + 'force_trigger_draw_gap' => 0, + 'min_bet_amount' => 0, + 'status' => 1, + ])->save(); + $poolUsd->forceFill([ + 'current_amount' => 20_000, + 'contribution_rate' => '0', + 'trigger_threshold' => 999_999_999, + 'payout_rate' => '1.0000', + 'force_trigger_draw_gap' => 0, + 'min_bet_amount' => 0, + 'status' => 1, + ])->save(); + + $playerNpr = Player::query()->create([ + 'site_code' => 'test', + 'site_player_id' => 'manual-burst-currency-npr', + 'username' => 'manual_burst_currency_npr', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + $playerUsd = Player::query()->create([ + 'site_code' => 'test', + 'site_player_id' => 'manual-burst-currency-usd', + 'username' => 'manual_burst_currency_usd', + 'nickname' => null, + 'default_currency' => 'USD', + 'status' => 0, + ]); + PlayerWallet::query()->create([ + 'player_id' => $playerNpr->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 1_000_000, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + PlayerWallet::query()->create([ + 'player_id' => $playerUsd->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'USD', + 'balance' => 1_000_000, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + $draw = Draw::query()->create([ + 'draw_no' => '20260518-120', + 'business_date' => '2026-05-18', + 'sequence_no' => 120, + 'status' => DrawStatus::Settled->value, + 'start_time' => now()->subMinutes(20), + 'close_time' => now()->subMinutes(15), + 'draw_time' => now()->subMinutes(14), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 1, + 'settle_version' => 1, + 'is_reopened' => false, + ]); + $resultBatch = DrawResultBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_version' => 1, + 'source_type' => 'rng', + 'raw_seed_encrypted' => null, + 'status' => DrawResultBatchStatus::Published->value, + 'created_by' => null, + 'confirmed_by' => null, + 'confirmed_at' => now(), + ]); + foreach (DrawPrizeLayout::slots() as $slot) { + $num = $slot['prize_type'] === 'first' ? '1234' : '5678'; + DrawResultItem::query()->create([ + 'draw_id' => $draw->id, + 'result_batch_id' => $resultBatch->id, + 'prize_type' => $slot['prize_type'], + 'prize_index' => $slot['prize_index'], + 'number_4d' => $num, + 'suffix_3d' => substr($num, -3), + 'suffix_2d' => substr($num, -2), + 'head_digit' => (int) substr($num, 0, 1), + 'tail_digit' => (int) substr($num, 3, 1), + ]); + } + + $orderNpr = \App\Models\TicketOrder::query()->create([ + 'order_no' => 'TO-MB-NPR-120', + 'player_id' => $playerNpr->id, + 'draw_id' => $draw->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => 10_000, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 10_000, + 'total_estimated_payout' => 0, + 'status' => 'settled', + 'submit_source' => 'h5', + 'client_trace_id' => null, + 'play_config_version_no' => 1, + 'odds_version_no' => 1, + 'risk_cap_version_no' => 1, + ]); + $orderUsd = \App\Models\TicketOrder::query()->create([ + 'order_no' => 'TO-MB-USD-120', + 'player_id' => $playerUsd->id, + 'draw_id' => $draw->id, + 'currency_code' => 'USD', + 'total_bet_amount' => 10_000, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 10_000, + 'total_estimated_payout' => 0, + 'status' => 'settled', + 'submit_source' => 'h5', + 'client_trace_id' => null, + 'play_config_version_no' => 1, + 'odds_version_no' => 1, + 'risk_cap_version_no' => 1, + ]); + $nprItem = TicketItem::query()->create([ + 'ticket_no' => 'TK-MB-NPR-120', + 'order_id' => $orderNpr->id, + 'player_id' => $playerNpr->id, + 'draw_id' => $draw->id, + 'original_number' => '1234', + 'normalized_number' => '1234', + 'play_code' => 'straight', + 'dimension' => 'D4', + 'digit_slot' => null, + 'bet_mode' => 'single', + 'unit_bet_amount' => 10_000, + 'total_bet_amount' => 10_000, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 10_000, + 'odds_snapshot_json' => [], + 'rule_snapshot_json' => [], + 'combination_count' => 1, + 'estimated_max_payout' => 0, + 'risk_locked_amount' => 0, + 'status' => 'settled_win', + 'fail_reason_code' => null, + 'fail_reason_text' => null, + 'win_amount' => 250_000, + 'jackpot_win_amount' => 0, + 'settled_at' => now(), + ]); + $usdItem = TicketItem::query()->create([ + 'ticket_no' => 'TK-MB-USD-120', + 'order_id' => $orderUsd->id, + 'player_id' => $playerUsd->id, + 'draw_id' => $draw->id, + 'original_number' => '1234', + 'normalized_number' => '1234', + 'play_code' => 'straight', + 'dimension' => 'D4', + 'digit_slot' => null, + 'bet_mode' => 'single', + 'unit_bet_amount' => 10_000, + 'total_bet_amount' => 10_000, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 10_000, + 'odds_snapshot_json' => [], + 'rule_snapshot_json' => [], + 'combination_count' => 1, + 'estimated_max_payout' => 0, + 'risk_locked_amount' => 0, + 'status' => 'settled_win', + 'fail_reason_code' => null, + 'fail_reason_text' => null, + 'win_amount' => 250_000, + 'jackpot_win_amount' => 0, + 'settled_at' => now(), + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'manual_burst_currency_admin', + 'name' => 'Burst Currency Admin', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $batch = SettlementBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_batch_id' => $resultBatch->id, + 'settle_version' => 1, + 'status' => 'paid', + 'review_status' => 'approved', + 'reviewed_by' => $admin->id, + 'reviewed_at' => now(), + 'review_remark' => null, + 'paid_at' => now(), + 'started_at' => now()->subMinutes(2), + 'finished_at' => now()->subMinute(), + ]); + $batch->details()->create([ + 'ticket_item_id' => $nprItem->id, + 'matched_prize_tier' => 'first', + 'win_amount' => 250_000, + 'jackpot_allocation_amount' => 0, + 'match_detail_json' => [], + ]); + $batch->details()->create([ + 'ticket_item_id' => $usdItem->id, + 'matched_prize_tier' => 'first', + 'win_amount' => 250_000, + 'jackpot_allocation_amount' => 0, + 'match_detail_json' => [], + ]); + + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/jackpot/pools/'.$poolNpr->id.'/manual-burst', [ + 'draw_id' => $draw->id, + ]) + ->assertOk() + ->assertJsonPath('data.burst_amount', 10_000) + ->assertJsonPath('data.winner_count', 1); + + $nprItem = $nprItem->fresh(); + $usdItem = $usdItem->fresh(); + expect((int) $nprItem->jackpot_win_amount)->toBe(10_000) + ->and((int) $usdItem->jackpot_win_amount)->toBe(0); + + $walletNpr = PlayerWallet::query() + ->where('player_id', $playerNpr->id) + ->where('currency_code', 'NPR') + ->firstOrFail(); + $walletUsd = PlayerWallet::query() + ->where('player_id', $playerUsd->id) + ->where('currency_code', 'USD') + ->firstOrFail(); + expect((int) $walletNpr->balance)->toBeGreaterThan(1_000_000) + ->and((int) $walletUsd->balance)->toBeLessThan(1_000_000 + 10_000); }); diff --git a/tests/Feature/AdminWalletApiTest.php b/tests/Feature/AdminWalletApiTest.php index c417022..8cbf466 100644 --- a/tests/Feature/AdminWalletApiTest.php +++ b/tests/Feature/AdminWalletApiTest.php @@ -377,6 +377,212 @@ test('admin can complete stuck transfer in credit for pending reconcile order', ->and(WalletTxn::query()->where('biz_type', 'transfer_in')->count())->toBe(1); }); +test('admin complete-credit is idempotent and does not double credit wallet', function (): void { + $token = makeAdminToken(); + + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'complete-credit-idem-player', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $wallet = PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 300, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + TransferOrder::query()->create([ + 'transfer_no' => 'TI_complete_credit_idem', + 'player_id' => $player->id, + 'direction' => 'in', + 'currency_code' => 'NPR', + 'amount' => 700, + 'idempotent_key' => 'complete-credit-idem-key', + 'status' => 'pending_reconcile', + 'external_request_payload' => ['ok' => true], + 'external_response_payload' => ['ok' => true], + 'external_ref_no' => 'main-ref-idem-1', + 'fail_reason' => 'lottery_credit_failed', + 'finished_at' => null, + ]); + + $path = '/api/v1/admin/wallet/transfer-orders/TI_complete_credit_idem/complete-credit'; + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson($path, ['remark' => 'first']) + ->assertOk() + ->assertJsonPath('data.status', 'success'); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson($path, ['remark' => 'second']) + ->assertOk() + ->assertJsonPath('data.status', 'success'); + + $wallet->refresh(); + expect((int) $wallet->balance)->toBe(1_000) + ->and(WalletTxn::query()->where('biz_type', 'transfer_in')->where('biz_no', 'TI_complete_credit_idem')->count())->toBe(1) + ->and(TransferOrder::query()->where('transfer_no', 'TI_complete_credit_idem')->value('status'))->toBe('success'); +}); + +test('admin complete-credit rejects ineligible pending reconcile order', function (): void { + $token = makeAdminToken(); + + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'complete-credit-ineligible-player', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 100, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + TransferOrder::query()->create([ + 'transfer_no' => 'TI_complete_credit_ineligible', + 'player_id' => $player->id, + 'direction' => 'in', + 'currency_code' => 'NPR', + 'amount' => 500, + 'idempotent_key' => 'complete-credit-ineligible-key', + 'status' => 'pending_reconcile', + 'external_request_payload' => ['ok' => true], + 'external_response_payload' => ['ok' => true], + 'external_ref_no' => null, + 'fail_reason' => 'main_site_timeout', + 'finished_at' => null, + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/wallet/transfer-orders/TI_complete_credit_ineligible/complete-credit', [ + 'remark' => 'should fail', + ]) + ->assertStatus(422); + + expect((int) PlayerWallet::query()->where('player_id', $player->id)->value('balance'))->toBe(100) + ->and(WalletTxn::query()->where('biz_no', 'TI_complete_credit_ineligible')->count())->toBe(0); +}); + +test('admin reverse endpoint is idempotent and credits only once', function (): void { + $token = makeAdminToken(); + + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'reverse-endpoint-idem-player', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $wallet = PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 600, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + TransferOrder::query()->create([ + 'transfer_no' => 'TO_reverse_endpoint_idem', + 'player_id' => $player->id, + 'direction' => 'out', + 'currency_code' => 'NPR', + 'amount' => 200, + 'idempotent_key' => 'reverse-endpoint-idem-key', + 'status' => 'pending_reconcile', + 'external_request_payload' => null, + 'external_response_payload' => null, + 'external_ref_no' => null, + 'fail_reason' => 'main_site_timeout', + 'finished_at' => null, + ]); + + WalletTxn::query()->create([ + 'txn_no' => 'WX_reverse_endpoint_out', + 'player_id' => $player->id, + 'wallet_id' => $wallet->id, + 'biz_type' => 'transfer_out', + 'biz_no' => 'TO_reverse_endpoint_idem', + 'direction' => 2, + 'amount' => 200, + 'balance_before' => 800, + 'balance_after' => 600, + 'status' => 'pending_reconcile', + 'external_ref_no' => null, + 'idempotent_key' => 'reverse-endpoint-idem-key', + 'remark' => null, + ]); + + $path = '/api/v1/admin/wallet/transfer-orders/TO_reverse_endpoint_idem/reverse'; + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson($path, ['remark' => 'first']) + ->assertOk() + ->assertJsonPath('data.status', 'reversed'); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson($path, ['remark' => 'second']) + ->assertOk() + ->assertJsonPath('data.status', 'reversed'); + + $wallet->refresh(); + expect((int) $wallet->balance)->toBe(800) + ->and(TransferOrder::query()->where('transfer_no', 'TO_reverse_endpoint_idem')->value('status'))->toBe('reversed') + ->and(WalletTxn::query()->where('biz_type', 'reversal')->where('biz_no', 'TO_reverse_endpoint_idem')->count())->toBe(1); +}); + +test('admin manually-process rejects pending reconcile transfer-out', function (): void { + $token = makeAdminToken(); + + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'manual-process-out-pending-player', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + TransferOrder::query()->create([ + 'transfer_no' => 'TO_manual_process_pending_out', + 'player_id' => $player->id, + 'direction' => 'out', + 'currency_code' => 'NPR', + 'amount' => 200, + 'idempotent_key' => 'manual-process-pending-out-key', + 'status' => 'pending_reconcile', + 'external_request_payload' => null, + 'external_response_payload' => null, + 'external_ref_no' => null, + 'fail_reason' => 'main_site_timeout', + 'finished_at' => null, + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/wallet/transfer-orders/TO_manual_process_pending_out/manually-process', [ + 'remark' => 'should reject', + ]) + ->assertStatus(422) + ->assertJsonPath('code', ErrorCode::WalletExternalRejected->value); +}); + test('admin shows player wallets', function (): void { $token = makeAdminToken(); diff --git a/tests/Feature/SettlementOrchestratorTest.php b/tests/Feature/SettlementOrchestratorTest.php index 382fba2..ad42009 100644 --- a/tests/Feature/SettlementOrchestratorTest.php +++ b/tests/Feature/SettlementOrchestratorTest.php @@ -411,3 +411,195 @@ test('settlement reject reverts tickets to pending_draw and allows re-settlement expect($item->status)->toBe('settled_win') ->and($draw->status)->toBe(DrawStatus::Settled->value); }); + +test('payout credits same player separately per currency', function (): void { + $uniq = bin2hex(random_bytes(4)); + $player = Player::query()->create([ + 'site_code' => 'test', + 'site_player_id' => 'settle-multi-currency-'.$uniq, + 'username' => 'smc_'.$uniq, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 100_000, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'USD', + 'balance' => 200_000, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + $draw = Draw::query()->create([ + 'draw_no' => '20260511-990', + 'business_date' => '2026-05-11', + 'sequence_no' => 990, + 'status' => DrawStatus::Settling->value, + 'start_time' => now()->subMinutes(10), + 'close_time' => now()->subMinutes(5), + 'draw_time' => now()->subMinutes(4), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 1, + 'settle_version' => 1, + 'is_reopened' => false, + ]); + $resultBatch = DrawResultBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_version' => 1, + 'source_type' => 'rng', + 'rng_seed_hash' => 'multi-currency', + 'raw_seed_encrypted' => null, + 'status' => DrawResultBatchStatus::Published->value, + 'created_by' => null, + 'confirmed_by' => null, + 'confirmed_at' => now(), + ]); + + $orderNpr = TicketOrder::query()->create([ + 'order_no' => 'TO-NPR-'.$uniq, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => 1000, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 1000, + 'total_estimated_payout' => 0, + 'status' => 'placed', + 'submit_source' => 'h5', + 'client_trace_id' => null, + 'play_config_version_no' => 1, + 'odds_version_no' => 1, + 'risk_cap_version_no' => 1, + ]); + $orderUsd = TicketOrder::query()->create([ + 'order_no' => 'TO-USD-'.$uniq, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'currency_code' => 'USD', + 'total_bet_amount' => 2000, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 2000, + 'total_estimated_payout' => 0, + 'status' => 'placed', + 'submit_source' => 'h5', + 'client_trace_id' => null, + 'play_config_version_no' => 1, + 'odds_version_no' => 1, + 'risk_cap_version_no' => 1, + ]); + + $itemNpr = TicketItem::query()->create([ + 'ticket_no' => 'TK-NPR-'.$uniq, + 'order_id' => $orderNpr->id, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'original_number' => '1234', + 'normalized_number' => '1234', + 'play_code' => 'big', + 'dimension' => 'D4', + 'digit_slot' => null, + 'bet_mode' => 'single', + 'unit_bet_amount' => 1000, + 'total_bet_amount' => 1000, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 1000, + 'odds_snapshot_json' => [], + 'rule_snapshot_json' => [], + 'combination_count' => 1, + 'estimated_max_payout' => 0, + 'risk_locked_amount' => 0, + 'status' => 'pending_payout', + 'fail_reason_code' => null, + 'fail_reason_text' => null, + 'win_amount' => 5000, + 'jackpot_win_amount' => 0, + 'settled_at' => null, + ]); + $itemUsd = TicketItem::query()->create([ + 'ticket_no' => 'TK-USD-'.$uniq, + 'order_id' => $orderUsd->id, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'original_number' => '5678', + 'normalized_number' => '5678', + 'play_code' => 'big', + 'dimension' => 'D4', + 'digit_slot' => null, + 'bet_mode' => 'single', + 'unit_bet_amount' => 2000, + 'total_bet_amount' => 2000, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 2000, + 'odds_snapshot_json' => [], + 'rule_snapshot_json' => [], + 'combination_count' => 1, + 'estimated_max_payout' => 0, + 'risk_locked_amount' => 0, + 'status' => 'pending_payout', + 'fail_reason_code' => null, + 'fail_reason_text' => null, + 'win_amount' => 8000, + 'jackpot_win_amount' => 0, + 'settled_at' => null, + ]); + + $batch = SettlementBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_batch_id' => $resultBatch->id, + 'settle_version' => 1, + 'status' => 'approved', + 'review_status' => 'approved', + 'reviewed_by' => null, + 'reviewed_at' => now(), + 'review_remark' => null, + 'started_at' => now()->subMinute(), + 'finished_at' => now(), + ]); + $batch->details()->create([ + 'ticket_item_id' => $itemNpr->id, + 'matched_prize_tier' => 'first', + 'win_amount' => 5000, + 'jackpot_allocation_amount' => 0, + 'match_detail_json' => [], + ]); + $batch->details()->create([ + 'ticket_item_id' => $itemUsd->id, + 'matched_prize_tier' => 'first', + 'win_amount' => 8000, + 'jackpot_allocation_amount' => 0, + 'match_detail_json' => [], + ]); + + app(SettlementBatchWorkflowService::class)->payout($batch->fresh()); + + $walletNpr = PlayerWallet::query() + ->where('player_id', $player->id) + ->where('currency_code', 'NPR') + ->firstOrFail(); + $walletUsd = PlayerWallet::query() + ->where('player_id', $player->id) + ->where('currency_code', 'USD') + ->firstOrFail(); + + expect((int) $walletNpr->balance)->toBe(105_000) + ->and((int) $walletUsd->balance)->toBe(208_000); + + expect(WalletTxn::query() + ->where('biz_type', 'settle_payout') + ->where('player_id', $player->id) + ->count())->toBe(2); +}); diff --git a/tests/Feature/WalletTransferScenariosTest.php b/tests/Feature/WalletTransferScenariosTest.php index d0b83ac..fc1fee3 100644 --- a/tests/Feature/WalletTransferScenariosTest.php +++ b/tests/Feature/WalletTransferScenariosTest.php @@ -460,3 +460,89 @@ test('replay while order still processing returns 1002', function (): void { ->assertStatus(409) ->assertJsonPath('code', ErrorCode::WalletTransferPending->value); }); + +test('transfer in replay while pending_reconcile stays pending without wallet credit', function (): void { + Http::fake([ + 'timeout-debit-replay.test/*' => Http::response([], 504), + ]); + config(['lottery.main_site.wallet_api_url' => 'https://timeout-debit-replay.test']); + config(['lottery.main_site.wallet_debit_path' => 'debit']); + + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'in-pending-replay', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $key = 'in-pending-replay-key'; + $payload = [ + 'amount' => 500, + 'currency' => 'NPR', + 'idempotent_key' => $key, + ]; + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/wallet/transfer-in', $payload) + ->assertStatus(409) + ->assertJsonPath('code', ErrorCode::WalletTransferPending->value); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/wallet/transfer-in', $payload) + ->assertStatus(409) + ->assertJsonPath('code', ErrorCode::WalletTransferPending->value); + + expect(TransferOrder::query()->where('idempotent_key', $key)->count())->toBe(1) + ->and(TransferOrder::query()->where('idempotent_key', $key)->value('status'))->toBe('pending_reconcile') + ->and(WalletTxn::query()->where('biz_type', 'transfer_in')->count())->toBe(0); +}); + +test('transfer out replay while pending_reconcile keeps single pending txn', function (): void { + Http::fake([ + 'timeout-credit-replay.test/*' => Http::response([], 504), + ]); + config(['lottery.main_site.wallet_api_url' => 'https://timeout-credit-replay.test']); + config(['lottery.main_site.wallet_credit_path' => 'credit']); + + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'out-pending-replay', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 1_000, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + $key = 'out-pending-replay-key'; + $payload = [ + 'amount' => 200, + 'idempotent_key' => $key, + ]; + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/wallet/transfer-out', $payload) + ->assertStatus(409) + ->assertJsonPath('code', ErrorCode::WalletTransferPending->value); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/wallet/transfer-out', $payload) + ->assertStatus(409) + ->assertJsonPath('code', ErrorCode::WalletTransferPending->value); + + expect(TransferOrder::query()->where('idempotent_key', $key)->count())->toBe(1) + ->and(TransferOrder::query()->where('idempotent_key', $key)->value('status'))->toBe('pending_reconcile') + ->and(WalletTxn::query()->where('biz_type', 'transfer_out')->count())->toBe(1) + ->and(WalletTxn::query()->where('biz_type', 'transfer_out')->value('status'))->toBe('pending_reconcile') + ->and((int) PlayerWallet::query()->where('player_id', $player->id)->value('balance'))->toBe(800); +});