Files
lotteryLaravel/app/Services/AgentSettlement/SettlementPaymentService.php
kang 7ec8f4c5a2
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled
fix(funds): 加固转账冲正、结算收付幂等与坏账核销
- 转入 main_site_timeout 等不可冲正场景直接拒绝,避免假结案
- payment_records / settlement_adjustments 增加 partial unique 索引
- 坏账核销行锁 + meta 幂等回放;补差单记录 result_bill_id
- 新增 FundOperationsHardeningTest 覆盖关键路径
2026-06-26 15:19:17 +08:00

188 lines
6.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services\AgentSettlement;
use App\Models\Player;
use App\Services\Player\PlayerCreditService;
use App\Support\DatabaseUniqueViolation;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class SettlementPaymentService
{
public function __construct(
private readonly AgentSettlementBillGuard $billGuard,
private readonly PlayerCreditService $playerCreditService,
private readonly PeriodCloseRebateService $periodCloseRebate,
private readonly AgentSettlementPeriodCompletionService $periodCompletion,
) {}
public function confirmBill(int $billId): void
{
$this->billGuard->markConfirmed($billId);
}
/**
* @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
{
$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;
}
}
try {
DB::transaction(function () use ($billId, $amount, $adminUserId, $meta, $idempotencyKey): void {
$this->insertPaymentRecord($billId, $amount, $adminUserId, $meta, $idempotencyKey);
});
} catch (QueryException $e) {
if (
$idempotencyKey !== null
&& $idempotencyKey !== ''
&& DatabaseUniqueViolation::matches($e)
&& $this->paymentIdempotencyExists($billId, $idempotencyKey)
) {
return;
}
throw $e;
}
}
/**
* @param array{method?: string|null, proof?: string|null, remark?: string|null, idempotency_key?: string|null} $meta
*/
private function insertPaymentRecord(
int $billId,
int $amount,
int $adminUserId,
array $meta,
?string $idempotencyKey,
): void {
if ($idempotencyKey !== null && $idempotencyKey !== '') {
$existing = DB::table('payment_records')
->where('settlement_bill_id', $billId)
->where('idempotency_key', $idempotencyKey)
->lockForUpdate()
->first();
if ($existing !== null) {
return;
}
}
$bill = DB::table('settlement_bills')->where('id', $billId)->lockForUpdate()->first();
if ($bill === null) {
throw new \InvalidArgumentException('bill_not_found');
}
$this->billGuard->assertPeriodMutable($billId);
if (! in_array((string) $bill->status, ['confirmed', 'partial_paid', 'overdue'], true)) {
throw ValidationException::withMessages([
'bill' => ['not_payable'],
]);
}
$unpaid = abs((int) $bill->unpaid_amount);
if ($amount > $unpaid) {
throw ValidationException::withMessages([
'amount' => ['exceeds_unpaid'],
]);
}
$payAmount = $amount;
if ($payAmount <= 0) {
return;
}
[$payerType, $payerId, $payeeType, $payeeId] = $this->resolvePayerPayee($bill);
DB::table('payment_records')->insert([
'settlement_bill_id' => $billId,
'payer_type' => $payerType,
'payer_id' => $payerId,
'payee_type' => $payeeType,
'payee_id' => $payeeId,
'amount' => $payAmount,
'method' => $meta['method'] ?? null,
'proof' => $meta['proof'] ?? null,
'remark' => $meta['remark'] ?? null,
'idempotency_key' => $idempotencyKey,
'status' => 'confirmed',
'created_by' => $adminUserId,
'confirmed_by' => $adminUserId,
'confirmed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$newPaid = (int) $bill->paid_amount + $payAmount;
$newUnpaid = max(0, (int) $bill->unpaid_amount - $payAmount);
$status = $newUnpaid === 0 ? 'settled' : 'partial_paid';
DB::table('settlement_bills')->where('id', $billId)->update([
'paid_amount' => $newPaid,
'unpaid_amount' => $newUnpaid,
'status' => $status,
'updated_at' => now(),
]);
if ($bill->owner_type === 'player' && (int) $bill->owner_id > 0) {
$player = Player::query()->find((int) $bill->owner_id);
if ($player !== null) {
if ((int) $bill->net_amount > 0) {
$this->playerCreditService->releaseFromSettlement($player, $newPaid, $billId);
} elseif ((int) $bill->net_amount < 0) {
$this->playerCreditService->applySettlementPayout($player, $newPaid, $billId);
}
if ($status === 'settled') {
$this->periodCloseRebate->markRebatesSettledForBill($billId);
}
}
}
$this->periodCompletion->syncIfReady((int) $bill->settlement_period_id);
}
private function paymentIdempotencyExists(int $billId, string $idempotencyKey): bool
{
return DB::table('payment_records')
->where('settlement_bill_id', $billId)
->where('idempotency_key', $idempotencyKey)
->exists();
}
/**
* net_amount > 0owner 应付 counterparty< 0counterparty 应付 owner。
*
* @return array{0: string, 1: int, 2: string, 3: int}
*/
private function resolvePayerPayee(object $bill): array
{
if ((int) $bill->net_amount < 0) {
return [
(string) $bill->counterparty_type,
(int) $bill->counterparty_id,
(string) $bill->owner_type,
(int) $bill->owner_id,
];
}
return [
(string) $bill->owner_type,
(int) $bill->owner_id,
(string) $bill->counterparty_type,
(int) $bill->counterparty_id,
];
}
}