create([ 'draw_no' => 'CHUNK-'.$suffix, 'business_date' => '2026-07-22', 'sequence_no' => random_int(1000, 9999), 'status' => DrawStatus::Settling->value, 'start_time' => now()->subMinutes(10), 'close_time' => now()->subMinutes(2), 'draw_time' => now()->subMinute(), 'current_result_version' => 1, 'settle_version' => 0, 'is_reopened' => false, ]); } function createChunkTestPlayer(string $suffix): Player { $player = Player::query()->create([ 'site_code' => 'test', 'site_player_id' => 'chunk-'.$suffix, 'username' => 'chunk_'.$suffix, 'default_currency' => 'NPR', 'status' => 0, ]); PlayerWallet::query()->create([ 'player_id' => $player->id, 'wallet_type' => 'lottery', 'currency_code' => 'NPR', 'balance' => 0, 'frozen_balance' => 0, 'status' => 0, 'version' => 0, ]); return $player; } function createChunkTestResult(Draw $draw, string $providerCode): DrawResultBatch { $batch = DrawResultBatch::query()->create([ 'draw_id' => $draw->id, 'provider_code' => $providerCode, 'provider_name' => $providerCode, 'result_version' => 1, 'source_type' => 'manual', 'status' => DrawResultBatchStatus::Published->value, 'confirmed_at' => now(), ]); foreach (DrawPrizeLayout::slots() as $slot) { $number = $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' => $number, 'suffix_3d' => substr($number, -3), 'suffix_2d' => substr($number, -2), 'head_digit' => (int) $number[0], 'tail_digit' => (int) $number[3], ]); } return $batch; } function createChunkTestTicket( Draw $draw, Player $player, TicketOrder $order, string $providerCode, string $suffix, ): TicketItem { $item = TicketItem::query()->create([ 'ticket_no' => 'CHUNK-TICKET-'.$suffix, 'order_id' => $order->id, 'player_id' => $player->id, 'draw_id' => $draw->id, 'provider_code' => $providerCode, 'provider_name' => $providerCode, 'original_number' => '1234', 'normalized_number' => '1234', 'play_code' => 'pos_4a', 'bet_mode' => 'unit', 'unit_bet_amount' => 10_000, 'total_bet_amount' => 10_000, 'actual_deduct_amount' => 10_000, 'odds_snapshot_json' => [['prize_scope' => 'first', 'odds_value' => 100_000]], 'rule_snapshot_json' => [], 'combination_count' => 1, 'estimated_max_payout' => 100_000, 'risk_locked_amount' => 0, 'status' => 'pending_draw', 'win_amount' => 0, 'jackpot_win_amount' => 0, ]); TicketCombination::query()->create([ 'ticket_item_id' => $item->id, 'combination_no' => 1, 'number_4d' => '1234', 'bet_amount' => 10_000, 'estimated_payout' => 100_000, ]); return $item; } function createChunkTestOrder(Draw $draw, Player $player, string $suffix, int $ticketCount): TicketOrder { return TicketOrder::query()->create([ 'order_no' => 'CHUNK-ORDER-'.$suffix, 'player_id' => $player->id, 'draw_id' => $draw->id, 'currency_code' => 'NPR', 'total_bet_amount' => 10_000 * $ticketCount, 'total_rebate_amount' => 0, 'total_actual_deduct' => 10_000 * $ticketCount, 'total_estimated_payout' => 100_000 * $ticketCount, 'status' => 'placed', 'submit_source' => 'test', 'client_trace_id' => 'chunk-trace-'.$suffix, ]); } test('settlement processes every ticket chunk into one provider batch', function (): void { config()->set('lottery.settlement.ticket_chunk_size', 2); $suffix = bin2hex(random_bytes(4)); $draw = createChunkTestDraw($suffix); $player = createChunkTestPlayer($suffix); $order = createChunkTestOrder($draw, $player, $suffix, 3); createChunkTestResult($draw, 'SG'); for ($i = 1; $i <= 3; $i++) { createChunkTestTicket($draw, $player, $order, 'SG', $suffix.'-'.$i); } expect(app(SettlementOrchestrator::class)->trySettleDraw($draw))->toBeTrue(); $batch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail(); expect(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1) ->and($batch->status)->toBe(SettlementBatchStatus::PendingReview->value) ->and((int) $batch->total_ticket_count)->toBe(3) ->and((int) $batch->total_win_count)->toBe(3) ->and((int) $batch->total_payout_amount)->toBe(300_000) ->and($batch->details()->count())->toBe(3) ->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'pending_draw')->exists())->toBeFalse(); }); test('settlement resumes a legacy partial provider batch instead of skipping remaining tickets', function (): void { config()->set('lottery.settlement.ticket_chunk_size', 2); $suffix = bin2hex(random_bytes(4)); $draw = createChunkTestDraw($suffix); $player = createChunkTestPlayer($suffix); $order = createChunkTestOrder($draw, $player, $suffix, 3); createChunkTestResult($draw, 'SG'); createChunkTestTicket($draw, $player, $order, 'SG', $suffix.'-1'); createChunkTestTicket($draw, $player, $order, 'SG', $suffix.'-2'); app(SettlementOrchestrator::class)->trySettleDraw($draw); $existingBatch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail(); createChunkTestTicket($draw, $player, $order, 'SG', $suffix.'-3'); expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue(); $resumedBatch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail(); expect((int) $resumedBatch->id)->toBe((int) $existingBatch->id) ->and(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1) ->and($resumedBatch->status)->toBe(SettlementBatchStatus::PendingReview->value) ->and((int) $resumedBatch->total_ticket_count)->toBe(3) ->and($resumedBatch->details()->count())->toBe(3) ->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'pending_draw')->exists())->toBeFalse(); }); test('approved provider batches payout independently and settle draw after the last provider', function (): void { config()->set('lottery.settlement.ticket_chunk_size', 1); $suffix = bin2hex(random_bytes(4)); $draw = createChunkTestDraw($suffix); $player = createChunkTestPlayer($suffix); $order = createChunkTestOrder($draw, $player, $suffix, 2); foreach (['SG', 'MY'] as $providerCode) { createChunkTestResult($draw, $providerCode); createChunkTestTicket($draw, $player, $order, $providerCode, $suffix.'-'.$providerCode); } expect(app(SettlementOrchestrator::class)->trySettleDraw($draw))->toBeTrue(); $batches = SettlementBatch::query()->where('draw_id', $draw->id)->orderBy('id')->get(); expect($batches)->toHaveCount(2) ->and($batches->every(fn (SettlementBatch $batch): bool => $batch->status === SettlementBatchStatus::PendingReview->value))->toBeTrue(); $admin = AdminUser::query()->create([ 'username' => 'chunk_reviewer_'.$suffix, 'name' => 'Chunk Reviewer', 'password' => Hash::make('secret-strong'), 'status' => 0, ]); $workflow = app(SettlementBatchWorkflowService::class); foreach ($batches as $batch) { $workflow->approve($batch, $admin); } expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue() ->and(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::Approved->value)->count())->toBe(2); $workflow->payout($batches[0]->fresh()); expect($draw->fresh()->status)->toBe(DrawStatus::Settling->value) ->and(SettlementBatch::query()->whereKey($batches[0]->id)->value('status'))->toBe(SettlementBatchStatus::Paid->value) ->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'pending_payout')->count())->toBe(1); $workflow->payout($batches[1]->fresh()); expect($draw->fresh()->status)->toBe(DrawStatus::Settled->value) ->and(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::Paid->value)->count())->toBe(2) ->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'settled_win')->count())->toBe(2) ->and($order->fresh()->status)->toBe('settled'); }); test('tick finalizer approves all provider batches before paying them', function (): void { $suffix = bin2hex(random_bytes(4)); $draw = createChunkTestDraw($suffix); $player = createChunkTestPlayer($suffix); $order = createChunkTestOrder($draw, $player, $suffix, 2); foreach (['SG', 'MY'] as $providerCode) { createChunkTestResult($draw, $providerCode); createChunkTestTicket($draw, $player, $order, $providerCode, $suffix.'-'.$providerCode); } app(SettlementOrchestrator::class)->trySettleDraw($draw); $result = app(SettlementTickFinalizer::class)->finalizePendingBatches(); expect($result)->toMatchArray(['approved' => 2, 'paid' => 2, 'payout_failed' => 0]) ->and($draw->fresh()->status)->toBe(DrawStatus::Settled->value) ->and(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::Paid->value)->count())->toBe(2) ->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'settled_win')->count())->toBe(2); }); test('tick finalizer resumes approved batches left before payout', function (): void { $suffix = bin2hex(random_bytes(4)); $draw = createChunkTestDraw($suffix); $player = createChunkTestPlayer($suffix); $order = createChunkTestOrder($draw, $player, $suffix, 2); foreach (['SG', 'MY'] as $providerCode) { createChunkTestResult($draw, $providerCode); createChunkTestTicket($draw, $player, $order, $providerCode, $suffix.'-'.$providerCode); } app(SettlementOrchestrator::class)->trySettleDraw($draw); $workflow = app(SettlementBatchWorkflowService::class); foreach (SettlementBatch::query()->where('draw_id', $draw->id)->get() as $batch) { $workflow->approveBySystem($batch, 'simulate restart after approval'); } expect(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::PendingReview->value)->exists())->toBeFalse() ->and(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::Approved->value)->count())->toBe(2); $result = app(SettlementTickFinalizer::class)->finalizePendingBatches(); expect($result)->toMatchArray(['approved' => 0, 'paid' => 2, 'payout_failed' => 0]) ->and($draw->fresh()->status)->toBe(DrawStatus::Settled->value) ->and(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::Paid->value)->count())->toBe(2) ->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'settled_win')->count())->toBe(2); }); test('blocked approved draw does not starve a later eligible draw at the scan limit', function (): void { config()->set('lottery.draw_tick_finalize_limit', 1); $blockedSuffix = bin2hex(random_bytes(4)); $blockedDraw = createChunkTestDraw($blockedSuffix); $blockedResult = createChunkTestResult($blockedDraw, 'BLOCKED'); foreach ([SettlementBatchStatus::Approved, SettlementBatchStatus::Running] as $status) { SettlementBatch::query()->create([ 'draw_id' => $blockedDraw->id, 'result_batch_id' => $blockedResult->id, 'settle_version' => 1, 'status' => $status->value, 'total_ticket_count' => 0, 'total_win_count' => 0, 'total_payout_amount' => 0, 'total_jackpot_payout_amount' => 0, 'review_status' => $status === SettlementBatchStatus::Approved ? 'approved' : 'pending', 'reviewed_at' => $status === SettlementBatchStatus::Approved ? now() : null, 'started_at' => now(), 'finished_at' => $status === SettlementBatchStatus::Approved ? now() : null, ]); } $eligibleSuffix = bin2hex(random_bytes(4)); $eligibleDraw = createChunkTestDraw($eligibleSuffix); $player = createChunkTestPlayer($eligibleSuffix); $order = createChunkTestOrder($eligibleDraw, $player, $eligibleSuffix, 1); createChunkTestResult($eligibleDraw, 'SG'); createChunkTestTicket($eligibleDraw, $player, $order, 'SG', $eligibleSuffix.'-SG'); app(SettlementOrchestrator::class)->trySettleDraw($eligibleDraw); $eligibleBatch = SettlementBatch::query()->where('draw_id', $eligibleDraw->id)->firstOrFail(); app(SettlementBatchWorkflowService::class)->approveBySystem($eligibleBatch, 'simulate approved backlog'); $result = app(SettlementTickFinalizer::class)->finalizePendingBatches(); expect($result)->toMatchArray(['approved' => 0, 'paid' => 1, 'payout_failed' => 0]) ->and($eligibleBatch->fresh()->status)->toBe(SettlementBatchStatus::Paid->value) ->and($eligibleDraw->fresh()->status)->toBe(DrawStatus::Settled->value) ->and(SettlementBatch::query()->where('draw_id', $blockedDraw->id)->where('status', SettlementBatchStatus::Approved->value)->count())->toBe(1); }); test('manual approval still allows automatic payout when auto approval is disabled', function (): void { $approvedSuffix = bin2hex(random_bytes(4)); $approvedDraw = createChunkTestDraw($approvedSuffix); $approvedPlayer = createChunkTestPlayer($approvedSuffix); $approvedOrder = createChunkTestOrder($approvedDraw, $approvedPlayer, $approvedSuffix, 1); createChunkTestResult($approvedDraw, 'SG'); createChunkTestTicket($approvedDraw, $approvedPlayer, $approvedOrder, 'SG', $approvedSuffix.'-SG'); app(SettlementOrchestrator::class)->trySettleDraw($approvedDraw); $approvedBatch = SettlementBatch::query()->where('draw_id', $approvedDraw->id)->firstOrFail(); app(SettlementBatchWorkflowService::class)->approveBySystem($approvedBatch, 'manual approval simulation'); $pendingSuffix = bin2hex(random_bytes(4)); $pendingDraw = createChunkTestDraw($pendingSuffix); $pendingPlayer = createChunkTestPlayer($pendingSuffix); $pendingOrder = createChunkTestOrder($pendingDraw, $pendingPlayer, $pendingSuffix, 1); createChunkTestResult($pendingDraw, 'MY'); createChunkTestTicket($pendingDraw, $pendingPlayer, $pendingOrder, 'MY', $pendingSuffix.'-MY'); app(SettlementOrchestrator::class)->trySettleDraw($pendingDraw); $pendingBatch = SettlementBatch::query()->where('draw_id', $pendingDraw->id)->firstOrFail(); LotterySettings::put('settlement.auto_approve_on_tick', false, 'settlement', 'test manual approval mode'); LotterySettings::put('settlement.auto_payout_on_tick', true, 'settlement', 'test automatic payout mode'); $result = app(SettlementTickFinalizer::class)->finalizePendingBatches(); expect($result)->toMatchArray(['approved' => 0, 'paid' => 1, 'payout_failed' => 0]) ->and($approvedBatch->fresh()->status)->toBe(SettlementBatchStatus::Paid->value) ->and($approvedDraw->fresh()->status)->toBe(DrawStatus::Settled->value) ->and($pendingBatch->fresh()->status)->toBe(SettlementBatchStatus::PendingReview->value) ->and($pendingDraw->fresh()->status)->toBe(DrawStatus::Settling->value); }); test('a repeatedly failing approved draw does not starve a later healthy draw', function (): void { config()->set('lottery.draw_tick_finalize_limit', 1); $poisonSuffix = bin2hex(random_bytes(4)); $poisonDraw = createChunkTestDraw($poisonSuffix); $poisonPlayer = createChunkTestPlayer($poisonSuffix); $poisonOrder = createChunkTestOrder($poisonDraw, $poisonPlayer, $poisonSuffix, 1); createChunkTestResult($poisonDraw, 'SG'); createChunkTestTicket($poisonDraw, $poisonPlayer, $poisonOrder, 'SG', $poisonSuffix.'-SG'); app(SettlementOrchestrator::class)->trySettleDraw($poisonDraw); $poisonBatch = SettlementBatch::query()->where('draw_id', $poisonDraw->id)->firstOrFail(); app(SettlementBatchWorkflowService::class)->approveBySystem($poisonBatch, 'simulate poison batch'); createChunkTestTicket($poisonDraw, $poisonPlayer, $poisonOrder, 'SG', $poisonSuffix.'-ORPHAN'); $healthySuffix = bin2hex(random_bytes(4)); $healthyDraw = createChunkTestDraw($healthySuffix); $healthyPlayer = createChunkTestPlayer($healthySuffix); $healthyOrder = createChunkTestOrder($healthyDraw, $healthyPlayer, $healthySuffix, 1); createChunkTestResult($healthyDraw, 'MY'); createChunkTestTicket($healthyDraw, $healthyPlayer, $healthyOrder, 'MY', $healthySuffix.'-MY'); app(SettlementOrchestrator::class)->trySettleDraw($healthyDraw); $healthyBatch = SettlementBatch::query()->where('draw_id', $healthyDraw->id)->firstOrFail(); app(SettlementBatchWorkflowService::class)->approveBySystem($healthyBatch, 'simulate healthy backlog'); $first = app(SettlementTickFinalizer::class)->finalizePendingBatches(); expect($first)->toMatchArray(['approved' => 0, 'paid' => 0, 'payout_failed' => 1]) ->and($poisonBatch->fresh()->status)->toBe(SettlementBatchStatus::Approved->value) ->and((int) $poisonBatch->fresh()->auto_payout_attempts)->toBe(1) ->and($healthyBatch->fresh()->status)->toBe(SettlementBatchStatus::Approved->value); $second = app(SettlementTickFinalizer::class)->finalizePendingBatches(); expect($second)->toMatchArray(['approved' => 0, 'paid' => 1, 'payout_failed' => 0]) ->and($healthyBatch->fresh()->status)->toBe(SettlementBatchStatus::Paid->value) ->and($healthyDraw->fresh()->status)->toBe(DrawStatus::Settled->value) ->and($poisonBatch->fresh()->status)->toBe(SettlementBatchStatus::Approved->value); });