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_credit_failed', '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' => $no === 'TI_credit_failed' ? 'main-ref-credit-failed' : null, 'fail_reason' => $no === 'TI_credit_failed' ? 'lottery_credit_failed' : 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'])->toBeFalse() ->and($byNo['TI_wait']['can_manually_process'])->toBeTrue() ->and($byNo['TI_wait']['can_complete_credit'])->toBeFalse() ->and($byNo['TI_credit_failed']['can_reverse'])->toBeTrue() ->and($byNo['TI_credit_failed']['can_manually_process'])->toBeFalse() ->and($byNo['TI_credit_failed']['can_complete_credit'])->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' => $no === 'TI_failed_manual' ? 'lottery_credit_failed' : 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') ->assertStatus(422) ->assertJsonPath('code', ErrorCode::WalletExternalRejected->value); $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/wallet/transfer-orders/TI_success_manual/manually-process') ->assertStatus(422); }); test('admin can reverse transfer in credit-failed pending reconcile order and refund main site once', function (): void { $token = makeAdminToken(); $this->app->instance(MainSiteWalletGateway::class, new class implements MainSiteWalletGateway { public function debitMainForLotteryDeposit(Player $player, string $currencyCode, int $amountMinor, string $idempotentKey): MainSiteWalletResult { return MainSiteWalletResult::failure('not_used'); } public function creditMainForLotteryWithdraw(Player $player, string $currencyCode, int $amountMinor, string $idempotentKey): MainSiteWalletResult { return MainSiteWalletResult::failure('not_used'); } public function refundMainForFailedLotteryDeposit(Player $player, string $currencyCode, int $amountMinor, string $idempotentKey): MainSiteWalletResult { return MainSiteWalletResult::success( 'main-refund-ref-1', ['success' => true, 'mock' => true], ['mock' => true, 'idempotent_key' => $idempotentKey], ); } }); $player = Player::query()->create([ 'site_code' => 'stub-refund-site', 'site_player_id' => 'reverse-credit-failed-player', 'username' => null, 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); TransferOrder::query()->create([ 'transfer_no' => 'TI_reverse_credit_failed', 'player_id' => $player->id, 'direction' => 'in', 'currency_code' => 'NPR', 'amount' => 350, 'idempotent_key' => 'reverse-credit-failed-key', 'status' => 'pending_reconcile', 'external_request_payload' => ['kind' => 'deposit'], 'external_response_payload' => ['kind' => 'timeout'], 'external_ref_no' => 'main-debit-ref-1', 'fail_reason' => 'lottery_credit_failed', 'finished_at' => null, ]); $path = '/api/v1/admin/wallet/transfer-orders/TI_reverse_credit_failed/reverse'; $this->withHeader('Authorization', 'Bearer '.$token) ->postJson($path, ['remark' => 'refund main site']) ->assertOk() ->assertJsonPath('data.status', 'reversed'); $this->withHeader('Authorization', 'Bearer '.$token) ->postJson($path, ['remark' => 'refund main site again']) ->assertOk() ->assertJsonPath('data.status', 'reversed'); $order = TransferOrder::query()->where('transfer_no', 'TI_reverse_credit_failed')->firstOrFail(); expect($order->status)->toBe('reversed') ->and($order->external_ref_no)->toBe('main-refund-ref-1') ->and(data_get($order->external_request_payload, 'mock'))->toBeTrue(); }); 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 can complete stuck transfer in credit for pending reconcile order', function (): void { $token = makeAdminToken(); $player = Player::query()->create([ 'site_code' => 'main', 'site_player_id' => 'complete-credit-player', 'username' => null, 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); $wallet = PlayerWallet::query()->create([ 'player_id' => $player->id, 'wallet_type' => 'lottery', 'currency_code' => 'NPR', 'balance' => 500, 'frozen_balance' => 0, 'status' => 0, 'version' => 0, ]); TransferOrder::query()->create([ 'transfer_no' => 'TI_complete_credit', 'player_id' => $player->id, 'direction' => 'in', 'currency_code' => 'NPR', 'amount' => 2_000, 'idempotent_key' => 'complete-credit-key', 'status' => 'pending_reconcile', 'external_request_payload' => ['ok' => true], 'external_response_payload' => ['ok' => true], 'external_ref_no' => 'main-ref-1', 'fail_reason' => 'lottery_credit_failed', 'finished_at' => null, ]); $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/wallet/transfer-orders/TI_complete_credit/complete-credit', [ 'remark' => 'manual complete', ]) ->assertOk() ->assertJsonPath('data.status', 'success'); $wallet->refresh(); expect((int) $wallet->balance)->toBe(2_500) ->and(TransferOrder::query()->where('transfer_no', 'TI_complete_credit')->value('status'))->toBe('success') ->and(WalletTxn::query()->where('biz_type', 'transfer_in')->count())->toBe(1); }); test('admin complete-credit is idempotent and does not double credit wallet', function (): void { $token = makeAdminToken(); $player = Player::query()->create([ 'site_code' => 'main', 'site_player_id' => 'complete-credit-idem-player', 'username' => null, 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); $wallet = PlayerWallet::query()->create([ 'player_id' => $player->id, 'wallet_type' => 'lottery', 'currency_code' => 'NPR', 'balance' => 300, 'frozen_balance' => 0, 'status' => 0, 'version' => 0, ]); TransferOrder::query()->create([ 'transfer_no' => 'TI_complete_credit_idem', 'player_id' => $player->id, 'direction' => 'in', 'currency_code' => 'NPR', 'amount' => 700, 'idempotent_key' => 'complete-credit-idem-key', 'status' => 'pending_reconcile', 'external_request_payload' => ['ok' => true], 'external_response_payload' => ['ok' => true], 'external_ref_no' => 'main-ref-idem-1', 'fail_reason' => 'lottery_credit_failed', 'finished_at' => null, ]); $path = '/api/v1/admin/wallet/transfer-orders/TI_complete_credit_idem/complete-credit'; $this->withHeader('Authorization', 'Bearer '.$token) ->postJson($path, ['remark' => 'first']) ->assertOk() ->assertJsonPath('data.status', 'success'); $this->withHeader('Authorization', 'Bearer '.$token) ->postJson($path, ['remark' => 'second']) ->assertOk() ->assertJsonPath('data.status', 'success'); $wallet->refresh(); expect((int) $wallet->balance)->toBe(1_000) ->and(WalletTxn::query()->where('biz_type', 'transfer_in')->where('biz_no', 'TI_complete_credit_idem')->count())->toBe(1) ->and(TransferOrder::query()->where('transfer_no', 'TI_complete_credit_idem')->value('status'))->toBe('success'); }); test('admin complete-credit rejects ineligible pending reconcile order', function (): void { $token = makeAdminToken(); $player = Player::query()->create([ 'site_code' => 'main', 'site_player_id' => 'complete-credit-ineligible-player', 'username' => null, 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); PlayerWallet::query()->create([ 'player_id' => $player->id, 'wallet_type' => 'lottery', 'currency_code' => 'NPR', 'balance' => 100, 'frozen_balance' => 0, 'status' => 0, 'version' => 0, ]); TransferOrder::query()->create([ 'transfer_no' => 'TI_complete_credit_ineligible', 'player_id' => $player->id, 'direction' => 'in', 'currency_code' => 'NPR', 'amount' => 500, 'idempotent_key' => 'complete-credit-ineligible-key', 'status' => 'pending_reconcile', 'external_request_payload' => ['ok' => true], 'external_response_payload' => ['ok' => true], 'external_ref_no' => null, 'fail_reason' => 'main_site_timeout', 'finished_at' => null, ]); $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/wallet/transfer-orders/TI_complete_credit_ineligible/complete-credit', [ 'remark' => 'should fail', ]) ->assertStatus(422); expect((int) PlayerWallet::query()->where('player_id', $player->id)->value('balance'))->toBe(100) ->and(WalletTxn::query()->where('biz_no', 'TI_complete_credit_ineligible')->count())->toBe(0); }); test('admin reverse endpoint is idempotent and credits only once', function (): void { $token = makeAdminToken(); $player = Player::query()->create([ 'site_code' => 'main', 'site_player_id' => 'reverse-endpoint-idem-player', 'username' => null, 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); $wallet = PlayerWallet::query()->create([ 'player_id' => $player->id, 'wallet_type' => 'lottery', 'currency_code' => 'NPR', 'balance' => 600, 'frozen_balance' => 0, 'status' => 0, 'version' => 0, ]); TransferOrder::query()->create([ 'transfer_no' => 'TO_reverse_endpoint_idem', 'player_id' => $player->id, 'direction' => 'out', 'currency_code' => 'NPR', 'amount' => 200, 'idempotent_key' => 'reverse-endpoint-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, ]); WalletTxn::query()->create([ 'txn_no' => 'WX_reverse_endpoint_out', 'player_id' => $player->id, 'wallet_id' => $wallet->id, 'biz_type' => 'transfer_out', 'biz_no' => 'TO_reverse_endpoint_idem', 'direction' => 2, 'amount' => 200, 'balance_before' => 800, 'balance_after' => 600, 'status' => 'pending_reconcile', 'external_ref_no' => null, 'idempotent_key' => 'reverse-endpoint-idem-key', 'remark' => null, ]); $path = '/api/v1/admin/wallet/transfer-orders/TO_reverse_endpoint_idem/reverse'; $this->withHeader('Authorization', 'Bearer '.$token) ->postJson($path, ['remark' => 'first']) ->assertOk() ->assertJsonPath('data.status', 'reversed'); $this->withHeader('Authorization', 'Bearer '.$token) ->postJson($path, ['remark' => 'second']) ->assertOk() ->assertJsonPath('data.status', 'reversed'); $wallet->refresh(); expect((int) $wallet->balance)->toBe(800) ->and(TransferOrder::query()->where('transfer_no', 'TO_reverse_endpoint_idem')->value('status'))->toBe('reversed') ->and(WalletTxn::query()->where('biz_type', 'reversal')->where('biz_no', 'TO_reverse_endpoint_idem')->count())->toBe(1); }); test('admin manually-process rejects pending reconcile transfer-out', function (): void { $token = makeAdminToken(); $player = Player::query()->create([ 'site_code' => 'main', 'site_player_id' => 'manual-process-out-pending-player', 'username' => null, 'nickname' => null, 'default_currency' => 'NPR', 'status' => 0, ]); TransferOrder::query()->create([ 'transfer_no' => 'TO_manual_process_pending_out', 'player_id' => $player->id, 'direction' => 'out', 'currency_code' => 'NPR', 'amount' => 200, 'idempotent_key' => 'manual-process-pending-out-key', 'status' => 'pending_reconcile', 'external_request_payload' => null, 'external_response_payload' => null, 'external_ref_no' => null, 'fail_reason' => 'main_site_timeout', 'finished_at' => null, ]); $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/wallet/transfer-orders/TO_manual_process_pending_out/manually-process', [ 'remark' => 'should reject', ]) ->assertStatus(422) ->assertJsonPath('code', ErrorCode::WalletExternalRejected->value); }); 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); });