feat: 增强代理和玩家管理功能

- 在多个控制器中更新权限检查逻辑,确保管理员能够更灵活地管理代理和玩家。
- 在 AdminPlayerStoreController 中引入对玩家创建能力的验证,确保只有具备相应权限的管理员能够创建玩家。
- 更新请求验证逻辑,新增 credit_limit、rebate_rate 和 extra_rebate_rate 字段,以支持更细粒度的玩家管理。
- 在 AgentNodeProfileController 中添加对父代理能力授予的验证,确保子代理的权限在父代理范围内。
- 引入 AgentProfileFieldRules 以简化代理资料更新请求的规则定义,提升代码复用性。
This commit is contained in:
2026-06-04 18:00:50 +08:00
parent 96545f87f6
commit a44679665d
183 changed files with 10054 additions and 857 deletions

View File

@@ -0,0 +1,67 @@
<?php
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Support\AgentPlatformRole;
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);
});
test('agent dashboard returns agent overview for operator with dashboard permission', 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);
$super = AdminUser::query()->create([
'username' => 'super_dash_agent',
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$branch = $service->createChild($super, [
'parent_id' => $rootId,
'code' => 'dash-branch',
'name' => 'Dash Branch',
'can_create_player' => true,
]);
$operator = AdminUser::query()->where('username', 'agent_'.$branch->code)->first();
if ($operator === null) {
$operator = AdminUser::query()
->whereIn('id', DB::table('admin_user_agents')->where('agent_node_id', $branch->id)->pluck('admin_user_id'))
->first();
}
expect($operator)->not->toBeNull();
$platformRoleId = AgentPlatformRole::id();
$boundRoleId = (int) DB::table('admin_user_agent_roles')
->where('admin_user_id', $operator->id)
->where('agent_node_id', $branch->id)
->value('role_id');
expect($boundRoleId)->toBe($platformRoleId);
$slugs = DB::table('admin_role_menu_actions as rma')
->join('admin_menu_actions as ma', 'ma.id', '=', 'rma.menu_action_id')
->where('rma.role_id', $platformRoleId)
->pluck('ma.permission_code')
->all();
expect(in_array('dashboard.view', $slugs, true))->toBeTrue();
$token = $operator->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/dashboard')
->assertOk()
->assertJsonPath('data.agent_overview.agent_node_id', $branch->id)
->assertJsonPath('data.agent_overview.agent_code', 'dash-branch');
});

View File

@@ -11,7 +11,7 @@ beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
test('super admin can provision agent line with aligned root code', function (): void {
test('super admin can provision root agent on existing integration site', function (): void {
$admin = AdminUser::query()->create([
'username' => 'line_super',
'name' => 'Line Super',
@@ -23,21 +23,29 @@ test('super admin can provision agent line with aligned root code', function ():
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'line-alpha',
'name' => 'Line Alpha Site',
'status' => 1,
])
->assertCreated()
->assertJsonPath('data.code', 'line-alpha');
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/agent-lines', [
'site_code' => 'line-alpha',
'code' => 'line-alpha',
'name' => 'Line Alpha',
'username' => 'line_alpha_owner',
'password' => 'secret-strong',
'currency_code' => 'NPR',
'status' => 1,
])
->assertCreated()
->assertJsonPath('data.code', 'line-alpha')
->assertJsonPath('data.agent_node.code', 'line-alpha')
->assertJsonPath('data.line_root.site_code', 'line-alpha')
->assertJsonPath('data.secrets.sso_jwt_secret', fn ($v) => is_string($v) && $v !== '')
->assertJsonPath('data.secrets.wallet_api_key', fn ($v) => is_string($v) && $v !== '');
->assertJsonMissingPath('data.secrets');
$siteId = (int) DB::table('admin_sites')->where('code', 'line-alpha')->value('id');
expect($siteId)->toBeGreaterThan(0);
@@ -55,7 +63,48 @@ test('super admin can provision agent line with aligned root code', function ():
)->toBe(1);
});
test('non super admin cannot create integration site directly', function (): void {
test('agent line provision rejects site that already has root', function (): void {
$admin = AdminUser::query()->create([
'username' => 'line_super2',
'name' => 'Line Super 2',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'line-beta',
'name' => 'Line Beta Site',
])
->assertCreated();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/agent-lines', [
'site_code' => 'line-beta',
'code' => 'line-beta',
'name' => 'Line Beta',
'username' => 'line_beta_owner',
'password' => 'secret-strong',
])
->assertCreated();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/agent-lines', [
'site_code' => 'line-beta',
'code' => 'line-beta-2',
'name' => 'Line Beta 2',
'username' => 'line_beta_owner2',
'password' => 'secret-strong',
])
->assertStatus(422)
->assertJsonPath('data.errors.site_code.0', 'site_root_exists');
});
test('integration manager with site.manage can create integration site', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$admin = AdminUser::query()->create([
'username' => 'line_ops',
@@ -98,8 +147,9 @@ test('non super admin cannot create integration site directly', function (): voi
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'blocked-site',
'name' => 'Blocked',
'code' => 'ops-site',
'name' => 'Ops Site',
])
->assertForbidden();
->assertCreated()
->assertJsonPath('data.code', 'ops-site');
});

View File

