feat: add idempotency support for settlement adjustments and payments, fix rebate handling
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled

- 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
This commit is contained in:
2026-06-23 16:49:57 +08:00
parent 9a7522e928
commit 3c57941895
20 changed files with 734 additions and 127 deletions

View File

@@ -33,6 +33,7 @@ final class AgentSettlementBillAdjustmentController extends Controller
(string) ($request->validated('adjustment_type') ?? 'adjustment'), (string) ($request->validated('adjustment_type') ?? 'adjustment'),
$request->validated('reason'), $request->validated('reason'),
(int) $admin->id, (int) $admin->id,
$request->validated('idempotency_key'),
); );
$after = DB::table('settlement_bills')->where('id', $newBillId)->first(); $after = DB::table('settlement_bills')->where('id', $newBillId)->first();

View File

@@ -35,6 +35,7 @@ final class AgentSettlementBillPaymentController extends Controller
'method' => $validated['method'] ?? null, 'method' => $validated['method'] ?? null,
'proof' => $validated['proof'] ?? null, 'proof' => $validated['proof'] ?? null,
'remark' => $validated['remark'] ?? null, 'remark' => $validated['remark'] ?? null,
'idempotency_key' => $validated['idempotency_key'] ?? null,
], ],
); );

View File

@@ -106,6 +106,13 @@ final class AdminPlayerStoreController extends Controller
} }
$agent = AgentNode::query()->findOrFail($agentNodeId); $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; $rebateRate = 0.0;
$extraRebateRate = 0.0; $extraRebateRate = 0.0;
if ($request->has('rebate_rate') || $request->has('extra_rebate_rate')) { 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') $creditLimit = $request->has('credit_limit')
? (int) $request->input('credit_limit', 0) ? (int) $request->input('credit_limit', 0)
: ($isNative ? 0 : 0); : ($isNative ? 0 : 0);
@@ -164,7 +165,7 @@ final class AdminPlayerStoreController extends Controller
$agentProfileService->refreshAllocatedCredit($agent); $agentProfileService->refreshAllocatedCredit($agent);
if ($request->has('rebate_rate')) { if ($request->has('rebate_rate') || $request->has('extra_rebate_rate')) {
DB::table('player_rebate_profiles')->insert([ DB::table('player_rebate_profiles')->insert([
'player_id' => $player->id, 'player_id' => $player->id,
'game_type' => '*', 'game_type' => '*',

View File

@@ -55,11 +55,21 @@ final class AdminPlayerUpdateController extends Controller
]); ]);
} }
$rebateRate = 0.0; $rebateRate = null;
$extraRebateRate = 0.0; $extraRebateRate = null;
if ($agent !== null && ($request->has('rebate_rate') || $request->has('extra_rebate_rate'))) { if ($agent !== null && ($request->has('rebate_rate') || $request->has('extra_rebate_rate'))) {
$rebateRate = (float) $request->input('rebate_rate', 0) / 100; $existing = DB::table('player_rebate_profiles')
$extraRebateRate = (float) $request->input('extra_rebate_rate', 0) / 100; ->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( $rebateLimitValidator->assertPlayerRebateWithinAgent(
$agent, $agent,
$rebateRate, $rebateRate,
@@ -78,13 +88,13 @@ final class AdminPlayerUpdateController extends Controller
unset($data['credit_limit']); 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( DB::table('player_rebate_profiles')->updateOrInsert(
['player_id' => $player->id, 'game_type' => '*'], ['player_id' => $player->id, 'game_type' => '*'],
[ [
'inherit_from_agent' => false, 'inherit_from_agent' => false,
'rebate_rate' => $rebateRate, 'rebate_rate' => $rebateRate ?? 0,
'extra_rebate_rate' => $extraRebateRate, 'extra_rebate_rate' => $extraRebateRate ?? 0,
'updated_at' => now(), 'updated_at' => now(),
'created_at' => now(), 'created_at' => now(),
], ],

View File

@@ -17,6 +17,7 @@ final class AdminSettlementBillAdjustmentRequest extends ApiFormRequest
'amount' => ['required', 'integer', 'not_in:0'], 'amount' => ['required', 'integer', 'not_in:0'],
'adjustment_type' => ['sometimes', 'string', 'in:adjustment,reversal'], 'adjustment_type' => ['sometimes', 'string', 'in:adjustment,reversal'],
'reason' => ['sometimes', 'nullable', 'string', 'max:255'], 'reason' => ['sometimes', 'nullable', 'string', 'max:255'],
'idempotency_key' => ['sometimes', 'nullable', 'string', 'max:64'],
]; ];
} }
} }

View File

@@ -18,6 +18,7 @@ final class AdminSettlementBillPaymentRequest extends ApiFormRequest
'method' => ['sometimes', 'nullable', 'string', 'max:32'], 'method' => ['sometimes', 'nullable', 'string', 'max:32'],
'proof' => ['sometimes', 'nullable', 'string', 'max:2000'], 'proof' => ['sometimes', 'nullable', 'string', 'max:2000'],
'remark' => ['sometimes', 'nullable', 'string', 'max:255'], 'remark' => ['sometimes', 'nullable', 'string', 'max:255'],
'idempotency_key' => ['sometimes', 'nullable', 'string', 'max:64'],
]; ];
} }
} }

