399 lines
14 KiB
PHP
399 lines
14 KiB
PHP
<?php
|
|
|
|
use App\Models\AdminUser;
|
|
use App\Models\AgentProfile;
|
|
use App\Models\Draw;
|
|
use App\Models\Player;
|
|
use App\Lottery\DrawStatus;
|
|
use App\Services\Agent\AgentNodeService;
|
|
use App\Services\AgentSettlement\AgentSettlementPeriodCloseService;
|
|
use App\Services\AgentSettlement\SettlementPaymentService;
|
|
use App\Support\Settlement\DesignDocExample12;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Hash;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function (): void {
|
|
$this->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,
|
|
]);
|
|
|
|
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);
|
|
});
|