create([ 'draw_no' => '20260722-'.random_int(1000, 9999), 'business_date' => '2026-07-22', 'sequence_no' => random_int(1000, 9999), 'status' => $status, 'start_time' => now()->subMinutes(20), 'close_time' => now()->subMinutes(10), 'draw_time' => now()->subMinutes(9), 'cooling_end_time' => now()->addMinutes(10), 'current_result_version' => $withPublishedResult ? 1 : 0, 'settle_version' => 0, 'is_reopened' => false, ]); if ($withPublishedResult) { DrawResultBatch::query()->create([ 'draw_id' => $draw->id, 'provider_code' => 'SG', 'provider_name' => 'Singapore', 'result_version' => 1, 'source_type' => 'rng', 'status' => DrawResultBatchStatus::Published->value, 'confirmed_at' => now(), ]); } return $draw; } function manualSettlementAdminToken(): string { $admin = AdminUser::query()->create([ 'username' => 'manual_settle_'.bin2hex(random_bytes(3)), 'name' => 'Manual Settlement', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; } test('payout manager may skip cooldown and settlement retry is idempotent', function (): void { $draw = createDrawForManualSettlement(); $originalCoolingEnd = $draw->cooling_end_time; $token = manualSettlementAdminToken(); $this->withHeader('Authorization', 'Bearer '.$token) ->postJson("/api/v1/admin/draws/{$draw->id}/settlement/run") ->assertOk() ->assertJsonPath('data.ran', true) ->assertJsonPath('data.cooldown_skipped', true) ->assertJsonPath('data.status', DrawStatus::Settling->value) ->assertJsonPath('data.settle_version', 1); $draw->refresh(); expect($draw->status)->toBe(DrawStatus::Settling->value) ->and($draw->cooling_end_time)->not->toBeNull() ->and($draw->cooling_end_time->lessThan($originalCoolingEnd))->toBeTrue() ->and(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1); $this->withHeader('Authorization', 'Bearer '.$token) ->postJson("/api/v1/admin/draws/{$draw->id}/settlement/run") ->assertOk() ->assertJsonPath('data.ran', true) ->assertJsonPath('data.cooldown_skipped', false) ->assertJsonPath('data.settle_version', 1); expect(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1); }); test('manual settlement rejects missing results and invalid draw states without mutation', function (): void { $token = manualSettlementAdminToken(); $missingResult = createDrawForManualSettlement(withPublishedResult: false); $this->withHeader('Authorization', 'Bearer '.$token) ->postJson("/api/v1/admin/draws/{$missingResult->id}/settlement/run") ->assertStatus(409) ->assertJsonPath('data.reason', 'draw_result_not_published'); expect($missingResult->fresh()->status)->toBe(DrawStatus::Cooldown->value) ->and(SettlementBatch::query()->where('draw_id', $missingResult->id)->exists())->toBeFalse(); $openDraw = createDrawForManualSettlement(status: DrawStatus::Open->value); $this->withHeader('Authorization', 'Bearer '.$token) ->postJson("/api/v1/admin/draws/{$openDraw->id}/settlement/run") ->assertStatus(409) ->assertJsonPath('data.reason', 'draw_not_ready_for_settlement'); expect($openDraw->fresh()->status)->toBe(DrawStatus::Open->value) ->and(SettlementBatch::query()->where('draw_id', $openDraw->id)->exists())->toBeFalse(); }); test('manual settlement resource is restricted to settlement management and audited', function (): void { $resource = collect(AdminAuthorizationRegistry::resources()) ->firstWhere('code', 'admin.draws.settlement.run'); expect($resource)->not->toBeNull() ->and($resource['permission_codes'])->toBe(['settlement.batch.manage']) ->and($resource['is_audit_required'])->toBeTrue(); $draw = createDrawForManualSettlement(); $token = manualSettlementAdminToken(); $before = AuditLog::query()->count(); $this->withHeader('Authorization', 'Bearer '.$token) ->postJson("/api/v1/admin/draws/{$draw->id}/settlement/run") ->assertOk(); expect(AuditLog::query()->count())->toBe($before + 1); }); test('draw finance summary exposes provider and result settlement versions', function (): void { $draw = createDrawForManualSettlement(status: DrawStatus::Settling->value); $result = DrawResultBatch::query()->where('draw_id', $draw->id)->firstOrFail(); SettlementBatch::query()->create([ 'draw_id' => $draw->id, 'result_batch_id' => $result->id, 'settle_version' => 3, 'status' => 'pending_review', 'total_ticket_count' => 0, 'total_win_count' => 0, 'total_payout_amount' => 0, 'total_jackpot_payout_amount' => 0, 'started_at' => now(), 'finished_at' => now(), ]); $token = manualSettlementAdminToken(); $this->withHeader('Authorization', 'Bearer '.$token) ->getJson("/api/v1/admin/draws/{$draw->id}/finance-summary") ->assertOk() ->assertJsonPath('data.settlement_batches.0.provider_code', 'SG') ->assertJsonPath('data.settlement_batches.0.provider_name', 'Singapore') ->assertJsonPath('data.settlement_batches.0.result_version', 1) ->assertJsonPath('data.settlement_batches.0.settle_version', 3); });