seed(CurrencySeeder::class); $this->seed(PlayTypeSeeder::class); $this->seed(OperationalConfigV1Seeder::class); $this->seed(LotterySettingsSeeder::class); }); function ticketPlayerWithWallet(int $balance = 200_000): Player { $player = Player::query()->create([ 'site_code' => 'test', 'site_player_id' => 'ticket-player-1', 'username' => 'tp1', '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); });