Skip to content
Merged
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
15 changes: 15 additions & 0 deletions app/(dashboard)/escrow/[id]/settlement/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { SettlementConfirmation } from '@/components/escrow/SettlementConfirmation';

export default async function EscrowSettlementPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;

return (
<main className="min-h-screen bg-gray-50">
<SettlementConfirmation escrowId={id} />
</main>
);
}
59 changes: 59 additions & 0 deletions hooks/__tests__/fixtures/settlementApiResponses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { SettlementBreakdown } from '@/types/settlementBreakdown';

/**
* Recorded response shapes from GET /api/escrow/{id}/settlement, used to
* stub the service layer in hook and component tests.
*/

export const ESCROW_ID = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC';
export const SETTLEMENT_TX_HASH = '3f9a1c7e5b2d4a6f8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d8b20e4';

export const confirmedSettlementResponse: SettlementBreakdown = {
escrowId: ESCROW_ID,
status: 'confirmed',
asset: 'XLM',
network: 'testnet',
totalEscrowAmount: 1250,
driverPayout: 1134.375,
platformFee: 31.25,
platformFeeRate: 0.025,
taxWithholdings: [
{ code: 'vat', label: 'VAT on platform fee', rate: 0.075, amount: 2.34375 },
{ code: 'wht', label: 'Withholding tax', rate: 0.05, amount: 57.03125 },
],
heldAmount: 25,
heldReason: 'Dispute window open for 24 hours',
remainingBalance: 0,
transactionHash: SETTLEMENT_TX_HASH,
ledger: 51234567,
settledAt: '2026-09-28T14:32:10Z',
delivery: {
trackingNumber: 'SWC-2026-004821',
origin: 'Lagos, NG',
destination: 'Accra, GH',
deliveredAt: '2026-09-28T13:58:02Z',
},
};

export const finalizingSettlementResponse: SettlementBreakdown = {
...confirmedSettlementResponse,
status: 'finalizing',
transactionHash: SETTLEMENT_TX_HASH,
ledger: null,
settledAt: null,
};

export const pendingSettlementResponse: SettlementBreakdown = {
...confirmedSettlementResponse,
status: 'pending',
transactionHash: null,
ledger: null,
settledAt: null,
};

export const failedSettlementResponse: SettlementBreakdown = {
...confirmedSettlementResponse,
status: 'failed',
ledger: null,
settledAt: null,
};
54 changes: 54 additions & 0 deletions lib/__tests__/settlementFormatters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {
formatAssetAmount,
formatRate,
formatSettlementDate,
getStellarExplorerTxUrl,
truncateHash,
} from '@/lib/settlementFormatters';

describe('settlementFormatters', () => {
describe('formatAssetAmount', () => {
it('shows at least two decimals with the asset code', () => {
expect(formatAssetAmount(1250, 'XLM')).toBe('1,250.00 XLM');
});

it('keeps Stellar 7-decimal precision', () => {
expect(formatAssetAmount(0.1234567, 'USDC')).toBe('0.1234567 USDC');
expect(formatAssetAmount(0.123456789, 'XLM')).toBe('0.1234568 XLM');
});
});

describe('formatRate', () => {
it('formats fractional rates as percentages', () => {
expect(formatRate(0.025)).toBe('2.5%');
expect(formatRate(0.1)).toBe('10%');
});
});

describe('getStellarExplorerTxUrl', () => {
it('links to the matching network on stellar.expert', () => {
expect(getStellarExplorerTxUrl('abc123', 'public')).toBe(
'https://stellar.expert/explorer/public/tx/abc123',
);
expect(getStellarExplorerTxUrl('abc123', 'testnet')).toBe(
'https://stellar.expert/explorer/testnet/tx/abc123',
);
});
});

describe('truncateHash', () => {
it('keeps the start and end of long hashes', () => {
expect(truncateHash('3f9a1c7e5b2d4a6f8c0e8b20e4')).toBe('3f9a1c…8b20e4');
});

it('returns short hashes unchanged', () => {
expect(truncateHash('abc123')).toBe('abc123');
});
});

describe('formatSettlementDate', () => {
it('formats ISO timestamps in UTC', () => {
expect(formatSettlementDate('2026-09-28T14:32:10Z')).toBe('Sep 28, 2026, 2:32 PM UTC');
});
});
});
45 changes: 45 additions & 0 deletions lib/settlementFormatters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { StellarNetwork } from '@/types/settlementBreakdown';

const STELLAR_EXPERT_BASE_URL = 'https://stellar.expert/explorer';

/**
* Formats an on-chain asset amount, keeping up to 7 decimal places (the
* precision of Stellar amounts) while always showing at least 2.
*/
export function formatAssetAmount(amount: number, asset: string): string {
const formatted = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 7,
}).format(amount);
return `${formatted} ${asset}`;
}

/** Formats a fractional rate (0.025) as a percentage string ("2.5%"). */
export function formatRate(rate: number): string {
return new Intl.NumberFormat('en-US', {
style: 'percent',
maximumFractionDigits: 2,
}).format(rate);
}

export function getStellarExplorerTxUrl(hash: string, network: StellarNetwork): string {
return `${STELLAR_EXPERT_BASE_URL}/${network}/tx/${encodeURIComponent(hash)}`;
}

