feat(core): harden sessions settlement and credit activity
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminUser;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Lottery\DrawStatus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use App\Support\AdminPermissionBridge;
|
||||
@@ -95,6 +97,25 @@ test('admin api resource middleware denies wallet reconcile resource without per
|
||||
->assertJsonPath('code', ErrorCode::AdminForbidden->value);
|
||||
});
|
||||
|
||||
test('admin api resource middleware denies settlement review only account from running draw settlement', function (): void {
|
||||
$token = mintAdminTokenWithLegacySlugs('resource_settlement_reviewer', ['prd.payout.review']);
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260722-2001',
|
||||
'business_date' => '2026-07-22',
|
||||
'sequence_no' => 2001,
|
||||
'status' => DrawStatus::Cooldown->value,
|
||||
'cooling_end_time' => now()->addMinutes(10),
|
||||
'settle_version' => 0,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/settlement/run")
|
||||
->assertForbidden()
|
||||
->assertJsonPath('code', ErrorCode::AdminForbidden->value);
|
||||
|
||||
expect($draw->fresh()->status)->toBe(DrawStatus::Cooldown->value);
|
||||
});
|
||||
|
||||
test('admin api resource middleware allows wallet reconcile resource with mapped permission', function (): void {
|
||||
$token = mintAdminTokenWithLegacySlugs('resource_wallet_viewer', ['prd.wallet_reconcile.view']);
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\AdminUser;
|
||||
use App\Support\AuditLogApiPresenter;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Services\AuditLogger;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use App\Services\AuditLogger;
|
||||
use App\Support\AuditLogApiPresenter;
|
||||
use App\Services\Agent\AgentNodeService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@@ -36,6 +37,24 @@ test('audit log presenter maps business action and entity target', function ():
|
||||
->and($payload['summary_label'])->toBe('代理管理 · 同步代理角色权限(角色 #5)');
|
||||
});
|
||||
|
||||
test('audit log presenter translates automatic payout failures', function (): void {
|
||||
$row = new AuditLog([
|
||||
'operator_type' => 'system',
|
||||
'operator_id' => 0,
|
||||
'module_code' => 'settlement',
|
||||
'action_code' => 'auto_payout_failed',
|
||||
'target_type' => 'settlement_batch',
|
||||
'target_id' => '1107',
|
||||
]);
|
||||
$row->id = 2;
|
||||
|
||||
$payload = AuditLogApiPresenter::row($row);
|
||||
|
||||
expect($payload['action_label'])->toBe('自动派彩失败')
|
||||
->and($payload['target_label'])->toBe('结算批次 #1107')
|
||||
->and($payload['summary_label'])->toBe('派彩结算 · 自动派彩失败(结算批次 #1107)');
|
||||
});
|
||||
|
||||
test('audit log presenter uses admin api resource name for middleware style rows', function (): void {
|
||||
$resourceName = (string) DB::table('admin_api_resources')
|
||||
->where('code', 'admin.agent-roles.permissions.sync')
|
||||
@@ -63,7 +82,7 @@ test('audit log presenter uses admin api resource name for middleware style rows
|
||||
test('agent role permission sync records one business audit and skips middleware duplicate', 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);
|
||||
$service = app(AgentNodeService::class);
|
||||
|
||||
$super = AdminUser::query()->create([
|
||||
'username' => 'audit_dedup_super',
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
use App\Models\AdminUser;
|
||||
use App\Lottery\ErrorCode;
|
||||
use App\Support\SitePlatformRole;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Support\SitePlatformRole;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -92,6 +92,89 @@ test('admin login returns bearer token when captcha passes validation', function
|
||||
->assertJsonPath('data.scope', 'admin');
|
||||
});
|
||||
|
||||
test('later admin login replaces the previous browser session', function () {
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'single_session_admin',
|
||||
'name' => '单会话管理员',
|
||||
'email' => null,
|
||||
'password' => 'secret-strong',
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
|
||||
$login = function () {
|
||||
$captchaKey = (string) Str::uuid();
|
||||
Cache::put(
|
||||
'admin_captcha:'.$captchaKey,
|
||||
hash_hmac('sha256', 'xwz2', (string) config('app.key')),
|
||||
now()->addSeconds(120),
|
||||
);
|
||||
|
||||
return $this->postJson('/api/v1/admin/auth/login', [
|
||||
'account' => 'single_session_admin',
|
||||
'password' => 'secret-strong',
|
||||
'captcha_key' => $captchaKey,
|
||||
'captcha_code' => 'xwz2',
|
||||
])->assertOk();
|
||||
};
|
||||
|
||||
$firstToken = (string) $login()->json('data.token');
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$firstToken)
|
||||
->getJson('/api/v1/admin/ping')
|
||||
->assertOk();
|
||||
|
||||
$secondToken = (string) $login()->json('data.token');
|
||||
|
||||
app('auth')->forgetGuards();
|
||||
$this->withHeader('Authorization', 'Bearer '.$firstToken)
|
||||
->getJson('/api/v1/admin/ping')
|
||||
->assertUnauthorized()
|
||||
->assertJsonPath('code', ErrorCode::AdminSessionReplaced->value);
|
||||
|
||||
app('auth')->forgetGuards();
|
||||
$this->withHeader('Authorization', 'Bearer '.$secondToken)
|
||||
->getJson('/api/v1/admin/ping')
|
||||
->assertOk()
|
||||
->assertJsonPath('code', ErrorCode::Success->value);
|
||||
|
||||
expect($admin->fresh()->admin_session_version)->toBe(2)
|
||||
->and($admin->tokens()->where('name', 'admin-api')->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('admin logout revokes browser sessions without deleting programmatic tokens', function () {
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'logout_admin',
|
||||
'name' => '退出管理员',
|
||||
'email' => null,
|
||||
'password' => 'secret-strong',
|
||||
'status' => 0,
|
||||
'admin_session_version' => 1,
|
||||
]);
|
||||
|
||||
$browserToken = $admin->createToken(
|
||||
'admin-api',
|
||||
['admin-session:1'],
|
||||
now()->addDay(),
|
||||
)->plainTextToken;
|
||||
$admin->createToken('test', ['*'], now()->addDay());
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$browserToken)
|
||||
->postJson('/api/v1/admin/auth/logout')
|
||||
->assertOk()
|
||||
->assertJsonPath('code', ErrorCode::Success->value)
|
||||
->assertJsonPath('data.logged_out', true);
|
||||
|
||||
expect($admin->tokens()->where('name', 'admin-api')->count())->toBe(0)
|
||||
->and($admin->tokens()->where('name', 'test')->count())->toBe(1);
|
||||
|
||||
app('auth')->forgetGuards();
|
||||
$this->withHeader('Authorization', 'Bearer '.$browserToken)
|
||||
->getJson('/api/v1/admin/ping')
|
||||
->assertUnauthorized()
|
||||
->assertJsonPath('code', ErrorCode::AdminUnauthenticated->value);
|
||||
});
|
||||
|
||||
test('agent operator auth me omits platform-only navigation', function (): void {
|
||||
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
use App\Models\Player;
|
||||
use App\Support\PlayerAuthSource;
|
||||
use App\Support\PlayerFundingMode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Database\Seeders\LotterySettingsSeeder;
|
||||
use App\Services\Player\PlayerCreditService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -345,3 +346,505 @@ test('credit wallet logs page 2 balance_after continues from page 1 baseline', f
|
||||
// Page 2 must continue the running balance from page 1, capped by credit limit.
|
||||
expect($page2FirstBalanceAfter)->toBe(min($page1LastBalanceAfter + 1000, 50000));
|
||||
});
|
||||
|
||||
test('credit player activity merges hold release loss and rebate into one order result', function (): void {
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'default_site',
|
||||
'site_player_id' => 'native:activity-loss',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'credit_activity_loss',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => 500,
|
||||
'used_credit' => 21,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$drawId = (int) DB::table('draws')->insertGetId([
|
||||
'draw_no' => 'ACTIVITY-LOSS-001',
|
||||
'business_date' => now()->toDateString(),
|
||||
'sequence_no' => 1,
|
||||
'status' => 'settled',
|
||||
'created_at' => now()->subMinutes(10),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$orderId = (int) DB::table('ticket_orders')->insertGetId([
|
||||
'order_no' => 'ORDER-ACTIVITY-LOSS-001',
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawId,
|
||||
'currency_code' => 'NPR',
|
||||
'total_bet_amount' => 2100,
|
||||
'total_rebate_amount' => 0,
|
||||
'total_actual_deduct' => 2100,
|
||||
'total_estimated_payout' => 50000,
|
||||
'status' => 'placed',
|
||||
'created_at' => now()->subMinutes(10),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$ticketItemId = (int) DB::table('ticket_items')->insertGetId([
|
||||
'ticket_no' => 'TICKET-ACTIVITY-LOSS-001',
|
||||
'order_id' => $orderId,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawId,
|
||||
'original_number' => '1234',
|
||||
'normalized_number' => '1234',
|
||||
'play_code' => '4d',
|
||||
'total_bet_amount' => 2100,
|
||||
'actual_deduct_amount' => 2100,
|
||||
'status' => 'settled_lose',
|
||||
'win_amount' => 0,
|
||||
'jackpot_win_amount' => 0,
|
||||
'settled_at' => now(),
|
||||
'created_at' => now()->subMinutes(10),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('share_ledger')->insert([
|
||||
'ticket_item_id' => $ticketItemId,
|
||||
'player_id' => $player->id,
|
||||
'agent_node_id' => null,
|
||||
'game_win_loss' => 2100,
|
||||
'basic_rebate' => 11,
|
||||
'shared_net_win_loss' => 2089,
|
||||
'settled_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
DB::table('rebate_records')->insert([
|
||||
'player_id' => $player->id,
|
||||
'ticket_item_id' => $ticketItemId,
|
||||
'game_type' => '4d',
|
||||
'valid_bet_amount' => 2100,
|
||||
'rebate_rate' => 0.0052,
|
||||
'rebate_amount' => 11,
|
||||
'rebate_type' => 'basic',
|
||||
'owner_agent_id' => null,
|
||||
'status' => 'accrued',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
DB::table('credit_ledger')->insert([
|
||||
[
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $player->id,
|
||||
'amount' => -2100,
|
||||
'reason' => 'bet_hold',
|
||||
'ref_type' => 'ticket_order',
|
||||
'ref_id' => $orderId,
|
||||
'created_at' => now()->subMinutes(10),
|
||||
'updated_at' => now()->subMinutes(10),
|
||||
],
|
||||
[
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $player->id,
|
||||
'amount' => 2100,
|
||||
'reason' => 'bet_hold_release',
|
||||
'ref_type' => 'ticket_item',
|
||||
'ref_id' => $ticketItemId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $player->id,
|
||||
'amount' => -2100,
|
||||
'reason' => 'game_settlement_loss',
|
||||
'ref_type' => 'ticket_item',
|
||||
'ref_id' => $ticketItemId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/wallet/logs?page=1&size=10')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.total', 1)
|
||||
->assertJsonPath('data.items.0.activity_kind', 'draw_result')
|
||||
->assertJsonPath('data.items.0.biz_type', 'settled_loss')
|
||||
->assertJsonPath('data.items.0.order_no', 'ORDER-ACTIVITY-LOSS-001')
|
||||
->assertJsonPath('data.items.0.draw_no', 'ACTIVITY-LOSS-001')
|
||||
->assertJsonPath('data.items.0.ticket_no', 'TICKET-ACTIVITY-LOSS-001')
|
||||
->assertJsonPath('data.items.0.stake_amount', 2100)
|
||||
->assertJsonPath('data.items.0.rebate_amount', 11)
|
||||
->assertJsonPath('data.items.0.net_amount', -2089)
|
||||
->assertJsonPath('data.items.0.balance_after', 47900);
|
||||
|
||||
expect($response->json('data.items'))->toHaveCount(1);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/wallet/logs?type=bet')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.total', 0);
|
||||
|
||||
$newerOrderId = (int) DB::table('ticket_orders')->insertGetId([
|
||||
'order_no' => 'ORDER-ACTIVITY-PENDING-001',
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawId,
|
||||
'currency_code' => 'NPR',
|
||||
'total_bet_amount' => 100,
|
||||
'total_rebate_amount' => 0,
|
||||
'total_actual_deduct' => 100,
|
||||
'total_estimated_payout' => 1000,
|
||||
'status' => 'placed',
|
||||
'created_at' => now()->addSecond(),
|
||||
'updated_at' => now()->addSecond(),
|
||||
]);
|
||||
DB::table('ticket_items')->insert([
|
||||
'ticket_no' => 'TICKET-ACTIVITY-PENDING-001',
|
||||
'order_id' => $newerOrderId,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawId,
|
||||
'original_number' => '5678',
|
||||
'normalized_number' => '5678',
|
||||
'play_code' => '4d',
|
||||
'total_bet_amount' => 100,
|
||||
'actual_deduct_amount' => 100,
|
||||
'status' => 'pending_draw',
|
||||
'created_at' => now()->addSecond(),
|
||||
'updated_at' => now()->addSecond(),
|
||||
]);
|
||||
DB::table('player_credit_accounts')
|
||||
->where('player_id', $player->id)
|
||||
->update(['used_credit' => 22, 'updated_at' => now()]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/wallet/logs?type=game_settlement')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.total', 1)
|
||||
->assertJsonPath('data.items.0.balance_after', 47900);
|
||||
});
|
||||
|
||||
test('credit player activities describe wins refunds and settlement reversals in player terms', function (): void {
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'default_site',
|
||||
'site_player_id' => 'native:activity-outcomes',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'credit_activity_outcomes',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => 1000,
|
||||
'used_credit' => 7,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$drawId = (int) DB::table('draws')->insertGetId([
|
||||
'draw_no' => 'ACTIVITY-OUTCOMES-001',
|
||||
'business_date' => now()->toDateString(),
|
||||
'sequence_no' => 1,
|
||||
'status' => 'open',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$createOrder = function (
|
||||
string $orderNo,
|
||||
string $ticketNo,
|
||||
string $orderStatus,
|
||||
string $ticketStatus,
|
||||
int $stake,
|
||||
int $minutesAgo,
|
||||
) use ($player, $drawId): array {
|
||||
$createdAt = now()->subMinutes($minutesAgo);
|
||||
$orderId = (int) DB::table('ticket_orders')->insertGetId([
|
||||
'order_no' => $orderNo,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawId,
|
||||
'currency_code' => 'NPR',
|
||||
'total_bet_amount' => $stake,
|
||||
'total_rebate_amount' => 0,
|
||||
'total_actual_deduct' => $stake,
|
||||
'total_estimated_payout' => 5000,
|
||||
'status' => $orderStatus,
|
||||
'created_at' => $createdAt,
|
||||
'updated_at' => $createdAt,
|
||||
]);
|
||||
$ticketId = (int) DB::table('ticket_items')->insertGetId([
|
||||
'ticket_no' => $ticketNo,
|
||||
'order_id' => $orderId,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawId,
|
||||
'original_number' => '1234',
|
||||
'normalized_number' => '1234',
|
||||
'play_code' => '4d',
|
||||
'total_bet_amount' => $stake,
|
||||
'actual_deduct_amount' => $stake,
|
||||
'status' => $ticketStatus,
|
||||
'win_amount' => $ticketStatus === 'settled_win' ? 4000 : 0,
|
||||
'jackpot_win_amount' => 0,
|
||||
'settled_at' => $ticketStatus === 'settled_win' ? $createdAt : null,
|
||||
'created_at' => $createdAt,
|
||||
'updated_at' => $createdAt,
|
||||
]);
|
||||
|
||||
return [$orderId, $ticketId];
|
||||
};
|
||||
|
||||
[, $winningTicketId] = $createOrder(
|
||||
'ORDER-ACTIVITY-WIN-001',
|
||||
'TICKET-ACTIVITY-WIN-001',
|
||||
'placed',
|
||||
'settled_win',
|
||||
1000,
|
||||
3,
|
||||
);
|
||||
DB::table('share_ledger')->insert([
|
||||
'ticket_item_id' => $winningTicketId,
|
||||
'player_id' => $player->id,
|
||||
'agent_node_id' => null,
|
||||
'game_win_loss' => -4000,
|
||||
'basic_rebate' => 0,
|
||||
'shared_net_win_loss' => -4000,
|
||||
'settled_at' => now()->subMinutes(3),
|
||||
'created_at' => now()->subMinutes(3),
|
||||
'updated_at' => now()->subMinutes(3),
|
||||
]);
|
||||
|
||||
$createOrder(
|
||||
'ORDER-ACTIVITY-REFUND-001',
|
||||
'TICKET-ACTIVITY-REFUND-001',
|
||||
'refunded',
|
||||
'refunded',
|
||||
500,
|
||||
2,
|
||||
);
|
||||
|
||||
[, $reversedTicketId] = $createOrder(
|
||||
'ORDER-ACTIVITY-REVERSED-001',
|
||||
'TICKET-ACTIVITY-REVERSED-001',
|
||||
'placed',
|
||||
'pending_draw',
|
||||
700,
|
||||
1,
|
||||
);
|
||||
$originalShareId = (int) DB::table('share_ledger')->insertGetId([
|
||||
'ticket_item_id' => $reversedTicketId,
|
||||
'player_id' => $player->id,
|
||||
'agent_node_id' => null,
|
||||
'game_win_loss' => 700,
|
||||
'basic_rebate' => 0,
|
||||
'shared_net_win_loss' => 700,
|
||||
'settled_at' => now()->subMinute(),
|
||||
'created_at' => now()->subMinute(),
|
||||
'updated_at' => now()->subMinute(),
|
||||
]);
|
||||
DB::table('share_ledger')->insert([
|
||||
'ticket_item_id' => $reversedTicketId,
|
||||
'player_id' => $player->id,
|
||||
'agent_node_id' => null,
|
||||
'game_win_loss' => -700,
|
||||
'basic_rebate' => 0,
|
||||
'shared_net_win_loss' => -700,
|
||||
'reversal_of_id' => $originalShareId,
|
||||
'settled_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$items = collect($this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/wallet/logs?page=1&size=10')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.total', 3)
|
||||
->json('data.items'))
|
||||
->keyBy('order_no');
|
||||
|
||||
expect($items['ORDER-ACTIVITY-WIN-001']['activity_kind'])->toBe('draw_result')
|
||||
->and($items['ORDER-ACTIVITY-WIN-001']['biz_type'])->toBe('settled_win')
|
||||
->and($items['ORDER-ACTIVITY-WIN-001']['win_amount'])->toBe(4000)
|
||||
->and($items['ORDER-ACTIVITY-WIN-001']['net_amount'])->toBe(4000)
|
||||
->and($items['ORDER-ACTIVITY-REFUND-001']['activity_kind'])->toBe('refund')
|
||||
->and($items['ORDER-ACTIVITY-REFUND-001']['biz_type'])->toBe('bet_refund')
|
||||
->and($items['ORDER-ACTIVITY-REFUND-001']['net_amount'])->toBe(500)
|
||||
->and($items['ORDER-ACTIVITY-REVERSED-001']['activity_kind'])->toBe('bet')
|
||||
->and($items['ORDER-ACTIVITY-REVERSED-001']['activity_status'])->toBe('pending')
|
||||
->and($items['ORDER-ACTIVITY-REVERSED-001']['net_amount'])->toBe(-700);
|
||||
});
|
||||
|
||||
test('credit hold records the real ticket order reference when provided', function (): void {
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'default_site',
|
||||
'site_player_id' => 'native:hold-order-ref',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'credit_hold_order_ref',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => 500,
|
||||
'used_credit' => 0,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
app(PlayerCreditService::class)->holdForBet($player, 2100, 77);
|
||||
|
||||
$row = DB::table('credit_ledger')->where('owner_id', $player->id)->first();
|
||||
expect($row?->ref_type)->toBe('ticket_order')
|
||||
->and((int) $row?->ref_id)->toBe(77);
|
||||
});
|
||||
|
||||
test('credit player activity shows each partial period payment as one player-facing record', function (): void {
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'default_site',
|
||||
'site_player_id' => 'native:period-payments',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'credit_period_payments',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => 500,
|
||||
'used_credit' => 0,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$siteId = (int) DB::table('admin_sites')->where('code', 'default_site')->value('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(),
|
||||
]);
|
||||
$billId = (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' => 0,
|
||||
'net_amount' => 300,
|
||||
'paid_amount' => 300,
|
||||
'unpaid_amount' => 0,
|
||||
'status' => 'settled',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
DB::table('payment_records')->insert([
|
||||
[
|
||||
'settlement_bill_id' => $billId,
|
||||
'payer_type' => 'player',
|
||||
'payer_id' => $player->id,
|
||||
'payee_type' => 'agent',
|
||||
'payee_id' => 0,
|
||||
'amount' => 100,
|
||||
'status' => 'confirmed',
|
||||
'confirmed_at' => now()->subMinute(),
|
||||
'created_at' => now()->subMinute(),
|
||||
'updated_at' => now()->subMinute(),
|
||||
],
|
||||
[
|
||||
'settlement_bill_id' => $billId,
|
||||
'payer_type' => 'player',
|
||||
'payer_id' => $player->id,
|
||||
'payee_type' => 'agent',
|
||||
'payee_id' => 0,
|
||||
'amount' => 200,
|
||||
'status' => 'confirmed',
|
||||
'confirmed_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/wallet/logs?type=bill_settlement')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.total', 2)
|
||||
->assertJsonPath('data.items.0.activity_kind', 'period_settlement')
|
||||
->assertJsonPath('data.items.0.biz_type', 'period_paid')
|
||||
->assertJsonPath('data.items.0.net_amount', -200)
|
||||
->assertJsonPath('data.items.1.net_amount', -100);
|
||||
});
|
||||
|
||||
test('credit player activity paginates after grouping orders', function (): void {
|
||||
$player = Player::query()->create([
|
||||
'site_code' => 'default_site',
|
||||
'site_player_id' => 'native:activity-pages',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'credit_activity_pages',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => 500,
|
||||
'used_credit' => 11,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$drawId = (int) DB::table('draws')->insertGetId([
|
||||
'draw_no' => 'ACTIVITY-PAGE-001',
|
||||
'business_date' => now()->toDateString(),
|
||||
'sequence_no' => 1,
|
||||
'status' => 'open',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
for ($i = 1; $i <= 11; $i++) {
|
||||
$placedAt = now()->subMinutes(11 - $i);
|
||||
$orderId = (int) DB::table('ticket_orders')->insertGetId([
|
||||
'order_no' => 'ORDER-ACTIVITY-PAGE-'.$i,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawId,
|
||||
'currency_code' => 'NPR',
|
||||
'total_bet_amount' => 100,
|
||||
'total_rebate_amount' => 0,
|
||||
'total_actual_deduct' => 100,
|
||||
'total_estimated_payout' => 1000,
|
||||
'status' => 'placed',
|
||||
'created_at' => $placedAt,
|
||||
'updated_at' => $placedAt,
|
||||
]);
|
||||
DB::table('ticket_items')->insert([
|
||||
'ticket_no' => 'TICKET-ACTIVITY-PAGE-'.$i,
|
||||
'order_id' => $orderId,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawId,
|
||||
'original_number' => '1234',
|
||||
'normalized_number' => '1234',
|
||||
'play_code' => '4d',
|
||||
'total_bet_amount' => 100,
|
||||
'actual_deduct_amount' => 100,
|
||||
'status' => 'pending_draw',
|
||||
'created_at' => $placedAt,
|
||||
'updated_at' => $placedAt,
|
||||
]);
|
||||
}
|
||||
|
||||
$page1 = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/wallet/logs?page=1&size=10')
|
||||
->assertOk()
|
||||
->json('data');
|
||||
$page2 = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/wallet/logs?page=2&size=10')
|
||||
->assertOk()
|
||||
->json('data');
|
||||
|
||||
expect($page1['total'])->toBe(11)
|
||||
->and($page1['items'])->toHaveCount(10)
|
||||
->and($page2['items'])->toHaveCount(1)
|
||||
->and($page2['items'][0]['activity_kind'])->toBe('bet');
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\BetProvider;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\BetProvider;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
@@ -134,6 +134,29 @@ test('draw result show includes neighbor draw numbers', function (): void {
|
||||
->assertJsonPath('data.next_draw_no', '20260509-102');
|
||||
});
|
||||
|
||||
test('draw result show accepts sequence numbers longer than three digits', function (): void {
|
||||
seedMinimalPublishedDraw([
|
||||
'draw_no' => '20260509-1001',
|
||||
'business_date' => '2026-05-09',
|
||||
'sequence_no' => 1001,
|
||||
'status' => DrawStatus::Settled->value,
|
||||
'start_time' => now()->subHour(),
|
||||
'close_time' => now()->subMinutes(45),
|
||||
'draw_time' => now()->subMinutes(30),
|
||||
'cooling_end_time' => null,
|
||||
'result_source' => 'rng',
|
||||
'current_result_version' => 1,
|
||||
'settle_version' => 1,
|
||||
'is_reopened' => false,
|
||||
], '4');
|
||||
|
||||
$this->getJson('/api/v1/draw/results/20260509-1001')
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0)
|
||||
->assertJsonPath('data.draw_no', '20260509-1001')
|
||||
->assertJsonPath('data.results.1st', '4444');
|
||||
});
|
||||
|
||||
test('draw results include all published provider batches for the same draw', function (): void {
|
||||
BetProvider::query()->updateOrCreate(['code' => 'SG'], ['name' => 'Singapore', 'is_enabled' => true, 'sort_order' => 10]);
|
||||
BetProvider::query()->updateOrCreate(['code' => 'MY'], ['name' => 'Malaysia', 'is_enabled' => true, 'sort_order' => 20]);
|
||||
|
||||
158
tests/Feature/DrawSettlementRunApiTest.php
Normal file
158
tests/Feature/DrawSettlementRunApiTest.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\AdminUser;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Models\SettlementBatch;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Support\AdminAuthorizationRegistry;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createDrawForManualSettlement(
|
||||
string $status = 'cooldown',
|
||||
bool $withPublishedResult = true,
|
||||
): Draw {
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260722-'.random_int(1000, 9999),
|
||||
'business_date' => '2026-07-22',
|
||||
'sequence_no' => random_int(1000, 9999),
|
||||
'status' => $status,
|
||||
'start_time' => now()->subMinutes(20),
|
||||
'close_time' => now()->subMinutes(10),
|
||||
'draw_time' => now()->subMinutes(9),
|
||||
'cooling_end_time' => now()->addMinutes(10),
|
||||
'current_result_version' => $withPublishedResult ? 1 : 0,
|
||||
'settle_version' => 0,
|
||||
'is_reopened' => false,
|
||||
]);
|
||||
|
||||
if ($withPublishedResult) {
|
||||
DrawResultBatch::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'provider_code' => 'SG',
|
||||
'provider_name' => 'Singapore',
|
||||
'result_version' => 1,
|
||||
'source_type' => 'rng',
|
||||
'status' => DrawResultBatchStatus::Published->value,
|
||||
'confirmed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $draw;
|
||||
}
|
||||
|
||||
function manualSettlementAdminToken(): string
|
||||
{
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'manual_settle_'.bin2hex(random_bytes(3)),
|
||||
'name' => 'Manual Settlement',
|
||||
'email' => null,
|
||||
'password' => Hash::make('secret-strong'),
|
||||
'status' => 0,
|
||||
]);
|
||||
grantSuperAdminRole($admin);
|
||||
|
||||
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
|
||||
}
|
||||
|
||||
test('payout manager may skip cooldown and settlement retry is idempotent', function (): void {
|
||||
$draw = createDrawForManualSettlement();
|
||||
$originalCoolingEnd = $draw->cooling_end_time;
|
||||
$token = manualSettlementAdminToken();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/settlement/run")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.ran', true)
|
||||
->assertJsonPath('data.cooldown_skipped', true)
|
||||
->assertJsonPath('data.status', DrawStatus::Settling->value)
|
||||
->assertJsonPath('data.settle_version', 1);
|
||||
|
||||
$draw->refresh();
|
||||
expect($draw->status)->toBe(DrawStatus::Settling->value)
|
||||
->and($draw->cooling_end_time)->not->toBeNull()
|
||||
->and($draw->cooling_end_time->lessThan($originalCoolingEnd))->toBeTrue()
|
||||
->and(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/settlement/run")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.ran', true)
|
||||
->assertJsonPath('data.cooldown_skipped', false)
|
||||
->assertJsonPath('data.settle_version', 1);
|
||||
|
||||
expect(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('manual settlement rejects missing results and invalid draw states without mutation', function (): void {
|
||||
$token = manualSettlementAdminToken();
|
||||
$missingResult = createDrawForManualSettlement(withPublishedResult: false);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$missingResult->id}/settlement/run")
|
||||
->assertStatus(409)
|
||||
->assertJsonPath('data.reason', 'draw_result_not_published');
|
||||
|
||||
expect($missingResult->fresh()->status)->toBe(DrawStatus::Cooldown->value)
|
||||
->and(SettlementBatch::query()->where('draw_id', $missingResult->id)->exists())->toBeFalse();
|
||||
|
||||
$openDraw = createDrawForManualSettlement(status: DrawStatus::Open->value);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$openDraw->id}/settlement/run")
|
||||
->assertStatus(409)
|
||||
->assertJsonPath('data.reason', 'draw_not_ready_for_settlement');
|
||||
|
||||
expect($openDraw->fresh()->status)->toBe(DrawStatus::Open->value)
|
||||
->and(SettlementBatch::query()->where('draw_id', $openDraw->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('manual settlement resource is restricted to settlement management and audited', function (): void {
|
||||
$resource = collect(AdminAuthorizationRegistry::resources())
|
||||
->firstWhere('code', 'admin.draws.settlement.run');
|
||||
|
||||
expect($resource)->not->toBeNull()
|
||||
->and($resource['permission_codes'])->toBe(['settlement.batch.manage'])
|
||||
->and($resource['is_audit_required'])->toBeTrue();
|
||||
|
||||
$draw = createDrawForManualSettlement();
|
||||
$token = manualSettlementAdminToken();
|
||||
$before = AuditLog::query()->count();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson("/api/v1/admin/draws/{$draw->id}/settlement/run")
|
||||
->assertOk();
|
||||
|
||||
expect(AuditLog::query()->count())->toBe($before + 1);
|
||||
});
|
||||
|
||||
test('draw finance summary exposes provider and result settlement versions', function (): void {
|
||||
$draw = createDrawForManualSettlement(status: DrawStatus::Settling->value);
|
||||
$result = DrawResultBatch::query()->where('draw_id', $draw->id)->firstOrFail();
|
||||
SettlementBatch::query()->create([
|
||||
'draw_id' => $draw->id,
|
||||
'result_batch_id' => $result->id,
|
||||
'settle_version' => 3,
|
||||
'status' => 'pending_review',
|
||||
'total_ticket_count' => 0,
|
||||
'total_win_count' => 0,
|
||||
'total_payout_amount' => 0,
|
||||
'total_jackpot_payout_amount' => 0,
|
||||
'started_at' => now(),
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
$token = manualSettlementAdminToken();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->getJson("/api/v1/admin/draws/{$draw->id}/finance-summary")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.settlement_batches.0.provider_code', 'SG')
|
||||
->assertJsonPath('data.settlement_batches.0.provider_name', 'Singapore')
|
||||
->assertJsonPath('data.settlement_batches.0.result_version', 1)
|
||||
->assertJsonPath('data.settlement_batches.0.settle_version', 3);
|
||||
});
|
||||
@@ -10,8 +10,10 @@ use Illuminate\Support\Facades\DB;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Database\Seeders\LotterySettingsSeeder;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use App\Events\PlayerSessionReplacedBroadcast;
|
||||
use App\Services\Player\PlayerNativeAuthService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
@@ -244,6 +246,63 @@ test('native player can login and access me', function (): void {
|
||||
->assertJsonPath('data.auth_source', PlayerAuthSource::LOTTERY_NATIVE);
|
||||
});
|
||||
|
||||
test('latest native login replaces the previous device session', function (): void {
|
||||
Event::fake([PlayerSessionReplacedBroadcast::class]);
|
||||
|
||||
$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:single-session',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'single_session_player',
|
||||
'password_hash' => Hash::make('secret-pass'),
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$firstLogin = $this->postJson('/api/v1/player/auth/login', array_merge([
|
||||
'username' => 'single_session_player',
|
||||
'password' => 'secret-pass',
|
||||
], playerLoginCaptcha()))->assertOk();
|
||||
$firstToken = (string) $firstLogin->json('data.access_token');
|
||||
|
||||
expect($player->fresh()->native_session_version)->toBe(1);
|
||||
$this->withHeader('Authorization', 'Bearer '.$firstToken)
|
||||
->getJson('/api/v1/player/me')
|
||||
->assertOk();
|
||||
|
||||
$secondLogin = $this->postJson('/api/v1/player/auth/login', array_merge([
|
||||
'username' => 'single_session_player',
|
||||
'password' => 'secret-pass',
|
||||
], playerLoginCaptcha()))->assertOk();
|
||||
$secondToken = (string) $secondLogin->json('data.access_token');
|
||||
|
||||
expect($secondToken)->not->toBe($firstToken)
|
||||
->and($player->fresh()->native_session_version)->toBe(2);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$firstToken)
|
||||
->getJson('/api/v1/player/me')
|
||||
->assertStatus(401)
|
||||
->assertJsonPath('code', ErrorCode::PlayerSessionReplaced->value);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$secondToken)
|
||||
->getJson('/api/v1/player/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.id', $player->id);
|
||||
|
||||
Event::assertDispatchedTimes(PlayerSessionReplacedBroadcast::class, 2);
|
||||
Event::assertDispatched(
|
||||
PlayerSessionReplacedBroadcast::class,
|
||||
fn (PlayerSessionReplacedBroadcast $event): bool => $event->playerId === (int) $player->id
|
||||
&& $event->sessionVersion === 2,
|
||||
);
|
||||
});
|
||||
|
||||
test('credit player wallet transfer in is rejected', function (): void {
|
||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Events\PlayCatalogUpdatedBroadcast;
|
||||
use Database\Seeders\LotterySettingsSeeder;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use App\Services\Config\RiskCapStreamService;
|
||||
use App\Events\PlayerSessionReplacedBroadcast;
|
||||
use App\Services\Wallet\LotteryTransferService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use App\Services\Wallet\WalletBalanceRealtimeNotifier;
|
||||
@@ -39,6 +40,21 @@ test('balance update broadcasts only on the player private channel', function ()
|
||||
->and($channels[0]->name)->toBe('private-player.42');
|
||||
});
|
||||
|
||||
test('session replacement broadcasts the monotonic version on the player private channel', function (): void {
|
||||
$event = new PlayerSessionReplacedBroadcast(42, 3, 1_234_567);
|
||||
$channels = $event->broadcastOn();
|
||||
|
||||
expect($channels)->toHaveCount(1)
|
||||
->and($channels[0])->toBeInstanceOf(PrivateChannel::class)
|
||||
->and($channels[0]->name)->toBe('private-player.42')
|
||||
->and($event->broadcastAs())->toBe('session.replaced')
|
||||
->and($event->broadcastWith())->toBe([
|
||||
'player_id' => 42,
|
||||
'session_version' => 3,
|
||||
'emitted_at_ms' => 1_234_567,
|
||||
]);
|
||||
});
|
||||
|
||||
test('player private channel auth allows only the matching bearer player', function (): void {
|
||||
config([
|
||||
'broadcasting.default' => 'reverb',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\Player;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\AdminRole;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\TicketItem;
|
||||
@@ -444,9 +445,24 @@ test('settlement tick finalizer keeps approved batch retryable when payout throw
|
||||
|
||||
expect($result['payout_failed'])->toBe(1)
|
||||
->and($batch->fresh()->status)->toBe(SettlementBatchStatus::Approved->value)
|
||||
->and($batch->fresh()->review_remark)->toContain('auto_payout_failed');
|
||||
->and($batch->fresh()->review_remark)->toContain('auto_payout_failed')
|
||||
->and((int) $batch->fresh()->auto_payout_attempts)->toBe(1)
|
||||
->and(AuditLog::query()
|
||||
->where('action_code', 'auto_payout_failed')
|
||||
->where('target_id', (string) $batch->id)
|
||||
->count())->toBe(1);
|
||||
|
||||
$immediateRetry = app(SettlementTickFinalizer::class)->finalizePendingBatches();
|
||||
|
||||
expect($immediateRetry)->toMatchArray(['approved' => 0, 'paid' => 0, 'payout_failed' => 0])
|
||||
->and((int) $batch->fresh()->auto_payout_attempts)->toBe(1)
|
||||
->and(AuditLog::query()
|
||||
->where('action_code', 'auto_payout_failed')
|
||||
->where('target_id', (string) $batch->id)
|
||||
->count())->toBe(1);
|
||||
|
||||
DB::table('ticket_items')->where('id', $orphanItemId)->delete();
|
||||
$this->travel((int) config('lottery.auto_payout_retry_base_seconds'))->seconds();
|
||||
$retry = app(SettlementTickFinalizer::class)->finalizePendingBatches();
|
||||
|
||||
expect($retry)->toMatchArray(['approved' => 0, 'paid' => 1, 'payout_failed' => 0])
|
||||
|
||||
@@ -2,24 +2,26 @@
|
||||
|
||||
use App\Models\Draw;
|
||||
use App\Models\Player;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\TicketItem;
|
||||
use App\Lottery\DrawStatus;
|
||||
use App\Models\JackpotPool;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Models\PlayerWallet;
|
||||
use App\Models\DrawResultItem;
|
||||
use App\Models\DrawResultBatch;
|
||||
use App\Models\TicketOrder;
|
||||
use App\Models\AdminUser;
|
||||
use App\Models\SettlementBatch;
|
||||
use App\Services\Draw\DrawPrizeLayout;
|
||||
use App\Services\Settlement\SettlementOrchestrator;
|
||||
use App\Services\Settlement\SettlementBatchWorkflowService;
|
||||
use App\Models\TicketCombination;
|
||||
use Database\Seeders\CurrencySeeder;
|
||||
use Database\Seeders\PlayTypeSeeder;
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use App\Lottery\DrawResultBatchStatus;
|
||||
use App\Services\Draw\DrawPrizeLayout;
|
||||
use Database\Seeders\LotterySettingsSeeder;
|
||||
use Database\Seeders\OperationalConfigV1Seeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use App\Services\Settlement\SettlementOrchestrator;
|
||||
use App\Services\Settlement\SettlementBatchWorkflowService;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -171,6 +173,12 @@ test('ticket items index returns placed ticket for player', function (): void {
|
||||
->assertJsonPath('data.ticket_no', $ticketNo)
|
||||
->assertJsonPath('data.combinations.0.number_4d', '1234');
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/ticket/items?ticket_no='.urlencode($ticketNo))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.total', 1)
|
||||
->assertJsonPath('data.items.0.ticket_no', $ticketNo);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/ticket/items?draw_no='.urlencode('20260511-777'))
|
||||
->assertOk()
|
||||
@@ -313,7 +321,7 @@ test('ticket item show returns match result and timeline', function (): void {
|
||||
|
||||
ticketItemsPublishAndSettle($draw, '1234');
|
||||
|
||||
$ticketNo = \App\Models\TicketItem::query()->where('draw_id', $draw->id)->value('ticket_no');
|
||||
$ticketNo = TicketItem::query()->where('draw_id', $draw->id)->value('ticket_no');
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/ticket/items/'.$ticketNo)
|
||||
@@ -348,9 +356,9 @@ test('my-match returns hit numbers when draw settled with winning ticket', funct
|
||||
]);
|
||||
|
||||
$draw = Draw::query()->create([
|
||||
'draw_no' => '20260511-778',
|
||||
'draw_no' => '20260511-1778',
|
||||
'business_date' => '2026-05-11',
|
||||
'sequence_no' => 778,
|
||||
'sequence_no' => 1778,
|
||||
'status' => DrawStatus::Open->value,
|
||||
'start_time' => now()->subMinutes(2),
|
||||
'close_time' => now()->addMinutes(5),
|
||||
@@ -364,7 +372,7 @@ test('my-match returns hit numbers when draw settled with winning ticket', funct
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->postJson('/api/v1/ticket/place', [
|
||||
'draw_id' => '20260511-778',
|
||||
'draw_id' => '20260511-1778',
|
||||
'currency_code' => 'NPR',
|
||||
'client_trace_id' => 'match-trace-1',
|
||||
'lines' => [
|
||||
@@ -376,7 +384,7 @@ test('my-match returns hit numbers when draw settled with winning ticket', funct
|
||||
ticketItemsPublishAndSettle($draw, '1234');
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
|
||||
->getJson('/api/v1/ticket/draws/20260511-778/my-match')
|
||||
->getJson('/api/v1/ticket/draws/20260511-1778/my-match')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.has_bets', true)
|
||||
->assertJsonPath('data.winning_ticket_count', 1)
|
||||
@@ -441,7 +449,7 @@ test('my-match only highlights settled winning tickets', function (): void {
|
||||
'client_trace_id' => 'pending-match',
|
||||
]);
|
||||
|
||||
$item = \App\Models\TicketItem::query()->create([
|
||||
$item = TicketItem::query()->create([
|
||||
'ticket_no' => 'TKPENDINGMATCH',
|
||||
'order_id' => $order->id,
|
||||
'player_id' => $player->id,
|
||||
@@ -467,7 +475,7 @@ test('my-match only highlights settled winning tickets', function (): void {
|
||||
'jackpot_win_amount' => 0,
|
||||
]);
|
||||
|
||||
\App\Models\TicketCombination::query()->create([
|
||||
TicketCombination::query()->create([
|
||||
'ticket_item_id' => $item->id,
|
||||
'combination_no' => 0,
|
||||
'number_4d' => '1234',
|
||||
|
||||
@@ -19,6 +19,7 @@ use App\Contracts\WalletApiDnsResolver;
|
||||
use App\Events\DrawStatusChangeBroadcast;
|
||||
use App\Events\PlayCatalogUpdatedBroadcast;
|
||||
use App\Events\DrawResultPublishedBroadcast;
|
||||
use App\Events\PlayerSessionReplacedBroadcast;
|
||||
use App\Support\Integration\WalletApiRequestGuard;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
@@ -208,6 +209,7 @@ function allBroadcastEvents(): array
|
||||
OddsUpdateBroadcast::class,
|
||||
PlayCatalogUpdatedBroadcast::class,
|
||||
PlayToggleBroadcast::class,
|
||||
PlayerSessionReplacedBroadcast::class,
|
||||
RiskSoldOutBroadcast::class,
|
||||
RiskWarningBroadcast::class,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user