From c5cf1194f3783af97432783922f353337d6f387a Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 20:41:16 -0400 Subject: [PATCH 1/7] fix(withdrawal): surface backend failures --- frontend/src/services/withdrawal.ts | 106 ++++++---------------------- 1 file changed, 20 insertions(+), 86 deletions(-) diff --git a/frontend/src/services/withdrawal.ts b/frontend/src/services/withdrawal.ts index 633d2dcf..879adb42 100644 --- a/frontend/src/services/withdrawal.ts +++ b/frontend/src/services/withdrawal.ts @@ -55,105 +55,39 @@ export interface WithdrawalResponse { } const withdrawalService = { - /** - * Fetch available anchors for withdrawal - */ + /** Fetch available anchors for withdrawal. */ getAvailableAnchors: async (assetCode: string = 'ORGUSD'): Promise => { - try { - const response = await axios.get<{ anchors: AnchorInfo[] }>( - `${API_BASE_URL}/withdrawal/anchors`, - { params: { assetCode } } - ); - return response.data.anchors; - } catch { - // Mock data for development until backend is ready - return [ - { - domain: 'anchor.ng', - name: 'Anchor Nigeria', - supportedCurrencies: ['NGN'], - withdrawFee: '1%', - withdrawMinAmount: 10, - withdrawMaxAmount: 10000, - }, - { - domain: 'flutterwave.com', - name: 'Flutterwave', - supportedCurrencies: ['NGN', 'KES', 'GHS', 'ZAR'], - withdrawFee: '0.5%', - withdrawMinAmount: 5, - withdrawMaxAmount: 50000, - }, - { - domain: 'stearn.com', - name: 'Stearn Financial', - supportedCurrencies: ['EUR', 'GBP'], - withdrawFee: '1.2%', - withdrawMinAmount: 20, - withdrawMaxAmount: 25000, - }, - ]; - } + const response = await axios.get<{ anchors: AnchorInfo[] }>( + `${API_BASE_URL}/withdrawal/anchors`, + { params: { assetCode } } + ); + return response.data.anchors; }, - /** - * Initiate a withdrawal via backend SEP-24 endpoint - */ + /** Initiate a withdrawal via backend SEP-24 endpoint. */ initiateWithdrawal: async (request: WithdrawalRequest): Promise => { - try { - const response = await axios.post( - `${API_BASE_URL}/withdrawal/initiate`, - request - ); - return response.data; - } catch { - // Mock response for development - const mockTxId = `wd-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`; - return { - transactionId: mockTxId, - interactiveUrl: `https://${request.anchorDomain}/withdraw?tx=${mockTxId}`, - status: 'pending_user_transfer', - }; - } + const response = await axios.post( + `${API_BASE_URL}/withdrawal/initiate`, + request + ); + return response.data; }, - /** - * Get withdrawal transaction status - */ + /** Get withdrawal transaction status. */ getTransactionStatus: async ( transactionId: string, anchorDomain: string ): Promise => { - try { - const response = await axios.get( - `${API_BASE_URL}/withdrawal/status/${transactionId}`, - { params: { anchorDomain } } - ); - return response.data; - } catch { - // Mock status for development - return { - id: transactionId, - anchorDomain, - status: 'pending_user_transfer', - amountIn: 100, - assetCode: 'ORGUSD', - interactiveUrl: `https://${anchorDomain}/withdraw?tx=${transactionId}`, - startedAt: new Date().toISOString(), - }; - } + const response = await axios.get( + `${API_BASE_URL}/withdrawal/status/${transactionId}`, + { params: { anchorDomain } } + ); + return response.data; }, - /** - * Cancel a pending withdrawal - */ + /** Cancel a pending withdrawal. */ cancelWithdrawal: async (transactionId: string): Promise => { - try { - await axios.post(`${API_BASE_URL}/withdrawal/cancel`, { transactionId }); - } catch { - // Mock success for development - console.log('Mock: Withdrawal cancelled', transactionId); - } + await axios.post(`${API_BASE_URL}/withdrawal/cancel`, { transactionId }); }, }; From e8e4d3533629db3493f7bcc75300d6b3d1df6223 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 20:41:48 -0400 Subject: [PATCH 2/7] test(withdrawal): cover backend failure propagation --- .../src/services/__tests__/withdrawal.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 frontend/src/services/__tests__/withdrawal.test.ts diff --git a/frontend/src/services/__tests__/withdrawal.test.ts b/frontend/src/services/__tests__/withdrawal.test.ts new file mode 100644 index 00000000..8ac4cc3f --- /dev/null +++ b/frontend/src/services/__tests__/withdrawal.test.ts @@ -0,0 +1,19 @@ +import axios from 'axios'; +import { describe, expect, it, vi } from 'vitest'; +import withdrawalService from '../withdrawal'; + +vi.mock('axios', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + }, +})); + +describe('withdrawalService', () => { + it('propagates anchor discovery failures instead of returning fallback data', async () => { + const failure = new Error('Network Error'); + vi.mocked(axios.get).mockRejectedValueOnce(failure); + + await expect(withdrawalService.getAvailableAnchors()).rejects.toBe(failure); + }); +}); From 12f3522913569d496ccb9b81e12c441967e5f2c8 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 23:59:19 -0400 Subject: [PATCH 3/7] test(withdrawal): satisfy unbound-method lint Use vi.spyOn(axios, 'get') so the mock is registered without an unbound method reference; fixes the @typescript-eslint/unbound-method error from the typechecked lint config. --- .../src/services/__tests__/withdrawal.test.ts | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/frontend/src/services/__tests__/withdrawal.test.ts b/frontend/src/services/__tests__/withdrawal.test.ts index 8ac4cc3f..0c451b07 100644 --- a/frontend/src/services/__tests__/withdrawal.test.ts +++ b/frontend/src/services/__tests__/withdrawal.test.ts @@ -1,19 +1,19 @@ -import axios from 'axios'; -import { describe, expect, it, vi } from 'vitest'; -import withdrawalService from '../withdrawal'; - -vi.mock('axios', () => ({ - default: { - get: vi.fn(), - post: vi.fn(), - }, -})); - -describe('withdrawalService', () => { - it('propagates anchor discovery failures instead of returning fallback data', async () => { - const failure = new Error('Network Error'); - vi.mocked(axios.get).mockRejectedValueOnce(failure); - - await expect(withdrawalService.getAvailableAnchors()).rejects.toBe(failure); - }); -}); +import axios from 'axios'; +import { describe, expect, it, vi } from 'vitest'; +import withdrawalService from '../withdrawal'; + +vi.mock('axios', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + }, +})); + +describe('withdrawalService', () => { + it('propagates anchor discovery failures instead of returning fallback data', async () => { + const failure = new Error('Network Error'); + vi.spyOn(axios, 'get').mockRejectedValueOnce(failure); + + await expect(withdrawalService.getAvailableAnchors()).rejects.toBe(failure); + }); +}); From 6618dea66c6b0cf9433779540d50ac3373440e6e Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 00:05:00 -0400 Subject: [PATCH 4/7] test(withdrawal): cover error propagation for all service methods Extend the vitest suite to assert rejection propagation for initiateWithdrawal, getTransactionStatus and cancelWithdrawal as well, matching the issue's error-path coverage criterion. --- .../src/services/__tests__/withdrawal.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/frontend/src/services/__tests__/withdrawal.test.ts b/frontend/src/services/__tests__/withdrawal.test.ts index 0c451b07..4e72e206 100644 --- a/frontend/src/services/__tests__/withdrawal.test.ts +++ b/frontend/src/services/__tests__/withdrawal.test.ts @@ -16,4 +16,33 @@ describe('withdrawalService', () => { await expect(withdrawalService.getAvailableAnchors()).rejects.toBe(failure); }); + + it('propagates withdrawal initiation failures instead of returning a fake transaction', async () => { + const failure = new Error('Request failed with status code 500'); + vi.spyOn(axios, 'post').mockRejectedValueOnce(failure); + + await expect( + withdrawalService.initiateWithdrawal({ + anchorDomain: 'anchor.ng', + assetCode: 'ORGUSD', + amount: 25, + destinationType: 'bank_account', + destinationDetails: { account: '0123456789' }, + }) + ).rejects.toBe(failure); + }); + + it('propagates transaction status failures instead of returning a fake success', async () => { + const failure = new Error('Request failed with status code 500'); + vi.spyOn(axios, 'get').mockRejectedValueOnce(failure); + + await expect(withdrawalService.getTransactionStatus('tx-1', 'anchor.ng')).rejects.toBe(failure); + }); + + it('propagates cancellation failures instead of resolving silently', async () => { + const failure = new Error('Network Error'); + vi.spyOn(axios, 'post').mockRejectedValueOnce(failure); + + await expect(withdrawalService.cancelWithdrawal('tx-1')).rejects.toBe(failure); + }); }); From 20a01c06eaea99bc4a612402148aa5188428d973 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 00:21:53 -0400 Subject: [PATCH 5/7] test(withdrawal): satisfy prettier print width Keep the status-propagation assertion under the 100-character print width so the format check passes. --- frontend/src/services/__tests__/withdrawal.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/services/__tests__/withdrawal.test.ts b/frontend/src/services/__tests__/withdrawal.test.ts index 4e72e206..52b4bceb 100644 --- a/frontend/src/services/__tests__/withdrawal.test.ts +++ b/frontend/src/services/__tests__/withdrawal.test.ts @@ -36,7 +36,7 @@ describe('withdrawalService', () => { const failure = new Error('Request failed with status code 500'); vi.spyOn(axios, 'get').mockRejectedValueOnce(failure); - await expect(withdrawalService.getTransactionStatus('tx-1', 'anchor.ng')).rejects.toBe(failure); + await expect(withdrawalService.getTransactionStatus('tx-1', 'anchor')).rejects.toBe(failure); }); it('propagates cancellation failures instead of resolving silently', async () => { From c6f73d81df87ec3de97a73b1651afbeeb157cad5 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 00:27:16 -0400 Subject: [PATCH 6/7] test(withdrawal): normalize line endings to LF Rewrite the test file with LF line endings so the prettier --check step passes. --- .../src/services/__tests__/withdrawal.test.ts | 96 +++++++++---------- 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/frontend/src/services/__tests__/withdrawal.test.ts b/frontend/src/services/__tests__/withdrawal.test.ts index 52b4bceb..af0c89bc 100644 --- a/frontend/src/services/__tests__/withdrawal.test.ts +++ b/frontend/src/services/__tests__/withdrawal.test.ts @@ -1,48 +1,48 @@ -import axios from 'axios'; -import { describe, expect, it, vi } from 'vitest'; -import withdrawalService from '../withdrawal'; - -vi.mock('axios', () => ({ - default: { - get: vi.fn(), - post: vi.fn(), - }, -})); - -describe('withdrawalService', () => { - it('propagates anchor discovery failures instead of returning fallback data', async () => { - const failure = new Error('Network Error'); - vi.spyOn(axios, 'get').mockRejectedValueOnce(failure); - - await expect(withdrawalService.getAvailableAnchors()).rejects.toBe(failure); - }); - - it('propagates withdrawal initiation failures instead of returning a fake transaction', async () => { - const failure = new Error('Request failed with status code 500'); - vi.spyOn(axios, 'post').mockRejectedValueOnce(failure); - - await expect( - withdrawalService.initiateWithdrawal({ - anchorDomain: 'anchor.ng', - assetCode: 'ORGUSD', - amount: 25, - destinationType: 'bank_account', - destinationDetails: { account: '0123456789' }, - }) - ).rejects.toBe(failure); - }); - - it('propagates transaction status failures instead of returning a fake success', async () => { - const failure = new Error('Request failed with status code 500'); - vi.spyOn(axios, 'get').mockRejectedValueOnce(failure); - - await expect(withdrawalService.getTransactionStatus('tx-1', 'anchor')).rejects.toBe(failure); - }); - - it('propagates cancellation failures instead of resolving silently', async () => { - const failure = new Error('Network Error'); - vi.spyOn(axios, 'post').mockRejectedValueOnce(failure); - - await expect(withdrawalService.cancelWithdrawal('tx-1')).rejects.toBe(failure); - }); -}); +import axios from 'axios'; +import { describe, expect, it, vi } from 'vitest'; +import withdrawalService from '../withdrawal'; + +vi.mock('axios', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + }, +})); + +describe('withdrawalService', () => { + it('propagates anchor discovery failures instead of returning fallback data', async () => { + const failure = new Error('Network Error'); + vi.spyOn(axios, 'get').mockRejectedValueOnce(failure); + + await expect(withdrawalService.getAvailableAnchors()).rejects.toBe(failure); + }); + + it('propagates withdrawal initiation failures instead of returning a fake transaction', async () => { + const failure = new Error('Request failed with status code 500'); + vi.spyOn(axios, 'post').mockRejectedValueOnce(failure); + + await expect( + withdrawalService.initiateWithdrawal({ + anchorDomain: 'anchor.ng', + assetCode: 'ORGUSD', + amount: 25, + destinationType: 'bank_account', + destinationDetails: { account: '0123456789' }, + }) + ).rejects.toBe(failure); + }); + + it('propagates transaction status failures instead of returning a fake success', async () => { + const failure = new Error('Request failed with status code 500'); + vi.spyOn(axios, 'get').mockRejectedValueOnce(failure); + + await expect(withdrawalService.getTransactionStatus('tx-1', 'anchor')).rejects.toBe(failure); + }); + + it('propagates cancellation failures instead of resolving silently', async () => { + const failure = new Error('Network Error'); + vi.spyOn(axios, 'post').mockRejectedValueOnce(failure); + + await expect(withdrawalService.cancelWithdrawal('tx-1')).rejects.toBe(failure); + }); +}); From 45a4159ca2c73f517308a53621e3b1a094c4cf52 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Mon, 5 Oct 2026 04:38:47 -0400 Subject: [PATCH 7/7] fix(withdrawal): report initiation outcome to confirmation caller The existing error-propagating service reaches useWithdrawal, but WithdrawalFlow then reads its pre-await render state and calls onSuccess even after a validation or request failure. Return an explicit boolean from initiation and notify the caller only on success. Preserve current errors, form/processing states, polling and the existing successful-initiation account-refresh timing; this does not claim settlement. This source residual was identified in Lantern's earlier PayD457 intake and remains present on the existing PR682 carrier. Only the hook and component change; backend/service and prior contributor work stay intact. Static source inspection of service, hook, component and actual EmployeePortal callback only. No tests, fixtures, build, browser, wallet/backend or runtime execution was performed. The prior fork CI receipt remains historical on c6f73d81; native validation and maintainer acceptance of this continuation are pending. --- frontend/src/components/WithdrawalFlow.tsx | 4 ++-- frontend/src/hooks/useWithdrawal.ts | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/WithdrawalFlow.tsx b/frontend/src/components/WithdrawalFlow.tsx index a19daaa6..eb117e67 100644 --- a/frontend/src/components/WithdrawalFlow.tsx +++ b/frontend/src/components/WithdrawalFlow.tsx @@ -55,8 +55,8 @@ const WithdrawalFlow: React.FC = ({ }; const handleConfirmWithdrawal = async () => { - await initiateWithdrawal(destinationType, destinationDetails); - if (state.step !== 'failed') { + const initiated = await initiateWithdrawal(destinationType, destinationDetails); + if (initiated) { onSuccess(); } }; diff --git a/frontend/src/hooks/useWithdrawal.ts b/frontend/src/hooks/useWithdrawal.ts index 09dd0fc3..d3aa0ed4 100644 --- a/frontend/src/hooks/useWithdrawal.ts +++ b/frontend/src/hooks/useWithdrawal.ts @@ -33,7 +33,7 @@ interface UseWithdrawalReturn { initiateWithdrawal: ( destinationType: 'bank_account' | 'mobile_money', destinationDetails: Record - ) => Promise; + ) => Promise; openInteractiveUrl: () => void; pollTransactionStatus: () => Promise; cancelWithdrawal: () => Promise; @@ -122,18 +122,18 @@ export function useWithdrawal( ) => { if (!state.selectedAnchor || !state.amount) { setState((prev) => ({ ...prev, error: 'Please select an anchor and enter an amount' })); - return; + return false; } const amount = parseFloat(state.amount); if (isNaN(amount) || amount <= 0) { setState((prev) => ({ ...prev, error: 'Please enter a valid amount' })); - return; + return false; } if (amount > balance) { setState((prev) => ({ ...prev, error: 'Insufficient balance' })); - return; + return false; } setState((prev) => ({ ...prev, isLoading: true, error: null, step: 'confirm' })); @@ -165,6 +165,7 @@ export function useWithdrawal( isLoading: false, step: 'processing', })); + return true; } catch (err) { setState((prev) => ({ ...prev, @@ -172,6 +173,7 @@ export function useWithdrawal( error: err instanceof Error ? err.message : 'Failed to initiate withdrawal', step: 'enter_amount', })); + return false; } }, [state.selectedAnchor, state.amount, balance]