View File

@@ -186,7 +186,7 @@ final class AgentPeriodAggregator
return (int) DB::table('rebate_records') return (int) DB::table('rebate_records')
->where('ticket_item_id', $ticketItemId) ->where('ticket_item_id', $ticketItemId)
->where('rebate_type', 'extra') ->where('rebate_type', 'extra')
->whereIn('status', ['accrued', 'reversed']) ->whereIn('status', ['accrued', 'in_bill', 'settled', 'reversed'])
->whereBetween('created_at', [$periodStart, $periodEnd]) ->whereBetween('created_at', [$periodStart, $periodEnd])
->sum('rebate_amount'); ->sum('rebate_amount');
} }

View File

@@ -21,6 +21,7 @@ final class AgentSettlementBillAdjustmentService
string $adjustmentType, string $adjustmentType,
?string $reason, ?string $reason,
int $adminUserId, int $adminUserId,
?string $idempotencyKey = null,
): int { ): int {
$original = DB::table('settlement_bills')->where('id', $originalBillId)->first(); $original = DB::table('settlement_bills')->where('id', $originalBillId)->first();
if ($original === null) { 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) $type = in_array($adjustmentType, ['adjustment', 'reversal'], true)
? $adjustmentType ? $adjustmentType
: 'adjustment'; : '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(); $now = now();
$newBillId = (int) DB::table('settlement_bills')->insertGetId([ $newBillId = (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => (int) $original->settlement_period_id, 'settlement_period_id' => (int) $original->settlement_period_id,
@@ -81,6 +109,7 @@ final class AgentSettlementBillAdjustmentService
'adjustment_type' => $type, 'adjustment_type' => $type,
'amount' => $amount, 'amount' => $amount,
'reason' => $reason, 'reason' => $reason,
'idempotency_key' => $idempotencyKey,
'created_by' => $adminUserId > 0 ? $adminUserId : null, 'created_by' => $adminUserId > 0 ? $adminUserId : null,
'created_at' => $now, 'created_at' => $now,
'updated_at' => $now, 'updated_at' => $now,

View File

@@ -46,57 +46,59 @@ final class AgentSettlementPeriodCloseService
(string) $period->period_end, (string) $period->period_end,
); );
try { return DB::transaction(function () use ($periodId, $period, $adminSiteId, $periodStart, $periodEnd): array {
$aggregate = $this->aggregator->aggregate($adminSiteId, $periodStart, $periodEnd); try {
} catch (\InvalidArgumentException $e) { $aggregate = $this->aggregator->aggregate($adminSiteId, $periodStart, $periodEnd);
if (str_starts_with($e->getMessage(), 'share_snapshot_missing')) { } catch (\InvalidArgumentException $e) {
throw ValidationException::withMessages([ if (str_starts_with($e->getMessage(), 'share_snapshot_missing')) {
'period' => ['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([ $siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code');
'status' => 'closed',
'updated_at' => now(),
]);
$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') $this->reconcileAllocatedCreditForSite($adminSiteId);
->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); return [
'period_id' => $periodId,
return [ 'bill_ids' => $billIds,
'period_id' => $periodId, 'player_count' => count($aggregate['players']),
'bill_ids' => $billIds, 'agent_edges' => $aggregate['agent_edges'],
'player_count' => count($aggregate['players']), 'rebate_dispatched' => $rebateStats['dispatched'],
'agent_edges' => $aggregate['agent_edges'], 'rebate_allocations' => $rebateStats['allocations'],
'rebate_dispatched' => $rebateStats['dispatched'], 'unsettled_ticket_count' => $unsettled['count'],
'rebate_allocations' => $rebateStats['allocations'], 'unsettled_ticket_sample' => $unsettled['ticket_item_ids'],
'unsettled_ticket_count' => $unsettled['count'], 'platform_rounding_adjustment' => $roundingDiff,
'unsettled_ticket_sample' => $unsettled['ticket_item_ids'], ];
'platform_rounding_adjustment' => $roundingDiff, });
];
} }
/** 关账后按真理源重算各代理「已下发额度」,避免与直属玩家/下级代理授信脱节。 */ /** 关账后按真理源重算各代理「已下发额度」,避免与直属玩家/下级代理授信脱节。 */

View File

@@ -13,7 +13,7 @@ final class CreditLedgerBetFlowPresenter
public const DISPLAY_GAME_SETTLEMENT = 'game_settlement'; 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<object> $rows credit_ledger 行(含 reason、ref_type、ref_id、amount、created_at * @param list<object> $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 private function stakeMinorForSettlement(object $settlement, int $ticketId, array $ticketRefs): int
{ {
$fromLoss = abs((int) ($settlement->amount ?? 0)); $fromTicketRef = (int) ($ticketRefs[$ticketId]['actual_deduct_amount'] ?? 0);
if ($fromLoss > 0) { if ($fromTicketRef > 0) {
return $fromLoss; 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 private function mergeSettlementEntries(int $ticketId, array $entries, array $ticketRefs): ?object
{ {
$loss = null; $loss = null;
$win = null;
$release = null; $release = null;
$latestAt = null; $latestAt = null;
@@ -181,6 +187,8 @@ final class CreditLedgerBetFlowPresenter
$reason = (string) ($entry->reason ?? ''); $reason = (string) ($entry->reason ?? '');
if ($reason === 'game_settlement_loss') { if ($reason === 'game_settlement_loss') {
$loss = $entry; $loss = $entry;
} elseif ($reason === 'game_settlement_win') {
$win = $entry;
} elseif ($reason === 'bet_hold_release') { } elseif ($reason === 'bet_hold_release') {
$release = $entry; $release = $entry;
} }
@@ -191,17 +199,17 @@ final class CreditLedgerBetFlowPresenter
} }
} }
$primary = $loss ?? $release; $primary = $loss ?? $win ?? $release;
if ($primary === null) { if ($primary === null) {
return null; return null;
} }
$signed = $loss !== null $signed = $loss !== null
? (int) $loss->amount ? (int) $loss->amount
: 0; : ($win !== null ? (int) $win->amount : 0);
return (object) [ return (object) [
'id' => (int) ($loss->id ?? $release->id ?? 0), 'id' => (int) ($loss->id ?? $win->id ?? $release->id ?? 0),
'amount' => $signed, 'amount' => $signed,
'reason' => self::DISPLAY_GAME_SETTLEMENT, 'reason' => self::DISPLAY_GAME_SETTLEMENT,
'ref_type' => 'ticket_item', 'ref_type' => 'ticket_item',

View File

@@ -15,10 +15,11 @@ final class PeriodCloseRebateService
*/ */
public function dispatchAndAllocate( public function dispatchAndAllocate(
int $periodId, int $periodId,
int $adminSiteId,
string $periodStart, string $periodStart,
string $periodEnd, string $periodEnd,
): array { ): array {
$rebateIds = $this->dispatchAccruedToPeriod($periodId, $periodStart, $periodEnd); $rebateIds = $this->dispatchAccruedToPeriod($periodId, $adminSiteId, $periodStart, $periodEnd);
$allocationCount = $this->buildAllocations($periodId, $rebateIds); $allocationCount = $this->buildAllocations($periodId, $rebateIds);
return [ return [
@@ -30,14 +31,18 @@ final class PeriodCloseRebateService
/** /**
* @return list<int> * @return list<int>
*/ */
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') $ids = DB::table('rebate_records as rr')
->join('ticket_items as ti', 'ti.id', '=', 'rr.ticket_item_id') ->join('ticket_items as ti', 'ti.id', '=', 'rr.ticket_item_id')
->join('share_ledger as sl', function ($join): void { ->join('share_ledger as sl', function ($join): void {
$join->on('sl.ticket_item_id', '=', 'ti.id') $join->on('sl.ticket_item_id', '=', 'ti.id')
->whereNull('sl.reversal_of_id'); ->whereNull('sl.reversal_of_id');
}) })
->join('players as p', 'p.id', '=', 'rr.player_id')
->where('p.site_code', $siteCode)
->where('rr.status', 'accrued') ->where('rr.status', 'accrued')
->whereBetween('sl.settled_at', [$periodStart, $periodEnd]) ->whereBetween('sl.settled_at', [$periodStart, $periodEnd])
->pluck('rr.id') ->pluck('rr.id')

View File

@@ -299,6 +299,7 @@ final class SettlementCenterLedgerService
'bet_hold', 'bet_hold',
'bet_hold_release', 'bet_hold_release',
'game_settlement_loss', 'game_settlement_loss',
'game_settlement_win',
]); ]);
} }
@@ -955,6 +956,7 @@ final class SettlementCenterLedgerService
'bet_hold', 'bet_hold',
'bet_hold_release', 'bet_hold_release',
'game_settlement_loss', 'game_settlement_loss',
'game_settlement_win',
]) ])
->select([ ->select([
'cl.id', 'cl.id',
@@ -1252,14 +1254,19 @@ final class SettlementCenterLedgerService
return []; return [];
} }
$adminSiteId = (int) DB::table('admin_sites')->where('code', $siteCode)->value('id');
if ($adminSiteId <= 0) {
return [];
}
$query = DB::table('settlement_adjustments as sa') $query = DB::table('settlement_adjustments as sa')
->leftJoin('settlement_bills as sb', 'sb.id', '=', 'sa.original_bill_id') ->leftJoin('settlement_bills as sb', 'sb.id', '=', 'sa.original_bill_id')
->leftJoin('settlement_periods as sp', 'sp.id', '=', 'sa.settlement_period_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 { ->leftJoin('players as p', function ($join): void {
$join->on('p.id', '=', 'sb.owner_id') $join->on('p.id', '=', 'sb.owner_id')
->where('sb.owner_type', '=', 'player'); ->where('sb.owner_type', '=', 'player');
}) })
->where('p.site_code', $siteCode)
->whereIn('sa.id', $ids) ->whereIn('sa.id', $ids)
->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id') ->leftJoin('agent_nodes as da', 'da.id', '=', 'p.agent_node_id')
->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id') ->leftJoin('agent_nodes as pa', 'pa.id', '=', 'da.parent_id')
@@ -1281,15 +1288,21 @@ final class SettlementCenterLedgerService
'p.auth_source', 'p.auth_source',
'p.funding_mode', 'p.funding_mode',
'p.default_currency', 'p.default_currency',
'da.id as direct_agent_id', DB::raw('COALESCE(da.id, owner_an.id) as direct_agent_id'),
'da.code as direct_agent_code', DB::raw('COALESCE(da.code, owner_an.code) as direct_agent_code'),
'da.name as direct_agent_name', DB::raw('COALESCE(da.name, owner_an.name) as direct_agent_name'),
'pa.id as parent_agent_id', 'pa.id as parent_agent_id',
'pa.code as parent_agent_code', 'pa.code as parent_agent_code',
'pa.name as parent_agent_name', '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'); $this->applyLedgerSiteScope($query, $admin, 'sp');
return $query->get()->all(); return $query->get()->all();

