Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions backend/docs/CROSS_BORDER_PAYMENTS.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);

Expand Down
146 changes: 146 additions & 0 deletions backend/src/routes/cross-border.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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<string, unknown>;

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<string, unknown>;

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<string, unknown>;
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<string, unknown>;
res.json({
data: unwrap(crossBorderPaymentService.failPayment(String(req.params.id), requireString(reason, 'reason'))),
});
}),
);
Loading
Loading