diff --git a/app/(dashboard)/escrow/[id]/settlement/page.tsx b/app/(dashboard)/escrow/[id]/settlement/page.tsx new file mode 100644 index 00000000..557975ed --- /dev/null +++ b/app/(dashboard)/escrow/[id]/settlement/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/hooks/__tests__/fixtures/settlementApiResponses.ts b/hooks/__tests__/fixtures/settlementApiResponses.ts new file mode 100644 index 00000000..e28312ef --- /dev/null +++ b/hooks/__tests__/fixtures/settlementApiResponses.ts @@ -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, +}; diff --git a/lib/__tests__/settlementFormatters.test.ts b/lib/__tests__/settlementFormatters.test.ts new file mode 100644 index 00000000..66b2e59f --- /dev/null +++ b/lib/__tests__/settlementFormatters.test.ts @@ -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'); + }); + }); +}); diff --git a/lib/settlementFormatters.ts b/lib/settlementFormatters.ts new file mode 100644 index 00000000..e180f78a --- /dev/null +++ b/lib/settlementFormatters.ts @@ -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)); +} diff --git a/services/__tests__/escrowSettlementService.test.ts b/services/__tests__/escrowSettlementService.test.ts new file mode 100644 index 00000000..d4dd8e46 --- /dev/null +++ b/services/__tests__/escrowSettlementService.test.ts @@ -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; + +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); + }); + }); +}); diff --git a/services/escrowSettlementService.ts b/services/escrowSettlementService.ts new file mode 100644 index 00000000..6b0749ba --- /dev/null +++ b/services/escrowSettlementService.ts @@ -0,0 +1,53 @@ +import { isAxiosError } from 'axios'; +import { apiClient } from '@/services/api'; +import type { SettlementBreakdown, SettlementStatus } from '@/types/settlementBreakdown'; + +const TERMINAL_STATUSES: ReadonlySet = 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 { + try { + const { data } = await apiClient.get( + `/escrow/${encodeURIComponent(escrowId)}/settlement`, + { signal }, + ); + return data; + } catch (error) { + throw toServiceError(error); + } + }, + + isTerminalStatus(status: SettlementStatus): boolean { + return TERMINAL_STATUSES.has(status); + }, +}; diff --git a/types/settlementBreakdown.ts b/types/settlementBreakdown.ts new file mode 100644 index 00000000..3c93f359 --- /dev/null +++ b/types/settlementBreakdown.ts @@ -0,0 +1,73 @@ +/** + * Types for the post-delivery settlement breakdown returned by + * GET /api/escrow/{id}/settlement. + * + * All monetary amounts are expressed in units of `asset` (e.g. XLM, USDC) + * and mirror the values emitted by the escrow contract's settlement event. + */ + +/** + * Lifecycle of a settlement after escrow release. + * - `pending` — release requested, transaction not yet submitted + * - `finalizing` — transaction submitted, awaiting ledger confirmation + * - `confirmed` — transaction included in a closed ledger (terminal) + * - `failed` — transaction rejected or expired (terminal) + */ +export type SettlementStatus = 'pending' | 'finalizing' | 'confirmed' | 'failed'; + +export type StellarNetwork = 'public' | 'testnet'; + +export interface SettlementTaxWithholding { + /** Stable identifier for the withholding, e.g. `vat` or `wht`. */ + code: string; + label: string; + /** Rate applied as a fraction, e.g. 0.075 for 7.5%. */ + rate: number; + amount: number; +} + +export interface SettlementDeliverySummary { + trackingNumber: string; + origin: string; + destination: string; + deliveredAt: string | null; +} + +export interface SettlementBreakdown { + escrowId: string; + status: SettlementStatus; + asset: string; + network: StellarNetwork; + /** Total amount that was locked in escrow for the delivery. */ + totalEscrowAmount: number; + /** Net amount released to the driver after fees and withholdings. */ + driverPayout: number; + platformFee: number; + /** Platform commission as a fraction, e.g. 0.025 for 2.5%. */ + platformFeeRate: number; + taxWithholdings: SettlementTaxWithholding[]; + /** Amount still held (e.g. pending dispute window or compliance review). */ + heldAmount: number; + heldReason: string | null; + /** Balance left in escrow or returned to the shipper after settlement. */ + remainingBalance: number; + transactionHash: string | null; + ledger: number | null; + settledAt: string | null; + delivery: SettlementDeliverySummary; +} + +export interface FormattedTaxWithholding extends SettlementTaxWithholding { + formattedAmount: string; + formattedRate: string; +} + +export interface FormattedSettlementAmounts { + totalEscrowAmount: string; + driverPayout: string; + platformFee: string; + platformFeeRate: string; + totalTaxWithheld: string; + heldAmount: string; + remainingBalance: string; +}