From 8980674bed00066ec322b8e7e89fc4df284e0dc2 Mon Sep 17 00:00:00 2001 From: BigManly4 Date: Tue, 29 Sep 2026 05:19:45 -0700 Subject: [PATCH 1/2] feat(hooks): add useSettlementBreakdown hook for post-delivery fund distribution - Add escrowSettlementService to fetch GET /api/escrow/{id}/settlement - Add SettlementBreakdown types for driver payout, fees, tax withholdings and held funds - Poll while the release transaction is pending/finalizing and stop on confirmed/failed - Expose formatted asset amounts, tax breakdown and Stellar explorer link - Skip retries for client errors and surface readable API error messages --- .../fixtures/settlementApiResponses.ts | 59 +++++ .../__tests__/useSettlementBreakdown.test.tsx | 223 ++++++++++++++++++ hooks/useSettlementBreakdown.ts | 129 ++++++++++ lib/__tests__/settlementFormatters.test.ts | 36 +++ lib/settlementFormatters.ts | 27 +++ .../__tests__/escrowSettlementService.test.ts | 105 +++++++++ services/escrowSettlementService.ts | 53 +++++ types/settlementBreakdown.ts | 73 ++++++ 8 files changed, 705 insertions(+) create mode 100644 hooks/__tests__/fixtures/settlementApiResponses.ts create mode 100644 hooks/__tests__/useSettlementBreakdown.test.tsx create mode 100644 hooks/useSettlementBreakdown.ts create mode 100644 lib/__tests__/settlementFormatters.test.ts create mode 100644 lib/settlementFormatters.ts create mode 100644 services/__tests__/escrowSettlementService.test.ts create mode 100644 services/escrowSettlementService.ts create mode 100644 types/settlementBreakdown.ts 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/hooks/__tests__/useSettlementBreakdown.test.tsx b/hooks/__tests__/useSettlementBreakdown.test.tsx new file mode 100644 index 00000000..7dd4f63b --- /dev/null +++ b/hooks/__tests__/useSettlementBreakdown.test.tsx @@ -0,0 +1,223 @@ +import React from 'react'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useSettlementBreakdown } from '@/hooks/useSettlementBreakdown'; +import { + escrowSettlementService, + SettlementServiceError, +} from '@/services/escrowSettlementService'; +import { + ESCROW_ID, + SETTLEMENT_TX_HASH, + confirmedSettlementResponse, + failedSettlementResponse, + finalizingSettlementResponse, + pendingSettlementResponse, +} from './fixtures/settlementApiResponses'; + +jest.mock('@/services/escrowSettlementService', () => { + const actual = jest.requireActual('@/services/escrowSettlementService'); + return { + ...actual, + escrowSettlementService: { + ...actual.escrowSettlementService, + getSettlement: jest.fn(), + }, + }; +}); + +const mockGetSettlement = escrowSettlementService.getSettlement as jest.MockedFunction< + typeof escrowSettlementService.getSettlement +>; + +const POLL_MS = 20; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { gcTime: 0, retryDelay: 0 } }, + }); + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; + }; +} + +describe('useSettlementBreakdown', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns structured settlement data with formatted values', async () => { + mockGetSettlement.mockResolvedValue(confirmedSettlementResponse); + + const { result } = renderHook(() => useSettlementBreakdown(ESCROW_ID), { + wrapper: createWrapper(), + }); + + expect(result.current.isLoading).toBe(true); + await waitFor(() => expect(result.current.settlement).not.toBeNull()); + + expect(mockGetSettlement).toHaveBeenCalledWith(ESCROW_ID, expect.any(AbortSignal)); + expect(result.current.settlement).toEqual(confirmedSettlementResponse); + expect(result.current.formatted).toEqual({ + totalEscrowAmount: '1,250.00 XLM', + driverPayout: '1,134.375 XLM', + platformFee: '31.25 XLM', + platformFeeRate: '2.5%', + totalTaxWithheld: '59.375 XLM', + heldAmount: '25.00 XLM', + remainingBalance: '0.00 XLM', + }); + expect(result.current.isTerminal).toBe(true); + expect(result.current.isFinalizing).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('exposes a formatted tax breakdown', async () => { + mockGetSettlement.mockResolvedValue(confirmedSettlementResponse); + + const { result } = renderHook(() => useSettlementBreakdown(ESCROW_ID), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.taxBreakdown).toHaveLength(2)); + expect(result.current.taxBreakdown[0]).toMatchObject({ + code: 'vat', + formattedAmount: '2.34375 XLM', + formattedRate: '7.5%', + }); + expect(result.current.taxBreakdown[1]).toMatchObject({ + code: 'wht', + formattedAmount: '57.03125 XLM', + formattedRate: '5%', + }); + }); + + it('builds a Stellar explorer link for the settlement transaction', async () => { + mockGetSettlement.mockResolvedValue(confirmedSettlementResponse); + + const { result } = renderHook(() => useSettlementBreakdown(ESCROW_ID), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.explorerUrl).not.toBeNull()); + expect(result.current.explorerUrl).toBe( + `https://stellar.expert/explorer/testnet/tx/${SETTLEMENT_TX_HASH}`, + ); + }); + + it('returns a null explorer link while no transaction hash exists', async () => { + mockGetSettlement.mockResolvedValue(pendingSettlementResponse); + + const { result } = renderHook( + () => useSettlementBreakdown(ESCROW_ID, { pollIntervalMs: 60_000 }), + { wrapper: createWrapper() }, + ); + + await waitFor(() => expect(result.current.settlement).not.toBeNull()); + expect(result.current.explorerUrl).toBeNull(); + expect(result.current.isFinalizing).toBe(true); + }); + + it('polls until the settlement reaches a terminal state', async () => { + mockGetSettlement + .mockResolvedValueOnce(pendingSettlementResponse) + .mockResolvedValueOnce(finalizingSettlementResponse) + .mockResolvedValue(confirmedSettlementResponse); + + const { result } = renderHook( + () => useSettlementBreakdown(ESCROW_ID, { pollIntervalMs: POLL_MS }), + { wrapper: createWrapper() }, + ); + + await waitFor(() => expect(result.current.settlement?.status).toBe('confirmed')); + expect(mockGetSettlement).toHaveBeenCalledTimes(3); + expect(result.current.isTerminal).toBe(true); + + // No further requests once terminal. + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 4)); + expect(mockGetSettlement).toHaveBeenCalledTimes(3); + }); + + it('stops polling when the settlement fails', async () => { + mockGetSettlement + .mockResolvedValueOnce(finalizingSettlementResponse) + .mockResolvedValue(failedSettlementResponse); + + const { result } = renderHook( + () => useSettlementBreakdown(ESCROW_ID, { pollIntervalMs: POLL_MS }), + { wrapper: createWrapper() }, + ); + + await waitFor(() => expect(result.current.settlement?.status).toBe('failed')); + await new Promise((resolve) => setTimeout(resolve, POLL_MS * 4)); + expect(mockGetSettlement).toHaveBeenCalledTimes(2); + expect(result.current.isTerminal).toBe(true); + expect(result.current.isFinalizing).toBe(false); + }); + + it('surfaces API errors without retrying client errors', async () => { + mockGetSettlement.mockRejectedValue( + new SettlementServiceError('Settlement not found for this escrow.', 404), + ); + + const { result } = renderHook(() => useSettlementBreakdown(ESCROW_ID), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.error).toBe('Settlement not found for this escrow.')); + expect(mockGetSettlement).toHaveBeenCalledTimes(1); + expect(result.current.settlement).toBeNull(); + expect(result.current.formatted).toBeNull(); + expect(result.current.isLoading).toBe(false); + }); + + it('retries server errors before reporting failure', async () => { + mockGetSettlement.mockRejectedValue( + new SettlementServiceError('Unable to load settlement details. Please try again.', 503), + ); + + const { result } = renderHook(() => useSettlementBreakdown(ESCROW_ID), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.error).not.toBeNull()); + expect(mockGetSettlement).toHaveBeenCalledTimes(3); + }); + + it('recovers after an error when refetch is called', async () => { + mockGetSettlement + .mockRejectedValueOnce(new SettlementServiceError('Bad request', 400)) + .mockResolvedValue(confirmedSettlementResponse); + + const { result } = renderHook(() => useSettlementBreakdown(ESCROW_ID), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.error).toBe('Bad request')); + + act(() => { + result.current.refetch(); + }); + + await waitFor(() => expect(result.current.settlement).toEqual(confirmedSettlementResponse)); + expect(result.current.error).toBeNull(); + }); + + it('does not fetch without an escrow id', () => { + const { result } = renderHook(() => useSettlementBreakdown(undefined), { + wrapper: createWrapper(), + }); + + expect(mockGetSettlement).not.toHaveBeenCalled(); + expect(result.current.settlement).toBeNull(); + expect(result.current.isLoading).toBe(false); + }); + + it('does not fetch when disabled', () => { + renderHook(() => useSettlementBreakdown(ESCROW_ID, { enabled: false }), { + wrapper: createWrapper(), + }); + + expect(mockGetSettlement).not.toHaveBeenCalled(); + }); +}); diff --git a/hooks/useSettlementBreakdown.ts b/hooks/useSettlementBreakdown.ts new file mode 100644 index 00000000..e2352051 --- /dev/null +++ b/hooks/useSettlementBreakdown.ts @@ -0,0 +1,129 @@ +'use client'; + +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { + escrowSettlementService, + SettlementServiceError, +} from '@/services/escrowSettlementService'; +import { + formatAssetAmount, + formatRate, + getStellarExplorerTxUrl, +} from '@/lib/settlementFormatters'; +import type { + FormattedSettlementAmounts, + FormattedTaxWithholding, + SettlementBreakdown, +} from '@/types/settlementBreakdown'; + +export const DEFAULT_SETTLEMENT_POLL_INTERVAL_MS = 5000; +const MAX_RETRIES = 2; + +export const settlementBreakdownKeys = { + all: ['settlementBreakdown'] as const, + detail: (escrowId: string) => ['settlementBreakdown', escrowId] as const, +}; + +export interface UseSettlementBreakdownOptions { + /** Interval used while the settlement is still finalizing. */ + pollIntervalMs?: number; + enabled?: boolean; +} + +export interface UseSettlementBreakdownResult { + settlement: SettlementBreakdown | null; + formatted: FormattedSettlementAmounts | null; + taxBreakdown: FormattedTaxWithholding[]; + explorerUrl: string | null; + isLoading: boolean; + isFetching: boolean; + /** True while polling for the settlement to reach a terminal state. */ + isFinalizing: boolean; + isTerminal: boolean; + error: string | null; + refetch: () => void; +} + +function shouldRetry(failureCount: number, error: Error): boolean { + // Client errors (e.g. 404 settlement not found) will not resolve on retry. + if (error instanceof SettlementServiceError && error.status !== null && error.status < 500) { + return false; + } + return failureCount < MAX_RETRIES; +} + +function roundAssetAmount(amount: number): number { + return Math.round(amount * 1e7) / 1e7; +} + +/** + * Fetches the finalized settlement breakdown for an escrow after release. + * Polls while the release transaction is still pending/finalizing and stops + * once the settlement is confirmed or failed. + */ +export function useSettlementBreakdown( + escrowId: string | null | undefined, + { pollIntervalMs = DEFAULT_SETTLEMENT_POLL_INTERVAL_MS, enabled = true }: UseSettlementBreakdownOptions = {}, +): UseSettlementBreakdownResult { + const query = useQuery({ + queryKey: settlementBreakdownKeys.detail(escrowId ?? ''), + queryFn: ({ signal }) => escrowSettlementService.getSettlement(escrowId as string, signal), + enabled: enabled && !!escrowId, + retry: shouldRetry, + refetchInterval: (q) => { + const data = q.state.data; + if (!data || q.state.status === 'error') return false; + return escrowSettlementService.isTerminalStatus(data.status) ? false : pollIntervalMs; + }, + }); + + const settlement = query.data ?? null; + + const derived = useMemo(() => { + if (!settlement) { + return { formatted: null, taxBreakdown: [], explorerUrl: null }; + } + + const { asset } = settlement; + const taxBreakdown: FormattedTaxWithholding[] = settlement.taxWithholdings.map((tax) => ({ + ...tax, + formattedAmount: formatAssetAmount(tax.amount, asset), + formattedRate: formatRate(tax.rate), + })); + const totalTaxWithheld = roundAssetAmount( + settlement.taxWithholdings.reduce((sum, tax) => sum + tax.amount, 0), + ); + + const formatted: FormattedSettlementAmounts = { + totalEscrowAmount: formatAssetAmount(settlement.totalEscrowAmount, asset), + driverPayout: formatAssetAmount(settlement.driverPayout, asset), + platformFee: formatAssetAmount(settlement.platformFee, asset), + platformFeeRate: formatRate(settlement.platformFeeRate), + totalTaxWithheld: formatAssetAmount(totalTaxWithheld, asset), + heldAmount: formatAssetAmount(settlement.heldAmount, asset), + remainingBalance: formatAssetAmount(settlement.remainingBalance, asset), + }; + + const explorerUrl = settlement.transactionHash + ? getStellarExplorerTxUrl(settlement.transactionHash, settlement.network) + : null; + + return { formatted, taxBreakdown, explorerUrl }; + }, [settlement]); + + const isTerminal = settlement ? escrowSettlementService.isTerminalStatus(settlement.status) : false; + + return { + settlement, + ...derived, + isLoading: query.isLoading, + isFetching: query.isFetching, + isFinalizing: !!settlement && !isTerminal && !query.isError, + isTerminal, + error: query.error ? query.error.message : null, + refetch: () => { + void query.refetch(); + }, + }; +} diff --git a/lib/__tests__/settlementFormatters.test.ts b/lib/__tests__/settlementFormatters.test.ts new file mode 100644 index 00000000..2386f0dc --- /dev/null +++ b/lib/__tests__/settlementFormatters.test.ts @@ -0,0 +1,36 @@ +import { + formatAssetAmount, + formatRate, + getStellarExplorerTxUrl, +} 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', + ); + }); + }); +}); diff --git a/lib/settlementFormatters.ts b/lib/settlementFormatters.ts new file mode 100644 index 00000000..c2749d36 --- /dev/null +++ b/lib/settlementFormatters.ts @@ -0,0 +1,27 @@ +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)}`; +} 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; +} From d9129b1179eb9f963dfaf0f6e4aa801336636b07 Mon Sep 17 00:00:00 2001 From: BigManly4 Date: Tue, 29 Sep 2026 06:21:52 -0700 Subject: [PATCH 2/2] feat(escrow): create settlement confirmation success screen with fund breakdown - Add SettlementConfirmation component consuming useSettlementBreakdown - Show amount released to driver, total escrow, platform commission, tax withholdings, held amounts and remaining balance - Link the settlement transaction hash to stellar.expert for the matching network - Show delivery summary (tracking number, route, delivered time) - Handle loading, finalizing, failed and error-with-retry states - Add /escrow/[id]/settlement route rendering the screen --- .../escrow/[id]/settlement/page.tsx | 15 + components/escrow/SettlementConfirmation.tsx | 279 ++++++++++++++++++ .../__tests__/SettlementConfirmation.test.tsx | 168 +++++++++++ lib/__tests__/settlementFormatters.test.ts | 18 ++ lib/settlementFormatters.ts | 18 ++ 5 files changed, 498 insertions(+) create mode 100644 app/(dashboard)/escrow/[id]/settlement/page.tsx create mode 100644 components/escrow/SettlementConfirmation.tsx create mode 100644 components/escrow/__tests__/SettlementConfirmation.test.tsx 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/components/escrow/SettlementConfirmation.tsx b/components/escrow/SettlementConfirmation.tsx new file mode 100644 index 00000000..7ad71122 --- /dev/null +++ b/components/escrow/SettlementConfirmation.tsx @@ -0,0 +1,279 @@ +'use client'; + +import React from 'react'; +import { + AlertCircle, + ArrowRight, + CheckCircle2, + ExternalLink, + Loader2, + MapPin, + Package, + RotateCw, + XCircle, +} from 'lucide-react'; +import { useSettlementBreakdown } from '@/hooks/useSettlementBreakdown'; +import { formatSettlementDate, truncateHash } from '@/lib/settlementFormatters'; + +interface SettlementConfirmationProps { + escrowId: string; + className?: string; +} + +interface BreakdownRowProps { + label: string; + value: string; + hint?: string | null; + emphasis?: 'positive' | 'muted' | 'warning'; +} + +const EMPHASIS_CLASSES: Record, string> = { + positive: 'text-emerald-700', + muted: 'text-gray-700', + warning: 'text-amber-700', +}; + +function BreakdownRow({ label, value, hint, emphasis = 'muted' }: BreakdownRowProps) { + return ( +
+
+ {label} + {hint && {hint}} +
+
+ {value} +
+
+ ); +} + +function LoadingState() { + return ( +
+
+
+
+
+
+ ); +} + +function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) { + return ( +
+
+
+
+ ); +} + +/** + * Success screen shown after escrow release. Displays how the escrowed funds + * were distributed (driver payout, platform fee, withholdings, held amounts), + * the settlement transaction and a summary of the completed delivery. + */ +export function SettlementConfirmation({ escrowId, className = '' }: SettlementConfirmationProps) { + const { settlement, formatted, taxBreakdown, explorerUrl, isLoading, isFinalizing, error, refetch } = + useSettlementBreakdown(escrowId); + + const wrapperClass = `mx-auto w-full max-w-2xl px-4 py-8 sm:px-6 ${className}`.trim(); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!settlement || !formatted) { + return ( +
+ +
+ ); + } + + const isFailed = settlement.status === 'failed'; + const { delivery } = settlement; + + return ( +
+
+
+ {isFailed ? ( +
+

+ {isFailed ? 'Settlement failed' : isFinalizing ? 'Finalizing settlement' : 'Settlement complete'} +

+

+ {isFailed + ? 'The release transaction was not confirmed on the Stellar network.' + : isFinalizing + ? 'Waiting for the release transaction to be confirmed on the Stellar network.' + : `Funds for delivery ${delivery.trackingNumber} have been released.`} +

+
+ + {isFinalizing && ( +

+ This page updates automatically once the transaction is confirmed. +

+ )} + + {error && ( +
+ +
+ )} + +
+

+ {isFailed ? 'Driver payout (not released)' : 'Released to driver'} +

+

+ {formatted.driverPayout} +

+ +

Fund breakdown

+
+ + + + {taxBreakdown.map((tax) => ( + + ))} + {settlement.heldAmount > 0 && ( + + )} + +
+
+ +
+

Transaction

+ {settlement.transactionHash && explorerUrl ? ( +
+ + {truncateHash(settlement.transactionHash, 10)} + + + View on Stellar Explorer + +
+ ) : ( +

Transaction hash will appear once submitted.

+ )} + {(settlement.ledger !== null || settlement.settledAt) && ( +
+ {settlement.ledger !== null && ( +
+
Ledger
+
{settlement.ledger}
+
+ )} + {settlement.settledAt && ( +
+
Settled at
+
{formatSettlementDate(settlement.settledAt)}
+
+ )} +
+ )} +
+ +
+

Delivery summary

+
+
+
+
+
+
+
+ {delivery.origin} + + {delivery.destination} +
+
+ {delivery.deliveredAt && ( +
+
+ )} +
+
+
+ ); +} + +export default SettlementConfirmation; diff --git a/components/escrow/__tests__/SettlementConfirmation.test.tsx b/components/escrow/__tests__/SettlementConfirmation.test.tsx new file mode 100644 index 00000000..206e4cee --- /dev/null +++ b/components/escrow/__tests__/SettlementConfirmation.test.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { SettlementConfirmation } from '@/components/escrow/SettlementConfirmation'; +import { + escrowSettlementService, + SettlementServiceError, +} from '@/services/escrowSettlementService'; +import { + ESCROW_ID, + SETTLEMENT_TX_HASH, + confirmedSettlementResponse, + failedSettlementResponse, + finalizingSettlementResponse, + pendingSettlementResponse, +} from '@/hooks/__tests__/fixtures/settlementApiResponses'; + +jest.mock('@/services/escrowSettlementService', () => { + const actual = jest.requireActual('@/services/escrowSettlementService'); + return { + ...actual, + escrowSettlementService: { + ...actual.escrowSettlementService, + getSettlement: jest.fn(), + }, + }; +}); + +const mockGetSettlement = escrowSettlementService.getSettlement as jest.MockedFunction< + typeof escrowSettlementService.getSettlement +>; + +function renderScreen() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { gcTime: 0, retry: false } }, + }); + return render( + + + , + ); +} + +function breakdownRow(label: RegExp) { + const term = screen.getByText(label).closest('div') as HTMLElement; + return within(term.parentElement as HTMLElement); +} + +describe('SettlementConfirmation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows a loading skeleton while fetching', () => { + mockGetSettlement.mockReturnValue(new Promise(() => {})); + renderScreen(); + + expect(screen.getByTestId('settlement-loading')).toHaveAttribute('aria-busy', 'true'); + }); + + it('renders the success header and amount released to the driver', async () => { + mockGetSettlement.mockResolvedValue(confirmedSettlementResponse); + renderScreen(); + + expect(await screen.findByRole('heading', { name: 'Settlement complete' })).toBeInTheDocument(); + expect(screen.getByTestId('driver-payout')).toHaveTextContent('1,134.375 XLM'); + expect(mockGetSettlement).toHaveBeenCalledWith(ESCROW_ID, expect.any(AbortSignal)); + }); + + it('renders the fund breakdown from the settlement values', async () => { + mockGetSettlement.mockResolvedValue(confirmedSettlementResponse); + renderScreen(); + + await screen.findByRole('heading', { name: 'Settlement complete' }); + + expect(breakdownRow(/Total escrow amount/).getByText('1,250.00 XLM')).toBeInTheDocument(); + expect(breakdownRow(/Platform commission \(2\.5%\)/).getByText('31.25 XLM')).toBeInTheDocument(); + expect(breakdownRow(/VAT on platform fee \(7\.5%\)/).getByText('2.34375 XLM')).toBeInTheDocument(); + expect(breakdownRow(/Withholding tax \(5%\)/).getByText('57.03125 XLM')).toBeInTheDocument(); + expect(breakdownRow(/Remaining balance/).getByText('0.00 XLM')).toBeInTheDocument(); + }); + + it('shows held amounts with the reason', async () => { + mockGetSettlement.mockResolvedValue(confirmedSettlementResponse); + renderScreen(); + + await screen.findByRole('heading', { name: 'Settlement complete' }); + + expect(screen.getByText('Pending / held')).toBeInTheDocument(); + expect(screen.getByText('25.00 XLM')).toBeInTheDocument(); + expect(screen.getByText('Dispute window open for 24 hours')).toBeInTheDocument(); + }); + + it('hides the held row when nothing is held', async () => { + mockGetSettlement.mockResolvedValue({ ...confirmedSettlementResponse, heldAmount: 0, heldReason: null }); + renderScreen(); + + await screen.findByRole('heading', { name: 'Settlement complete' }); + expect(screen.queryByText('Pending / held')).not.toBeInTheDocument(); + }); + + it('links the transaction hash to the Stellar explorer', async () => { + mockGetSettlement.mockResolvedValue(confirmedSettlementResponse); + renderScreen(); + + const link = await screen.findByRole('link', { name: /view on stellar explorer/i }); + expect(link).toHaveAttribute('href', `https://stellar.expert/explorer/testnet/tx/${SETTLEMENT_TX_HASH}`); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + expect(screen.getByTitle(SETTLEMENT_TX_HASH)).toBeInTheDocument(); + expect(screen.getByText('51234567')).toBeInTheDocument(); + }); + + it('renders the delivery summary', async () => { + mockGetSettlement.mockResolvedValue(confirmedSettlementResponse); + renderScreen(); + + await screen.findByRole('heading', { name: 'Settlement complete' }); + + expect(screen.getByText('SWC-2026-004821')).toBeInTheDocument(); + expect(screen.getByText('Lagos, NG')).toBeInTheDocument(); + expect(screen.getByText('Accra, GH')).toBeInTheDocument(); + expect(screen.getByText('Sep 28, 2026, 1:58 PM UTC')).toBeInTheDocument(); + }); + + it('shows a finalizing notice while the transaction is confirming', async () => { + mockGetSettlement.mockResolvedValue(finalizingSettlementResponse); + renderScreen(); + + expect(await screen.findByRole('heading', { name: 'Finalizing settlement' })).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent(/updates automatically/i); + }); + + it('explains the missing hash before the transaction is submitted', async () => { + mockGetSettlement.mockResolvedValue(pendingSettlementResponse); + renderScreen(); + + await screen.findByRole('heading', { name: 'Finalizing settlement' }); + expect(screen.queryByRole('link', { name: /view on stellar explorer/i })).not.toBeInTheDocument(); + expect(screen.getByText(/transaction hash will appear once submitted/i)).toBeInTheDocument(); + }); + + it('renders the failed state', async () => { + mockGetSettlement.mockResolvedValue(failedSettlementResponse); + renderScreen(); + + expect(await screen.findByRole('heading', { name: 'Settlement failed' })).toBeInTheDocument(); + expect(screen.getByText('Driver payout (not released)')).toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('shows an error with retry when the API fails', async () => { + mockGetSettlement + .mockRejectedValueOnce(new SettlementServiceError('Settlement not found for this escrow.', 404)) + .mockResolvedValue(confirmedSettlementResponse); + renderScreen(); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent('Settlement not found for this escrow.'); + + await userEvent.click(within(alert).getByRole('button', { name: /try again/i })); + + await waitFor(() => + expect(screen.getByRole('heading', { name: 'Settlement complete' })).toBeInTheDocument(), + ); + }); +}); diff --git a/lib/__tests__/settlementFormatters.test.ts b/lib/__tests__/settlementFormatters.test.ts index 2386f0dc..66b2e59f 100644 --- a/lib/__tests__/settlementFormatters.test.ts +++ b/lib/__tests__/settlementFormatters.test.ts @@ -1,7 +1,9 @@ import { formatAssetAmount, formatRate, + formatSettlementDate, getStellarExplorerTxUrl, + truncateHash, } from '@/lib/settlementFormatters'; describe('settlementFormatters', () => { @@ -33,4 +35,20 @@ describe('settlementFormatters', () => { ); }); }); + + 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 index c2749d36..e180f78a 100644 --- a/lib/settlementFormatters.ts +++ b/lib/settlementFormatters.ts @@ -25,3 +25,21 @@ export function formatRate(rate: number): string { 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)); +}