create([ 'username' => 'wallet_admin', 'name' => 'Wallet', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; } test('admin wallet transfer orders list requires authentication', function (): void { $this->getJson('/api/v1/admin/wallet/transfer-orders') ->assertUnauthorized() ->assertJsonPath('code', ErrorCode::AdminUnauthenticated->value); }); test('admin lists transfer orders with player info', function (): void { $token = makeAdminToken(); $player = Player::query()->create([ 'site_code' => 'main', 'site_player_id' => 'p99', 'username' => 'u99', 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); TransferOrder::query()->create([ 'transfer_no' => 'TI_test_order_1', 'player_id' => $player->id, 'direction' => 'in', 'currency_code' => 'NPR', 'amount' => 1000, 'idempotent_key' => 'adm-test-1', 'status' => 'success', 'external_request_payload' => null, 'external_response_payload' => null, 'external_ref_no' => 'ref1', 'fail_reason' => null, 'finished_at' => now(), ]); $this->withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/admin/wallet/transfer-orders?per_page=10') ->assertOk() ->assertJsonPath('code', ErrorCode::Success->value) ->assertJsonPath('data.total', 1) ->assertJsonPath('data.items.0.transfer_no', 'TI_test_order_1') ->assertJsonPath('data.items.0.site_player_id', 'p99') ->assertJsonPath('data.items.0.status', 'success'); }); test('admin filters abnormal transfer orders', function (): void { $token = makeAdminToken(); $player = Player::query()->create([ 'site_code' => 'main', 'site_player_id' => 'pa', 'username' => null, 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); foreach ( [ ['TI_ok', 'success'], ['TI_bad', 'failed'], ['TI_wait', 'pending_reconcile'], ] as [$no, $st] ) { TransferOrder::query()->create([ 'transfer_no' => $no, 'player_id' => $player->id, 'direction' => 'in', 'currency_code' => 'NPR', 'amount' => 100, 'idempotent_key' => 'k-'.$no, 'status' => $st, 'external_request_payload' => null, 'external_response_payload' => null, 'external_ref_no' => null, 'fail_reason' => null, 'finished_at' => $st === 'success' ? now() : null, ]); } $resp = $this->withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/admin/wallet/transfer-orders?abnormal=1'); $resp->assertOk()->assertJsonPath('data.total', 2); }); test('admin transfer order list exposes available reconcile actions by status', function (): void { $token = makeAdminToken(); $player = Player::query()->create([ 'site_code' => 'main', 'site_player_id' => 'action-player', 'username' => null, 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); foreach ( [ ['TI_processing', 'processing'], ['TI_failed', 'failed'], ['TI_wait', 'pending_reconcile'], ['TI_done', 'success'], ] as [$no, $st] ) { TransferOrder::query()->create([ 'transfer_no' => $no, 'player_id' => $player->id, 'direction' => 'in', 'currency_code' => 'NPR', 'amount' => 100, 'idempotent_key' => 'action-'.$no, 'status' => $st, 'external_request_payload' => null, 'external_response_payload' => null, 'external_ref_no' => null, 'fail_reason' => null, 'finished_at' => $st === 'success' ? now() : null, ]); } $items = $this->withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/admin/wallet/transfer-orders?per_page=10') ->assertOk() ->json('data.items'); $byNo = collect($items)->keyBy('transfer_no'); expect($byNo['TI_processing']['can_reverse'])->toBeFalse() ->and($byNo['TI_processing']['can_manually_process'])->toBeTrue() ->and($byNo['TI_failed']['can_reverse'])->toBeFalse() ->and($byNo['TI_failed']['can_manually_process'])->toBeTrue() ->and($byNo['TI_wait']['can_reverse'])->toBeTrue() ->and($byNo['TI_wait']['can_manually_process'])->toBeTrue() ->and($byNo['TI_done']['can_reverse'])->toBeFalse() ->and($byNo['TI_done']['can_manually_process'])->toBeFalse(); }); test('admin can manually process abnormal transfer orders except completed ones', function (): void { $token = makeAdminToken(); $player = Player::query()->create([ 'site_code' => 'main', 'site_player_id' => 'manual-player', 'username' => null, 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); foreach ( [ ['TI_processing_manual', 'processing'], ['TI_failed_manual', 'failed'], ['TI_success_manual', 'success'], ] as [$no, $st] ) { TransferOrder::query()->create([ 'transfer_no' => $no, 'player_id' => $player->id, 'direction' => 'in', 'currency_code' => 'NPR', 'amount' => 100, 'idempotent_key' => 'manual-'.$no, 'status' => $st, 'external_request_payload' => null, 'external_response_payload' => null, 'external_ref_no' => null, 'fail_reason' => null, 'finished_at' => $st === 'success' ? now() : null, ]); } $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/wallet/transfer-orders/TI_processing_manual/manually-process') ->assertOk() ->assertJsonPath('data.status', 'manually_processed'); $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/wallet/transfer-orders/TI_failed_manual/manually-process') ->assertOk() ->assertJsonPath('data.status', 'manually_processed'); $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/wallet/transfer-orders/TI_success_manual/manually-process') ->assertStatus(422); }); test('admin lists wallet transactions and filters abnormal', function (): void { $token = makeAdminToken(); $player = Player::query()->create([ 'site_code' => 'main', 'site_player_id' => 'pb', 'username' => null, 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); $wallet = PlayerWallet::query()->create([ 'player_id' => $player->id, 'wallet_type' => 'lottery', 'currency_code' => 'NPR', 'balance' => 5000, 'frozen_balance' => 0, 'status' => 0, 'version' => 1, ]); WalletTxn::query()->create([ 'txn_no' => 'WX_posted_1', 'player_id' => $player->id, 'wallet_id' => $wallet->id, 'biz_type' => 'transfer_in', 'biz_no' => 'TI_x', 'direction' => 1, 'amount' => 100, 'balance_before' => 0, 'balance_after' => 100, 'status' => 'posted', 'external_ref_no' => null, 'idempotent_key' => 'ik1', 'remark' => null, ]); WalletTxn::query()->create([ 'txn_no' => 'WX_pending_1', 'player_id' => $player->id, 'wallet_id' => $wallet->id, 'biz_type' => 'transfer_out', 'biz_no' => 'TO_x', 'direction' => 2, 'amount' => 50, 'balance_before' => 100, 'balance_after' => 50, 'status' => 'pending_reconcile', 'external_ref_no' => null, 'idempotent_key' => 'ik2', 'remark' => null, ]); $this->withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/admin/wallet/transactions') ->assertOk() ->assertJsonPath('data.total', 2); $this->withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/admin/wallet/transactions?abnormal=1') ->assertOk() ->assertJsonPath('data.total', 1) ->assertJsonPath('data.items.0.status', 'pending_reconcile'); }); test('admin transfer reverse is idempotent under concurrent reconcile', function (): void { $player = Player::query()->create([ 'site_code' => 'main', 'site_player_id' => 'reverse-idem', 'username' => null, 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); $wallet = PlayerWallet::query()->create([ 'player_id' => $player->id, 'wallet_type' => 'lottery', 'currency_code' => 'NPR', 'balance' => 1_000, 'frozen_balance' => 0, 'status' => 0, 'version' => 0, ]); $order = TransferOrder::query()->create([ 'transfer_no' => 'TI_reverse_idem', 'player_id' => $player->id, 'direction' => 'out', 'currency_code' => 'NPR', 'amount' => 400, 'idempotent_key' => 'reverse-idem-key', 'status' => 'pending_reconcile', 'external_request_payload' => null, 'external_response_payload' => null, 'external_ref_no' => null, 'fail_reason' => 'main_site_timeout', 'finished_at' => null, ]); $service = app(LotteryTransferService::class); $service->reconcileTransferOrder($order, 'reverse', 'first'); $service->reconcileTransferOrder($order->fresh(), 'reverse', 'second'); $wallet->refresh(); $order->refresh(); expect((int) $wallet->balance)->toBe(1_400) ->and($order->status)->toBe('reversed') ->and(WalletTxn::query()->where('biz_type', 'reversal')->count())->toBe(1); }); test('admin shows player wallets', function (): void { $token = makeAdminToken(); $player = Player::query()->create([ 'site_code' => 'main', 'site_player_id' => 'pc', 'username' => null, 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); PlayerWallet::query()->create([ 'player_id' => $player->id, 'wallet_type' => 'lottery', 'currency_code' => 'NPR', 'balance' => 77700, 'frozen_balance' => 0, 'status' => 0, 'version' => 0, ]); $this->withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/admin/players/'.$player->id.'/wallets') ->assertOk() ->assertJsonPath('data.player.site_player_id', 'pc') ->assertJsonPath('data.wallets.0.balance', 77700) ->assertJsonPath('data.wallets.0.available_balance', 77700); });