diff --git a/backend/docs/CROSS_BORDER_PAYMENTS.md b/backend/docs/CROSS_BORDER_PAYMENTS.md new file mode 100644 index 00000000..4e263676 --- /dev/null +++ b/backend/docs/CROSS_BORDER_PAYMENTS.md @@ -0,0 +1,112 @@ +# Cross-Border Payments (#920) + +Send payments across borders with automatic FX conversion, transparent fees, +and corridor-aware settlement rails. + +The feature has two layers: + +- **FX rates** — `services/fx` (issue #626) supplies cached, auditable + mid-market rates for every conversion. +- **Cross-border orchestration** — `services/cross-border` prices a corridor + (fees, recipient amount, settlement estimate), holds the rate in a quote, + and turns quotes into payments that a settlement rail completes or fails. + +## Flow + +``` +POST /api/v1/cross-border/quote → quote (rate held for 2 minutes) +POST /api/v1/cross-border/payments → payment (status: processing) +POST /api/v1/cross-border/payments/:id/complete → payment (status: completed) +POST /api/v1/cross-border/payments/:id/fail → payment (status: failed) +``` + +## Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/v1/cross-border/corridors` | Supported corridors with limits, fees, and rails | +| `POST` | `/api/v1/cross-border/quote` | Price a transfer | +| `GET` | `/api/v1/cross-border/quotes/:id` | Fetch a quote | +| `POST` | `/api/v1/cross-border/payments` | Create a payment from a quote | +| `GET` | `/api/v1/cross-border/payments` | List payments (`senderId`, `recipientId`, `status`) | +| `GET` | `/api/v1/cross-border/payments/:id` | Fetch a payment | +| `POST` | `/api/v1/cross-border/payments/:id/complete` | Settlement success (`txHash`) | +| `POST` | `/api/v1/cross-border/payments/:id/fail` | Settlement failure (`reason`) | + +FX rates and conversion remain available directly at `/api/v1/fx` +(see `backend/docs/FX_CONVERSION.md`). + +## Pricing model + +``` +fxFee = sourceAmount × corridor.fxFeePct +fees.total = fxFee + corridor.fixedFee +convertible = sourceAmount − fees.total +targetAmount = convertible × fxRate +``` + +Quotes can be requested in two modes: + +- `mode: "source"` (default) — `amount` is what the sender is debited. +- `mode: "target"` — `amount` is what the recipient should receive; the + required source amount is solved for (`(target/rate + fixedFee) / (1 − fxFeePct)`). + +Amounts are rounded to the currency's minor units (2 decimals for fiat, +7 for crypto such as XLM). + +### Example + +```bash +curl -X POST http://localhost:3001/api/v1/cross-border/quote \ + -H 'Content-Type: application/json' \ + -d '{ "amount": 100, "sourceCurrency": "USD", "targetCurrency": "EUR" }' +``` + +```json +{ + "data": { + "corridorId": "USD:EUR", + "rail": "sepa", + "sourceAmount": 100, + "rate": 0.92, + "fees": { "fxFee": 0.5, "fixedFee": 1.5, "total": 2 }, + "convertibleAmount": 98, + "targetAmount": 90.16, + "expiresAt": "2026-01-01T00:02:00.000Z" + } +} +``` + +## Corridors + +| Corridor | Rail | Fee | Settlement | +|----------|------|-----|------------| +| USD→EUR / GBP→EUR | SEPA | 0.5% + 1.5 | ~60 min | +| USD→GBP / EUR→GBP | Faster Payments | 0.5% + 1.5 | ~30 min | +| EUR→USD / GBP→USD | ACH | 0.5% + 2 | ~4 h | +| USD/EUR/GBP→XLM | Stellar | 0.3% + 0.5 | ~5 min | +| XLM→USD/EUR/GBP | Stellar | 0.3% + 0.5 | ~5 min | + +Unsupported pairs return `422 CORRIDOR_NOT_SUPPORTED`; amounts outside a +corridor's `minAmount`/`maxAmount` return `422 AMOUNT_OUT_OF_RANGE`. + +## Idempotency & rate holds + +- Quotes expire after `quoteTtlMs` (default 2 minutes). Consuming an expired + quote returns `409 QUOTE_EXPIRED`. +- `POST /payments` accepts an `idempotencyKey`; replaying the same key returns + the original payment instead of debiting twice. +- Payments move `processing → completed | failed`; invalid transitions return + `409 CONFLICT`. + +## Persistence + +`CrossBorderPaymentService` uses an in-memory store, matching the testability +convention of `services/fx` and `services/archival`. Swap the store for Prisma +models once `CrossBorderPayment`/`CrossBorderQuote` tables are added. + +## Tests + +`backend/src/services/__tests__/cross-border-service.test.ts` covers corridor +lookups, source/target quoting, fee math, limits, expiry, idempotency, and +payment status transitions. diff --git a/backend/src/index.ts b/backend/src/index.ts index fdca8608..2794d574 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -68,6 +68,8 @@ import { eventsRouter } from './routes/events.js'; import { threatDetectionRouter } from './routes/threat-detection.js'; import { serviceMeshRouter } from './routes/service-mesh.js'; import { escrowRouter } from './routes/escrow.js'; +import { fxRouter } from './routes/fx.js'; +import { crossBorderRouter } from './routes/cross-border.js'; import { multisigRouter } from './routes/multisig.js'; import { fiatPaymentsRouter } from './routes/fiat-payments.js'; import { paymentLinksRouter } from './routes/payment-links.js'; @@ -307,6 +309,10 @@ app.use('/api/v1/service-mesh', serviceMeshRouter); // Fiat ACH/Wire payment approval workflows app.use('/api/v1/fiat-payments', fiatPaymentsRouter); +// Multi-currency FX rates/conversion (Issue #626) and cross-border payments (Issue #920) +app.use('/api/v1/fx', fxRouter); +app.use('/api/v1/cross-border', crossBorderRouter); + // Merchant dynamic payment links app.use('/api/v1/payment-links', paymentLinksRouter); diff --git a/backend/src/routes/cross-border.ts b/backend/src/routes/cross-border.ts new file mode 100644 index 00000000..cbe77d95 --- /dev/null +++ b/backend/src/routes/cross-border.ts @@ -0,0 +1,146 @@ +// Cross-border payments API routes — Issue #920 +// Mounted at /api/v1/cross-border (see backend/docs/CROSS_BORDER_PAYMENTS.md) +// +// GET /corridors — supported corridors and their limits/fees +// POST /quote — { amount, sourceCurrency, targetCurrency, mode? } -> priced quote +// GET /quotes/:id — fetch a previously created quote +// POST /payments — { quoteId, senderId, recipientId, reference?, idempotencyKey? } +// GET /payments — ?senderId=&recipientId=&status= +// GET /payments/:id — fetch a single payment +// POST /payments/:id/complete — { txHash? } settlement success callback +// POST /payments/:id/fail — { reason } settlement failure callback + +import { Router } from 'express'; +import { AppError, asyncHandler } from '../middleware/errorHandler.js'; +import { + crossBorderPaymentService, + type CrossBorderPaymentStatus, + type QuoteAmountMode, +} from '../services/cross-border/index.js'; + +export const crossBorderRouter = Router(); + +const PAYMENT_STATUSES: CrossBorderPaymentStatus[] = ['processing', 'completed', 'failed', 'cancelled']; + +function requireString(value: unknown, field: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new AppError(400, `${field} is required`, 'VALIDATION_ERROR'); + } + return value; +} + +function requirePositiveNumber(value: unknown, field: string): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + throw new AppError(400, `${field} must be a positive finite number`, 'VALIDATION_ERROR'); + } + return value; +} + +function unwrap(result: { ok: boolean; value?: T; error?: { statusCode: number; message: string; code: string } }): T { + if (!result.ok || result.value === undefined) { + const error = result.error!; + throw new AppError(error.statusCode, error.message, error.code); + } + return result.value; +} + +crossBorderRouter.get( + '/corridors', + asyncHandler(async (_req, res) => { + res.json({ data: crossBorderPaymentService.listCorridors() }); + }), +); + +crossBorderRouter.post( + '/quote', + asyncHandler(async (req, res) => { + const { amount, sourceCurrency, targetCurrency, mode } = req.body as Record; + + if (mode !== undefined && mode !== 'source' && mode !== 'target') { + throw new AppError(400, "mode must be 'source' or 'target'", 'VALIDATION_ERROR'); + } + + const result = await crossBorderPaymentService.createQuote({ + amount: requirePositiveNumber(amount, 'amount'), + sourceCurrency: requireString(sourceCurrency, 'sourceCurrency'), + targetCurrency: requireString(targetCurrency, 'targetCurrency'), + mode: mode as QuoteAmountMode | undefined, + }); + + res.status(201).json({ data: unwrap(result) }); + }), +); + +crossBorderRouter.get( + '/quotes/:id', + asyncHandler(async (req, res) => { + res.json({ data: unwrap(crossBorderPaymentService.getQuote(String(req.params.id))) }); + }), +); + +crossBorderRouter.post( + '/payments', + asyncHandler(async (req, res) => { + const { quoteId, senderId, recipientId, reference, idempotencyKey } = req.body as Record; + + const result = await crossBorderPaymentService.initiatePayment({ + quoteId: requireString(quoteId, 'quoteId'), + senderId: requireString(senderId, 'senderId'), + recipientId: requireString(recipientId, 'recipientId'), + reference: typeof reference === 'string' ? reference : undefined, + idempotencyKey: typeof idempotencyKey === 'string' ? idempotencyKey : undefined, + }); + + res.status(201).json({ data: unwrap(result) }); + }), +); + +crossBorderRouter.get( + '/payments', + asyncHandler(async (req, res) => { + const { senderId, recipientId, status } = req.query; + + if (status !== undefined && !PAYMENT_STATUSES.includes(status as CrossBorderPaymentStatus)) { + throw new AppError(400, `status must be one of ${PAYMENT_STATUSES.join(', ')}`, 'VALIDATION_ERROR'); + } + + const result = crossBorderPaymentService.listPayments({ + senderId: typeof senderId === 'string' ? senderId : undefined, + recipientId: typeof recipientId === 'string' ? recipientId : undefined, + status: status ? (status as CrossBorderPaymentStatus) : undefined, + }); + + res.json({ data: unwrap(result) }); + }), +); + +crossBorderRouter.get( + '/payments/:id', + asyncHandler(async (req, res) => { + res.json({ data: unwrap(crossBorderPaymentService.getPayment(String(req.params.id))) }); + }), +); + +crossBorderRouter.post( + '/payments/:id/complete', + asyncHandler(async (req, res) => { + const { txHash } = req.body as Record; + res.json({ + data: unwrap( + crossBorderPaymentService.completePayment(String(req.params.id), { + txHash: typeof txHash === 'string' ? txHash : undefined, + }), + ), + }); + }), +); + +crossBorderRouter.post( + '/payments/:id/fail', + asyncHandler(async (req, res) => { + const { reason } = req.body as Record; + res.json({ + data: unwrap(crossBorderPaymentService.failPayment(String(req.params.id), requireString(reason, 'reason'))), + }); + }), +); diff --git a/backend/src/services/__tests__/cross-border-service.test.ts b/backend/src/services/__tests__/cross-border-service.test.ts new file mode 100644 index 00000000..455134d8 --- /dev/null +++ b/backend/src/services/__tests__/cross-border-service.test.ts @@ -0,0 +1,254 @@ +/** + * CrossBorderPaymentService tests — Issue #920 + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { CrossBorderPaymentService, type RateProvider } from '../cross-border/cross-border-service.js'; + +const RATES: Record = { + 'USD:EUR': 0.9, + 'EUR:USD': 1 / 0.9, + 'USD:XLM': 10, +}; + +const rateProvider: RateProvider = { + async getRate(base, quote) { + const rate = RATES[`${base}:${quote}`] ?? 1; + const now = new Date(); + return { + ok: true, + value: { + rate, + baseCurrency: base, + quoteCurrency: quote, + fetchedAt: now, + expiresAt: new Date(now.getTime() + 60_000), + }, + }; + }, +}; + +let currentTime = Date.parse('2026-01-01T00:00:00.000Z'); +const clock = () => new Date(currentTime); + +function makeService(quoteTtlMs = 120_000) { + return new CrossBorderPaymentService({ fx: rateProvider, now: clock, quoteTtlMs }); +} + +describe('cross-border corridors', () => { + it('exposes both directions for every seeded corridor', () => { + const service = makeService(); + const corridors = service.listCorridors(); + + expect(corridors).toHaveLength(12); + expect(service.getCorridor('USD', 'EUR')).toMatchObject({ rail: 'sepa', fxFeePct: 0.005 }); + expect(service.getCorridor('EUR', 'USD')).toMatchObject({ rail: 'ach' }); + expect(service.getCorridor('USD', 'XLM')).toMatchObject({ rail: 'stellar' }); + }); + + it('returns undefined for unsupported pairs', () => { + expect(makeService().getCorridor('USD', 'JPY')).toBeUndefined(); + }); +}); + +describe('createQuote', () => { + let service: CrossBorderPaymentService; + + beforeEach(() => { + currentTime = Date.parse('2026-01-01T00:00:00.000Z'); + service = makeService(); + }); + + it('prices a source-amount quote with FX and fixed fees', async () => { + const result = await service.createQuote({ amount: 100, sourceCurrency: 'usd', targetCurrency: 'eur' }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value).toMatchObject({ + corridorId: 'USD:EUR', + sourceCurrency: 'USD', + targetCurrency: 'EUR', + sourceAmount: 100, + rate: 0.9, + convertibleAmount: 98, + fees: { fxFee: 0.5, fixedFee: 1.5, total: 2 }, + targetAmount: 88.2, + }); + expect(result.value.expiresAt.getTime()).toBeGreaterThan(result.value.createdAt.getTime()); + }); + + it('solves the source amount in target mode so the recipient receives the requested amount', async () => { + const result = await service.createQuote({ + amount: 90, + sourceCurrency: 'USD', + targetCurrency: 'EUR', + mode: 'target', + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.mode).toBe('target'); + expect(result.value.targetAmount).toBe(90); + expect(result.value.sourceAmount).toBe(102.01); + expect(result.value.fees.total).toBe(2.01); + }); + + it('rejects unsupported corridors', async () => { + const result = await service.createQuote({ amount: 100, sourceCurrency: 'USD', targetCurrency: 'JPY' }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('CORRIDOR_NOT_SUPPORTED'); + expect(result.error.statusCode).toBe(422); + }); + + it('rejects same-currency transfers', async () => { + const result = await service.createQuote({ amount: 100, sourceCurrency: 'USD', targetCurrency: 'USD' }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.code).toBe('VALIDATION_ERROR'); + }); + + it('enforces corridor amount limits', async () => { + const tooSmall = await service.createQuote({ amount: 5, sourceCurrency: 'USD', targetCurrency: 'EUR' }); + expect(tooSmall.ok).toBe(false); + if (!tooSmall.ok) expect(tooSmall.error.code).toBe('AMOUNT_OUT_OF_RANGE'); + + const tooLarge = await service.createQuote({ amount: 2_000_000, sourceCurrency: 'USD', targetCurrency: 'EUR' }); + expect(tooLarge.ok).toBe(false); + if (!tooLarge.ok) expect(tooLarge.error.code).toBe('AMOUNT_OUT_OF_RANGE'); + }); + + it('rejects non-positive and non-finite amounts', async () => { + for (const amount of [0, -10, Number.NaN, Number.POSITIVE_INFINITY]) { + const result = await service.createQuote({ amount, sourceCurrency: 'USD', targetCurrency: 'EUR' }); + expect(result.ok).toBe(false); + } + }); + + it('returns not-found for unknown quotes', () => { + const result = service.getQuote('does-not-exist'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.statusCode).toBe(404); + }); +}); + +describe('cross-border payments', () => { + let service: CrossBorderPaymentService; + + beforeEach(() => { + currentTime = Date.parse('2026-01-01T00:00:00.000Z'); + service = makeService(); + }); + + async function quote(amount = 100) { + const result = await service.createQuote({ amount, sourceCurrency: 'USD', targetCurrency: 'EUR' }); + if (!result.ok) throw new Error('quote failed'); + return result.value; + } + + it('initiates a processing payment from a quote', async () => { + const q = await quote(); + const result = await service.initiatePayment({ quoteId: q.id, senderId: 's1', recipientId: 'r1' }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value).toMatchObject({ + status: 'processing', + senderId: 's1', + recipientId: 'r1', + sourceAmount: 100, + targetAmount: 88.2, + corridorId: 'USD:EUR', + }); + }); + + it('is idempotent when an idempotency key is reused', async () => { + const q = await quote(); + const first = await service.initiatePayment({ + quoteId: q.id, + senderId: 's1', + recipientId: 'r1', + idempotencyKey: 'pay-1', + }); + const second = await service.initiatePayment({ + quoteId: q.id, + senderId: 's1', + recipientId: 'r1', + idempotencyKey: 'pay-1', + }); + + expect(first.ok && second.ok).toBe(true); + if (!first.ok || !second.ok) return; + expect(second.value.id).toBe(first.value.id); + + const all = service.listPayments(); + expect(all.ok && all.value).toHaveLength(1); + }); + + it('rejects expired quotes with a conflict', async () => { + const q = await quote(); + currentTime += 120_001; + + const result = await service.initiatePayment({ quoteId: q.id, senderId: 's1', recipientId: 'r1' }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe('QUOTE_EXPIRED'); + }); + + it('requires sender and recipient ids', async () => { + const q = await quote(); + const result = await service.initiatePayment({ quoteId: q.id, senderId: '', recipientId: 'r1' }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe('VALIDATION_ERROR'); + }); + + it('completes a payment and blocks invalid transitions', async () => { + const q = await quote(); + const initiated = await service.initiatePayment({ quoteId: q.id, senderId: 's1', recipientId: 'r1' }); + if (!initiated.ok) throw new Error('initiate failed'); + + const completed = service.completePayment(initiated.value.id, { txHash: '0xabc' }); + expect(completed.ok).toBe(true); + if (completed.ok) { + expect(completed.value.status).toBe('completed'); + expect(completed.value.txHash).toBe('0xabc'); + expect(completed.value.completedAt).toBeInstanceOf(Date); + } + + const again = service.failPayment(initiated.value.id, 'late failure'); + expect(again.ok).toBe(false); + if (!again.ok) expect(again.error.statusCode).toBe(409); + }); + + it('fails a payment with a reason', async () => { + const q = await quote(); + const initiated = await service.initiatePayment({ quoteId: q.id, senderId: 's1', recipientId: 'r1' }); + if (!initiated.ok) throw new Error('initiate failed'); + + const failed = service.failPayment(initiated.value.id, 'rail rejected'); + expect(failed.ok).toBe(true); + if (failed.ok) { + expect(failed.value.status).toBe('failed'); + expect(failed.value.failureReason).toBe('rail rejected'); + } + }); + + it('filters payments and reports missing ones', async () => { + const q1 = await quote(100); + const q2 = await quote(200); + const p1 = await service.initiatePayment({ quoteId: q1.id, senderId: 's1', recipientId: 'r1' }); + await service.initiatePayment({ quoteId: q2.id, senderId: 's2', recipientId: 'r2' }); + if (!p1.ok) throw new Error('initiate failed'); + service.completePayment(p1.value.id); + + const bySender = service.listPayments({ senderId: 's1' }); + expect(bySender.ok && bySender.value).toHaveLength(1); + + const completed = service.listPayments({ status: 'completed' }); + expect(completed.ok && completed.value).toHaveLength(1); + + const missing = service.getPayment('nope'); + expect(missing.ok).toBe(false); + if (!missing.ok) expect(missing.error.statusCode).toBe(404); + }); +}); diff --git a/backend/src/services/cross-border/cross-border-service.ts b/backend/src/services/cross-border/cross-border-service.ts new file mode 100644 index 00000000..4da06450 --- /dev/null +++ b/backend/src/services/cross-border/cross-border-service.ts @@ -0,0 +1,517 @@ +/** + * cross-border-service.ts — Issue #920 + * + * Cross-border payments with FX conversion. Built on top of the FX service + * (issue #626) so rates come from the same cached, auditable source. + * + * A payment is a two-step flow: + * 1. `createQuote` — price a corridor: FX rate, fees, recipient amount, + * settlement estimate, and a rate-hold expiry. + * 2. `initiatePayment` — consume a quote and create a payment record that + * can then be `completePayment`/`failPayment`-ed by the + * settlement rail. + * + * Mirrors the conventions of `services/fx/fx-service.ts`: extends BaseService, + * returns `Result`, and is fully usable without a live Postgres connection + * (in-memory fallback store) so it is unit-testable in isolation. + */ + +import { randomUUID } from 'node:crypto'; +import { BaseService } from '../BaseService.js'; +import type { Result } from '../../lib/result.js'; +import { fxService, type FxRateRecord } from '../fx/index.js'; + +export type SettlementRail = 'stellar' | 'sepa' | 'ach' | 'faster_payments' | 'swift'; + +export type QuoteAmountMode = 'source' | 'target'; + +export type CrossBorderPaymentStatus = 'processing' | 'completed' | 'failed' | 'cancelled'; + +export interface Corridor { + /** Stable id, e.g. `USD:EUR`. */ + id: string; + sourceCurrency: string; + targetCurrency: string; + rail: SettlementRail; + /** Minimum source amount accepted by the corridor. */ + minAmount: number; + /** Maximum source amount accepted by the corridor. */ + maxAmount: number; + /** Proportional fee applied to the source amount (0.005 = 0.5%). */ + fxFeePct: number; + /** Flat fee in the source currency. */ + fixedFee: number; + /** Typical time to settle, in minutes — drives `estimatedArrival`. */ + settlementMinutes: number; +} + +export interface CrossBorderFees { + fxFee: number; + fixedFee: number; + total: number; +} + +export interface CrossBorderQuote { + id: string; + corridorId: string; + rail: SettlementRail; + sourceCurrency: string; + targetCurrency: string; + mode: QuoteAmountMode; + /** Amount debited from the sender, in the source currency. */ + sourceAmount: number; + /** Mid-market rate used for the conversion. */ + rate: number; + /** Amount left to convert after fees, in the source currency. */ + convertibleAmount: number; + fees: CrossBorderFees; + /** Amount the recipient receives, in the target currency. */ + targetAmount: number; + estimatedArrival: Date; + expiresAt: Date; + createdAt: Date; +} + +export interface CrossBorderPayment { + id: string; + quoteId: string; + status: CrossBorderPaymentStatus; + senderId: string; + recipientId: string; + reference?: string; + idempotencyKey?: string; + corridorId: string; + rail: SettlementRail; + sourceCurrency: string; + targetCurrency: string; + sourceAmount: number; + targetAmount: number; + rate: number; + fees: CrossBorderFees; + txHash?: string; + failureReason?: string; + createdAt: Date; + updatedAt: Date; + completedAt?: Date; +} + +/** Minimal rate-fetching surface, satisfied by the shared FxService. */ +export interface RateProvider { + getRate(base: string, quote: string): Promise>>; +} + +export interface CrossBorderServiceOptions { + fx?: RateProvider; + /** How long a quote holds its rate. Defaults to 2 minutes. */ + quoteTtlMs?: number; + /** Injectable clock, for deterministic tests. */ + now?: () => Date; +} + +const DEFAULT_QUOTE_TTL_MS = 2 * 60 * 1000; +const CRYPTO_CURRENCIES = new Set(['XLM', 'BTC', 'ETH', 'USDC', 'USDT']); +const CURRENCY_PATTERN = /^[A-Z0-9]{3,5}$/; + +// Canonical corridor table. Each entry is expanded into both directions below. +interface CorridorSeed { + source: string; + target: string; + rail: SettlementRail; + minAmount: number; + maxAmount: number; + fxFeePct: number; + fixedFee: number; + settlementMinutes: number; + /** Fees/limits for the reverse direction (target -> source). */ + reverse?: Partial>; +} + +const CORRIDOR_SEEDS: CorridorSeed[] = [ + { + source: 'USD', + target: 'EUR', + rail: 'sepa', + minAmount: 10, + maxAmount: 1_000_000, + fxFeePct: 0.005, + fixedFee: 1.5, + settlementMinutes: 60, + reverse: { rail: 'ach', fixedFee: 2, settlementMinutes: 240 }, + }, + { + source: 'USD', + target: 'GBP', + rail: 'faster_payments', + minAmount: 10, + maxAmount: 1_000_000, + fxFeePct: 0.005, + fixedFee: 1.5, + settlementMinutes: 30, + reverse: { rail: 'ach', fixedFee: 2, settlementMinutes: 240 }, + }, + { + source: 'USD', + target: 'XLM', + rail: 'stellar', + minAmount: 5, + maxAmount: 1_000_000, + fxFeePct: 0.003, + fixedFee: 0.5, + settlementMinutes: 5, + reverse: { fixedFee: 0.5, settlementMinutes: 5 }, + }, + { + source: 'EUR', + target: 'GBP', + rail: 'faster_payments', + minAmount: 10, + maxAmount: 1_000_000, + fxFeePct: 0.005, + fixedFee: 1.5, + settlementMinutes: 30, + reverse: { rail: 'sepa', settlementMinutes: 60 }, + }, + { + source: 'EUR', + target: 'XLM', + rail: 'stellar', + minAmount: 5, + maxAmount: 1_000_000, + fxFeePct: 0.003, + fixedFee: 0.5, + settlementMinutes: 5, + reverse: { fixedFee: 0.5, settlementMinutes: 5 }, + }, + { + source: 'GBP', + target: 'XLM', + rail: 'stellar', + minAmount: 5, + maxAmount: 1_000_000, + fxFeePct: 0.003, + fixedFee: 0.5, + settlementMinutes: 5, + reverse: { fixedFee: 0.5, settlementMinutes: 5 }, + }, +]; + +function buildCorridors(): Corridor[] { + const corridors: Corridor[] = []; + + for (const seed of CORRIDOR_SEEDS) { + corridors.push({ + id: `${seed.source}:${seed.target}`, + sourceCurrency: seed.source, + targetCurrency: seed.target, + rail: seed.rail, + minAmount: seed.minAmount, + maxAmount: seed.maxAmount, + fxFeePct: seed.fxFeePct, + fixedFee: seed.fixedFee, + settlementMinutes: seed.settlementMinutes, + }); + + const reverse = seed.reverse ?? {}; + corridors.push({ + id: `${seed.target}:${seed.source}`, + sourceCurrency: seed.target, + targetCurrency: seed.source, + rail: reverse.rail ?? seed.rail, + minAmount: reverse.minAmount ?? seed.minAmount, + maxAmount: reverse.maxAmount ?? seed.maxAmount, + fxFeePct: reverse.fxFeePct ?? seed.fxFeePct, + fixedFee: reverse.fixedFee ?? seed.fixedFee, + settlementMinutes: reverse.settlementMinutes ?? seed.settlementMinutes, + }); + } + + return corridors; +} + +const CORRIDORS = buildCorridors(); +const CORRIDOR_BY_ID = new Map(CORRIDORS.map((corridor) => [corridor.id, corridor])); + +function decimalsFor(currency: string): number { + return CRYPTO_CURRENCIES.has(currency) ? 7 : 2; +} + +function roundMoney(amount: number, currency: string): number { + const factor = 10 ** decimalsFor(currency); + return Math.round(amount * factor) / factor; +} + +export class CrossBorderPaymentService extends BaseService { + private fx: RateProvider; + private quoteTtlMs: number; + private now: () => Date; + + private readonly quotes = new Map(); + private readonly payments = new Map(); + private readonly paymentsByIdempotencyKey = new Map(); + + constructor(options: CrossBorderServiceOptions = {}) { + super(); + this.fx = options.fx ?? fxService; + this.quoteTtlMs = options.quoteTtlMs ?? DEFAULT_QUOTE_TTL_MS; + this.now = options.now ?? (() => new Date()); + } + + /** Clears in-memory state. Intended for tests. */ + resetForTests(): void { + this.quotes.clear(); + this.payments.clear(); + this.paymentsByIdempotencyKey.clear(); + } + + // --------------------------------------------------------------------- + // Corridors + // --------------------------------------------------------------------- + + listCorridors(): Corridor[] { + return CORRIDORS.map((corridor) => ({ ...corridor })); + } + + getCorridor(sourceCurrency: string, targetCurrency: string): Corridor | undefined { + const corridor = CORRIDOR_BY_ID.get(`${sourceCurrency.toUpperCase()}:${targetCurrency.toUpperCase()}`); + return corridor ? { ...corridor } : undefined; + } + + // --------------------------------------------------------------------- + // Quotes + // --------------------------------------------------------------------- + + /** + * prices a cross-border transfer and holds the rate for `quoteTtlMs`. + * + * In `source` mode (`amount` is what the sender is debited) fees are taken + * out of `amount`. In `target` mode (`amount` is what the recipient should + * receive) the required source amount is solved for, fees included. + */ + async createQuote(input: { + amount: number; + sourceCurrency: string; + targetCurrency: string; + mode?: QuoteAmountMode; + }): Promise> { + const sourceCurrency = input.sourceCurrency?.trim().toUpperCase() ?? ''; + const targetCurrency = input.targetCurrency?.trim().toUpperCase() ?? ''; + const mode: QuoteAmountMode = input.mode ?? 'source'; + + if (!CURRENCY_PATTERN.test(sourceCurrency) || !CURRENCY_PATTERN.test(targetCurrency)) { + return this.validationFailure('sourceCurrency and targetCurrency must be valid currency codes'); + } + if (sourceCurrency === targetCurrency) { + return this.validationFailure('Cross-border payments require two different currencies'); + } + if (!Number.isFinite(input.amount) || input.amount <= 0) { + return this.validationFailure('amount must be a positive finite number'); + } + if (mode !== 'source' && mode !== 'target') { + return this.validationFailure("mode must be 'source' or 'target'"); + } + + const corridor = CORRIDOR_BY_ID.get(`${sourceCurrency}:${targetCurrency}`); + if (!corridor) { + return this.fail( + `No cross-border corridor from ${sourceCurrency} to ${targetCurrency}`, + 422, + 'CORRIDOR_NOT_SUPPORTED', + { sourceCurrency, targetCurrency }, + ); + } + + const rateResult = await this.fx.getRate(sourceCurrency, targetCurrency); + if (!rateResult.ok) return rateResult; + + const rate = rateResult.value.rate; + if (!Number.isFinite(rate) || rate <= 0) { + return this.validationFailure(`Invalid FX rate for ${sourceCurrency}/${targetCurrency}: ${rate}`); + } + + const sourceAmount = + mode === 'source' + ? roundMoney(input.amount, sourceCurrency) + : roundMoney((input.amount / rate + corridor.fixedFee) / (1 - corridor.fxFeePct), sourceCurrency); + + if (sourceAmount < corridor.minAmount || sourceAmount > corridor.maxAmount) { + return this.fail( + `Amount must be between ${corridor.minAmount} and ${corridor.maxAmount} ${sourceCurrency} for corridor ${corridor.id}`, + 422, + 'AMOUNT_OUT_OF_RANGE', + { corridorId: corridor.id, minAmount: corridor.minAmount, maxAmount: corridor.maxAmount }, + ); + } + + const fxFee = roundMoney(sourceAmount * corridor.fxFeePct, sourceCurrency); + const fixedFee = roundMoney(corridor.fixedFee, sourceCurrency); + const fees: CrossBorderFees = { + fxFee, + fixedFee, + total: roundMoney(fxFee + fixedFee, sourceCurrency), + }; + + const convertibleAmount = roundMoney(sourceAmount - fees.total, sourceCurrency); + if (convertibleAmount <= 0) { + return this.validationFailure('Amount does not cover the corridor fees'); + } + + const targetAmount = roundMoney(convertibleAmount * rate, targetCurrency); + const createdAt = this.now(); + + const quote: CrossBorderQuote = { + id: randomUUID(), + corridorId: corridor.id, + rail: corridor.rail, + sourceCurrency, + targetCurrency, + mode, + sourceAmount, + rate, + convertibleAmount, + fees, + targetAmount, + estimatedArrival: new Date(createdAt.getTime() + corridor.settlementMinutes * 60 * 1000), + expiresAt: new Date(createdAt.getTime() + this.quoteTtlMs), + createdAt, + }; + + this.quotes.set(quote.id, quote); + return this.ok(quote); + } + + getQuote(id: string): Result { + const quote = this.quotes.get(id); + if (!quote) return this.notFoundFailure('Quote', id); + return this.ok(quote); + } + + // --------------------------------------------------------------------- + // Payments + // --------------------------------------------------------------------- + + /** + * Consumes a quote and creates a payment. Idempotent when an + * `idempotencyKey` is supplied: replaying the same key returns the existing + * payment instead of debiting twice. + */ + async initiatePayment(input: { + quoteId: string; + senderId: string; + recipientId: string; + reference?: string; + idempotencyKey?: string; + }): Promise> { + const { quoteId, senderId, recipientId, reference, idempotencyKey } = input; + + if (!quoteId) return this.validationFailure('quoteId is required'); + if (!senderId) return this.validationFailure('senderId is required'); + if (!recipientId) return this.validationFailure('recipientId is required'); + + if (idempotencyKey) { + const existingId = this.paymentsByIdempotencyKey.get(idempotencyKey); + const existing = existingId ? this.payments.get(existingId) : undefined; + if (existing) return this.ok(existing); + } + + const quote = this.quotes.get(quoteId); + if (!quote) return this.notFoundFailure('Quote', quoteId); + + if (this.now().getTime() > quote.expiresAt.getTime()) { + return this.fail('Quote has expired', 409, 'QUOTE_EXPIRED', { quoteId, expiresAt: quote.expiresAt }); + } + + const now = this.now(); + const payment: CrossBorderPayment = { + id: randomUUID(), + quoteId: quote.id, + status: 'processing', + senderId, + recipientId, + reference, + idempotencyKey, + corridorId: quote.corridorId, + rail: quote.rail, + sourceCurrency: quote.sourceCurrency, + targetCurrency: quote.targetCurrency, + sourceAmount: quote.sourceAmount, + targetAmount: quote.targetAmount, + rate: quote.rate, + fees: quote.fees, + createdAt: now, + updatedAt: now, + }; + + this.payments.set(payment.id, payment); + if (idempotencyKey) this.paymentsByIdempotencyKey.set(idempotencyKey, payment.id); + + return this.ok(payment); + } + + /** Marks a processing payment as settled by the rail. */ + completePayment(id: string, options: { txHash?: string } = {}): Result { + const payment = this.payments.get(id); + if (!payment) return this.notFoundFailure('Payment', id); + + if (payment.status === 'completed') return this.ok(payment); + if (payment.status !== 'processing') { + return this.conflictFailure(`Cannot complete a payment in status '${payment.status}'`); + } + + const now = this.now(); + const completed: CrossBorderPayment = { + ...payment, + status: 'completed', + txHash: options.txHash, + completedAt: now, + updatedAt: now, + }; + this.payments.set(id, completed); + return this.ok(completed); + } + + /** Marks a processing payment as failed (rail rejection, compliance hold, …). */ + failPayment(id: string, reason: string): Result { + const payment = this.payments.get(id); + if (!payment) return this.notFoundFailure('Payment', id); + + if (payment.status !== 'processing') { + return this.conflictFailure(`Cannot fail a payment in status '${payment.status}'`); + } + if (!reason) return this.validationFailure('reason is required'); + + const now = this.now(); + const failed: CrossBorderPayment = { + ...payment, + status: 'failed', + failureReason: reason, + updatedAt: now, + }; + this.payments.set(id, failed); + return this.ok(failed); + } + + getPayment(id: string): Result { + const payment = this.payments.get(id); + if (!payment) return this.notFoundFailure('Payment', id); + return this.ok(payment); + } + + listPayments(filters: { + senderId?: string; + recipientId?: string; + status?: CrossBorderPaymentStatus; + } = {}): Result { + const payments = [...this.payments.values()] + .filter((payment) => { + if (filters.senderId && payment.senderId !== filters.senderId) return false; + if (filters.recipientId && payment.recipientId !== filters.recipientId) return false; + if (filters.status && payment.status !== filters.status) return false; + return true; + }) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + + return this.ok(payments); + } +} + +export const crossBorderPaymentService = new CrossBorderPaymentService(); diff --git a/backend/src/services/cross-border/index.ts b/backend/src/services/cross-border/index.ts new file mode 100644 index 00000000..55da5f90 --- /dev/null +++ b/backend/src/services/cross-border/index.ts @@ -0,0 +1,16 @@ +// cross-border/index.ts — Issue #920 +// Re-exports + shared CrossBorderPaymentService singleton. + +export { + CrossBorderPaymentService, + crossBorderPaymentService, + type Corridor, + type CrossBorderFees, + type CrossBorderPayment, + type CrossBorderPaymentStatus, + type CrossBorderQuote, + type CrossBorderServiceOptions, + type QuoteAmountMode, + type RateProvider, + type SettlementRail, +} from './cross-border-service.js';