> */ 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 $orders * @return list> */ 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 $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> */ 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 $ticketIds * @return Collection */ 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 $ticketIds * @return array */ 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 $ticketIds * @return array */ 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 */ 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(); } }