Files
lotteryLaravel/app/Services/Wallet/PlayerCreditActivityService.php
wchino 15bd997c4e
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled
feat(core): harden sessions settlement and credit activity
2026-07-22 20:53:43 +08:00

426 lines
16 KiB
PHP

<?php
namespace App\Services\Wallet;
use Carbon\Carbon;
use App\Models\Player;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* 将信用盘底层账务过程整理成玩家能理解的业务活动。
*
* 原始 credit_ledger 仍由管理端与审计使用;玩家端按订单、开奖结果和账期收付展示。
*/
final class PlayerCreditActivityService
{
/**
* @return list<array<string, mixed>>
*/
public function build(Player $player): array
{
$orders = DB::table('ticket_orders as o')
->leftJoin('draws as d', 'd.id', '=', 'o.draw_id')
->where('o.player_id', (int) $player->id)
->where('o.total_actual_deduct', '>', 0)
->orderByDesc('o.id')
->get([
'o.id',
'o.order_no',
'o.currency_code',
'o.total_actual_deduct',
'o.status',
'o.created_at',
'o.updated_at',
'd.draw_no',
]);
$orderActivities = $this->orderActivities($player, $orders);
$paymentActivities = $this->paymentActivities($player);
$activities = array_merge($orderActivities, $paymentActivities);
usort($activities, static function (array $left, array $right): int {
$leftTime = isset($left['created_at']) ? strtotime((string) $left['created_at']) : 0;
$rightTime = isset($right['created_at']) ? strtotime((string) $right['created_at']) : 0;
if ($leftTime === $rightTime) {
return strcmp((string) ($right['log_id'] ?? ''), (string) ($left['log_id'] ?? ''));
}
return $rightTime <=> $leftTime;
});
return $activities;
}
/**
* @param Collection<int, object> $orders
* @return list<array<string, mixed>>
*/
private function orderActivities(Player $player, Collection $orders): array
{
if ($orders->isEmpty()) {
return [];
}
$orderIds = $orders->pluck('id')->map(static fn ($id): int => (int) $id)->all();
$items = DB::table('ticket_items')
->whereIn('order_id', $orderIds)
->where('actual_deduct_amount', '>', 0)
->orderBy('id')
->get([
'id',
'order_id',
'ticket_no',
'actual_deduct_amount',
'status',
'win_amount',
'jackpot_win_amount',
'settled_at',
'updated_at',
]);
$itemsByOrder = $items->groupBy(static fn (object $item): int => (int) $item->order_id);
$ticketIds = $items->pluck('id')->map(static fn ($id): int => (int) $id)->all();
$activeShares = $this->activeShareRows($ticketIds)->keyBy('ticket_item_id');
$activeRebates = $this->activeRebateTotals($ticketIds);
$availableDeltas = $this->ticketAvailableDeltas((int) $player->id, $ticketIds);
$activities = [];
foreach ($orders as $order) {
/** @var Collection<int, object> $orderItems */
$orderItems = $itemsByOrder->get((int) $order->id, collect());
if ($orderItems->isEmpty()) {
continue;
}
$stakeAmount = (int) $orderItems->sum(
static fn (object $item): int => (int) $item->actual_deduct_amount,
);
$rebateAmount = 0;
$gameWinLoss = 0;
$winAmount = 0;
$availableDelta = 0;
$hasSettlement = false;
$hasPendingPayout = false;
$latestSettlementAt = null;
foreach ($orderItems as $item) {
$ticketId = (int) $item->id;
$share = $activeShares->get($ticketId);
if ($share !== null) {
$ticketGameWinLoss = (int) $share->game_win_loss;
$gameWinLoss += $ticketGameWinLoss;
$winAmount += max(0, -$ticketGameWinLoss);
$hasSettlement = true;
$latestSettlementAt = $this->latestTimestamp(
$latestSettlementAt,
$share->settled_at ?? $share->created_at ?? null,
);
} elseif (in_array((string) $item->status, ['settled_win', 'settled_lose', 'pending_payout'], true)) {
$fallbackWin = (int) $item->win_amount + (int) $item->jackpot_win_amount;
$ticketGameWinLoss = (string) $item->status === 'settled_lose'
? (int) $item->actual_deduct_amount
: -$fallbackWin;
$gameWinLoss += $ticketGameWinLoss;
$winAmount += max(0, -$ticketGameWinLoss);
$hasSettlement = true;
}
$rebateAmount += (int) ($activeRebates[$ticketId] ?? 0);
$availableDelta += (int) ($availableDeltas[$ticketId] ?? 0);
$hasPendingPayout = $hasPendingPayout || (string) $item->status === 'pending_payout';
$latestSettlementAt = $this->latestTimestamp(
$latestSettlementAt,
$item->settled_at ?? $item->updated_at ?? null,
);
}
$refunded = in_array((string) $order->status, ['refunded', 'cancelled'], true)
|| $orderItems->every(
static fn (object $item): bool => in_array((string) $item->status, ['refunded', 'failed'], true),
);
$ticketCount = $orderItems->count();
$firstTicketNo = $ticketCount === 1 ? (string) ($orderItems->first()->ticket_no ?? '') : null;
if ($refunded) {
$activities[] = $this->formatActivity(
logId: 'CA-ORDER-'.$order->id,
type: 'reversal',
bizType: 'bet_refund',
activityKind: 'refund',
activityStatus: 'reversed',
amount: $stakeAmount,
currency: (string) $order->currency_code,
availableDelta: 0,
createdAt: $this->isoTimestamp($order->updated_at ?? $order->created_at ?? null),
order: $order,
ticketCount: $ticketCount,
ticketNo: $firstTicketNo,
stakeAmount: $stakeAmount,
winAmount: 0,
rebateAmount: 0,
);
continue;
}
if ($hasSettlement) {
$netAmount = -$gameWinLoss + $rebateAmount;
$activities[] = $this->formatActivity(
logId: 'CA-ORDER-'.$order->id,
type: 'game_settlement',
bizType: $netAmount > 0 ? 'settled_win' : ($netAmount < 0 ? 'settled_loss' : 'settled_even'),
activityKind: 'draw_result',
activityStatus: $hasPendingPayout ? 'pending' : 'completed',
amount: $netAmount,
currency: (string) $order->currency_code,
availableDelta: $availableDelta,
createdAt: $this->isoTimestamp($latestSettlementAt ?? $order->updated_at ?? null),
order: $order,
ticketCount: $ticketCount,
ticketNo: $firstTicketNo,
stakeAmount: $stakeAmount,
winAmount: $winAmount,
rebateAmount: $rebateAmount,
);
continue;
}
$activities[] = $this->formatActivity(
logId: 'CA-ORDER-'.$order->id,
type: 'bet',
bizType: 'bet_pending',
activityKind: 'bet',
activityStatus: 'pending',
amount: -$stakeAmount,
currency: (string) $order->currency_code,
availableDelta: -$stakeAmount,
createdAt: $this->isoTimestamp($order->created_at ?? null),
order: $order,
ticketCount: $ticketCount,
ticketNo: $firstTicketNo,
stakeAmount: $stakeAmount,
winAmount: 0,
rebateAmount: 0,
);
}
return $activities;
}
/**
* @return list<array<string, mixed>>
*/
private function paymentActivities(Player $player): array
{
$rows = DB::table('payment_records as pr')
->join('settlement_bills as sb', 'sb.id', '=', 'pr.settlement_bill_id')
->where('sb.bill_type', 'player')
->where('sb.owner_type', 'player')
->where('sb.owner_id', (int) $player->id)
->where('pr.status', 'confirmed')
->orderByDesc('pr.id')
->get([
'pr.id',
'pr.settlement_bill_id',
'pr.payer_type',
'pr.payer_id',
'pr.payee_type',
'pr.payee_id',
'pr.amount',
'pr.confirmed_at',
'pr.created_at',
]);
$activities = [];
foreach ($rows as $row) {
$playerPaid = (string) $row->payer_type === 'player'
&& (int) $row->payer_id === (int) $player->id;
$playerReceived = (string) $row->payee_type === 'player'
&& (int) $row->payee_id === (int) $player->id;
if (! $playerPaid && ! $playerReceived) {
continue;
}
$amount = (int) $row->amount * ($playerReceived ? 1 : -1);
$activities[] = [
'log_id' => 'CA-PAYMENT-'.$row->id,
'type' => 'bill_settlement',
'biz_type' => $playerReceived ? 'period_received' : 'period_paid',
'activity_kind' => 'period_settlement',
'activity_status' => 'completed',
'amount' => $amount,
'amount_abs' => abs($amount),
'direction' => $amount >= 0 ? 'in' : 'out',
'currency_code' => (string) $player->default_currency,
'balance_after' => null,
'affects_available_credit' => $playerPaid,
'_available_delta' => $playerPaid ? (int) $row->amount : 0,
'ref_id' => 'settlement_bill#'.$row->settlement_bill_id,
'settlement_bill_id' => (int) $row->settlement_bill_id,
'order_no' => null,
'draw_no' => null,
'ticket_no' => null,
'ticket_count' => 0,
'stake_amount' => 0,
'win_amount' => 0,
'rebate_amount' => 0,
'net_amount' => $amount,
'idempotent_key' => null,
'external_ref_no' => null,
'status' => 'posted',
'remark' => null,
'created_at' => $this->isoTimestamp($row->confirmed_at ?? $row->created_at ?? null),
'ledger_source' => 'credit_activity',
'funding_mode' => (string) $player->funding_mode,
'auth_source' => $player->auth_source,
];
}
return $activities;
}
/**
* @param list<int> $ticketIds
* @return Collection<int, object>
*/
private function activeShareRows(array $ticketIds): Collection
{
if ($ticketIds === []) {
return collect();
}
return DB::table('share_ledger as sl')
->whereIn('sl.ticket_item_id', $ticketIds)
->whereNull('sl.reversal_of_id')
->whereNotExists(function ($query): void {
$query->selectRaw('1')
->from('share_ledger as reversal')
->whereColumn('reversal.reversal_of_id', 'sl.id');
})
->get(['sl.ticket_item_id', 'sl.game_win_loss', 'sl.settled_at', 'sl.created_at']);
}
/**
* @param list<int> $ticketIds
* @return array<int, int>
*/
private function activeRebateTotals(array $ticketIds): array
{
if ($ticketIds === []) {
return [];
}
return DB::table('rebate_records as rr')
->whereIn('rr.ticket_item_id', $ticketIds)
->whereNull('rr.reversal_of_id')
->whereNotExists(function ($query): void {
$query->selectRaw('1')
->from('rebate_records as reversal')
->whereColumn('reversal.reversal_of_id', 'rr.id');
})
->selectRaw('rr.ticket_item_id, SUM(rr.rebate_amount) as total_rebate')
->groupBy('rr.ticket_item_id')
->pluck('total_rebate', 'ticket_item_id')
->map(static fn ($amount): int => (int) $amount)
->all();
}
/**
* @param list<int> $ticketIds
* @return array<int, int>
*/
private function ticketAvailableDeltas(int $playerId, array $ticketIds): array
{
if ($ticketIds === []) {
return [];
}
return DB::table('credit_ledger')
->where('owner_type', 'player')
->where('owner_id', $playerId)
->where('ref_type', 'ticket_item')
->whereIn('ref_id', $ticketIds)
->whereIn('reason', ['game_settlement_loss', 'game_settlement_win', 'game_settlement_reversal'])
->selectRaw('ref_id, SUM(amount) as available_delta')
->groupBy('ref_id')
->pluck('available_delta', 'ref_id')
->map(static fn ($amount): int => (int) $amount)
->all();
}
/**
* @return array<string, mixed>
*/
private function formatActivity(
string $logId,
string $type,
string $bizType,
string $activityKind,
string $activityStatus,
int $amount,
string $currency,
int $availableDelta,
?string $createdAt,
object $order,
int $ticketCount,
?string $ticketNo,
int $stakeAmount,
int $winAmount,
int $rebateAmount,
): array {
return [
'log_id' => $logId,
'type' => $type,
'biz_type' => $bizType,
'activity_kind' => $activityKind,
'activity_status' => $activityStatus,
'amount' => $amount,
'amount_abs' => abs($amount),
'direction' => $amount >= 0 ? 'in' : 'out',
'currency_code' => $currency,
'balance_after' => null,
'affects_available_credit' => $availableDelta !== 0,
'_available_delta' => $availableDelta,
'ref_id' => (string) $order->order_no,
'order_no' => (string) $order->order_no,
'draw_no' => $order->draw_no !== null ? (string) $order->draw_no : null,
'ticket_no' => $ticketNo !== '' ? $ticketNo : null,
'ticket_count' => $ticketCount,
'stake_amount' => $stakeAmount,
'win_amount' => $winAmount,
'rebate_amount' => $rebateAmount,
'net_amount' => $amount,
'settlement_bill_id' => null,
'idempotent_key' => null,
'external_ref_no' => null,
'status' => $activityStatus === 'pending' ? 'pending_reconcile' : ($activityStatus === 'reversed' ? 'reversed' : 'posted'),
'remark' => null,
'created_at' => $createdAt,
'ledger_source' => 'credit_activity',
];
}
private function latestTimestamp(mixed $current, mixed $candidate): mixed
{
if ($candidate === null || $candidate === '') {
return $current;
}
if ($current === null || $current === '') {
return $candidate;
}
return strtotime((string) $candidate) > strtotime((string) $current) ? $candidate : $current;
}
private function isoTimestamp(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}
return Carbon::parse((string) $value)->toIso8601String();
}
}