diff --git a/app/Http/Controllers/Api/V1/Admin/AdminSettingController.php b/app/Http/Controllers/Api/V1/Admin/AdminSettingController.php index 04ac8d2..a57a5aa 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminSettingController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminSettingController.php @@ -7,11 +7,14 @@ use App\Http\Middleware\RecordAdminApiAudit; use App\Http\Requests\Admin\AdminSettingBatchUpdateRequest; use App\Http\Requests\Admin\AdminSettingIndexRequest; use App\Http\Requests\Admin\AdminSettingUpdateRequest; +use App\Lottery\ErrorCode; use App\Models\LotterySetting; use App\Services\AuditLogger; -use App\Support\ApiResponse; use App\Services\LotterySettings; +use App\Support\ApiMessage; +use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; /** @@ -19,13 +22,21 @@ use Illuminate\Support\Facades\DB; */ final class AdminSettingController extends Controller { - public function deployment(): JsonResponse + public function deployment(Request $request): JsonResponse { + if ($denied = $this->ensureSuperAdmin($request)) { + return $denied; + } + return ApiResponse::success(LotterySettings::deploymentSnapshot()); } public function index(AdminSettingIndexRequest $request): JsonResponse { + if ($denied = $this->ensureSuperAdmin($request)) { + return $denied; + } + $validated = $request->validated(); $query = LotterySetting::query()->orderBy('setting_key'); @@ -45,6 +56,10 @@ final class AdminSettingController extends Controller public function update(AdminSettingUpdateRequest $request, string $key): JsonResponse { + if ($denied = $this->ensureSuperAdmin($request)) { + return $denied; + } + $setting = LotterySetting::query()->where('setting_key', $key)->first(); LotterySettings::put( @@ -66,6 +81,10 @@ final class AdminSettingController extends Controller public function batchUpdate(AdminSettingBatchUpdateRequest $request): JsonResponse { + if ($denied = $this->ensureSuperAdmin($request)) { + return $denied; + } + /** @var list $items */ $items = $request->validated('items'); @@ -106,4 +125,14 @@ final class AdminSettingController extends Controller ])->values()->all(), ]); } + + private function ensureSuperAdmin(Request $request): ?JsonResponse + { + $admin = $request->lotteryAdmin(); + if ($admin === null || ! $admin->isSuperAdmin()) { + return ApiMessage::errorResponse($request, 'admin.permission_denied', ErrorCode::AdminForbidden->value, null, 403); + } + + return null; + } } diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerIndexController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerIndexController.php index d8d05bc..bf62b3a 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerIndexController.php @@ -38,6 +38,7 @@ final class AdminPlayerIndexController extends Controller $p = AdminApiList::readPaging($request); $keyword = trim((string) $request->query('keyword', '')); $status = $request->query('status'); + $agentScope = trim((string) $request->query('agent_scope', '')); $scope = AdminScopePolicy::resolveContext($request, $admin); $q = Player::query() @@ -49,6 +50,10 @@ final class AdminPlayerIndexController extends Controller AdminScopePolicy::applyPlayerFilters($q, $scope); + if ($agentScope === 'direct' && $scope->effectiveRequestedAgentNodeId() !== null) { + $q->where('agent_node_id', $scope->effectiveRequestedAgentNodeId()); + } + if ($keyword !== '') { $term = '%'.addcslashes($keyword, '%_\\').'%'; $q->where(static function ($sub) use ($term, $keyword): void { diff --git a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerUpdateController.php b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerUpdateController.php index f91526b..059850a 100644 --- a/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerUpdateController.php +++ b/app/Http/Controllers/Api/V1/Admin/Player/AdminPlayerUpdateController.php @@ -42,6 +42,14 @@ final class AdminPlayerUpdateController extends Controller $data['status'] = (int) $data['status']; } + if ($request->has('default_currency') + && PlayerFundingMode::usesCredit($player) + && strtoupper((string) $request->input('default_currency')) !== strtoupper((string) $player->default_currency)) { + throw ValidationException::withMessages([ + 'default_currency' => ['credit_player_currency_locked'], + ]); + } + $agent = $player->agent_node_id !== null ? AgentNode::query()->find((int) $player->agent_node_id) : null; diff --git a/app/Http/Controllers/Api/V1/Wallet/WalletSettlementBillsController.php b/app/Http/Controllers/Api/V1/Wallet/WalletSettlementBillsController.php new file mode 100644 index 0000000..7ff8f87 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Wallet/WalletSettlementBillsController.php @@ -0,0 +1,107 @@ +lotteryPlayer(); + abort_if($player === null, 500, 'lottery_player missing'); + + if (! PlayerFundingMode::usesCredit($player)) { + return ApiResponse::success([ + 'items' => [], + 'summary' => [ + 'pending_receivable' => 0, + 'pending_payable' => 0, + 'net_pending' => 0, + 'pending_count' => 0, + ], + 'funding_mode' => (string) ($player->funding_mode ?? PlayerFundingMode::WALLET), + ]); + } + + $rows = DB::table('settlement_bills as sb') + ->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id') + ->where('sb.bill_type', 'player') + ->where('sb.owner_type', 'player') + ->where('sb.owner_id', (int) $player->id) + ->whereIn('sb.status', ['pending_confirm', 'confirmed', 'partial_paid', 'overdue']) + ->orderByDesc('sp.period_end') + ->orderByDesc('sb.id') + ->limit(5) + ->get([ + 'sb.id', + 'sb.settlement_period_id', + 'sb.gross_win_loss', + 'sb.rebate_amount', + 'sb.net_amount', + 'sb.paid_amount', + 'sb.unpaid_amount', + 'sb.status', + 'sp.period_start', + 'sp.period_end', + ]); + + $pendingReceivable = 0; + $pendingPayable = 0; + $items = $rows->map(function (object $row) use (&$pendingReceivable, &$pendingPayable): array { + $netAmount = (int) $row->net_amount; + $unpaidAmount = (int) $row->unpaid_amount; + $direction = $netAmount > 0 ? 'payable' : 'receivable'; + + if ($direction === 'payable') { + $pendingPayable += $unpaidAmount; + } else { + $pendingReceivable += $unpaidAmount; + } + + return [ + 'id' => (int) $row->id, + 'settlement_period_id' => (int) $row->settlement_period_id, + 'period_start' => $this->isoTimestamp($row->period_start), + 'period_end' => $this->isoTimestamp($row->period_end), + 'gross_win_loss' => (int) $row->gross_win_loss, + 'rebate_amount' => (int) $row->rebate_amount, + 'net_amount' => $netAmount, + 'paid_amount' => (int) $row->paid_amount, + 'unpaid_amount' => $unpaidAmount, + 'direction' => $direction, + 'status' => (string) $row->status, + ]; + })->all(); + + return ApiResponse::success([ + 'items' => $items, + 'summary' => [ + 'pending_receivable' => $pendingReceivable, + 'pending_receivable_formatted' => CurrencyFormatter::fromMinor($pendingReceivable), + 'pending_payable' => $pendingPayable, + 'pending_payable_formatted' => CurrencyFormatter::fromMinor($pendingPayable), + 'net_pending' => $pendingReceivable - $pendingPayable, + 'net_pending_formatted' => CurrencyFormatter::fromMinor(abs($pendingReceivable - $pendingPayable)), + 'pending_count' => count($items), + ], + 'funding_mode' => PlayerFundingMode::CREDIT, + ]); + } + + private function isoTimestamp(mixed $value): ?string + { + if ($value === null || $value === '') { + return null; + } + + return \Carbon\Carbon::parse((string) $value)->toIso8601String(); + } +} diff --git a/app/Services/Agent/AgentProfileService.php b/app/Services/Agent/AgentProfileService.php index 85c5ed8..5058da8 100644 --- a/app/Services/Agent/AgentProfileService.php +++ b/app/Services/Agent/AgentProfileService.php @@ -118,6 +118,17 @@ final class AgentProfileService $profile->used_credit = 0; } $profile->save(); + $this->clampDescendantAgentShareRates($node, (float) $profile->total_share_rate); + $this->clampDirectPlayerRebates( + $node, + (float) $profile->rebate_limit, + (bool) $profile->can_grant_extra_rebate, + ); + $this->clampDescendantAgentRebates( + $node, + (float) $profile->rebate_limit, + (bool) $profile->can_grant_extra_rebate, + ); if ($parent !== null) { $this->allocatedSync->syncForAgent($parent); @@ -290,11 +301,18 @@ final class AgentProfileService */ public function assertChildCapabilityGrantsWithinParent(AgentNode $parent, array $childPayload, AdminUser $actor): void { + $parentProfile = $this->profileForNode((int) $parent->id); + if ((bool) ($childPayload['can_grant_extra_rebate'] ?? false) + && ! ($parentProfile?->can_grant_extra_rebate ?? false)) { + throw ValidationException::withMessages([ + 'can_grant_extra_rebate' => ['parent_cannot_delegate'], + ]); + } + if ($actor->isSuperAdmin() || \App\Support\AdminAgentSettlementScope::canManageSitePeriods($actor)) { return; } - $parentProfile = $this->profileForNode((int) $parent->id); if ((bool) ($childPayload['can_create_child_agent'] ?? false) && ! ($parentProfile?->can_create_child_agent ?? false)) { throw ValidationException::withMessages([ @@ -324,6 +342,113 @@ final class AgentProfileService return (bool) ($profile?->can_create_player ?? false); } + private function clampDescendantAgentShareRates(AgentNode $parent, float $parentTotalShareRate): void + { + $children = AgentNode::query() + ->where('parent_id', (int) $parent->id) + ->orderBy('id') + ->get(); + + foreach ($children as $child) { + $profile = AgentProfile::query() + ->where('agent_node_id', (int) $child->id) + ->first(); + + if ($profile === null) { + continue; + } + + $totalShareRate = max(0.0, (float) $profile->total_share_rate); + $nextTotalShareRate = min($totalShareRate, $parentTotalShareRate); + + if (abs($nextTotalShareRate - $totalShareRate) >= 1e-9) { + $profile->forceFill([ + 'total_share_rate' => $nextTotalShareRate, + ])->save(); + } + + $this->clampDescendantAgentShareRates($child, $nextTotalShareRate); + } + } + + private function clampDirectPlayerRebates(AgentNode $agent, float $rebateLimit, bool $canGrantExtraRebate): void + { + $rows = DB::table('player_rebate_profiles as prp') + ->join('players as p', 'p.id', '=', 'prp.player_id') + ->where('p.agent_node_id', (int) $agent->id) + ->where('prp.inherit_from_agent', false) + ->select([ + 'prp.id', + 'prp.rebate_rate', + 'prp.extra_rebate_rate', + ]) + ->get(); + + $now = now(); + foreach ($rows as $row) { + $rebateRate = max(0.0, (float) $row->rebate_rate); + $extraRate = max(0.0, (float) $row->extra_rebate_rate); + + $nextRebate = min($rebateRate, $rebateLimit); + $remaining = max(0.0, $rebateLimit - $nextRebate); + $nextExtra = $canGrantExtraRebate ? min($extraRate, $remaining) : 0.0; + + if (abs($nextRebate - $rebateRate) < 1e-9 && abs($nextExtra - $extraRate) < 1e-9) { + continue; + } + + DB::table('player_rebate_profiles') + ->where('id', (int) $row->id) + ->update([ + 'rebate_rate' => $nextRebate, + 'extra_rebate_rate' => $nextExtra, + 'updated_at' => $now, + ]); + } + } + + private function clampDescendantAgentRebates( + AgentNode $parent, + float $parentRebateLimit, + bool $parentCanGrantExtraRebate, + ): void { + $children = AgentNode::query() + ->where('parent_id', (int) $parent->id) + ->orderBy('id') + ->get(); + + foreach ($children as $child) { + $profile = AgentProfile::query() + ->where('agent_node_id', (int) $child->id) + ->first(); + + if ($profile === null) { + continue; + } + + $rebateLimit = max(0.0, (float) $profile->rebate_limit); + $defaultRebate = max(0.0, (float) $profile->default_player_rebate); + $canGrantExtraRebate = (bool) $profile->can_grant_extra_rebate; + + $nextRebateLimit = min($rebateLimit, $parentRebateLimit); + $nextDefaultRebate = min($defaultRebate, $nextRebateLimit); + $nextCanGrantExtraRebate = $parentCanGrantExtraRebate && $canGrantExtraRebate; + + if (abs($nextRebateLimit - $rebateLimit) >= 1e-9 + || abs($nextDefaultRebate - $defaultRebate) >= 1e-9 + || $nextCanGrantExtraRebate !== $canGrantExtraRebate) { + $profile->forceFill([ + 'rebate_limit' => $nextRebateLimit, + 'default_player_rebate' => $nextDefaultRebate, + 'can_grant_extra_rebate' => $nextCanGrantExtraRebate, + ])->save(); + } + + $this->clampDirectPlayerRebates($child, $nextRebateLimit, $nextCanGrantExtraRebate); + $this->clampDescendantAgentRebates($child, $nextRebateLimit, $nextCanGrantExtraRebate); + } + } + private function assertAgentProfileExists(AgentNode $agent): void { if (AgentProfile::query()->where('agent_node_id', $agent->id)->exists()) { diff --git a/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php b/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php index 2b6d0fb..45d2e88 100644 --- a/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php +++ b/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php @@ -3,7 +3,9 @@ namespace App\Services\AgentSettlement; use App\Models\AgentNode; +use App\Models\Player; use App\Services\Agent\AgentCreditAllocatedSyncService; +use App\Services\Player\PlayerCreditService; use App\Support\AgentSettlementPeriodWindow; use App\Support\AgentSettlementProductionGuard; use Illuminate\Support\Facades\DB; @@ -18,6 +20,7 @@ final class AgentSettlementPeriodCloseService private readonly UnsettledTicketPeriodWarning $unsettledWarning, private readonly PlatformRoundingAdjuster $platformRounding, private readonly AgentCreditAllocatedSyncService $allocatedSync, + private readonly PlayerCreditService $playerCreditService, ) {} /** @@ -63,6 +66,7 @@ final class AgentSettlementPeriodCloseService $roundingDiff = $this->platformRounding->apply($periodId, $aggregate); $rebateStats = $this->periodCloseRebate->dispatchAndAllocate($periodId, $adminSiteId, $periodStart, $periodEnd); + $this->releasePlayerBillRebatesFromCredit($periodId); $unsettled = $this->unsettledWarning->countForSite($adminSiteId, $periodStart, $periodEnd); @@ -100,6 +104,28 @@ final class AgentSettlementPeriodCloseService }); } + /** 玩家亏损账单里的回水已抵扣应付额,应同步释放同等已用信用。 */ + private function releasePlayerBillRebatesFromCredit(int $periodId): void + { + $bills = DB::table('settlement_bills') + ->where('settlement_period_id', $periodId) + ->where('bill_type', 'player') + ->where('owner_type', 'player') + ->where('gross_win_loss', '>', 0) + ->where('rebate_amount', '>', 0) + ->get(['id', 'owner_id', 'gross_win_loss', 'rebate_amount']); + + foreach ($bills as $bill) { + $player = Player::query()->find((int) $bill->owner_id); + if ($player === null) { + continue; + } + + $rebateMinor = min((int) $bill->gross_win_loss, (int) $bill->rebate_amount); + $this->playerCreditService->releaseRebateInBill($player, $rebateMinor, (int) $bill->id); + } + } + /** 关账后按真理源重算各代理「已下发额度」,避免与直属玩家/下级代理授信脱节。 */ private function reconcileAllocatedCreditForSite(int $adminSiteId): void { diff --git a/app/Services/Player/PlayerCreditService.php b/app/Services/Player/PlayerCreditService.php index fad2b5b..a624842 100644 --- a/app/Services/Player/PlayerCreditService.php +++ b/app/Services/Player/PlayerCreditService.php @@ -448,6 +448,27 @@ final class PlayerCreditService ); } + /** + * 账期回水进入玩家账单后,已抵扣玩家应付亏损,需同步释放对应已用信用。 + */ + public function releaseRebateInBill(Player $player, int $rebateMinor, int $billId): void + { + if ($rebateMinor <= 0 || ! PlayerFundingMode::usesCredit($player)) { + return; + } + + $delta = $this->syncSettlementBillLedger( + $player, + $billId, + 'rebate_in_bill', + $rebateMinor, + ); + if ($delta > 0) { + $this->decreaseUsedCredit($player, $delta); + $this->syncAgentUsedCredit($player); + } + } + /** * 同步账期收付台账:每账单每种 reason 仅一行,amount 为累计已付;返回本次应追加的 minor 增量。 */ diff --git a/app/Services/Ticket/TicketPlacementService.php b/app/Services/Ticket/TicketPlacementService.php index 0257ef0..2541e04 100644 --- a/app/Services/Ticket/TicketPlacementService.php +++ b/app/Services/Ticket/TicketPlacementService.php @@ -39,6 +39,8 @@ final class TicketPlacementService public function place(Player $player, array $payload): array { $currencyCode = strtoupper((string) $payload['currency_code']); + $this->assertCreditCurrencyMatchesPlayer($player, $currencyCode); + $clientTraceId = isset($payload['client_trace_id']) && $payload['client_trace_id'] !== '' ? (string) $payload['client_trace_id'] : null; @@ -552,4 +554,21 @@ final class TicketPlacementService return str_pad($number, 4, '0', STR_PAD_LEFT); } + + private function assertCreditCurrencyMatchesPlayer(Player $player, string $currencyCode): void + { + if (! PlayerFundingMode::usesCredit($player)) { + return; + } + + $defaultCurrency = strtoupper((string) $player->default_currency); + if ($currencyCode === $defaultCurrency) { + return; + } + + throw new TicketOperationException('credit_currency_mismatch', ErrorCode::ConfigCurrencyInvalid->value, 400, [ + 'currency_code' => $currencyCode, + 'expected_currency' => $defaultCurrency, + ]); + } } diff --git a/app/Services/Ticket/TicketPreviewService.php b/app/Services/Ticket/TicketPreviewService.php index 6b638a4..88c8811 100644 --- a/app/Services/Ticket/TicketPreviewService.php +++ b/app/Services/Ticket/TicketPreviewService.php @@ -37,6 +37,8 @@ final class TicketPreviewService } $currencyCode = strtoupper((string) $payload['currency_code']); + $this->assertCreditCurrencyMatchesPlayer($player, $currencyCode); + $lines = []; $totalBet = 0; $totalRebate = 0; @@ -136,4 +138,21 @@ final class TicketPreviewService 'warnings' => $warningRows, ]; } + + private function assertCreditCurrencyMatchesPlayer(Player $player, string $currencyCode): void + { + if (! PlayerFundingMode::usesCredit($player)) { + return; + } + + $defaultCurrency = strtoupper((string) $player->default_currency); + if ($currencyCode === $defaultCurrency) { + return; + } + + throw new TicketOperationException('credit_currency_mismatch', ErrorCode::ConfigCurrencyInvalid->value, 400, [ + 'currency_code' => $currencyCode, + 'expected_currency' => $defaultCurrency, + ]); + } } diff --git a/app/Services/Wallet/PlayerLedgerLogsService.php b/app/Services/Wallet/PlayerLedgerLogsService.php index 2598c3c..de10fbf 100644 --- a/app/Services/Wallet/PlayerLedgerLogsService.php +++ b/app/Services/Wallet/PlayerLedgerLogsService.php @@ -182,12 +182,19 @@ final class PlayerLedgerLogsService $offset = max(0, ($page - 1) * $perPage); $pageRows = array_slice($simplified, $offset, $perPage); - $runningMinor = $this->playerCreditService->availableCreditMinor($player, $currency); + $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->adminCreditRowSignedDelta($priorRow); + $runningMinor = $this->clampCreditAvailableMinor( + $runningMinor - $this->adminCreditRowSignedDelta($priorRow), + $creditLimitMinor, + ); } $items = []; foreach ($pageRows as $formatted) { @@ -208,9 +215,9 @@ final class PlayerLedgerLogsService 'balance_after_formatted' => CurrencyFormatter::fromMinor($runningMinor), 'balance_before' => $signed >= 0 ? max(0, $runningMinor - $signed) - : $runningMinor + abs($signed), + : $this->clampCreditAvailableMinor($runningMinor + abs($signed), $creditLimitMinor), ]); - $runningMinor -= $signed; + $runningMinor = $this->clampCreditAvailableMinor($runningMinor - $signed, $creditLimitMinor); } return [ @@ -449,14 +456,16 @@ final class PlayerLedgerLogsService ->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): array { + ->map(function (object $entry) use (&$runningMinor, $player, $currency, $creditLimitMinor): array { if ($entry->source === 'rebate') { return $this->formatPlayerRebateRow($entry->row, $player, $currency); } @@ -473,7 +482,7 @@ final class PlayerLedgerLogsService $affectsBalance, ); if ($affectsBalance) { - $runningMinor -= $amount; + $runningMinor = $this->clampCreditAvailableMinor($runningMinor - $amount, $creditLimitMinor); } return $formatted; @@ -694,7 +703,10 @@ final class PlayerLedgerLogsService ?array $reasonFilter, int $runningMinor, int $skipRows, + int $creditLimitMinor, ): int { + $runningMinor = $this->clampCreditAvailableMinor($runningMinor, $creditLimitMinor); + if ($skipRows <= 0) { return $runningMinor; } @@ -707,12 +719,27 @@ final class PlayerLedgerLogsService if (! $this->creditReasonAffectsAvailableBalance((string) $row->reason)) { continue; } - $runningMinor -= (int) $row->amount; + $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 $formatted */ diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index 9c7d94d..4cdc6a2 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -165,7 +165,7 @@ final class AdminAuthorizationRegistry ['segment' => 'admin_users', 'label' => 'Admin Users', 'href' => '/admin/admin-users', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.admin_user.manage']], ['segment' => 'admin_roles', 'label' => 'Admin Roles', 'href' => '/admin/admin-roles', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.admin_role.manage']], ['segment' => 'audit', 'label' => 'Audit Logs', 'href' => '/admin/audit-logs', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.audit.view']], - ['segment' => 'settings', 'label' => 'Settings', 'href' => '/admin/settings', 'nav_group' => 'platform', 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.rebate.manage', 'prd.rebate.view', 'prd.payout.manage']], + ['segment' => 'settings', 'label' => 'Settings', 'href' => '/admin/settings', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.wallet_reconcile.manage', 'prd.rebate.manage', 'prd.rebate.view', 'prd.payout.manage']], ['segment' => 'risk', 'label' => 'Risk', 'href' => '/admin/risk', 'nav_group' => 'platform', 'platform_only' => true, 'requiredAny' => ['prd.risk.view', 'prd.risk.manage']], ]; } diff --git a/routes/api/v1/player.php b/routes/api/v1/player.php index 729abcd..0a55608 100644 --- a/routes/api/v1/player.php +++ b/routes/api/v1/player.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Api\V1\Wallet\WalletLogsController; use App\Http\Controllers\Api\V1\Ticket\TicketPlaceController; use App\Http\Controllers\Api\V1\Ticket\TicketPreviewController; use App\Http\Controllers\Api\V1\Wallet\WalletBalanceController; +use App\Http\Controllers\Api\V1\Wallet\WalletSettlementBillsController; use App\Http\Controllers\Api\V1\Ticket\TicketItemShowController; use App\Http\Controllers\Api\V1\Ticket\TicketItemsIndexController; use App\Http\Controllers\Api\V1\Wallet\WalletTransferInController; @@ -29,6 +30,7 @@ Route::middleware('lottery.player')->group(function (): void { ->group(function (): void { Route::get('balance', WalletBalanceController::class)->name('balance'); Route::get('logs', WalletLogsController::class)->name('logs'); + Route::get('settlement-bills', WalletSettlementBillsController::class)->name('settlement-bills'); Route::post('transfer-in', WalletTransferInController::class)->name('transfer-in'); Route::post('transfer-out', WalletTransferOutController::class)->name('transfer-out'); }); diff --git a/tests/Feature/AdminAgentProfileApiTest.php b/tests/Feature/AdminAgentProfileApiTest.php index 5987bf4..fc598e6 100644 --- a/tests/Feature/AdminAgentProfileApiTest.php +++ b/tests/Feature/AdminAgentProfileApiTest.php @@ -297,6 +297,61 @@ test('child agent profile update rejects credit above parent available', functio ->assertJsonPath('code', ErrorCode::ValidationFailed->value); }); +test('agent profile update rejects credit below already allocated amount', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + $service = app(\App\Services\Agent\AgentNodeService::class); + + \App\Models\AgentProfile::query()->updateOrCreate( + ['agent_node_id' => $rootId], + [ + 'total_share_rate' => 100, + 'credit_limit' => 10000, + 'allocated_credit' => 0, + 'used_credit' => 0, + 'rebate_limit' => 1, + 'default_player_rebate' => 0, + ], + ); + + $super = AdminUser::query()->create([ + 'username' => 'below_allocated_super', + 'name' => 'Below Allocated Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $agent = $service->createChild($super, agentChildPayload([ + 'parent_id' => $rootId, + 'code' => 'below-allocated-agent', + 'name' => 'Below Allocated Agent', + 'username' => 'below_allocated_agent', + 'credit_limit' => 5000, + ])); + $service->createChild($super, agentChildPayload([ + 'parent_id' => $agent->id, + 'code' => 'below-allocated-child', + 'name' => 'Below Allocated Child', + 'username' => 'below_allocated_child', + 'credit_limit' => 3000, + ])); + + $token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/agent-nodes/'.$agent->id.'/profile', [ + 'credit_limit' => 2000, + ]) + ->assertStatus(422) + ->assertJsonPath('code', ErrorCode::ValidationFailed->value); + + $profile = DB::table('agent_profiles')->where('agent_node_id', $agent->id)->first(); + expect((int) $profile->credit_limit)->toBe(5000) + ->and((int) $profile->allocated_credit)->toBe(3000); +}); + test('child agent profile update rejects rebate above parent', function (): void { $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); @@ -385,6 +440,266 @@ test('agent profile update rejects default rebate above limit', function (): voi ->assertJsonPath('code', ErrorCode::ValidationFailed->value); }); +test('lowering agent rebate limit clamps descendant agent and player rebate profiles', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code'); + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + $service = app(\App\Services\Agent\AgentNodeService::class); + + \App\Models\AgentProfile::query()->updateOrCreate( + ['agent_node_id' => $rootId], + [ + 'total_share_rate' => 100, + 'credit_limit' => 10000, + 'allocated_credit' => 0, + 'used_credit' => 0, + 'rebate_limit' => 1, + 'default_player_rebate' => 0, + 'can_grant_extra_rebate' => true, + ], + ); + + $super = AdminUser::query()->create([ + 'username' => 'clamp_rebate_super', + 'name' => 'Clamp Rebate Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $agent = $service->createChild($super, agentChildPayload([ + 'parent_id' => $rootId, + 'code' => 'clamp-rebate-agent', + 'name' => 'Clamp Rebate Agent', + 'username' => 'clamp_rebate_agent', + 'rebate_limit' => 10, + 'default_player_rebate' => 6, + 'can_grant_extra_rebate' => true, + ])); + $childAgent = $service->createChild($super, agentChildPayload([ + 'parent_id' => $agent->id, + 'code' => 'clamp-rebate-child', + 'name' => 'Clamp Rebate Child', + 'username' => 'clamp_rebate_child', + 'rebate_limit' => 8, + 'default_player_rebate' => 6, + 'can_grant_extra_rebate' => true, + ])); + $grandchildAgent = $service->createChild($super, agentChildPayload([ + 'parent_id' => $childAgent->id, + 'code' => 'clamp-rebate-grandchild', + 'name' => 'Clamp Rebate Grandchild', + 'username' => 'clamp_rebate_grandchild', + 'rebate_limit' => 7, + 'default_player_rebate' => 5, + 'can_grant_extra_rebate' => true, + ])); + + $directPlayer = \App\Models\Player::query()->create([ + 'site_code' => $siteCode, + 'agent_node_id' => $agent->id, + 'site_player_id' => 'clamp-direct-player', + 'username' => 'clamp_direct_player', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + $downlinePlayer = \App\Models\Player::query()->create([ + 'site_code' => $siteCode, + 'agent_node_id' => $childAgent->id, + 'site_player_id' => 'clamp-downline-player', + 'username' => 'clamp_downline_player', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + $grandchildPlayer = \App\Models\Player::query()->create([ + 'site_code' => $siteCode, + 'agent_node_id' => $grandchildAgent->id, + 'site_player_id' => 'clamp-grandchild-player', + 'username' => 'clamp_grandchild_player', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('player_rebate_profiles')->insert([ + [ + 'player_id' => $directPlayer->id, + 'game_type' => '*', + 'inherit_from_agent' => false, + 'rebate_rate' => 0.10, + 'extra_rebate_rate' => 0.03, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'player_id' => $downlinePlayer->id, + 'game_type' => '*', + 'inherit_from_agent' => false, + 'rebate_rate' => 0.10, + 'extra_rebate_rate' => 0.03, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'player_id' => $grandchildPlayer->id, + 'game_type' => '*', + 'inherit_from_agent' => false, + 'rebate_rate' => 0.07, + 'extra_rebate_rate' => 0.01, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/agent-nodes/'.$agent->id.'/profile', [ + 'rebate_limit' => 2, + 'default_player_rebate' => 0, + 'can_grant_extra_rebate' => false, + ]) + ->assertOk() + ->assertJsonPath('data.rebate_limit', 2); + + $directProfile = DB::table('player_rebate_profiles')->where('player_id', $directPlayer->id)->first(); + expect(round((float) $directProfile->rebate_rate, 4))->toBe(0.02) + ->and(round((float) $directProfile->extra_rebate_rate, 4))->toBe(0.0); + + $childProfile = DB::table('agent_profiles')->where('agent_node_id', $childAgent->id)->first(); + expect(round((float) $childProfile->rebate_limit, 4))->toBe(0.02) + ->and(round((float) $childProfile->default_player_rebate, 4))->toBe(0.02) + ->and((bool) $childProfile->can_grant_extra_rebate)->toBeFalse(); + + $downlineProfile = DB::table('player_rebate_profiles')->where('player_id', $downlinePlayer->id)->first(); + expect(round((float) $downlineProfile->rebate_rate, 4))->toBe(0.02) + ->and(round((float) $downlineProfile->extra_rebate_rate, 4))->toBe(0.0); + + $grandchildProfile = DB::table('agent_profiles')->where('agent_node_id', $grandchildAgent->id)->first(); + expect(round((float) $grandchildProfile->rebate_limit, 4))->toBe(0.02) + ->and(round((float) $grandchildProfile->default_player_rebate, 4))->toBe(0.02) + ->and((bool) $grandchildProfile->can_grant_extra_rebate)->toBeFalse(); + + $grandchildPlayerProfile = DB::table('player_rebate_profiles')->where('player_id', $grandchildPlayer->id)->first(); + expect(round((float) $grandchildPlayerProfile->rebate_rate, 4))->toBe(0.02) + ->and(round((float) $grandchildPlayerProfile->extra_rebate_rate, 4))->toBe(0.0); +}); + +test('agent profile update rejects enabling extra rebate when parent disallows it', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + $service = app(\App\Services\Agent\AgentNodeService::class); + + \App\Models\AgentProfile::query()->updateOrCreate( + ['agent_node_id' => $rootId], + [ + 'total_share_rate' => 100, + 'credit_limit' => 10000, + 'allocated_credit' => 0, + 'used_credit' => 0, + 'rebate_limit' => 1, + 'default_player_rebate' => 0, + 'can_grant_extra_rebate' => true, + ], + ); + + $super = AdminUser::query()->create([ + 'username' => 'extra_rebate_super', + 'name' => 'Extra Rebate Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $parent = $service->createChild($super, agentChildPayload([ + 'parent_id' => $rootId, + 'code' => 'extra-rebate-parent', + 'name' => 'Extra Rebate Parent', + 'username' => 'extra_rebate_parent', + 'rebate_limit' => 10, + 'can_grant_extra_rebate' => false, + ])); + $child = $service->createChild($super, agentChildPayload([ + 'parent_id' => $parent->id, + 'code' => 'extra-rebate-child', + 'name' => 'Extra Rebate Child', + 'username' => 'extra_rebate_child', + 'rebate_limit' => 2, + ])); + + $token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/agent-nodes/'.$child->id.'/profile', [ + 'can_grant_extra_rebate' => true, + ]) + ->assertStatus(422) + ->assertJsonPath('code', ErrorCode::ValidationFailed->value); +}); + +test('lowering agent share rate clamps descendant agent share rates', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + $service = app(\App\Services\Agent\AgentNodeService::class); + + $super = AdminUser::query()->create([ + 'username' => 'clamp_share_super', + 'name' => 'Clamp Share Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $agent = $service->createChild($super, agentChildPayload([ + 'parent_id' => $rootId, + 'code' => 'clamp-share-agent', + 'name' => 'Clamp Share Agent', + 'username' => 'clamp_share_agent', + 'total_share_rate' => 60, + ])); + $childAgent = $service->createChild($super, agentChildPayload([ + 'parent_id' => $agent->id, + 'code' => 'clamp-share-child', + 'name' => 'Clamp Share Child', + 'username' => 'clamp_share_child', + 'total_share_rate' => 40, + ])); + $grandchildAgent = $service->createChild($super, agentChildPayload([ + 'parent_id' => $childAgent->id, + 'code' => 'clamp-share-grandchild', + 'name' => 'Clamp Share Grandchild', + 'username' => 'clamp_share_grandchild', + 'total_share_rate' => 25, + ])); + $lowShareGrandchild = $service->createChild($super, agentChildPayload([ + 'parent_id' => $childAgent->id, + 'code' => 'clamp-share-low', + 'name' => 'Clamp Share Low', + 'username' => 'clamp_share_low', + 'total_share_rate' => 15, + ])); + + $token = $super->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/agent-nodes/'.$agent->id.'/profile', [ + 'total_share_rate' => 20, + ]) + ->assertOk() + ->assertJsonPath('data.total_share_rate', 20); + + $childProfile = DB::table('agent_profiles')->where('agent_node_id', $childAgent->id)->first(); + expect(round((float) $childProfile->total_share_rate, 4))->toBe(20.0); + + $grandchildProfile = DB::table('agent_profiles')->where('agent_node_id', $grandchildAgent->id)->first(); + expect(round((float) $grandchildProfile->total_share_rate, 4))->toBe(20.0); + + $lowShareGrandchildProfile = DB::table('agent_profiles')->where('agent_node_id', $lowShareGrandchild->id)->first(); + expect(round((float) $lowShareGrandchildProfile->total_share_rate, 4))->toBe(15.0); +}); + test('partial agent profile update preserves unchanged share rate', function (): void { $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); diff --git a/tests/Feature/AdminAuthLoginTest.php b/tests/Feature/AdminAuthLoginTest.php index 043ee4e..5e478c9 100644 --- a/tests/Feature/AdminAuthLoginTest.php +++ b/tests/Feature/AdminAuthLoginTest.php @@ -2,6 +2,7 @@ use App\Models\AdminUser; use App\Lottery\ErrorCode; +use App\Support\SitePlatformRole; use Illuminate\Support\Str; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Cache; @@ -152,6 +153,36 @@ test('agent operator auth me omits platform-only navigation', function (): void ->and($keys)->not->toContain('admin_users', 'admin_roles', 'settings', 'integration', 'rules_plays'); }); +test('site admin auth me omits system settings navigation', function (): void { + $this->artisan('lottery:admin-auth-sync')->assertExitCode(0); + + $admin = AdminUser::query()->create([ + 'username' => 'site_admin_nav', + 'name' => 'Site Admin Nav', + 'email' => null, + 'password' => 'secret-strong', + 'status' => 0, + ]); + + $admin->roles()->sync([ + SitePlatformRole::id() => [ + 'site_id' => AdminUser::defaultAdminSiteId(), + 'granted_at' => now(), + ], + ]); + + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $segments = $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/auth/me') + ->assertOk() + ->json('data.admin.navigation'); + + $keys = array_column($segments, 'segment'); + expect($keys)->toContain('dashboard', 'agents', 'players', 'wallet') + ->and($keys)->not->toContain('settings', 'admin_users', 'admin_roles', 'integration', 'rules_plays'); +}); + test('admin captcha exposes key and image base64', function () { $resp = $this->getJson('/api/v1/admin/auth/captcha'); diff --git a/tests/Feature/AdminPlayerManageApiTest.php b/tests/Feature/AdminPlayerManageApiTest.php index 844832a..2c6dabd 100644 --- a/tests/Feature/AdminPlayerManageApiTest.php +++ b/tests/Feature/AdminPlayerManageApiTest.php @@ -237,6 +237,60 @@ test('player manage permission gates write and freeze APIs separately from view ->assertOk(); }); +test('agent players list can filter direct players without including downline players', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code'); + $rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + + $super = AdminUser::query()->create([ + 'username' => 'direct_player_super', + 'name' => 'Direct Player Super', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($super); + + $child = app(\App\Services\Agent\AgentNodeService::class)->createChild($super, [ + 'parent_id' => $rootId, + 'code' => 'direct-child', + 'name' => 'Direct Child', + ]); + + $rootPlayer = Player::query()->create([ + 'site_code' => $siteCode, + 'agent_node_id' => $rootId, + 'site_player_id' => 'direct-root-player', + 'username' => 'direct_root_player', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + $childPlayer = Player::query()->create([ + 'site_code' => $siteCode, + 'agent_node_id' => $child->id, + 'site_player_id' => 'direct-child-player', + 'username' => 'direct_child_player', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $token = playerManageAdminToken(); + + $rootResponse = $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/players?site_code='.$siteCode.'&agent_node_id='.$rootId.'&agent_scope=direct'); + + $rootResponse->assertOk()->assertJsonPath('data.meta.total', 1); + $rootIds = collect($rootResponse->json('data.items'))->pluck('id')->map(static fn ($id): int => (int) $id); + expect($rootIds)->toContain($rootPlayer->id)->not->toContain($childPlayer->id); + + $childResponse = $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/players?site_code='.$siteCode.'&agent_node_id='.$child->id.'&agent_scope=direct'); + + $childResponse->assertOk()->assertJsonPath('data.meta.total', 1); + $childIds = collect($childResponse->json('data.items'))->pluck('id')->map(static fn ($id): int => (int) $id); + expect($childIds)->toContain($childPlayer->id)->not->toContain($rootPlayer->id); +}); + test('admin can update player default currency and validation rejects unknown code', function (): void { $player = Player::query()->create([ 'site_code' => 'main', @@ -244,6 +298,7 @@ test('admin can update player default currency and validation rejects unknown co 'username' => 'currency_user', 'nickname' => 'Currency', 'default_currency' => 'NPR', + 'funding_mode' => PlayerFundingMode::WALLET, 'status' => 0, ]); @@ -268,6 +323,46 @@ test('admin can update player default currency and validation rejects unknown co ->assertStatus(422); }); +test('admin cannot change credit player default currency', function (): void { + $siteCode = DB::table('admin_sites')->where('is_default', true)->value('code'); + $siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site'; + $rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id'); + + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'agent_node_id' => $rootId, + 'site_player_id' => 'credit-currency-locked-1', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'credit_currency_locked', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 500, + 'used_credit' => 120, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $token = playerManageAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/players/'.$player->id, [ + 'default_currency' => 'USD', + ]) + ->assertStatus(422) + ->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value); + + $this->assertDatabaseHas('players', [ + 'id' => $player->id, + 'default_currency' => 'NPR', + ]); +}); + test('admin can set player credit limit without clobbering used credit', function (): void { $siteCode = DB::table('admin_sites')->where('is_default', true)->value('code'); $siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site'; diff --git a/tests/Feature/AdminSettingBatchUpdateTest.php b/tests/Feature/AdminSettingBatchUpdateTest.php index b08d54c..a15ee4d 100644 --- a/tests/Feature/AdminSettingBatchUpdateTest.php +++ b/tests/Feature/AdminSettingBatchUpdateTest.php @@ -4,6 +4,7 @@ use App\Models\AdminRole; use App\Models\AdminUser; use App\Models\LotterySetting; use App\Services\LotterySettings; +use App\Support\SitePlatformRole; use Illuminate\Support\Facades\Hash; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -22,24 +23,7 @@ function settingsAdminToken(): string 'password' => Hash::make('secret-strong'), 'status' => 0, ]); - - $role = AdminRole::query()->create([ - 'slug' => 'settings_role', - 'name' => 'Settings Role', - ]); - $role->syncLegacyPermissionSlugs([ - 'prd.payout.manage', - 'prd.draw_result.manage', - 'prd.wallet_reconcile.manage', - 'prd.rebate.manage', - ]); - - $admin->roles()->sync([ - (int) $role->id => [ - 'site_id' => AdminUser::defaultAdminSiteId(), - 'granted_at' => now(), - ], - ]); + grantSuperAdminRole($admin); return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; } @@ -70,6 +54,26 @@ function settingsReadOnlyToken(): string return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; } +function settingsSiteAdminToken(): string +{ + $admin = AdminUser::query()->create([ + 'username' => 'settings_site_admin', + 'name' => 'Settings Site Admin', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + $admin->roles()->sync([ + SitePlatformRole::id() => [ + 'site_id' => AdminUser::defaultAdminSiteId(), + 'granted_at' => now(), + ], + ]); + + return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + test('admin can batch update settings in one request', function (): void { LotterySettings::put('draw.cooldown_minutes', 15, 'draw'); LotterySettings::put('draw.require_manual_review', true, 'draw'); @@ -124,6 +128,35 @@ test('admin settings deployment returns read only snapshot', function (): void { ->assertJsonFragment(['key' => 'draw.interval_minutes', 'value' => 7]); }); +test('site admin cannot access system settings endpoints', function (): void { + LotterySettings::put('frontend.play_rules_html_zh', '
old
', 'frontend'); + $token = settingsSiteAdminToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/settings') + ->assertForbidden(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/settings/deployment') + ->assertForbidden(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/settings/frontend.play_rules_html_zh', [ + 'value' => '
new
', + ]) + ->assertForbidden(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/admin/settings/batch', [ + 'items' => [ + ['key' => 'frontend.play_rules_html_zh', 'value' => '
new
'], + ], + ]) + ->assertForbidden(); + + expect(LotterySetting::query()->where('setting_key', 'frontend.play_rules_html_zh')->value('value_json'))->toBe('
old
'); +}); + test('admin settings batch update rejects empty items', function (): void { $token = settingsAdminToken(); @@ -232,7 +265,7 @@ test('wallet max limit cannot be less than current min limit', function (): void expect(LotterySetting::query()->where('setting_key', 'wallet.transfer_in_max_minor')->value('value_json'))->toBe(10_000); }); -test('settings updates require permission for their setting group', function (): void { +test('non super admin cannot update settings even with setting related permissions', function (): void { LotterySettings::put('draw.cooldown_minutes', 15, 'draw'); LotterySettings::put('frontend.play_rules_html_zh', '
old
', 'frontend'); @@ -248,8 +281,8 @@ test('settings updates require permission for their setting group', function (): ->putJson('/api/v1/admin/settings/frontend.play_rules_html_zh', [ 'value' => '
new
', ]) - ->assertOk(); + ->assertForbidden(); expect(LotterySetting::query()->where('setting_key', 'draw.cooldown_minutes')->value('value_json'))->toBe(15) - ->and(LotterySetting::query()->where('setting_key', 'frontend.play_rules_html_zh')->value('value_json'))->toBe('
new
'); + ->and(LotterySetting::query()->where('setting_key', 'frontend.play_rules_html_zh')->value('value_json'))->toBe('
old
'); }); diff --git a/tests/Feature/AgentPeriodCloseFromGameSettlementTest.php b/tests/Feature/AgentPeriodCloseFromGameSettlementTest.php index 40481dc..f9610ce 100644 --- a/tests/Feature/AgentPeriodCloseFromGameSettlementTest.php +++ b/tests/Feature/AgentPeriodCloseFromGameSettlementTest.php @@ -138,6 +138,9 @@ test('period close aggregates share ledger written by game settlement recorder', expect($recorder->shouldRecord($item))->toBeTrue(); $recorder->recordForTicketItem($item, 0, 'settled_lose'); + DB::table('player_credit_accounts') + ->where('player_id', $player->id) + ->update(['used_credit' => \App\Support\CreditAmountScale::minorToMajor($betMinor, 'NPR')]); $item->refresh(); expect($item->agent_settled_at)->not->toBeNull() @@ -168,9 +171,26 @@ test('period close aggregates share ledger written by game settlement recorder', ->where('owner_id', $player->id) ->first(); + $usedAfterClose = (int) DB::table('player_credit_accounts') + ->where('player_id', $player->id) + ->value('used_credit'); + $rebateCreditLedger = DB::table('credit_ledger') + ->where('owner_type', 'player') + ->where('owner_id', $player->id) + ->where('reason', 'rebate_in_bill') + ->where('ref_type', 'settlement_bill') + ->where('ref_id', $playerBill->id ?? 0) + ->first(); + expect($playerBill)->not->toBeNull() ->and((int) $playerBill->gross_win_loss)->toBe($betMinor) - ->and((int) $playerBill->rebate_amount)->toBeGreaterThan(0); + ->and((int) $playerBill->rebate_amount)->toBeGreaterThan(0) + ->and($rebateCreditLedger)->not->toBeNull() + ->and((int) $rebateCreditLedger->amount)->toBe((int) $playerBill->rebate_amount) + ->and($usedAfterClose)->toBe( + \App\Support\CreditAmountScale::minorToMajor($betMinor, 'NPR') + - \App\Support\CreditAmountScale::minorToMajor((int) $playerBill->rebate_amount, 'NPR'), + ); }); test('credit settlement records base rebate plus add-on rebate and keeps extra rebate separate', function (): void { diff --git a/tests/Feature/CreditWalletLogsTest.php b/tests/Feature/CreditWalletLogsTest.php index 06d8752..7065f77 100644 --- a/tests/Feature/CreditWalletLogsTest.php +++ b/tests/Feature/CreditWalletLogsTest.php @@ -141,6 +141,72 @@ test('credit player wallet logs distinguish win credit from bill settlement', fu ->assertJsonPath('data.items.0.type', 'game_settlement'); }); +test('credit wallet logs cap available credit after win above credit limit', function (): void { + $player = Player::query()->create([ + 'site_code' => 'default_site', + 'site_player_id' => 'native:logs-win-cap', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'credit_logs_win_cap', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 200, + 'used_credit' => 0, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('credit_ledger')->insert([ + [ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => -5000, + 'reason' => 'bet_hold', + 'ref_type' => 'bet', + 'ref_id' => 1, + 'created_at' => now()->subMinutes(2), + 'updated_at' => now()->subMinutes(2), + ], + [ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => 5000, + 'reason' => 'bet_hold_release', + 'ref_type' => 'ticket_item', + 'ref_id' => 1, + 'created_at' => now()->subMinute(), + 'updated_at' => now()->subMinute(), + ], + [ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => 50000, + 'reason' => 'game_settlement_win', + 'ref_type' => 'ticket_item', + 'ref_id' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $data = $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/wallet/logs?page=1&size=10') + ->assertOk() + ->json('data'); + + expect($data['items'][0]['biz_type'])->toBe('game_settlement_win') + ->and($data['items'][0]['balance_after'])->toBe(20000) + ->and($data['items'][1]['biz_type'])->toBe('bet_hold_release') + ->and($data['items'][1]['balance_after'])->toBe(0) + ->and($data['items'][2]['biz_type'])->toBe('bet_hold') + ->and($data['items'][2]['balance_after'])->toBe(0); +}); + test('credit player wallet logs include period rebate records without affecting available credit', function (): void { $player = Player::query()->create([ 'site_code' => 'default_site', @@ -276,6 +342,6 @@ test('credit wallet logs page 2 balance_after continues from page 1 baseline', f $page1LastBalanceAfter = $page1['items'][9]['balance_after']; $page2FirstBalanceAfter = $page2['items'][0]['balance_after']; - // Page 2 must continue the running balance from page 1, not restart at current available. - expect($page2FirstBalanceAfter)->toBe($page1LastBalanceAfter + 1000); + // Page 2 must continue the running balance from page 1, capped by credit limit. + expect($page2FirstBalanceAfter)->toBe(min($page1LastBalanceAfter + 1000, 50000)); }); diff --git a/tests/Feature/SettlementPartialPaymentCreditTest.php b/tests/Feature/SettlementPartialPaymentCreditTest.php index e486620..815ce25 100644 --- a/tests/Feature/SettlementPartialPaymentCreditTest.php +++ b/tests/Feature/SettlementPartialPaymentCreditTest.php @@ -124,6 +124,26 @@ test('partial payments accumulate settlement_confirm ledger and release used cre expect((string) DB::table('settlement_bills')->where('id', $billId)->value('status'))->toBe('settled'); }); +test('rebate in player bill plus net payment releases full loss credit', function (): void { + $fixture = partialPaymentCreditFixture(900, 10); + $player = $fixture['player']; + $billId = $fixture['billId']; + $adminId = (int) $fixture['admin']->id; + + app(\App\Services\Player\PlayerCreditService::class)->releaseRebateInBill($player, 100, $billId); + + expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit')) + ->toBe(9); + + app(SettlementPaymentService::class)->recordPayment($billId, 900, $adminId); + + expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit')) + ->toBe(0); + expect(settlementBillLedgerRows($player, $billId, 'rebate_in_bill'))->toHaveCount(1) + ->and((int) settlementBillLedgerRows($player, $billId, 'rebate_in_bill')[0]->amount)->toBe(100) + ->and((int) settlementBillLedgerRows($player, $billId, 'settlement_confirm')[0]->amount)->toBe(900); +}); + test('partial payments accumulate settlement_payout ledger for player win bill', function (): void { $fixture = partialPaymentCreditFixture(-800); $player = $fixture['player']; diff --git a/tests/Feature/TicketBettingApiTest.php b/tests/Feature/TicketBettingApiTest.php index 699bc57..ee7317b 100644 --- a/tests/Feature/TicketBettingApiTest.php +++ b/tests/Feature/TicketBettingApiTest.php @@ -1033,6 +1033,61 @@ test('credit player ticket preview shows period rebate estimate without instant ->assertJsonPath('data.lines.0.actual_deduct_amount', 10_000); }); +test('credit player preview and place reject non default currency', function (): void { + $site = DB::table('admin_sites')->where('is_default', true)->first(); + $rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id'); + + $player = Player::query()->create([ + 'site_code' => (string) $site->code, + 'agent_node_id' => $rootId, + 'site_player_id' => 'native:credit-currency-mismatch', + 'auth_source' => 'lottery_native', + 'funding_mode' => 'credit', + 'username' => 'credit_currency_user', + 'password_hash' => bcrypt('secret-pass'), + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 500_000, + 'used_credit' => 0, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + ticketOpenDraw('20260511-credit-currency'); + + $payload = [ + 'draw_id' => '20260511-credit-currency', + 'currency_code' => 'USD', + 'client_trace_id' => 'trace-credit-currency-mismatch', + 'lines' => [ + ['number' => '12', 'play_code' => 'pos_2a', 'amount' => 10_000], + ], + ]; + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/preview', $payload) + ->assertStatus(400) + ->assertJsonPath('code', ErrorCode::ConfigCurrencyInvalid->value) + ->assertJsonPath('data.currency_code', 'USD') + ->assertJsonPath('data.expected_currency', 'NPR'); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->postJson('/api/v1/ticket/place', $payload) + ->assertStatus(400) + ->assertJsonPath('code', ErrorCode::ConfigCurrencyInvalid->value) + ->assertJsonPath('data.currency_code', 'USD') + ->assertJsonPath('data.expected_currency', 'NPR'); + + expect(TicketOrder::query()->where('player_id', $player->id)->exists())->toBeFalse() + ->and(DB::table('credit_ledger')->where('owner_id', $player->id)->exists())->toBeFalse(); +}); + test('ticket pending confirmation reconcile releases risk when wallet deduction is missing', function (): void { $draw = ticketOpenDraw(); $player = ticketPlayerWithWallet(); diff --git a/tests/Feature/WalletSettlementBillsTest.php b/tests/Feature/WalletSettlementBillsTest.php new file mode 100644 index 0000000..9c397eb --- /dev/null +++ b/tests/Feature/WalletSettlementBillsTest.php @@ -0,0 +1,160 @@ +seed(CurrencySeeder::class); +}); + +test('credit player can view own pending settlement bills summary', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $player = Player::query()->create([ + 'site_code' => 'default_site', + 'site_player_id' => 'native:settlement-bills', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'settlement_bills_player', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => now()->subWeek(), + 'period_end' => now(), + 'status' => 'closed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('settlement_bills')->insert([ + [ + 'settlement_period_id' => $periodId, + 'bill_type' => 'player', + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'counterparty_type' => 'agent', + 'counterparty_id' => 1, + 'gross_win_loss' => -50000, + 'rebate_amount' => 0, + 'adjustment_amount' => 0, + 'net_amount' => -50000, + 'paid_amount' => 10000, + 'unpaid_amount' => 40000, + 'status' => 'partial_paid', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'settlement_period_id' => $periodId, + 'bill_type' => 'player', + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'counterparty_type' => 'agent', + 'counterparty_id' => 1, + 'gross_win_loss' => 12000, + 'rebate_amount' => 0, + 'adjustment_amount' => 0, + 'net_amount' => 12000, + 'paid_amount' => 0, + 'unpaid_amount' => 12000, + 'status' => 'confirmed', + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/wallet/settlement-bills') + ->assertOk() + ->assertJsonPath('data.summary.pending_receivable', 40000) + ->assertJsonPath('data.summary.pending_payable', 12000) + ->assertJsonPath('data.summary.net_pending', 28000) + ->assertJsonPath('data.summary.pending_count', 2) + ->assertJsonPath('data.items.0.direction', 'payable') + ->assertJsonPath('data.items.1.direction', 'receivable'); +}); + +test('credit player win above credit limit is shown as agent payable to player', function (): void { + $siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $player = Player::query()->create([ + 'site_code' => 'default_site', + 'site_player_id' => 'native:settlement-win-above-limit', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'settlement_win_above_limit', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 200, + 'used_credit' => 0, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => now()->subWeek(), + 'period_end' => now(), + 'status' => 'closed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('settlement_bills')->insert([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'player', + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'counterparty_type' => 'agent', + 'counterparty_id' => 1, + 'gross_win_loss' => -25000, + 'rebate_amount' => 0, + 'adjustment_amount' => 0, + 'net_amount' => -25000, + 'paid_amount' => 0, + 'unpaid_amount' => 25000, + 'status' => 'confirmed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/wallet/settlement-bills') + ->assertOk() + ->assertJsonPath('data.summary.pending_receivable', 25000) + ->assertJsonPath('data.summary.pending_payable', 0) + ->assertJsonPath('data.summary.net_pending', 25000) + ->assertJsonPath('data.items.0.direction', 'receivable') + ->assertJsonPath('data.items.0.unpaid_amount', 25000) + ->assertJsonPath('data.items.0.net_amount', -25000); +}); + +test('wallet player settlement bills response is empty', function (): void { + $player = Player::query()->create([ + 'site_code' => 'default_site', + 'site_player_id' => 'wallet:settlement-bills', + 'auth_source' => PlayerAuthSource::MAIN_SITE_SSO, + 'funding_mode' => PlayerFundingMode::WALLET, + 'username' => 'wallet_settlement_bills', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $this->withHeader('Authorization', 'Bearer dev:'.$player->id) + ->getJson('/api/v1/wallet/settlement-bills') + ->assertOk() + ->assertJsonPath('data.summary.pending_count', 0) + ->assertJsonCount(0, 'data.items'); +});