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

@@ -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');
}

View File

@@ -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,

View File

@@ -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,
];
});
}
/** 关账后按真理源重算各代理「已下发额度」,避免与直属玩家/下级代理授信脱节。 */

View File

@@ -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<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
{
$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',

View File

@@ -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<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')
->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')

View File

@@ -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();

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
{
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,