fix(core): harden settlement and wallet integration

This commit is contained in:
wchino
2026-07-22 01:40:37 +08:00
parent 150c3e7ebd
commit 35f6e46958
54 changed files with 2564 additions and 359 deletions

View File

@@ -1,19 +1,21 @@
<?php
use App\Models\AdminSite;
use App\Models\AuditLog;
use App\Models\AdminUser;
use App\Models\Player;
use App\Services\Integration\PartnerSiteConfig;
use App\Services\Integration\PartnerSiteConfigResolver;
use App\Models\AuditLog;
use App\Models\AdminSite;
use App\Models\AdminUser;
use Illuminate\Support\Facades\DB;
use Database\Seeders\CurrencySeeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http;
use App\Services\Integration\PartnerSiteConfig;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Services\Integration\PartnerSiteConfigResolver;
uses(RefreshDatabase::class);
beforeEach(function (): void {
fakeWalletApiDns();
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
@@ -221,7 +223,81 @@ test('connectivity test probes partner balance api', function (): void {
$response->assertOk()
->assertJsonPath('data.probe.success', true)
->assertJsonPath('data.probe.main_balance_minor', 12345);
->assertJsonPath('data.probe.main_balance_minor', 12345)
->assertJsonMissingPath('data.probe.response_preview');
});
test('connectivity test rejects hostname resolving to a private address before sending', function (): void {
fakeWalletApiDns([
'wallet.private.test' => ['93.184.216.34', '10.0.0.8'],
], []);
Http::preventStrayRequests();
$token = integrationAdminToken();
$create = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'private-probe-site',
'name' => 'Private Probe',
'wallet_api_url' => 'https://wallet.private.test',
'admin_account' => [
'username' => 'private_probe_admin',
'nickname' => 'Private Probe Admin',
'password' => 'secret-strong',
],
]);
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites/'.(int) $create->json('data.id').'/connectivity-test', [
'site_player_id' => '10001',
'currency_code' => 'NPR',
])
->assertOk()
->assertJsonPath('data.probe.success', false)
->assertJsonPath('data.probe.http_status', null)
->assertJsonPath('data.probe.message', 'wallet_api_url 无效(拒绝以防 SSRF')
->assertJsonMissingPath('data.probe.response_preview');
Http::assertSentCount(0);
});
test('connectivity test does not follow redirects or expose response preview', function (): void {
fakeWalletApiDns([
'wallet.redirect.test' => ['93.184.216.34'],
], []);
Http::fake([
'https://wallet.redirect.test/*' => Http::response(
['secret' => 'must-not-be-returned'],
302,
['Location' => 'https://169.254.169.254/latest/meta-data'],
),
'https://169.254.169.254/*' => Http::response(['role' => 'internal'], 200),
]);
$token = integrationAdminToken();
$create = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'redirect-probe-site',
'name' => 'Redirect Probe',
'wallet_api_url' => 'https://wallet.redirect.test',
'admin_account' => [
'username' => 'redirect_probe_admin',
'nickname' => 'Redirect Probe Admin',
'password' => 'secret-strong',
],
]);
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites/'.(int) $create->json('data.id').'/connectivity-test', [
'site_player_id' => '10001',
'currency_code' => 'NPR',
])
->assertOk()
->assertJsonPath('data.probe.success', false)
->assertJsonPath('data.probe.http_status', 302)
->assertJsonMissingPath('data.probe.response_preview')
->assertJsonMissing(['must-not-be-returned']);
Http::assertSentCount(1);
});
test('export parameter sheet excludes plaintext secrets', function (): void {
@@ -329,7 +405,7 @@ test('site scoped admin only sees bound integration sites', function (): void {
});
test('player list is filtered by admin site binding', function (): void {
$this->seed(\Database\Seeders\CurrencySeeder::class);
$this->seed(CurrencySeeder::class);
Player::query()->create([
'site_code' => 'site-a',

View File

@@ -0,0 +1,234 @@
<?php
use App\Models\AdminRole;
use App\Models\AdminSite;
use App\Models\AdminUser;
use App\Models\ReportJob;
use Laravel\Sanctum\Sanctum;
use App\Services\AuditLogger;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
/**
* @param list<string> $permissionCodes
*/
function makeReportScopeAdmin(string $username, int $siteId, array $permissionCodes): AdminUser
{
$admin = AdminUser::query()->create([
'username' => $username,
'name' => $username,
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$role = AdminRole::query()->create([
'slug' => 'report_scope_'.$username,
'code' => 'report_scope_'.$username,
'name' => 'Report Scope '.$username,
'scope_type' => AdminRole::SCOPE_SYSTEM,
'status' => 1,
'is_system' => false,
'sort_order' => 0,
]);
$actionIds = DB::table('admin_menu_actions')
->whereIn('permission_code', $permissionCodes)
->pluck('id');
foreach ($actionIds as $actionId) {
DB::table('admin_role_menu_actions')->insert([
'role_id' => (int) $role->id,
'menu_action_id' => (int) $actionId,
]);
}
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => (int) $admin->id,
'site_id' => $siteId,
'role_id' => (int) $role->id,
'granted_at' => now(),
]);
return $admin;
}
function makeReportScopeJob(AdminUser $owner, string $jobNo, string $reportType = 'daily_profit_summary'): ReportJob
{
return ReportJob::query()->create([
'job_no' => $jobNo,
'admin_user_id' => (int) $owner->id,
'report_type' => $reportType,
'export_format' => 'csv',
'filter_json' => [
'date_from' => now()->toDateString(),
'date_to' => now()->toDateString(),
],
'status' => 'completed',
'output_path' => 'reports/'.$jobNo.'.csv',
'finished_at' => now(),
]);
}
function makeReportScopeSuperAdmin(): AdminUser
{
$admin = AdminUser::query()->create([
'username' => 'report_scope_super',
'name' => 'Report Scope Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
return $admin;
}
test('report job list show and download are owner only with an explicit super admin exception', function (): void {
$siteAId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$siteB = AdminSite::query()->create([
'code' => 'report-scope-b',
'name' => 'Report Scope B',
'currency_code' => 'NPR',
'status' => 1,
'is_default' => false,
]);
$permissions = ['service.report.view', 'service.report.export'];
$owner = makeReportScopeAdmin('report_owner', $siteAId, $permissions);
$sameSitePeer = makeReportScopeAdmin('report_same_site_peer', $siteAId, $permissions);
$crossSitePeer = makeReportScopeAdmin('report_cross_site_peer', (int) $siteB->id, $permissions);
$ownedJob = makeReportScopeJob($owner, 'RPT-SCOPE-OWN');
$sameSiteJob = makeReportScopeJob($sameSitePeer, 'RPT-SCOPE-SAME');
$crossSiteJob = makeReportScopeJob($crossSitePeer, 'RPT-SCOPE-CROSS');
$deletedCreator = makeReportScopeAdmin('report_deleted_creator', $siteAId, $permissions);
$orphanedJob = makeReportScopeJob($deletedCreator, 'RPT-SCOPE-ORPHAN');
$deletedCreator->delete();
$orphanedJob->refresh();
expect($orphanedJob->admin_user_id)->toBeNull();
Sanctum::actingAs($owner, ['*']);
$visibleIds = collect($this->getJson('/api/v1/admin/report-jobs')
->assertOk()
->json('data.items'))
->pluck('id')
->all();
expect($visibleIds)->toBe([(int) $ownedJob->id]);
$this->getJson('/api/v1/admin/report-jobs/'.$ownedJob->id)->assertOk();
$this->get('/api/v1/admin/report-jobs/'.$ownedJob->id.'/download')->assertOk();
foreach ([$sameSiteJob, $crossSiteJob, $orphanedJob] as $deniedJob) {
$this->getJson('/api/v1/admin/report-jobs/'.$deniedJob->id)->assertForbidden();
$this->get('/api/v1/admin/report-jobs/'.$deniedJob->id.'/download')->assertForbidden();
}
$super = makeReportScopeSuperAdmin();
Sanctum::actingAs($super, ['*']);
$superVisibleIds = collect($this->getJson('/api/v1/admin/report-jobs')
->assertOk()
->json('data.items'))
->pluck('id')
->all();
expect($superVisibleIds)->toContain(
(int) $ownedJob->id,
(int) $sameSiteJob->id,
(int) $crossSiteJob->id,
(int) $orphanedJob->id,
);
$this->getJson('/api/v1/admin/report-jobs/'.$orphanedJob->id)->assertOk();
$this->get('/api/v1/admin/report-jobs/'.$orphanedJob->id.'/download')->assertOk();
});
test('sensitive report types require their own capabilities and risk export is super admin only', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$base = makeReportScopeAdmin('report_base_exporter', $siteId, [
'service.report.view',
'service.report.export',
]);
$auditor = makeReportScopeAdmin('report_auditor', $siteId, [
'service.report.view',
'service.report.export',
'service.audit.view',
]);
$riskViewer = makeReportScopeAdmin('report_risk_viewer', $siteId, [
'service.report.view',
'service.report.export',
'risk.monitor.view',
]);
Sanctum::actingAs($base, ['*']);
$this->postJson('/api/v1/admin/report-jobs', [
'report_type' => 'audit_operation_report',
])->assertForbidden();
$legacyAuditJob = makeReportScopeJob($base, 'RPT-SENSITIVE-AUDIT', 'audit_operation_report');
$this->get('/api/v1/admin/report-jobs/'.$legacyAuditJob->id.'/download')->assertForbidden();
Sanctum::actingAs($auditor, ['*']);
$this->postJson('/api/v1/admin/report-jobs', [
'report_type' => 'audit_operation_report',
])->assertOk();
Sanctum::actingAs($riskViewer, ['*']);
$legacyRiskJob = makeReportScopeJob($riskViewer, 'RPT-SENSITIVE-RISK', 'hot_number_risk_report');
foreach (['hot_number_risk_report', 'sold_out_number_report'] as $reportType) {
$this->postJson('/api/v1/admin/report-jobs', [
'report_type' => $reportType,
])->assertForbidden();
}
$this->get('/api/v1/admin/report-jobs/'.$legacyRiskJob->id.'/download')->assertForbidden();
$super = makeReportScopeSuperAdmin();
Sanctum::actingAs($super, ['*']);
foreach (['hot_number_risk_report', 'sold_out_number_report'] as $reportType) {
$created = $this->postJson('/api/v1/admin/report-jobs', [
'report_type' => $reportType,
])->assertOk();
$this->get('/api/v1/admin/report-jobs/'.(int) $created->json('data.id').'/download')->assertOk();
}
});
test('non super audit export is limited to the current actor', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$auditor = makeReportScopeAdmin('report_audit_owner', $siteId, [
'service.report.view',
'service.report.export',
'service.audit.view',
]);
$other = makeReportScopeAdmin('report_audit_other', $siteId, [
'service.report.view',
'service.report.export',
'service.audit.view',
]);
AuditLogger::recordForAdmin($auditor, null, 'audit_scope_own', 'own_action', null, null, null, null);
AuditLogger::recordForAdmin($other, null, 'audit_scope_other', 'other_action', null, null, null, null);
Sanctum::actingAs($auditor, ['*']);
$create = $this->postJson('/api/v1/admin/report-jobs', [
'report_type' => 'audit_operation_report',
'export_format' => 'csv',
'parameters' => [
'date_from' => now()->toDateString(),
'date_to' => now()->toDateString(),
],
])->assertOk();
$content = $this->get('/api/v1/admin/report-jobs/'.(int) $create->json('data.id').'/download')
->assertOk()
->streamedContent();
expect($content)->toContain('audit_scope_own')
->not->toContain('audit_scope_other');
});

View File

@@ -1,10 +1,13 @@
<?php
use App\Models\Draw;
use App\Models\Player;
use App\Models\TicketItem;
use App\Services\AgentSettlement\GameSettlementReversalService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Lottery\DrawStatus;
use Illuminate\Support\Facades\DB;
use App\Services\Player\PlayerCreditService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Services\AgentSettlement\GameSettlementReversalService;
uses(RefreshDatabase::class);
@@ -31,11 +34,11 @@ test('reversal zeroes share ledger net and marks rebates reversed', function ():
'status' => 0,
]);
$drawId = (int) \App\Models\Draw::query()->create([
$drawId = (int) Draw::query()->create([
'draw_no' => 'REV-DRAW-1',
'business_date' => now()->toDateString(),
'sequence_no' => 1,
'status' => \App\Lottery\DrawStatus::Open->value,
'status' => DrawStatus::Open->value,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
@@ -148,11 +151,11 @@ test('reversal restores credit used for settled win on credit player', function
'updated_at' => now(),
]);
$drawId = (int) \App\Models\Draw::query()->create([
$drawId = (int) Draw::query()->create([
'draw_no' => 'REV-CREDIT-DRAW',
'business_date' => now()->toDateString(),
'sequence_no' => 1,
'status' => \App\Lottery\DrawStatus::Open->value,
'status' => DrawStatus::Open->value,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
@@ -219,12 +222,52 @@ test('reversal restores credit used for settled win on credit player', function
'updated_at' => now(),
]);
// Simulate post-win used_credit (win decreased used by 10 major for 1000 minor).
// 模拟结算前已有 11 主单位占额:释放本注 1再以中奖额释放 10结算后为 0。
DB::table('player_credit_accounts')->where('player_id', $player->id)->update(['used_credit' => 0]);
$item = TicketItem::query()->findOrFail($itemId);
app(GameSettlementReversalService::class)->reverseTicketItem($item);
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(10);
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(11);
expect(DB::table('credit_ledger')->where('reason', 'game_settlement_reversal')->where('owner_id', $player->id)->exists())->toBeTrue();
});
test('reversal restores only the credit actually released by an oversized win', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$player = Player::query()->create([
'site_code' => (string) $site->code,
'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'),
'site_player_id' => 'rev-credit-clipped',
'auth_source' => 'lottery_native',
'funding_mode' => 'credit',
'username' => 'revcreditclipped',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => 10000,
'used_credit' => 1,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$credit = app(PlayerCreditService::class);
$credit->releaseBetHold($player, 100, 99881, 1);
$credit->applySettledWin($player, 1000, 99881, 1);
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(0)
->and((int) DB::table('credit_ledger')
->where('ref_id', 99881)
->where('reason', 'game_settlement_win')
->where('settlement_version', 1)
->value('amount'))->toBe(0);
$credit->reverseGameSettlement($player, -1000, 99881, 1);
$credit->restoreBetHoldAfterSettlementReversal($player, 100, 99881, 1);
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(1);
});

View File

@@ -116,6 +116,37 @@ test('jwt first successful login auto-registers player mapping', function () {
->and($player->nickname)->toBe($username);
});
test('main site sso jwt cannot take over an existing native credit player mapping', function (): void {
config(['lottery.player_auth.dev_bypass' => false]);
config(['lottery.main_site.sso_jwt_secret' => 'jwt-test-secret-at-least-32-bytes-long']);
$native = Player::query()->create([
'site_code' => 'main',
'site_player_id' => 'native-sso-collision',
'auth_source' => 'lottery_native',
'funding_mode' => 'credit',
'username' => 'native_collision',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$now = time();
$jwt = JWT::encode([
'site_code' => 'main',
'site_player_id' => $native->site_player_id,
'iat' => $now,
'exp' => $now + 300,
], 'jwt-test-secret-at-least-32-bytes-long', 'HS256');
$this->withHeader('Authorization', 'Bearer '.$jwt)
->getJson('/api/v1/player/me')
->assertUnauthorized()
->assertJsonPath('code', ErrorCode::PlayerTokenInvalid->value);
expect($native->fresh()->last_login_at)->toBeNull();
});
test('player me rejects non-active status with 8005', function () {
$code = ErrorCode::PlayerAccountSuspended->value;
$player = Player::query()->create([

View File

@@ -0,0 +1,174 @@
<?php
use Firebase\JWT\JWT;
use App\Models\Player;
use App\Lottery\ErrorCode;
use App\Support\PlayerAuthSource;
use App\Support\PlayerFundingMode;
use Illuminate\Support\Facades\DB;
use Database\Seeders\CurrencySeeder;
use Illuminate\Support\Facades\Hash;
use Database\Seeders\LotterySettingsSeeder;
use App\Services\Player\PlayerNativeAuthService;
use App\Exceptions\PlayerAuthenticationException;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
config([
'lottery.player_auth.native.secret' => 'independent-native-secret-32bytes!!',
'lottery.player_auth.native.ttl_seconds' => 3600,
'lottery.main_site.sso_jwt_secret' => null,
'lottery.main_site.wallet_api_url' => null,
]);
$this->seed(CurrencySeeder::class);
$this->seed(LotterySettingsSeeder::class);
});
function nativeSecretGuardPlayer(string $suffix): Player
{
$site = DB::table('admin_sites')->where('is_default', true)->first();
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
return Player::query()->create([
'site_code' => (string) $site->code,
'agent_node_id' => $rootId,
'site_player_id' => 'native:secret-guard-'.$suffix,
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'secret_guard_'.$suffix,
'password_hash' => Hash::make('secret-pass'),
'default_currency' => 'NPR',
'status' => 0,
]);
}
function nativeSecretGuardToken(Player $player, string $secret): string
{
$now = time();
return JWT::encode([
'player_id' => (int) $player->id,
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'token_version' => (int) ($player->native_token_version ?? 0),
'site_code' => (string) $player->site_code,
'iat' => $now,
'exp' => $now + 3600,
], $secret, 'HS256');
}
function expectNativeSecretConfigurationRejected(Closure $callback): void
{
try {
$callback();
test()->fail('Expected native JWT secret configuration to be rejected.');
} catch (PlayerAuthenticationException $exception) {
expect($exception->lotteryCode)->toBe(ErrorCode::PlayerSsoSecretNotConfigured->value)
->and($exception->httpStatus)->toBe(503);
}
}
test('missing native secret rejects token issuance and verification with 503', function (): void {
$player = nativeSecretGuardPlayer('missing');
config(['lottery.player_auth.native.secret' => null]);
expectNativeSecretConfigurationRejected(
fn () => app(PlayerNativeAuthService::class)->issueToken($player),
);
$token = nativeSecretGuardToken($player, 'previous-native-secret-32bytes!!');
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/player/me')
->assertStatus(503)
->assertJsonPath('code', ErrorCode::PlayerSsoSecretNotConfigured->value);
});
test('native secret matching legacy sso secret rejects token issuance and verification', function (): void {
$player = nativeSecretGuardPlayer('legacy-match');
$sharedSecret = 'shared-legacy-native-secret-32bytes!!';
config([
'lottery.player_auth.native.secret' => $sharedSecret,
'lottery.main_site.sso_jwt_secret' => $sharedSecret,
]);
expectNativeSecretConfigurationRejected(
fn () => app(PlayerNativeAuthService::class)->issueToken($player),
);
$token = nativeSecretGuardToken($player, $sharedSecret);
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/player/me')
->assertStatus(503)
->assertJsonPath('code', ErrorCode::PlayerSsoSecretNotConfigured->value);
});
test('native secret matching enabled database site sso secret rejects issuance and verification', function (): void {
$player = nativeSecretGuardPlayer('database-match');
$sharedSecret = 'shared-database-native-secret-32bytes!!';
config([
'lottery.player_auth.native.secret' => $sharedSecret,
'lottery.main_site.sso_jwt_secret' => 'different-legacy-sso-secret-32bytes!!',
]);
DB::table('admin_sites')->where('is_default', true)->update([
'status' => 1,
'sso_jwt_secret_encrypted' => encrypt($sharedSecret),
'updated_at' => now(),
]);
expectNativeSecretConfigurationRejected(
fn () => app(PlayerNativeAuthService::class)->issueToken($player),
);
$token = nativeSecretGuardToken($player, $sharedSecret);
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/player/me')
->assertStatus(503)
->assertJsonPath('code', ErrorCode::PlayerSsoSecretNotConfigured->value);
});
test('native secret matching disabled database site sso secret rejects issuance and verification', function (): void {
$player = nativeSecretGuardPlayer('disabled-database-match');
$sharedSecret = 'shared-disabled-site-secret-32bytes!!';
config([
'lottery.player_auth.native.secret' => $sharedSecret,
'lottery.main_site.sso_jwt_secret' => 'different-legacy-sso-secret-32bytes!!',
]);
DB::table('admin_sites')->where('is_default', true)->update([
'status' => 0,
'sso_jwt_secret_encrypted' => encrypt($sharedSecret),
'updated_at' => now(),
]);
expectNativeSecretConfigurationRejected(
fn () => app(PlayerNativeAuthService::class)->issueToken($player),
);
$token = nativeSecretGuardToken($player, $sharedSecret);
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/player/me')
->assertStatus(503)
->assertJsonPath('code', ErrorCode::PlayerSsoSecretNotConfigured->value);
});
test('independent native secret still issues and verifies tokens', function (): void {
$player = nativeSecretGuardPlayer('independent');
config([
'lottery.player_auth.native.secret' => 'independent-native-secret-32bytes!!',
'lottery.main_site.sso_jwt_secret' => 'different-legacy-sso-secret-32bytes!!',
]);
DB::table('admin_sites')->where('is_default', true)->update([
'status' => 1,
'sso_jwt_secret_encrypted' => encrypt('different-database-sso-secret-32bytes!!'),
'updated_at' => now(),
]);
$token = app(PlayerNativeAuthService::class)->issueToken($player);
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/player/me')
->assertOk()
->assertJsonPath('data.id', $player->id)
->assertJsonPath('data.auth_source', PlayerAuthSource::LOTTERY_NATIVE);
});

View File

@@ -1,21 +1,25 @@
<?php
use App\Events\BalanceUpdateBroadcast;
use App\Events\PlayCatalogUpdatedBroadcast;
use App\Events\RiskSoldOutBroadcast;
use App\Events\RiskWarningBroadcast;
use App\Services\Config\RiskCapStreamService;
use App\Models\Draw;
use App\Models\Player;
use App\Models\PlayerWallet;
use App\Models\RiskPool;
use App\Services\Ticket\RiskPoolService;
use App\Services\Wallet\LotteryTransferService;
use App\Services\Wallet\WalletBalanceRealtimeNotifier;
use App\Models\AdminUser;
use App\Models\PlayerWallet;
use App\Events\RiskSoldOutBroadcast;
use App\Events\RiskWarningBroadcast;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\LotterySettingsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Event;
use App\Events\BalanceUpdateBroadcast;
use App\Services\Ticket\RiskPoolService;
use Illuminate\Support\Facades\Broadcast;
use App\Events\PlayCatalogUpdatedBroadcast;
use Database\Seeders\LotterySettingsSeeder;
use Illuminate\Broadcasting\PrivateChannel;
use App\Services\Config\RiskCapStreamService;
use App\Services\Wallet\LotteryTransferService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Services\Wallet\WalletBalanceRealtimeNotifier;
uses(RefreshDatabase::class);
@@ -26,6 +30,55 @@ beforeEach(function (): void {
$this->seed(LotterySettingsSeeder::class);
});
test('balance update broadcasts only on the player private channel', function (): void {
$event = new BalanceUpdateBroadcast(42, 'NPR', 10_000, -500, 'bet', 1_234_567);
$channels = $event->broadcastOn();
expect($channels)->toHaveCount(1)
->and($channels[0])->toBeInstanceOf(PrivateChannel::class)
->and($channels[0]->name)->toBe('private-player.42');
});
test('player private channel auth allows only the matching bearer player', function (): void {
config([
'broadcasting.default' => 'reverb',
'broadcasting.connections.reverb.key' => 'test-reverb-key',
'broadcasting.connections.reverb.secret' => 'test-reverb-secret',
'broadcasting.connections.reverb.app_id' => 'test-reverb-app',
]);
Broadcast::purge('reverb');
require base_path('routes/channels.php');
$player = Player::query()->create([
'site_code' => 'test',
'site_player_id' => 'ws-auth-owner',
'default_currency' => 'NPR',
'status' => 0,
]);
$otherPlayer = Player::query()->create([
'site_code' => 'test',
'site_player_id' => 'ws-auth-other',
'default_currency' => 'NPR',
'status' => 0,
]);
$payload = [
'socket_id' => '1234.5678',
'channel_name' => 'private-player.'.$player->id,
];
$this->postJson('/api/broadcasting/auth', $payload)
->assertUnauthorized();
$this->withHeader('Authorization', 'Bearer dev:'.$otherPlayer->id)
->postJson('/api/broadcasting/auth', $payload)
->assertForbidden();
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/broadcasting/auth', $payload)
->assertOk()
->assertJsonStructure(['auth']);
});
test('wallet balance notifier dispatches balance update broadcast', function (): void {
Event::fake([BalanceUpdateBroadcast::class]);
@@ -109,11 +162,11 @@ test('risk pool acquire dispatches warning and sold out broadcasts', function ()
test('risk cap publish dispatches play catalog updated broadcast', function (): void {
Event::fake([PlayCatalogUpdatedBroadcast::class]);
$admin = \App\Models\AdminUser::query()->create([
$admin = AdminUser::query()->create([
'username' => 'risk_cap_admin',
'name' => 'Risk Cap QA',
'email' => null,
'password' => \Illuminate\Support\Facades\Hash::make('secret-strong'),
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);

View File

@@ -0,0 +1,401 @@
<?php
use App\Models\Draw;
use App\Models\Player;
use App\Models\AdminUser;
use App\Models\TicketItem;
use App\Lottery\DrawStatus;
use App\Models\TicketOrder;
use App\Models\PlayerWallet;
use App\Models\DrawResultItem;
use App\Models\DrawResultBatch;
use App\Models\SettlementBatch;
use App\Models\TicketCombination;
use App\Services\LotterySettings;
use Illuminate\Support\Facades\Hash;
use App\Lottery\DrawResultBatchStatus;
use App\Lottery\SettlementBatchStatus;
use App\Services\Draw\DrawPrizeLayout;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Services\Settlement\SettlementOrchestrator;
use App\Services\Settlement\SettlementTickFinalizer;
use App\Services\Settlement\SettlementBatchWorkflowService;
uses(RefreshDatabase::class);
function createChunkTestDraw(string $suffix): Draw
{
return Draw::query()->create([
'draw_no' => 'CHUNK-'.$suffix,
'business_date' => '2026-07-22',
'sequence_no' => random_int(1000, 9999),
'status' => DrawStatus::Settling->value,
'start_time' => now()->subMinutes(10),
'close_time' => now()->subMinutes(2),
'draw_time' => now()->subMinute(),
'current_result_version' => 1,
'settle_version' => 0,
'is_reopened' => false,
]);
}
function createChunkTestPlayer(string $suffix): Player
{
$player = Player::query()->create([
'site_code' => 'test',
'site_player_id' => 'chunk-'.$suffix,
'username' => 'chunk_'.$suffix,
'default_currency' => 'NPR',
'status' => 0,
]);
PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => 'NPR',
'balance' => 0,
'frozen_balance' => 0,
'status' => 0,
'version' => 0,
]);
return $player;
}
function createChunkTestResult(Draw $draw, string $providerCode): DrawResultBatch
{
$batch = DrawResultBatch::query()->create([
'draw_id' => $draw->id,
'provider_code' => $providerCode,
'provider_name' => $providerCode,
'result_version' => 1,
'source_type' => 'manual',
'status' => DrawResultBatchStatus::Published->value,
'confirmed_at' => now(),
]);
foreach (DrawPrizeLayout::slots() as $slot) {
$number = $slot['prize_type'] === 'first' ? '1234' : '5678';
DrawResultItem::query()->create([
'draw_id' => $draw->id,
'result_batch_id' => $batch->id,
'prize_type' => $slot['prize_type'],
'prize_index' => $slot['prize_index'],
'number_4d' => $number,
'suffix_3d' => substr($number, -3),
'suffix_2d' => substr($number, -2),
'head_digit' => (int) $number[0],
'tail_digit' => (int) $number[3],
]);
}
return $batch;
}
function createChunkTestTicket(
Draw $draw,
Player $player,
TicketOrder $order,
string $providerCode,
string $suffix,
): TicketItem {
$item = TicketItem::query()->create([
'ticket_no' => 'CHUNK-TICKET-'.$suffix,
'order_id' => $order->id,
'player_id' => $player->id,
'draw_id' => $draw->id,
'provider_code' => $providerCode,
'provider_name' => $providerCode,
'original_number' => '1234',
'normalized_number' => '1234',
'play_code' => 'pos_4a',
'bet_mode' => 'unit',
'unit_bet_amount' => 10_000,
'total_bet_amount' => 10_000,
'actual_deduct_amount' => 10_000,
'odds_snapshot_json' => [['prize_scope' => 'first', 'odds_value' => 100_000]],
'rule_snapshot_json' => [],
'combination_count' => 1,
'estimated_max_payout' => 100_000,
'risk_locked_amount' => 0,
'status' => 'pending_draw',
'win_amount' => 0,
'jackpot_win_amount' => 0,
]);
TicketCombination::query()->create([
'ticket_item_id' => $item->id,
'combination_no' => 1,
'number_4d' => '1234',
'bet_amount' => 10_000,
'estimated_payout' => 100_000,
]);
return $item;
}
function createChunkTestOrder(Draw $draw, Player $player, string $suffix, int $ticketCount): TicketOrder
{
return TicketOrder::query()->create([
'order_no' => 'CHUNK-ORDER-'.$suffix,
'player_id' => $player->id,
'draw_id' => $draw->id,
'currency_code' => 'NPR',
'total_bet_amount' => 10_000 * $ticketCount,
'total_rebate_amount' => 0,
'total_actual_deduct' => 10_000 * $ticketCount,
'total_estimated_payout' => 100_000 * $ticketCount,
'status' => 'placed',
'submit_source' => 'test',
'client_trace_id' => 'chunk-trace-'.$suffix,
]);
}
test('settlement processes every ticket chunk into one provider batch', function (): void {
config()->set('lottery.settlement.ticket_chunk_size', 2);
$suffix = bin2hex(random_bytes(4));
$draw = createChunkTestDraw($suffix);
$player = createChunkTestPlayer($suffix);
$order = createChunkTestOrder($draw, $player, $suffix, 3);
createChunkTestResult($draw, 'SG');
for ($i = 1; $i <= 3; $i++) {
createChunkTestTicket($draw, $player, $order, 'SG', $suffix.'-'.$i);
}
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw))->toBeTrue();
$batch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail();
expect(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1)
->and($batch->status)->toBe(SettlementBatchStatus::PendingReview->value)
->and((int) $batch->total_ticket_count)->toBe(3)
->and((int) $batch->total_win_count)->toBe(3)
->and((int) $batch->total_payout_amount)->toBe(300_000)
->and($batch->details()->count())->toBe(3)
->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'pending_draw')->exists())->toBeFalse();
});
test('settlement resumes a legacy partial provider batch instead of skipping remaining tickets', function (): void {
config()->set('lottery.settlement.ticket_chunk_size', 2);
$suffix = bin2hex(random_bytes(4));
$draw = createChunkTestDraw($suffix);
$player = createChunkTestPlayer($suffix);
$order = createChunkTestOrder($draw, $player, $suffix, 3);
createChunkTestResult($draw, 'SG');
createChunkTestTicket($draw, $player, $order, 'SG', $suffix.'-1');
createChunkTestTicket($draw, $player, $order, 'SG', $suffix.'-2');
app(SettlementOrchestrator::class)->trySettleDraw($draw);
$existingBatch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail();
createChunkTestTicket($draw, $player, $order, 'SG', $suffix.'-3');
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue();
$resumedBatch = SettlementBatch::query()->where('draw_id', $draw->id)->firstOrFail();
expect((int) $resumedBatch->id)->toBe((int) $existingBatch->id)
->and(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1)
->and($resumedBatch->status)->toBe(SettlementBatchStatus::PendingReview->value)
->and((int) $resumedBatch->total_ticket_count)->toBe(3)
->and($resumedBatch->details()->count())->toBe(3)
->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'pending_draw')->exists())->toBeFalse();
});
test('approved provider batches payout independently and settle draw after the last provider', function (): void {
config()->set('lottery.settlement.ticket_chunk_size', 1);
$suffix = bin2hex(random_bytes(4));
$draw = createChunkTestDraw($suffix);
$player = createChunkTestPlayer($suffix);
$order = createChunkTestOrder($draw, $player, $suffix, 2);
foreach (['SG', 'MY'] as $providerCode) {
createChunkTestResult($draw, $providerCode);
createChunkTestTicket($draw, $player, $order, $providerCode, $suffix.'-'.$providerCode);
}
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw))->toBeTrue();
$batches = SettlementBatch::query()->where('draw_id', $draw->id)->orderBy('id')->get();
expect($batches)->toHaveCount(2)
->and($batches->every(fn (SettlementBatch $batch): bool => $batch->status === SettlementBatchStatus::PendingReview->value))->toBeTrue();
$admin = AdminUser::query()->create([
'username' => 'chunk_reviewer_'.$suffix,
'name' => 'Chunk Reviewer',
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$workflow = app(SettlementBatchWorkflowService::class);
foreach ($batches as $batch) {
$workflow->approve($batch, $admin);
}
expect(app(SettlementOrchestrator::class)->trySettleDraw($draw->fresh()))->toBeTrue()
->and(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::Approved->value)->count())->toBe(2);
$workflow->payout($batches[0]->fresh());
expect($draw->fresh()->status)->toBe(DrawStatus::Settling->value)
->and(SettlementBatch::query()->whereKey($batches[0]->id)->value('status'))->toBe(SettlementBatchStatus::Paid->value)
->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'pending_payout')->count())->toBe(1);
$workflow->payout($batches[1]->fresh());
expect($draw->fresh()->status)->toBe(DrawStatus::Settled->value)
->and(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::Paid->value)->count())->toBe(2)
->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'settled_win')->count())->toBe(2)
->and($order->fresh()->status)->toBe('settled');
});
test('tick finalizer approves all provider batches before paying them', function (): void {
$suffix = bin2hex(random_bytes(4));
$draw = createChunkTestDraw($suffix);
$player = createChunkTestPlayer($suffix);
$order = createChunkTestOrder($draw, $player, $suffix, 2);
foreach (['SG', 'MY'] as $providerCode) {
createChunkTestResult($draw, $providerCode);
createChunkTestTicket($draw, $player, $order, $providerCode, $suffix.'-'.$providerCode);
}
app(SettlementOrchestrator::class)->trySettleDraw($draw);
$result = app(SettlementTickFinalizer::class)->finalizePendingBatches();
expect($result)->toMatchArray(['approved' => 2, 'paid' => 2, 'payout_failed' => 0])
->and($draw->fresh()->status)->toBe(DrawStatus::Settled->value)
->and(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::Paid->value)->count())->toBe(2)
->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'settled_win')->count())->toBe(2);
});
test('tick finalizer resumes approved batches left before payout', function (): void {
$suffix = bin2hex(random_bytes(4));
$draw = createChunkTestDraw($suffix);
$player = createChunkTestPlayer($suffix);
$order = createChunkTestOrder($draw, $player, $suffix, 2);
foreach (['SG', 'MY'] as $providerCode) {
createChunkTestResult($draw, $providerCode);
createChunkTestTicket($draw, $player, $order, $providerCode, $suffix.'-'.$providerCode);
}
app(SettlementOrchestrator::class)->trySettleDraw($draw);
$workflow = app(SettlementBatchWorkflowService::class);
foreach (SettlementBatch::query()->where('draw_id', $draw->id)->get() as $batch) {
$workflow->approveBySystem($batch, 'simulate restart after approval');
}
expect(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::PendingReview->value)->exists())->toBeFalse()
->and(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::Approved->value)->count())->toBe(2);
$result = app(SettlementTickFinalizer::class)->finalizePendingBatches();
expect($result)->toMatchArray(['approved' => 0, 'paid' => 2, 'payout_failed' => 0])
->and($draw->fresh()->status)->toBe(DrawStatus::Settled->value)
->and(SettlementBatch::query()->where('draw_id', $draw->id)->where('status', SettlementBatchStatus::Paid->value)->count())->toBe(2)
->and(TicketItem::query()->where('draw_id', $draw->id)->where('status', 'settled_win')->count())->toBe(2);
});
test('blocked approved draw does not starve a later eligible draw at the scan limit', function (): void {
config()->set('lottery.draw_tick_finalize_limit', 1);
$blockedSuffix = bin2hex(random_bytes(4));
$blockedDraw = createChunkTestDraw($blockedSuffix);
$blockedResult = createChunkTestResult($blockedDraw, 'BLOCKED');
foreach ([SettlementBatchStatus::Approved, SettlementBatchStatus::Running] as $status) {
SettlementBatch::query()->create([
'draw_id' => $blockedDraw->id,
'result_batch_id' => $blockedResult->id,
'settle_version' => 1,
'status' => $status->value,
'total_ticket_count' => 0,
'total_win_count' => 0,
'total_payout_amount' => 0,
'total_jackpot_payout_amount' => 0,
'review_status' => $status === SettlementBatchStatus::Approved ? 'approved' : 'pending',
'reviewed_at' => $status === SettlementBatchStatus::Approved ? now() : null,
'started_at' => now(),
'finished_at' => $status === SettlementBatchStatus::Approved ? now() : null,
]);
}
$eligibleSuffix = bin2hex(random_bytes(4));
$eligibleDraw = createChunkTestDraw($eligibleSuffix);
$player = createChunkTestPlayer($eligibleSuffix);
$order = createChunkTestOrder($eligibleDraw, $player, $eligibleSuffix, 1);
createChunkTestResult($eligibleDraw, 'SG');
createChunkTestTicket($eligibleDraw, $player, $order, 'SG', $eligibleSuffix.'-SG');
app(SettlementOrchestrator::class)->trySettleDraw($eligibleDraw);
$eligibleBatch = SettlementBatch::query()->where('draw_id', $eligibleDraw->id)->firstOrFail();
app(SettlementBatchWorkflowService::class)->approveBySystem($eligibleBatch, 'simulate approved backlog');
$result = app(SettlementTickFinalizer::class)->finalizePendingBatches();
expect($result)->toMatchArray(['approved' => 0, 'paid' => 1, 'payout_failed' => 0])
->and($eligibleBatch->fresh()->status)->toBe(SettlementBatchStatus::Paid->value)
->and($eligibleDraw->fresh()->status)->toBe(DrawStatus::Settled->value)
->and(SettlementBatch::query()->where('draw_id', $blockedDraw->id)->where('status', SettlementBatchStatus::Approved->value)->count())->toBe(1);
});
test('manual approval still allows automatic payout when auto approval is disabled', function (): void {
$approvedSuffix = bin2hex(random_bytes(4));
$approvedDraw = createChunkTestDraw($approvedSuffix);
$approvedPlayer = createChunkTestPlayer($approvedSuffix);
$approvedOrder = createChunkTestOrder($approvedDraw, $approvedPlayer, $approvedSuffix, 1);
createChunkTestResult($approvedDraw, 'SG');
createChunkTestTicket($approvedDraw, $approvedPlayer, $approvedOrder, 'SG', $approvedSuffix.'-SG');
app(SettlementOrchestrator::class)->trySettleDraw($approvedDraw);
$approvedBatch = SettlementBatch::query()->where('draw_id', $approvedDraw->id)->firstOrFail();
app(SettlementBatchWorkflowService::class)->approveBySystem($approvedBatch, 'manual approval simulation');
$pendingSuffix = bin2hex(random_bytes(4));
$pendingDraw = createChunkTestDraw($pendingSuffix);
$pendingPlayer = createChunkTestPlayer($pendingSuffix);
$pendingOrder = createChunkTestOrder($pendingDraw, $pendingPlayer, $pendingSuffix, 1);
createChunkTestResult($pendingDraw, 'MY');
createChunkTestTicket($pendingDraw, $pendingPlayer, $pendingOrder, 'MY', $pendingSuffix.'-MY');
app(SettlementOrchestrator::class)->trySettleDraw($pendingDraw);
$pendingBatch = SettlementBatch::query()->where('draw_id', $pendingDraw->id)->firstOrFail();
LotterySettings::put('settlement.auto_approve_on_tick', false, 'settlement', 'test manual approval mode');
LotterySettings::put('settlement.auto_payout_on_tick', true, 'settlement', 'test automatic payout mode');
$result = app(SettlementTickFinalizer::class)->finalizePendingBatches();
expect($result)->toMatchArray(['approved' => 0, 'paid' => 1, 'payout_failed' => 0])
->and($approvedBatch->fresh()->status)->toBe(SettlementBatchStatus::Paid->value)
->and($approvedDraw->fresh()->status)->toBe(DrawStatus::Settled->value)
->and($pendingBatch->fresh()->status)->toBe(SettlementBatchStatus::PendingReview->value)
->and($pendingDraw->fresh()->status)->toBe(DrawStatus::Settling->value);
});
test('a repeatedly failing approved draw does not starve a later healthy draw', function (): void {
config()->set('lottery.draw_tick_finalize_limit', 1);
$poisonSuffix = bin2hex(random_bytes(4));
$poisonDraw = createChunkTestDraw($poisonSuffix);
$poisonPlayer = createChunkTestPlayer($poisonSuffix);
$poisonOrder = createChunkTestOrder($poisonDraw, $poisonPlayer, $poisonSuffix, 1);
createChunkTestResult($poisonDraw, 'SG');
createChunkTestTicket($poisonDraw, $poisonPlayer, $poisonOrder, 'SG', $poisonSuffix.'-SG');
app(SettlementOrchestrator::class)->trySettleDraw($poisonDraw);
$poisonBatch = SettlementBatch::query()->where('draw_id', $poisonDraw->id)->firstOrFail();
app(SettlementBatchWorkflowService::class)->approveBySystem($poisonBatch, 'simulate poison batch');
createChunkTestTicket($poisonDraw, $poisonPlayer, $poisonOrder, 'SG', $poisonSuffix.'-ORPHAN');
$healthySuffix = bin2hex(random_bytes(4));
$healthyDraw = createChunkTestDraw($healthySuffix);
$healthyPlayer = createChunkTestPlayer($healthySuffix);
$healthyOrder = createChunkTestOrder($healthyDraw, $healthyPlayer, $healthySuffix, 1);
createChunkTestResult($healthyDraw, 'MY');
createChunkTestTicket($healthyDraw, $healthyPlayer, $healthyOrder, 'MY', $healthySuffix.'-MY');
app(SettlementOrchestrator::class)->trySettleDraw($healthyDraw);
$healthyBatch = SettlementBatch::query()->where('draw_id', $healthyDraw->id)->firstOrFail();
app(SettlementBatchWorkflowService::class)->approveBySystem($healthyBatch, 'simulate healthy backlog');
$first = app(SettlementTickFinalizer::class)->finalizePendingBatches();
expect($first)->toMatchArray(['approved' => 0, 'paid' => 0, 'payout_failed' => 1])
->and($poisonBatch->fresh()->status)->toBe(SettlementBatchStatus::Approved->value)
->and((int) $poisonBatch->fresh()->auto_payout_attempts)->toBe(1)
->and($healthyBatch->fresh()->status)->toBe(SettlementBatchStatus::Approved->value);
$second = app(SettlementTickFinalizer::class)->finalizePendingBatches();
expect($second)->toMatchArray(['approved' => 0, 'paid' => 1, 'payout_failed' => 0])
->and($healthyBatch->fresh()->status)->toBe(SettlementBatchStatus::Paid->value)
->and($healthyDraw->fresh()->status)->toBe(DrawStatus::Settled->value)
->and($poisonBatch->fresh()->status)->toBe(SettlementBatchStatus::Approved->value);
});

View File

@@ -1,23 +1,24 @@
<?php
use App\Models\Draw;
use App\Models\Player;
use App\Models\AdminRole;
use App\Models\AdminUser;
use App\Models\Draw;
use App\Models\DrawResultBatch;
use App\Models\Player;
use App\Models\SettlementBatch;
use App\Models\TicketItem;
use App\Lottery\DrawResultBatchStatus;
use App\Lottery\DrawStatus;
use App\Lottery\SettlementBatchStatus;
use App\Services\AgentSettlement\AgentGameSettlementRecorder;
use App\Services\AgentSettlement\GameSettlementReversalService;
use App\Services\Settlement\SettlementBatchWorkflowService;
use App\Services\Settlement\SettlementTickFinalizer;
use App\Models\DrawResultBatch;
use App\Models\SettlementBatch;
use App\Support\PlayerFundingMode;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use App\Lottery\DrawResultBatchStatus;
use App\Lottery\SettlementBatchStatus;
use App\Services\Player\PlayerCreditService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Services\Settlement\SettlementTickFinalizer;
use App\Services\Settlement\SettlementBatchWorkflowService;
use App\Services\AgentSettlement\AgentGameSettlementRecorder;
use App\Services\AgentSettlement\GameSettlementReversalService;
uses(RefreshDatabase::class);
@@ -132,15 +133,63 @@ test('agent recorder allows re-settlement after share ledger reversal', function
$item->setRelation('player', $player);
$recorder = app(AgentGameSettlementRecorder::class);
$recorder->recordForTicketItem($item, 0, 'settled_lose');
expect(countActiveShareLedgerRows($item->id))->toBe(1);
DB::table('player_credit_accounts')->where('player_id', $player->id)->update(['used_credit' => 1]);
app(GameSettlementReversalService::class)->reverseTicketItem($item->fresh());
expect(countActiveShareLedgerRows($item->id))->toBe(0);
$recorder->recordForTicketItem($item, 0, 'settled_lose', 1);
expect(countActiveShareLedgerRows($item->id))->toBe(1)
->and((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(1);
app(GameSettlementReversalService::class)->reverseTicketItem($item->fresh(), 1);
expect(countActiveShareLedgerRows($item->id))->toBe(0)
->and((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(1);
$item->forceFill(['agent_settled_at' => null])->save();
$recorder->recordForTicketItem($item->fresh(), 0, 'settled_lose');
expect(countActiveShareLedgerRows($item->id))->toBe(1);
$recorder->recordForTicketItem($item->fresh(), 0, 'settled_lose', 2);
expect(countActiveShareLedgerRows($item->id))->toBe(1)
->and((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(1)
->and(DB::table('credit_ledger')
->where('ref_type', 'ticket_item')
->where('ref_id', $item->id)
->where('reason', 'game_settlement_loss')
->count())->toBe(2);
app(GameSettlementReversalService::class)->reverseTicketItem($item->fresh(), 2);
expect(countActiveShareLedgerRows($item->id))->toBe(0)
->and((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(1)
->and(DB::table('credit_ledger')
->where('ref_type', 'ticket_item')
->where('ref_id', $item->id)
->where('reason', 'game_settlement_reversal')
->count())->toBe(2)
->and(DB::table('credit_ledger')
->where('ref_type', 'ticket_item')
->where('ref_id', $item->id)
->where('reason', 'bet_hold_restore')
->count())->toBe(2);
});
test('credit win re-settlement applies the same direction again for a new version', function (): void {
$player = creditAgentPlayerForFixtures('win-re-settle');
DB::table('player_credit_accounts')->where('player_id', $player->id)->update(['used_credit' => 10]);
$credit = app(PlayerCreditService::class);
$credit->releaseBetHold($player, 100, 99001, 1);
$credit->applySettledWin($player, 300, 99001, 1);
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(6);
$credit->reverseGameSettlement($player, -300, 99001, 1);
$credit->restoreBetHoldAfterSettlementReversal($player, 100, 99001, 1);
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(10);
$credit->releaseBetHold($player, 100, 99001, 2);
$credit->applySettledWin($player, 300, 99001, 2);
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(6)
->and(DB::table('credit_ledger')
->where('ref_type', 'ticket_item')
->where('ref_id', 99001)
->where('reason', 'game_settlement_win')
->count())->toBe(2);
});
test('credit player payout skips lottery wallet settle_payout txn', function (): void {
@@ -279,7 +328,7 @@ test('settlement report show rejects inaccessible settlement period', function (
->assertForbidden();
});
test('settlement tick finalizer marks approved batch failed when payout throws', function (): void {
test('settlement tick finalizer keeps approved batch retryable when payout throws', function (): void {
$player = creditAgentPlayerForFixtures('tick-fail');
$draw = Draw::query()->create([
'draw_no' => 'FIX-FAIL-DRAW',
@@ -363,7 +412,7 @@ test('settlement tick finalizer marks approved batch failed when payout throws',
'updated_at' => now(),
]);
DB::table('ticket_items')->insert([
$orphanItemId = (int) DB::table('ticket_items')->insertGetId([
'ticket_no' => 'T-FIX-FAIL-ORPHAN',
'order_id' => $orderId,
'player_id' => $player->id,
@@ -394,6 +443,13 @@ test('settlement tick finalizer marks approved batch failed when payout throws',
$result = app(SettlementTickFinalizer::class)->finalizePendingBatches();
expect($result['payout_failed'])->toBe(1)
->and($batch->fresh()->status)->toBe(SettlementBatchStatus::Failed->value)
->and($batch->fresh()->status)->toBe(SettlementBatchStatus::Approved->value)
->and($batch->fresh()->review_remark)->toContain('auto_payout_failed');
});
DB::table('ticket_items')->where('id', $orphanItemId)->delete();
$retry = app(SettlementTickFinalizer::class)->finalizePendingBatches();
expect($retry)->toMatchArray(['approved' => 0, 'paid' => 1, 'payout_failed' => 0])
->and($batch->fresh()->status)->toBe(SettlementBatchStatus::Paid->value)
->and($draw->fresh()->status)->toBe(DrawStatus::Settled->value);
});

View File

@@ -1446,3 +1446,126 @@ test('ticket pending confirmation reconcile refunds when draw no longer accepts
->and(WalletTxn::query()->where('biz_type', 'bet_reverse')->where('biz_no', 'TO-PENDING-CLOSED')->count())->toBe(1)
->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(0);
});
test('ticket pending confirmation reconcile releases credit hold when draw no longer accepts bets', function (): void {
$draw = ticketOpenDraw('20260511-credit-stale');
$draw->forceFill([
'status' => DrawStatus::Closed->value,
'close_time' => now()->subMinute(),
'draw_time' => now()->subMinute(),
])->save();
$site = DB::table('admin_sites')->where('is_default', true)->first();
$player = Player::query()->create([
'site_code' => (string) $site->code,
'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'),
'site_player_id' => 'native:stale-credit-hold',
'auth_source' => 'lottery_native',
'funding_mode' => 'credit',
'username' => 'stale_credit_hold',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => 1000,
'used_credit' => 1,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$order = TicketOrder::query()->create([
'order_no' => 'TO-PENDING-CREDIT-CLOSED',
'player_id' => $player->id,
'draw_id' => $draw->id,
'currency_code' => 'NPR',
'total_bet_amount' => 100,
'total_rebate_amount' => 0,
'total_actual_deduct' => 100,
'total_estimated_payout' => 3000,
'status' => 'pending_confirm',
'submit_source' => 'h5',
'client_trace_id' => 'pending-credit-on-closed-draw',
'created_at' => now()->subMinutes(20),
'updated_at' => now()->subMinutes(20),
]);
TicketOrder::query()->whereKey($order->id)->update(['updated_at' => now()->subMinutes(20)]);
$item = TicketItem::query()->create([
'ticket_no' => 'TK-PENDING-CREDIT-CLOSED',
'order_id' => $order->id,
'player_id' => $player->id,
'draw_id' => $draw->id,
'original_number' => '1234',
'normalized_number' => '1234',
'play_code' => 'big',
'dimension' => 4,
'digit_slot' => null,
'bet_mode' => 'straight',
'unit_bet_amount' => 100,
'total_bet_amount' => 100,
'rebate_rate_snapshot' => 0,
'commission_rate_snapshot' => 0,
'actual_deduct_amount' => 100,
'odds_snapshot_json' => [],
'rule_snapshot_json' => [],
'combination_count' => 1,
'estimated_max_payout' => 3000,
'risk_locked_amount' => 3000,
'status' => 'pending_confirm',
'fail_reason_code' => null,
'fail_reason_text' => null,
'win_amount' => 0,
'jackpot_win_amount' => 0,
'settled_at' => null,
'created_at' => now()->subMinutes(20),
'updated_at' => now()->subMinutes(20),
]);
TicketCombination::query()->create([
'ticket_item_id' => $item->id,
'combination_no' => 1,
'number_4d' => '1234',
'bet_amount' => 100,
'estimated_payout' => 3000,
'created_at' => now()->subMinutes(20),
]);
RiskPool::query()->create([
'draw_id' => $draw->id,
'normalized_number' => '1234',
'total_cap_amount' => 5000,
'locked_amount' => 3000,
'remaining_amount' => 2000,
'sold_out_status' => 0,
'version' => 1,
]);
$this->artisan('lottery:ticket-pending-confirm-reconcile --stale-minutes=15 --limit=100')
->expectsOutputToContain('refunded: 1')
->assertExitCode(0);
expect($order->fresh()->status)->toBe('refunded')
->and($item->fresh()->status)->toBe('refunded')
->and((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(0)
->and(DB::table('credit_ledger')
->where('owner_id', $player->id)
->where('reason', 'bet_hold_release')
->where('ref_type', 'ticket_order')
->where('ref_id', $order->id)
->count())->toBe(1)
->and((int) RiskPool::query()->where('draw_id', $draw->id)->where('normalized_number', '1234')->value('locked_amount'))->toBe(0);
$this->artisan('lottery:ticket-pending-confirm-reconcile --stale-minutes=15 --limit=100')
->assertExitCode(0);
expect(DB::table('credit_ledger')
->where('owner_id', $player->id)
->where('reason', 'bet_hold_release')
->where('ref_type', 'ticket_order')
->where('ref_id', $order->id)
->count())->toBe(1);
});

View File

@@ -4,12 +4,13 @@ use App\Models\Player;
use App\Lottery\ErrorCode;
use App\Models\PlayerWallet;
use Database\Seeders\CurrencySeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
fakeWalletApiDns();
$this->seed(CurrencySeeder::class);
config(['lottery.main_site.wallet_api_url' => null]);
});

View File

@@ -18,6 +18,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
fakeWalletApiDns();
config(['lottery.main_site.wallet_api_url' => null]);
$this->seed(CurrencySeeder::class);
$this->seed(LotterySettingsSeeder::class);
@@ -139,6 +140,38 @@ test('transfer in main site explicit failure returns 1009 and marks order failed
->and(WalletTxn::query()->where('player_id', $player->id)->count())->toBe(0);
});
test('transfer in rejects private dns answer before sending wallet bearer request', function (): void {
fakeWalletApiDns([
'private-debit.test' => ['10.0.0.8'],
], []);
Http::preventStrayRequests();
config(['lottery.main_site.wallet_api_url' => 'https://private-debit.test']);
config(['lottery.main_site.wallet_debit_path' => 'debit']);
$player = Player::query()->create([
'site_code' => 'main',
'site_player_id' => 'private-dns-in',
'username' => null,
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$key = 'private-dns-in-'.uniqid('', true);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->postJson('/api/v1/wallet/transfer-in', [
'amount' => 500,
'currency' => 'NPR',
'idempotent_key' => $key,
])
->assertStatus(400)
->assertJsonPath('code', ErrorCode::WalletExternalRejected->value);
expect(TransferOrder::query()->where('idempotent_key', $key)->value('status'))->toBe('failed');
Http::assertSentCount(0);
});
test('transfer in main site timeout returns 1002 and pending_reconcile', function (): void {
Http::fake([
'timeout-debit.test/*' => Http::response([], 504),

View File

@@ -14,6 +14,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
fakeWalletApiDns();
config(['lottery.main_site.wallet_api_url' => null]);
$this->seed(CurrencySeeder::class);
$this->seed(LotterySettingsSeeder::class);

View File

@@ -2,9 +2,24 @@
use Tests\TestCase;
use App\Models\AdminUser;
use App\Models\AgentProfile;
use Illuminate\Support\Carbon;
use App\Support\SuperAdminAccount;
use Illuminate\Support\Facades\DB;
use App\Events\OddsUpdateBroadcast;
use App\Events\PlayToggleBroadcast;
use App\Events\RiskSoldOutBroadcast;
use App\Events\RiskWarningBroadcast;
use App\Support\PlatformSystemRoles;
use App\Events\JackpotBurstBroadcast;
use App\Events\BalanceUpdateBroadcast;
use App\Events\DrawCountdownBroadcast;
use Illuminate\Support\Facades\Schema;
use App\Contracts\WalletApiDnsResolver;
use App\Events\DrawStatusChangeBroadcast;
use App\Events\PlayCatalogUpdatedBroadcast;
use App\Events\DrawResultPublishedBroadcast;
use App\Support\Integration\WalletApiRequestGuard;
use Illuminate\Foundation\Testing\RefreshDatabase;
/*
@@ -53,8 +68,8 @@ expect()->extend('toBeOne', function () {
/** 为后台测试账号挂上唯一超级管理员(不绑定站点)。 */
function grantSuperAdminRole(AdminUser $admin): void
{
\App\Support\PlatformSystemRoles::ensureSuperAdminRole();
\App\Support\SuperAdminAccount::assign($admin);
PlatformSystemRoles::ensureSuperAdminRole();
SuperAdminAccount::assign($admin);
}
/** 为后台测试账号挂上代理节点(需已存在 agent_nodes / admin_user_agents 表)。 */
@@ -76,6 +91,37 @@ function agentChildPayload(array $overrides = []): array
], $overrides);
}
/**
* 为钱包 HTTP 测试提供离线 DNS避免测试依赖机器或公网解析状态。
*
* @param array<string, list<string>> $recordsByHost
* @param list<string> $defaultRecords
*/
function fakeWalletApiDns(
array $recordsByHost = [],
array $defaultRecords = ['93.184.216.34'],
): void {
app()->instance(WalletApiDnsResolver::class, new class($recordsByHost, $defaultRecords) implements WalletApiDnsResolver
{
/**
* @param array<string, list<string>> $recordsByHost
* @param list<string> $defaultRecords
*/
public function __construct(
private readonly array $recordsByHost,
private readonly array $defaultRecords,
) {}
public function resolveAll(string $hostname): array
{
return $this->recordsByHost[$hostname] ?? $this->defaultRecords;
}
});
// Guard 可能已在当前测试中解析过,清除后确保使用刚绑定的 DNS resolver。
app()->forgetInstance(WalletApiRequestGuard::class);
}
function bindAdminUserToAgent(AdminUser $admin, int $agentNodeId): void
{
DB::table('admin_user_agents')->updateOrInsert(
@@ -96,7 +142,7 @@ function ensureRootAgentProfileSeeded(): void
return;
}
\App\Models\AgentProfile::query()->updateOrCreate(
AgentProfile::query()->updateOrCreate(
['agent_node_id' => (int) $rootId],
[
'total_share_rate' => 100,
@@ -154,15 +200,15 @@ function ensureAdminActionCatalogSeeded(): void
function allBroadcastEvents(): array
{
return [
\App\Events\BalanceUpdateBroadcast::class,
\App\Events\DrawCountdownBroadcast::class,
\App\Events\DrawResultPublishedBroadcast::class,
\App\Events\DrawStatusChangeBroadcast::class,
\App\Events\JackpotBurstBroadcast::class,
\App\Events\OddsUpdateBroadcast::class,
\App\Events\PlayCatalogUpdatedBroadcast::class,
\App\Events\PlayToggleBroadcast::class,
\App\Events\RiskSoldOutBroadcast::class,
\App\Events\RiskWarningBroadcast::class,
BalanceUpdateBroadcast::class,
DrawCountdownBroadcast::class,
DrawResultPublishedBroadcast::class,
DrawStatusChangeBroadcast::class,
JackpotBurstBroadcast::class,
OddsUpdateBroadcast::class,
PlayCatalogUpdatedBroadcast::class,
PlayToggleBroadcast::class,
RiskSoldOutBroadcast::class,
RiskWarningBroadcast::class,
];
}

View File

@@ -0,0 +1,74 @@
<?php
use Illuminate\Http\Client\PendingRequest;
use App\Support\Integration\WalletApiRequestGuard;
use App\Support\Integration\WalletApiUrlSanitizer;
test('wallet api sanitizer rejects private and reserved literal addresses', function (string $url): void {
expect(WalletApiUrlSanitizer::normalizeAndValidate($url))->toBeNull();
})->with([
'ipv4 loopback' => 'https://127.0.0.1',
'ipv4 shortened notation' => 'https://127.1',
'ipv4 decimal integer notation' => 'https://2130706433',
'ipv4 hexadecimal notation' => 'https://0x7f000001',
'ipv4 octal notation' => 'https://0177.0.0.1',
'ipv4 private' => 'https://10.0.0.1',
'ipv4 link local' => 'https://169.254.169.254',
'ipv4 cgnat' => 'https://100.64.0.1',
'ipv4 documentation' => 'https://192.0.2.1',
'ipv6 loopback' => 'https://[::1]',
'ipv6 unique local' => 'https://[fd00::1]',
'ipv6 link local' => 'https://[fe80::1]',
'ipv6 nat64 private target' => 'https://[64:ff9b::a00:1]',
'ipv6 protocol assignment' => 'https://[2001::1]',
'ipv6 documentation' => 'https://[2001:db8::1]',
]);
test('wallet api guard rejects hostname when any dns answer is non-public', function (): void {
fakeWalletApiDns([
'wallet.example.test' => ['93.184.216.34', '10.0.0.8'],
], []);
expect(app(WalletApiRequestGuard::class)->guard('https://wallet.example.test', 10))
->toBeNull();
});
test('wallet api guard rejects unresolved hostname', function (): void {
fakeWalletApiDns([], []);
expect(app(WalletApiRequestGuard::class)->guard('https://missing.example.test', 10))
->toBeNull();
});
test('wallet api guard accepts public ipv4 and ipv6 dns answers and bounds connect timeout', function (): void {
fakeWalletApiDns([
'wallet.example.test' => ['93.184.216.34', '2606:4700:4700::1111'],
], []);
$endpoint = app(WalletApiRequestGuard::class)->guard('https://wallet.example.test:8443', 120);
expect($endpoint)->not->toBeNull()
->and($endpoint?->baseUrl)->toBe('https://wallet.example.test:8443')
->and($endpoint?->port)->toBe(8443)
->and($endpoint?->pinnedIp)->toBe('93.184.216.34')
->and($endpoint?->totalTimeoutSeconds)->toBe(120)
->and($endpoint?->connectTimeoutSeconds)->toBe(5);
$pending = $endpoint?->request(['Authorization' => 'Bearer secret']);
$optionsProperty = new ReflectionProperty(PendingRequest::class, 'options');
$options = $optionsProperty->getValue($pending);
expect($options['allow_redirects'] ?? null)->toBeFalse()
->and($options['proxy'] ?? null)->toBe('')
->and($options['timeout'] ?? null)->toBe(120)
->and($options['connect_timeout'] ?? null)->toBe(5)
->and($options['curl'][CURLOPT_RESOLVE] ?? null)
->toBe(['wallet.example.test:8443:93.184.216.34']);
});
test('wallet api sanitizer accepts normal public literals', function (): void {
expect(WalletApiUrlSanitizer::normalizeAndValidate('https://93.184.216.34'))
->toBe('https://93.184.216.34')
->and(WalletApiUrlSanitizer::normalizeAndValidate('https://[2606:4700:4700::1111]'))
->toBe('https://[2606:4700:4700::1111]');
});