Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions frontend/src/components/WithdrawalFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ const WithdrawalFlow: React.FC<WithdrawalFlowProps> = ({
};

const handleConfirmWithdrawal = async () => {
await initiateWithdrawal(destinationType, destinationDetails);
if (state.step !== 'failed') {
const initiated = await initiateWithdrawal(destinationType, destinationDetails);
if (initiated) {
onSuccess();
}
};
Expand Down
10 changes: 6 additions & 4 deletions frontend/src/hooks/useWithdrawal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ interface UseWithdrawalReturn {
initiateWithdrawal: (
destinationType: 'bank_account' | 'mobile_money',
destinationDetails: Record<string, string>
) => Promise<void>;
) => Promise<boolean>;
openInteractiveUrl: () => void;
pollTransactionStatus: () => Promise<void>;
cancelWithdrawal: () => Promise<void>;
Expand Down Expand Up @@ -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' }));
Expand Down Expand Up @@ -165,13 +165,15 @@ export function useWithdrawal(
isLoading: false,
step: 'processing',
}));
return true;
} catch (err) {
setState((prev) => ({
...prev,
isLoading: false,
error: err instanceof Error ? err.message : 'Failed to initiate withdrawal',
step: 'enter_amount',
}));
return false;
}
},
[state.selectedAnchor, state.amount, balance]
Expand Down
48 changes: 48 additions & 0 deletions frontend/src/services/__tests__/withdrawal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +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);
});
});
106 changes: 20 additions & 86 deletions frontend/src/services/withdrawal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnchorInfo[]> => {
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<WithdrawalResponse> => {
try {
const response = await axios.post<WithdrawalResponse>(
`${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<WithdrawalResponse>(
`${API_BASE_URL}/withdrawal/initiate`,
request
);
return response.data;
},

/**
* Get withdrawal transaction status
*/
/** Get withdrawal transaction status. */
getTransactionStatus: async (
transactionId: string,
anchorDomain: string
): Promise<WithdrawalTransaction> => {
try {
const response = await axios.get<WithdrawalTransaction>(
`${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<WithdrawalTransaction>(
`${API_BASE_URL}/withdrawal/status/${transactionId}`,
{ params: { anchorDomain } }
);
return response.data;
},

/**
* Cancel a pending withdrawal
*/
/** Cancel a pending withdrawal. */
cancelWithdrawal: async (transactionId: string): Promise<void> => {
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 });
},
};

Expand Down