@@ -161,6 +161,43 @@ test('agent profile update normalizes empty settlement cycle', function (): void
->assertJsonPath('data.settlement_cycle', 'weekly');
});
test('bound agent cannot update own profile share and credit', 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);
$super = AdminUser::query()->create([
'username' => 'self_profile_super',
'name' => 'Self Profile Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$agentNode = $service->createChild($super, agentChildPayload([
'parent_id' => $rootId,
'code' => 'self-profile-agent',
'name' => 'Self Profile Agent',
'username' => 'self_profile_agent',
'total_share_rate' => 20,
'credit_limit' => 4000,
]));
$agentUser = AdminUser::query()->where('username', 'self_profile_agent')->firstOrFail();
bindAdminUserToAgent($agentUser, $agentNode->id);
$agentUser->syncPrimaryPlatformAgentRole($agentNode->id);
$token = $agentUser->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/agent-nodes/'.$agentNode->id.'/profile', [
'total_share_rate' => 99,
'credit_limit' => 999_999,
])
->assertForbidden();
});
test('agent profile update rejects default rebate above limit', 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');

View File

@@ -0,0 +1,129 @@
<?php
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Models\AgentProfile;
use App\Support\AdminAuthProfile;
use App\Support\AgentPlatformRole;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:agent-roles-sync')->assertExitCode(0);
});
test('agent profile switches strip create player and child manage from effective permissions', 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');
$child = AgentNode::query()->create([
'admin_site_id' => $siteId,
'parent_id' => $rootId,
'path' => '/',
'depth' => 1,
'code' => 'cap-child',
'name' => 'Cap Child',
'status' => 1,
]);
$child->path = "/{$rootId}/{$child->id}/";
$child->save();
AgentProfile::query()->create([
'agent_node_id' => $child->id,
'total_share_rate' => 10,
'credit_limit' => 0,
'allocated_credit' => 0,
'used_credit' => 0,
'rebate_limit' => 0,
'default_player_rebate' => 0,
'settlement_cycle' => 'weekly',
'can_grant_extra_rebate' => false,
'can_create_child_agent' => false,
'can_create_player' => false,
]);
$admin = AdminUser::query()->create([
'username' => 'cap_child_agent',
'name' => 'Cap Child Agent',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
DB::table('admin_user_agents')->insert([
'admin_user_id' => $admin->id,
'agent_node_id' => $child->id,
'is_primary' => true,
'granted_at' => now(),
]);
$admin->syncPrimaryPlatformAgentRole($child->id);
$fresh = $admin->fresh();
$profile = AdminAuthProfile::fromAdmin($fresh);
$perms = $profile['permissions'];
expect($perms)->toContain('prd.agent.view')
->not->toContain('prd.agent.manage')
->not->toContain('prd.users.manage');
expect($fresh->hasPermissionCode('agent.node.manage'))->toBeFalse();
expect($fresh->hasPermissionCode('service.players.manage'))->toBeFalse();
expect($profile['agent']['can_create_child_agent'])->toBeFalse();
expect($profile['agent']['can_create_player'])->toBeFalse();
});
test('agent profile switches on grant create capabilities even when platform agent role omits manage', 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');
$child = AgentNode::query()->create([
'admin_site_id' => $siteId,
'parent_id' => $rootId,
'path' => '/',
'depth' => 1,
'code' => 'cap-child-on',
'name' => 'Cap Child On',
'status' => 1,
]);
$child->path = "/{$rootId}/{$child->id}/";
$child->save();
AgentProfile::query()->create([
'agent_node_id' => $child->id,
'total_share_rate' => 10,
'credit_limit' => 0,
'allocated_credit' => 0,
'used_credit' => 0,
'rebate_limit' => 0,
'default_player_rebate' => 0,
'settlement_cycle' => 'weekly',
'can_grant_extra_rebate' => false,
'can_create_child_agent' => true,
'can_create_player' => true,
]);
$admin = AdminUser::query()->create([
'username' => 'cap_child_on_agent',
'name' => 'Cap Child On Agent',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
DB::table('admin_user_agents')->insert([
'admin_user_id' => $admin->id,
'agent_node_id' => $child->id,
'is_primary' => true,
'granted_at' => now(),
]);
$admin->syncPrimaryPlatformAgentRole($child->id);
$fresh = $admin->fresh();
expect($fresh->hasPermissionCode('agent.node.manage'))->toBeTrue();
expect($fresh->hasPermissionCode('service.players.manage'))->toBeTrue();
expect($fresh->adminPermissionSlugs())->toContain('prd.agent.manage')
->and($fresh->adminPermissionSlugs())->toContain('prd.users.manage');
});

View File

@@ -0,0 +1,119 @@
<?php
use App\Models\AdminUser;
use App\Models\Player;
use App\Support\PlayerAuthSource;
use App\Support\PlayerFundingMode;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
test('admin credit ledger index returns credit player ledger rows', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$siteId = (int) $site->id;
$siteCode = (string) $site->code;
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId,
'period_start' => now()->subDays(3),
'period_end' => now()->addDay(),
'status' => 'open',
'created_at' => now(),
'updated_at' => now(),
]);
$player = Player::query()->create([
'site_code' => $siteCode,
'site_player_id' => 'native:credit-ledger-admin',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'credit_admin_flow',
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => -500,
'reason' => 'bet_hold',
'ref_type' => 'bet',
'ref_id' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$admin = AdminUser::query()->create([
'username' => 'credit_ledger_super',
'name' => 'Credit Ledger',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/credit-ledger?admin_site_id='.$siteId.'&settlement_period_id='.$periodId)
->assertOk()
->assertJsonPath('data.ledger_source', 'settlement_ledger')
->assertJsonPath('data.total', 1)
->assertJsonPath('data.items.0.entry_kind', 'credit')
->assertJsonPath('data.items.0.player_id', $player->id)
->assertJsonPath('data.items.0.biz_type', 'bet_hold')
->assertJsonPath('data.items.0.ledger_source', 'credit_ledger')
->assertJsonPath('data.items.0.funding_mode', PlayerFundingMode::CREDIT)
->assertJsonPath('data.items.0.available_actions', ['view_player']);
});
test('settlement periods include pipeline credit and share counts', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$siteId = (int) $site->id;
$siteCode = (string) $site->code;
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId,
'period_start' => now()->subDay(),
'period_end' => now()->addDay(),
'status' => 'open',
'created_at' => now(),
'updated_at' => now(),
]);
$player = Player::query()->create([
'site_code' => $siteCode,
'site_player_id' => 'native:pipeline-1',
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'pipe_user',
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => -100,
'reason' => 'bet_hold',
'created_at' => now(),
'updated_at' => now(),
]);
$admin = AdminUser::query()->create([
'username' => 'pipeline_super',
'name' => 'Pipeline',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/settlement-periods?admin_site_id='.$siteId)
->assertOk()
->assertJsonPath('data.items.0.id', $periodId)
->assertJsonPath('data.items.0.pipeline.credit_ledger_count', 1)
->assertJsonPath('data.items.0.pipeline.share_ledger_count', 0);
});

View File

@@ -0,0 +1,175 @@
<?php
use App\Models\AdminRole;
use App\Models\AdminUser;
use App\Models\Draw;
use App\Lottery\DrawStatus;
use App\Support\AdminAuthProfile;
use App\Support\AdminPermissionBridge;
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);
});
function drawViewOnlyToken(): string
{
$admin = AdminUser::query()->create([
'username' => 'draw_view_only_admin',
'name' => 'Draw View',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$role = AdminRole::query()->create([
'slug' => 'draw_view_only_role',
'name' => 'Draw view only role',
]);
$role->syncLegacyPermissionSlugs(['prd.draw_result.view']);
$siteId = AdminUser::defaultAdminSiteId();
$admin->roles()->sync([
(int) $role->id => ['site_id' => $siteId, 'granted_at' => now()],
]);
return $admin->fresh()->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
function drawViewOnlyFixtureDraw(): Draw
{
return Draw::query()->create([
'draw_no' => '20260604-099',
'business_date' => '2026-06-04',
'sequence_no' => 99,
'status' => DrawStatus::Settled->value,
'start_time' => now()->subHours(3),
'close_time' => now()->subHours(2),
'draw_time' => now()->subHours(1),
'result_source' => 'rng',
'current_result_version' => 1,
'settle_version' => 1,
'is_reopened' => false,
]);
}
test('partial draw review codes do not infer manage slug', function (): void {
$granted = AdminPermissionBridge::legacySlugsGrantedByMenuActionCodes(['draw.review.publish']);
expect($granted)->not->toContain('prd.draw_result.manage');
});
test('draw view only admin profile excludes manage and cannot store draw', function (): void {
$admin = AdminUser::query()->create([
'username' => 'draw_view_only_profile',
'name' => 'Draw View',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$role = AdminRole::query()->create([
'slug' => 'draw_view_only_role_profile',
'name' => 'Draw view only role',
]);
$role->syncLegacyPermissionSlugs(['prd.draw_result.view']);
$siteId = AdminUser::defaultAdminSiteId();
$admin->roles()->sync([
(int) $role->id => ['site_id' => $siteId, 'granted_at' => now()],
]);
$profile = AdminAuthProfile::fromAdmin($admin->fresh());
expect($profile['permissions'])->toContain('prd.draw_result.view')
->not->toContain('prd.draw_result.manage');
$token = $admin->fresh()->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/draws', [
'admin_site_id' => $siteId,
'draw_no' => 'test-view-only-001',
'start_time' => now()->toIso8601String(),
'close_time' => now()->addHour()->toIso8601String(),
'draw_time' => now()->addHours(2)->toIso8601String(),
])
->assertForbidden();
});
test('draw view only list and show omit finance and operational fields', function (): void {
$token = drawViewOnlyToken();
$draw = drawViewOnlyFixtureDraw();
$listPayload = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/draws')
->assertOk()
->json('data');
$listRow = collect($listPayload['items'])->firstWhere('id', $draw->id);
expect($listRow)->not->toBeNull()
->and($listRow)->not->toHaveKey('total_bet_minor')
->and($listRow)->not->toHaveKey('result_source')
->and($listPayload['capabilities']['can_manage_draw_results'])->toBeFalse()
->and($listPayload['capabilities']['can_view_draw_finance'])->toBeFalse();
$show = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/draws/'.$draw->id)
->assertOk()
->json('data');
expect($show)->not->toHaveKeys([
'result_source',
'current_result_version',
'settle_version',
'is_reopened',
'created_at',
'updated_at',
])
->and($show['result_batch_counts'])->not->toHaveKey('pending_review')
->and($show['result_batch_counts'])->not->toHaveKey('total')
->and($show['capabilities']['can_manage_draw_results'])->toBeFalse();
});
test('draw view only cannot read finance summary and result batches hide ops metadata', function (): void {
$token = drawViewOnlyToken();
$draw = drawViewOnlyFixtureDraw();
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/draws/'.$draw->id.'/finance-summary')
->assertForbidden();
$payload = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/draws/'.$draw->id.'/result-batches')
->assertOk()
->json('data');
expect($payload['capabilities']['can_manage_draw_results'])->toBeFalse();
foreach ($payload['batches'] as $batch) {
expect($batch)->not->toHaveKeys([
'source_type',
'rng_seed_hash',
'created_by',
'confirmed_by',
'created_at',
'updated_at',
]);
}
$hasPending = DB::table('draw_result_batches')
->where('draw_id', $draw->id)
->where('status', 'pending_review')
->exists();
if ($hasPending) {
$statuses = collect($payload['batches'])->pluck('status')->unique()->all();
expect($statuses)->not->toContain('pending_review');
}
});

View File

@@ -59,6 +59,34 @@ test('super admin can create integration site and receive secrets once', functio
expect(AuditLog::query()->where('module_code', 'integration')->where('action_code', 'create')->exists())->toBeTrue();
});
test('super admin can reveal integration site secrets for copy', function (): void {
$token = integrationAdminToken();
$create = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/integration-sites', [
'code' => 'partner-secrets',
'name' => 'Partner Secrets',
])
->assertCreated();
$id = (int) $create->json('data.id');
$plainSso = (string) $create->json('data.secrets.sso_jwt_secret');
$plainWallet = (string) $create->json('data.secrets.wallet_api_key');
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/integration-sites/'.$id.'/secrets')
->assertOk()
->assertJsonPath('data.sso_jwt_secret', $plainSso)
->assertJsonPath('data.wallet_api_key', $plainWallet);
expect(
AuditLog::query()
->where('module_code', 'integration')
->where('action_code', 'reveal_secrets')
->exists()
)->toBeTrue();
});
test('integration site code cannot be changed on update', function (): void {
$token = integrationAdminToken();

View File

@@ -1,10 +1,13 @@
<?php
use App\Models\AgentProfile;
use App\Models\Player;
use App\Models\AuditLog;
use App\Models\AdminRole;
use App\Models\AdminUser;
use App\Models\PlayerWallet;
use App\Support\PlayerAuthSource;
use App\Support\PlayerFundingMode;
use Database\Seeders\CurrencySeeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB;
@@ -263,3 +266,58 @@ test('admin can update player default currency and validation rejects unknown co
])
->assertStatus(422);
});
test('admin can set player credit limit without clobbering used credit', function (): void {
$siteCode = DB::table('admin_sites')->where('is_default', true)->value('code');
$siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site';
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
AgentProfile::query()->updateOrCreate(
['agent_node_id' => $rootId],
[
'total_share_rate' => 60,
'credit_limit' => 50_000,
'allocated_credit' => 0,
'used_credit' => 0,
'rebate_limit' => 0.01,
'default_player_rebate' => 0.005,
'settlement_cycle' => 'weekly',
],
);
$player = Player::query()->create([
'site_code' => $siteCode,
'agent_node_id' => $rootId,
'site_player_id' => 'credit-limit-1',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'credit_limit_user',
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => 500,
'used_credit' => 120,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$token = playerManageAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/players/'.$player->id, [
'credit_limit' => 2000,
])
->assertOk()
->assertJsonPath('data.credit_limit', 2000)
->assertJsonPath('data.available_credit', 1880);
$this->assertDatabaseHas('player_credit_accounts', [
'player_id' => $player->id,
'credit_limit' => 2000,
'used_credit' => 120,
]);
});

