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' => 'fix-'.$suffix, 'auth_source' => 'lottery_native', 'funding_mode' => PlayerFundingMode::CREDIT, 'username' => 'fix_'.$suffix, '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(), ]); return $player; } function countActiveShareLedgerRows(int $ticketItemId): int { return (int) DB::table('share_ledger as sl') ->where('sl.ticket_item_id', $ticketItemId) ->whereNull('sl.reversal_of_id') ->whereNotExists(function ($query): void { $query->selectRaw('1') ->from('share_ledger as rev') ->whereColumn('rev.reversal_of_id', 'sl.id'); }) ->count(); } function createResultBatchForDraw(Draw $draw): DrawResultBatch { return DrawResultBatch::query()->create([ 'draw_id' => $draw->id, 'result_version' => 1, 'source_type' => 'rng', 'rng_seed_hash' => 'test', 'raw_seed_encrypted' => null, 'status' => DrawResultBatchStatus::Published->value, 'created_by' => null, 'confirmed_by' => null, 'confirmed_at' => now(), ]); } test('agent recorder allows re-settlement after share ledger reversal', function (): void { $player = creditAgentPlayerForFixtures('re-settle'); $drawId = (int) Draw::query()->create([ 'draw_no' => 'FIX-RE-DRAW', 'business_date' => now()->toDateString(), 'sequence_no' => 1, 'status' => DrawStatus::Open->value, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, ])->id; $orderId = (int) DB::table('ticket_orders')->insertGetId([ 'order_no' => 'ORD-FIX-RE-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(), ]); $item = TicketItem::query()->create([ 'ticket_no' => 'T-FIX-RE-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' => 'pending_payout', 'win_amount' => 0, 'jackpot_win_amount' => 0, ]); $item->setRelation('player', $player); $recorder = app(AgentGameSettlementRecorder::class); $recorder->recordForTicketItem($item, 0, 'settled_lose'); expect(countActiveShareLedgerRows($item->id))->toBe(1); app(GameSettlementReversalService::class)->reverseTicketItem($item->fresh()); expect(countActiveShareLedgerRows($item->id))->toBe(0); $item->forceFill(['agent_settled_at' => null])->save(); $recorder->recordForTicketItem($item->fresh(), 0, 'settled_lose'); expect(countActiveShareLedgerRows($item->id))->toBe(1); }); test('credit player payout skips lottery wallet settle_payout txn', function (): void { $player = creditAgentPlayerForFixtures('no-wallet-payout'); $draw = Draw::query()->create([ 'draw_no' => 'FIX-PAY-DRAW', 'business_date' => now()->toDateString(), 'sequence_no' => 1, 'status' => DrawStatus::Settling->value, 'current_result_version' => 1, 'settle_version' => 1, 'is_reopened' => false, ]); $resultBatch = createResultBatchForDraw($draw); $batch = SettlementBatch::query()->create([ 'draw_id' => $draw->id, 'result_batch_id' => $resultBatch->id, 'settle_version' => 1, 'status' => SettlementBatchStatus::Approved->value, 'total_ticket_count' => 1, 'total_win_count' => 1, 'total_payout_amount' => 500, 'total_jackpot_payout_amount' => 0, 'review_status' => 'approved', 'reviewed_by' => null, 'reviewed_at' => now(), 'review_remark' => null, 'paid_at' => null, 'started_at' => now(), 'finished_at' => now(), ]); $orderId = (int) DB::table('ticket_orders')->insertGetId([ 'order_no' => 'ORD-FIX-PAY-1', 'player_id' => $player->id, 'draw_id' => $draw->id, 'currency_code' => 'NPR', 'total_bet_amount' => 100, 'total_rebate_amount' => 0, 'total_actual_deduct' => 100, 'total_estimated_payout' => 500, 'status' => 'placed', 'created_at' => now(), 'updated_at' => now(), ]); $itemId = (int) DB::table('ticket_items')->insertGetId([ 'ticket_no' => 'T-FIX-PAY-1', 'order_id' => $orderId, 'player_id' => $player->id, 'draw_id' => $draw->id, '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' => 500, 'risk_locked_amount' => 0, 'status' => 'pending_payout', 'win_amount' => 500, 'jackpot_win_amount' => 0, 'created_at' => now(), 'updated_at' => now(), ]); DB::table('ticket_settlement_details')->insert([ 'settlement_batch_id' => $batch->id, 'ticket_item_id' => $itemId, 'matched_prize_tier' => 'first', 'win_amount' => 500, 'jackpot_allocation_amount' => 0, 'match_detail_json' => '{}', 'created_at' => now(), 'updated_at' => now(), ]); app(SettlementBatchWorkflowService::class)->payout($batch->fresh()); expect(DB::table('wallet_txns')->where('biz_type', 'settle_payout')->count())->toBe(0) ->and(TicketItem::query()->find($itemId)?->status)->toBe('settled_win'); }); test('settlement report show rejects inaccessible settlement period', function (): void { $siteAId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); $siteBId = (int) DB::table('admin_sites')->insertGetId([ 'code' => 'site_fix_b', 'name' => 'Site Fix B', 'is_default' => false, 'status' => 1, 'extra_json' => '{}', 'created_at' => now(), 'updated_at' => now(), ]); $periodBId = (int) DB::table('settlement_periods')->insertGetId([ 'admin_site_id' => $siteBId, 'period_start' => now()->subDay(), 'period_end' => now()->addDay(), 'status' => 'open', 'created_at' => now(), 'updated_at' => now(), ]); $admin = AdminUser::query()->create([ 'username' => 'report_scope_admin', 'name' => 'Report Scope Admin', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); $role = AdminRole::query()->create([ 'slug' => 'report_scope_role', 'name' => 'Report Scope Role', ]); $role->syncLegacyPermissionSlugs(['prd.settlement.agent.view']); DB::table('admin_user_site_roles')->insert([ 'admin_user_id' => $admin->id, 'site_id' => $siteAId, 'role_id' => $role->id, 'granted_at' => now(), ]); $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; $this->withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/admin/settlement-reports?type=player_win_loss&settlement_period_id='.$periodBId) ->assertForbidden(); }); test('settlement tick finalizer marks approved batch failed when payout throws', function (): void { $player = creditAgentPlayerForFixtures('tick-fail'); $draw = Draw::query()->create([ 'draw_no' => 'FIX-FAIL-DRAW', 'business_date' => now()->toDateString(), 'sequence_no' => 1, 'status' => DrawStatus::Settling->value, 'current_result_version' => 1, 'settle_version' => 1, 'is_reopened' => false, ]); $resultBatch = createResultBatchForDraw($draw); $batch = SettlementBatch::query()->create([ 'draw_id' => $draw->id, 'result_batch_id' => $resultBatch->id, 'settle_version' => 1, 'status' => SettlementBatchStatus::PendingReview->value, 'total_ticket_count' => 1, 'total_win_count' => 0, 'total_payout_amount' => 0, 'total_jackpot_payout_amount' => 0, 'review_status' => 'pending', 'reviewed_by' => null, 'reviewed_at' => null, 'review_remark' => null, 'paid_at' => null, 'started_at' => now(), 'finished_at' => now(), ]); $orderId = (int) DB::table('ticket_orders')->insertGetId([ 'order_no' => 'ORD-FIX-FAIL-1', 'player_id' => $player->id, 'draw_id' => $draw->id, 'currency_code' => 'NPR', 'total_bet_amount' => 100, 'total_rebate_amount' => 0, 'total_actual_deduct' => 100, 'total_estimated_payout' => 0, 'status' => 'placed', 'created_at' => now(), 'updated_at' => now(), ]); $settledItemId = (int) DB::table('ticket_items')->insertGetId([ 'ticket_no' => 'T-FIX-FAIL-SETTLED', 'order_id' => $orderId, 'player_id' => $player->id, 'draw_id' => $draw->id, '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_lose', 'win_amount' => 0, 'jackpot_win_amount' => 0, 'created_at' => now(), 'updated_at' => now(), ]); DB::table('ticket_settlement_details')->insert([ 'settlement_batch_id' => $batch->id, 'ticket_item_id' => $settledItemId, 'matched_prize_tier' => null, 'win_amount' => 0, 'jackpot_allocation_amount' => 0, 'match_detail_json' => '{}', 'created_at' => now(), 'updated_at' => now(), ]); DB::table('ticket_items')->insert([ 'ticket_no' => 'T-FIX-FAIL-ORPHAN', 'order_id' => $orderId, 'player_id' => $player->id, 'draw_id' => $draw->id, 'original_number' => '5678', 'normalized_number' => '5678', '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' => 'pending_draw', 'win_amount' => 0, 'jackpot_win_amount' => 0, 'created_at' => now(), 'updated_at' => now(), ]); $result = app(SettlementTickFinalizer::class)->finalizePendingBatches(); expect($result['payout_failed'])->toBe(1) ->and($batch->fresh()->status)->toBe(SettlementBatchStatus::Failed->value) ->and($batch->fresh()->review_remark)->toContain('auto_payout_failed'); });