seed(CurrencySeeder::class); $this->seed(PlayTypeSeeder::class); $this->seed(OperationalConfigV1Seeder::class); $this->seed(LotterySettingsSeeder::class); }); function mintSettlementAdminToken(): string { $admin = AdminUser::query()->create([ 'username' => 'settlement_admin', 'name' => 'Settlement QA', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; } function mintRiskOperatorToken(): string { $now = now(); DB::table('admin_roles')->updateOrInsert( ['slug' => 'risk_operator'], ['name' => 'Risk', 'code' => 'risk_operator', 'created_at' => $now, 'updated_at' => $now], ); $roleId = (int) DB::table('admin_roles')->where('slug', 'risk_operator')->value('id'); $admin = AdminUser::query()->create([ 'username' => 'risk_jp_admin', 'name' => 'Risk JP', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); DB::table('admin_user_site_roles')->insert([ 'admin_user_id' => $admin->id, 'site_id' => $siteId, 'role_id' => $roleId, 'granted_at' => $now, ]); $manageMenuActionId = (int) DB::table('admin_menu_actions') ->where('permission_code', 'config.jackpot.manage') ->value('id'); if ($manageMenuActionId > 0) { DB::table('admin_role_menu_actions')->updateOrInsert( ['role_id' => $roleId, 'menu_action_id' => $manageMenuActionId], [], ); } return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; } test('admin settlement batches index is authenticated', function (): void { $this->getJson('/api/v1/admin/settlement-batches')->assertUnauthorized(); }); test('admin jackpot pools index returns rows', function (): void { $token = mintSettlementAdminToken(); $this->withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/admin/jackpot/pools') ->assertOk() ->assertJsonPath('data.items.0.currency_code', 'NPR') ->assertJsonPath('data.items.0.contribution_rate', '0.0200') ->assertJsonPath('data.items.0.trigger_threshold', 100000000) ->assertJsonPath('data.items.0.payout_rate', '0.5000') ->assertJsonPath('data.items.0.force_trigger_draw_gap', 100) ->assertJsonPath('data.items.0.min_bet_amount', 100) ->assertJsonPath('data.items.0.status', 0) ->assertJsonPath('data.items.0.combo_trigger_play_codes', []); }); test('admin can update jackpot combo trigger', function (): void { $pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail(); $token = mintSettlementAdminToken(); $this->withHeader('Authorization', 'Bearer '.$token) ->putJson('/api/v1/admin/jackpot/pools/'.$pool->id, [ 'combo_trigger_play_codes' => ['straight', 'ibox'], ]) ->assertOk() ->assertJsonPath('data.combo_trigger_play_codes.0', 'straight') ->assertJsonPath('data.combo_trigger_play_codes.1', 'ibox'); }); test('risk operator cannot manually burst jackpot', function (): void { $pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail(); $pool->forceFill(['current_amount' => 1000, 'status' => 1])->save(); $draw = Draw::query()->create([ 'draw_no' => '20260518-099', 'business_date' => '2026-05-18', 'sequence_no' => 99, 'status' => DrawStatus::Settled->value, 'start_time' => now()->subHours(2), 'close_time' => now()->subHour(), 'draw_time' => now()->subHour(), 'cooling_end_time' => null, 'result_source' => null, 'current_result_version' => 0, 'settle_version' => 1, 'is_reopened' => false, ]); $token = mintRiskOperatorToken(); $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/manual-burst', [ 'draw_id' => $draw->id, ]) ->assertForbidden(); }); test('super admin manual burst allocates jackpot to first prize winners after settlement', function (): void { $pool = JackpotPool::query()->where('currency_code', 'NPR')->firstOrFail(); $pool->forceFill([ 'current_amount' => 10_000, 'contribution_rate' => '0', 'trigger_threshold' => 999_999_999, 'payout_rate' => '0.5000', 'force_trigger_draw_gap' => 0, 'min_bet_amount' => 0, 'status' => 0, 'last_trigger_draw_id' => null, ])->save(); $player = Player::query()->create([ 'site_code' => 'test', 'site_player_id' => 'manual-burst-p1', 'username' => 'manual_burst_p1', 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); PlayerWallet::query()->create([ 'player_id' => $player->id, 'wallet_type' => 'lottery', 'currency_code' => 'NPR', 'balance' => 5_000_000, 'frozen_balance' => 0, 'status' => 0, 'version' => 0, ]); $draw = Draw::query()->create([ 'draw_no' => '20260518-010', 'business_date' => '2026-05-18', 'sequence_no' => 10, 'status' => DrawStatus::Open->value, 'start_time' => now()->subMinutes(5), '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, ]); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => (string) $draw->draw_no, 'currency_code' => 'NPR', 'client_trace_id' => 'manual-burst-bet-1', 'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000]], ]) ->assertOk(); $batch = 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' => $batch->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), ]); } $draw->forceFill([ 'status' => DrawStatus::Settling->value, 'current_result_version' => 1, ])->save(); expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); $item = TicketItem::query()->where('draw_id', $draw->id)->firstOrFail(); expect((int) $item->jackpot_win_amount)->toBe(0); $admin = AdminUser::query()->where('username', 'settlement_admin')->first(); if ($admin === null) { mintSettlementAdminToken(); $admin = AdminUser::query()->where('username', 'settlement_admin')->firstOrFail(); } else { grantSuperAdminRole($admin); } $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(); $pool->refresh(); $pool->forceFill([ 'current_amount' => 10_000, 'status' => 1, 'payout_rate' => '0.5000', ])->save(); $token = $admin->createToken('burst', ['*'], now()->addDay())->plainTextToken; $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/manual-burst', [ 'draw_id' => $draw->id, ]) ->assertOk() ->assertJsonPath('data.burst_amount', 5000) ->assertJsonPath('data.current_amount', 5000) ->assertJsonPath('data.winner_count', 1); $item->refresh(); expect((int) $item->jackpot_win_amount)->toBe(5000); $log = JackpotPayoutLog::query()->where('draw_id', $draw->id)->firstOrFail(); expect($log->trigger_type)->toBe('manual') ->and($log->winner_count)->toBe(1) ->and((int) $log->total_payout_amount)->toBe(5000); expect(WalletTxn::query()->where('biz_type', 'jackpot_manual_payout')->count())->toBe(1); }); 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' => 0, 'payout_rate' => '1'])->save(); $player = Player::query()->create([ 'site_code' => 'test', 'site_player_id' => 'manual-burst-p2', 'username' => 'manual_burst_p2', 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); PlayerWallet::query()->create([ 'player_id' => $player->id, 'wallet_type' => 'lottery', 'currency_code' => 'NPR', 'balance' => 1_000_000, 'frozen_balance' => 0, 'status' => 0, 'version' => 0, ]); $draw = Draw::query()->create([ 'draw_no' => '20260518-002', 'business_date' => '2026-05-18', 'sequence_no' => 2, 'status' => DrawStatus::Open->value, 'start_time' => now()->subMinutes(5), '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, ]); $this->withHeader('Authorization', 'Bearer dev:'.$player->id) ->postJson('/api/v1/ticket/place', [ 'draw_id' => (string) $draw->draw_no, 'currency_code' => 'NPR', 'client_trace_id' => 'manual-burst-bet-2', 'lines' => [['number' => '1234', 'play_code' => 'straight', 'amount' => 10_000]], ]) ->assertOk(); $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), ]); } $draw->forceFill([ 'status' => DrawStatus::Settling->value, 'current_result_version' => 1, 'result_source' => 'rng', ])->save(); expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); $admin = AdminUser::query()->create([ 'username' => 'manual_burst_admin2', 'name' => 'Burst Admin', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); $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(); $pool->refresh(); $pool->forceFill(['current_amount' => 500, 'status' => 1, 'payout_rate' => '1'])->save(); $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/jackpot/pools/'.$pool->id.'/manual-burst', [ 'draw_id' => $draw->id, ]) ->assertOk(); }); 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); });