View File

@@ -120,6 +120,7 @@ test('admin can sync user roles for default site', function (): void {
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/admin-users/'.$target->id.'/roles', [
'admin_site_id' => AdminUser::defaultAdminSiteId(),
'role_slugs' => ['role_sync_b', 'role_sync_a'],
])
->assertOk()
@@ -247,7 +248,6 @@ test('permission catalog groups permissions by admin navigation order', function
'rules_odds',
'jackpot',
'risk_cap',
'integration',
'currencies',
'admin_users',
'admin_roles',
@@ -257,12 +257,12 @@ test('permission catalog groups permissions by admin navigation order', function
]);
expect($groups[1]['key'])->toBe('agents');
expect($groups[2]['key'])->toBe('draws');
expect($groups[15]['label'])->toBe('管理列表');
expect(array_column($groups[15]['permissions'], 'slug'))->toBe(['prd.admin_user.manage']);
expect($groups[16]['label'])->toBe('角色管理');
expect(array_column($groups[16]['permissions'], 'slug'))->toBe(['prd.admin_role.manage']);
$groupsByKey = collect($groups)->keyBy('key');
expect($groupsByKey['admin_users']['label'])->toBe('管理列表');
expect(array_column($groupsByKey['admin_users']['permissions'], 'slug'))->toBe(['prd.admin_user.manage']);
expect($groupsByKey['admin_roles']['label'])->toBe('角色管理');
expect(array_column($groupsByKey['admin_roles']['permissions'], 'slug'))->toBe(['prd.admin_role.manage']);
expect(array_column($groupsByKey['tickets']['permissions'], 'slug'))->toBe([
'prd.tickets.view',
]);
@@ -280,7 +280,7 @@ test('permission catalog groups permissions by admin navigation order', function
]);
});
test('admin can repair role permissions from the full catalog after role creation', function (): void {
test('admin can adjust platform agent role permissions from the catalog', function (): void {
$token = makeAdminWithPermissions('role_permission_repairer', ['prd.admin_user.manage', 'prd.admin_role.manage']);
$catalog = $this->withHeader('Authorization', 'Bearer '.$token)
@@ -300,39 +300,37 @@ test('admin can repair role permissions from the full catalog after role creatio
->toContain('prd.report.view')
->toContain('prd.wallet_reconcile.manage');
$role = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/admin-roles', [
'slug' => 'repairable_role',
'name' => 'Repairable Role',
'permission_slugs' => [],
])
$agentRole = collect($this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/admin-roles')
->assertOk()
->assertJsonPath('data.permission_slugs', [])
->json('data');
->json('data.items'))
->firstWhere('slug', 'agent');
expect($agentRole)->not->toBeNull();
$repairResponse = $this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/admin-roles/'.$role['id'].'/permissions', [
->putJson('/api/v1/admin/admin-roles/'.$agentRole['id'].'/permissions', [
'permission_slugs' => ['prd.report.view', 'prd.wallet_reconcile.manage'],
])
->assertOk()
->assertJsonPath('data.slug', 'repairable_role');
->assertJsonPath('data.slug', 'agent');
expect($repairResponse->json('data.permission_slugs'))
->toContain('prd.report.view', 'prd.wallet_reconcile.manage');
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/admin-roles/'.$role['id'].'/permissions', [
->putJson('/api/v1/admin/admin-roles/'.$agentRole['id'].'/permissions', [
'permission_slugs' => ['prd.admin_role.manage'],
])
->assertOk()
->assertJsonPath('data.permission_slugs', ['prd.admin_role.manage']);
$persistedPermissions = $this->withHeader('Authorization', 'Bearer '.$token)
$persistedRole = collect($this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/admin-roles')
->assertOk()
->json('data.items');
->json('data.items'))
->firstWhere('slug', 'agent');
$persistedRole = collect($persistedPermissions)->firstWhere('slug', 'repairable_role');
expect($persistedRole['permission_slugs'])->toBe(['prd.admin_role.manage']);
});
@@ -348,6 +346,7 @@ test('admin can create update and delete users with crud rules', function (): vo
'email' => 'newuser@example.com',
'password' => 'secret-long',
'status' => 0,
'admin_site_id' => AdminUser::defaultAdminSiteId(),
'role_slugs' => ['crud_new_user_role'],
])
->assertOk()
@@ -364,6 +363,7 @@ test('admin can create update and delete users with crud rules', function (): vo
'nickname' => 'dup',
'email' => null,
'password' => 'secret-long',
'admin_site_id' => AdminUser::defaultAdminSiteId(),
'role_slugs' => [$crudRole->slug],
])
->assertStatus(422)
@@ -407,6 +407,7 @@ test('admin user create requires at least one role slug', function (): void {
'nickname' => 'NR',
'email' => null,
'password' => 'secret-long',
'admin_site_id' => AdminUser::defaultAdminSiteId(),
'role_slugs' => [],
])
->assertStatus(422)

View File

