From e8508e2886b7eb2dbb622b7b9a9e9460cefd6450 Mon Sep 17 00:00:00 2001 From: davidsoniaudin2-oss Date: Mon, 28 Sep 2026 03:30:38 +0000 Subject: [PATCH 1/2] feat(transfers): instant account-to-account transfer rail (#913) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a real-time A2A transfer service with short-lived quotes that lock fees and FX rates, idempotent initiation to prevent double-charges, per-transfer and rolling 24h velocity limits, cancellation of unsettled transfers, and post-settlement reversals. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- backend/src/index.ts | 2 + backend/src/routes/transfers.ts | 138 +++++ .../__tests__/instant-transfers.test.ts | 479 ++++++++++++++++++ backend/src/services/transfers.ts | 471 +++++++++++++++++ 4 files changed, 1090 insertions(+) create mode 100644 backend/src/routes/transfers.ts create mode 100644 backend/src/services/__tests__/instant-transfers.test.ts create mode 100644 backend/src/services/transfers.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index e89b8cdf..c29ce3d9 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -114,6 +114,7 @@ import { installmentsRouter } from './routes/installments.js'; import { jobQueueRouter } from './routes/job-queue.js'; import { recurringBillingRouter } from './routes/recurring-billing.js'; import { splitPaymentsRouter } from './routes/split-payments.js'; +import { transfersRouter } from './routes/transfers.js'; // Validate environment variables at startup validateEnv(); @@ -277,6 +278,7 @@ apiV1Router.use('/installments', installmentsRouter); apiV1Router.use('/job-queue', jobQueueRouter); apiV1Router.use('/recurring-payments', recurringBillingRouter); apiV1Router.use('/split-payments', splitPaymentsRouter); +apiV1Router.use('/transfers', transfersRouter); apiV1Router.get('/compression/metrics', (_req, res) => { res.json(getCompressionMetrics()); }); diff --git a/backend/src/routes/transfers.ts b/backend/src/routes/transfers.ts new file mode 100644 index 00000000..92f1ed7c --- /dev/null +++ b/backend/src/routes/transfers.ts @@ -0,0 +1,138 @@ +import { Router, Request, Response } from 'express'; +import { z } from 'zod'; +import { AppError, asyncHandler } from '../middleware/errorHandler.js'; +import { validate } from '../middleware/validate.js'; +import { transferService } from '../services/transfers.js'; + +export const transfersRouter = Router(); + +const accountSchema = z.object({ + id: z.string().min(1).optional(), + currency: z.string().length(3), + status: z.enum(['active', 'frozen', 'closed']).optional(), + holderName: z.string().max(120).optional(), +}); + +const quoteSchema = z.object({ + sourceAccountId: z.string().min(1), + destinationAccountId: z.string().min(1), + amount: z.number().positive(), + destinationCurrency: z.string().length(3).optional(), +}); + +const createTransferSchema = z.object({ + quoteId: z.string().min(1).optional(), + sourceAccountId: z.string().min(1), + destinationAccountId: z.string().min(1), + amount: z.number().positive(), + currency: z.string().length(3), + destinationCurrency: z.string().length(3).optional(), + idempotencyKey: z.string().min(1).max(200), + scheduledFor: z.string().datetime().optional(), + metadata: z.record(z.string()).optional(), +}); + +const reverseSchema = z.object({ + reason: z.string().min(1).max(280), +}); + +/** + * Map errors thrown by the in-memory transfer service onto AppError so the + * shared error handler preserves the service's status code and error code. + */ +const serviceHandler = + (handler: (req: Request, res: Response) => unknown | Promise) => + asyncHandler(async (req, res) => { + try { + await handler(req, res); + } catch (err) { + const serviceError = err as { statusCode?: number; code?: string; message?: string }; + if (typeof serviceError?.statusCode === 'number') { + throw new AppError(serviceError.statusCode, serviceError.message ?? 'Transfer error', serviceError.code); + } + throw err; + } + }); + +// Register an account that can send or receive transfers. +transfersRouter.post( + '/accounts', + validate(accountSchema), + serviceHandler((req: Request, res: Response) => { + const account = transferService.registerAccount(req.body); + res.status(201).json({ data: account }); + }), +); + +// Price a transfer and lock the fee/FX rate for a short window. +transfersRouter.post( + '/quotes', + validate(quoteSchema), + serviceHandler((req: Request, res: Response) => { + const quote = transferService.createQuote(req.body); + res.status(201).json({ data: quote }); + }), +); + +// Initiate a transfer. Idempotent on the supplied idempotencyKey. +transfersRouter.post( + '/transfers', + validate(createTransferSchema), + serviceHandler((req: Request, res: Response) => { + const idempotencyKey = (req.get('Idempotency-Key') as string | undefined) ?? req.body.idempotencyKey; + const { transfer, idempotent } = transferService.initiateTransfer({ + ...req.body, + idempotencyKey, + }); + res.status(idempotent ? 200 : 201).json({ data: transfer, idempotent }); + }), +); + +transfersRouter.get( + '/transfers', + serviceHandler((req: Request, res: Response) => { + const { accountId, status, limit, offset } = req.query; + const result = transferService.listTransfers({ + accountId: accountId as string | undefined, + status: status as never, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + }); + res.json(result); + }), +); + +transfersRouter.get( + '/transfers/:id', + serviceHandler((req: Request, res: Response) => { + const transfer = transferService.getTransfer(String(req.params.id)); + if (!transfer) throw new AppError(404, 'Transfer not found', 'NOT_FOUND'); + res.json({ data: transfer }); + }), +); + +transfersRouter.post( + '/transfers/:id/cancel', + serviceHandler((req: Request, res: Response) => { + const transfer = transferService.cancelTransfer(String(req.params.id)); + res.json({ data: transfer }); + }), +); + +transfersRouter.post( + '/transfers/:id/reverse', + validate(reverseSchema), + serviceHandler((req: Request, res: Response) => { + const transfer = transferService.reverseTransfer(String(req.params.id), req.body.reason); + res.json({ data: transfer }); + }), +); + +transfersRouter.get( + '/accounts/:id/volume', + serviceHandler((req: Request, res: Response) => { + const account = transferService.getAccount(String(req.params.id)); + if (!account) throw new AppError(404, 'Account not found', 'NOT_FOUND'); + res.json({ data: { accountId: account.id, currency: account.currency, dailyVolume: transferService.getDailyVolume(account.id) } }); + }), +); diff --git a/backend/src/services/__tests__/instant-transfers.test.ts b/backend/src/services/__tests__/instant-transfers.test.ts new file mode 100644 index 00000000..e95aeae6 --- /dev/null +++ b/backend/src/services/__tests__/instant-transfers.test.ts @@ -0,0 +1,479 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { TransferService, DEFAULT_TRANSFER_LIMITS } from '../transfers.js'; + +describe('TransferService — instant account-to-account transfers (#913)', () => { + let service: TransferService; + let now: number; + let usd: string; + let eur: string; + + beforeEach(() => { + now = new Date('2026-01-01T00:00:00.000Z').getTime(); + service = new TransferService(DEFAULT_TRANSFER_LIMITS, () => now); + usd = service.registerAccount({ currency: 'USD', holderName: 'Ada' }).id; + eur = service.registerAccount({ currency: 'EUR', holderName: 'Babbage' }).id; + }); + + const expectError = (fn: () => unknown, statusCode: number, message?: RegExp) => { + try { + fn(); + throw new Error('Expected function to throw'); + } catch (err) { + const error = err as Error & { statusCode?: number }; + expect(error.statusCode).toBe(statusCode); + if (message) expect(error.message).toMatch(message); + } + }; + + describe('accounts', () => { + it('registers accounts as active by default', () => { + const account = service.registerAccount({ currency: 'gbp' }); + expect(account.status).toBe('active'); + expect(account.currency).toBe('GBP'); + expect(account.id).toMatch(/^acct_/); + }); + + it('looks up a registered account', () => { + expect(service.getAccount(usd)?.holderName).toBe('Ada'); + expect(service.getAccount('missing')).toBeUndefined(); + }); + }); + + describe('quotes', () => { + it('creates a quote that locks fee and FX rate', () => { + const quote = service.createQuote({ + sourceAccountId: usd, + destinationAccountId: eur, + amount: 1000, + }); + + expect(quote.fee).toBe(5); // 0.5% + expect(quote.totalDebit).toBe(1005); + expect(quote.fxRate).toBeCloseTo(0.92, 6); + expect(quote.destinationAmount).toBe(920); + expect(quote.rail).toBe('instant'); + expect(new Date(quote.expiresAt).getTime()).toBe(now + DEFAULT_TRANSFER_LIMITS.quoteTtlMs); + }); + + it('applies the minimum fee floor for small transfers', () => { + const quote = service.createQuote({ + sourceAccountId: usd, + destinationAccountId: eur, + amount: 20, + }); + expect(quote.fee).toBe(0.5); + }); + + it('caps the fee for large transfers', () => { + const quote = service.createQuote({ + sourceAccountId: usd, + destinationAccountId: eur, + amount: 20_000, + }); + expect(quote.fee).toBe(25); + }); + + it('rejects a zero or negative amount', () => { + expectError( + () => service.createQuote({ sourceAccountId: usd, destinationAccountId: eur, amount: 0 }), + 400, + /greater than 0/, + ); + }); + + it('rejects quoting between the same account', () => { + expectError( + () => service.createQuote({ sourceAccountId: usd, destinationAccountId: usd, amount: 10 }), + 400, + /must be different/, + ); + }); + + it('reports a missing destination account', () => { + expectError( + () => service.createQuote({ sourceAccountId: usd, destinationAccountId: 'nope', amount: 10 }), + 404, + /Destination account not found/, + ); + }); + + it('enforces the per-transfer limit', () => { + expectError( + () => + service.createQuote({ + sourceAccountId: usd, + destinationAccountId: eur, + amount: DEFAULT_TRANSFER_LIMITS.perTransfer + 1, + }), + 400, + /per-transfer limit/, + ); + }); + }); + + describe('initiation and settlement', () => { + it('settles an instant transfer synchronously', () => { + const { transfer, idempotent } = service.initiateTransfer({ + sourceAccountId: usd, + destinationAccountId: eur, + amount: 500, + currency: 'USD', + idempotencyKey: 'key-1', + }); + + expect(idempotent).toBe(false); + expect(transfer.status).toBe('completed'); + expect(transfer.rail).toBe('instant'); + expect(transfer.completedAt).toBeTruthy(); + expect(transfer.destinationAmount).toBe(460); + }); + + it('accepts a quote and validates the amount against it', () => { + const quote = service.createQuote({ + sourceAccountId: usd, + destinationAccountId: eur, + amount: 100, + }); + + const { transfer } = service.initiateTransfer({ + quoteId: quote.id, + sourceAccountId: usd, + destinationAccountId: eur, + amount: 100, + currency: 'USD', + idempotencyKey: 'key-quote', + }); + + expect(transfer.quoteId).toBe(quote.id); + expect(transfer.fee).toBe(quote.fee); + }); + + it('rejects a mismatched amount for a locked quote', () => { + const quote = service.createQuote({ sourceAccountId: usd, destinationAccountId: eur, amount: 100 }); + expectError( + () => + service.initiateTransfer({ + quoteId: quote.id, + sourceAccountId: usd, + destinationAccountId: eur, + amount: 250, + currency: 'USD', + idempotencyKey: 'key-mismatch', + }), + 400, + /does not match the quote/, + ); + }); + + it('rejects an expired quote', () => { + const quote = service.createQuote({ sourceAccountId: usd, destinationAccountId: eur, amount: 100 }); + now += DEFAULT_TRANSFER_LIMITS.quoteTtlMs + 1; + expectError( + () => + service.initiateTransfer({ + quoteId: quote.id, + sourceAccountId: usd, + destinationAccountId: eur, + amount: 100, + currency: 'USD', + idempotencyKey: 'key-expired', + }), + 400, + /expired/, + ); + }); + + it('reports a missing quote', () => { + expectError( + () => + service.initiateTransfer({ + quoteId: 'missing', + sourceAccountId: usd, + destinationAccountId: eur, + amount: 100, + currency: 'USD', + idempotencyKey: 'key-missing-quote', + }), + 404, + /Transfer quote not found/, + ); + }); + + it('is idempotent — retries return the original transfer', () => { + const input = { + sourceAccountId: usd, + destinationAccountId: eur, + amount: 300, + currency: 'USD', + idempotencyKey: 'retry-me', + }; + + const first = service.initiateTransfer(input); + const second = service.initiateTransfer(input); + + expect(second.idempotent).toBe(true); + expect(second.transfer.id).toBe(first.transfer.id); + expect(service.listTransfers().total).toBe(1); + }); + + it('requires an idempotency key', () => { + expectError( + () => + service.initiateTransfer({ + sourceAccountId: usd, + destinationAccountId: eur, + amount: 10, + currency: 'USD', + idempotencyKey: '', + }), + 400, + /idempotencyKey is required/, + ); + }); + + it('keeps a future-dated transfer pending', () => { + const { transfer } = service.initiateTransfer({ + sourceAccountId: usd, + destinationAccountId: eur, + amount: 100, + currency: 'USD', + idempotencyKey: 'scheduled', + scheduledFor: new Date(now + 60_000).toISOString(), + }); + + expect(transfer.status).toBe('pending'); + expect(transfer.rail).toBe('standard'); + expect(transfer.completedAt).toBeUndefined(); + }); + + it('settles a pending transfer explicitly', () => { + const { transfer } = service.initiateTransfer({ + sourceAccountId: usd, + destinationAccountId: eur, + amount: 100, + currency: 'USD', + idempotencyKey: 'settle-later', + scheduledFor: new Date(now + 60_000).toISOString(), + }); + + const settled = service.settleTransfer(transfer.id); + expect(settled.status).toBe('completed'); + }); + }); + + describe('failure paths', () => { + it('rejects an unknown source account', () => { + expectError( + () => + service.initiateTransfer({ + sourceAccountId: 'ghost', + destinationAccountId: eur, + amount: 10, + currency: 'USD', + idempotencyKey: 'ghost-src', + }), + 404, + /Source account not found/, + ); + }); + + it('rejects a frozen source account', () => { + const frozen = service.registerAccount({ currency: 'USD', status: 'frozen' }).id; + expectError( + () => + service.initiateTransfer({ + sourceAccountId: frozen, + destinationAccountId: eur, + amount: 10, + currency: 'USD', + idempotencyKey: 'frozen-src', + }), + 403, + /not active/, + ); + }); + + it('enforces the rolling daily limit', () => { + const big = service.registerAccount({ currency: 'USD' }).id; + service.initiateTransfer({ + sourceAccountId: big, + destinationAccountId: eur, + amount: 25_000, + currency: 'USD', + idempotencyKey: 'day-1', + }); + service.initiateTransfer({ + sourceAccountId: big, + destinationAccountId: eur, + amount: 25_000, + currency: 'USD', + idempotencyKey: 'day-2', + }); + service.initiateTransfer({ + sourceAccountId: big, + destinationAccountId: eur, + amount: 25_000, + currency: 'USD', + idempotencyKey: 'day-3', + }); + service.initiateTransfer({ + sourceAccountId: big, + destinationAccountId: eur, + amount: 25_000, + currency: 'USD', + idempotencyKey: 'day-4', + }); + + expect(service.getDailyVolume(big)).toBe(DEFAULT_TRANSFER_LIMITS.daily); + + expectError( + () => + service.initiateTransfer({ + sourceAccountId: big, + destinationAccountId: eur, + amount: 25_000, + currency: 'USD', + idempotencyKey: 'day-5', + }), + 400, + /Daily transfer limit/, + ); + }); + + it('forgets volume that has aged out of the 24h window', () => { + const account = service.registerAccount({ currency: 'USD' }).id; + service.initiateTransfer({ + sourceAccountId: account, + destinationAccountId: eur, + amount: 25_000, + currency: 'USD', + idempotencyKey: 'aged', + }); + expect(service.getDailyVolume(account)).toBe(25_000); + + now += 24 * 60 * 60 * 1000 + 1; + expect(service.getDailyVolume(account)).toBe(0); + }); + }); + + describe('cancellation and reversal', () => { + it('cancels a pending transfer', () => { + const { transfer } = service.initiateTransfer({ + sourceAccountId: usd, + destinationAccountId: eur, + amount: 100, + currency: 'USD', + idempotencyKey: 'cancel-me', + scheduledFor: new Date(now + 60_000).toISOString(), + }); + + const cancelled = service.cancelTransfer(transfer.id); + expect(cancelled.status).toBe('cancelled'); + expect(cancelled.cancelledAt).toBeTruthy(); + }); + + it('refuses to cancel an already settled transfer', () => { + const { transfer } = service.initiateTransfer({ + sourceAccountId: usd, + destinationAccountId: eur, + amount: 100, + currency: 'USD', + idempotencyKey: 'settled', + }); + + expectError(() => service.cancelTransfer(transfer.id), 409, /cannot be cancelled/); + }); + + it('reverses a completed transfer inside the window', () => { + const { transfer } = service.initiateTransfer({ + sourceAccountId: usd, + destinationAccountId: eur, + amount: 100, + currency: 'USD', + idempotencyKey: 'reverse-me', + }); + + const reversed = service.reverseTransfer(transfer.id, 'duplicate charge'); + expect(reversed.status).toBe('reversed'); + expect(reversed.reversalReason).toBe('duplicate charge'); + }); + + it('refuses to reverse outside the reversal window', () => { + const { transfer } = service.initiateTransfer({ + sourceAccountId: usd, + destinationAccountId: eur, + amount: 100, + currency: 'USD', + idempotencyKey: 'too-late', + }); + + now += DEFAULT_TRANSFER_LIMITS.reversalWindowMs + 1; + expectError(() => service.reverseTransfer(transfer.id, 'late'), 409, /reversal window/); + }); + + it('requires a reversal reason', () => { + const { transfer } = service.initiateTransfer({ + sourceAccountId: usd, + destinationAccountId: eur, + amount: 100, + currency: 'USD', + idempotencyKey: 'no-reason', + }); + expectError(() => service.reverseTransfer(transfer.id, ''), 400, /reason is required/); + }); + + it('refuses to reverse a non-completed transfer', () => { + const { transfer } = service.initiateTransfer({ + sourceAccountId: usd, + destinationAccountId: eur, + amount: 100, + currency: 'USD', + idempotencyKey: 'pending-rev', + scheduledFor: new Date(now + 60_000).toISOString(), + }); + expectError(() => service.reverseTransfer(transfer.id, 'nope'), 409, /Only completed/); + }); + }); + + describe('listing', () => { + beforeEach(() => { + service.initiateTransfer({ + sourceAccountId: usd, + destinationAccountId: eur, + amount: 100, + currency: 'USD', + idempotencyKey: 'list-1', + }); + service.initiateTransfer({ + sourceAccountId: usd, + destinationAccountId: eur, + amount: 200, + currency: 'USD', + idempotencyKey: 'list-2', + scheduledFor: new Date(now + 60_000).toISOString(), + }); + }); + + it('filters by status', () => { + expect(service.listTransfers({ status: 'completed' }).total).toBe(1); + expect(service.listTransfers({ status: 'pending' }).total).toBe(1); + }); + + it('filters by participating account', () => { + expect(service.listTransfers({ accountId: usd }).total).toBe(2); + expect(service.listTransfers({ accountId: eur }).total).toBe(2); + expect(service.listTransfers({ accountId: 'nobody' }).total).toBe(0); + }); + + it('paginates results', () => { + const page = service.listTransfers({ limit: 1, offset: 0 }); + expect(page.transfers).toHaveLength(1); + expect(page.total).toBe(2); + }); + }); + + it('clears state between tests via resetForTests', () => { + service.resetForTests(); + expect(service.listTransfers().total).toBe(0); + }); +}); diff --git a/backend/src/services/transfers.ts b/backend/src/services/transfers.ts new file mode 100644 index 00000000..ecbce468 --- /dev/null +++ b/backend/src/services/transfers.ts @@ -0,0 +1,471 @@ +/** + * Transfers.ts — Issue #913 + * + * Instant account-to-account (A2A) transfers. + * + * The service models a real-time bank transfer rail: + * - accounts are registered and validated before money can move; + * - a short-lived quote locks the fee and FX rate shown to the customer; + * - transfers are initiated with an idempotency key so retries never + * double-charge; + * - instant transfers settle synchronously, while transfers with a future + * `scheduledFor` remain `pending` and can be cancelled before settlement; + * - completed transfers can be reversed inside a configurable window. + */ + +import { randomUUID } from 'node:crypto'; +import { BaseService } from './BaseService.js'; + +export type TransferStatus = + | 'pending' + | 'processing' + | 'completed' + | 'failed' + | 'cancelled' + | 'reversed'; + +export type TransferRail = 'instant' | 'standard'; + +export type AccountStatus = 'active' | 'frozen' | 'closed'; + +export interface Account { + id: string; + currency: string; + status: AccountStatus; + holderName?: string; + createdAt: string; +} + +export interface TransferQuote { + id: string; + sourceAccountId: string; + destinationAccountId: string; + amount: number; + currency: string; + destinationCurrency: string; + destinationAmount: number; + fee: number; + totalDebit: number; + fxRate: number; + rail: TransferRail; + expiresAt: string; + createdAt: string; +} + +export interface Transfer { + id: string; + quoteId?: string; + idempotencyKey: string; + sourceAccountId: string; + destinationAccountId: string; + amount: number; + fee: number; + totalDebit: number; + currency: string; + destinationCurrency: string; + destinationAmount: number; + fxRate: number; + rail: TransferRail; + status: TransferStatus; + scheduledFor?: string; + completedAt?: string; + failedAt?: string; + failureReason?: string; + cancelledAt?: string; + reversedAt?: string; + reversalReason?: string; + metadata?: Record; + createdAt: string; + updatedAt: string; +} + +export interface CreateTransferInput { + quoteId?: string; + sourceAccountId: string; + destinationAccountId: string; + amount: number; + currency: string; + destinationCurrency?: string; + idempotencyKey: string; + scheduledFor?: string; + metadata?: Record; +} + +/** Currencies that settle on the instant rail. Others fall back to standard. */ +const INSTANT_CURRENCIES = new Set(['USD', 'EUR', 'GBP', 'USDC']); + +/** Indicative FX rates against USD. In production these come from a rates feed. */ +const FX_RATES: Record = { + USD: 1, + USDC: 1, + EUR: 0.92, + GBP: 0.79, + NGN: 1550, + INR: 83.2, + BRL: 5.05, +}; + +export interface TransferLimits { + /** Maximum notional for a single transfer, in source currency. */ + perTransfer: number; + /** Rolling 24h volume allowed per source account, in source currency. */ + daily: number; + /** Millisecond window in which a completed transfer may be reversed. */ + reversalWindowMs: number; + /** Lifetime of a quote, in milliseconds. */ + quoteTtlMs: number; +} + +export const DEFAULT_TRANSFER_LIMITS: TransferLimits = { + perTransfer: 25_000, + daily: 100_000, + reversalWindowMs: 24 * 60 * 60 * 1000, + quoteTtlMs: 60_000, +}; + +export class TransferService extends BaseService { + private accounts = new Map(); + private quotes = new Map(); + private transfers = new Map(); + private idempotencyIndex = new Map(); + + constructor( + private readonly limits: TransferLimits = DEFAULT_TRANSFER_LIMITS, + private readonly now: () => number = Date.now, + ) { + super(); + } + + // ---------------------------------------------------------------- accounts + + registerAccount(params: { + id?: string; + currency: string; + status?: AccountStatus; + holderName?: string; + }): Account { + const currency = params.currency.toUpperCase(); + this.validate(currency.length === 3, 'Currency must be a 3-letter ISO code'); + this.validate(!!FX_RATES[currency], `Unsupported currency: ${currency}`); + + const account: Account = { + id: params.id ?? `acct_${randomUUID()}`, + currency, + status: params.status ?? 'active', + holderName: params.holderName, + createdAt: new Date(this.now()).toISOString(), + }; + + this.accounts.set(account.id, account); + return account; + } + + getAccount(id: string): Account | undefined { + return this.accounts.get(id); + } + + // ------------------------------------------------------------------ quotes + + createQuote(params: { + sourceAccountId: string; + destinationAccountId: string; + amount: number; + destinationCurrency?: string; + }): TransferQuote { + const { source, destination, amount, currency, destinationCurrency, rail } = + this.resolveTransferContext(params); + + const { fee, fxRate, destinationAmount } = this.priceTransfer(amount, currency, destinationCurrency); + + const createdAt = this.now(); + const quote: TransferQuote = { + id: `qt_${randomUUID()}`, + sourceAccountId: source.id, + destinationAccountId: destination.id, + amount: this.round(amount), + currency, + destinationCurrency, + destinationAmount, + fee, + totalDebit: this.round(amount + fee), + fxRate, + rail, + expiresAt: new Date(createdAt + this.limits.quoteTtlMs).toISOString(), + createdAt: new Date(createdAt).toISOString(), + }; + + this.quotes.set(quote.id, quote); + return quote; + } + + getQuote(id: string): TransferQuote | undefined { + return this.quotes.get(id); + } + + // --------------------------------------------------------------- transfers + + /** + * Initiate a transfer. Retrying with the same `idempotencyKey` returns the + * original transfer instead of moving money twice. + */ + initiateTransfer(input: CreateTransferInput): { transfer: Transfer; idempotent: boolean } { + this.validate(!!input.idempotencyKey, 'idempotencyKey is required'); + + const existingId = this.idempotencyIndex.get(input.idempotencyKey); + if (existingId) { + const existing = this.transfers.get(existingId); + if (existing) return { transfer: existing, idempotent: true }; + } + + const context = this.resolveTransferContext(input); + const scheduledFor = input.scheduledFor ? new Date(input.scheduledFor) : undefined; + if (scheduledFor) { + this.validate(!Number.isNaN(scheduledFor.getTime()), 'scheduledFor must be a valid ISO date'); + } + + const isFutureDated = !!scheduledFor && scheduledFor.getTime() > this.now(); + const rail: TransferRail = isFutureDated ? 'standard' : context.rail; + + let fee: number; + let fxRate: number; + let destinationAmount: number; + let quoteId: string | undefined; + + if (input.quoteId) { + const quote = this.quotes.get(input.quoteId); + if (!quote) this.notFound('Transfer quote', input.quoteId); + this.validate( + new Date(quote.expiresAt).getTime() > this.now(), + 'Transfer quote has expired', + ); + this.validate( + quote.sourceAccountId === context.source.id && + quote.destinationAccountId === context.destination.id, + 'Transfer quote does not match the source/destination accounts', + ); + this.validate( + this.round(quote.amount) === this.round(input.amount), + 'Transfer amount does not match the quote', + ); + fee = quote.fee; + fxRate = quote.fxRate; + destinationAmount = quote.destinationAmount; + quoteId = quote.id; + } else { + ({ fee, fxRate, destinationAmount } = this.priceTransfer( + input.amount, + context.currency, + context.destinationCurrency, + )); + } + + this.assertVelocityLimit(context.source.id, context.currency, input.amount); + + const createdAt = this.now(); + const transfer: Transfer = { + id: `tr_${randomUUID()}`, + quoteId, + idempotencyKey: input.idempotencyKey, + sourceAccountId: context.source.id, + destinationAccountId: context.destination.id, + amount: this.round(input.amount), + fee, + totalDebit: this.round(input.amount + fee), + currency: context.currency, + destinationCurrency: context.destinationCurrency, + destinationAmount, + fxRate, + rail, + status: isFutureDated ? 'pending' : 'processing', + scheduledFor: scheduledFor?.toISOString(), + metadata: input.metadata, + createdAt: new Date(createdAt).toISOString(), + updatedAt: new Date(createdAt).toISOString(), + }; + + this.transfers.set(transfer.id, transfer); + this.idempotencyIndex.set(transfer.idempotencyKey, transfer.id); + + // Instant transfers settle before the response is returned. + if (!isFutureDated) { + this.settleTransfer(transfer.id); + } + + return { transfer: this.transfers.get(transfer.id)!, idempotent: false }; + } + + /** Move a pending transfer onto the rail and settle it. */ + settleTransfer(id: string): Transfer { + const transfer = this.transfers.get(id); + if (!transfer) this.notFound('Transfer', id); + if (transfer.status !== 'pending' && transfer.status !== 'processing') { + this.conflict(`Transfer cannot be settled from status ${transfer.status}`); + } + + const now = this.now(); + transfer.status = 'completed'; + transfer.completedAt = new Date(now).toISOString(); + transfer.updatedAt = transfer.completedAt; + this.transfers.set(id, transfer); + return transfer; + } + + getTransfer(id: string): Transfer | undefined { + return this.transfers.get(id); + } + + listTransfers(filter: { + accountId?: string; + status?: TransferStatus; + limit?: number; + offset?: number; + } = {}): { transfers: Transfer[]; total: number } { + const all = Array.from(this.transfers.values()) + .filter((t) => (filter.accountId ? t.sourceAccountId === filter.accountId || t.destinationAccountId === filter.accountId : true)) + .filter((t) => (filter.status ? t.status === filter.status : true)) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + const offset = filter.offset ?? 0; + const limit = Math.min(filter.limit ?? 50, 100); + + return { transfers: all.slice(offset, offset + limit), total: all.length }; + } + + /** Cancel a transfer that has not settled yet. */ + cancelTransfer(id: string): Transfer { + const transfer = this.transfers.get(id); + if (!transfer) this.notFound('Transfer', id); + this.conflictIfSettled(transfer, 'cancel'); + + transfer.status = 'cancelled'; + transfer.cancelledAt = new Date(this.now()).toISOString(); + transfer.updatedAt = transfer.cancelledAt; + this.transfers.set(id, transfer); + return transfer; + } + + /** Reverse a settled transfer inside the reversal window. */ + reverseTransfer(id: string, reason: string): Transfer { + this.validate(!!reason, 'Reversal reason is required'); + + const transfer = this.transfers.get(id); + if (!transfer) this.notFound('Transfer', id); + if (transfer.status !== 'completed') { + this.conflict('Only completed transfers can be reversed'); + } + + const completedAt = transfer.completedAt ? new Date(transfer.completedAt).getTime() : 0; + if (this.now() - completedAt > this.limits.reversalWindowMs) { + this.conflict('Transfer is outside the reversal window'); + } + + transfer.status = 'reversed'; + transfer.reversedAt = new Date(this.now()).toISOString(); + transfer.reversalReason = reason; + transfer.updatedAt = transfer.reversedAt; + this.transfers.set(id, transfer); + return transfer; + } + + /** Total settled/in-flight volume for an account in the last 24 hours. */ + getDailyVolume(accountId: string): number { + const since = this.now() - 24 * 60 * 60 * 1000; + return this.round( + Array.from(this.transfers.values()) + .filter((t) => t.sourceAccountId === accountId) + .filter((t) => ['pending', 'processing', 'completed'].includes(t.status)) + .filter((t) => new Date(t.createdAt).getTime() >= since) + .reduce((sum, t) => sum + t.amount, 0), + ); + } + + resetForTests(): void { + this.accounts.clear(); + this.quotes.clear(); + this.transfers.clear(); + this.idempotencyIndex.clear(); + } + + // ---------------------------------------------------------------- internals + + private resolveTransferContext(params: { + sourceAccountId: string; + destinationAccountId: string; + amount: number; + destinationCurrency?: string; + }): { + source: Account; + destination: Account; + amount: number; + currency: string; + destinationCurrency: string; + rail: TransferRail; + } { + this.validate(params.amount > 0, 'Amount must be greater than 0'); + this.validate( + params.sourceAccountId !== params.destinationAccountId, + 'Source and destination accounts must be different', + ); + + const source = this.accounts.get(params.sourceAccountId); + if (!source) this.notFound('Source account', params.sourceAccountId); + const destination = this.accounts.get(params.destinationAccountId); + if (!destination) this.notFound('Destination account', params.destinationAccountId); + + if (source.status !== 'active') this.forbidden('Source account is not active'); + if (destination.status !== 'active') this.forbidden('Destination account is not active'); + this.validate( + params.amount <= this.limits.perTransfer, + `Amount exceeds the per-transfer limit of ${this.limits.perTransfer} ${source.currency}`, + ); + + const destinationCurrency = (params.destinationCurrency ?? destination.currency).toUpperCase(); + const rail: TransferRail = + INSTANT_CURRENCIES.has(source.currency) && INSTANT_CURRENCIES.has(destinationCurrency) + ? 'instant' + : 'standard'; + + return { + source, + destination, + amount: params.amount, + currency: source.currency, + destinationCurrency, + rail, + }; + } + + private priceTransfer(amount: number, currency: string, destinationCurrency: string) { + // Flat 0.5% with a 0.50 floor and 25.00 cap. + const fee = this.round(Math.min(Math.max(amount * 0.005, 0.5), 25)); + const sourceRate = FX_RATES[currency]; + const destinationRate = FX_RATES[destinationCurrency]; + this.validate(!!sourceRate, `Unsupported source currency: ${currency}`); + this.validate(!!destinationRate, `Unsupported destination currency: ${destinationCurrency}`); + + const fxRate = this.round(destinationRate / sourceRate, 6); + const destinationAmount = this.round(amount * fxRate); + return { fee, fxRate, destinationAmount }; + } + + private assertVelocityLimit(accountId: string, currency: string, amount: number): void { + const projected = this.getDailyVolume(accountId) + amount; + this.validate( + projected <= this.limits.daily, + `Daily transfer limit of ${this.limits.daily} ${currency} would be exceeded`, + ); + } + + private conflictIfSettled(transfer: Transfer, action: string): void { + if (transfer.status !== 'pending') { + this.conflict(`Transfer in status ${transfer.status} cannot be ${action}led`); + } + } + + private round(value: number, decimals = 2): number { + const factor = 10 ** decimals; + return Math.round((value + Number.EPSILON) * factor) / factor; + } +} + +export const transferService = new TransferService(); From 3296c9bdf90617258df3af6119f911ae063ac3a1 Mon Sep 17 00:00:00 2001 From: davidsoniaudin2-oss Date: Mon, 28 Sep 2026 03:39:25 +0000 Subject: [PATCH 2/2] docs(transfers): document the instant A2A transfer rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- docs/INSTANT_TRANSFERS.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 docs/INSTANT_TRANSFERS.md diff --git a/docs/INSTANT_TRANSFERS.md b/docs/INSTANT_TRANSFERS.md new file mode 100644 index 00000000..a996ea66 --- /dev/null +++ b/docs/INSTANT_TRANSFERS.md @@ -0,0 +1,39 @@ +# Instant Account-to-Account Transfers + +Issue: [#913](https://github.com/Smartdevs17/agenticpay/issues/913) + +Real-time transfers between two registered accounts, with short-lived quotes, +idempotent initiation, velocity limits, cancellation and reversals. + +## Backend + +Service: `backend/src/services/transfers.ts` +Routes: `backend/src/routes/transfers.ts` (mounted at `/api/v1/transfers`) + +| Method | Path | Purpose | +| --- | --- | --- | +| `POST` | `/accounts` | Register an account (currency, status, holder) | +| `POST` | `/quotes` | Lock the fee and FX rate for a transfer | +| `POST` | `/transfers` | Initiate a transfer (idempotent) | +| `GET` | `/transfers` | List transfers (`accountId`, `status`, `limit`, `offset`) | +| `GET` | `/transfers/:id` | Read a transfer | +| `POST` | `/transfers/:id/cancel` | Cancel a not-yet-settled transfer | +| `POST` | `/transfers/:id/reverse` | Reverse a completed transfer inside the window | +| `GET` | `/accounts/:id/volume` | Rolling 24h volume for an account | + +### Behaviour + +- **Quotes** live for 60 seconds (`quoteTtlMs`) and lock the fee and FX rate. + The fee is 0.5% of the amount with a 0.50 floor and 25.00 cap. +- **Idempotency** — reusing an `idempotencyKey` (body or `Idempotency-Key` + header) returns the original transfer instead of moving money twice. +- **Limits** — 25,000 per transfer and 100,000 per source account per rolling + 24 hours by default (`DEFAULT_TRANSFER_LIMITS`). +- **Settlement** — same-currency instant-rail transfers settle synchronously + and return `completed`. Transfers with a future `scheduledFor` stay `pending` + on the standard rail and can be cancelled. +- **Reversals** are allowed for 24 hours after settlement. + +## Tests + +`backend/src/services/__tests__/instant-transfers.test.ts`