feat: 增强代理和玩家管理功能
- 在多个控制器中更新权限检查逻辑,确保管理员能够更灵活地管理代理和玩家。 - 在 AdminPlayerStoreController 中引入对玩家创建能力的验证,确保只有具备相应权限的管理员能够创建玩家。 - 更新请求验证逻辑,新增 credit_limit、rebate_rate 和 extra_rebate_rate 字段,以支持更细粒度的玩家管理。 - 在 AgentNodeProfileController 中添加对父代理能力授予的验证,确保子代理的权限在父代理范围内。 - 引入 AgentProfileFieldRules 以简化代理资料更新请求的规则定义,提升代码复用性。
This commit is contained in:
67
tests/Feature/AdminAgentDashboardOverviewTest.php
Normal file
67
tests/Feature/AdminAgentDashboardOverviewTest.php
Normal 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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
129
tests/Feature/AdminAgentProfileCapabilityPermissionTest.php
Normal file
129
tests/Feature/AdminAgentProfileCapabilityPermissionTest.php
Normal 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');
|
||||
});
|
||||
119
tests/Feature/AdminCreditLedgerIndexTest.php
Normal file
119
tests/Feature/AdminCreditLedgerIndexTest.php
Normal 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);
|
||||
});
|
||||
175
tests/Feature/AdminDrawViewOnlyAuthorizationTest.php
Normal file
175
tests/Feature/AdminDrawViewOnlyAuthorizationTest.php
Normal 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');
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
243
tests/Feature/AdminUserSiteRoleBindingTest.php
Normal file
243
tests/Feature/AdminUserSiteRoleBindingTest.php
Normal 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]);
|
||||
});
|
||||
171
tests/Feature/AgentCreditAllocationTest.php
Normal file
171
tests/Feature/AgentCreditAllocationTest.php
Normal 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);
|
||||
});
|
||||
72
tests/Feature/AgentOverdueCreatePlayerTest.php
Normal file
72
tests/Feature/AgentOverdueCreatePlayerTest.php
Normal 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);
|
||||
});
|
||||
212
tests/Feature/AgentPeriodCloseE2eTest.php
Normal file
212
tests/Feature/AgentPeriodCloseE2eTest.php
Normal 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);
|
||||
});
|
||||
90
tests/Feature/AgentSettlementBadDebtTest.php
Normal file
90
tests/Feature/AgentSettlementBadDebtTest.php
Normal 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',
|
||||
]);
|
||||
});
|
||||
46
tests/Feature/AgentSettlementBillAdjustmentTest.php
Normal file
46
tests/Feature/AgentSettlementBillAdjustmentTest.php
Normal 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);
|
||||
});
|
||||
84
tests/Feature/AgentSettlementListsApiTest.php
Normal file
84
tests/Feature/AgentSettlementListsApiTest.php
Normal 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');
|
||||
});
|
||||
76
tests/Feature/AgentSettlementPeriodSummaryTest.php
Normal file
76
tests/Feature/AgentSettlementPeriodSummaryTest.php
Normal 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);
|
||||
});
|
||||
66
tests/Feature/BetShareSnapshotImmutabilityTest.php
Normal file
66
tests/Feature/BetShareSnapshotImmutabilityTest.php
Normal 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);
|
||||
});
|
||||
92
tests/Feature/CreditHoldSettlementNoDoubleTest.php
Normal file
92
tests/Feature/CreditHoldSettlementNoDoubleTest.php
Normal 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);
|
||||
});
|
||||
62
tests/Feature/CreditLineBetHoldTest.php
Normal file
62
tests/Feature/CreditLineBetHoldTest.php
Normal 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();
|
||||
});
|
||||
59
tests/Feature/CreditWalletLogsTest.php
Normal file
59
tests/Feature/CreditWalletLogsTest.php
Normal 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');
|
||||
});
|
||||
126
tests/Feature/GameSettlementReversalTest.php
Normal file
126
tests/Feature/GameSettlementReversalTest.php
Normal 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();
|
||||
});
|
||||
100
tests/Feature/PlatformSystemRolesTest.php
Normal file
100
tests/Feature/PlatformSystemRolesTest.php
Normal 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);
|
||||
});
|
||||
139
tests/Feature/PlayerNativeAuthTest.php
Normal file
139
tests/Feature/PlayerNativeAuthTest.php
Normal 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);
|
||||
});
|
||||
42
tests/Feature/SettlementBillLockTest.php
Normal file
42
tests/Feature/SettlementBillLockTest.php
Normal 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);
|
||||
});
|
||||
72
tests/Feature/SettlementOverdueFreezeTest.php
Normal file
72
tests/Feature/SettlementOverdueFreezeTest.php
Normal 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);
|
||||
});
|
||||
46
tests/Feature/WalletBalanceCreditPlayerTest.php
Normal file
46
tests/Feature/WalletBalanceCreditPlayerTest.php
Normal 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');
|
||||
});
|
||||
37
tests/Unit/AgentDefaultRolePermissionsTest.php
Normal file
37
tests/Unit/AgentDefaultRolePermissionsTest.php
Normal 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');
|
||||
});
|
||||
@@ -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('回水比例不能超过 1(100% 记为 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('回水上限不能超过 1(100% 记为 1)。');
|
||||
});
|
||||
|
||||
18
tests/Unit/CreditAmountScaleTest.php
Normal file
18
tests/Unit/CreditAmountScaleTest.php
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user