@@ -0,0 +1,243 @@
<?php
use App\Models\AdminRole;
use App\Models\AdminSite;
use App\Models\AdminUser;
use App\Models\Player;
use App\Support\AdminSiteScope;
use App\Lottery\ErrorCode;
use Database\Seeders\CurrencySeeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function siteRoleBindingAdmin(string $username, array $permissionSlugs, ?int $boundSiteId = null): string
{
$admin = AdminUser::query()->create([
'username' => $username,
'name' => 'Tester',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$role = AdminRole::query()->create([
'slug' => 'role_'.$username,
'name' => 'Role '.$username,
]);
$role->syncLegacyPermissionSlugs($permissionSlugs);
$siteId = $boundSiteId ?? AdminUser::defaultAdminSiteId();
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $admin->id,
'site_id' => $siteId,
'role_id' => $role->id,
'granted_at' => now(),
]);
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
test('platform user created with admin_site_id only sees that site players', function (): void {
$this->seed(CurrencySeeder::class);
AdminSite::query()->firstOrCreate(['code' => 'site-a'], ['name' => 'A', 'currency_code' => 'NPR', 'status' => 1]);
AdminSite::query()->firstOrCreate(['code' => 'site-b'], ['name' => 'B', 'currency_code' => 'NPR', 'status' => 1]);
$siteBId = (int) AdminSite::query()->where('code', 'site-b')->value('id');
Player::query()->create([
'site_code' => 'site-a',
'site_player_id' => 'pa-bind-1',
'username' => 'pa_bind_1',
'nickname' => 'PA',
'default_currency' => 'NPR',
'status' => 0,
]);
Player::query()->create([
'site_code' => 'site-b',
'site_player_id' => 'pb-bind-1',
'username' => 'pb_bind_1',
'nickname' => 'PB',
'default_currency' => 'NPR',
'status' => 0,
]);
$opsRole = AdminRole::query()->create([
'slug' => 'site_b_ops',
'name' => 'Site B Ops',
'scope_type' => AdminRole::SCOPE_SYSTEM,
]);
$opsRole->syncLegacyPermissionSlugs(['prd.users.view_finance', 'prd.admin_user.manage']);
$creator = AdminUser::query()->create([
'username' => 'super_creator',
'name' => 'Creator',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($creator);
$token = $creator->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/admin-users', [
'username' => 'site_b_ops_user',
'nickname' => 'Site B Ops User',
'email' => null,
'password' => 'secret-long',
'admin_site_id' => $siteBId,
'role_slugs' => ['site_b_ops'],
])
->assertOk()
->assertJsonPath('data.site_bindings.0.site_id', $siteBId)
->assertJsonPath('data.site_bindings.0.site_code', 'site-b');
$created = AdminUser::query()->where('username', 'site_b_ops_user')->firstOrFail();
expect($created->isSuperAdmin())->toBeFalse();
expect($created->accessibleAdminSiteIds())->toEqual([$siteBId]);
expect(AdminSiteScope::accessibleSiteCodes($created))->toBe(['site-b']);
$scopedQuery = Player::query();
AdminSiteScope::applyToPlayerQuery($scopedQuery, $created);
expect($scopedQuery->pluck('site_code')->unique()->values()->all())->toBe(['site-b']);
$createdToken = $created->createToken('test', ['*'], now()->addDay())->plainTextToken;
expect($creator->id)->not->toBe($created->id);
$boundRoleSlugs = DB::table('admin_user_site_roles as usr')
->join('admin_roles as r', 'r.id', '=', 'usr.role_id')
->where('usr.admin_user_id', $created->id)
->orderBy('r.slug')
->pluck('r.slug')
->all();
expect($boundRoleSlugs)->toBe(['site_b_ops']);
expect($created->fresh()->isSuperAdmin())->toBeFalse();
app('auth')->forgetGuards();
$this->withHeader('Authorization', 'Bearer '.$createdToken)
->getJson('/api/v1/admin/auth/me')
->assertOk()
->assertJsonPath('data.admin.id', $created->id)
->assertJsonPath('data.admin.username', 'site_b_ops_user')
->assertJsonCount(1, 'data.admin.accessible_sites');
$codes = collect(
$this->withHeader('Authorization', 'Bearer '.$createdToken)
->getJson('/api/v1/admin/players')
->assertOk()
->json('data.items'),
)->pluck('site_code')->unique()->values()->all();
expect($codes)->toBe(['site-b']);
$this->withHeader('Authorization', 'Bearer '.$createdToken)
->getJson('/api/v1/admin/auth/me')
->assertOk()
->assertJsonPath('data.admin.accessible_sites.0.code', 'site-b')
->assertJsonPath('data.admin.agent', null);
});
test('scoped operator cannot assign roles on site outside their binding', function (): void {
AdminSite::query()->firstOrCreate(['code' => 'site-a'], ['name' => 'A', 'currency_code' => 'NPR', 'status' => 1]);
AdminSite::query()->firstOrCreate(['code' => 'site-b'], ['name' => 'B', 'currency_code' => 'NPR', 'status' => 1]);
$siteAId = (int) AdminSite::query()->where('code', 'site-a')->value('id');
$siteBId = (int) AdminSite::query()->where('code', 'site-b')->value('id');
$target = AdminUser::query()->create([
'username' => 'bind_target',
'name' => 'Target',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
AdminRole::query()->create([
'slug' => 'scoped_assign_role',
'name' => 'Scoped Assign',
'scope_type' => AdminRole::SCOPE_SYSTEM,
]);
$token = siteRoleBindingAdmin('site_a_only_mgr', ['prd.admin_user.manage'], $siteAId);
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/admin-users/'.$target->id.'/roles', [
'admin_site_id' => $siteBId,
'role_slugs' => ['scoped_assign_role'],
])
->assertStatus(422)
->assertJsonPath('code', ErrorCode::ValidationFailed->value);
});
test('role sync replaces roles only for requested site', function (): void {
AdminSite::query()->firstOrCreate(['code' => 'site-a'], ['name' => 'A', 'currency_code' => 'NPR', 'status' => 1]);
AdminSite::query()->firstOrCreate(['code' => 'site-b'], ['name' => 'B', 'currency_code' => 'NPR', 'status' => 1]);
$siteAId = (int) AdminSite::query()->where('code', 'site-a')->value('id');
$siteBId = (int) AdminSite::query()->where('code', 'site-b')->value('id');
$rA = AdminRole::query()->create([
'slug' => 'multi_a',
'name' => 'A',
'scope_type' => AdminRole::SCOPE_SYSTEM,
]);
$rB = AdminRole::query()->create([
'slug' => 'multi_b',
'name' => 'B',
'scope_type' => AdminRole::SCOPE_SYSTEM,
]);
$target = AdminUser::query()->create([
'username' => 'multi_site_user',
'name' => 'Multi',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
DB::table('admin_user_site_roles')->insert([
['admin_user_id' => $target->id, 'site_id' => $siteAId, 'role_id' => $rA->id, 'granted_at' => now()],
['admin_user_id' => $target->id, 'site_id' => $siteBId, 'role_id' => $rB->id, 'granted_at' => now()],
]);
$actor = AdminUser::query()->create([
'username' => 'multi_sync_actor',
'name' => 'Actor',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($actor);
$token = $actor->createToken('test', ['*'], now()->addDay())->plainTextToken;
$rC = AdminRole::query()->create([
'slug' => 'multi_c',
'name' => 'C',
'scope_type' => AdminRole::SCOPE_SYSTEM,
]);
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/admin-users/'.$target->id.'/roles', [
'admin_site_id' => $siteAId,
'role_slugs' => ['multi_c'],
])
->assertOk();
$siteARoles = DB::table('admin_user_site_roles')
->where('admin_user_id', $target->id)
->where('site_id', $siteAId)
->pluck('role_id')
->map(static fn ($id): int => (int) $id)
->all();
$siteBRoles = DB::table('admin_user_site_roles')
->where('admin_user_id', $target->id)
->where('site_id', $siteBId)
->pluck('role_id')
->map(static fn ($id): int => (int) $id)
->all();
expect($siteARoles)->toBe([(int) $rC->id]);
expect($siteBRoles)->toBe([(int) $rB->id]);
});

View File

@@ -0,0 +1,171 @@
<?php
use App\Models\AgentNode;
use App\Models\AgentProfile;
use App\Services\Agent\AgentProfileService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
function createAgentLineForAllocation(string $code, int $creditLimit): AgentNode
{
$siteId = (int) DB::table('admin_sites')->insertGetId([
'code' => $code,
'name' => $code,
'is_default' => false,
'created_at' => now(),
'updated_at' => now(),
]);
$rootId = (int) DB::table('agent_nodes')->insertGetId([
'admin_site_id' => $siteId,
'parent_id' => null,
'depth' => 0,
'path' => '/'.$code,
'code' => $code,
'name' => 'Root',
'status' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
AgentProfile::query()->create([
'agent_node_id' => $rootId,
'total_share_rate' => 60,
'credit_limit' => $creditLimit,
'allocated_credit' => 0,
'used_credit' => 0,
'rebate_limit' => 0.01,
'default_player_rebate' => 0.005,
'settlement_cycle' => 'weekly',
]);
return AgentNode::query()->findOrFail($rootId);
}
test('player credit account syncs agent allocated credit', function (): void {
$root = createAgentLineForAllocation('line-alloc', 10000);
$service = app(AgentProfileService::class);
$playerId = (int) DB::table('players')->insertGetId([
'site_code' => 'line-alloc',
'agent_node_id' => $root->id,
'site_player_id' => 'p-alloc-1',
'username' => 'alloc1',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $playerId,
'credit_limit' => 2000,
'used_credit' => 0,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$service->refreshAllocatedCredit($root);
$profile = AgentProfile::query()->where('agent_node_id', $root->id)->first();
expect((int) $profile->allocated_credit)->toBe(2000);
expect($service->present($profile)['available_credit'])->toBe(8000);
});
test('player credit allocation exceeds available throws', function (): void {
$root = createAgentLineForAllocation('line-over', 5000);
$service = app(AgentProfileService::class);
expect(fn () => $service->assertMayIncreasePlayerCredit($root, 6000))
->toThrow(\Illuminate\Validation\ValidationException::class);
});
test('win loss does not change agent allocated credit', function (): void {
$root = createAgentLineForAllocation('line-hold', 10000);
$playerId = (int) DB::table('players')->insertGetId([
'site_code' => 'line-hold',
'agent_node_id' => $root->id,
'site_player_id' => 'p-hold-1',
'username' => 'hold1',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $playerId,
'credit_limit' => 2000,
'used_credit' => 200,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
app(AgentProfileService::class)->refreshAllocatedCredit($root);
$profile = AgentProfile::query()->where('agent_node_id', $root->id)->first();
expect((int) $profile->allocated_credit)->toBe(2000);
});
test('raising player credit limit succeeds when agent allocated credit includes other subordinates', function (): void {
$root = createAgentLineForAllocation('line-player-raise', 10000);
$service = app(AgentProfileService::class);
$playerId = (int) DB::table('players')->insertGetId([
'site_code' => 'line-player-raise',
'agent_node_id' => $root->id,
'site_player_id' => 'p-other',
'username' => 'other',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $playerId,
'credit_limit' => 3000,
'used_credit' => 0,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$service->refreshAllocatedCredit($root);
$newPlayerId = (int) DB::table('players')->insertGetId([
'site_code' => 'line-player-raise',
'agent_node_id' => $root->id,
'site_player_id' => 'p-raise-1',
'username' => 'raise1',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $newPlayerId,
'credit_limit' => 0,
'used_credit' => 0,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$service->adjustPlayerCreditAllocation($root, 0, 2000);
DB::table('player_credit_accounts')->where('player_id', $newPlayerId)->update(['credit_limit' => 2000]);
$service->refreshAllocatedCredit($root);
$profile = AgentProfile::query()->where('agent_node_id', $root->id)->first();
expect((int) $profile->allocated_credit)->toBe(5000);
});

View File

@@ -0,0 +1,72 @@
<?php
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Services\Agent\AgentNodeService;
use App\Services\Agent\AgentProfileService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
test('agent with overdue bill cannot create player', 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');
$super = AdminUser::query()->create([
'username' => 'od_super',
'name' => 'OD',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$agent = app(AgentNodeService::class)->createChild($super, agentChildPayload([
'parent_id' => $rootId,
'code' => 'OD1',
'name' => 'OD1',
'username' => 'od_agent_user',
'total_share_rate' => 50,
'credit_limit' => 10000,
'can_create_player' => true,
]));
$admin = AdminUser::query()->where('username', 'od_agent_user')->first();
expect($admin)->not->toBeNull();
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId,
'period_start' => now()->subWeek(),
'period_end' => now()->subDay(),
'status' => 'closed',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('settlement_bills')->insert([
'settlement_period_id' => $periodId,
'bill_type' => 'agent',
'owner_type' => 'agent',
'owner_id' => $agent->id,
'counterparty_type' => 'agent',
'counterparty_id' => $rootId,
'gross_win_loss' => 0,
'rebate_amount' => 0,
'adjustment_amount' => 0,
'net_amount' => 500,
'paid_amount' => 0,
'unpaid_amount' => 500,
'status' => 'overdue',
'created_at' => now(),
'updated_at' => now(),
]);
expect(fn () => app(AgentProfileService::class)->assertActorMayCreatePlayer($admin))
->toThrow(\Illuminate\Validation\ValidationException::class);
});

View File

@@ -0,0 +1,212 @@
<?php
use App\Models\AgentNode;
use App\Models\AgentProfile;
use App\Models\Player;
use App\Services\Agent\AgentNodeService;
use App\Services\AgentSettlement\AgentSettlementPeriodCloseService;
use App\Support\Settlement\DesignDocExample12;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
test('period close from share ledger matches design doc example 12', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$service = app(AgentNodeService::class);
$super = \App\Models\AdminUser::query()->create([
'username' => 'e2e_super',
'name' => 'E2E',
'email' => null,
'password' => \Illuminate\Support\Facades\Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$a = $service->createChild($super, agentChildPayload([
'parent_id' => $rootId,
'code' => 'A',
'name' => 'A',
'username' => 'e2e_a',
'total_share_rate' => 60,
'credit_limit' => 500000,
]));
$b = $service->createChild($super, agentChildPayload([
'parent_id' => $a->id,
'code' => 'B',
'name' => 'B',
'username' => 'e2e_b',
'total_share_rate' => 40,
'credit_limit' => 200000,
]));
$c = $service->createChild($super, agentChildPayload([
'parent_id' => $b->id,
'code' => 'C',
'name' => 'C',
'username' => 'e2e_c',
'total_share_rate' => 25,
'credit_limit' => 100000,
]));
$player = Player::query()->create([
'site_code' => $siteCode,
'agent_node_id' => $c->id,
'site_player_id' => 'e2e-p1',
'username' => 'e2eplayer',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$draw = \App\Models\Draw::query()->create([
'draw_no' => 'E2E-AG-001',
'business_date' => now()->toDateString(),
'sequence_no' => 99,
'status' => \App\Lottery\DrawStatus::Open->value,
'start_time' => null,
'close_time' => null,
'draw_time' => null,
'cooling_end_time' => null,
'result_source' => null,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$orderId = (int) DB::table('ticket_orders')->insertGetId([
'order_no' => 'ORD-E2E-AG-1',
'player_id' => $player->id,
'draw_id' => $draw->id,
'currency_code' => 'NPR',
'total_bet_amount' => 10000,
'total_rebate_amount' => 0,
'total_actual_deduct' => 10000,
'total_estimated_payout' => 0,
'status' => 'confirmed',
'submit_source' => 'h5',
'client_trace_id' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$ticketItemId = (int) DB::table('ticket_items')->insertGetId([
'ticket_no' => 'T-E2E-AG-1',
'order_id' => $orderId,
'player_id' => $player->id,
'draw_id' => $draw->id,
'original_number' => null,
'normalized_number' => '1234',
'play_code' => 'big',
'dimension' => 2,
'digit_slot' => null,
'bet_mode' => null,
'unit_bet_amount' => 10000,
'total_bet_amount' => 10000,
'rebate_rate_snapshot' => 0,
'commission_rate_snapshot' => 0,
'actual_deduct_amount' => 10000,
'odds_snapshot_json' => null,
'rule_snapshot_json' => null,
'combination_count' => 1,
'estimated_max_payout' => 0,
'risk_locked_amount' => 0,
'status' => 'settled_lose',
'fail_reason_code' => null,
'fail_reason_text' => null,
'win_amount' => 0,
'jackpot_win_amount' => 0,
'settled_at' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$settledAt = now();
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId,
'period_start' => $settledAt->copy()->subDay(),
'period_end' => $settledAt->copy()->addDay(),
'status' => 'open',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('share_ledger')->insert([
'ticket_item_id' => $ticketItemId,
'player_id' => $player->id,
'agent_node_id' => $c->id,
'agent_path' => json_encode([$a->id, $b->id, $c->id]),
'share_snapshot' => json_encode([
'total_shares' => ['C' => 25, 'B' => 40, 'A' => 60],
'actual_shares' => ['C' => 25, 'B' => 15, 'A' => 20, 'platform' => 40],
'chain_codes' => ['C', 'B', 'A'],
'agent_path' => [$a->id, $b->id, $c->id],
]),
'game_win_loss' => DesignDocExample12::GAME_WIN_LOSS,
'basic_rebate' => DesignDocExample12::BASIC_REBATE,
'shared_net_win_loss' => DesignDocExample12::SHARED_NET_WIN_LOSS,
'allocations_json' => json_encode([]),
'settled_at' => $settledAt,
'created_at' => $settledAt,
'updated_at' => $settledAt,
]);
DB::table('rebate_records')->insert([
[
'player_id' => $player->id,
'ticket_item_id' => $ticketItemId,
'game_type' => '*',
'valid_bet_amount' => 10000,
'rebate_rate' => 0.005,
'rebate_amount' => DesignDocExample12::BASIC_REBATE,
'rebate_type' => 'basic',
'owner_agent_id' => $c->id,
'status' => 'accrued',
'created_at' => $settledAt,
'updated_at' => $settledAt,
],
[
'player_id' => $player->id,
'ticket_item_id' => $ticketItemId,
'game_type' => '*',
'valid_bet_amount' => 10000,
'rebate_rate' => 0.002,
'rebate_amount' => DesignDocExample12::EXTRA_REBATE_BY_C,
'rebate_type' => 'extra',
'owner_agent_id' => $c->id,
'status' => 'accrued',
'created_at' => $settledAt,
'updated_at' => $settledAt,
],
]);
$close = app(AgentSettlementPeriodCloseService::class)->closePeriod($periodId);
$playerBill = DB::table('settlement_bills')
->where('settlement_period_id', $periodId)
->where('bill_type', 'player')
->where('owner_id', $player->id)
->first();
expect($playerBill)->not->toBeNull();
expect((int) $playerBill->net_amount)->toBe((int) DesignDocExample12::PLAYER_NET_SETTLEMENT);
$edgeCtoB = DB::table('settlement_bills')
->where('settlement_period_id', $periodId)
->where('meta_json', 'like', '%C_to_B%')
->value('net_amount');
expect((int) $edgeCtoB)->toBe((int) round(DesignDocExample12::TIER_C_TO_B));
expect($close['rebate_dispatched'])->toBe(2);
expect($close['rebate_allocations'])->toBeGreaterThan(0);
expect(DB::table('rebate_records')->where('status', 'in_bill')->count())->toBe(2);
expect(DB::table('rebate_allocations')->where('settlement_bill_id', $playerBill->id)->count())
->toBeGreaterThan(0);
});

View File

@@ -0,0 +1,90 @@
<?php
use App\Models\AdminUser;
use App\Models\Player;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
test('admin can write off player bill bad debt and complete period when all settled', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$agentId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => (int) $site->id,
'period_start' => now()->subDays(7),
'period_end' => now(),
'status' => 'closed',
'created_at' => now(),
'updated_at' => now(),
]);
$player = Player::query()->create([
'site_code' => (string) $site->code,
'agent_node_id' => $agentId,
'site_player_id' => 'bd-p1',
'auth_source' => 'lottery_native',
'funding_mode' => 'credit',
'username' => 'bduser',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$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' => $agentId,
'gross_win_loss' => 10000,
'rebate_amount' => 0,
'adjustment_amount' => 0,
'platform_rounding_adjustment' => 0,
'net_amount' => 10000,
'paid_amount' => 0,
'unpaid_amount' => 10000,
'status' => 'overdue',
'confirmed_at' => now(),
'locked_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$admin = AdminUser::query()->create([
'username' => 'bad_debt_super',
'name' => 'Bad Debt',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/settlement-bills/'.$billId.'/bad-debt-write-off', [
'reason' => 'uncollectible',
])
->assertOk()
->assertJsonPath('data.original_bill_id', $billId);
$this->assertDatabaseHas('settlement_bills', [
'id' => $billId,
'status' => 'settled',
'unpaid_amount' => 0,
]);
$this->assertDatabaseHas('settlement_adjustments', [
'original_bill_id' => $billId,
'adjustment_type' => 'bad_debt',
'amount' => 10000,
]);
$this->assertDatabaseHas('settlement_periods', [
'id' => $periodId,
'status' => 'completed',
]);
});

View File

@@ -0,0 +1,46 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
test('locked bill can receive adjustment bill', function (): void {
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => (int) DB::table('admin_sites')->where('is_default', true)->value('id'),
'period_start' => now()->subDay(),
'period_end' => now()->addDay(),
'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' => 1,
'counterparty_type' => 'agent',
'counterparty_id' => 1,
'gross_win_loss' => 1000,
'rebate_amount' => 50,
'adjustment_amount' => 0,
'net_amount' => 930,
'paid_amount' => 0,
'unpaid_amount' => 930,
'status' => 'confirmed',
'locked_at' => now(),
'confirmed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$newId = app(\App\Services\AgentSettlement\AgentSettlementBillAdjustmentService::class)
->createAdjustment($billId, -30, 'adjustment', 'correction', 0);
$adjustment = DB::table('settlement_bills')->where('id', $newId)->first();
expect($adjustment)->not->toBeNull();
expect((string) $adjustment->bill_type)->toBe('adjustment');
expect((int) $adjustment->reversed_bill_id)->toBe($billId);
expect((int) $adjustment->net_amount)->toBe(-30);
});

View File

@@ -0,0 +1,84 @@
<?php
use App\Models\AdminUser;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
test('settlement payments and adjustments index return items', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->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' => 1,
'counterparty_type' => 'agent',
'counterparty_id' => 1,
'net_amount' => 1000,
'unpaid_amount' => 0,
'paid_amount' => 1000,
'status' => 'settled',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('payment_records')->insert([
'settlement_bill_id' => $billId,
'payer_type' => 'player',
'payer_id' => 1,
'payee_type' => 'agent',
'payee_id' => 1,
'amount' => 1000,
'method' => 'cash',
'status' => 'confirmed',
'confirmed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('settlement_adjustments')->insert([
'settlement_period_id' => $periodId,
'original_bill_id' => $billId,
'adjustment_type' => 'adjustment',
'amount' => 100,
'reason' => 'test',
'created_at' => now(),
'updated_at' => now(),
]);
$admin = AdminUser::query()->create([
'username' => 'lists_super',
'name' => 'Lists',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/settlement-payments?admin_site_id='.$siteId)
->assertOk()
->assertJsonPath('data.items.0.settlement_bill_id', $billId);
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/settlement-adjustments?admin_site_id='.$siteId)
->assertOk()
->assertJsonPath('data.items.0.original_bill_id', $billId);
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/settlement-bills?admin_site_id='.$siteId.'&bill_type=player')
->assertOk()
->assertJsonPath('data.items.0.bill_type', 'player');
});

View File

@@ -0,0 +1,76 @@
<?php
use App\Models\AdminUser;
use App\Services\AgentSettlement\AgentSettlementPeriodSummaryService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
test('settlement periods index includes bill summary per period', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->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(),
]);
DB::table('settlement_bills')->insert([
[
'settlement_period_id' => $periodId,
'bill_type' => 'player',
'owner_type' => 'player',
'owner_id' => 1,
'counterparty_type' => 'agent',
'counterparty_id' => 1,
'net_amount' => 1000,
'unpaid_amount' => 1000,
'paid_amount' => 0,
'status' => 'pending_confirm',
'created_at' => now(),
'updated_at' => now(),
],
[
'settlement_period_id' => $periodId,
'bill_type' => 'agent',
'owner_type' => 'agent',
'owner_id' => 1,
'counterparty_type' => 'platform',
'counterparty_id' => 0,
'net_amount' => 5000,
'unpaid_amount' => 5000,
'paid_amount' => 0,
'status' => 'confirmed',
'created_at' => now(),
'updated_at' => now(),
],
]);
$admin = AdminUser::query()->create([
'username' => 'period_summary_admin',
'name' => 'Summary',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/settlement-periods?admin_site_id='.$siteId)
->assertOk()
->assertJsonPath('data.items.0.summary.player_bills', 1)
->assertJsonPath('data.items.0.summary.agent_bills', 1)
->assertJsonPath('data.items.0.summary.pending_confirm', 1)
->assertJsonPath('data.items.0.summary.awaiting_payment', 1)
->assertJsonPath('data.items.0.summary.total_unpaid', 6000);
$service = app(AgentSettlementPeriodSummaryService::class);
$summaries = $service->summariesForPeriodIds([$periodId]);
expect($summaries[$periodId]['player_bills'])->toBe(1);
expect($summaries[$periodId]['agent_bills'])->toBe(1);
});

View File

@@ -0,0 +1,66 @@
<?php
use App\Models\AgentProfile;
use App\Services\Agent\AgentProfileService;
use App\Services\AgentSettlement\BetSettlementSnapshotBuilder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
test('share snapshot uses profile at build time not after change', function (): void {
$siteId = (int) DB::table('admin_sites')->insertGetId([
'code' => 'snap-line',
'name' => 'snap',
'is_default' => false,
'created_at' => now(),
'updated_at' => now(),
]);
$rootId = (int) DB::table('agent_nodes')->insertGetId([
'admin_site_id' => $siteId,
'parent_id' => null,
'depth' => 0,
'path' => '/snap-line',
'code' => 'snap-line',
'name' => 'Root',
'status' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
AgentProfile::query()->create([
'agent_node_id' => $rootId,
'total_share_rate' => 25,
'credit_limit' => 10000,
'allocated_credit' => 0,
'used_credit' => 0,
'rebate_limit' => 0.01,
'default_player_rebate' => 0.005,
'settlement_cycle' => 'weekly',
]);
$playerId = (int) DB::table('players')->insertGetId([
'site_code' => 'snap-line',
'agent_node_id' => $rootId,
'site_player_id' => 'snap-p1',
'username' => 'snap1',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$player = \App\Models\Player::query()->findOrFail($playerId);
$builder = app(BetSettlementSnapshotBuilder::class);
$first = $builder->buildForPlayer($player);
AgentProfile::query()->where('agent_node_id', $rootId)->update(['total_share_rate' => 50]);
$stored = json_encode($first['total_shares']);
$second = $builder->buildForPlayer($player->fresh());
expect($stored)->toContain('"snap-line":25');
expect($second['total_shares']['snap-line'])->toBe(50.0);
});

View File

@@ -0,0 +1,92 @@
<?php
use App\Models\Player;
use App\Services\AgentSettlement\AgentGameSettlementRecorder;
use App\Services\Player\PlayerCreditService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
test('settled loss does not double count bet hold', 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' => 'hold-p1',
'auth_source' => 'lottery_native',
'funding_mode' => 'credit',
'username' => 'hold1',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => 5000,
'used_credit' => 0,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$credit = app(PlayerCreditService::class);
$credit->assertMayPlaceBet($player, 200);
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(2);
$drawId = (int) \App\Models\Draw::query()->create([
'draw_no' => 'HOLD-DRAW',
'business_date' => now()->toDateString(),
'sequence_no' => 1,
'status' => \App\Lottery\DrawStatus::Open->value,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
])->id;
$orderId = (int) DB::table('ticket_orders')->insertGetId([
'order_no' => 'ORD-HOLD-1',
'player_id' => $player->id,
'draw_id' => $drawId,
'currency_code' => 'NPR',
'total_bet_amount' => 200,
'total_rebate_amount' => 0,
'total_actual_deduct' => 200,
'total_estimated_payout' => 0,
'status' => 'placed',
'created_at' => now(),
'updated_at' => now(),
]);
$item = \App\Models\TicketItem::query()->create([
'ticket_no' => 'T-HOLD-1',
'order_id' => $orderId,
'player_id' => $player->id,
'draw_id' => $drawId,
'original_number' => '1234',
'normalized_number' => '1234',
'play_code' => 'direct',
'dimension' => '4d',
'digit_slot' => null,
'bet_mode' => 'single',
'unit_bet_amount' => 200,
'total_bet_amount' => 200,
'rebate_rate_snapshot' => 0,
'commission_rate_snapshot' => 0,
'actual_deduct_amount' => 200,
'odds_snapshot_json' => '{}',
'rule_snapshot_json' => '{}',
'combination_count' => 1,
'estimated_max_payout' => 0,
'risk_locked_amount' => 0,
'status' => 'settled_lose',
'win_amount' => 0,
'jackpot_win_amount' => 0,
]);
$item->setRelation('player', $player);
app(AgentGameSettlementRecorder::class)->recordForTicketItem($item, 0, 'settled_lose');
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(2);
});

View File

@@ -0,0 +1,62 @@
<?php
use App\Models\Player;
use App\Models\PlayerWallet;
use App\Services\Player\PlayerCreditService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
test('credit line hold does not change wallet balance', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$extra = json_decode((string) ($site->extra_json ?? '{}'), true);
if (! is_array($extra)) {
$extra = [];
}
$extra['credit_line_mode'] = true;
DB::table('admin_sites')->where('id', $site->id)->update([
'extra_json' => json_encode($extra),
'updated_at' => now(),
]);
$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' => 'cl-p1',
'auth_source' => 'lottery_native',
'funding_mode' => 'credit',
'username' => 'cl1',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => 'NPR',
'balance' => 50000,
'frozen_balance' => 0,
'status' => 0,
'version' => 0,
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => 10000,
'used_credit' => 0,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$walletBefore = (int) PlayerWallet::query()->where('player_id', $player->id)->value('balance');
app(PlayerCreditService::class)->assertMayPlaceBet($player, 500);
$walletAfter = (int) PlayerWallet::query()->where('player_id', $player->id)->value('balance');
expect($walletAfter)->toBe($walletBefore);
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(5);
expect(DB::table('credit_ledger')->where('reason', 'bet_hold')->where('owner_id', $player->id)->exists())->toBeTrue();
});

View File

@@ -0,0 +1,59 @@
<?php
use App\Models\Player;
use App\Support\PlayerAuthSource;
use App\Support\PlayerFundingMode;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\LotterySettingsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->seed(CurrencySeeder::class);
$this->seed(LotterySettingsSeeder::class);
});
test('credit player wallet logs reads credit_ledger not wallet_txns', function (): void {
$player = Player::query()->create([
'site_code' => 'default_site',
'site_player_id' => 'native:logs-1',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'credit_logs',
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => 200,
'used_credit' => 10,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => -1000,
'reason' => 'bet_hold',
'ref_type' => 'bet',
'ref_id' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->getJson('/api/v1/wallet/logs?page=1&size=10')
->assertOk()
->assertJsonPath('data.ledger_source', 'credit_ledger')
->assertJsonPath('data.funding_mode', PlayerFundingMode::CREDIT)
->assertJsonPath('data.auth_source', PlayerAuthSource::LOTTERY_NATIVE)
->assertJsonPath('data.total', 1)
->assertJsonPath('data.items.0.type', 'bet')
->assertJsonPath('data.items.0.biz_type', 'bet_hold')
->assertJsonPath('data.items.0.ledger_source', 'credit_ledger');
});

View File

@@ -0,0 +1,126 @@
<?php
use App\Models\Player;
use App\Models\TicketItem;
use App\Services\AgentSettlement\GameSettlementReversalService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
test('reversal zeroes share ledger net and marks rebates reversed', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$extra = json_decode((string) ($site->extra_json ?? '{}'), true);
if (! is_array($extra)) {
$extra = [];
}
$extra['credit_line_mode'] = true;
DB::table('admin_sites')->where('id', $site->id)->update([
'extra_json' => json_encode($extra),
'updated_at' => now(),
]);
$siteCode = (string) $site->code;
$player = Player::query()->create([
'site_code' => $siteCode,
'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'),
'site_player_id' => 'rev-p1',
'username' => 'rev1',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$drawId = (int) \App\Models\Draw::query()->create([
'draw_no' => 'REV-DRAW-1',
'business_date' => now()->toDateString(),
'sequence_no' => 1,
'status' => \App\Lottery\DrawStatus::Open->value,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
])->id;
$orderId = (int) DB::table('ticket_orders')->insertGetId([
'order_no' => 'ORD-REV-1',
'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' => 0,
'status' => 'placed',
'created_at' => now(),
'updated_at' => now(),
]);
$itemId = (int) DB::table('ticket_items')->insertGetId([
'ticket_no' => 'T-REV-1',
'order_id' => $orderId,
'player_id' => $player->id,
'draw_id' => $drawId,
'original_number' => '1234',
'normalized_number' => '1234',
'play_code' => 'direct',
'dimension' => '4d',
'digit_slot' => null,
'bet_mode' => 'single',
'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' => 0,
'risk_locked_amount' => 0,
'status' => 'settled',
'win_amount' => 0,
'jackpot_win_amount' => 0,
'agent_node_id' => $player->agent_node_id,
'share_snapshot' => '{}',
'agent_settled_at' => now(),
'settled_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$ledgerId = (int) DB::table('share_ledger')->insertGetId([
'ticket_item_id' => $itemId,
'player_id' => $player->id,
'agent_node_id' => $player->agent_node_id,
'agent_path' => '[]',
'share_snapshot' => '{}',
'game_win_loss' => -1000,
'basic_rebate' => 50,
'shared_net_win_loss' => -950,
'allocations_json' => '[]',
'settled_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$rebateId = (int) DB::table('rebate_records')->insertGetId([
'player_id' => $player->id,
'ticket_item_id' => $itemId,
'game_type' => '*',
'valid_bet_amount' => 1000,
'rebate_rate' => 0.005,
'rebate_amount' => 50,
'rebate_type' => 'basic',
'owner_agent_id' => $player->agent_node_id,
'status' => 'accrued',
'created_at' => now(),
'updated_at' => now(),
]);
$item = TicketItem::query()->findOrFail($itemId);
app(GameSettlementReversalService::class)->reverseTicketItem($item);
$sum = (int) DB::table('share_ledger')->where('ticket_item_id', $itemId)->sum('shared_net_win_loss');
expect($sum)->toBe(0);
expect((string) DB::table('rebate_records')->where('id', $rebateId)->value('status'))->toBe('reversed');
expect(DB::table('share_ledger')->where('reversal_of_id', $ledgerId)->exists())->toBeTrue();
});

View File

@@ -0,0 +1,100 @@
<?php
use App\Models\AdminRole;
use App\Models\AdminUser;
use App\Support\PlatformSystemRoles;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
function platformRolesApiToken(string $username): string
{
$admin = AdminUser::query()->create([
'username' => $username,
'name' => 'Tester',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
test('platform role index only lists fixed super_admin and agent roles', function (): void {
AdminRole::query()->create([
'slug' => 'legacy_custom_ops',
'code' => 'legacy_custom_ops',
'name' => 'Legacy Ops',
'scope_type' => AdminRole::SCOPE_SYSTEM,
'status' => 1,
'is_system' => false,
'sort_order' => 99,
]);
PlatformSystemRoles::ensureAll();
$token = platformRolesApiToken('platform_role_index');
$slugs = collect($this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/admin-roles')
->assertOk()
->json('data.items'))
->pluck('slug')
->all();
expect($slugs)->toBe(['super_admin', 'agent']);
});
test('platform roles cannot be created and super_admin permissions are full catalog', function (): void {
PlatformSystemRoles::ensureAll();
$token = platformRolesApiToken('platform_role_guard');
$menuActionCount = (int) DB::table('admin_menu_actions')->where('status', 1)->count();
$super = AdminRole::query()->where('slug', 'super_admin')->firstOrFail();
expect($super->is_system)->toBeTrue();
expect((int) DB::table('admin_role_menu_actions')->where('role_id', $super->id)->count())
->toBe($menuActionCount);
expect($super->legacyPermissionSlugs())->not->toBeEmpty();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/admin/admin-roles', [
'slug' => 'new_ops',
'name' => 'New Ops',
])
->assertStatus(422);
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/admin-roles/'.$super->id.'/permissions', [
'permission_slugs' => ['prd.dashboard.view'],
])
->assertStatus(422);
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/admin-roles/'.$super->id, [
'name' => 'Renamed Super',
])
->assertStatus(422);
$this->withHeader('Authorization', 'Bearer '.$token)
->deleteJson('/api/v1/admin/admin-roles/'.$super->id)
->assertStatus(422);
});
test('admin-auth-sync grants super_admin the full permission catalog', function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
$super = AdminRole::query()->where('slug', 'super_admin')->firstOrFail();
$menuActionCount = (int) DB::table('admin_menu_actions')->where('status', 1)->count();
expect((int) DB::table('admin_role_menu_actions')->where('role_id', $super->id)->count())
->toBe($menuActionCount);
});

View File

@@ -0,0 +1,139 @@
<?php
use App\Models\Player;
use App\Support\PlayerAuthSource;
use App\Support\PlayerFundingMode;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\LotterySettingsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
beforeEach(function (): void {
config([
'lottery.player_auth.native.secret' => 'test-native-jwt-secret-32bytes!!',
'lottery.player_auth.native.ttl_seconds' => 3600,
'lottery.main_site.wallet_api_url' => null,
]);
$this->seed(CurrencySeeder::class);
$this->seed(LotterySettingsSeeder::class);
});
test('native player can login and access me', function (): void {
$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:test-1',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'agentplayer1',
'password_hash' => Hash::make('secret-pass'),
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => 50000,
'used_credit' => 0,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$login = $this->postJson('/api/v1/player/auth/login', [
'site_code' => $site->code,
'username' => 'agentplayer1',
'password' => 'secret-pass',
]);
$login->assertOk();
$token = (string) $login->json('data.access_token');
expect($token)->not->toBe('');
$me = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/player/me');
$me->assertOk()
->assertJsonPath('data.id', $player->id)
->assertJsonPath('data.funding_mode', PlayerFundingMode::CREDIT)
->assertJsonPath('data.auth_source', PlayerAuthSource::LOTTERY_NATIVE);
});
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');
$player = Player::query()->create([
'site_code' => (string) $site->code,
'agent_node_id' => $rootId,
'site_player_id' => 'native:test-2',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'agentplayer2',
'password_hash' => Hash::make('secret-pass'),
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$auth = app(\App\Services\Player\PlayerNativeAuthService::class);
$token = $auth->issueToken($player);
$response = $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/v1/wallet/transfer-in', [
'amount' => 1000,
'idempotent_key' => 'native-ti-1',
'currency' => 'NPR',
]);
$response->assertJsonPath('code', 1011);
});
test('sso wallet player balance does not use credit when site credit mode on', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$extra = json_decode((string) ($site->extra_json ?? '{}'), true);
if (! is_array($extra)) {
$extra = [];
}
$extra['credit_line_mode'] = true;
DB::table('admin_sites')->where('id', $site->id)->update([
'extra_json' => json_encode($extra),
'updated_at' => now(),
]);
$player = Player::query()->create([
'site_code' => (string) $site->code,
'site_player_id' => 'sso-wallet-1',
'auth_source' => PlayerAuthSource::MAIN_SITE_SSO,
'funding_mode' => PlayerFundingMode::WALLET,
'username' => 'ssouser',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
\App\Models\PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => 'NPR',
'balance' => 12000,
'frozen_balance' => 0,
'status' => 0,
'version' => 0,
]);
$response = $this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->getJson('/api/v1/wallet/balance?currency=NPR');
$response->assertOk()
->assertJsonPath('data.credit_line_mode', false)
->assertJsonPath('data.funding_mode', PlayerFundingMode::WALLET)
->assertJsonPath('data.available_balance', 12000);
});

View File

@@ -0,0 +1,42 @@
<?php
use App\Services\AgentSettlement\AgentSettlementBillGuard;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
test('confirmed bill net amount cannot be mutated via guard', function (): void {
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => (int) DB::table('admin_sites')->where('is_default', true)->value('id'),
'period_start' => now()->subDay(),
'period_end' => now()->addDay(),
'status' => 'open',
'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' => 1,
'counterparty_type' => 'agent',
'counterparty_id' => 1,
'gross_win_loss' => 1000,
'rebate_amount' => 50,
'adjustment_amount' => 0,
'net_amount' => 930,
'paid_amount' => 0,
'unpaid_amount' => 930,
'status' => 'pending_confirm',
'created_at' => now(),
'updated_at' => now(),
]);
$guard = app(AgentSettlementBillGuard::class);
$guard->markConfirmed($billId);
expect(fn () => $guard->assertNetAmountMutable($billId))
->toThrow(\Illuminate\Validation\ValidationException::class);
});

View File

@@ -0,0 +1,72 @@
<?php
use App\Models\Player;
use App\Services\Player\PlayerCreditService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
test('overdue player cannot place bet on credit line', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$extra = json_decode((string) ($site->extra_json ?? '{}'), true);
if (! is_array($extra)) {
$extra = [];
}
$extra['credit_line_mode'] = true;
DB::table('admin_sites')->where('id', $site->id)->update([
'extra_json' => json_encode($extra),
'updated_at' => now(),
]);
$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' => 'od-p1',
'auth_source' => 'lottery_native',
'funding_mode' => 'credit',
'username' => 'od1',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => 10000,
'used_credit' => 0,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => (int) $site->id,
'period_start' => now()->subWeek(),
'period_end' => now()->subDay(),
'status' => 'closed',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('settlement_bills')->insert([
'settlement_period_id' => $periodId,
'bill_type' => 'player',
'owner_type' => 'player',
'owner_id' => $player->id,
'counterparty_type' => 'agent',
'counterparty_id' => $player->agent_node_id,
'gross_win_loss' => 1000,
'rebate_amount' => 0,
'adjustment_amount' => 0,
'net_amount' => 1000,
'paid_amount' => 0,
'unpaid_amount' => 1000,
'status' => 'overdue',
'created_at' => now(),
'updated_at' => now(),
]);
expect(fn () => app(PlayerCreditService::class)->assertMayPlaceBet($player, 100))
->toThrow(\Illuminate\Validation\ValidationException::class);
});

View File

@@ -0,0 +1,46 @@
<?php
use App\Models\Player;
use App\Support\PlayerFundingMode;
use Database\Seeders\CurrencySeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->seed(CurrencySeeder::class);
});
test('credit player wallet balance returns minor units matching admin credit limit', 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' => 'credit-bal-1',
'auth_source' => 'lottery_native',
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'creditbal',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('player_credit_accounts')->insert([
'player_id' => $player->id,
'credit_limit' => 200,
'used_credit' => 0,
'frozen_credit' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->getJson('/api/v1/wallet/balance?currency=NPR')
->assertOk()
->assertJsonPath('data.credit_line_mode', true)
->assertJsonPath('data.available_balance', 20000)
->assertJsonPath('data.credit_limit', 20000)
->assertJsonPath('data.available_balance_formatted', '200.00');
});

View File

@@ -0,0 +1,37 @@
<?php
use App\Models\AgentProfile;
use App\Support\AgentDefaultRolePermissions;
test('base owner slugs include dashboard and settlement view but not wallet reconcile', function (): void {
$slugs = AgentDefaultRolePermissions::baseSlugs();
expect($slugs)
->toContain('prd.dashboard.view')
->toContain('prd.settlement.agent.view')
->not->toContain('prd.wallet_reconcile.view')
->not->toContain('prd.wallet_reconcile.view_cs');
});
test('line root owner slugs include agent management packages', function (): void {
$slugs = AgentDefaultRolePermissions::lineRootOwnerSlugs();
expect($slugs)
->toContain('prd.agent.manage')
->toContain('prd.settlement.agent.manage')
->toContain('prd.agent.role.manage');
});
test('owner slugs from profile add manage slugs when capabilities enabled', function (): void {
$profile = new AgentProfile([
'can_create_child_agent' => true,
'can_create_player' => false,
]);
$slugs = AgentDefaultRolePermissions::ownerSlugsFromProfile($profile);
expect($slugs)
->toContain('prd.agent.manage')
->toContain('prd.agent.profile.manage')
->not->toContain('prd.users.manage');
});

View File

@@ -50,3 +50,21 @@ test('normalizes exact draw items message in zh', function (): void {
expect($errors['items'][0])->toContain('23');
});
test('normalizes compact english max for rebate rate in zh', function (): void {
$errors = ApiValidationErrors::normalize(
['rebate_rate' => ['rebate rate must not be greater than 1.']],
'zh',
);
expect($errors['rebate_rate'][0])->toBe('回水比例不能超过 1100% 记为 1。');
});
test('normalizes compact english max for rebate limit in zh', function (): void {
$errors = ApiValidationErrors::normalize(
['rebate_limit' => ['rebate limit must not be greater than 1.']],
'zh',
);
expect($errors['rebate_limit'][0])->toBe('回水上限不能超过 1100% 记为 1。');
});

View File

@@ -0,0 +1,18 @@
<?php
use App\Support\CreditAmountScale;
use Database\Seeders\CurrencySeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->seed(CurrencySeeder::class);
});
test('major and minor convert for two decimal currency', function (): void {
expect(CreditAmountScale::majorToMinor(200, 'NPR'))->toBe(20000);
expect(CreditAmountScale::minorToMajor(20000, 'NPR'))->toBe(200);
expect(CreditAmountScale::minorToMajor(250, 'NPR'))->toBe(3);
expect(CreditAmountScale::minorToMajor(200, 'NPR'))->toBe(2);
});