feat: add idempotency support for settlement adjustments and payments, fix rebate handling
- Added idempotency_key parameter to settlement bill adjustment and payment endpoints to prevent duplicate operations - Fixed player rebate profile updates to preserve existing values when only one field is modified - Moved wallet player validation earlier in AdminPlayerStoreController to prevent unnecessary processing - Enhanced extra rebate calculation to include 'in_bill' and 'settled' statuses in AgentP
This commit is contained in:
@@ -146,6 +146,122 @@ test('admin credit ledger simple display merges release and loss per ticket', fu
|
||||
->assertJsonPath('data.items.0.signed_amount', -1200);
|
||||
});
|
||||
|
||||
test('admin credit ledger simple display shows win amount for winning bet', 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:win-flow',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'win_flow',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$now = now();
|
||||
|
||||
$drawId = (int) DB::table('draws')->insertGetId([
|
||||
'draw_no' => 'WIN-FLOW-001',
|
||||
'business_date' => now()->toDateString(),
|
||||
'sequence_no' => 1,
|
||||
'status' => 'settled',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$orderId = (int) DB::table('ticket_orders')->insertGetId([
|
||||
'order_no' => 'ORD-WIN-001',
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawId,
|
||||
'currency_code' => 'NPR',
|
||||
'total_bet_amount' => 800,
|
||||
'total_actual_deduct' => 800,
|
||||
'status' => 'settled',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
DB::table('ticket_items')->insert([
|
||||
'id' => 99,
|
||||
'ticket_no' => 'TI-WIN-99',
|
||||
'order_id' => $orderId,
|
||||
'player_id' => $player->id,
|
||||
'draw_id' => $drawId,
|
||||
'normalized_number' => '1234',
|
||||
'play_code' => 'straight',
|
||||
'total_bet_amount' => 800,
|
||||
'actual_deduct_amount' => 800,
|
||||
'status' => 'settled_win',
|
||||
'win_amount' => 2500,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
DB::table('credit_ledger')->insert([
|
||||
[
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $player->id,
|
||||
'amount' => -800,
|
||||
'reason' => 'bet_hold',
|
||||
'ref_type' => 'bet',
|
||||
'ref_id' => null,
|
||||
'created_at' => $now->copy()->subMinutes(2),
|
||||
'updated_at' => $now->copy()->subMinutes(2),
|
||||
],
|
||||
[
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $player->id,
|
||||
'amount' => 800,
|
||||
'reason' => 'bet_hold_release',
|
||||
'ref_type' => 'ticket_item',
|
||||
'ref_id' => 99,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'owner_type' => 'player',
|
||||
'owner_id' => $player->id,
|
||||
'amount' => 2500,
|
||||
'reason' => 'game_settlement_win',
|
||||
'ref_type' => 'ticket_item',
|
||||
'ref_id' => 99,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
]);
|
||||
|
||||
$admin = AdminUser::query()->create([
|
||||
'username' => 'win_flow_super',
|
||||
'name' => 'Win Flow',
|
||||
'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.'&bet_flow_display=simple')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.ledger_source', 'credit_ledger')
|
||||
->assertJsonPath('data.total', 1)
|
||||
->assertJsonCount(1, 'data.items')
|
||||
->assertJsonPath('data.items.0.biz_type', 'game_settlement')
|
||||
->assertJsonPath('data.items.0.signed_amount', 2500);
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -374,3 +374,89 @@ test('native player create rejects chinese username', function (): void {
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value);
|
||||
});
|
||||
|
||||
test('partial rebate update preserves the other rebate field', 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' => 100,
|
||||
'credit_limit' => 50_000,
|
||||
'allocated_credit' => 0,
|
||||
'used_credit' => 0,
|
||||
'rebate_limit' => 0.02,
|
||||
'default_player_rebate' => 0.005,
|
||||
'can_grant_extra_rebate' => true,
|
||||
],
|
||||
);
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => $siteCode,
|
||||
'agent_node_id' => $rootId,
|
||||
'site_player_id' => 'partial-rebate-1',
|
||||
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
|
||||
'funding_mode' => PlayerFundingMode::CREDIT,
|
||||
'username' => 'partial_rebate_user',
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
DB::table('player_credit_accounts')->insert([
|
||||
'player_id' => $player->id,
|
||||
'credit_limit' => 0,
|
||||
'used_credit' => 0,
|
||||
'frozen_credit' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('player_rebate_profiles')->insert([
|
||||
'player_id' => $player->id,
|
||||
'game_type' => '*',
|
||||
'inherit_from_agent' => false,
|
||||
'rebate_rate' => 0.005,
|
||||
'extra_rebate_rate' => 0.002,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$token = playerManageAdminToken();
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/players/'.$player->id, [
|
||||
'extra_rebate_rate' => 0.1,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$this->assertDatabaseHas('player_rebate_profiles', [
|
||||
'player_id' => $player->id,
|
||||
'game_type' => '*',
|
||||
'rebate_rate' => 0.005,
|
||||
'extra_rebate_rate' => 0.001,
|
||||
]);
|
||||
|
||||
DB::table('player_rebate_profiles')
|
||||
->where('player_id', $player->id)
|
||||
->where('game_type', '*')
|
||||
->update([
|
||||
'rebate_rate' => 0.008,
|
||||
'extra_rebate_rate' => 0.001,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->putJson('/api/v1/admin/players/'.$player->id, [
|
||||
'rebate_rate' => 0.3,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$this->assertDatabaseHas('player_rebate_profiles', [
|
||||
'player_id' => $player->id,
|
||||
'game_type' => '*',
|
||||
'rebate_rate' => 0.003,
|
||||
'extra_rebate_rate' => 0.001,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -396,3 +396,189 @@ test('period close succeeds with no share ledger rows in window', function (): v
|
||||
expect(DB::table('settlement_bills')->where('settlement_period_id', $periodId)->count())
|
||||
->toBe(0);
|
||||
});
|
||||
|
||||
test('period close does not dispatch rebates from other sites', function (): void {
|
||||
$siteA = createSiteWithRoot('rebate-site-a');
|
||||
$siteB = createSiteWithRoot('rebate-site-b');
|
||||
|
||||
$playerA = Player::query()->create([
|
||||
'site_code' => $siteA['site_code'],
|
||||
'agent_node_id' => $siteA['root_id'],
|
||||
'site_player_id' => 'p-rebate-a',
|
||||
'username' => 'rebate_a',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$playerB = Player::query()->create([
|
||||
'site_code' => $siteB['site_code'],
|
||||
'agent_node_id' => $siteB['root_id'],
|
||||
'site_player_id' => 'p-rebate-b',
|
||||
'username' => 'rebate_b',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$settledAt = now();
|
||||
|
||||
$ticketAId = createTicketItemForPlayer($playerA, 'T-REB-A');
|
||||
$ticketBId = createTicketItemForPlayer($playerB, 'T-REB-B');
|
||||
|
||||
$snapshotA = json_encode([
|
||||
'total_shares' => ['rebate-site-a' => 100],
|
||||
'chain_codes' => ['rebate-site-a'],
|
||||
'agent_path' => [$siteA['root_id']],
|
||||
]);
|
||||
$snapshotB = json_encode([
|
||||
'total_shares' => ['rebate-site-b' => 100],
|
||||
'chain_codes' => ['rebate-site-b'],
|
||||
'agent_path' => [$siteB['root_id']],
|
||||
]);
|
||||
|
||||
DB::table('share_ledger')->insert([
|
||||
[
|
||||
'ticket_item_id' => $ticketAId,
|
||||
'player_id' => $playerA->id,
|
||||
'agent_node_id' => $siteA['root_id'],
|
||||
'agent_path' => json_encode([$siteA['root_id']]),
|
||||
'share_snapshot' => $snapshotA,
|
||||
'game_win_loss' => 1000,
|
||||
'basic_rebate' => 0,
|
||||
'shared_net_win_loss' => 1000,
|
||||
'allocations_json' => json_encode([]),
|
||||
'settled_at' => $settledAt,
|
||||
'created_at' => $settledAt,
|
||||
'updated_at' => $settledAt,
|
||||
],
|
||||
[
|
||||
'ticket_item_id' => $ticketBId,
|
||||
'player_id' => $playerB->id,
|
||||
'agent_node_id' => $siteB['root_id'],
|
||||
'agent_path' => json_encode([$siteB['root_id']]),
|
||||
'share_snapshot' => $snapshotB,
|
||||
'game_win_loss' => 2000,
|
||||
'basic_rebate' => 0,
|
||||
'shared_net_win_loss' => 2000,
|
||||
'allocations_json' => json_encode([]),
|
||||
'settled_at' => $settledAt,
|
||||
'created_at' => $settledAt,
|
||||
'updated_at' => $settledAt,
|
||||
],
|
||||
]);
|
||||
|
||||
$rebateBId = (int) DB::table('rebate_records')->insertGetId([
|
||||
'player_id' => $playerB->id,
|
||||
'ticket_item_id' => $ticketBId,
|
||||
'game_type' => '*',
|
||||
'valid_bet_amount' => 10000,
|
||||
'rebate_rate' => 0.005,
|
||||
'rebate_amount' => 50,
|
||||
'rebate_type' => 'basic',
|
||||
'owner_agent_id' => $siteB['root_id'],
|
||||
'status' => 'accrued',
|
||||
'created_at' => $settledAt,
|
||||
'updated_at' => $settledAt,
|
||||
]);
|
||||
|
||||
$periodAId = (int) DB::table('settlement_periods')->insertGetId([
|
||||
'admin_site_id' => $siteA['site_id'],
|
||||
'period_start' => $settledAt->copy()->subDay(),
|
||||
'period_end' => $settledAt->copy()->addDay(),
|
||||
'status' => 'open',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$result = app(AgentSettlementPeriodCloseService::class)->closePeriod($periodAId);
|
||||
|
||||
expect($result['rebate_dispatched'])->toBe(0);
|
||||
|
||||
expect((string) DB::table('rebate_records')->where('id', $rebateBId)->value('status'))
|
||||
->toBe('accrued');
|
||||
expect(DB::table('rebate_records')->where('id', $rebateBId)->value('settlement_period_id'))
|
||||
->toBeNull();
|
||||
});
|
||||
|
||||
test('period close nets out original and reversal share ledger rows in same period', function (): void {
|
||||
['site_id' => $siteId, 'site_code' => $siteCode, 'root_id' => $rootId] = createSiteWithRoot('reversal-net');
|
||||
|
||||
$player = Player::query()->create([
|
||||
'site_code' => $siteCode,
|
||||
'agent_node_id' => $rootId,
|
||||
'site_player_id' => 'reversal-net-p1',
|
||||
'auth_source' => 'lottery_native',
|
||||
'funding_mode' => 'credit',
|
||||
'username' => 'reversal_net_p',
|
||||
'nickname' => null,
|
||||
'default_currency' => 'NPR',
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$settledAt = now();
|
||||
$ticketId = createTicketItemForPlayer($player, 'T-REV-NET');
|
||||
|
||||
$snapshot = json_encode([
|
||||
'total_shares' => ['reversal-net' => 100],
|
||||
'chain_codes' => ['reversal-net'],
|
||||
'agent_path' => [$rootId],
|
||||
]);
|
||||
|
||||
$originalLedgerId = (int) DB::table('share_ledger')->insertGetId([
|
||||
'ticket_item_id' => $ticketId,
|
||||
'player_id' => $player->id,
|
||||
'agent_node_id' => $rootId,
|
||||
'agent_path' => json_encode([$rootId]),
|
||||
'share_snapshot' => $snapshot,
|
||||
'game_win_loss' => 5000,
|
||||
'basic_rebate' => 0,
|
||||
'shared_net_win_loss' => 5000,
|
||||
'allocations_json' => json_encode([]),
|
||||
'settled_at' => $settledAt->copy()->subSecond(),
|
||||
'created_at' => $settledAt->copy()->subSecond(),
|
||||
'updated_at' => $settledAt->copy()->subSecond(),
|
||||
]);
|
||||
|
||||
$reversalLedgerId = (int) DB::table('share_ledger')->insertGetId([
|
||||
'ticket_item_id' => $ticketId,
|
||||
'player_id' => $player->id,
|
||||
'agent_node_id' => $rootId,
|
||||
'agent_path' => json_encode([$rootId]),
|
||||
'share_snapshot' => $snapshot,
|
||||
'game_win_loss' => -5000,
|
||||
'basic_rebate' => 0,
|
||||
'shared_net_win_loss' => -5000,
|
||||
'allocations_json' => json_encode([]),
|
||||
'reversal_of_id' => $originalLedgerId,
|
||||
'settled_at' => $settledAt,
|
||||
'created_at' => $settledAt,
|
||||
'updated_at' => $settledAt,
|
||||
]);
|
||||
|
||||
$periodId = (int) DB::table('settlement_periods')->insertGetId([
|
||||
'admin_site_id' => $siteId,
|
||||
'period_start' => $settledAt->copy()->subDay(),
|
||||
'period_end' => $settledAt->copy()->addDay(),
|
||||
'status' => 'open',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$result = app(AgentSettlementPeriodCloseService::class)->closePeriod($periodId);
|
||||
|
||||
expect($result['player_count'])->toBe(1);
|
||||
|
||||
$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(0);
|
||||
|
||||
expect(DB::table('share_ledger')->where('id', $originalLedgerId)->value('settlement_period_id'))
|
||||
->toBe($periodId);
|
||||
expect(DB::table('share_ledger')->where('id', $reversalLedgerId)->value('settlement_period_id'))
|
||||
->toBe($periodId);
|
||||
});
|
||||
|
||||
50
tests/Feature/CreditBetHoldReverseIdempotencyTest.php
Normal file
50
tests/Feature/CreditBetHoldReverseIdempotencyTest.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Player;
|
||||
use App\Services\Player\PlayerCreditService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('reverse bet hold is idempotent', 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' => 'reverse-hold-p1',
|
||||
'auth_source' => 'lottery_native',
|
||||
'funding_mode' => 'credit',
|
||||
'username' => 'reverse_hold_1',
|
||||
'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);
|
||||
|
||||
$credit->reverseBetHold($player, 200, 999);
|
||||
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(0);
|
||||
|
||||
$credit->reverseBetHold($player, 200, 999);
|
||||
expect((int) DB::table('player_credit_accounts')->where('player_id', $player->id)->value('used_credit'))->toBe(0);
|
||||
|
||||
expect(DB::table('credit_ledger')
|
||||
->where('owner_type', 'player')
|
||||
->where('owner_id', $player->id)
|
||||
->where('reason', 'bet_hold_release')
|
||||
->where('ref_type', 'ticket_order')
|
||||
->where('ref_id', 999)
|
||||
->count())->toBe(1);
|
||||
});
|
||||
Reference in New Issue
Block a user