seed(CurrencySeeder::class); $this->seed(PlayTypeSeeder::class); $this->seed(OperationalConfigV1Seeder::class); $this->seed(LotterySettingsSeeder::class); }); function ticketPlayerWithWallet(int $balance = 200_000): Player { $uniq = bin2hex(random_bytes(5)); $player = Player::query()->create([ 'site_code' => 'test', 'site_player_id' => 'ticket-player-'.$uniq, 'username' => 'tp_'.$uniq, 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); PlayerWallet::query()->create([ 'player_id' => $player->id, 'wallet_type' => 'lottery', 'currency_code' => 'NPR', 'balance' => $balance, 'frozen_balance' => 0, 'status' => 0, 'version' => 0, ]); return $player; } function ticketOpenDraw(string $drawNo = '20260511-001'): Draw { return Draw::query()->create([ 'draw_no' => $drawNo, 'business_date' => '2026-05-11', 'sequence_no' => 1, 'status' => DrawStatus::Open->value, 'start_time' => now()->subMinutes(2), 'close_time' => now()->addMinutes(5), 'draw_time' => now()->addMinutes(6), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); } function ticketPreviewPayload(string $drawNo = '20260511-001'): array { return [ 'draw_id' => $drawNo, 'currency_code' => 'NPR', 'client_trace_id' => 'trace-001', 'lines' => [ [ 'number' => '1234', 'play_code' => 'big', 'amount' => 10_000, ], [ 'number' => '1234', 'play_code' => 'ibox', 'amount' => 100, ], ], ]; } test('ticket preview returns computed summary for open draw', function (): void { $player = ticketPlayerWithWallet(); ticketOpenDraw(); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/preview', ticketPreviewPayload()) ->assertOk() ->assertJsonPath('code', ErrorCode::Success->value) ->assertJsonPath('data.draw.draw_id', '20260511-001') ->assertJsonPath('data.summary.total_bet_amount', 12_400) ->assertJsonPath('data.summary.total_actual_deduct', 12_400) ->assertJsonPath('data.config_versions.play_config_version_no', 1) ->assertJsonPath('data.config_versions.odds_version_no', 1) ->assertJsonPath('data.config_versions.risk_cap_version_no', 1) ->assertJsonCount(2, 'data.lines'); }); test('module 6 box family expands combinations and computes amount semantics', function (): void { $player = ticketPlayerWithWallet(500_000); ticketOpenDraw(); $payload = [ 'draw_id' => '20260511-001', 'currency_code' => 'NPR', 'client_trace_id' => 'trace-module6-box', 'lines' => [ ['number' => '1234', 'play_code' => 'box', 'amount' => 10_000], ['number' => '1123', 'play_code' => 'box', 'amount' => 10_000], ['number' => '1122', 'play_code' => 'box', 'amount' => 10_000], ['number' => '1112', 'play_code' => 'box', 'amount' => 10_000], ['number' => '1111', 'play_code' => 'box', 'amount' => 10_000], ['number' => '1122', 'play_code' => 'ibox', 'amount' => 100], ['number' => '1234', 'play_code' => 'mbox', 'amount' => 10_001], ], ]; $resp = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/preview', $payload) ->assertOk(); $lines = collect($resp->json('data.lines'))->keyBy('client_line_no'); expect($lines[1]['combination_count'])->toBe(24) ->and($lines[2]['combination_count'])->toBe(12) ->and($lines[3]['combination_count'])->toBe(6) ->and($lines[4]['combination_count'])->toBe(4) ->and($lines[5]['combination_count'])->toBe(1) ->and($lines[6]['combination_count'])->toBe(6) ->and($lines[6]['total_bet_amount'])->toBe(600) ->and($lines[7]['combination_count'])->toBe(24) ->and($lines[7]['total_bet_amount'])->toBe(9_984) ->and($lines[7]['actual_deduct_amount'])->toBe(9_984) ->and($lines[7]['rule_snapshot_json']['rounding_refund_amount'] ?? null)->toBe(17); }); test('module 6 roll expands each R position and charges per expanded combination', function (): void { $player = ticketPlayerWithWallet(500_000); ticketOpenDraw(); $resp = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/preview', [ 'draw_id' => '20260511-001', 'currency_code' => 'NPR', 'client_trace_id' => 'trace-module6-roll', 'lines' => [ ['number' => 'R234', 'play_code' => 'roll', 'amount' => 100], ['number' => 'RR34', 'play_code' => 'roll', 'amount' => 100], ], ]) ->assertOk(); $lines = collect($resp->json('data.lines'))->keyBy('client_line_no'); expect($lines[1]['combination_count'])->toBe(10) ->and($lines[1]['total_bet_amount'])->toBe(1_000) ->and($lines[2]['combination_count'])->toBe(100) ->and($lines[2]['total_bet_amount'])->toBe(10_000); }); test('module 6 reserved and phase two plays are not available for betting or public entry', function (): void { $player = ticketPlayerWithWallet(); ticketOpenDraw(); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/preview', [ 'draw_id' => '20260511-001', 'currency_code' => 'NPR', 'client_trace_id' => 'trace-module6-half-box', 'lines' => [ ['number' => '1234', 'play_code' => 'half_box', 'amount' => 10_000], ], ]) ->assertStatus(400) ->assertJsonPath('code', ErrorCode::PlayModeClosed->value); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/preview', [ 'draw_id' => '20260511-001', 'currency_code' => 'NPR', 'client_trace_id' => 'trace-module6-5d', 'lines' => [ ['number' => '12345', 'play_code' => '5d', 'amount' => 10_000], ], ]) ->assertStatus(400) ->assertJsonPath('code', ErrorCode::PlayModeClosed->value); $plays = collect($this->getJson('/api/v1/play/effective?currency=NPR')->assertOk()->json('data.plays')); expect($plays->firstWhere('play_code', 'half_box')['config']['is_enabled'])->toBeFalse() ->and($plays->contains('play_code', '5d'))->toBeFalse() ->and($plays->contains('play_code', '6d'))->toBeFalse(); }); test('ticket place deducts wallet and persists order items combinations and logs', function (): void { $player = ticketPlayerWithWallet(); ticketOpenDraw(); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', ticketPreviewPayload()) ->assertOk() ->assertJsonPath('code', ErrorCode::Success->value) ->assertJsonPath('data.draw.draw_id', '20260511-001') ->assertJsonPath('data.summary.total_bet_amount', 12_400) ->assertJsonPath('data.summary.total_actual_deduct', 12_400); expect(TicketOrder::query()->count())->toBe(1); expect(TicketItem::query()->count())->toBe(2); expect(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(1); expect(RiskPool::query()->count())->toBeGreaterThan(0); $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); expect((int) $wallet->balance)->toBe(200_000 - 12_400); }); test('ticket place is idempotent by player draw and client trace id', function (): void { $player = ticketPlayerWithWallet(); ticketOpenDraw(); $payload = array_merge(ticketPreviewPayload(), ['client_trace_id' => 'same-submit-once']); $first = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', $payload) ->assertOk() ->assertJsonPath('code', ErrorCode::Success->value) ->json('data'); $second = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', $payload) ->assertOk() ->assertJsonPath('code', ErrorCode::Success->value) ->json('data'); expect($second['order_no'])->toBe($first['order_no']) ->and(TicketOrder::query()->count())->toBe(1) ->and(TicketItem::query()->count())->toBe(2) ->and(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(1); $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); expect((int) $wallet->balance)->toBe(200_000 - 12_400); }); test('box family estimated max payout is the sum of every expanded combination payout', function (): void { $player = ticketPlayerWithWallet(500_000); ticketOpenDraw(); $response = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => '20260511-001', 'currency_code' => 'NPR', 'client_trace_id' => 'combo-payout-sum', 'lines' => [ ['number' => '1122', 'play_code' => 'ibox', 'amount' => 100], ], ]) ->assertOk() ->json('data'); $item = TicketItem::query()->firstOrFail(); $combinationSum = TicketCombination::query() ->where('ticket_item_id', $item->id) ->sum('estimated_payout'); expect((int) $response['summary']['total_estimated_payout'])->toBe((int) $combinationSum) ->and((int) $item->estimated_max_payout)->toBe((int) $combinationSum) ->and((int) $item->risk_locked_amount)->toBe((int) $combinationSum); }); test('ticket place rejects closed draw', function (): void { $player = ticketPlayerWithWallet(); $draw = ticketOpenDraw(); $draw->update(['status' => DrawStatus::Closed->value]); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', ticketPreviewPayload()) ->assertStatus(400) ->assertJsonPath('code', ErrorCode::DrawClosed->value); }); test('ticket preview and place reject open draw after server close time', function (): void { $player = ticketPlayerWithWallet(); $draw = ticketOpenDraw(); $draw->update(['close_time' => now()->subSecond()]); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/preview', ticketPreviewPayload()) ->assertStatus(400) ->assertJsonPath('code', ErrorCode::DrawClosed->value); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', ticketPreviewPayload()) ->assertStatus(400) ->assertJsonPath('code', ErrorCode::DrawClosed->value); expect(TicketOrder::query()->count())->toBe(0); }); test('ticket place rejects insufficient balance', function (): void { $player = ticketPlayerWithWallet(1_000); ticketOpenDraw(); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', ticketPreviewPayload()) ->assertStatus(400) ->assertJsonPath('code', ErrorCode::BetInsufficientBalance->value); expect(TicketOrder::query()->count())->toBe(0); expect(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0); }); test('ticket place succeeds when expected_config_versions matches preview', function (): void { $player = ticketPlayerWithWallet(); ticketOpenDraw(); $versions = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/preview', ticketPreviewPayload()) ->assertOk() ->json('data.config_versions'); $payload = array_merge(ticketPreviewPayload(), ['expected_config_versions' => $versions]); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', $payload) ->assertOk() ->assertJsonPath('code', ErrorCode::Success->value); expect(TicketOrder::query()->count())->toBe(1); $order = TicketOrder::query()->firstOrFail(); expect((int) $order->play_config_version_no)->toBe((int) $versions['play_config_version_no']) ->and((int) $order->odds_version_no)->toBe((int) $versions['odds_version_no']) ->and((int) $order->risk_cap_version_no)->toBe((int) $versions['risk_cap_version_no']); }); test('ticket place rejects stale expected_config_versions', function (): void { $player = ticketPlayerWithWallet(); ticketOpenDraw(); $preview = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/preview', ticketPreviewPayload()) ->assertOk() ->json('data.config_versions'); OddsVersion::query()->where('status', ConfigVersionStatus::Active->value)->update(['version_no' => 99]); $payload = array_merge(ticketPreviewPayload(), ['expected_config_versions' => $preview]); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', $payload) ->assertStatus(400) ->assertJsonPath('code', ErrorCode::BetConfigStale->value); expect(TicketOrder::query()->count())->toBe(0); }); test('ticket place rejects sold out risk pool', function (): void { $player = ticketPlayerWithWallet(); $draw = ticketOpenDraw(); RiskPool::query()->create([ 'draw_id' => $draw->id, 'normalized_number' => '1234', 'total_cap_amount' => 100, 'locked_amount' => 100, 'remaining_amount' => 0, 'sold_out_status' => 1, 'version' => 1, ]); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => '20260511-001', 'currency_code' => 'NPR', 'client_trace_id' => 'trace-002', 'lines' => [ [ 'number' => '1234', 'play_code' => 'big', 'amount' => 10_000, ], ], ]) ->assertStatus(400) ->assertJsonPath('code', ErrorCode::RiskPoolSoldOut->value); $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); expect((int) $wallet->balance)->toBe(200_000); expect(TicketOrder::query()->count())->toBe(0); }); test('ticket place can return mixed success and failed risk results', function (): void { $player = ticketPlayerWithWallet(500_000); $draw = ticketOpenDraw(); RiskPool::query()->create([ 'draw_id' => $draw->id, 'normalized_number' => '1234', 'total_cap_amount' => 5000, 'locked_amount' => 0, 'remaining_amount' => 5000, 'sold_out_status' => 0, 'version' => 0, ]); RiskPool::query()->create([ 'draw_id' => $draw->id, 'normalized_number' => '5678', 'total_cap_amount' => 100, 'locked_amount' => 100, 'remaining_amount' => 0, 'sold_out_status' => 1, 'version' => 1, ]); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => '20260511-001', 'currency_code' => 'NPR', 'client_trace_id' => 'trace-mixed-risk', 'lines' => [ ['number' => '1234', 'play_code' => 'big', 'amount' => 120], ['number' => '5678', 'play_code' => 'big', 'amount' => 120], ], ]) ->assertOk() ->assertJsonPath('code', ErrorCode::Success->value) ->assertJsonPath('data.summary.success_count', 1) ->assertJsonPath('data.summary.failure_count', 1) ->assertJsonPath('data.items.0.status', 'pending_draw') ->assertJsonPath('data.items.1.status', 'failed') ->assertJsonPath('data.items.1.fail_reason_code', (string) ErrorCode::RiskPoolSoldOut->value); $order = TicketOrder::query()->firstOrFail(); expect($order->status)->toBe('partial_failed') ->and((int) $order->total_actual_deduct)->toBe(120) ->and(TicketItem::query()->where('status', 'pending_draw')->count())->toBe(1) ->and(TicketItem::query()->where('status', 'failed')->count())->toBe(1); $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); expect((int) $wallet->balance)->toBe(500_000 - 120); }); test('ticket place rejects disabled play from active catalog', function (): void { $player = ticketPlayerWithWallet(); ticketOpenDraw(); $versionId = PlayConfigVersion::query() ->where('status', ConfigVersionStatus::Active->value) ->value('id'); expect($versionId)->not->toBeNull(); PlayConfigItem::query() ->where('version_id', $versionId) ->where('play_code', 'big') ->update(['is_enabled' => false]); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->withHeader('X-Locale', 'zh') ->postJson('/api/v1/ticket/place', [ 'draw_id' => '20260511-001', 'currency_code' => 'NPR', 'client_trace_id' => 'trace-disabled-play', 'lines' => [ ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000], ], ]) ->assertStatus(400) ->assertJsonPath('code', ErrorCode::PlayModeClosed->value) ->assertJsonPath('msg', __('wallet.2002', [], 'zh')) ->assertJsonPath('data.cleanup_hint', '玩法已关闭,相关注项已清理') ->assertJsonPath('data.cleanup_lines.0.client_line_no', 1) ->assertJsonPath('data.cleanup_lines.0.play_code', 'big'); expect(TicketOrder::query()->count())->toBe(0); }); test('ticket preview returns cleanup hint when draft contains closed play', function (): void { $player = ticketPlayerWithWallet(); ticketOpenDraw(); $versionId = PlayConfigVersion::query() ->where('status', ConfigVersionStatus::Active->value) ->value('id'); expect($versionId)->not->toBeNull(); PlayConfigItem::query() ->where('version_id', $versionId) ->where('play_code', 'big') ->update(['is_enabled' => false]); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->withHeader('X-Locale', 'zh') ->postJson('/api/v1/ticket/preview', [ 'draw_id' => '20260511-001', 'currency_code' => 'NPR', 'client_trace_id' => 'trace-preview-closed-play', 'lines' => [ ['number' => '1234', 'play_code' => 'big', 'amount' => 10_000], ], ]) ->assertStatus(400) ->assertJsonPath('code', ErrorCode::PlayModeClosed->value) ->assertJsonPath('msg', __('wallet.2002', [], 'zh')) ->assertJsonPath('data.cleanup_hint', '玩法已关闭,相关注项已清理') ->assertJsonPath('data.cleanup_lines.0.client_line_no', 1) ->assertJsonPath('data.cleanup_lines.0.play_code', 'big'); }); test('ticket place rejects bet amount below configured minimum', function (): void { $player = ticketPlayerWithWallet(); ticketOpenDraw(); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => '20260511-001', 'currency_code' => 'NPR', 'client_trace_id' => 'trace-min-bet', 'lines' => [ ['number' => '1234', 'play_code' => 'big', 'amount' => 50], ], ]) ->assertStatus(400) ->assertJsonPath('code', ErrorCode::WalletAmountExceedsLimit->value); expect(TicketOrder::query()->count())->toBe(0); }); test('ticket preview rejects invalid line amount per validation rules', function (): void { $player = ticketPlayerWithWallet(); ticketOpenDraw(); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/preview', [ 'draw_id' => '20260511-001', 'currency_code' => 'NPR', 'client_trace_id' => 'trace-invalid-amt', 'lines' => [ ['number' => '1234', 'play_code' => 'big', 'amount' => 0], ], ]) ->assertStatus(422); }); /** * §10.1.2 混合成功失败:同一订单两行共享号码时,首条占用成功,第二条额度不足则该注项失败。 * big + amount 120 → estimated_payout 3000(maxOdds 250000 / 10000 = 25)。 */ test('ticket place records partial failed when mid-order acquire fails', function (): void { $player = ticketPlayerWithWallet(500_000); $draw = ticketOpenDraw(); RiskPool::query()->create([ 'draw_id' => $draw->id, 'normalized_number' => '1234', 'total_cap_amount' => 5000, 'locked_amount' => 0, 'remaining_amount' => 5000, 'sold_out_status' => 0, 'version' => 0, ]); $payload = [ 'draw_id' => '20260511-001', 'currency_code' => 'NPR', 'client_trace_id' => 'trace-rollback', 'lines' => [ ['number' => '1234', 'play_code' => 'big', 'amount' => 120], ['number' => '1234', 'play_code' => 'big', 'amount' => 120], ], ]; $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', $payload) ->assertOk() ->assertJsonPath('code', ErrorCode::Success->value) ->assertJsonPath('data.summary.success_count', 1) ->assertJsonPath('data.summary.failure_count', 1); expect(TicketOrder::query()->count())->toBe(1); expect(TicketOrder::query()->firstOrFail()->status)->toBe('partial_failed'); expect(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(1); $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); expect((int) $wallet->balance)->toBe(500_000 - 120); $pool = RiskPool::query() ->where('draw_id', $draw->id) ->where('normalized_number', '1234') ->firstOrFail(); expect((int) $pool->remaining_amount)->toBe(2000); expect((int) $pool->locked_amount)->toBe(3000); }); /** §13.5 并发下注(顺序挤出):先成功者占用额度,后一盘同一号码收到售罄。 */ test('ticket place sold out for second player after first consumes shared pool', function (): void { $draw = ticketOpenDraw(); $playerA = ticketPlayerWithWallet(); $playerB = ticketPlayerWithWallet(); RiskPool::query()->create([ 'draw_id' => $draw->id, 'normalized_number' => '1234', 'total_cap_amount' => 5000, 'locked_amount' => 0, 'remaining_amount' => 5000, 'sold_out_status' => 0, 'version' => 0, ]); $payload = [ 'draw_id' => '20260511-001', 'currency_code' => 'NPR', 'client_trace_id' => 'trace-race-a', 'lines' => [ ['number' => '1234', 'play_code' => 'big', 'amount' => 120], ], ]; $this->withHeader('Authorization', 'Bearer dev:'.$playerA->id) ->postJson('/api/v1/ticket/place', array_merge($payload, ['client_trace_id' => 'race-a'])) ->assertOk() ->assertJsonPath('code', ErrorCode::Success->value); $this->withHeader('Authorization', 'Bearer dev:'.$playerB->id) ->postJson('/api/v1/ticket/place', array_merge($payload, ['client_trace_id' => 'race-b'])) ->assertStatus(400) ->assertJsonPath('code', ErrorCode::RiskPoolSoldOut->value); expect(TicketOrder::query()->count())->toBe(1); $pool = RiskPool::query() ->where('draw_id', $draw->id) ->where('normalized_number', '1234') ->firstOrFail(); expect((int) $pool->remaining_amount)->toBe(2000); }); test('ticket pending confirmation reconcile releases risk when wallet deduction is missing', function (): void { $draw = ticketOpenDraw(); $player = ticketPlayerWithWallet(); $order = TicketOrder::query()->create([ 'order_no' => 'TO-PENDING-001', 'player_id' => $player->id, 'draw_id' => $draw->id, 'currency_code' => 'NPR', 'total_bet_amount' => 100, 'total_rebate_amount' => 0, 'total_actual_deduct' => 100, 'total_estimated_payout' => 3000, 'status' => 'pending_confirm', 'submit_source' => 'h5', 'client_trace_id' => 'pending-confirm-missing-wallet', 'created_at' => now()->subMinutes(20), 'updated_at' => now()->subMinutes(20), ]); TicketOrder::query()->whereKey($order->id)->update(['updated_at' => now()->subMinutes(20)]); $item = TicketItem::query()->create([ 'ticket_no' => 'TK-PENDING-001', 'order_id' => $order->id, 'player_id' => $player->id, 'draw_id' => $draw->id, 'original_number' => '1234', 'normalized_number' => '1234', 'play_code' => 'big', 'dimension' => 4, 'digit_slot' => null, 'bet_mode' => 'straight', 'unit_bet_amount' => 100, 'total_bet_amount' => 100, 'rebate_rate_snapshot' => 0, 'commission_rate_snapshot' => 0, 'actual_deduct_amount' => 100, 'odds_snapshot_json' => [], 'rule_snapshot_json' => [], 'combination_count' => 1, 'estimated_max_payout' => 3000, 'risk_locked_amount' => 3000, 'status' => 'pending_confirm', 'fail_reason_code' => null, 'fail_reason_text' => null, 'win_amount' => 0, 'jackpot_win_amount' => 0, 'settled_at' => null, 'created_at' => now()->subMinutes(20), 'updated_at' => now()->subMinutes(20), ]); TicketCombination::query()->create([ 'ticket_item_id' => $item->id, 'combination_no' => 1, 'number_4d' => '1234', 'bet_amount' => 100, 'estimated_payout' => 3000, 'created_at' => now()->subMinutes(20), ]); RiskPool::query()->create([ 'draw_id' => $draw->id, 'normalized_number' => '1234', 'total_cap_amount' => 5000, 'locked_amount' => 3000, 'remaining_amount' => 2000, 'sold_out_status' => 0, 'version' => 1, ]); $this->artisan('lottery:ticket-pending-confirm-reconcile --stale-minutes=15 --limit=100') ->expectsOutputToContain('refunded: 1') ->assertExitCode(0); expect($order->fresh()->status)->toBe('refunded') ->and($item->fresh()->status)->toBe('refunded') ->and($item->fresh()->fail_reason_text)->toBe('pending_confirm_timeout_refund'); $pool = RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->firstOrFail(); expect((int) $pool->locked_amount)->toBe(0) ->and((int) $pool->remaining_amount)->toBe(5000) ->and(WalletTxn::query()->where('biz_no', 'TO-PENDING-001')->count())->toBe(0); }); test('ticket pending confirmation reconcile confirms order when wallet deduction exists', function (): void { $draw = ticketOpenDraw(); $player = ticketPlayerWithWallet(10_000); $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); $order = TicketOrder::query()->create([ 'order_no' => 'TO-PENDING-002', 'player_id' => $player->id, 'draw_id' => $draw->id, 'currency_code' => 'NPR', 'total_bet_amount' => 100, 'total_rebate_amount' => 0, 'total_actual_deduct' => 100, 'total_estimated_payout' => 3000, 'status' => 'pending_confirm', 'submit_source' => 'h5', 'client_trace_id' => 'pending-confirm-with-wallet', 'created_at' => now()->subMinutes(20), 'updated_at' => now()->subMinutes(20), ]); TicketOrder::query()->whereKey($order->id)->update(['updated_at' => now()->subMinutes(20)]); $item = TicketItem::query()->create([ 'ticket_no' => 'TK-PENDING-002', 'order_id' => $order->id, 'player_id' => $player->id, 'draw_id' => $draw->id, 'original_number' => '1234', 'normalized_number' => '1234', 'play_code' => 'big', 'dimension' => 4, 'digit_slot' => null, 'bet_mode' => 'straight', 'unit_bet_amount' => 100, 'total_bet_amount' => 100, 'rebate_rate_snapshot' => 0, 'commission_rate_snapshot' => 0, 'actual_deduct_amount' => 100, 'odds_snapshot_json' => [], 'rule_snapshot_json' => [], 'combination_count' => 1, 'estimated_max_payout' => 3000, 'risk_locked_amount' => 3000, 'status' => 'pending_confirm', 'fail_reason_code' => null, 'fail_reason_text' => null, 'win_amount' => 0, 'jackpot_win_amount' => 0, 'settled_at' => null, 'created_at' => now()->subMinutes(20), 'updated_at' => now()->subMinutes(20), ]); TicketCombination::query()->create([ 'ticket_item_id' => $item->id, 'combination_no' => 1, 'number_4d' => '1234', 'bet_amount' => 100, 'estimated_payout' => 3000, 'created_at' => now()->subMinutes(20), ]); RiskPool::query()->create([ 'draw_id' => $draw->id, 'normalized_number' => '1234', 'total_cap_amount' => 5000, 'locked_amount' => 3000, 'remaining_amount' => 2000, 'sold_out_status' => 0, 'version' => 1, ]); WalletTxn::query()->create([ 'txn_no' => 'WL-PENDING-002', 'player_id' => $player->id, 'wallet_id' => $wallet->id, 'biz_type' => 'bet_deduct', 'biz_no' => 'TO-PENDING-002', 'direction' => 2, 'amount' => 100, 'balance_before' => 10_000, 'balance_after' => 9_900, 'status' => 'posted', 'external_ref_no' => null, 'idempotent_key' => 'pending-confirm-with-wallet', 'remark' => null, ]); $this->artisan('lottery:ticket-pending-confirm-reconcile --stale-minutes=15 --limit=100') ->expectsOutputToContain('confirmed: 1') ->assertExitCode(0); expect($order->fresh()->status)->toBe('placed') ->and($item->fresh()->status)->toBe('pending_draw') ->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(3000); }); test('ticket place reverses wallet and releases risk when post deduction confirmation fails', function (): void { $player = ticketPlayerWithWallet(20_000); $draw = ticketOpenDraw(); JackpotPool::query()->create([ 'currency_code' => 'NPR', 'current_amount' => 0, 'contribution_rate' => 1, 'trigger_threshold' => 0, 'payout_rate' => 0, 'force_trigger_draw_gap' => 0, 'min_bet_amount' => 0, 'status' => 1, ]); DB::statement("CREATE TRIGGER fail_jackpot_contribution_insert BEFORE INSERT ON jackpot_contributions BEGIN SELECT RAISE(ABORT, 'forced_confirmation_failure'); END"); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => '20260511-001', 'currency_code' => 'NPR', 'client_trace_id' => 'wallet-reverse-on-confirm-fail', 'lines' => [ ['number' => '1234', 'play_code' => 'big', 'amount' => 120], ], ]) ->assertStatus(500); $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); $order = TicketOrder::query()->where('client_trace_id', 'wallet-reverse-on-confirm-fail')->firstOrFail(); expect((int) $wallet->balance)->toBe(20_000) ->and($order->status)->toBe('refunded') ->and(WalletTxn::query()->where('biz_no', $order->order_no)->where('biz_type', 'bet_deduct')->count())->toBe(1) ->and(WalletTxn::query()->where('biz_no', $order->order_no)->where('biz_type', 'bet_reverse')->count())->toBe(1) ->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(0); });