artisan('lottery:admin-auth-sync')->assertExitCode(0); }); function createSiteWithRoot(string $code): array { $siteId = (int) DB::table('admin_sites')->insertGetId([ 'code' => $code, 'name' => $code, 'is_default' => false, 'created_at' => now(), 'updated_at' => now(), ]); $rootId = (int) DB::table('agent_nodes')->insertGetId([ 'admin_site_id' => $siteId, 'parent_id' => null, 'depth' => 0, 'path' => '/'.$code, 'code' => $code, 'name' => 'Root '.$code, 'status' => 1, 'created_at' => now(), 'updated_at' => now(), ]); AgentProfile::query()->create([ 'agent_node_id' => $rootId, 'total_share_rate' => 100, 'credit_limit' => 500_000, 'allocated_credit' => 0, 'used_credit' => 0, 'rebate_limit' => 0.01, 'default_player_rebate' => 0.005, 'settlement_cycle' => 'weekly', ]); return ['site_id' => $siteId, 'site_code' => $code, 'root_id' => $rootId]; } function createTicketItemForPlayer(Player $player, string $ticketNo): int { $draw = Draw::query()->create([ 'draw_no' => 'DRAW-'.$ticketNo, 'business_date' => now()->toDateString(), 'sequence_no' => random_int(1, 9999), 'status' => DrawStatus::Open->value, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ]); $orderId = (int) DB::table('ticket_orders')->insertGetId([ 'order_no' => 'ORD-'.$ticketNo, 'player_id' => $player->id, 'draw_id' => $draw->id, 'currency_code' => 'NPR', 'total_bet_amount' => 10_000, 'total_rebate_amount' => 0, 'total_actual_deduct' => 10_000, 'total_estimated_payout' => 0, 'status' => 'confirmed', 'submit_source' => 'h5', 'client_trace_id' => null, 'created_at' => now(), 'updated_at' => now(), ]); return (int) DB::table('ticket_items')->insertGetId([ 'ticket_no' => $ticketNo, 'order_id' => $orderId, 'player_id' => $player->id, 'draw_id' => $draw->id, 'original_number' => null, 'normalized_number' => '1234', 'play_code' => 'big', 'dimension' => 2, 'digit_slot' => null, 'bet_mode' => null, 'unit_bet_amount' => 10_000, 'total_bet_amount' => 10_000, 'rebate_rate_snapshot' => 0, 'commission_rate_snapshot' => 0, 'actual_deduct_amount' => 10_000, 'odds_snapshot_json' => null, 'rule_snapshot_json' => null, 'combination_count' => 1, 'estimated_max_payout' => 0, 'risk_locked_amount' => 0, 'status' => 'settled_lose', 'win_amount' => 0, 'jackpot_win_amount' => 0, 'settled_at' => null, 'created_at' => now(), 'updated_at' => now(), ]); } test('period close only tags share ledger rows for the closing site', function (): void { $siteA = createSiteWithRoot('close-site-a'); $siteB = createSiteWithRoot('close-site-b'); $playerA = Player::query()->create([ 'site_code' => $siteA['site_code'], 'agent_node_id' => $siteA['root_id'], 'site_player_id' => 'p-close-a', 'username' => 'close_a', 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); $playerB = Player::query()->create([ 'site_code' => $siteB['site_code'], 'agent_node_id' => $siteB['root_id'], 'site_player_id' => 'p-close-b', 'username' => 'close_b', 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); $settledAt = now(); $snapshot = json_encode([ 'total_shares' => ['close-site-a' => 100], 'chain_codes' => ['close-site-a'], 'agent_path' => [$siteA['root_id']], ]); $snapshotB = json_encode([ 'total_shares' => ['close-site-b' => 100], 'chain_codes' => ['close-site-b'], 'agent_path' => [$siteB['root_id']], ]); $ticketAId = createTicketItemForPlayer($playerA, 'T-P0-A'); $ticketBId = createTicketItemForPlayer($playerB, 'T-P0-B'); $ledgerAId = (int) DB::table('share_ledger')->insertGetId([ 'ticket_item_id' => $ticketAId, 'player_id' => $playerA->id, 'agent_node_id' => $siteA['root_id'], 'agent_path' => json_encode([$siteA['root_id']]), 'share_snapshot' => $snapshot, 'game_win_loss' => 1000, 'basic_rebate' => 0, 'shared_net_win_loss' => 1000, 'allocations_json' => json_encode([]), 'settled_at' => $settledAt, 'created_at' => $settledAt, 'updated_at' => $settledAt, ]); $ledgerBId = (int) DB::table('share_ledger')->insertGetId([ 'ticket_item_id' => $ticketBId, 'player_id' => $playerB->id, 'agent_node_id' => $siteB['root_id'], 'agent_path' => json_encode([$siteB['root_id']]), 'share_snapshot' => $snapshotB, 'game_win_loss' => 2000, 'basic_rebate' => 0, 'shared_net_win_loss' => 2000, 'allocations_json' => json_encode([]), 'settled_at' => $settledAt, 'created_at' => $settledAt, 'updated_at' => $settledAt, ]); $periodId = (int) DB::table('settlement_periods')->insertGetId([ 'admin_site_id' => $siteA['site_id'], 'period_start' => $settledAt->copy()->subDay(), 'period_end' => $settledAt->copy()->addDay(), 'status' => 'open', 'created_at' => now(), 'updated_at' => now(), ]); app(AgentSettlementPeriodCloseService::class)->closePeriod($periodId); expect((int) DB::table('share_ledger')->where('id', $ledgerAId)->value('settlement_period_id')) ->toBe($periodId); expect(DB::table('share_ledger')->where('id', $ledgerBId)->value('settlement_period_id')) ->toBeNull(); }); test('agent bill unpaid amount uses abs net so player-wins chain can be paid', 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' => 'p0_agent_pay', 'name' => 'P0', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($super); $service = app(AgentNodeService::class); $a = $service->createChild($super, agentChildPayload([ 'parent_id' => $rootId, 'code' => 'P0A', 'name' => 'P0A', 'username' => 'p0_a', 'total_share_rate' => 60, 'credit_limit' => 500_000, ])); $b = $service->createChild($super, agentChildPayload([ 'parent_id' => $a->id, 'code' => 'P0B', 'name' => 'P0B', 'username' => 'p0_b', 'total_share_rate' => 40, 'credit_limit' => 200_000, ])); $c = $service->createChild($super, agentChildPayload([ 'parent_id' => $b->id, 'code' => 'P0C', 'name' => 'P0C', 'username' => 'p0_c', 'total_share_rate' => 25, 'credit_limit' => 100_000, ])); $player = Player::query()->create([ 'site_code' => $siteCode, 'agent_node_id' => $c->id, 'site_player_id' => 'p0-win-p1', 'username' => 'p0win', 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); $settledAt = now(); $periodId = (int) DB::table('settlement_periods')->insertGetId([ 'admin_site_id' => $siteId, 'period_start' => $settledAt->copy()->subDay(), 'period_end' => $settledAt->copy()->addDay(), 'status' => 'open', 'created_at' => now(), 'updated_at' => now(), ]); $ticketItemId = createTicketItemForPlayer($player, 'T-P0-WIN'); // Player wins: negative game_win_loss produces negative agent edge settlements. DB::table('share_ledger')->insert([ 'ticket_item_id' => $ticketItemId, 'player_id' => $player->id, 'agent_node_id' => $c->id, 'agent_path' => json_encode([$a->id, $b->id, $c->id]), 'share_snapshot' => json_encode([ 'total_shares' => ['P0C' => 25, 'P0B' => 40, 'P0A' => 60], 'actual_shares' => ['P0C' => 25, 'P0B' => 15, 'P0A' => 20, 'platform' => 40], 'chain_codes' => ['P0C', 'P0B', 'P0A'], 'agent_path' => [$a->id, $b->id, $c->id], ]), 'game_win_loss' => -DesignDocExample12::GAME_WIN_LOSS, 'basic_rebate' => DesignDocExample12::BASIC_REBATE, 'shared_net_win_loss' => -DesignDocExample12::SHARED_NET_WIN_LOSS, 'allocations_json' => json_encode([]), 'settled_at' => $settledAt, 'created_at' => $settledAt, 'updated_at' => $settledAt, ]); app(AgentSettlementPeriodCloseService::class)->closePeriod($periodId); $agentBill = DB::table('settlement_bills') ->where('settlement_period_id', $periodId) ->where('bill_type', 'agent') ->where('meta_json', 'like', '%P0C_to_P0B%') ->first(); expect($agentBill)->not->toBeNull(); expect((int) $agentBill->net_amount)->toBeLessThan(0); expect((int) $agentBill->unpaid_amount)->toBe(abs((int) $agentBill->net_amount)); DB::table('settlement_bills')->where('id', $agentBill->id)->update([ 'status' => 'confirmed', 'updated_at' => now(), ]); app(SettlementPaymentService::class)->recordPayment( (int) $agentBill->id, (int) $agentBill->unpaid_amount, (int) $super->id, ['method' => 'cash'], ); $record = DB::table('payment_records')->where('settlement_bill_id', $agentBill->id)->first(); expect($record)->not->toBeNull(); expect((string) $record->payer_type)->toBe('agent'); expect((int) $record->payer_id)->toBe((int) $agentBill->counterparty_id); expect((string) $record->payee_type)->toBe('agent'); expect((int) $record->payee_id)->toBe((int) $agentBill->owner_id); $refreshed = DB::table('settlement_bills')->where('id', $agentBill->id)->first(); expect((int) $refreshed->unpaid_amount)->toBe(0); expect((string) $refreshed->status)->toBe('settled'); }); test('period close fails when share ledger row is missing snapshot', 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'); $player = Player::query()->create([ 'site_code' => $siteCode, 'agent_node_id' => $rootId, 'site_player_id' => 'p0-missing-snap', 'username' => 'p0snap', 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); $settledAt = now(); $ticketItemId = createTicketItemForPlayer($player, 'T-P0-NOSNAP'); DB::table('share_ledger')->insert([ 'ticket_item_id' => $ticketItemId, 'player_id' => $player->id, 'agent_node_id' => $rootId, 'agent_path' => json_encode([$rootId]), 'share_snapshot' => null, 'game_win_loss' => 500, 'basic_rebate' => 0, 'shared_net_win_loss' => 500, 'allocations_json' => json_encode([]), 'settled_at' => $settledAt, 'created_at' => $settledAt, 'updated_at' => $settledAt, ]); $periodId = (int) DB::table('settlement_periods')->insertGetId([ 'admin_site_id' => $siteId, 'period_start' => $settledAt->copy()->subDay(), 'period_end' => $settledAt->copy()->addDay(), 'status' => 'open', 'created_at' => now(), 'updated_at' => now(), ]); $caught = null; try { app(AgentSettlementPeriodCloseService::class)->closePeriod($periodId); } catch (\Illuminate\Validation\ValidationException $e) { $caught = $e; } expect($caught)->toBeInstanceOf(\Illuminate\Validation\ValidationException::class); expect($caught?->errors()['period'][0] ?? null)->toBe('share_snapshot_missing'); expect((string) DB::table('settlement_periods')->where('id', $periodId)->value('status')) ->toBe('open'); expect(DB::table('settlement_bills')->where('settlement_period_id', $periodId)->count()) ->toBe(0); }); test('period close succeeds with no share ledger rows in window', function (): void { ['site_id' => $siteId] = createSiteWithRoot('empty-close'); $periodId = (int) DB::table('settlement_periods')->insertGetId([ 'admin_site_id' => $siteId, 'period_start' => '2026-06-01 00:00:00', 'period_end' => '2026-06-07 23:59:59', 'status' => 'open', 'created_at' => now(), 'updated_at' => now(), ]); $result = app(AgentSettlementPeriodCloseService::class)->closePeriod($periodId); expect($result['bill_ids'])->toBe([]); expect($result['player_count'])->toBe(0); expect((string) DB::table('settlement_periods')->where('id', $periodId)->value('status')) ->toBe('closed'); expect(DB::table('settlement_bills')->where('settlement_period_id', $periodId)->count()) ->toBe(0); });