Files
lotteryLaravel/tests/Feature/SettlementChunkAndProviderPayoutTest.php

402 lines
19 KiB
PHP

<?php
use App\Models\Draw;
use App\Models\Player;
use App\Models\AdminUser;
use App\Models\TicketItem;
use App\Lottery\DrawStatus;
use App\Models\TicketOrder;
use App\Models\PlayerWallet;
use App\Models\DrawResultItem;
use App\Models\DrawResultBatch;
use App\Models\SettlementBatch;
use App\Models\TicketCombination;
use App\Services\LotterySettings;
use Illuminate\Support\Facades\Hash;
use App\Lottery\DrawResultBatchStatus;
use App\Lottery\SettlementBatchStatus;
use App\Services\Draw\DrawPrizeLayout;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Services\Settlement\SettlementOrchestrator;
use App\Services\Settlement\SettlementTickFinalizer;
use App\Services\Settlement\SettlementBatchWorkflowService;
uses(RefreshDatabase::class);
function createChunkTestDraw(string $suffix): Draw
{
return Draw::query()->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);
});