diff --git a/components/escrow/EscrowInsuranceCheckout.tsx b/components/escrow/EscrowInsuranceCheckout.tsx new file mode 100644 index 0000000..71b14b4 --- /dev/null +++ b/components/escrow/EscrowInsuranceCheckout.tsx @@ -0,0 +1,332 @@ +'use client'; + +import React from 'react'; +import { AlertCircle, Check, Loader, Shield, ShieldOff } from 'lucide-react'; +import { useEscrowCheckout } from '@/hooks/useEscrowCheckout'; +import { formatXlm } from '@/services/insuranceService'; +import type { EscrowCheckoutReceipt, InsurancePlan } from '@/types/insurance'; + +const NO_COVERAGE = 'none'; + +export interface EscrowInsuranceCheckoutProps { + shipmentId: string; + /** Freight cost in XLM, before any insurance premium */ + shipmentXlm: number; + /** Connected wallet address, or undefined while disconnected */ + walletAddress?: string; + onComplete?: (receipt: EscrowCheckoutReceipt) => void; +} + +/** + * EscrowInsuranceCheckout — cargo insurance selection followed by the XLM + * escrow payment that funds the shipment. + * + * Step 1 offers the available coverage (or none at all), step 2 reviews the + * priced breakdown, and step 3 confirms the on-chain receipt. + */ +export function EscrowInsuranceCheckout({ + shipmentId, + shipmentXlm, + walletAddress, + onComplete, +}: EscrowInsuranceCheckoutProps) { + const { + step, + plans, + isLoadingPlans, + plansError, + selectedPlan, + quote, + isPaying, + paymentError, + receipt, + selectPlan, + loadPlans, + goToReview, + goBackToInsurance, + confirmPayment, + } = useEscrowCheckout({ shipmentId, shipmentXlm, walletAddress }); + + const isWalletConnected = !!walletAddress; + + const handleConfirm = async () => { + await confirmPayment(); + }; + + // Notify once per settled payment. Keeping the receipt in a ref means an + // inline onComplete prop cannot re-fire the callback on every re-render. + const notifiedReceipt = React.useRef(null); + + React.useEffect(() => { + if (receipt && notifiedReceipt.current !== receipt) { + notifiedReceipt.current = receipt; + onComplete?.(receipt); + } + }, [receipt, onComplete]); + + if (step === 'complete' && receipt) { + return ( +
+
+
+
+

+ Escrow funded +

+

+ {formatXlm(receipt.lockedXlm)} is locked until delivery is confirmed. +

+
+
+
Escrow ID
+
+ {receipt.escrowId} +
+
+
+
Transaction hash
+
+ {receipt.transactionHash} +
+
+ {receipt.insurancePolicyId && ( +
+
Insurance policy
+
+ {receipt.insurancePolicyId} +
+
+ )} +
+
+
+ ); + } + + if (step === 'review' && quote) { + return ( +
+

+ Review and pay +

+ +
+
+
Shipment
+
+ {formatXlm(quote.shipmentXlm)} +
+
+
+
+ {quote.plan ? `Insurance (${quote.plan.name})` : 'Insurance (declined)'} +
+
+ {formatXlm(quote.premiumXlm)} +
+
+
+
Total locked in escrow
+
+ {formatXlm(quote.totalXlm)} +
+
+
+ + {!isWalletConnected && ( +

+ Connect your wallet to fund this escrow. +

+ )} + + {paymentError && ( +

+

+ )} + +
+ + +
+
+ ); + } + + return ( +
+

+ Cargo insurance +

+

+ Coverage is optional. Any premium is added to the escrow total. +

+ + {isLoadingPlans && ( +

+

+ )} + + {!isLoadingPlans && plansError && ( +
+

+

+ +
+ )} + + {!isLoadingPlans && !plansError && plans.length === 0 && ( +

+ No insurance plans are available for this shipment. You can continue without + coverage. +

+ )} + + {!isLoadingPlans && !plansError && ( +
+ Choose cargo coverage +
+ {plans.map((plan) => ( + selectPlan(plan.id)} + /> + ))} + + +
+
+ )} + + {!isLoadingPlans && !plansError && ( + + )} +
+ ); +} + +interface PlanOptionProps { + plan: InsurancePlan; + checked: boolean; + onSelect: () => void; +} + +function PlanOption({ plan, checked, onSelect }: PlanOptionProps) { + return ( + + ); +} + +export default EscrowInsuranceCheckout; diff --git a/components/escrow/__tests__/EscrowInsuranceCheckout.test.tsx b/components/escrow/__tests__/EscrowInsuranceCheckout.test.tsx new file mode 100644 index 0000000..f1d1a02 --- /dev/null +++ b/components/escrow/__tests__/EscrowInsuranceCheckout.test.tsx @@ -0,0 +1,440 @@ +import React from 'react'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { EscrowInsuranceCheckout } from '@/components/escrow/EscrowInsuranceCheckout'; +import { insuranceService } from '@/services/insuranceService'; +import type { EscrowCheckoutReceipt, InsurancePlan } from '@/types/insurance'; + +/** + * Only the two network-bound members are mocked. buildCheckoutQuote and + * formatXlm stay real so the totals asserted here are the ones the production + * pricing logic produces. + */ +jest.mock('@/services/insuranceService', () => { + const actual = jest.requireActual('@/services/insuranceService'); + return { + ...actual, + insuranceService: { + ...actual.insuranceService, + getPlans: jest.fn(), + payWithEscrow: jest.fn(), + }, + }; +}); + +const mockGetPlans = insuranceService.getPlans as jest.MockedFunction< + typeof insuranceService.getPlans +>; +const mockPayWithEscrow = insuranceService.payWithEscrow as jest.MockedFunction< + typeof insuranceService.payWithEscrow +>; + +const PLANS: InsurancePlan[] = [ + { + id: 'plan-basic', + name: 'Basic Cover', + description: 'Covers loss in transit.', + coverageXlm: 1000, + premiumXlm: 12.5, + }, + { + id: 'plan-premium', + name: 'Premium Cover', + description: 'Covers loss, damage and delay.', + coverageXlm: 5000, + premiumXlm: 40.25, + deductibleXlm: 25, + recommended: true, + }, +]; + +const RECEIPT: EscrowCheckoutReceipt = { + escrowId: 'escrow-9001', + transactionHash: '3f8a1c2d4e5b6a7c8d9e0f1a2b3c4d5e', + lockedXlm: 290.25, + insurancePolicyId: 'policy-77', +}; + +const SHIPMENT_XLM = 250; + +const WALLET = 'GBTESTWALLETADDRESS000000000000000000000000000000000000'; + +function renderCheckout(props: Partial> = {}) { + return render( + + ); +} + +/** Wait for the plan list to replace the loading indicator. */ +async function awaitPlansLoaded() { + await waitFor(() => + expect(screen.queryByText('Loading insurance plans')).not.toBeInTheDocument() + ); +} + +describe('EscrowInsuranceCheckout — escrow and cargo insurance flow', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetPlans.mockResolvedValue(PLANS); + mockPayWithEscrow.mockResolvedValue(RECEIPT); + }); + + describe('insurance step', () => { + it('shows a loading indicator while plans are being fetched', () => { + mockGetPlans.mockReturnValue(new Promise(() => {})); + + renderCheckout(); + + expect(screen.getByRole('status')).toHaveTextContent('Loading insurance plans'); + }); + + it('requests plans for the shipment being funded', async () => { + renderCheckout({ shipmentId: 'shipment-42' }); + + await awaitPlansLoaded(); + + expect(mockGetPlans).toHaveBeenCalledWith('shipment-42'); + expect(mockGetPlans).toHaveBeenCalledTimes(1); + }); + + it('lists every available plan with its coverage and premium', async () => { + renderCheckout(); + await awaitPlansLoaded(); + + expect(screen.getByRole('radio', { name: /Basic Cover/ })).toBeInTheDocument(); + expect( + screen.getByRole('radio', { name: /Covers up to 1000 XLM for 12.5 XLM/ }) + ).toBeInTheDocument(); + expect( + screen.getByRole('radio', { name: /25 XLM deductible/ }) + ).toBeInTheDocument(); + expect(screen.getByText('Recommended')).toBeInTheDocument(); + }); + + it('defaults to declining coverage', async () => { + renderCheckout(); + await awaitPlansLoaded(); + + expect( + screen.getByRole('radio', { name: /Continue without coverage/ }) + ).toBeChecked(); + expect(screen.getByRole('radio', { name: /Basic Cover/ })).not.toBeChecked(); + }); + + it('selects a plan and deselects the previous choice', async () => { + const user = userEvent.setup(); + renderCheckout(); + await awaitPlansLoaded(); + + await user.click(screen.getByRole('radio', { name: /Basic Cover/ })); + expect(screen.getByRole('radio', { name: /Basic Cover/ })).toBeChecked(); + + await user.click(screen.getByRole('radio', { name: /Premium Cover/ })); + expect(screen.getByRole('radio', { name: /Premium Cover/ })).toBeChecked(); + expect(screen.getByRole('radio', { name: /Basic Cover/ })).not.toBeChecked(); + }); + }); + + describe('happy path', () => { + it('completes the full flow: select insurance, review, pay, confirm', async () => { + const user = userEvent.setup(); + const onComplete = jest.fn(); + renderCheckout({ onComplete }); + await awaitPlansLoaded(); + + // Step 1 — choose coverage + await user.click(screen.getByRole('radio', { name: /Premium Cover/ })); + await user.click(screen.getByRole('button', { name: 'Continue to payment' })); + + // Step 2 — the premium is added to the escrow total + expect( + screen.getByRole('heading', { name: 'Review and pay' }) + ).toBeInTheDocument(); + expect(screen.getByTestId('summary-shipment')).toHaveTextContent('250 XLM'); + expect(screen.getByTestId('summary-premium')).toHaveTextContent('40.25 XLM'); + expect(screen.getByTestId('summary-total')).toHaveTextContent('290.25 XLM'); + + // Step 3 — pay + await user.click(screen.getByRole('button', { name: 'Pay 290.25 XLM' })); + + expect(mockPayWithEscrow).toHaveBeenCalledWith({ + shipmentId: 'shipment-1', + walletAddress: WALLET, + insurancePlanId: 'plan-premium', + totalXlm: 290.25, + }); + + const heading = await screen.findByRole('heading', { name: 'Escrow funded' }); + const receiptSection = heading.closest('section') as HTMLElement; + + expect( + within(receiptSection).getByText('290.25 XLM is locked until delivery is confirmed.') + ).toBeInTheDocument(); + expect(within(receiptSection).getByText('escrow-9001')).toBeInTheDocument(); + expect( + within(receiptSection).getByText('3f8a1c2d4e5b6a7c8d9e0f1a2b3c4d5e') + ).toBeInTheDocument(); + expect(within(receiptSection).getByText('policy-77')).toBeInTheDocument(); + + await waitFor(() => expect(onComplete).toHaveBeenCalledWith(RECEIPT)); + }); + + it('completes checkout without coverage and charges no premium', async () => { + const user = userEvent.setup(); + mockPayWithEscrow.mockResolvedValue({ + escrowId: 'escrow-1', + transactionHash: 'abc123', + lockedXlm: SHIPMENT_XLM, + }); + + renderCheckout(); + await awaitPlansLoaded(); + + await user.click(screen.getByRole('button', { name: 'Continue to payment' })); + + expect(screen.getByTestId('summary-premium')).toHaveTextContent('0 XLM'); + expect(screen.getByTestId('summary-total')).toHaveTextContent('250 XLM'); + expect(screen.getByText('Insurance (declined)')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Pay 250 XLM' })); + + expect(mockPayWithEscrow).toHaveBeenCalledWith( + expect.objectContaining({ insurancePlanId: null, totalXlm: 250 }) + ); + await screen.findByRole('heading', { name: 'Escrow funded' }); + }); + + it('omits the policy reference when no coverage was purchased', async () => { + const user = userEvent.setup(); + mockPayWithEscrow.mockResolvedValue({ + escrowId: 'escrow-1', + transactionHash: 'abc123', + lockedXlm: SHIPMENT_XLM, + }); + + renderCheckout(); + await awaitPlansLoaded(); + await user.click(screen.getByRole('button', { name: 'Continue to payment' })); + await user.click(screen.getByRole('button', { name: 'Pay 250 XLM' })); + + await screen.findByRole('heading', { name: 'Escrow funded' }); + expect(screen.queryByText('Insurance policy')).not.toBeInTheDocument(); + }); + + it('recalculates the total when the shipper changes plan before paying', async () => { + const user = userEvent.setup(); + renderCheckout(); + await awaitPlansLoaded(); + + await user.click(screen.getByRole('radio', { name: /Premium Cover/ })); + await user.click(screen.getByRole('button', { name: 'Continue to payment' })); + expect(screen.getByTestId('summary-total')).toHaveTextContent('290.25 XLM'); + + await user.click(screen.getByRole('button', { name: 'Back' })); + await user.click(screen.getByRole('radio', { name: /Basic Cover/ })); + await user.click(screen.getByRole('button', { name: 'Continue to payment' })); + + expect(screen.getByTestId('summary-total')).toHaveTextContent('262.5 XLM'); + + await user.click(screen.getByRole('button', { name: 'Pay 262.5 XLM' })); + + expect(mockPayWithEscrow).toHaveBeenCalledWith( + expect.objectContaining({ insurancePlanId: 'plan-basic', totalXlm: 262.5 }) + ); + }); + + it('rounds the escrow total to stroop precision', async () => { + const user = userEvent.setup(); + mockGetPlans.mockResolvedValue([ + { ...PLANS[0], premiumXlm: 0.2 }, + ]); + + renderCheckout({ shipmentXlm: 0.1 }); + await awaitPlansLoaded(); + + await user.click(screen.getByRole('radio', { name: /Basic Cover/ })); + await user.click(screen.getByRole('button', { name: 'Continue to payment' })); + + // 0.1 + 0.2 must not surface as 0.30000000000000004 + expect(screen.getByTestId('summary-total')).toHaveTextContent('0.3 XLM'); + }); + }); + + describe('empty and disconnected states', () => { + it('lets the shipper continue when no plans are offered', async () => { + const user = userEvent.setup(); + mockGetPlans.mockResolvedValue([]); + mockPayWithEscrow.mockResolvedValue({ + escrowId: 'escrow-1', + transactionHash: 'abc123', + lockedXlm: SHIPMENT_XLM, + }); + + renderCheckout(); + await awaitPlansLoaded(); + + expect( + screen.getByText(/No insurance plans are available for this shipment/) + ).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Continue to payment' })); + await user.click(screen.getByRole('button', { name: 'Pay 250 XLM' })); + + await screen.findByRole('heading', { name: 'Escrow funded' }); + }); + + it('warns and blocks payment when no wallet is connected', async () => { + const user = userEvent.setup(); + renderCheckout({ walletAddress: undefined }); + await awaitPlansLoaded(); + + await user.click(screen.getByRole('button', { name: 'Continue to payment' })); + + expect(screen.getByRole('alert')).toHaveTextContent( + 'Connect your wallet to fund this escrow.' + ); + expect(screen.getByRole('button', { name: 'Pay 250 XLM' })).toBeDisabled(); + expect(mockPayWithEscrow).not.toHaveBeenCalled(); + }); + + it('disables the continue button when the shipment amount is invalid', async () => { + renderCheckout({ shipmentXlm: 0 }); + await awaitPlansLoaded(); + + expect(screen.getByRole('button', { name: 'Continue to payment' })).toBeDisabled(); + }); + }); + + describe('error handling', () => { + it('surfaces a plan lookup failure and recovers on retry', async () => { + const user = userEvent.setup(); + mockGetPlans.mockRejectedValueOnce(new Error('Insurance service unavailable')); + + renderCheckout(); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent('Insurance service unavailable'); + expect(screen.queryByRole('radio')).not.toBeInTheDocument(); + + mockGetPlans.mockResolvedValueOnce(PLANS); + await user.click(screen.getByRole('button', { name: 'Retry' })); + + expect( + await screen.findByRole('radio', { name: /Basic Cover/ }) + ).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('shows the payment error and keeps the shipper on the review step', async () => { + const user = userEvent.setup(); + mockPayWithEscrow.mockRejectedValue(new Error('Insufficient XLM balance')); + + renderCheckout(); + await awaitPlansLoaded(); + + await user.click(screen.getByRole('radio', { name: /Basic Cover/ })); + await user.click(screen.getByRole('button', { name: 'Continue to payment' })); + await user.click(screen.getByRole('button', { name: 'Pay 262.5 XLM' })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Insufficient XLM balance' + ); + expect(screen.getByRole('heading', { name: 'Review and pay' })).toBeInTheDocument(); + expect( + screen.queryByRole('heading', { name: 'Escrow funded' }) + ).not.toBeInTheDocument(); + }); + + it('allows a retry after a rejected payment', async () => { + const user = userEvent.setup(); + mockPayWithEscrow + .mockRejectedValueOnce(new Error('User declined the transaction')) + .mockResolvedValueOnce(RECEIPT); + + renderCheckout(); + await awaitPlansLoaded(); + + await user.click(screen.getByRole('radio', { name: /Premium Cover/ })); + await user.click(screen.getByRole('button', { name: 'Continue to payment' })); + await user.click(screen.getByRole('button', { name: 'Pay 290.25 XLM' })); + + await screen.findByRole('alert'); + + await user.click(screen.getByRole('button', { name: 'Pay 290.25 XLM' })); + + await screen.findByRole('heading', { name: 'Escrow funded' }); + expect(mockPayWithEscrow).toHaveBeenCalledTimes(2); + }); + + it('clears a payment error when the shipper goes back to change coverage', async () => { + const user = userEvent.setup(); + mockPayWithEscrow.mockRejectedValue(new Error('Network error')); + + renderCheckout(); + await awaitPlansLoaded(); + await user.click(screen.getByRole('button', { name: 'Continue to payment' })); + await user.click(screen.getByRole('button', { name: 'Pay 250 XLM' })); + await screen.findByRole('alert'); + + await user.click(screen.getByRole('button', { name: 'Back' })); + await user.click(screen.getByRole('button', { name: 'Continue to payment' })); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('disables both actions and shows progress while the payment settles', async () => { + const user = userEvent.setup(); + let settle: (receipt: EscrowCheckoutReceipt) => void = () => {}; + mockPayWithEscrow.mockReturnValue( + new Promise((resolve) => { + settle = resolve; + }) + ); + + renderCheckout(); + await awaitPlansLoaded(); + await user.click(screen.getByRole('button', { name: 'Continue to payment' })); + await user.click(screen.getByRole('button', { name: 'Pay 250 XLM' })); + + const payButton = await screen.findByRole('button', { name: /Processing payment/ }); + expect(payButton).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled(); + + settle({ + escrowId: 'escrow-1', + transactionHash: 'abc123', + lockedXlm: SHIPMENT_XLM, + }); + + await screen.findByRole('heading', { name: 'Escrow funded' }); + }); + + it('does not submit the payment twice on a double click', async () => { + const user = userEvent.setup(); + let settle: (receipt: EscrowCheckoutReceipt) => void = () => {}; + mockPayWithEscrow.mockReturnValue( + new Promise((resolve) => { + settle = resolve; + }) + ); + + renderCheckout(); + await awaitPlansLoaded(); + await user.click(screen.getByRole('button', { name: 'Continue to payment' })); + + const payButton = screen.getByRole('button', { name: 'Pay 250 XLM' }); + await user.click(payButton); + await user.click(payButton); + + expect(mockPayWithEscrow).toHaveBeenCalledTimes(1); + + settle(RECEIPT); + await screen.findByRole('heading', { name: 'Escrow funded' }); + }); + }); +}); diff --git a/hooks/useEscrowCheckout.ts b/hooks/useEscrowCheckout.ts new file mode 100644 index 0000000..f8faf5e --- /dev/null +++ b/hooks/useEscrowCheckout.ts @@ -0,0 +1,160 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { insuranceService } from '@/services/insuranceService'; +import type { + EscrowCheckoutQuote, + EscrowCheckoutReceipt, + InsurancePlan, +} from '@/types/insurance'; + +/** The three steps a shipper moves through to fund a shipment. */ +export type CheckoutStep = 'insurance' | 'review' | 'complete'; + +export interface UseEscrowCheckoutParams { + shipmentId: string; + /** Freight cost in XLM, before any insurance premium */ + shipmentXlm: number; + /** Connected wallet address, or undefined while disconnected */ + walletAddress?: string; +} + +export interface UseEscrowCheckoutReturn { + step: CheckoutStep; + plans: InsurancePlan[]; + isLoadingPlans: boolean; + plansError: string | null; + selectedPlan: InsurancePlan | null; + quote: EscrowCheckoutQuote | null; + isPaying: boolean; + paymentError: string | null; + receipt: EscrowCheckoutReceipt | null; + selectPlan: (planId: string | null) => void; + loadPlans: () => Promise; + goToReview: () => void; + goBackToInsurance: () => void; + confirmPayment: () => Promise; +} + +/** + * useEscrowCheckout — drives the cargo insurance selection and the XLM escrow + * payment that concludes checkout. + * + * Follows the Component -> Hook -> Service pattern: the component owns + * presentation, this hook owns step and request state, and insuranceService + * owns every network call. + */ +export function useEscrowCheckout({ + shipmentId, + shipmentXlm, + walletAddress, +}: UseEscrowCheckoutParams): UseEscrowCheckoutReturn { + const [step, setStep] = useState('insurance'); + const [plans, setPlans] = useState([]); + const [isLoadingPlans, setIsLoadingPlans] = useState(true); + const [plansError, setPlansError] = useState(null); + const [selectedPlanId, setSelectedPlanId] = useState(null); + const [isPaying, setIsPaying] = useState(false); + const [paymentError, setPaymentError] = useState(null); + const [receipt, setReceipt] = useState(null); + + const loadPlans = useCallback(async () => { + setIsLoadingPlans(true); + setPlansError(null); + + try { + const available = await insuranceService.getPlans(shipmentId); + setPlans(available); + } catch (err) { + setPlans([]); + setPlansError( + err instanceof Error ? err.message : 'Failed to load insurance plans' + ); + } finally { + setIsLoadingPlans(false); + } + }, [shipmentId]); + + useEffect(() => { + // Fetching on mount is the point of this effect; loadPlans owns its own + // loading and error state, matching the pattern used across the hooks here. + // eslint-disable-next-line react-hooks/set-state-in-effect + void loadPlans(); + }, [loadPlans]); + + const selectedPlan = useMemo( + () => plans.find((plan) => plan.id === selectedPlanId) ?? null, + [plans, selectedPlanId] + ); + + const quote = useMemo(() => { + try { + return insuranceService.buildCheckoutQuote(shipmentXlm, selectedPlan); + } catch { + return null; + } + }, [shipmentXlm, selectedPlan]); + + /** Choose a plan, or pass null to decline coverage. */ + const selectPlan = useCallback((planId: string | null) => { + setSelectedPlanId(planId); + }, []); + + const goToReview = useCallback(() => { + setPaymentError(null); + setStep('review'); + }, []); + + const goBackToInsurance = useCallback(() => { + setPaymentError(null); + setStep('insurance'); + }, []); + + const confirmPayment = useCallback(async () => { + if (!walletAddress) { + setPaymentError('Connect your wallet to fund this escrow.'); + return; + } + + if (!quote) { + setPaymentError('Escrow amount must be a positive number'); + return; + } + + setIsPaying(true); + setPaymentError(null); + + try { + const result = await insuranceService.payWithEscrow({ + shipmentId, + walletAddress, + insurancePlanId: selectedPlan?.id ?? null, + totalXlm: quote.totalXlm, + }); + + setReceipt(result); + setStep('complete'); + } catch (err) { + setPaymentError(err instanceof Error ? err.message : 'Escrow payment failed'); + } finally { + setIsPaying(false); + } + }, [quote, selectedPlan, shipmentId, walletAddress]); + + return { + step, + plans, + isLoadingPlans, + plansError, + selectedPlan, + quote, + isPaying, + paymentError, + receipt, + selectPlan, + loadPlans, + goToReview, + goBackToInsurance, + confirmPayment, + }; +} diff --git a/services/__tests__/insuranceService.test.ts b/services/__tests__/insuranceService.test.ts new file mode 100644 index 0000000..707ee66 --- /dev/null +++ b/services/__tests__/insuranceService.test.ts @@ -0,0 +1,222 @@ +import axios from 'axios'; +import { + buildCheckoutQuote, + formatXlm, + insuranceService, + toStroopPrecision, +} from '@/services/insuranceService'; +import type { InsurancePlan } from '@/types/insurance'; + +jest.mock('axios'); + +const mockAxios = axios as jest.Mocked; + +const PLAN: InsurancePlan = { + id: 'plan-basic', + name: 'Basic Cover', + description: 'Covers loss in transit.', + coverageXlm: 1000, + premiumXlm: 12.5, +}; + +describe('insuranceService', () => { + beforeEach(() => { + jest.clearAllMocks(); + // axios.isAxiosError is a type guard, not a request method, so it is not + // auto-mocked into something useful — restore the real behaviour. + (mockAxios.isAxiosError as unknown as jest.Mock).mockImplementation( + (error: unknown) => !!(error as { isAxiosError?: boolean })?.isAxiosError + ); + }); + + describe('toStroopPrecision', () => { + it('rounds to seven decimal places', () => { + expect(toStroopPrecision(0.1 + 0.2)).toBe(0.3); + expect(toStroopPrecision(1.123456789)).toBe(1.1234568); + }); + + it('leaves exact amounts untouched', () => { + expect(toStroopPrecision(250)).toBe(250); + }); + }); + + describe('formatXlm', () => { + it('appends the asset code and trims trailing zeros', () => { + expect(formatXlm(250)).toBe('250 XLM'); + expect(formatXlm(12.5)).toBe('12.5 XLM'); + expect(formatXlm(0)).toBe('0 XLM'); + }); + }); + + describe('buildCheckoutQuote', () => { + it('adds the premium of the selected plan to the total', () => { + expect(buildCheckoutQuote(250, PLAN)).toEqual({ + shipmentXlm: 250, + premiumXlm: 12.5, + totalXlm: 262.5, + plan: PLAN, + }); + }); + + it('charges no premium when coverage is declined', () => { + expect(buildCheckoutQuote(250, null)).toEqual({ + shipmentXlm: 250, + premiumXlm: 0, + totalXlm: 250, + plan: null, + }); + }); + + it('rounds the total to stroop precision', () => { + expect(buildCheckoutQuote(0.1, { ...PLAN, premiumXlm: 0.2 }).totalXlm).toBe(0.3); + }); + + it.each([0, -1, NaN, Infinity])('throws for a shipment amount of %p', (amount) => { + expect(() => buildCheckoutQuote(amount, null)).toThrow( + 'Shipment amount must be a positive number' + ); + }); + }); + + describe('getPlans', () => { + it('returns the plans available for a shipment', async () => { + mockAxios.get.mockResolvedValue({ data: { success: true, data: [PLAN] } }); + + await expect(insuranceService.getPlans('shipment-1')).resolves.toEqual([PLAN]); + expect(mockAxios.get).toHaveBeenCalledWith( + '/api/shipments/shipment-1/insurance-plans' + ); + }); + + it('returns an empty list when no coverage is offered', async () => { + mockAxios.get.mockResolvedValue({ data: { success: true, data: [] } }); + + await expect(insuranceService.getPlans('shipment-1')).resolves.toEqual([]); + }); + + it('rejects a missing shipment id without calling the API', async () => { + await expect(insuranceService.getPlans('')).rejects.toThrow( + 'Shipment ID is required' + ); + expect(mockAxios.get).not.toHaveBeenCalled(); + }); + + it('surfaces an unsuccessful API response', async () => { + mockAxios.get.mockResolvedValue({ + data: { success: false, error: 'Shipment not insurable' }, + }); + + await expect(insuranceService.getPlans('shipment-1')).rejects.toThrow( + 'Shipment not insurable' + ); + }); + + it('surfaces the API error body on a request failure', async () => { + mockAxios.get.mockRejectedValue({ + isAxiosError: true, + response: { data: { error: 'Insurance service unavailable' } }, + }); + + await expect(insuranceService.getPlans('shipment-1')).rejects.toThrow( + 'Insurance service unavailable' + ); + }); + + it('falls back to a generic message for an unrecognised failure', async () => { + mockAxios.get.mockRejectedValue({ isAxiosError: true, response: undefined }); + + await expect(insuranceService.getPlans('shipment-1')).rejects.toThrow( + 'Failed to load insurance plans' + ); + }); + }); + + describe('payWithEscrow', () => { + const params = { + shipmentId: 'shipment-1', + walletAddress: 'GBWALLET', + insurancePlanId: 'plan-basic', + totalXlm: 262.5, + }; + + const receipt = { + escrowId: 'escrow-1', + transactionHash: 'hash-1', + lockedXlm: 262.5, + insurancePolicyId: 'policy-1', + }; + + it('posts the checkout and returns the receipt', async () => { + mockAxios.post.mockResolvedValue({ data: { success: true, data: receipt } }); + + await expect(insuranceService.payWithEscrow(params)).resolves.toEqual(receipt); + expect(mockAxios.post).toHaveBeenCalledWith('/api/escrow/checkout', params); + }); + + it('rounds the submitted amount to stroop precision', async () => { + mockAxios.post.mockResolvedValue({ data: { success: true, data: receipt } }); + + await insuranceService.payWithEscrow({ ...params, totalXlm: 0.1 + 0.2 }); + + expect(mockAxios.post).toHaveBeenCalledWith( + '/api/escrow/checkout', + expect.objectContaining({ totalXlm: 0.3 }) + ); + }); + + it('sends a null plan id when coverage was declined', async () => { + mockAxios.post.mockResolvedValue({ data: { success: true, data: receipt } }); + + await insuranceService.payWithEscrow({ ...params, insurancePlanId: null }); + + expect(mockAxios.post).toHaveBeenCalledWith( + '/api/escrow/checkout', + expect.objectContaining({ insurancePlanId: null }) + ); + }); + + it.each([ + ['shipment id', { shipmentId: '' }, 'Shipment ID is required'], + [ + 'wallet address', + { walletAddress: '' }, + 'A connected wallet is required to fund the escrow', + ], + ['amount', { totalXlm: 0 }, 'Escrow amount must be a positive number'], + ])('rejects a missing %s without calling the API', async (_label, override, message) => { + await expect( + insuranceService.payWithEscrow({ ...params, ...override }) + ).rejects.toThrow(message as string); + expect(mockAxios.post).not.toHaveBeenCalled(); + }); + + it('surfaces an unsuccessful API response', async () => { + mockAxios.post.mockResolvedValue({ + data: { success: false, error: 'Insufficient XLM balance' }, + }); + + await expect(insuranceService.payWithEscrow(params)).rejects.toThrow( + 'Insufficient XLM balance' + ); + }); + + it('surfaces a rejected wallet signature', async () => { + mockAxios.post.mockRejectedValue({ + isAxiosError: true, + response: { data: { error: 'User declined the transaction' } }, + }); + + await expect(insuranceService.payWithEscrow(params)).rejects.toThrow( + 'User declined the transaction' + ); + }); + + it('falls back to a generic message for a non-axios failure', async () => { + mockAxios.post.mockRejectedValue('boom'); + + await expect(insuranceService.payWithEscrow(params)).rejects.toThrow( + 'Escrow payment failed' + ); + }); + }); +}); diff --git a/services/insuranceService.ts b/services/insuranceService.ts new file mode 100644 index 0000000..37866c2 --- /dev/null +++ b/services/insuranceService.ts @@ -0,0 +1,140 @@ +/** + * Insurance Service + * API communication for cargo insurance plans and the escrow payment that + * settles a shipment together with any coverage the shipper selected. + * + * Components never call this directly — they go through useEscrowCheckout. + */ + +import axios from 'axios'; +import type { + EscrowCheckoutParams, + EscrowCheckoutQuote, + EscrowCheckoutReceipt, + EscrowCheckoutResult, + InsurancePlan, + InsurancePlansResult, +} from '@/types/insurance'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? ''; + +/** XLM is divisible to seven decimal places (one stroop). */ +const STROOP_PRECISION = 7; + +/** + * Round an XLM amount to stroop precision so accumulated floating point error + * never reaches the displayed total or the amount sent on chain. + */ +export function toStroopPrecision(amount: number): number { + return Number(amount.toFixed(STROOP_PRECISION)); +} + +/** + * Format an XLM amount for display, trimming trailing zeros. + */ +export function formatXlm(amount: number): string { + return `${toStroopPrecision(amount)} XLM`; +} + +/** + * Build the priced breakdown for a checkout. + * Pure function — the totals shown to the shipper and the amount submitted to + * the escrow contract are derived from this single source. + * + * @param shipmentXlm - Freight cost in XLM + * @param plan - Selected coverage, or null when declined + * @throws Error when the shipment cost is not a positive finite number + */ +export function buildCheckoutQuote( + shipmentXlm: number, + plan: InsurancePlan | null +): EscrowCheckoutQuote { + if (!Number.isFinite(shipmentXlm) || shipmentXlm <= 0) { + throw new Error('Shipment amount must be a positive number'); + } + + const premiumXlm = plan ? toStroopPrecision(plan.premiumXlm) : 0; + + return { + shipmentXlm: toStroopPrecision(shipmentXlm), + premiumXlm, + totalXlm: toStroopPrecision(shipmentXlm + premiumXlm), + plan, + }; +} + +function messageFor(error: unknown, fallback: string): string { + if (axios.isAxiosError(error)) { + return error.response?.data?.error ?? error.message ?? fallback; + } + return error instanceof Error ? error.message : fallback; +} + +/** + * Fetch the cargo insurance plans available for a shipment. + * + * @param shipmentId - Shipment the quote applies to + * @throws Error when the shipment id is missing or the request fails + */ +async function getPlans(shipmentId: string): Promise { + if (!shipmentId) { + throw new Error('Shipment ID is required'); + } + + try { + const { data } = await axios.get( + `${API_BASE_URL}/api/shipments/${shipmentId}/insurance-plans` + ); + + if (!data.success) { + throw new Error(data.error); + } + + return data.data; + } catch (error) { + throw new Error(messageFor(error, 'Failed to load insurance plans')); + } +} + +/** + * Lock the escrow payment for a shipment, including any selected coverage. + * + * @param params - Shipment, wallet, chosen plan and total to lock + * @throws Error when the payload is incomplete or the payment fails + */ +async function payWithEscrow( + params: EscrowCheckoutParams +): Promise { + if (!params.shipmentId) { + throw new Error('Shipment ID is required'); + } + + if (!params.walletAddress) { + throw new Error('A connected wallet is required to fund the escrow'); + } + + if (!Number.isFinite(params.totalXlm) || params.totalXlm <= 0) { + throw new Error('Escrow amount must be a positive number'); + } + + try { + const { data } = await axios.post( + `${API_BASE_URL}/api/escrow/checkout`, + { ...params, totalXlm: toStroopPrecision(params.totalXlm) } + ); + + if (!data.success) { + throw new Error(data.error); + } + + return data.data; + } catch (error) { + throw new Error(messageFor(error, 'Escrow payment failed')); + } +} + +export const insuranceService = { + getPlans, + payWithEscrow, + buildCheckoutQuote, +}; diff --git a/types/insurance.ts b/types/insurance.ts new file mode 100644 index 0000000..ca2f065 --- /dev/null +++ b/types/insurance.ts @@ -0,0 +1,82 @@ +/** + * Cargo Insurance Types + * Interfaces for optional cargo coverage offered during escrow checkout. + */ + +/** + * A cargo insurance product a shipper can attach to a shipment. + * All monetary values are denominated in XLM. + */ +export interface InsurancePlan { + id: string; + name: string; + description: string; + /** Maximum payout for a claim against this plan, in XLM */ + coverageXlm: number; + /** One-off premium added to the escrow total, in XLM */ + premiumXlm: number; + /** Amount the shipper bears before coverage applies, in XLM */ + deductibleXlm?: number; + /** Marks the plan the UI highlights as the default choice */ + recommended?: boolean; +} + +/** Successful plan lookup. */ +export interface InsurancePlansResponse { + success: true; + data: InsurancePlan[]; +} + +/** Failed plan lookup. */ +export interface InsuranceErrorResponse { + success: false; + error: string; +} + +export type InsurancePlansResult = InsurancePlansResponse | InsuranceErrorResponse; + +/** + * Priced breakdown of an escrow checkout, including any selected coverage. + */ +export interface EscrowCheckoutQuote { + /** Freight cost being placed in escrow, in XLM */ + shipmentXlm: number; + /** Insurance premium, in XLM. Zero when coverage is declined. */ + premiumXlm: number; + /** Sum locked into escrow, in XLM */ + totalXlm: number; + /** The chosen plan, or null when the shipper declined coverage */ + plan: InsurancePlan | null; +} + +/** + * Payload sent when locking the escrow payment. + */ +export interface EscrowCheckoutParams { + shipmentId: string; + walletAddress: string; + /** Chosen insurance plan id, or null when coverage was declined */ + insurancePlanId: string | null; + /** Total XLM to lock, including any premium */ + totalXlm: number; +} + +/** + * Confirmation returned once the escrow payment settles on chain. + */ +export interface EscrowCheckoutReceipt { + escrowId: string; + transactionHash: string; + /** Amount actually locked, in XLM */ + lockedXlm: number; + /** Policy reference when coverage was purchased */ + insurancePolicyId?: string; +} + +/** Successful checkout submission. */ +export interface EscrowCheckoutResponse { + success: true; + data: EscrowCheckoutReceipt; +} + +export type EscrowCheckoutResult = EscrowCheckoutResponse | InsuranceErrorResponse;