Files
lotteryLaravel/app/Services/Wallet/PlayerLedgerLogsService.php
kang 992195b00c
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled
feat(agent-profile): 限制代理及玩家返点和分成范围,增强权限校验
- 添加ensureSuperAdmin方法,限制管理员设置操作权限
- 在AdminSettingController接口中新增权限检查,防止非超级管理员操作
- AdminPlayerIndexController新增direct agent筛选支持
- AdminPlayerUpdateController新增对信用玩家默认币别变更的拒绝逻辑
- 新增WalletSettlementBillsController,实现玩家信用盘账期账单摘要接口
- AgentProfileService调整,新增返点限额和分享比例自动下调机制,保持子代理及玩家配置不超父级
- AgentProfileService增加can_grant_extra_rebate权限继承限制,阻止无权限代理开启
- AgentSettlementPeriodCloseService增加玩家账单回水信用释放逻辑,确保信用额度同步
- PlayerCreditService新增释放账单回水对应信用逻辑,维护账期信用一致性
- TicketPlacementService和TicketPreviewService新增信用玩家投注币别匹配校验,防止币别不符
- PlayerLedgerLogsService优化信用额度计算,增加可用额度上下限限制,防止负值和超限
- 调整后台管理导航,仅超管可见设置入口,强化权限隔离
- 路由新增玩家信用账单查询接口
- 补充多项AgentProfile相关单元测试覆盖额度限制、返点继承、返点下调场景及权限限制
- 增加站点管理员登录测试,验证系统设置菜单不可见,提升用户权限体验
2026-07-01 15:35:46 +08:00