View File

@@ -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 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(); $bill = DB::table('settlement_bills')->where('id', $billId)->lockForUpdate()->first();
if ($bill === null) { if ($bill === null) {
throw new \InvalidArgumentException('bill_not_found'); throw new \InvalidArgumentException('bill_not_found');
@@ -63,6 +86,7 @@ final class SettlementPaymentService
'method' => $meta['method'] ?? null, 'method' => $meta['method'] ?? null,
'proof' => $meta['proof'] ?? null, 'proof' => $meta['proof'] ?? null,
'remark' => $meta['remark'] ?? null, 'remark' => $meta['remark'] ?? null,
'idempotency_key' => $idempotencyKey,
'status' => 'confirmed', 'status' => 'confirmed',
'created_by' => $adminUserId, 'created_by' => $adminUserId,
'confirmed_by' => $adminUserId, 'confirmed_by' => $adminUserId,

View File

@@ -220,18 +220,29 @@ final class PlayerCreditService
return; return;
} }
$this->decreaseUsedCredit($player, $amountMinor); $now = now();
DB::table('credit_ledger')->insert([ // 先写 credit_ledger以 (ref_type, ref_id, reason) partial unique 索引为幂等闸门。
'owner_type' => 'player', // 已存在同 (ticket_item, game_settlement_win) 的流水则直接返回,避免并发/重入场景下重复扣减 used_credit。
'owner_id' => $player->id, try {
'amount' => $amountMinor, DB::table('credit_ledger')->insert([
'reason' => 'game_settlement_win', 'owner_type' => 'player',
'ref_type' => 'ticket_item', 'owner_id' => $player->id,
'ref_id' => $ticketItemId, 'amount' => $amountMinor,
'created_at' => now(), 'reason' => 'game_settlement_win',
'updated_at' => now(), '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 public function assertMayPlaceBet(Player $player, int $amountMinor): void
@@ -276,38 +287,60 @@ final class PlayerCreditService
return; return;
} }
$this->decreaseUsedCredit($player, $amountMinor); $now = now();
DB::table('credit_ledger')->insert([ // 先写 credit_ledger以 (ref_type, ref_id, reason) partial unique 索引为幂等闸门。
'owner_type' => 'player', // 已存在同 (ticket_item, bet_hold_release) 的流水则直接返回,避免并发/重入场景下重复扣减 used_credit。
'owner_id' => $player->id, try {
'amount' => $amountMinor, DB::table('credit_ledger')->insert([
'reason' => 'bet_hold_release', 'owner_type' => 'player',
'ref_type' => 'ticket_item', 'owner_id' => $player->id,
'ref_id' => $ticketItemId, 'amount' => $amountMinor,
'created_at' => now(), 'reason' => 'bet_hold_release',
'updated_at' => now(), '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)) { if ($amountMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) {
return; return;
} }
$this->decreaseUsedCredit($player, $amountMinor); $now = now();
DB::table('credit_ledger')->insert([ // 先写 credit_ledger以 (ref_type, ref_id, reason) partial unique 索引为幂等闸门。
'owner_type' => 'player', // 已存在同 (ticket_order, bet_hold_release) 的流水则直接返回,避免并发/重入场景下重复扣减 used_credit。
'owner_id' => $player->id, try {
'amount' => $amountMinor, DB::table('credit_ledger')->insert([
'reason' => 'bet_hold_release', 'owner_type' => 'player',
'ref_type' => 'bet', 'owner_id' => $player->id,
'ref_id' => null, 'amount' => $amountMinor,
'created_at' => now(), 'reason' => 'bet_hold_release',
'updated_at' => now(), '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 public function reverseGameSettlement(Player $player, int $gameWinLossSigned, int $ticketItemId): void
@@ -317,46 +350,51 @@ final class PlayerCreditService
} }
$now = now(); $now = now();
$amountMinor = abs($gameWinLossSigned);
if ($gameWinLossSigned > 0) { // 先写 credit_ledger以 (ref_type, ref_id, reason) partial unique 索引为幂等闸门。
$amountMinor = $gameWinLossSigned; try {
$this->decreaseUsedCredit($player, $amountMinor);
DB::table('credit_ledger')->insert([ DB::table('credit_ledger')->insert([
'owner_type' => 'player', 'owner_type' => 'player',
'owner_id' => $player->id, 'owner_id' => $player->id,
'amount' => $amountMinor, 'amount' => $gameWinLossSigned > 0 ? $amountMinor : -$amountMinor,
'reason' => 'game_settlement_reversal', 'reason' => 'game_settlement_reversal',
'ref_type' => 'ticket_item', 'ref_type' => 'ticket_item',
'ref_id' => $ticketItemId, 'ref_id' => $ticketItemId,
'created_at' => $now, 'created_at' => $now,
'updated_at' => $now, 'updated_at' => $now,
]); ]);
} catch (QueryException $e) {
if ($this->isUniqueViolation($e)) {
return;
}
throw $e;
}
if ($gameWinLossSigned > 0) {
$this->decreaseUsedCredit($player, $amountMinor);
return; return;
} }
$amountMinor = abs($gameWinLossSigned);
$currency = (string) $player->default_currency; $currency = (string) $player->default_currency;
$majorDelta = CreditAmountScale::minorToMajor($amountMinor, $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') DB::table('player_credit_accounts')
->where('player_id', $player->id) ->where('player_id', $player->id)
->update([ ->update([
'used_credit' => DB::raw('used_credit + '.$majorDelta), 'used_credit' => (int) $row->used_credit + $majorDelta,
'updated_at' => $now, '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,
]);
} }
/** /**

View File

@@ -423,6 +423,7 @@ final class TicketPlacementService
$this->playerCreditService->reverseBetHold( $this->playerCreditService->reverseBetHold(
$player, $player,
(int) $placement['success_total_actual_deduct'], (int) $placement['success_total_actual_deduct'],
(int) $order->id,
); );
} }
}); });

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('payment_records', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@@ -146,6 +146,122 @@ test('admin credit ledger simple display merges release and loss per ticket', fu
->assertJsonPath('data.items.0.signed_amount', -1200); ->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 { test('settlement periods include pipeline credit and share counts', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first(); $site = DB::table('admin_sites')->where('is_default', true)->first();
$siteId = (int) $site->id; $siteId = (int) $site->id;

View File

@@ -374,3 +374,89 @@ test('native player create rejects chinese username', function (): void {
->assertStatus(422) ->assertStatus(422)
->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value); ->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,
]);
});

View File

@@ -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()) expect(DB::table('settlement_bills')->where('settlement_period_id', $periodId)->count())
->toBe(0); ->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);
});

View File

@@ -0,0 +1,50 @@
<?php
use App\Models\Player;
use App\Services\Player\PlayerCreditService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
test('reverse bet hold is idempotent', function (): void {
$site = DB::table('admin_sites')->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);
});