402 lines
19 KiB
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);
|
|
});
|