/** Shortens a transaction hash for display, e.g. "3f9a1c…8b20e4". */
export function truncateHash(hash: string, visible = 6): string {
if (hash.length <= visible * 2 + 1) return hash;
return `${hash.slice(0, visible)}…${hash.slice(-visible)}`;
}

export function formatSettlementDate(iso: string): string {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZone: 'UTC',
timeZoneName: 'short',
}).format(new Date(iso));
}
105 changes: 105 additions & 0 deletions services/__tests__/escrowSettlementService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { AxiosError, AxiosHeaders } from 'axios';
import { apiClient } from '@/services/api';
import {
escrowSettlementService,
SettlementServiceError,
} from '@/services/escrowSettlementService';
import {
ESCROW_ID,
confirmedSettlementResponse,
} from '@/hooks/__tests__/fixtures/settlementApiResponses';

jest.mock('@/services/api', () => ({
apiClient: { get: jest.fn() },
}));

const mockGet = apiClient.get as jest.MockedFunction<typeof apiClient.get>;

function axiosErrorWith(status: number, data?: unknown): AxiosError {
const headers = new AxiosHeaders();
return new AxiosError('Request failed', 'ERR_BAD_RESPONSE', { headers }, null, {
status,
statusText: '',
headers: {},
config: { headers },
data,
});
}

describe('escrowSettlementService', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('getSettlement', () => {
it('requests the settlement endpoint for the escrow', async () => {
mockGet.mockResolvedValue({ data: confirmedSettlementResponse });
const controller = new AbortController();

const result = await escrowSettlementService.getSettlement(ESCROW_ID, controller.signal);

expect(mockGet).toHaveBeenCalledWith(`/escrow/${ESCROW_ID}/settlement`, {
signal: controller.signal,
});
expect(result).toEqual(confirmedSettlementResponse);
});

it('encodes the escrow id in the path', async () => {
mockGet.mockResolvedValue({ data: confirmedSettlementResponse });

await escrowSettlementService.getSettlement('esc/1 2');

expect(mockGet).toHaveBeenCalledWith('/escrow/esc%2F1%202/settlement', {
signal: undefined,
});
});

it('uses the backend error message when provided', async () => {
mockGet.mockRejectedValue(axiosErrorWith(422, { message: 'Escrow not yet released' }));

await expect(escrowSettlementService.getSettlement(ESCROW_ID)).rejects.toEqual(
expect.objectContaining({ message: 'Escrow not yet released', status: 422 }),
);
});

it('maps 404 responses to a not-found error', async () => {
mockGet.mockRejectedValue(axiosErrorWith(404));

const error = await escrowSettlementService.getSettlement(ESCROW_ID).catch((e) => e);

expect(error).toBeInstanceOf(SettlementServiceError);
expect(error.message).toBe('Settlement not found for this escrow.');
expect(error.status).toBe(404);
});

it('maps other failures to a generic error', async () => {
mockGet.mockRejectedValue(axiosErrorWith(500));

await expect(escrowSettlementService.getSettlement(ESCROW_ID)).rejects.toEqual(
expect.objectContaining({
message: 'Unable to load settlement details. Please try again.',
status: 500,
}),
);
});

it('wraps non-axios errors', async () => {
mockGet.mockRejectedValue(new Error('Network down'));

await expect(escrowSettlementService.getSettlement(ESCROW_ID)).rejects.toEqual(
expect.objectContaining({ message: 'Network down', status: null }),
);
});
});

describe('isTerminalStatus', () => {
it.each([
['confirmed', true],
['failed', true],
['pending', false],
['finalizing', false],
] as const)('treats %s as terminal=%s', (status, expected) => {
expect(escrowSettlementService.isTerminalStatus(status)).toBe(expected);
});
});
});
53 changes: 53 additions & 0 deletions services/escrowSettlementService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { isAxiosError } from 'axios';
import { apiClient } from '@/services/api';
import type { SettlementBreakdown, SettlementStatus } from '@/types/settlementBreakdown';

const TERMINAL_STATUSES: ReadonlySet<SettlementStatus> = new Set(['confirmed', 'failed']);

export class SettlementServiceError extends Error {
readonly status: number | null;

constructor(message: string, status: number | null) {
super(message);
this.name = 'SettlementServiceError';
this.status = status;
}
}

function toServiceError(error: unknown): SettlementServiceError {
if (isAxiosError<{ message?: string }>(error)) {
const status = error.response?.status ?? null;
const message =
error.response?.data?.message ??
(status === 404
? 'Settlement not found for this escrow.'
: 'Unable to load settlement details. Please try again.');
return new SettlementServiceError(message, status);
}
if (error instanceof Error) {
return new SettlementServiceError(error.message, null);
}
return new SettlementServiceError('Unable to load settlement details. Please try again.', null);
}

/**
* escrowSettlementService — post-release settlement API communication.
* Hooks call this; components never call this directly.
*/
export const escrowSettlementService = {
async getSettlement(escrowId: string, signal?: AbortSignal): Promise<SettlementBreakdown> {
try {
const { data } = await apiClient.get<SettlementBreakdown>(
`/escrow/${encodeURIComponent(escrowId)}/settlement`,
{ signal },
);
return data;
} catch (error) {
throw toServiceError(error);
}
},

isTerminalStatus(status: SettlementStatus): boolean {
return TERMINAL_STATUSES.has(status);
},
};
Loading
Loading