852 lines
30 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\Wallet;
use Carbon\Carbon;
use App\Models\AdminUser;
use App\Models\Player;
use App\Models\WalletTxn;
use Illuminate\Support\Str;
use App\Support\AdminDataScope;
use App\Support\CurrencyFormatter;
use App\Support\LimitedQuery;
use App\Support\PlayerFundingMode;
use App\Services\AgentSettlement\CreditLedgerBetFlowPresenter;
use App\Services\AgentSettlement\SettlementPartyEnrichment;
use App\Services\Player\PlayerCreditService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
/**
* 玩家流水:钱包玩家读 {@see wallet_txns},信用盘玩家读 {@see credit_ledger}。
*/
final class PlayerLedgerLogsService
{
/** PRD 对外类型 → wallet_txns.biz_type */
private const WALLET_TYPE_TO_BIZ = [
'transfer_in' => ['transfer_in'],
'transfer_out' => ['transfer_out'],
'refund' => ['transfer_out_refund'],
'reversal' => ['reversal', 'bet_reverse'],
'bet' => ['bet_deduct', 'bet'],
'prize' => ['settle_payout', 'prize', 'jackpot_manual_payout'],
];
/** PRD 对外类型 → credit_ledger.reason信用盘不用钱包「派彩」口径 */
private const CREDIT_TYPE_TO_REASON = [
'bet' => ['bet_hold'],
'game_settlement' => ['bet_hold_release', 'game_settlement_loss', 'game_settlement_win', 'game_settlement_reversal'],
'reversal' => ['bet_hold_release', 'game_settlement_reversal'],
'refund' => ['settlement_confirm'],
'win_credit' => ['game_settlement_win'],
'credit_release' => ['game_settlement_win', 'settlement_confirm', 'bet_hold_release'],
'bill_settlement' => ['settlement_confirm', 'settlement_payout'],
'rebate' => [],
'transfer_in' => [],
'transfer_out' => [],
];
public function __construct(
private readonly PlayerCreditService $playerCreditService,
private readonly CreditLedgerBetFlowPresenter $betFlowPresenter,
private readonly SettlementPartyEnrichment $partyEnrichment,
) {}
/**
* @return array{
* items: list<array<string, mixed>>,
* total: int,
* page: int,
* per_page: int,
* ledger_source: string,
* funding_mode: string,
* auth_source: string|null,
* }
*/
public function listForPlayerApi(
Player $player,
int $page,
int $perPage,
string $currencyCode,
string $typeFilterRaw,
): array {
$meta = [
'ledger_source' => PlayerFundingMode::usesCredit($player) ? 'credit_ledger' : 'wallet_txn',
'funding_mode' => (string) ($player->funding_mode ?? ''),
'auth_source' => $player->auth_source,
];
if (PlayerFundingMode::usesCredit($player)) {
$result = $this->paginateCreditLedger($player, $page, $perPage, $typeFilterRaw);
return array_merge($result, $meta);
}
$result = $this->paginateWalletTxns($player, $page, $perPage, $currencyCode, $typeFilterRaw);
return array_merge($result, $meta);
}
/**
* 后台玩家详情「钱包流水」:信用盘玩家返回 credit_ledger字段形状对齐 wallet_txns 列表。
*
* @return array{items: list<array<string, mixed>>, total: int, page: int, per_page: int}
*/
public function listForAdminPlayer(
Player $player,
int $page,
int $perPage,
?string $bizType = null,
): array {
if (! PlayerFundingMode::usesCredit($player)) {
return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $perPage];
}
$currency = (string) $player->default_currency;
$limited = LimitedQuery::get($this->creditLedgerQuery($player->id, [
'bet_hold',
'bet_hold_release',
'game_settlement_loss',
'game_settlement_win',
'game_settlement_reversal',
'settlement_confirm',
'settlement_payout',
]), 5000);
$rawRows = $limited['rows']->all();
$enriched = array_map(function (object $row) use ($player): object {
return (object) [
'id' => (int) $row->id,
'amount' => (int) $row->amount,
'reason' => (string) $row->reason,
'ref_type' => $row->ref_type ?? null,
'ref_id' => $row->ref_id ?? null,
'created_at' => $row->created_at ?? null,
'updated_at' => $row->updated_at ?? null,
'player_id' => (int) $player->id,
'site_code' => $player->site_code,
'site_player_id' => $player->site_player_id,
'username' => $player->username,
'nickname' => $player->nickname,
'agent_node_id' => $player->agent_node_id,
'funding_mode' => $player->funding_mode,
'auth_source' => $player->auth_source,
'default_currency' => $player->default_currency,
'direct_agent_id' => null,
'direct_agent_code' => null,
'direct_agent_name' => null,
'parent_agent_id' => null,
'parent_agent_code' => null,
'parent_agent_name' => null,
];
}, $rawRows);
$ticketIds = [];
foreach ($enriched as $row) {
if ((string) ($row->ref_type ?? '') === 'ticket_item') {
$ticketId = (int) ($row->ref_id ?? 0);
if ($ticketId > 0) {
$ticketIds[] = $ticketId;
}
}
}
$ticketRefs = $this->partyEnrichment->loadTicketRefs(array_values(array_unique($ticketIds)));
$simplified = $this->betFlowPresenter->simplifyCreditRows(
$enriched,
$ticketRefs,
fn (object $row): array => $this->formatAdminCreditRow($row, $player, $currency, 0),
function (object $row) use ($player, $currency): array {
$formatted = $this->formatAdminCreditRow($row, $player, $currency, 0);
$ticketId = (int) ($row->ref_id ?? 0);
if ($ticketId > 0) {
$formatted['txn_no'] = 'CLS-T'.$ticketId;
}
return $formatted;
},
);
if ($bizType !== null && $bizType !== '') {
$filterType = trim($bizType);
$simplified = array_values(array_filter(
$simplified,
static fn (array $item): bool => ($item['biz_type'] ?? '') === $filterType
|| ($filterType === 'bet_hold' && ($item['biz_type'] ?? '') === CreditLedgerBetFlowPresenter::DISPLAY_BET_HOLD)
|| ($filterType === 'game_settlement' && ($item['biz_type'] ?? '') === CreditLedgerBetFlowPresenter::DISPLAY_GAME_SETTLEMENT),
));
}
$total = count($simplified);
$offset = max(0, ($page - 1) * $perPage);
$pageRows = array_slice($simplified, $offset, $perPage);
$creditLimitMinor = $this->creditLimitMinor($player, $currency);
$runningMinor = $this->clampCreditAvailableMinor(
$this->playerCreditService->availableCreditMinor($player, $currency),
$creditLimitMinor,
);
foreach (array_slice($simplified, 0, $offset) as $priorRow) {
if (! $this->adminCreditRowAffectsAvailableBalance($priorRow)) {
continue;
}
$runningMinor = $this->clampCreditAvailableMinor(
$runningMinor - $this->adminCreditRowSignedDelta($priorRow),
$creditLimitMinor,
);
}
$items = [];
foreach ($pageRows as $formatted) {
if (! $this->adminCreditRowAffectsAvailableBalance($formatted)) {
$items[] = array_merge($formatted, [
'balance_after' => null,
'balance_after_formatted' => null,
'balance_before' => null,
'balance_before_formatted' => null,
]);
continue;
}
$signed = $this->adminCreditRowSignedDelta($formatted);
$items[] = array_merge($formatted, [
'balance_after' => $runningMinor,
'balance_after_formatted' => CurrencyFormatter::fromMinor($runningMinor),
'balance_before' => $signed >= 0
? max(0, $runningMinor - $signed)
: $this->clampCreditAvailableMinor($runningMinor + abs($signed), $creditLimitMinor),
]);
$runningMinor = $this->clampCreditAvailableMinor($runningMinor - $signed, $creditLimitMinor);
}
return [
'items' => $items,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'truncated' => $limited['truncated'],
];
}
/**
* 结算中心:站点下全部信用盘玩家的 {@see credit_ledger} 流水。
*
* @return array{
* items: list<array<string, mixed>>,
* total: int,
* page: int,
* per_page: int,
* ledger_source: string,
* }
*/
public function listForAdminCreditIndex(
AdminUser $admin,
string $siteCode,
int $page,
int $perPage,
?int $settlementPeriodId = null,
?int $playerId = null,
?string $reason = null,
?string $createdFrom = null,
?string $createdTo = null,
): array {
$query = DB::table('credit_ledger as cl')
->join('players as p', function ($join): void {
$join->on('p.id', '=', 'cl.owner_id')
->where('cl.owner_type', '=', 'player');
})
->where('p.site_code', $siteCode)
->where('p.funding_mode', PlayerFundingMode::CREDIT)
->select([
'cl.id',
'cl.amount',
'cl.reason',
'cl.ref_type',
'cl.ref_id',
'cl.created_at',
'cl.updated_at',
'p.id as player_id',
'p.site_code',
'p.site_player_id',
'p.username',
'p.nickname',
'p.funding_mode',
'p.auth_source',
'p.default_currency',
])
->orderByDesc('cl.id');
AdminDataScope::applyToPlayersAlias($query, $admin, 'p');
if ($playerId !== null && $playerId > 0) {
$query->where('p.id', $playerId);
}
if ($reason !== null && $reason !== '') {
$query->where('cl.reason', $reason);
}
$range = $this->resolveCreatedRange($settlementPeriodId, $createdFrom, $createdTo);
if ($range !== null) {
$query->whereBetween('cl.created_at', $range);
}
/** @var LengthAwarePaginator $paginator */
$paginator = $query->paginate($perPage, ['*'], 'page', $page);
$items = $paginator->getCollection()
->map(fn (object $row): array => $this->formatAdminCreditIndexRow($row))
->values()
->all();
return [
'items' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'ledger_source' => 'credit_ledger',
];
}
/**
* @return array{0: Carbon, 1: Carbon}|null
*/
private function resolveCreatedRange(
?int $settlementPeriodId,
?string $createdFrom,
?string $createdTo,
): ?array {
if ($settlementPeriodId !== null && $settlementPeriodId > 0) {
$period = DB::table('settlement_periods')->where('id', $settlementPeriodId)->first();
if ($period === null) {
return null;
}
return [
Carbon::parse($period->period_start)->startOfDay(),
Carbon::parse($period->period_end)->endOfDay(),
];
}
$from = $createdFrom !== null && $createdFrom !== ''
? Carbon::parse($createdFrom)->startOfDay()
: null;
$to = $createdTo !== null && $createdTo !== ''
? Carbon::parse($createdTo)->endOfDay()
: null;
if ($from === null && $to === null) {
return null;
}
return [
$from ?? Carbon::parse('1970-01-01')->startOfDay(),
$to ?? Carbon::now()->endOfDay(),
];
}
/**
* @return array<string, mixed>
*/
private function formatAdminCreditIndexRow(object $row): array
{
$amount = (int) $row->amount;
$amountAbs = abs($amount);
$currency = (string) ($row->default_currency ?? '');
return [
'id' => (int) $row->id,
'txn_no' => 'CL-'.$row->id,
'player_id' => (int) $row->player_id,
'site_code' => (string) $row->site_code,
'site_player_id' => $row->site_player_id,
'username' => $row->username,
'nickname' => $row->nickname,
'biz_type' => (string) $row->reason,
'type' => $this->creditReasonToPublicType((string) $row->reason),
'biz_no' => $this->creditRefLabel($row),
'direction' => $amount >= 0 ? 1 : 2,
'amount' => $amountAbs,
'amount_formatted' => CurrencyFormatter::fromMinor($amountAbs),
'signed_amount' => $amount,
'currency_code' => $currency,
'status' => 'posted',
'created_at' => $this->isoTimestamp($row->created_at ?? null),
'updated_at' => $this->isoTimestamp($row->updated_at ?? null),
'ledger_source' => 'credit_ledger',
'funding_mode' => (string) ($row->funding_mode ?? PlayerFundingMode::CREDIT),
'auth_source' => $row->auth_source,
];
}
/**
* @return array{items: list<array<string, mixed>>, total: int, page: int, per_page: int}
*/
private function paginateWalletTxns(
Player $player,
int $page,
int $perPage,
string $currencyCode,
string $typeFilterRaw,
): array {
$bizFilter = $this->resolveWalletBizFilter($typeFilterRaw);
if (is_array($bizFilter) && $bizFilter === []) {
return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $perPage];
}
$query = WalletTxn::query()
->where('player_id', $player->id)
->with('wallet')
->orderByDesc('id');
if ($currencyCode !== '') {
$query->whereHas('wallet', fn ($q) => $q->where('currency_code', $currencyCode));
}
if ($bizFilter !== null) {
$query->whereIn('biz_type', $bizFilter);
}
$paginator = $query->paginate($perPage, ['*'], 'page', $page);
$items = $paginator->getCollection()
->map(fn (WalletTxn $txn) => $this->formatWalletTxnRow($txn))
->values()
->all();
return [
'items' => $items,
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
];
}
/**
* @return array{items: list<array<string, mixed>>, total: int, page: int, per_page: int}
*/
private function paginateCreditLedger(
Player $player,
int $page,
int $perPage,
string $typeFilterRaw,
): array {
$reasonFilter = $this->resolveCreditReasonFilter($typeFilterRaw);
$includeRebates = $this->creditFilterIncludesRebates($typeFilterRaw);
if (is_array($reasonFilter) && $reasonFilter === [] && ! $includeRebates) {
return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $perPage];
}
$currency = (string) $player->default_currency;
$creditRows = is_array($reasonFilter) && $reasonFilter === []
? collect()
: $this->creditLedgerQuery((int) $player->id, $reasonFilter)->get();
$rebateRows = $includeRebates ? $this->rebateRecordRows((int) $player->id) : collect();
$rows = $creditRows
->map(fn (object $row): object => (object) ['source' => 'credit', 'row' => $row])
->concat($rebateRows->map(fn (object $row): object => (object) ['source' => 'rebate', 'row' => $row]))
->sortByDesc(fn (object $entry): string => (string) ($entry->row->created_at ?? ''))
->values();
$total = $rows->count();
$offset = max(0, ($page - 1) * $perPage);
$pageRows = $rows->slice($offset, $perPage)->values();
$priorCreditRows = $rows
->slice(0, $offset)
->filter(fn (object $entry): bool => $entry->source === 'credit')
->count();
$creditLimitMinor = $this->creditLimitMinor($player, $currency);
$runningMinor = $this->advanceCreditLedgerRunningMinor(
(int) $player->id,
$reasonFilter,
$this->playerCreditService->availableCreditMinor($player, $currency),
$priorCreditRows,
$creditLimitMinor,
);
$items = $pageRows
->map(function (object $entry) use (&$runningMinor, $player, $currency, $creditLimitMinor): array {
if ($entry->source === 'rebate') {
return $this->formatPlayerRebateRow($entry->row, $player, $currency);
}
$row = $entry->row;
$amount = (int) $row->amount;
$reason = (string) $row->reason;
$affectsBalance = $this->creditReasonAffectsAvailableBalance($reason);
$formatted = $this->formatPlayerCreditRow(
$row,
$player,
$currency,
$affectsBalance ? $runningMinor : null,
$affectsBalance,
);
if ($affectsBalance) {
$runningMinor = $this->clampCreditAvailableMinor($runningMinor - $amount, $creditLimitMinor);
}
return $formatted;
})
->values()
->all();
return [
'items' => $items,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
];
}
/**
* @param list<string>|null $reasonFilter
*/
private function creditLedgerQuery(int $playerId, ?array $reasonFilter)
{
$query = DB::table('credit_ledger')
->where('owner_type', 'player')
->where('owner_id', $playerId)
->orderByDesc('id');
if ($reasonFilter !== null) {
$query->whereIn('reason', $reasonFilter);
}
return $query;
}
/**
* @return list<string>|null
*/
private function resolveWalletBizFilter(string $raw): ?array
{
return $this->resolveTypeFilterMap($raw, self::WALLET_TYPE_TO_BIZ);
}
/**
* @return list<string>|null
*/
private function resolveCreditReasonFilter(string $raw): ?array
{
return $this->resolveTypeFilterMap($raw, self::CREDIT_TYPE_TO_REASON);
}
private function creditFilterIncludesRebates(string $raw): bool
{
$raw = trim($raw);
if ($raw === '') {
return true;
}
$parts = array_filter(array_map('trim', explode(',', $raw)));
return in_array('rebate', array_map(Str::lower(...), $parts), true);
}
/**
* @param array<string, list<string>> $map
* @return list<string>|null
*/
private function resolveTypeFilterMap(string $raw, array $map): ?array
{
$raw = trim($raw);
if ($raw === '') {
return null;
}
$parts = array_filter(array_map('trim', explode(',', $raw)));
if ($parts === []) {
return null;
}
$resolved = [];
foreach ($parts as $part) {
$key = Str::lower($part);
if (! isset($map[$key])) {
continue;
}
foreach ($map[$key] as $value) {
$resolved[] = $value;
}
}
return array_values(array_unique($resolved));
}
/**
* @return array<string, mixed>
*/
private function formatWalletTxnRow(WalletTxn $txn): array
{
$currency = $txn->wallet?->currency_code ?? '';
$amount = (int) $txn->amount;
$balanceAfter = (int) $txn->balance_after;
return [
'log_id' => $txn->txn_no,
'type' => $this->walletBizToPublicType((string) $txn->biz_type),
'biz_type' => $txn->biz_type,
'amount' => $this->signedWalletAmount($txn),
'amount_formatted' => CurrencyFormatter::fromMinor($amount),
'amount_abs' => $amount,
'amount_abs_formatted' => CurrencyFormatter::fromMinor($amount),
'direction' => (int) $txn->direction === 1 ? 'in' : 'out',
'currency_code' => $currency,
'balance_after' => $balanceAfter,
'balance_after_formatted' => CurrencyFormatter::fromMinor($balanceAfter),
'ref_id' => $txn->biz_no,
'idempotent_key' => $txn->idempotent_key,
'external_ref_no' => $txn->external_ref_no,
'status' => $txn->status,
'remark' => $txn->remark,
'created_at' => $txn->created_at?->toIso8601String(),
'ledger_source' => 'wallet_txn',
];
}
/**
* @return array<string, mixed>
*/
private function formatPlayerCreditRow(
object $row,
Player $player,
string $currency,
?int $balanceAfterMinor,
?bool $affectsAvailableBalance = null,
): array {
$amount = (int) $row->amount;
$amountAbs = abs($amount);
$reason = (string) $row->reason;
$publicType = $this->creditReasonToPublicType($reason);
$affectsBalance = $affectsAvailableBalance ?? $this->creditReasonAffectsAvailableBalance($reason);
return [
'log_id' => 'CL-'.$row->id,
'type' => $publicType,
'biz_type' => $reason,
'amount' => $amount,
'amount_formatted' => CurrencyFormatter::fromMinor($amountAbs),
'amount_abs' => $amountAbs,
'amount_abs_formatted' => CurrencyFormatter::fromMinor($amountAbs),
'direction' => $amount >= 0 ? 'in' : 'out',
'currency_code' => $currency,
'balance_after' => $affectsBalance ? $balanceAfterMinor : null,
'balance_after_formatted' => $affectsBalance && $balanceAfterMinor !== null
? CurrencyFormatter::fromMinor($balanceAfterMinor)
: null,
'affects_available_credit' => $affectsBalance,
'ref_id' => $this->creditRefLabel($row),
'idempotent_key' => null,
'external_ref_no' => null,
'status' => 'posted',
'remark' => null,
'created_at' => $this->isoTimestamp($row->created_at ?? null),
'ledger_source' => 'credit_ledger',
'funding_mode' => (string) ($player->funding_mode ?? PlayerFundingMode::CREDIT),
'auth_source' => $player->auth_source,
];
}
private function rebateRecordRows(int $playerId)
{
return DB::table('rebate_records')
->where('player_id', $playerId)
->whereNull('reversal_of_id')
->orderByDesc('created_at')
->orderByDesc('id')
->get();
}
/**
* @return array<string, mixed>
*/
private function formatPlayerRebateRow(object $row, Player $player, string $currency): array
{
$amount = (int) $row->rebate_amount;
return [
'log_id' => 'RB-'.$row->id,
'type' => 'rebate',
'biz_type' => 'rebate_'.$row->status,
'amount' => $amount,
'amount_formatted' => CurrencyFormatter::fromMinor(abs($amount)),
'amount_abs' => abs($amount),
'amount_abs_formatted' => CurrencyFormatter::fromMinor(abs($amount)),
'direction' => $amount >= 0 ? 'in' : 'out',
'currency_code' => $currency,
'balance_after' => null,
'balance_after_formatted' => null,
'affects_available_credit' => false,
'ref_id' => 'ticket_item#'.$row->ticket_item_id,
'idempotent_key' => null,
'external_ref_no' => null,
'status' => (string) $row->status,
'remark' => 'period_rebate',
'created_at' => $this->isoTimestamp($row->created_at ?? null),
'ledger_source' => 'rebate_record',
'funding_mode' => (string) ($player->funding_mode ?? PlayerFundingMode::CREDIT),
'auth_source' => $player->auth_source,
];
}
/** 账期收付记账不改变 player_credit_accounts不参与可用信用倒推。 */
private function creditReasonAffectsAvailableBalance(string $reason): bool
{
return $reason !== 'settlement_payout';
}
/**
* @param list<string>|null $reasonFilter
*/
private function advanceCreditLedgerRunningMinor(
int $playerId,
?array $reasonFilter,
int $runningMinor,
int $skipRows,
int $creditLimitMinor,
): int {
$runningMinor = $this->clampCreditAvailableMinor($runningMinor, $creditLimitMinor);
if ($skipRows <= 0) {
return $runningMinor;
}
$priorRows = $this->creditLedgerQuery($playerId, $reasonFilter)
->limit($skipRows)
->get();
foreach ($priorRows as $row) {
if (! $this->creditReasonAffectsAvailableBalance((string) $row->reason)) {
continue;
}
$runningMinor = $this->clampCreditAvailableMinor($runningMinor - (int) $row->amount, $creditLimitMinor);
}
return $runningMinor;
}
private function creditLimitMinor(Player $player, string $currency): int
{
$credit = DB::table('player_credit_accounts')->where('player_id', $player->id)->first();
if ($credit === null) {
return 0;
}
return \App\Support\CreditAmountScale::majorToMinor((int) $credit->credit_limit, $currency);
}
private function clampCreditAvailableMinor(int $amountMinor, int $creditLimitMinor): int
{
return min(max(0, $amountMinor), max(0, $creditLimitMinor));
}
/**
* @param array<string, mixed> $formatted
*/
private function adminCreditRowAffectsAvailableBalance(array $formatted): bool
{
return ($formatted['biz_type'] ?? '') !== 'settlement_payout';
}
/**
* @param array<string, mixed> $formatted
*/
private function adminCreditRowSignedDelta(array $formatted): int
{
return (int) ($formatted['direction'] === 1 ? $formatted['amount'] : -$formatted['amount']);
}
/**
* @return array<string, mixed>
*/
private function formatAdminCreditRow(
object $row,
Player $player,
string $currency,
int $balanceAfterMinor,
): array {
$amount = (int) $row->amount;
$amountAbs = abs($amount);
$balanceBefore = $amount >= 0
? max(0, $balanceAfterMinor - $amount)
: $balanceAfterMinor + $amountAbs;
return [
'id' => (int) $row->id,
'txn_no' => 'CL-'.$row->id,
'player_id' => (int) $player->id,
'site_code' => $player->site_code,
'site_player_id' => $player->site_player_id,
'username' => $player->username,
'nickname' => $player->nickname,
'wallet_id' => null,
'biz_type' => (string) $row->reason,
'biz_no' => $this->creditRefLabel($row),
'direction' => $amount >= 0 ? 1 : 2,
'amount' => $amountAbs,
'amount_formatted' => CurrencyFormatter::fromMinor($amountAbs),
'balance_before' => $balanceBefore,
'balance_before_formatted' => CurrencyFormatter::fromMinor($balanceBefore),
'balance_after' => $balanceAfterMinor,
'balance_after_formatted' => CurrencyFormatter::fromMinor($balanceAfterMinor),
'status' => 'posted',
'external_ref_no' => null,
'idempotent_key' => null,
'remark' => null,
'created_at' => $this->isoTimestamp($row->created_at ?? null),
'updated_at' => $this->isoTimestamp($row->updated_at ?? null),
'ledger_source' => 'credit_ledger',
'funding_mode' => (string) ($player->funding_mode ?? PlayerFundingMode::CREDIT),
'auth_source' => $player->auth_source,
];
}
private function creditReasonToPublicType(string $reason): string
{
return match ($reason) {
'bet_hold' => 'bet',
'bet_hold_release', 'game_settlement_loss', 'game_settlement_win', 'game_settlement_reversal' => 'game_settlement',
'settlement_confirm' => 'bill_settlement',
'settlement_payout' => 'bill_settlement',
default => $reason,
};
}
private function walletBizToPublicType(string $biz): string
{
return match ($biz) {
'transfer_out_refund' => 'refund',
'bet_deduct', 'bet' => 'bet',
'bet_reverse' => 'reversal',
'settle_payout', 'prize', 'jackpot_manual_payout' => 'prize',
'reversal' => 'reversal',
default => $biz,
};
}
private function signedWalletAmount(WalletTxn $txn): int
{
$a = (int) $txn->amount;
return (int) $txn->direction === 1 ? $a : -$a;
}
private function creditRefLabel(object $row): ?string
{
if ($row->ref_type === null || $row->ref_id === null) {
return null;
}
return (string) $row->ref_type.'#'.$row->ref_id;
}
private function isoTimestamp(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}
return Carbon::parse($value)->toIso8601String();
}
}