fix(core): harden settlement and wallet integration

This commit is contained in:
wchino
2026-07-22 01:40:37 +08:00
parent 150c3e7ebd
commit 35f6e46958
54 changed files with 2564 additions and 359 deletions

View File

@@ -1,23 +1,24 @@
<?php
use App\Models\Draw;
use App\Models\Player;
use App\Models\AdminRole;
use App\Models\AdminUser;
use App\Models\Draw;
use App\Models\DrawResultBatch;
use App\Models\Player;
use App\Models\SettlementBatch;
use App\Models\TicketItem;
use App\Lottery\DrawResultBatchStatus;
use App\Lottery\DrawStatus;
use App\Lottery\SettlementBatchStatus;
use App\Services\AgentSettlement\AgentGameSettlementRecorder;
use App\Services\AgentSettlement\GameSettlementReversalService;
use App\Services\Settlement\SettlementBatchWorkflowService;
use App\Services\Settlement\SettlementTickFinalizer;
use App\Models\DrawResultBatch;
use App\Models\SettlementBatch;
use App\Support\PlayerFundingMode;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use App\Lottery\DrawResultBatchStatus;
use App\Lottery\SettlementBatchStatus;
use App\Services\Player\PlayerCreditService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Services\Settlement\SettlementTickFinalizer;
use App\Services\Settlement\SettlementBatchWorkflowService;
use App\Services\AgentSettlement\AgentGameSettlementRecorder;
use App\Services\AgentSettlement\GameSettlementReversalService;
uses(RefreshDatabase::class);
@@ -132,15 +133,63 @@ test('agent recorder allows re-settlement after share ledger reversal', function
$item->setRelation('player', $player);
$recorder = app(AgentGameSettlementRecorder::class);
$recorder->recordForTicketItem($item, 0, 'settled_lose');
expect(countActiveShareLedgerRows($item->id))->toBe(1);
DB::table('player_credit_accounts')->where('player_id', $player->id)->update(['used_credit' => 1]);
app(GameSettlementReversalService::class)->reverseTicketItem($item->fresh());
expect(countActiveShareLedgerRows($item->id))->toBe(0);
$recorder->recordForTicketItem($item, 0, 'settled_lose', 1);
expect(countActiveShareLedgerRows($item->id))->toBe(1)
->and((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(1);
app(GameSettlementReversalService::class)->reverseTicketItem($item->fresh(), 1);
expect(countActiveShareLedgerRows($item->id))->toBe(0)
->and((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(1);
$item->forceFill(['agent_settled_at' => null])->save();
$recorder->recordForTicketItem($item->fresh(), 0, 'settled_lose');
expect(countActiveShareLedgerRows($item->id))->toBe(1);
$recorder->recordForTicketItem($item->fresh(), 0, 'settled_lose', 2);
expect(countActiveShareLedgerRows($item->id))->toBe(1)
->and((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(1)
->and(DB::table('credit_ledger')
->where('ref_type', 'ticket_item')
->where('ref_id', $item->id)
->where('reason', 'game_settlement_loss')
->count())->toBe(2);
app(GameSettlementReversalService::class)->reverseTicketItem($item->fresh(), 2);
expect(countActiveShareLedgerRows($item->id))->toBe(0)
->and((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(1)
->and(DB::table('credit_ledger')
->where('ref_type', 'ticket_item')
->where('ref_id', $item->id)
->where('reason', 'game_settlement_reversal')
->count())->toBe(2)
->and(DB::table('credit_ledger')
->where('ref_type', 'ticket_item')
->where('ref_id', $item->id)
->where('reason', 'bet_hold_restore')
->count())->toBe(2);
});
test('credit win re-settlement applies the same direction again for a new version', function (): void {
$player = creditAgentPlayerForFixtures('win-re-settle');
DB::table('player_credit_accounts')->where('player_id', $player->id)->update(['used_credit' => 10]);
$credit = app(PlayerCreditService::class);
$credit->releaseBetHold($player, 100, 99001, 1);
$credit->applySettledWin($player, 300, 99001, 1);
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(6);
$credit->reverseGameSettlement($player, -300, 99001, 1);
$credit->restoreBetHoldAfterSettlementReversal($player, 100, 99001, 1);
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(10);
$credit->releaseBetHold($player, 100, 99001, 2);
$credit->applySettledWin($player, 300, 99001, 2);
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(6)
->and(DB::table('credit_ledger')
->where('ref_type', 'ticket_item')
->where('ref_id', 99001)
->where('reason', 'game_settlement_win')
->count())->toBe(2);
});
test('credit player payout skips lottery wallet settle_payout txn', function (): void {
@@ -279,7 +328,7 @@ test('settlement report show rejects inaccessible settlement period', function (
->assertForbidden();
});
test('settlement tick finalizer marks approved batch failed when payout throws', function (): void {
test('settlement tick finalizer keeps approved batch retryable when payout throws', function (): void {
$player = creditAgentPlayerForFixtures('tick-fail');
$draw = Draw::query()->create([
'draw_no' => 'FIX-FAIL-DRAW',
@@ -363,7 +412,7 @@ test('settlement tick finalizer marks approved batch failed when payout throws',
'updated_at' => now(),
]);
DB::table('ticket_items')->insert([
$orphanItemId = (int) DB::table('ticket_items')->insertGetId([
'ticket_no' => 'T-FIX-FAIL-ORPHAN',
'order_id' => $orderId,
'player_id' => $player->id,
@@ -394,6 +443,13 @@ test('settlement tick finalizer marks approved batch failed when payout throws',
$result = app(SettlementTickFinalizer::class)->finalizePendingBatches();
expect($result['payout_failed'])->toBe(1)
->and($batch->fresh()->status)->toBe(SettlementBatchStatus::Failed->value)
->and($batch->fresh()->status)->toBe(SettlementBatchStatus::Approved->value)
->and($batch->fresh()->review_remark)->toContain('auto_payout_failed');
});
DB::table('ticket_items')->where('id', $orphanItemId)->delete();
$retry = app(SettlementTickFinalizer::class)->finalizePendingBatches();
expect($retry)->toMatchArray(['approved' => 0, 'paid' => 1, 'payout_failed' => 0])
->and($batch->fresh()->status)->toBe(SettlementBatchStatus::Paid->value)
->and($draw->fresh()->status)->toBe(DrawStatus::Settled->value);
});