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('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 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 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); }); 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 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')); expect(TicketOrder::query()->count())->toBe(0); }); 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); }); /** * §13.5 风险占用回滚:同一订单两行共享号码时,preview 各自通过;首条 acquire 后剩余不足则第二条失败,整单事务回滚。 * big + amount 120 → estimated_payout 3000(maxOdds 250000 / 10000 = 25)。 */ test('ticket place rolls back order wallet and risk locks 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) ->assertStatus(400) ->assertJsonPath('code', ErrorCode::RiskPoolSoldOut->value); expect(TicketOrder::query()->count())->toBe(0); expect(WalletTxn::query()->where('biz_type', 'bet_deduct')->count())->toBe(0); $wallet = PlayerWallet::query()->where('player_id', $player->id)->firstOrFail(); expect((int) $wallet->balance)->toBe(500_000); $pool = RiskPool::query() ->where('draw_id', $draw->id) ->where('normalized_number', '1234') ->firstOrFail(); expect((int) $pool->remaining_amount)->toBe(5000); expect((int) $pool->locked_amount)->toBe(0); }); /** §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); });