From 3c5794189500dbbd5ce3059f98b1612825647436 Mon Sep 17 00:00:00 2001 From: kang Date: Tue, 23 Jun 2026 16:49:57 +0800 Subject: [PATCH] feat: add idempotency support for settlement adjustments and payments, fix rebate handling - Added idempotency_key parameter to settlement bill adjustment and payment endpoints to prevent duplicate operations - Fixed player rebate profile updates to preserve existing values when only one field is modified - Moved wallet player validation earlier in AdminPlayerStoreController to prevent unnecessary processing - Enhanced extra rebate calculation to include 'in_bill' and 'settled' statuses in AgentP --- ...gentSettlementBillAdjustmentController.php | 1 + .../AgentSettlementBillPaymentController.php | 1 + .../Player/AdminPlayerStoreController.php | 15 +- .../Player/AdminPlayerUpdateController.php | 24 ++- .../AdminSettlementBillAdjustmentRequest.php | 1 + .../AdminSettlementBillPaymentRequest.php | 1 + .../AgentSettlement/AgentPeriodAggregator.php | 2 +- .../AgentSettlementBillAdjustmentService.php | 31 ++- .../AgentSettlementPeriodCloseService.php | 84 ++++---- .../CreditLedgerBetFlowPresenter.php | 24 ++- .../PeriodCloseRebateService.php | 9 +- .../SettlementCenterLedgerService.php | 25 ++- .../SettlementPaymentService.php | 28 ++- app/Services/Player/PlayerCreditService.php | 142 ++++++++----- .../Ticket/TicketPlacementService.php | 1 + ...mpotency_key_to_payment_and_adjustment.php | 34 ++++ tests/Feature/AdminCreditLedgerIndexTest.php | 116 +++++++++++ tests/Feature/AdminPlayerManageApiTest.php | 86 ++++++++ .../AgentSettlementPeriodCloseP0FixesTest.php | 186 ++++++++++++++++++ .../CreditBetHoldReverseIdempotencyTest.php | 50 +++++ 20 files changed, 734 insertions(+), 127 deletions(-) create mode 100644 database/migrations/2026_06_23_130000_add_idempotency_key_to_payment_and_adjustment.php create mode 100644 tests/Feature/CreditBetHoldReverseIdempotencyTest.php diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillAdjustmentController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillAdjustmentController.php index a318a77..a6b0e8f 100644 --- a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillAdjustmentController.php +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillAdjustmentController.php @@ -33,6 +33,7 @@ final class AgentSettlementBillAdjustmentController extends Controller (string) ($request->validated('adjustment_type') ?? 'adjustment'), $request->validated('reason'), (int) $admin->id, + $request->validated('idempotency_key'), ); $after = DB::table('settlement_bills')->where('id', $newBillId)->first(); diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillPaymentController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillPaymentController.php index 0842861..9ad4df7 100644 --- a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillPaymentController.php +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillPaymentController.php @@ -35,6 +35,7 @@ final class AgentSettlementBillPaymentController extends Controller 'method' => $validated['method'] ?? null, 'proof' => $validated['proof'] ?? null, 'remark' => $validated['remark'] ?? null, + 'idempotency_key' => $validated['idempotency_key'] ?? null, ], ); diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php index 47a6809..d13ea00 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerStoreController.php @@ -106,6 +106,13 @@ final class AdminPlayerStoreController extends Controller } $agent = AgentNode::query()->findOrFail($agentNodeId); + + if (! $isNative && ($request->has('credit_limit') || $request->has('rebate_rate') || $request->has('extra_rebate_rate'))) { + throw ValidationException::withMessages([ + 'credit_limit' => ['wallet_player_prohibited'], + ]); + } + $rebateRate = 0.0; $extraRebateRate = 0.0; if ($request->has('rebate_rate') || $request->has('extra_rebate_rate')) { @@ -118,12 +125,6 @@ final class AdminPlayerStoreController extends Controller ); } - if (! $isNative && ($request->has('credit_limit') || $request->has('rebate_rate') || $request->has('extra_rebate_rate'))) { - throw ValidationException::withMessages([ - 'credit_limit' => ['wallet_player_prohibited'], - ]); - } - $creditLimit = $request->has('credit_limit') ? (int) $request->input('credit_limit', 0) : ($isNative ? 0 : 0); @@ -164,7 +165,7 @@ final class AdminPlayerStoreController extends Controller $agentProfileService->refreshAllocatedCredit($agent); - if ($request->has('rebate_rate')) { + if ($request->has('rebate_rate') || $request->has('extra_rebate_rate')) { DB::table('player_rebate_profiles')->insert([ 'player_id' => $player->id, 'game_type' => '*', diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerUpdateController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerUpdateController.php index 0a57113..f91526b 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerUpdateController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerUpdateController.php @@ -55,11 +55,21 @@ final class AdminPlayerUpdateController extends Controller ]); } - $rebateRate = 0.0; - $extraRebateRate = 0.0; + $rebateRate = null; + $extraRebateRate = null; if ($agent !== null && ($request->has('rebate_rate') || $request->has('extra_rebate_rate'))) { - $rebateRate = (float) $request->input('rebate_rate', 0) / 100; - $extraRebateRate = (float) $request->input('extra_rebate_rate', 0) / 100; + $existing = DB::table('player_rebate_profiles') + ->where('player_id', $player->id) + ->where('game_type', '*') + ->first(); + + $rebateRate = $request->has('rebate_rate') + ? (float) $request->input('rebate_rate', 0) / 100 + : (float) ($existing->rebate_rate ?? 0); + $extraRebateRate = $request->has('extra_rebate_rate') + ? (float) $request->input('extra_rebate_rate', 0) / 100 + : (float) ($existing->extra_rebate_rate ?? 0); + $rebateLimitValidator->assertPlayerRebateWithinAgent( $agent, $rebateRate, @@ -78,13 +88,13 @@ final class AdminPlayerUpdateController extends Controller unset($data['credit_limit']); } - if ($request->has('rebate_rate') || $request->has('extra_rebate_rate')) { + if ($rebateRate !== null || $extraRebateRate !== null) { DB::table('player_rebate_profiles')->updateOrInsert( ['player_id' => $player->id, 'game_type' => '*'], [ 'inherit_from_agent' => false, - 'rebate_rate' => $rebateRate, - 'extra_rebate_rate' => $extraRebateRate, + 'rebate_rate' => $rebateRate ?? 0, + 'extra_rebate_rate' => $extraRebateRate ?? 0, 'updated_at' => now(), 'created_at' => now(), ], diff --git a/app/Http/Requests/Admin/AdminSettlementBillAdjustmentRequest.php b/app/Http/Requests/Admin/AdminSettlementBillAdjustmentRequest.php index 04bc7e1..f4dd8b1 100644 --- a/app/Http/Requests/Admin/AdminSettlementBillAdjustmentRequest.php +++ b/app/Http/Requests/Admin/AdminSettlementBillAdjustmentRequest.php @@ -17,6 +17,7 @@ final class AdminSettlementBillAdjustmentRequest extends ApiFormRequest 'amount' => ['required', 'integer', 'not_in:0'], 'adjustment_type' => ['sometimes', 'string', 'in:adjustment,reversal'], 'reason' => ['sometimes', 'nullable', 'string', 'max:255'], + 'idempotency_key' => ['sometimes', 'nullable', 'string', 'max:64'], ]; } } diff --git a/app/Http/Requests/Admin/AdminSettlementBillPaymentRequest.php b/app/Http/Requests/Admin/AdminSettlementBillPaymentRequest.php index 6c7d06b..01018c1 100644 --- a/app/Http/Requests/Admin/AdminSettlementBillPaymentRequest.php +++ b/app/Http/Requests/Admin/AdminSettlementBillPaymentRequest.php @@ -18,6 +18,7 @@ final class AdminSettlementBillPaymentRequest extends ApiFormRequest 'method' => ['sometimes', 'nullable', 'string', 'max:32'], 'proof' => ['sometimes', 'nullable', 'string', 'max:2000'], 'remark' => ['sometimes', 'nullable', 'string', 'max:255'], + 'idempotency_key' => ['sometimes', 'nullable', 'string', 'max:64'], ]; } } diff --git a/app/Services/AgentSettlement/AgentPeriodAggregator.php b/app/Services/AgentSettlement/AgentPeriodAggregator.php index 964a695..cb0c31b 100644 --- a/app/Services/AgentSettlement/AgentPeriodAggregator.php +++ b/app/Services/AgentSettlement/AgentPeriodAggregator.php @@ -186,7 +186,7 @@ final class AgentPeriodAggregator return (int) DB::table('rebate_records') ->where('ticket_item_id', $ticketItemId) ->where('rebate_type', 'extra') - ->whereIn('status', ['accrued', 'reversed']) + ->whereIn('status', ['accrued', 'in_bill', 'settled', 'reversed']) ->whereBetween('created_at', [$periodStart, $periodEnd]) ->sum('rebate_amount'); } diff --git a/app/Services/AgentSettlement/AgentSettlementBillAdjustmentService.php b/app/Services/AgentSettlement/AgentSettlementBillAdjustmentService.php index 0e63364..61b48fa 100644 --- a/app/Services/AgentSettlement/AgentSettlementBillAdjustmentService.php +++ b/app/Services/AgentSettlement/AgentSettlementBillAdjustmentService.php @@ -21,6 +21,7 @@ final class AgentSettlementBillAdjustmentService string $adjustmentType, ?string $reason, int $adminUserId, + ?string $idempotencyKey = null, ): int { $original = DB::table('settlement_bills')->where('id', $originalBillId)->first(); if ($original === null) { @@ -45,11 +46,38 @@ 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'); + } + } + $type = in_array($adjustmentType, ['adjustment', 'reversal'], true) ? $adjustmentType : 'adjustment'; - return (int) DB::transaction(function () use ($original, $amount, $type, $reason, $adminUserId): int { + 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'); + } + } + $now = now(); $newBillId = (int) DB::table('settlement_bills')->insertGetId([ 'settlement_period_id' => (int) $original->settlement_period_id, @@ -81,6 +109,7 @@ final class AgentSettlementBillAdjustmentService 'adjustment_type' => $type, 'amount' => $amount, 'reason' => $reason, + 'idempotency_key' => $idempotencyKey, 'created_by' => $adminUserId > 0 ? $adminUserId : null, 'created_at' => $now, 'updated_at' => $now, diff --git a/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php b/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php index d3188eb..5ded4cf 100644 --- a/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php +++ b/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php @@ -46,57 +46,59 @@ final class AgentSettlementPeriodCloseService (string) $period->period_end, ); - try { - $aggregate = $this->aggregator->aggregate($adminSiteId, $periodStart, $periodEnd); - } catch (\InvalidArgumentException $e) { - if (str_starts_with($e->getMessage(), 'share_snapshot_missing')) { - throw ValidationException::withMessages([ - 'period' => ['share_snapshot_missing'], - ]); + return DB::transaction(function () use ($periodId, $period, $adminSiteId, $periodStart, $periodEnd): array { + try { + $aggregate = $this->aggregator->aggregate($adminSiteId, $periodStart, $periodEnd); + } catch (\InvalidArgumentException $e) { + if (str_starts_with($e->getMessage(), 'share_snapshot_missing')) { + throw ValidationException::withMessages([ + 'period' => ['share_snapshot_missing'], + ]); + } + + throw $e; } - throw $e; - } + $billIds = $this->billGenerator->generate($periodId, $adminSiteId, $aggregate); - $billIds = $this->billGenerator->generate($periodId, $adminSiteId, $aggregate); + $roundingDiff = $this->platformRounding->apply($periodId, $aggregate); - $roundingDiff = $this->platformRounding->apply($periodId, $aggregate); + $rebateStats = $this->periodCloseRebate->dispatchAndAllocate($periodId, $adminSiteId, $periodStart, $periodEnd); - $rebateStats = $this->periodCloseRebate->dispatchAndAllocate($periodId, $periodStart, $periodEnd); + $unsettled = $this->unsettledWarning->countForSite($adminSiteId, $periodStart, $periodEnd); - $unsettled = $this->unsettledWarning->countForSite($adminSiteId, $periodStart, $periodEnd); + DB::table('settlement_periods')->where('id', $periodId)->update([ + 'status' => 'closed', + 'updated_at' => now(), + ]); - DB::table('settlement_periods')->where('id', $periodId)->update([ - 'status' => 'closed', - 'updated_at' => now(), - ]); + $siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code'); - $siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code'); + DB::table('share_ledger') + ->whereIn('id', function ($query) use ($siteCode, $periodStart, $periodEnd): void { + $query->select('sl.id') + ->from('share_ledger as sl') + ->join('players as p', 'p.id', '=', 'sl.player_id') + ->where('p.site_code', $siteCode) + ->whereNull('sl.settlement_period_id') + ->whereBetween('sl.settled_at', [$periodStart, $periodEnd]); + }) + ->update(['settlement_period_id' => $periodId]); - DB::table('share_ledger') - ->whereIn('id', function ($query) use ($siteCode, $periodStart, $periodEnd): void { - $query->select('sl.id') - ->from('share_ledger as sl') - ->join('players as p', 'p.id', '=', 'sl.player_id') - ->where('p.site_code', $siteCode) - ->whereNull('sl.settlement_period_id') - ->whereBetween('sl.settled_at', [$periodStart, $periodEnd]); - }) - ->update(['settlement_period_id' => $periodId]); + $this->reconcileAllocatedCreditForSite($adminSiteId); - $this->reconcileAllocatedCreditForSite($adminSiteId); - - return [ - 'period_id' => $periodId, - 'bill_ids' => $billIds, - 'player_count' => count($aggregate['players']), - 'agent_edges' => $aggregate['agent_edges'], - 'rebate_dispatched' => $rebateStats['dispatched'], - 'rebate_allocations' => $rebateStats['allocations'], - 'unsettled_ticket_count' => $unsettled['count'], - 'unsettled_ticket_sample' => $unsettled['ticket_item_ids'], - 'platform_rounding_adjustment' => $roundingDiff, - ]; + return [ + 'period_id' => $periodId, + 'bill_ids' => $billIds, + 'player_count' => count($aggregate['players']), + 'agent_edges' => $aggregate['agent_edges'], + 'rebate_dispatched' => $rebateStats['dispatched'], + 'rebate_allocations' => $rebateStats['allocations'], + 'unsettled_ticket_count' => $unsettled['count'], + 'unsettled_ticket_sample' => $unsettled['ticket_item_ids'], + 'platform_rounding_adjustment' => $roundingDiff, + ]; + }); } /** 关账后按真理源重算各代理「已下发额度」,避免与直属玩家/下级代理授信脱节。 */ diff --git a/app/Services/AgentSettlement/CreditLedgerBetFlowPresenter.php b/app/Services/AgentSettlement/CreditLedgerBetFlowPresenter.php index 2df659a..6e9cc20 100644 --- a/app/Services/AgentSettlement/CreditLedgerBetFlowPresenter.php +++ b/app/Services/AgentSettlement/CreditLedgerBetFlowPresenter.php @@ -13,7 +13,7 @@ final class CreditLedgerBetFlowPresenter public const DISPLAY_GAME_SETTLEMENT = 'game_settlement'; - private const SETTLEMENT_REASONS = ['bet_hold_release', 'game_settlement_loss']; + private const SETTLEMENT_REASONS = ['bet_hold_release', 'game_settlement_loss', 'game_settlement_win']; /** * @param list $rows credit_ledger 行(含 reason、ref_type、ref_id、amount、created_at) @@ -159,12 +159,17 @@ final class CreditLedgerBetFlowPresenter */ private function stakeMinorForSettlement(object $settlement, int $ticketId, array $ticketRefs): int { - $fromLoss = abs((int) ($settlement->amount ?? 0)); - if ($fromLoss > 0) { - return $fromLoss; + $fromTicketRef = (int) ($ticketRefs[$ticketId]['actual_deduct_amount'] ?? 0); + if ($fromTicketRef > 0) { + return $fromTicketRef; } - return (int) ($ticketRefs[$ticketId]['actual_deduct_amount'] ?? 0); + $amount = (int) ($settlement->amount ?? 0); + if ($amount < 0) { + return abs($amount); + } + + return 0; } /** @@ -174,6 +179,7 @@ final class CreditLedgerBetFlowPresenter private function mergeSettlementEntries(int $ticketId, array $entries, array $ticketRefs): ?object { $loss = null; + $win = null; $release = null; $latestAt = null; @@ -181,6 +187,8 @@ final class CreditLedgerBetFlowPresenter $reason = (string) ($entry->reason ?? ''); if ($reason === 'game_settlement_loss') { $loss = $entry; + } elseif ($reason === 'game_settlement_win') { + $win = $entry; } elseif ($reason === 'bet_hold_release') { $release = $entry; } @@ -191,17 +199,17 @@ final class CreditLedgerBetFlowPresenter } } - $primary = $loss ?? $release; + $primary = $loss ?? $win ?? $release; if ($primary === null) { return null; } $signed = $loss !== null ? (int) $loss->amount - : 0; + : ($win !== null ? (int) $win->amount : 0); return (object) [ - 'id' => (int) ($loss->id ?? $release->id ?? 0), + 'id' => (int) ($loss->id ?? $win->id ?? $release->id ?? 0), 'amount' => $signed, 'reason' => self::DISPLAY_GAME_SETTLEMENT, 'ref_type' => 'ticket_item', diff --git a/app/Services/AgentSettlement/PeriodCloseRebateService.php b/app/Services/AgentSettlement/PeriodCloseRebateService.php index 303b12f..6497d25 100644 --- a/app/Services/AgentSettlement/PeriodCloseRebateService.php +++ b/app/Services/AgentSettlement/PeriodCloseRebateService.php @@ -15,10 +15,11 @@ final class PeriodCloseRebateService */ public function dispatchAndAllocate( int $periodId, + int $adminSiteId, string $periodStart, string $periodEnd, ): array { - $rebateIds = $this->dispatchAccruedToPeriod($periodId, $periodStart, $periodEnd); + $rebateIds = $this->dispatchAccruedToPeriod($periodId, $adminSiteId, $periodStart, $periodEnd); $allocationCount = $this->buildAllocations($periodId, $rebateIds); return [ @@ -30,14 +31,18 @@ final class PeriodCloseRebateService /** * @return list */ - private function dispatchAccruedToPeriod(int $periodId, string $periodStart, string $periodEnd): array + private function dispatchAccruedToPeriod(int $periodId, int $adminSiteId, string $periodStart, string $periodEnd): array { + $siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code'); + $ids = DB::table('rebate_records as rr') ->join('ticket_items as ti', 'ti.id', '=', 'rr.ticket_item_id') ->join('share_ledger as sl', function ($join): void { $join->on('sl.ticket_item_id', '=', 'ti.id') ->whereNull('sl.reversal_of_id'); }) + ->join('players as p', 'p.id', '=', 'rr.player_id') + ->where('p.site_code', $siteCode) ->where('rr.status', 'accrued') ->whereBetween('sl.settled_at', [$periodStart, $periodEnd]) ->pluck('rr.id') diff --git a/app/Services/AgentSettlement/SettlementCenterLedgerService.php b/app/Services/AgentSettlement/SettlementCenterLedgerService.php index 3e7028b..bb92a6f 100644 --- a/app/Services/AgentSettlement/SettlementCenterLedgerService.php +++ b/app/Services/AgentSettlement/SettlementCenterLedgerService.php @@ -299,6 +299,7 @@ final class SettlementCenterLedgerService 'bet_hold', 'bet_hold_release', 'game_settlement_loss', + 'game_settlement_win', ]); } @@ -955,6 +956,7 @@ final class SettlementCenterLedgerService 'bet_hold', 'bet_hold_release', 'game_settlement_loss', + 'game_settlement_win', ]) ->select([ 'cl.id', @@ -1252,14 +1254,19 @@ final class SettlementCenterLedgerService return []; } + $adminSiteId = (int) DB::table('admin_sites')->where('code', $siteCode)->value('id'); + if ($adminSiteId <= 0) { + return []; + } + $query = DB::table('settlement_adjustments as sa') ->leftJoin('settlement_bills as sb', 'sb.id', '=', 'sa.original_bill_id') ->leftJoin('settlement_periods as sp', 'sp.id', '=', 'sa.settlement_period_id') + ->where('sp.admin_site_id', $adminSiteId) ->leftJoin('players as p', function ($join): void { $join->on('p.id', '=', 'sb.owner_id') ->where('sb.owner_type', '=', 'player'); }) - ->where('p.site_code', $siteCode) ->whereIn('sa.id', $ids) ->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id') ->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id') @@ -1281,15 +1288,21 @@ final class SettlementCenterLedgerService 'p.auth_source', 'p.funding_mode', 'p.default_currency', - 'da.id as direct_agent_id', - 'da.code as direct_agent_code', - 'da.name as direct_agent_name', + DB::raw('COALESCE(da.id, owner_an.id) as direct_agent_id'), + DB::raw('COALESCE(da.code, owner_an.code) as direct_agent_code'), + DB::raw('COALESCE(da.name, owner_an.name) as direct_agent_name'), 'pa.id as parent_agent_id', 'pa.code as parent_agent_code', 'pa.name as parent_agent_name', - ]); + ]) + ->selectRaw('COALESCE(p.site_code, ?) as site_code', [$siteCode]); - AdminAgentSettlementScope::applyDirectPlayersToAlias($query, $admin, 'p'); + $query->leftJoin('agent_nodes as owner_an', function ($join): void { + $join->on('owner_an.id', '=', 'sb.owner_id') + ->where('sb.owner_type', '=', 'agent'); + }); + + AdminAgentSettlementScope::applySubtreeToBillsQuery($query, $admin, 'sb'); $this->applyLedgerSiteScope($query, $admin, 'sp'); return $query->get()->all(); diff --git a/app/Services/AgentSettlement/SettlementPaymentService.php b/app/Services/AgentSettlement/SettlementPaymentService.php index a6a48a2..54ca48e 100644 --- a/app/Services/AgentSettlement/SettlementPaymentService.php +++ b/app/Services/AgentSettlement/SettlementPaymentService.php @@ -22,11 +22,34 @@ final class SettlementPaymentService } /** - * @param array{method?: string|null, proof?: string|null, remark?: string|null} $meta + * @param array{method?: string|null, proof?: string|null, remark?: string|null, idempotency_key?: string|null} $meta */ public function recordPayment(int $billId, int $amount, int $adminUserId, array $meta = []): void { - DB::transaction(function () use ($billId, $amount, $adminUserId, $meta): void { + $idempotencyKey = $meta['idempotency_key'] ?? null; + + if ($idempotencyKey !== null && $idempotencyKey !== '') { + $existing = DB::table('payment_records') + ->where('settlement_bill_id', $billId) + ->where('idempotency_key', $idempotencyKey) + ->first(); + if ($existing !== null) { + return; + } + } + + 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; + } + } + $bill = DB::table('settlement_bills')->where('id', $billId)->lockForUpdate()->first(); if ($bill === null) { throw new \InvalidArgumentException('bill_not_found'); @@ -63,6 +86,7 @@ final class SettlementPaymentService 'method' => $meta['method'] ?? null, 'proof' => $meta['proof'] ?? null, 'remark' => $meta['remark'] ?? null, + 'idempotency_key' => $idempotencyKey, 'status' => 'confirmed', 'created_by' => $adminUserId, 'confirmed_by' => $adminUserId, diff --git a/app/Services/Player/PlayerCreditService.php b/app/Services/Player/PlayerCreditService.php index d433328..d125067 100644 --- a/app/Services/Player/PlayerCreditService.php +++ b/app/Services/Player/PlayerCreditService.php @@ -220,18 +220,29 @@ final class PlayerCreditService return; } - $this->decreaseUsedCredit($player, $amountMinor); + $now = now(); - DB::table('credit_ledger')->insert([ - 'owner_type' => 'player', - 'owner_id' => $player->id, - 'amount' => $amountMinor, - 'reason' => 'game_settlement_win', - 'ref_type' => 'ticket_item', - 'ref_id' => $ticketItemId, - 'created_at' => now(), - 'updated_at' => now(), - ]); + // 先写 credit_ledger:以 (ref_type, ref_id, reason) partial unique 索引为幂等闸门。 + // 已存在同 (ticket_item, game_settlement_win) 的流水则直接返回,避免并发/重入场景下重复扣减 used_credit。 + try { + DB::table('credit_ledger')->insert([ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => $amountMinor, + 'reason' => 'game_settlement_win', + 'ref_type' => 'ticket_item', + 'ref_id' => $ticketItemId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } catch (QueryException $e) { + if ($this->isUniqueViolation($e)) { + return; + } + throw $e; + } + + $this->decreaseUsedCredit($player, $amountMinor); } public function assertMayPlaceBet(Player $player, int $amountMinor): void @@ -276,38 +287,60 @@ final class PlayerCreditService return; } - $this->decreaseUsedCredit($player, $amountMinor); + $now = now(); - DB::table('credit_ledger')->insert([ - 'owner_type' => 'player', - 'owner_id' => $player->id, - 'amount' => $amountMinor, - 'reason' => 'bet_hold_release', - 'ref_type' => 'ticket_item', - 'ref_id' => $ticketItemId, - 'created_at' => now(), - 'updated_at' => now(), - ]); + // 先写 credit_ledger:以 (ref_type, ref_id, reason) partial unique 索引为幂等闸门。 + // 已存在同 (ticket_item, bet_hold_release) 的流水则直接返回,避免并发/重入场景下重复扣减 used_credit。 + try { + DB::table('credit_ledger')->insert([ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => $amountMinor, + 'reason' => 'bet_hold_release', + 'ref_type' => 'ticket_item', + 'ref_id' => $ticketItemId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } catch (QueryException $e) { + if ($this->isUniqueViolation($e)) { + return; + } + throw $e; + } + + $this->decreaseUsedCredit($player, $amountMinor); } - public function reverseBetHold(Player $player, int $amountMinor): void + public function reverseBetHold(Player $player, int $amountMinor, int $ticketOrderId): void { if ($amountMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) { return; } - $this->decreaseUsedCredit($player, $amountMinor); + $now = now(); - DB::table('credit_ledger')->insert([ - 'owner_type' => 'player', - 'owner_id' => $player->id, - 'amount' => $amountMinor, - 'reason' => 'bet_hold_release', - 'ref_type' => 'bet', - 'ref_id' => null, - 'created_at' => now(), - 'updated_at' => now(), - ]); + // 先写 credit_ledger:以 (ref_type, ref_id, reason) partial unique 索引为幂等闸门。 + // 已存在同 (ticket_order, bet_hold_release) 的流水则直接返回,避免并发/重入场景下重复扣减 used_credit。 + try { + DB::table('credit_ledger')->insert([ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => $amountMinor, + 'reason' => 'bet_hold_release', + 'ref_type' => 'ticket_order', + 'ref_id' => $ticketOrderId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } catch (QueryException $e) { + if ($this->isUniqueViolation($e)) { + return; + } + throw $e; + } + + $this->decreaseUsedCredit($player, $amountMinor); } public function reverseGameSettlement(Player $player, int $gameWinLossSigned, int $ticketItemId): void @@ -317,46 +350,51 @@ final class PlayerCreditService } $now = now(); + $amountMinor = abs($gameWinLossSigned); - if ($gameWinLossSigned > 0) { - $amountMinor = $gameWinLossSigned; - $this->decreaseUsedCredit($player, $amountMinor); - + // 先写 credit_ledger:以 (ref_type, ref_id, reason) partial unique 索引为幂等闸门。 + try { DB::table('credit_ledger')->insert([ 'owner_type' => 'player', 'owner_id' => $player->id, - 'amount' => $amountMinor, + 'amount' => $gameWinLossSigned > 0 ? $amountMinor : -$amountMinor, 'reason' => 'game_settlement_reversal', 'ref_type' => 'ticket_item', 'ref_id' => $ticketItemId, 'created_at' => $now, 'updated_at' => $now, ]); + } catch (QueryException $e) { + if ($this->isUniqueViolation($e)) { + return; + } + throw $e; + } + + if ($gameWinLossSigned > 0) { + $this->decreaseUsedCredit($player, $amountMinor); return; } - $amountMinor = abs($gameWinLossSigned); $currency = (string) $player->default_currency; $majorDelta = CreditAmountScale::minorToMajor($amountMinor, $currency); + $row = DB::table('player_credit_accounts') + ->where('player_id', $player->id) + ->lockForUpdate() + ->first(); + + if ($row === null) { + return; + } + DB::table('player_credit_accounts') ->where('player_id', $player->id) ->update([ - 'used_credit' => DB::raw('used_credit + '.$majorDelta), + 'used_credit' => (int) $row->used_credit + $majorDelta, 'updated_at' => $now, ]); - - DB::table('credit_ledger')->insert([ - 'owner_type' => 'player', - 'owner_id' => $player->id, - 'amount' => -$amountMinor, - 'reason' => 'game_settlement_reversal', - 'ref_type' => 'ticket_item', - 'ref_id' => $ticketItemId, - 'created_at' => $now, - 'updated_at' => $now, - ]); } /** diff --git a/app/Services/Ticket/TicketPlacementService.php b/app/Services/Ticket/TicketPlacementService.php index 883aebf..0257ef0 100644 --- a/app/Services/Ticket/TicketPlacementService.php +++ b/app/Services/Ticket/TicketPlacementService.php @@ -423,6 +423,7 @@ final class TicketPlacementService $this->playerCreditService->reverseBetHold( $player, (int) $placement['success_total_actual_deduct'], + (int) $order->id, ); } }); diff --git a/database/migrations/2026_06_23_130000_add_idempotency_key_to_payment_and_adjustment.php b/database/migrations/2026_06_23_130000_add_idempotency_key_to_payment_and_adjustment.php new file mode 100644 index 0000000..1599a75 --- /dev/null +++ b/database/migrations/2026_06_23_130000_add_idempotency_key_to_payment_and_adjustment.php @@ -0,0 +1,34 @@ +string('idempotency_key', 64)->nullable()->after('remark'); + $table->index(['settlement_bill_id', 'idempotency_key']); + }); + + Schema::table('settlement_adjustments', function (Blueprint $table): void { + $table->string('idempotency_key', 64)->nullable()->after('reason'); + $table->index(['original_bill_id', 'idempotency_key']); + }); + } + + public function down(): void + { + Schema::table('payment_records', function (Blueprint $table): void { + $table->dropIndex(['settlement_bill_id', 'idempotency_key']); + $table->dropColumn('idempotency_key'); + }); + + Schema::table('settlement_adjustments', function (Blueprint $table): void { + $table->dropIndex(['original_bill_id', 'idempotency_key']); + $table->dropColumn('idempotency_key'); + }); + } +}; diff --git a/tests/Feature/AdminCreditLedgerIndexTest.php b/tests/Feature/AdminCreditLedgerIndexTest.php index 6710136..43558d0 100644 --- a/tests/Feature/AdminCreditLedgerIndexTest.php +++ b/tests/Feature/AdminCreditLedgerIndexTest.php @@ -146,6 +146,122 @@ test('admin credit ledger simple display merges release and loss per ticket', fu ->assertJsonPath('data.items.0.signed_amount', -1200); }); +test('admin credit ledger simple display shows win amount for winning bet', function (): void { + $site = DB::table('admin_sites')->where('is_default', true)->first(); + $siteId = (int) $site->id; + $siteCode = (string) $site->code; + + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => now()->subDay(), + 'period_end' => now()->addDay(), + 'status' => 'open', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'site_player_id' => 'native:win-flow', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'win_flow', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $now = now(); + + $drawId = (int) DB::table('draws')->insertGetId([ + 'draw_no' => 'WIN-FLOW-001', + 'business_date' => now()->toDateString(), + 'sequence_no' => 1, + 'status' => 'settled', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $orderId = (int) DB::table('ticket_orders')->insertGetId([ + 'order_no' => 'ORD-WIN-001', + 'player_id' => $player->id, + 'draw_id' => $drawId, + 'currency_code' => 'NPR', + 'total_bet_amount' => 800, + 'total_actual_deduct' => 800, + 'status' => 'settled', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + DB::table('ticket_items')->insert([ + 'id' => 99, + 'ticket_no' => 'TI-WIN-99', + 'order_id' => $orderId, + 'player_id' => $player->id, + 'draw_id' => $drawId, + 'normalized_number' => '1234', + 'play_code' => 'straight', + 'total_bet_amount' => 800, + 'actual_deduct_amount' => 800, + 'status' => 'settled_win', + 'win_amount' => 2500, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + DB::table('credit_ledger')->insert([ + [ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => -800, + 'reason' => 'bet_hold', + 'ref_type' => 'bet', + 'ref_id' => null, + 'created_at' => $now->copy()->subMinutes(2), + 'updated_at' => $now->copy()->subMinutes(2), + ], + [ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => 800, + 'reason' => 'bet_hold_release', + 'ref_type' => 'ticket_item', + 'ref_id' => 99, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => 2500, + 'reason' => 'game_settlement_win', + 'ref_type' => 'ticket_item', + 'ref_id' => 99, + 'created_at' => $now, + 'updated_at' => $now, + ], + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'win_flow_super', + 'name' => 'Win Flow', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/credit-ledger?admin_site_id='.$siteId.'&settlement_period_id='.$periodId.'&bet_flow_display=simple') + ->assertOk() + ->assertJsonPath('data.ledger_source', 'credit_ledger') + ->assertJsonPath('data.total', 1) + ->assertJsonCount(1, 'data.items') + ->assertJsonPath('data.items.0.biz_type', 'game_settlement') + ->assertJsonPath('data.items.0.signed_amount', 2500); +}); + test('settlement periods include pipeline credit and share counts', function (): void { $site = DB::table('admin_sites')->where('is_default', true)->first(); $siteId = (int) $site->id; diff --git a/tests/Feature/AdminPlayerManageApiTest.php b/tests/Feature/AdminPlayerManageApiTest.php index c148c8f..844832a 100644 --- a/tests/Feature/AdminPlayerManageApiTest.php +++ b/tests/Feature/AdminPlayerManageApiTest.php @@ -374,3 +374,89 @@ test('native player create rejects chinese username', function (): void { ->assertStatus(422) ->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value); }); + +test('partial rebate update preserves the other rebate field', function (): void { + $siteCode = DB::table('admin_sites')->where('is_default', true)->value('code'); + $siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site'; + $rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id'); + + AgentProfile::query()->updateOrCreate( + ['agent_node_id' => $rootId], + [ + 'total_share_rate' => 100, + 'credit_limit' => 50_000, + 'allocated_credit' => 0, + 'used_credit' => 0, + 'rebate_limit' => 0.02, + 'default_player_rebate' => 0.005, + 'can_grant_extra_rebate' => true, + ], + ); + + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'agent_node_id' => $rootId, + 'site_player_id' => 'partial-rebate-1', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'partial_rebate_user', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 0, + 'used_credit' => 0, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('player_rebate_profiles')->insert([ + 'player_id' => $player->id, + 'game_type' => '*', + 'inherit_from_agent' => false, + 'rebate_rate' => 0.005, + 'extra_rebate_rate' => 0.002, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $token = playerManageAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/players/'.$player->id, [ + 'extra_rebate_rate' => 0.1, + ]) + ->assertOk(); + + $this->assertDatabaseHas('player_rebate_profiles', [ + 'player_id' => $player->id, + 'game_type' => '*', + 'rebate_rate' => 0.005, + 'extra_rebate_rate' => 0.001, + ]); + + DB::table('player_rebate_profiles') + ->where('player_id', $player->id) + ->where('game_type', '*') + ->update([ + 'rebate_rate' => 0.008, + 'extra_rebate_rate' => 0.001, + 'updated_at' => now(), + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/players/'.$player->id, [ + 'rebate_rate' => 0.3, + ]) + ->assertOk(); + + $this->assertDatabaseHas('player_rebate_profiles', [ + 'player_id' => $player->id, + 'game_type' => '*', + 'rebate_rate' => 0.003, + 'extra_rebate_rate' => 0.001, + ]); +}); diff --git a/tests/Feature/AgentSettlementPeriodCloseP0FixesTest.php b/tests/Feature/AgentSettlementPeriodCloseP0FixesTest.php index 65167b6..a4a75da 100644 --- a/tests/Feature/AgentSettlementPeriodCloseP0FixesTest.php +++ b/tests/Feature/AgentSettlementPeriodCloseP0FixesTest.php @@ -396,3 +396,189 @@ test('period close succeeds with no share ledger rows in window', function (): v expect(DB::table('settlement_bills')->where('settlement_period_id', $periodId)->count()) ->toBe(0); }); + +test('period close does not dispatch rebates from other sites', function (): void { + $siteA = createSiteWithRoot('rebate-site-a'); + $siteB = createSiteWithRoot('rebate-site-b'); + + $playerA = Player::query()->create([ + 'site_code' => $siteA['site_code'], + 'agent_node_id' => $siteA['root_id'], + 'site_player_id' => 'p-rebate-a', + 'username' => 'rebate_a', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $playerB = Player::query()->create([ + 'site_code' => $siteB['site_code'], + 'agent_node_id' => $siteB['root_id'], + 'site_player_id' => 'p-rebate-b', + 'username' => 'rebate_b', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $settledAt = now(); + + $ticketAId = createTicketItemForPlayer($playerA, 'T-REB-A'); + $ticketBId = createTicketItemForPlayer($playerB, 'T-REB-B'); + + $snapshotA = json_encode([ + 'total_shares' => ['rebate-site-a' => 100], + 'chain_codes' => ['rebate-site-a'], + 'agent_path' => [$siteA['root_id']], + ]); + $snapshotB = json_encode([ + 'total_shares' => ['rebate-site-b' => 100], + 'chain_codes' => ['rebate-site-b'], + 'agent_path' => [$siteB['root_id']], + ]); + + DB::table('share_ledger')->insert([ + [ + 'ticket_item_id' => $ticketAId, + 'player_id' => $playerA->id, + 'agent_node_id' => $siteA['root_id'], + 'agent_path' => json_encode([$siteA['root_id']]), + 'share_snapshot' => $snapshotA, + 'game_win_loss' => 1000, + 'basic_rebate' => 0, + 'shared_net_win_loss' => 1000, + 'allocations_json' => json_encode([]), + 'settled_at' => $settledAt, + 'created_at' => $settledAt, + 'updated_at' => $settledAt, + ], + [ + 'ticket_item_id' => $ticketBId, + 'player_id' => $playerB->id, + 'agent_node_id' => $siteB['root_id'], + 'agent_path' => json_encode([$siteB['root_id']]), + 'share_snapshot' => $snapshotB, + 'game_win_loss' => 2000, + 'basic_rebate' => 0, + 'shared_net_win_loss' => 2000, + 'allocations_json' => json_encode([]), + 'settled_at' => $settledAt, + 'created_at' => $settledAt, + 'updated_at' => $settledAt, + ], + ]); + + $rebateBId = (int) DB::table('rebate_records')->insertGetId([ + 'player_id' => $playerB->id, + 'ticket_item_id' => $ticketBId, + 'game_type' => '*', + 'valid_bet_amount' => 10000, + 'rebate_rate' => 0.005, + 'rebate_amount' => 50, + 'rebate_type' => 'basic', + 'owner_agent_id' => $siteB['root_id'], + 'status' => 'accrued', + 'created_at' => $settledAt, + 'updated_at' => $settledAt, + ]); + + $periodAId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteA['site_id'], + 'period_start' => $settledAt->copy()->subDay(), + 'period_end' => $settledAt->copy()->addDay(), + 'status' => 'open', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $result = app(AgentSettlementPeriodCloseService::class)->closePeriod($periodAId); + + expect($result['rebate_dispatched'])->toBe(0); + + expect((string) DB::table('rebate_records')->where('id', $rebateBId)->value('status')) + ->toBe('accrued'); + expect(DB::table('rebate_records')->where('id', $rebateBId)->value('settlement_period_id')) + ->toBeNull(); +}); + +test('period close nets out original and reversal share ledger rows in same period', function (): void { + ['site_id' => $siteId, 'site_code' => $siteCode, 'root_id' => $rootId] = createSiteWithRoot('reversal-net'); + + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'agent_node_id' => $rootId, + 'site_player_id' => 'reversal-net-p1', + 'auth_source' => 'lottery_native', + 'funding_mode' => 'credit', + 'username' => 'reversal_net_p', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $settledAt = now(); + $ticketId = createTicketItemForPlayer($player, 'T-REV-NET'); + + $snapshot = json_encode([ + 'total_shares' => ['reversal-net' => 100], + 'chain_codes' => ['reversal-net'], + 'agent_path' => [$rootId], + ]); + + $originalLedgerId = (int) DB::table('share_ledger')->insertGetId([ + 'ticket_item_id' => $ticketId, + 'player_id' => $player->id, + 'agent_node_id' => $rootId, + 'agent_path' => json_encode([$rootId]), + 'share_snapshot' => $snapshot, + 'game_win_loss' => 5000, + 'basic_rebate' => 0, + 'shared_net_win_loss' => 5000, + 'allocations_json' => json_encode([]), + 'settled_at' => $settledAt->copy()->subSecond(), + 'created_at' => $settledAt->copy()->subSecond(), + 'updated_at' => $settledAt->copy()->subSecond(), + ]); + + $reversalLedgerId = (int) DB::table('share_ledger')->insertGetId([ + 'ticket_item_id' => $ticketId, + 'player_id' => $player->id, + 'agent_node_id' => $rootId, + 'agent_path' => json_encode([$rootId]), + 'share_snapshot' => $snapshot, + 'game_win_loss' => -5000, + 'basic_rebate' => 0, + 'shared_net_win_loss' => -5000, + 'allocations_json' => json_encode([]), + 'reversal_of_id' => $originalLedgerId, + 'settled_at' => $settledAt, + 'created_at' => $settledAt, + 'updated_at' => $settledAt, + ]); + + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => $settledAt->copy()->subDay(), + 'period_end' => $settledAt->copy()->addDay(), + 'status' => 'open', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $result = app(AgentSettlementPeriodCloseService::class)->closePeriod($periodId); + + expect($result['player_count'])->toBe(1); + + $playerBill = DB::table('settlement_bills') + ->where('settlement_period_id', $periodId) + ->where('bill_type', 'player') + ->where('owner_id', $player->id) + ->first(); + expect($playerBill)->not->toBeNull(); + expect((int) $playerBill->net_amount)->toBe(0); + + expect(DB::table('share_ledger')->where('id', $originalLedgerId)->value('settlement_period_id')) + ->toBe($periodId); + expect(DB::table('share_ledger')->where('id', $reversalLedgerId)->value('settlement_period_id')) + ->toBe($periodId); +}); diff --git a/tests/Feature/CreditBetHoldReverseIdempotencyTest.php b/tests/Feature/CreditBetHoldReverseIdempotencyTest.php new file mode 100644 index 0000000..0c7337e --- /dev/null +++ b/tests/Feature/CreditBetHoldReverseIdempotencyTest.php @@ -0,0 +1,50 @@ +where('is_default', true)->first(); + $player = Player::query()->create([ + 'site_code' => (string) $site->code, + 'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'), + 'site_player_id' => 'reverse-hold-p1', + 'auth_source' => 'lottery_native', + 'funding_mode' => 'credit', + 'username' => 'reverse_hold_1', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 5000, + 'used_credit' => 0, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $credit = app(PlayerCreditService::class); + $credit->assertMayPlaceBet($player, 200); + expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(2); + + $credit->reverseBetHold($player, 200, 999); + expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(0); + + $credit->reverseBetHold($player, 200, 999); + expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(0); + + expect(DB::table('credit_ledger') + ->where('owner_type', 'player') + ->where('owner_id', $player->id) + ->where('reason', 'bet_hold_release') + ->where('ref_type', 'ticket_order') + ->where('ref_id', 999) + ->count())->toBe(1); +});