artisan('lottery:admin-auth-sync')->assertExitCode(0); $this->artisan('lottery:agent-roles-sync')->assertExitCode(0); }); test('bound parent agent cannot see player bills under direct child agent', function (): void { [$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId] = seedDirectEdgeSettlementFixture(); $playerBillId = insertPlayerBill($periodId, $player->id, $child->id, 4400); insertAgentBill($periodId, $child->id, $parent->id, 4400); insertAgentBill($periodId, $parent->id, $rootId, 3520); $parentAdmin = createBoundSettlementAgentAdmin($siteId, $parent, 'parent_scope'); $childAdmin = createBoundSettlementAgentAdmin($siteId, $child, 'child_scope'); $parentToken = $parentAdmin->createToken('test', ['*'], now()->addDay())->plainTextToken; expect(\App\Support\AdminAgentSettlementScope::billAccessible($childAdmin, $playerBillId))->toBeTrue() ->and(\App\Support\AdminAgentSettlementScope::billAccessible($parentAdmin, $playerBillId))->toBeFalse(); $this->withHeader('Authorization', 'Bearer '.$parentToken) ->getJson('/api/v1/admin/settlement-bills?settlement_period_id='.$periodId) ->assertOk() ->assertJsonPath('data.items', function (array $items) use ($playerBillId): bool { $ids = array_column($items, 'id'); return ! in_array($playerBillId, $ids, true); }); $this->withHeader('Authorization', 'Bearer '.$parentToken) ->getJson('/api/v1/admin/settlement-bills/'.$playerBillId) ->assertNotFound(); $this->actingAs($childAdmin, 'sanctum') ->getJson('/api/v1/admin/settlement-bills?settlement_period_id='.$periodId.'&bill_id='.$playerBillId) ->assertOk() ->assertJsonPath('data.items.0.id', $playerBillId); $this->actingAs($childAdmin, 'sanctum') ->getJson('/api/v1/admin/settlement-bills/'.$playerBillId) ->assertOk() ->assertJsonPath('data.bill.id', $playerBillId); }); test('bound parent agent sees direct child agent bill but not deeper chain bills', function (): void { [$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId] = seedDirectEdgeSettlementFixture(); $childToParentBillId = insertAgentBill($periodId, $child->id, $parent->id, 4400); $parentToRootBillId = insertAgentBill($periodId, $parent->id, $rootId, 3520); $super = AdminUser::query()->create([ 'username' => 'edge_grand_super_'.uniqid(), 'name' => 'Super', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($super); $grandchild = app(\App\Services\Agent\AgentNodeService::class)->createChild( $super, [ 'parent_id' => $child->id, 'code' => 'edge-grandchild', 'name' => 'Edge Grandchild', ], ); $deepBillId = insertAgentBill($periodId, $grandchild->id, $child->id, 1200); $parentAdmin = createBoundSettlementAgentAdmin($siteId, $parent, 'parent_edge'); $parentToken = $parentAdmin->createToken('test', ['*'], now()->addDay())->plainTextToken; $response = $this->withHeader('Authorization', 'Bearer '.$parentToken) ->getJson('/api/v1/admin/settlement-bills?settlement_period_id='.$periodId) ->assertOk(); $ids = array_column($response->json('data.items'), 'id'); expect($ids)->toContain($childToParentBillId) ->and($ids)->toContain($parentToRootBillId) ->and($ids)->not->toContain($deepBillId); }); test('bound parent agent cannot confirm or pay player bill under child', function (): void { [$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId] = seedDirectEdgeSettlementFixture(); $playerBillId = (int) DB::table('settlement_bills')->insertGetId([ 'settlement_period_id' => $periodId, 'bill_type' => 'player', 'owner_type' => 'player', 'owner_id' => $player->id, 'counterparty_type' => 'agent', 'counterparty_id' => $child->id, 'net_amount' => 4400, 'unpaid_amount' => 4400, 'paid_amount' => 0, 'status' => 'pending_confirm', 'created_at' => now(), 'updated_at' => now(), ]); $parentAdmin = createBoundSettlementAgentAdmin($siteId, $parent, 'parent_pay'); $parentToken = $parentAdmin->createToken('test', ['*'], now()->addDay())->plainTextToken; $this->withHeader('Authorization', 'Bearer '.$parentToken) ->postJson('/api/v1/admin/settlement-bills/'.$playerBillId.'/confirm') ->assertNotFound(); DB::table('settlement_bills')->where('id', $playerBillId)->update(['status' => 'confirmed']); $this->withHeader('Authorization', 'Bearer '.$parentToken) ->postJson('/api/v1/admin/settlement-bills/'.$playerBillId.'/payments', [ 'amount' => 4400, ]) ->assertNotFound(); }); test('parent agent can register payment on child agent bill where parent is payee', function (): void { [$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId] = seedDirectEdgeSettlementFixture(); $agentBillId = (int) DB::table('settlement_bills')->insertGetId([ 'settlement_period_id' => $periodId, 'bill_type' => 'agent', 'owner_type' => 'agent', 'owner_id' => $child->id, 'counterparty_type' => 'agent', 'counterparty_id' => $parent->id, 'net_amount' => 4400, 'unpaid_amount' => 4400, 'paid_amount' => 0, 'status' => 'confirmed', 'created_at' => now(), 'updated_at' => now(), ]); $parentAdmin = createBoundSettlementAgentAdmin($siteId, $parent, 'parent_payee'); $childAdmin = createBoundSettlementAgentAdmin($siteId, $child, 'child_payer'); $this->actingAs($parentAdmin, 'sanctum') ->postJson('/api/v1/admin/settlement-bills/'.$agentBillId.'/payments', ['amount' => 4400]) ->assertOk(); $this->actingAs($childAdmin, 'sanctum') ->postJson('/api/v1/admin/settlement-bills/'.$agentBillId.'/payments', ['amount' => 4400]) ->assertForbidden(); }); test('settlement credit ledger excludes players under child agent for parent viewer', function (): void { [$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId] = seedDirectEdgeSettlementFixture(); DB::table('credit_ledger')->insert([ 'owner_type' => 'player', 'owner_id' => $player->id, 'amount' => -100, 'reason' => 'bet_hold', 'created_at' => now(), 'updated_at' => now(), ]); $parentAdmin = createBoundSettlementAgentAdmin($siteId, $parent, 'parent_ledger'); $this->actingAs($parentAdmin, 'sanctum') ->getJson('/api/v1/admin/credit-ledger?admin_site_id='.$siteId.'&settlement_period_id='.$periodId) ->assertOk() ->assertJsonPath('data.items', fn (array $items): bool => count($items) === 0); }); test('direct child agent can confirm and pay own player bill', function (): void { [$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId] = seedDirectEdgeSettlementFixture(); $playerBillId = (int) DB::table('settlement_bills')->insertGetId([ 'settlement_period_id' => $periodId, 'bill_type' => 'player', 'owner_type' => 'player', 'owner_id' => $player->id, 'counterparty_type' => 'agent', 'counterparty_id' => $child->id, 'net_amount' => 4400, 'unpaid_amount' => 4400, 'paid_amount' => 0, 'status' => 'pending_confirm', 'created_at' => now(), 'updated_at' => now(), ]); $childAdmin = createBoundSettlementAgentAdmin($siteId, $child, 'child_pay'); $this->actingAs($childAdmin, 'sanctum') ->postJson('/api/v1/admin/settlement-bills/'.$playerBillId.'/confirm') ->assertOk(); $this->actingAs($childAdmin, 'sanctum') ->postJson('/api/v1/admin/settlement-bills/'.$playerBillId.'/payments', [ 'amount' => 4400, ]) ->assertOk() ->assertJsonPath('data.bill.status', 'settled'); }); /** * @return array{0: int, 1: string, 2: int, 3: \App\Models\AgentNode, 4: \App\Models\AgentNode, 5: Player, 6: int} */ function seedDirectEdgeSettlementFixture(): array { $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' => 'edge_scope_super_'.uniqid(), 'name' => 'Super', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($super); $service = app(\App\Services\Agent\AgentNodeService::class); $parent = $service->createChild($super, [ 'parent_id' => $rootId, 'code' => 'edge-parent-'.uniqid(), 'name' => 'Edge Parent', ]); $child = $service->createChild($super, [ 'parent_id' => $parent->id, 'code' => 'edge-child-'.uniqid(), 'name' => 'Edge Child', ]); $player = Player::query()->create([ 'site_code' => $siteCode, 'site_player_id' => 'native:edge-'.uniqid(), 'funding_mode' => PlayerFundingMode::CREDIT, 'username' => 'edge_player_'.uniqid(), 'default_currency' => 'NPR', 'status' => 0, 'agent_node_id' => $child->id, ]); $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(), ]); return [$siteId, $siteCode, $rootId, $parent, $child, $player, $periodId]; } function insertPlayerBill(int $periodId, int $playerId, int $agentId, int $amount): int { return (int) DB::table('settlement_bills')->insertGetId([ 'settlement_period_id' => $periodId, 'bill_type' => 'player', 'owner_type' => 'player', 'owner_id' => $playerId, 'counterparty_type' => 'agent', 'counterparty_id' => $agentId, 'net_amount' => $amount, 'unpaid_amount' => $amount, 'paid_amount' => 0, 'status' => 'confirmed', 'created_at' => now(), 'updated_at' => now(), ]); } function insertAgentBill(int $periodId, int $ownerId, int $counterpartyId, int $amount): int { return (int) DB::table('settlement_bills')->insertGetId([ 'settlement_period_id' => $periodId, 'bill_type' => 'agent', 'owner_type' => 'agent', 'owner_id' => $ownerId, 'counterparty_type' => 'agent', 'counterparty_id' => $counterpartyId, 'net_amount' => $amount, 'unpaid_amount' => $amount, 'paid_amount' => 0, 'status' => 'confirmed', 'created_at' => now(), 'updated_at' => now(), ]); } function createBoundSettlementAgentAdmin(int $siteId, \App\Models\AgentNode $agent, string $prefix): AdminUser { $admin = AdminUser::query()->create([ 'username' => $prefix.'_'.uniqid(), 'name' => ucfirst($prefix), 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); DB::table('admin_user_agents')->insert([ 'admin_user_id' => $admin->id, 'agent_node_id' => (int) $agent->id, 'is_primary' => true, 'granted_at' => now(), ]); $admin->syncPrimaryPlatformAgentRole((int) $agent->id); return $admin->fresh(); } function grantSettlementManageToAgentAdmin(AdminUser $admin, \App\Models\AgentNode $agent): void { $now = now(); $roleId = DB::table('admin_roles')->insertGetId([ 'slug' => 'settle_manage_'.$admin->id, 'code' => 'settle_manage_'.$admin->id, 'name' => 'Settlement Manage', 'status' => 1, 'is_system' => false, 'sort_order' => 0, 'created_at' => $now, 'updated_at' => $now, ]); $actionIds = DB::table('admin_menu_actions') ->whereIn('permission_code', ['settlement.agent.manage', 'prd.settlement.agent.manage']) ->pluck('id'); foreach ($actionIds as $actionId) { DB::table('admin_role_menu_actions')->insert([ 'role_id' => $roleId, 'menu_action_id' => (int) $actionId, ]); } DB::table('admin_user_agent_roles')->insert([ 'admin_user_id' => $admin->id, 'agent_node_id' => (int) $agent->id, 'role_id' => $roleId, 'granted_at' => $now, ]); }