From 7ec8f4c5a2bfd1ef0eeaacf5e4e378708ed9f6b8 Mon Sep 17 00:00:00 2001 From: kang Date: Fri, 26 Jun 2026 15:19:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(funds):=20=E5=8A=A0=E5=9B=BA=E8=BD=AC?= =?UTF-8?q?=E8=B4=A6=E5=86=B2=E6=AD=A3=E3=80=81=E7=BB=93=E7=AE=97=E6=94=B6?= =?UTF-8?q?=E4=BB=98=E5=B9=82=E7=AD=89=E4=B8=8E=E5=9D=8F=E8=B4=A6=E6=A0=B8?= =?UTF-8?q?=E9=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 转入 main_site_timeout 等不可冲正场景直接拒绝,避免假结案 - payment_records / settlement_adjustments 增加 partial unique 索引 - 坏账核销行锁 + meta 幂等回放;补差单记录 result_bill_id - 新增 FundOperationsHardeningTest 覆盖关键路径 --- .../AgentSettlementBadDebtService.php | 63 +++-- .../AgentSettlementBillAdjustmentService.php | 148 ++++++---- .../SettlementPaymentService.php | 60 +++- .../Wallet/LotteryTransferService.php | 22 ++ .../AdminTransferOrderCapabilities.php | 4 +- app/Support/DatabaseUniqueViolation.php | 26 ++ ...den_settlement_and_payment_idempotency.php | 55 ++++ tests/Feature/FundOperationsHardeningTest.php | 261 ++++++++++++++++++ 8 files changed, 535 insertions(+), 104 deletions(-) create mode 100644 app/Support/DatabaseUniqueViolation.php create mode 100644 database/migrations/2026_06_26_150000_harden_settlement_and_payment_idempotency.php create mode 100644 tests/Feature/FundOperationsHardeningTest.php diff --git a/app/Services/AgentSettlement/AgentSettlementBadDebtService.php b/app/Services/AgentSettlement/AgentSettlementBadDebtService.php index 4cf48c0..cb0fbcd 100644 --- a/app/Services/AgentSettlement/AgentSettlementBadDebtService.php +++ b/app/Services/AgentSettlement/AgentSettlementBadDebtService.php @@ -17,37 +17,44 @@ final class AgentSettlementBadDebtService public function writeOff(int $originalBillId, ?string $reason, int $adminUserId): int { - $original = DB::table('settlement_bills')->where('id', $originalBillId)->first(); - if ($original === null) { - throw new \InvalidArgumentException('bill_not_found'); - } + return (int) DB::transaction(function () use ($originalBillId, $reason, $adminUserId): int { + /** @var object|null $original */ + $original = DB::table('settlement_bills')->where('id', $originalBillId)->lockForUpdate()->first(); + if ($original === null) { + throw new \InvalidArgumentException('bill_not_found'); + } - if ($this->periodCompletion->isPeriodReadOnly((int) $original->settlement_period_id)) { - throw ValidationException::withMessages([ - 'period' => ['completed'], - ]); - } + $meta = $this->decodeMeta($original->meta_json); + $existingArchiveId = (int) ($meta['bad_debt_bill_id'] ?? 0); + if ($existingArchiveId > 0) { + return $existingArchiveId; + } - if (! in_array((string) $original->status, ['confirmed', 'partial_paid', 'overdue'], true)) { - throw ValidationException::withMessages([ - 'bill' => ['not_eligible'], - ]); - } + if ($this->periodCompletion->isPeriodReadOnly((int) $original->settlement_period_id)) { + throw ValidationException::withMessages([ + 'period' => ['completed'], + ]); + } - $unpaid = (int) $original->unpaid_amount; - if ($unpaid <= 0) { - throw ValidationException::withMessages([ - 'bill' => ['no_unpaid'], - ]); - } + if (! in_array((string) $original->status, ['confirmed', 'partial_paid', 'overdue'], true)) { + throw ValidationException::withMessages([ + 'bill' => ['not_eligible'], + ]); + } - if (in_array((string) $original->bill_type, ['adjustment', 'reversal', 'bad_debt'], true)) { - throw ValidationException::withMessages([ - 'bill' => ['not_eligible'], - ]); - } + $unpaid = (int) $original->unpaid_amount; + if ($unpaid <= 0) { + throw ValidationException::withMessages([ + 'bill' => ['no_unpaid'], + ]); + } + + if (in_array((string) $original->bill_type, ['adjustment', 'reversal', 'bad_debt'], true)) { + throw ValidationException::withMessages([ + 'bill' => ['not_eligible'], + ]); + } - return (int) DB::transaction(function () use ($original, $originalBillId, $unpaid, $reason, $adminUserId): int { $now = now(); $periodId = (int) $original->settlement_period_id; @@ -93,7 +100,7 @@ final class AgentSettlementBadDebtService 'unpaid_amount' => 0, 'status' => 'settled', 'meta_json' => json_encode(array_merge( - $this->decodeMeta($original->meta_json), + $meta, [ 'bad_debt_bill_id' => $archiveBillId, 'written_off_amount' => $unpaid, @@ -133,4 +140,4 @@ final class AgentSettlementBadDebtService return is_array($decoded) ? $decoded : []; } -} +} \ No newline at end of file diff --git a/app/Services/AgentSettlement/AgentSettlementBillAdjustmentService.php b/app/Services/AgentSettlement/AgentSettlementBillAdjustmentService.php index 61b48fa..bdb99b5 100644 --- a/app/Services/AgentSettlement/AgentSettlementBillAdjustmentService.php +++ b/app/Services/AgentSettlement/AgentSettlementBillAdjustmentService.php @@ -2,6 +2,8 @@ namespace App\Services\AgentSettlement; +use App\Support\DatabaseUniqueViolation; +use Illuminate\Database\QueryException; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -47,15 +49,9 @@ final class AgentSettlementBillAdjustmentService } if ($idempotencyKey !== null && $idempotencyKey !== '') { - $existing = DB::table('settlement_adjustments') - ->where('original_bill_id', $originalBillId) - ->where('idempotency_key', $idempotencyKey) - ->first(); - if ($existing !== null) { - return (int) DB::table('settlement_bills') - ->where('reversed_bill_id', $originalBillId) - ->where('bill_type', 'adjustment') - ->value('id'); + $existingBillId = $this->findAdjustmentBillByIdempotency($originalBillId, $idempotencyKey); + if ($existingBillId !== null) { + return $existingBillId; } } @@ -63,59 +59,89 @@ final class AgentSettlementBillAdjustmentService ? $adjustmentType : 'adjustment'; - return (int) DB::transaction(function () use ($original, $amount, $type, $reason, $adminUserId, $idempotencyKey): int { - if ($idempotencyKey !== null && $idempotencyKey !== '') { - $existing = DB::table('settlement_adjustments') - ->where('original_bill_id', (int) $original->id) - ->where('idempotency_key', $idempotencyKey) - ->lockForUpdate() - ->first(); - if ($existing !== null) { - return (int) DB::table('settlement_bills') - ->where('reversed_bill_id', (int) $original->id) - ->where('bill_type', 'adjustment') - ->value('id'); + try { + return (int) DB::transaction(function () use ($original, $amount, $type, $reason, $adminUserId, $idempotencyKey): int { + return $this->insertAdjustmentBill($original, $amount, $type, $reason, $adminUserId, $idempotencyKey); + }); + } catch (QueryException $e) { + if ( + $idempotencyKey !== null + && $idempotencyKey !== '' + && DatabaseUniqueViolation::matches($e) + ) { + $existingBillId = $this->findAdjustmentBillByIdempotency((int) $original->id, $idempotencyKey); + if ($existingBillId !== null) { + return $existingBillId; } } - $now = now(); - $newBillId = (int) DB::table('settlement_bills')->insertGetId([ - 'settlement_period_id' => (int) $original->settlement_period_id, - 'bill_type' => $type, - 'owner_type' => (string) $original->owner_type, - 'owner_id' => (int) $original->owner_id, - 'counterparty_type' => (string) $original->counterparty_type, - 'counterparty_id' => (int) $original->counterparty_id, - 'gross_win_loss' => 0, - 'rebate_amount' => 0, - 'adjustment_amount' => $amount, - 'platform_rounding_adjustment' => 0, - 'net_amount' => $amount, - 'paid_amount' => 0, - 'unpaid_amount' => abs($amount), - 'status' => 'pending_confirm', - 'reversed_bill_id' => (int) $original->id, - 'meta_json' => json_encode([ - 'original_bill_id' => (int) $original->id, - 'original_net_amount' => (int) $original->net_amount, - ]), - 'created_at' => $now, - 'updated_at' => $now, - ]); - - DB::table('settlement_adjustments')->insert([ - 'settlement_period_id' => (int) $original->settlement_period_id, - 'original_bill_id' => (int) $original->id, - 'adjustment_type' => $type, - 'amount' => $amount, - 'reason' => $reason, - 'idempotency_key' => $idempotencyKey, - 'created_by' => $adminUserId > 0 ? $adminUserId : null, - 'created_at' => $now, - 'updated_at' => $now, - ]); - - return $newBillId; - }); + throw $e; + } } -} + + private function insertAdjustmentBill( + object $original, + int $amount, + string $type, + ?string $reason, + int $adminUserId, + ?string $idempotencyKey, + ): int { + if ($idempotencyKey !== null && $idempotencyKey !== '') { + $existingBillId = $this->findAdjustmentBillByIdempotency((int) $original->id, $idempotencyKey); + if ($existingBillId !== null) { + return $existingBillId; + } + } + + $now = now(); + $newBillId = (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => (int) $original->settlement_period_id, + 'bill_type' => $type, + 'owner_type' => (string) $original->owner_type, + 'owner_id' => (int) $original->owner_id, + 'counterparty_type' => (string) $original->counterparty_type, + 'counterparty_id' => (int) $original->counterparty_id, + 'gross_win_loss' => 0, + 'rebate_amount' => 0, + 'adjustment_amount' => $amount, + 'platform_rounding_adjustment' => 0, + 'net_amount' => $amount, + 'paid_amount' => 0, + 'unpaid_amount' => abs($amount), + 'status' => 'pending_confirm', + 'reversed_bill_id' => (int) $original->id, + 'meta_json' => json_encode([ + 'original_bill_id' => (int) $original->id, + 'original_net_amount' => (int) $original->net_amount, + ]), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + DB::table('settlement_adjustments')->insert([ + 'settlement_period_id' => (int) $original->settlement_period_id, + 'original_bill_id' => (int) $original->id, + 'result_bill_id' => $newBillId, + 'adjustment_type' => $type, + 'amount' => $amount, + 'reason' => $reason, + 'idempotency_key' => $idempotencyKey, + 'created_by' => $adminUserId > 0 ? $adminUserId : null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + return $newBillId; + } + + private function findAdjustmentBillByIdempotency(int $originalBillId, string $idempotencyKey): ?int + { + $resultBillId = DB::table('settlement_adjustments') + ->where('original_bill_id', $originalBillId) + ->where('idempotency_key', $idempotencyKey) + ->value('result_bill_id'); + + return $resultBillId !== null ? (int) $resultBillId : null; + } +} \ No newline at end of file diff --git a/app/Services/AgentSettlement/SettlementPaymentService.php b/app/Services/AgentSettlement/SettlementPaymentService.php index 54ca48e..12990b7 100644 --- a/app/Services/AgentSettlement/SettlementPaymentService.php +++ b/app/Services/AgentSettlement/SettlementPaymentService.php @@ -4,6 +4,8 @@ namespace App\Services\AgentSettlement; use App\Models\Player; use App\Services\Player\PlayerCreditService; +use App\Support\DatabaseUniqueViolation; +use Illuminate\Database\QueryException; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -38,19 +40,46 @@ final class SettlementPaymentService } } - DB::transaction(function () use ($billId, $amount, $adminUserId, $meta, $idempotencyKey): void { - if ($idempotencyKey !== null && $idempotencyKey !== '') { - $existing = DB::table('payment_records') - ->where('settlement_bill_id', $billId) - ->where('idempotency_key', $idempotencyKey) - ->lockForUpdate() - ->first(); - if ($existing !== null) { - return; - } + try { + DB::transaction(function () use ($billId, $amount, $adminUserId, $meta, $idempotencyKey): void { + $this->insertPaymentRecord($billId, $amount, $adminUserId, $meta, $idempotencyKey); + }); + } catch (QueryException $e) { + if ( + $idempotencyKey !== null + && $idempotencyKey !== '' + && DatabaseUniqueViolation::matches($e) + && $this->paymentIdempotencyExists($billId, $idempotencyKey) + ) { + return; } - $bill = DB::table('settlement_bills')->where('id', $billId)->lockForUpdate()->first(); + throw $e; + } + } + + /** + * @param array{method?: string|null, proof?: string|null, remark?: string|null, idempotency_key?: string|null} $meta + */ + private function insertPaymentRecord( + int $billId, + int $amount, + int $adminUserId, + array $meta, + ?string $idempotencyKey, + ): void { + if ($idempotencyKey !== null && $idempotencyKey !== '') { + $existing = DB::table('payment_records') + ->where('settlement_bill_id', $billId) + ->where('idempotency_key', $idempotencyKey) + ->lockForUpdate() + ->first(); + if ($existing !== null) { + return; + } + } + + $bill = DB::table('settlement_bills')->where('id', $billId)->lockForUpdate()->first(); if ($bill === null) { throw new \InvalidArgumentException('bill_not_found'); } @@ -122,7 +151,14 @@ final class SettlementPaymentService } $this->periodCompletion->syncIfReady((int) $bill->settlement_period_id); - }); + } + + private function paymentIdempotencyExists(int $billId, string $idempotencyKey): bool + { + return DB::table('payment_records') + ->where('settlement_bill_id', $billId) + ->where('idempotency_key', $idempotencyKey) + ->exists(); } /** diff --git a/app/Services/Wallet/LotteryTransferService.php b/app/Services/Wallet/LotteryTransferService.php index cd7f20c..28d0b11 100644 --- a/app/Services/Wallet/LotteryTransferService.php +++ b/app/Services/Wallet/LotteryTransferService.php @@ -384,6 +384,14 @@ final class LotteryTransferService ); } + if (! $this->isEligibleForReverse($locked)) { + throw new WalletOperationException( + 'reverse_not_eligible', + ErrorCode::WalletExternalRejected->value, + 422, + ); + } + if ($locked->direction === self::DIR_OUT) { $idempotentKey = 'reversal:'.$locked->transfer_no; $alreadyCredited = WalletTxn::query() @@ -566,6 +574,20 @@ final class LotteryTransferService && $this->isEligibleForCompleteCredit($order); } + /** 后台冲正:转出 pending_reconcile 或转入 lottery_credit_failed 且主站已扣款。 */ + public function isEligibleForReverse(TransferOrder $order): bool + { + if ($order->status !== self::ST_PENDING_RECONCILE) { + return false; + } + + if ($order->direction === self::DIR_OUT) { + return true; + } + + return $this->isEligibleForTransferInReverse($order); + } + public function isEligibleForManualProcess(TransferOrder $order): bool { if (! in_array($order->status, [self::ST_PROCESSING, self::ST_FAILED, self::ST_PENDING_RECONCILE], true)) { diff --git a/app/Support/AdminTransferOrderCapabilities.php b/app/Support/AdminTransferOrderCapabilities.php index 17478dc..9114fe4 100644 --- a/app/Support/AdminTransferOrderCapabilities.php +++ b/app/Support/AdminTransferOrderCapabilities.php @@ -32,9 +32,7 @@ final class AdminTransferOrderCapabilities $canWrite = self::canWriteWallet($admin); return [ - 'can_reverse' => $canWrite - && $order->status === 'pending_reconcile' - && ($order->direction === 'out' || $transferService->isEligibleForTransferInReverse($order)), + 'can_reverse' => $canWrite && $transferService->isEligibleForReverse($order), 'can_complete_credit' => $canWrite && $order->direction === 'in' && $order->status === 'pending_reconcile' diff --git a/app/Support/DatabaseUniqueViolation.php b/app/Support/DatabaseUniqueViolation.php new file mode 100644 index 0000000..559b8c1 --- /dev/null +++ b/app/Support/DatabaseUniqueViolation.php @@ -0,0 +1,26 @@ +getCode(); + $errorInfo = $e->errorInfo ?? null; + $driverCode = is_array($errorInfo) ? (int) ($errorInfo[1] ?? 0) : 0; + + if ($sqlState === '23505' || $sqlState === '23000' || $sqlState === '19') { + return true; + } + + if ($driverCode === 19 || $driverCode === 1062) { + return true; + } + + return stripos($e->getMessage(), 'unique') !== false; + } +} \ No newline at end of file diff --git a/database/migrations/2026_06_26_150000_harden_settlement_and_payment_idempotency.php b/database/migrations/2026_06_26_150000_harden_settlement_and_payment_idempotency.php new file mode 100644 index 0000000..36c6087 --- /dev/null +++ b/database/migrations/2026_06_26_150000_harden_settlement_and_payment_idempotency.php @@ -0,0 +1,55 @@ +foreignId('result_bill_id') + ->nullable() + ->after('original_bill_id') + ->constrained('settlement_bills') + ->nullOnDelete(); + }); + + Schema::table('payment_records', function (Blueprint $table): void { + $table->dropIndex(['settlement_bill_id', 'idempotency_key']); + }); + + Schema::table('settlement_adjustments', function (Blueprint $table): void { + $table->dropIndex(['original_bill_id', 'idempotency_key']); + }); + + DB::statement( + 'CREATE UNIQUE INDEX payment_records_bill_idempotency_unique ' + .'ON payment_records (settlement_bill_id, idempotency_key) ' + ."WHERE idempotency_key IS NOT NULL AND idempotency_key <> ''", + ); + + DB::statement( + 'CREATE UNIQUE INDEX settlement_adjustments_bill_idempotency_unique ' + .'ON settlement_adjustments (original_bill_id, idempotency_key) ' + ."WHERE idempotency_key IS NOT NULL AND idempotency_key <> ''", + ); + } + + public function down(): void + { + DB::statement('DROP INDEX IF EXISTS payment_records_bill_idempotency_unique'); + DB::statement('DROP INDEX IF EXISTS settlement_adjustments_bill_idempotency_unique'); + + Schema::table('payment_records', function (Blueprint $table): void { + $table->index(['settlement_bill_id', 'idempotency_key']); + }); + + Schema::table('settlement_adjustments', function (Blueprint $table): void { + $table->index(['original_bill_id', 'idempotency_key']); + $table->dropConstrainedForeignId('result_bill_id'); + }); + } +}; \ No newline at end of file diff --git a/tests/Feature/FundOperationsHardeningTest.php b/tests/Feature/FundOperationsHardeningTest.php new file mode 100644 index 0000000..5adafd0 --- /dev/null +++ b/tests/Feature/FundOperationsHardeningTest.php @@ -0,0 +1,261 @@ +create([ + 'username' => 'fund_ops_admin', + 'name' => 'Fund Ops', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + + return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + +test('admin cannot reverse transfer in pending reconcile main site timeout order', function (): void { + $token = fundOpsAdminToken(); + + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'reverse-in-timeout', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + TransferOrder::query()->create([ + 'transfer_no' => 'TI_reverse_in_timeout', + 'player_id' => $player->id, + 'direction' => 'in', + 'currency_code' => 'NPR', + 'amount' => 500, + 'idempotent_key' => 'reverse-in-timeout-key', + 'status' => 'pending_reconcile', + 'external_request_payload' => null, + 'external_response_payload' => null, + 'external_ref_no' => null, + 'fail_reason' => 'main_site_timeout', + 'finished_at' => null, + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/wallet/transfer-orders/TI_reverse_in_timeout/reverse') + ->assertStatus(422); + + expect(TransferOrder::query()->where('transfer_no', 'TI_reverse_in_timeout')->value('status')) + ->toBe('pending_reconcile'); +}); + +test('transfer in timeout order list hides reverse action', function (): void { + $token = fundOpsAdminToken(); + + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'list-in-timeout', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + TransferOrder::query()->create([ + 'transfer_no' => 'TI_list_in_timeout', + 'player_id' => $player->id, + 'direction' => 'in', + 'currency_code' => 'NPR', + 'amount' => 500, + 'idempotent_key' => 'list-in-timeout-key', + 'status' => 'pending_reconcile', + 'external_request_payload' => null, + 'external_response_payload' => null, + 'external_ref_no' => null, + 'fail_reason' => 'main_site_timeout', + 'finished_at' => null, + ]); + + $items = $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/wallet/transfer-orders?player_id='.$player->id) + ->assertOk() + ->json('data.items'); + + $item = collect($items)->firstWhere('transfer_no', 'TI_list_in_timeout'); + expect($item)->not->toBeNull() + ->and($item['can_reverse'])->toBeFalse(); +}); + +test('settlement payment idempotency key prevents duplicate records', function (): void { + $site = DB::table('admin_sites')->where('is_default', true)->first(); + $agentId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id'); + + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => (int) $site->id, + 'period_start' => now()->subDays(7), + 'period_end' => now(), + 'status' => 'closed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $billId = (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'player', + 'owner_type' => 'player', + 'owner_id' => 1, + 'counterparty_type' => 'agent', + 'counterparty_id' => $agentId, + 'gross_win_loss' => 1000, + 'rebate_amount' => 0, + 'adjustment_amount' => 0, + 'net_amount' => 1000, + 'paid_amount' => 0, + 'unpaid_amount' => 1000, + 'status' => 'confirmed', + 'confirmed_at' => now(), + 'locked_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'payment_idem_admin', + 'name' => 'Payment Idem', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + $service = app(SettlementPaymentService::class); + $meta = ['idempotency_key' => 'pay-idem-key-1']; + + $service->recordPayment($billId, 400, (int) $admin->id, $meta); + $service->recordPayment($billId, 400, (int) $admin->id, $meta); + + expect(DB::table('payment_records')->where('settlement_bill_id', $billId)->count())->toBe(1); + + $bill = DB::table('settlement_bills')->where('id', $billId)->first(); + expect((int) $bill->paid_amount)->toBe(400) + ->and((int) $bill->unpaid_amount)->toBe(600); +}); + +test('bad debt write off is idempotent when retried', function (): void { + $site = DB::table('admin_sites')->where('is_default', true)->first(); + $agentId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id'); + + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => (int) $site->id, + 'period_start' => now()->subDays(7), + 'period_end' => now(), + 'status' => 'closed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $player = Player::query()->create([ + 'site_code' => (string) $site->code, + 'agent_node_id' => $agentId, + 'site_player_id' => 'bd-idem', + 'auth_source' => 'lottery_native', + 'funding_mode' => 'credit', + 'username' => 'bdidem', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $billId = (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'player', + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'counterparty_type' => 'agent', + 'counterparty_id' => $agentId, + 'gross_win_loss' => 8000, + 'rebate_amount' => 0, + 'adjustment_amount' => 0, + 'net_amount' => 8000, + 'paid_amount' => 0, + 'unpaid_amount' => 8000, + 'status' => 'overdue', + 'confirmed_at' => now(), + 'locked_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'bad_debt_idem_admin', + 'name' => 'Bad Debt Idem', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + $service = app(AgentSettlementBadDebtService::class); + $first = $service->writeOff($billId, 'uncollectible', (int) $admin->id); + $second = $service->writeOff($billId, 'uncollectible', (int) $admin->id); + + expect($second)->toBe($first) + ->and(DB::table('settlement_bills')->where('bill_type', 'bad_debt')->count())->toBe(1) + ->and(DB::table('settlement_adjustments')->where('original_bill_id', $billId)->count())->toBe(1); +}); + +test('out pending reconcile reverse still credits lottery wallet once', function (): void { + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'reverse-out-ok', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $wallet = PlayerWallet::query()->create([ + 'player_id' => $player->id, + 'wallet_type' => 'lottery', + 'currency_code' => 'NPR', + 'balance' => 1_000, + 'frozen_balance' => 0, + 'status' => 0, + 'version' => 0, + ]); + + $order = TransferOrder::query()->create([ + 'transfer_no' => 'TO_reverse_out_ok', + 'player_id' => $player->id, + 'direction' => 'out', + 'currency_code' => 'NPR', + 'amount' => 400, + 'idempotent_key' => 'reverse-out-ok-key', + 'status' => 'pending_reconcile', + 'external_request_payload' => null, + 'external_response_payload' => null, + 'external_ref_no' => null, + 'fail_reason' => 'main_site_timeout', + 'finished_at' => null, + ]); + + $service = app(LotteryTransferService::class); + $service->reconcileTransferOrder($order, 'reverse', 'ok'); + + $wallet->refresh(); + $order->refresh(); + + expect((int) $wallet->balance)->toBe(1_400) + ->and($order->status)->toBe('reversed'); +}); \ No newline at end of file