From 8bed202bfc030e5a2f9e0e2c81099080c598edab Mon Sep 17 00:00:00 2001 From: Maryermarh <119738147+Maryermarh@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:26:42 +0100 Subject: [PATCH] feat(payments): bound Soroban RPC calls, add rail failover, and lock currency math to integers Soroban RPC calls are now bounded. Every getAccount, simulate, send and status read runs inside the existing withTimeout helper with a configurable SOROBAN_RPC_TIMEOUT_MS (default 10000, validated at construction). A timeout is treated as transient: it retries within that endpoint's budget and then fails over to the next endpoint exactly like a connection outage, because a hung endpoint is not a terminal provider verdict. Rail failover is now an explicit, opt-in policy rather than an implicit failure. PaymentRailRegistry gains an availability-aware resolve() that follows ordered PAYMENT_RAIL_FAILOVER edges, and it is used at initiation only. The Payment row stores the rail that was actually used and the requested rail is retained in metadata, so webhook, reconciliation and refund lookups keep resolving the same adapter that initiated the payment. The strict get() path is unchanged for every post-initiation caller. See the new payments/adapters/README.md for the policy and its documented limits. Request correlation is propagated to outbound calls through a single outboundRequestHeaders() helper that returns both accepted inbound header names and never emits a placeholder id. It is applied to the manual-review SMTP alert, and to the Soroban RPC transport through the SDK seam the pinned version exposes, with an explicit note where a provider cannot accept custom headers. Currency arithmetic was audited across the backend. payments/utils contained no money math, and the existing basis-point allocation already used guarded integer arithmetic, so no correct code was rewritten. Instead payments/utils/minor-units.ts adds a strict integer-only primitive for future payment code, and regression assertions lock in the existing allocation's exactness. Closes #1785 Closes #1784 Closes #1783 Closes #1782 --- backend/.env.example | 14 +- backend/src/common/request-context.spec.ts | 43 ++++ backend/src/common/request-context.ts | 22 ++ backend/src/credits/split-allocation.spec.ts | 3 + backend/src/payments/adapters/README.md | 58 ++++++ .../payments/payment-rail-registry.spec.ts | 55 +++++ backend/src/payments/payment-rail-registry.ts | 145 +++++++++++++ backend/src/payments/payments.module.ts | 28 ++- backend/src/payments/payments.service.spec.ts | 72 ++++++- backend/src/payments/payments.service.ts | 78 ++++++- .../payments/reconciliation.service.spec.ts | 47 ++++- .../src/payments/reconciliation.service.ts | 13 +- .../soroban/soroban-rpc-client.spec.ts | 46 ++++- .../payments/soroban/soroban-rpc-client.ts | 192 ++++++++++++++++-- .../src/payments/utils/minor-units.spec.ts | 164 +++++++++++++++ backend/src/payments/utils/minor-units.ts | 179 ++++++++++++++++ 16 files changed, 1118 insertions(+), 41 deletions(-) create mode 100644 backend/src/common/request-context.spec.ts create mode 100644 backend/src/payments/adapters/README.md create mode 100644 backend/src/payments/utils/minor-units.spec.ts create mode 100644 backend/src/payments/utils/minor-units.ts diff --git a/backend/.env.example b/backend/.env.example index f655b283..a61971fb 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -50,8 +50,9 @@ EMAIL_RATE_LIMIT_PER_HOUR=100 # On-chain escrow for long-term (MONTHLY/QUARTERLY/YEARLY) bookings. # Leave SOROBAN_ENABLED=false (or unset) to skip escrow cleanly — no Stellar # config is required and long-term bookings confirm normally. -# When SOROBAN_ENABLED=true, ALL of the variables below are required or the -# app will refuse to start with an error naming the missing variable(s). +# When SOROBAN_ENABLED=true, the required identity/contract variables below +# must be present or the app will refuse to start with an error naming the +# missing variable(s). Optional resilience controls are documented inline. SOROBAN_ENABLED=false STELLAR_ESCROW_CONTRACT_ID=your_stellar_escrow_contract_id STELLAR_SECRET_KEY=your_stellar_secret_key @@ -63,6 +64,9 @@ STELLAR_HORIZON_URL=https://soroban-testnet.stellar.org # (issue #1574). Falls back to a single endpoint (STELLAR_HORIZON_URL) # when unset; for real resilience, configure at least two. STELLAR_RPC_URLS=https://soroban-testnet.stellar.org,https://rpc-futurenet.stellar.org +# Per-RPC-call timeout (ms). Defaults to 10000; a timeout is transient and +# triggers retry/failover to the next configured RPC endpoint. +SOROBAN_RPC_TIMEOUT_MS=10000 # How long the submission pipeline polls a submitted transaction for # finality before giving up and leaving the Payment AWAITING_CONFIRMATION # for chain-state reconciliation to pick up later. @@ -75,6 +79,12 @@ BOOKING_PAYMENT_TTL_MINUTES=120 # Payments module (issue #1570) # Minutes an INITIATED Payment may sit before it is eligible for expiry sweep PAYMENT_INITIATED_TTL_MINUTES=30 +# Optional, explicit rail failover for new initiations only. Each entry is +# FROM=TO; leave unset/empty to preserve the existing unavailable-rail error. +# Example: PAYMENT_RAIL_FAILOVER=STELLAR_CUSTODIAL=FIAT,FIAT= +# The rail actually used is stored on the Payment and the requested rail is +# kept in metadata; already-created Payments are never switched. +PAYMENT_RAIL_FAILOVER= # Payments confirmation pipeline (issue #1571) # Shared secret for verifying the sandbox rail's webhook HMAC signature diff --git a/backend/src/common/request-context.spec.ts b/backend/src/common/request-context.spec.ts new file mode 100644 index 00000000..baf96936 --- /dev/null +++ b/backend/src/common/request-context.spec.ts @@ -0,0 +1,43 @@ +import { + currentRequestId, + outboundRequestHeaders, + runWithRequestContext, + withRequestId, +} from './request-context'; + +describe('request context', () => { + it('returns both accepted correlation header names inside a request', () => { + const headers = runWithRequestContext( + { requestId: 'request-123' }, + () => outboundRequestHeaders(), + ); + + expect(headers).toEqual({ + 'x-request-id': 'request-123', + 'x-correlation-id': 'request-123', + }); + }); + + it('returns no headers outside a request context', () => { + expect(currentRequestId()).toBeUndefined(); + expect(outboundRequestHeaders()).toEqual({}); + }); + + it('does not emit a placeholder for an empty request id', () => { + const headers = runWithRequestContext( + { requestId: '' }, + () => outboundRequestHeaders(), + ); + + expect(headers).toEqual({}); + }); + + it('keeps the request id available to log formatting', () => { + const message = runWithRequestContext( + { requestId: 'request-456' }, + () => withRequestId('doing work'), + ); + + expect(message).toBe('[request:request-456] doing work'); + }); +}); diff --git a/backend/src/common/request-context.ts b/backend/src/common/request-context.ts index 56e6fb17..bccd19f8 100644 --- a/backend/src/common/request-context.ts +++ b/backend/src/common/request-context.ts @@ -17,6 +17,28 @@ export function currentRequestId(): string | undefined { return requestContext.getStore()?.requestId; } +/** + * Builds the correlation headers for an outbound call made by the current + * request. The middleware already accepts `x-request-id` and + * `x-correlation-id` as inbound headers, so sending both names lets the + * identifier survive a full round trip through services that choose either + * convention. An empty object is returned when no request context is active; + * callers must not manufacture a placeholder identifier for background work + * or for code running outside an HTTP request. This helper can populate only + * an API that accepts custom headers; a provider that discards or does not + * expose them cannot be made traceable by adding fields to this object. + */ +export function outboundRequestHeaders(): Record { + const requestId = currentRequestId(); + if (!requestId) { + return {}; + } + return { + 'x-request-id': requestId, + 'x-correlation-id': requestId, + }; +} + export function withRequestId(message: string): string { const requestId = currentRequestId(); return requestId ? `[request:${requestId}] ${message}` : message; diff --git a/backend/src/credits/split-allocation.spec.ts b/backend/src/credits/split-allocation.spec.ts index 7ba2b54b..5a306de6 100644 --- a/backend/src/credits/split-allocation.spec.ts +++ b/backend/src/credits/split-allocation.spec.ts @@ -90,6 +90,9 @@ describe('allocateByBasisPoints', () => { for (const amount of amounts) { const allocations = allocateByBasisPoints(amount, config); expect(total(allocations)).toBe(amount); + expect( + allocations.every((a) => Number.isInteger(a.amount)), + ).toBe(true); expect(allocations.every((a) => a.amount >= 0)).toBe(true); // The remainder pass hands out strictly fewer units than there are // recipients, so nobody can be topped up twice in one allocation. diff --git a/backend/src/payments/adapters/README.md b/backend/src/payments/adapters/README.md new file mode 100644 index 00000000..0b754347 --- /dev/null +++ b/backend/src/payments/adapters/README.md @@ -0,0 +1,58 @@ +# Payment-rail failover + +Payment initiation may use a different rail when the requested rail is not +available, but the policy is deliberately opt-in. `PaymentRailRegistry.get()` +is the strict lookup used after a payment exists; `resolve()` is used only by +`PaymentsService` while initiating a new payment. + +## What availability means + +Availability currently means that the adapter is registered and configured: + +- `FIAT` is available through the sandbox adapter. +- `STELLAR_CUSTODIAL` and `STELLAR_EXTERNAL` are available only when the + Soroban adapter is configured. +- Resolution does not perform a live network health check. An adapter that + is registered can still fail when its provider call is made; switching after + that call could duplicate a charge or an escrow, so it is not attempted. + +## Configuration + +Set `PAYMENT_RAIL_FAILOVER` to a comma-separated list of explicit +`FROM=TO` mappings. The mappings form an ordered, auditable chain: + +```dotenv +PAYMENT_RAIL_FAILOVER=STELLAR_CUSTODIAL=FIAT,FIAT= +``` + +The example means that an unavailable `STELLAR_CUSTODIAL` may fall back to +`FIAT`; the empty `FIAT=` target explicitly ends the chain. A longer chain +can be expressed by linking mappings: + +```dotenv +PAYMENT_RAIL_FAILOVER=STELLAR_CUSTODIAL=FIAT,FIAT=STELLAR_EXTERNAL,STELLAR_EXTERNAL= +``` + +A source or target that is not a known `PaymentRail`, a duplicate source, or +a cycle is rejected with a configuration error. If the variable is unset or +empty, there is no fallback and the existing clear unavailable-rail error is +preserved. + +## Stored-rail invariant + +When a fallback is selected, `PaymentsService` stores the rail that actually +received the initiation call in `Payment.rail` and records the caller's +request as `metadata.requestedRail`. The original request is retained so an +idempotency-key replay compares against the requested payload rather than the +fallback implementation detail. + +The stored rail is never rewritten later. Webhook confirmation, +reconciliation, verify-on-return, and refund provider calls resolve the +adapter from `Payment.rail`, so changing it after initiation could send a +provider reference to a different rail and break confirmation. + +If no configured fallback is available, initiation fails with the existing +clear configuration error; it does not enter a silent degraded mode. Once a +provider call has started, an error is left to the normal payment error and +reconciliation paths rather than starting a second provider call on another +rail. diff --git a/backend/src/payments/payment-rail-registry.spec.ts b/backend/src/payments/payment-rail-registry.spec.ts index e96855fd..49e2bccd 100644 --- a/backend/src/payments/payment-rail-registry.spec.ts +++ b/backend/src/payments/payment-rail-registry.spec.ts @@ -33,4 +33,59 @@ describe('PaymentRailRegistry', () => { /SOROBAN_ENABLED/, ); }); + + it('resolves an available requested rail without using a fallback', () => { + const sandbox = {} as any; + const config = { + get: jest.fn((key: string) => + key === 'PAYMENT_RAIL_FAILOVER' + ? 'STELLAR_CUSTODIAL=FIAT,FIAT=' + : undefined, + ), + }; + const registry = new PaymentRailRegistry( + sandbox, + undefined, + config as any, + ); + + expect(registry.resolve(PaymentRail.FIAT)).toEqual({ + adapter: sandbox, + rail: PaymentRail.FIAT, + usedFallback: false, + }); + }); + + it('uses the first explicitly configured available fallback', () => { + const sandbox = {} as any; + const config = { + get: jest.fn((key: string) => + key === 'PAYMENT_RAIL_FAILOVER' + ? 'STELLAR_CUSTODIAL=FIAT,FIAT=' + : undefined, + ), + }; + const registry = new PaymentRailRegistry( + sandbox, + undefined, + config as any, + ); + + expect(registry.resolve(PaymentRail.STELLAR_CUSTODIAL)).toEqual({ + adapter: sandbox, + rail: PaymentRail.FIAT, + usedFallback: true, + }); + }); + + it('keeps the strict stored-rail error when no fallback is configured', () => { + const sandbox = {} as any; + const registry = new PaymentRailRegistry(sandbox, undefined); + + expect(registry.isAvailable(PaymentRail.FIAT)).toBe(true); + expect(registry.isAvailable(PaymentRail.STELLAR_CUSTODIAL)).toBe(false); + expect(() => registry.resolve(PaymentRail.STELLAR_CUSTODIAL)).toThrow( + /SOROBAN_ENABLED/, + ); + }); }); diff --git a/backend/src/payments/payment-rail-registry.ts b/backend/src/payments/payment-rail-registry.ts index 6db7c9d8..1b0e2cc5 100644 --- a/backend/src/payments/payment-rail-registry.ts +++ b/backend/src/payments/payment-rail-registry.ts @@ -1,9 +1,18 @@ import { Inject, Injectable, Optional } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { PaymentRail } from './enums/payment-rail.enum'; import { PaymentRailAdapter } from './interfaces/payment-rail-adapter.interface'; import { SandboxRailAdapter } from './adapters/sandbox-rail.adapter'; import { SOROBAN_RAIL_ADAPTER } from './soroban/soroban.tokens'; +export interface PaymentRailResolution { + adapter: PaymentRailAdapter; + rail: PaymentRail; + usedFallback: boolean; +} + +type FallbackMap = Map; + /** * Resolves the right PaymentRailAdapter for a Payment#rail (issue #1574 — * #1570 only ever needed one adapter, so nothing dispatched by rail yet). @@ -11,6 +20,13 @@ import { SOROBAN_RAIL_ADAPTER } from './soroban/soroban.tokens'; * to the Soroban adapter only when it's actually configured * (SOROBAN_ENABLED=true) — otherwise callers get a clear error instead of * a payment silently going nowhere. + * + * `get` is the strict stored-rail lookup. `resolve` is deliberately separate + * and is for initiation only: it may choose a configured fallback before a + * provider is called, but it never changes the rail of a Payment that has + * already been created. Webhooks, reconciliation, and refunds therefore keep + * using `get(payment.rail)` and can always find the adapter that initiated + * the payment. */ @Injectable() export class PaymentRailRegistry { @@ -19,8 +35,15 @@ export class PaymentRailRegistry { @Optional() @Inject(SOROBAN_RAIL_ADAPTER) private readonly sorobanRailAdapter: PaymentRailAdapter | undefined, + @Optional() + @Inject(ConfigService) + private readonly config?: ConfigService, ) {} + /** + * Strict lookup used by every post-initiation path. An unavailable rail + * retains the original, actionable configuration error. + */ get(rail: PaymentRail): PaymentRailAdapter { switch (rail) { case PaymentRail.FIAT: @@ -36,4 +59,126 @@ export class PaymentRailRegistry { return this.sorobanRailAdapter; } } + + /** + * Reports configuration/registration availability without making a provider + * network call. The current adapters have no health-probe contract, so an + * adapter that is registered is considered available until an actual + * initiation call reports a provider error. This keeps selection side + * effect free and avoids pretending that a DNS or RPC probe is a payment + * verdict. + */ + isAvailable(rail: PaymentRail): boolean { + return this.tryGet(rail) !== undefined; + } + + /** + * Resolves a rail for a new initiation. If the requested adapter is not + * registered, follows the explicit `PAYMENT_RAIL_FAILOVER` edges in their + * configured order and returns the first available adapter. The requested + * rail is never silently rewritten on an existing Payment; the caller must + * persist the returned rail and retain the request in metadata. + */ + resolve(rail: PaymentRail): PaymentRailResolution { + const requestedAdapter = this.tryGet(rail); + if (requestedAdapter) { + return { adapter: requestedAdapter, rail, usedFallback: false }; + } + + for (const fallbackRail of this.fallbackChain(rail)) { + const fallbackAdapter = this.tryGet(fallbackRail); + if (fallbackAdapter) { + return { + adapter: fallbackAdapter, + rail: fallbackRail, + usedFallback: true, + }; + } + } + + this.get(rail); + throw new Error(`Payment rail ${rail} is unavailable and has no fallback`); + } + + private tryGet(rail: PaymentRail): PaymentRailAdapter | undefined { + try { + return this.get(rail) ?? undefined; + } catch { + return undefined; + } + } + + /** + * Parses the opt-in policy as a graph of one-way `FROM=TO` edges. A blank + * target such as `FIAT=` explicitly terminates a chain. Multiple mappings + * can describe a longer ordered chain, for example + * `STELLAR_CUSTODIAL=FIAT,FIAT=STELLAR_EXTERNAL`. + */ + private fallbackChain(requestedRail: PaymentRail): PaymentRail[] { + const mappings = this.parseFallbackMappings(); + const chain: PaymentRail[] = []; + const visited = new Set([requestedRail]); + let current = mappings.get(requestedRail); + + while (current !== undefined && current !== null) { + if (visited.has(current)) { + throw new Error( + `PAYMENT_RAIL_FAILOVER contains a cycle involving ${current}`, + ); + } + chain.push(current); + visited.add(current); + current = mappings.get(current); + } + + return chain; + } + + private parseFallbackMappings(): FallbackMap { + const mappings: FallbackMap = new Map(); + const raw = this.config?.get('PAYMENT_RAIL_FAILOVER'); + if (!raw?.trim()) { + return mappings; + } + + for (const entry of raw.split(',')) { + const trimmed = entry.trim(); + if (!trimmed) { + continue; + } + const separator = trimmed.indexOf('='); + if (separator <= 0) { + throw new Error( + `Invalid PAYMENT_RAIL_FAILOVER entry "${trimmed}"; expected FROM=TO`, + ); + } + + const from = this.parseRail( + trimmed.slice(0, separator).trim(), + 'PAYMENT_RAIL_FAILOVER source', + ); + if (mappings.has(from)) { + throw new Error( + `PAYMENT_RAIL_FAILOVER contains more than one mapping for ${from}`, + ); + } + + const target = trimmed.slice(separator + 1).trim(); + mappings.set( + from, + target + ? this.parseRail(target, 'PAYMENT_RAIL_FAILOVER target') + : null, + ); + } + + return mappings; + } + + private parseRail(value: string, label: string): PaymentRail { + if (!Object.values(PaymentRail).includes(value as PaymentRail)) { + throw new Error(`${label} is not a known payment rail: ${value}`); + } + return value as PaymentRail; + } } diff --git a/backend/src/payments/payments.module.ts b/backend/src/payments/payments.module.ts index e703149b..0687ae53 100644 --- a/backend/src/payments/payments.module.ts +++ b/backend/src/payments/payments.module.ts @@ -27,8 +27,10 @@ import { EXTERNAL_PAYOUT_RAIL } from '../credits/credits.tokens'; import { EscrowSubmissionProcessor } from './soroban/escrow-submission.processor'; import { EscrowContractClient } from './soroban/escrow-contract.client'; import { + DEFAULT_SOROBAN_RPC_TIMEOUT_MS, SorobanRpcClient, createSorobanRpcServer, + parseSorobanRpcTimeoutMs, } from './soroban/soroban-rpc-client'; import { ESCROW_CONTRACT_CLIENT, @@ -70,13 +72,25 @@ import { }, { provide: SorobanRpcClient, - inject: [SOROBAN_CONFIG], - useFactory: (sorobanConfig: ReturnType) => - sorobanConfig - ? new SorobanRpcClient( - sorobanConfig.rpcUrls.map(createSorobanRpcServer), - ) - : null, + inject: [SOROBAN_CONFIG, ConfigService], + useFactory: ( + sorobanConfig: ReturnType, + config: ConfigService, + ) => { + if (!sorobanConfig) { + return null; + } + const timeoutMs = parseSorobanRpcTimeoutMs( + config.get( + 'SOROBAN_RPC_TIMEOUT_MS', + DEFAULT_SOROBAN_RPC_TIMEOUT_MS, + ), + ); + return new SorobanRpcClient( + sorobanConfig.rpcUrls.map(createSorobanRpcServer), + timeoutMs, + ); + }, }, { provide: ESCROW_CONTRACT_CLIENT, diff --git a/backend/src/payments/payments.service.spec.ts b/backend/src/payments/payments.service.spec.ts index d42e7f12..592a194a 100644 --- a/backend/src/payments/payments.service.spec.ts +++ b/backend/src/payments/payments.service.spec.ts @@ -55,7 +55,7 @@ function uniqueViolation(constraint: string) { describe('PaymentsService', () => { let repository: MockRepository; let railAdapter: { initiate: jest.Mock }; - let railRegistry: { get: jest.Mock }; + let railRegistry: { get: jest.Mock; resolve: jest.Mock }; let config: { get: jest.Mock }; let metrics: { recordPaymentTransition: jest.Mock }; let service: PaymentsService; @@ -65,7 +65,14 @@ describe('PaymentsService', () => { railAdapter = { initiate: jest.fn().mockResolvedValue({ providerReference: 'ref-1' }), }; - railRegistry = { get: jest.fn().mockReturnValue(railAdapter) }; + railRegistry = { + get: jest.fn().mockReturnValue(railAdapter), + resolve: jest.fn().mockReturnValue({ + adapter: railAdapter, + rail: PaymentRail.FIAT, + usedFallback: false, + }), + }; config = { get: jest.fn().mockReturnValue(30) }; metrics = { recordPaymentTransition: jest.fn() }; service = new PaymentsService( @@ -96,6 +103,67 @@ describe('PaymentsService', () => { expect(repository.save).toHaveBeenCalledTimes(2); }); + it('stores the fallback rail and retains the requested rail in metadata', async () => { + const fallbackAdapter = { + initiate: jest + .fn() + .mockResolvedValue({ providerReference: 'fallback-ref' }), + }; + railRegistry.resolve.mockReturnValue({ + adapter: fallbackAdapter, + rail: PaymentRail.FIAT, + usedFallback: true, + }); + repository.findOne.mockResolvedValueOnce(null); + repository.findOne.mockResolvedValueOnce(null); + + const result = await service.initiate( + 'user-1', + 'key-fallback', + makeDto({ rail: PaymentRail.STELLAR_CUSTODIAL }), + ); + + expect(result.rail).toBe(PaymentRail.FIAT); + expect(result.metadata).toMatchObject({ + requestedRail: PaymentRail.STELLAR_CUSTODIAL, + }); + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ + rail: PaymentRail.FIAT, + metadata: expect.objectContaining({ + requestedRail: PaymentRail.STELLAR_CUSTODIAL, + }), + }), + ); + expect(fallbackAdapter.initiate).toHaveBeenCalledWith( + expect.objectContaining({ rail: PaymentRail.FIAT }), + ); + expect(railAdapter.initiate).not.toHaveBeenCalled(); + }); + + it('replays a fallback payment against its originally requested rail', async () => { + const existing = { + id: 'p-fallback', + userId: 'user-1', + bookingId: 'booking-1', + amount: 5000, + currency: 'USD', + rail: PaymentRail.FIAT, + metadata: { requestedRail: PaymentRail.STELLAR_CUSTODIAL }, + status: PaymentStatus.AWAITING_CONFIRMATION, + } as unknown as Payment; + repository.findOne.mockResolvedValueOnce(existing); + + const result = await service.initiate( + 'user-1', + 'key-fallback', + makeDto({ rail: PaymentRail.STELLAR_CUSTODIAL }), + ); + + expect(result).toBe(existing); + expect(railRegistry.resolve).not.toHaveBeenCalled(); + }); + it('replays the same Idempotency-Key and returns the original payment without creating a duplicate', async () => { const existing = { id: 'p-1', diff --git a/backend/src/payments/payments.service.ts b/backend/src/payments/payments.service.ts index b2e6ccc8..a9330f85 100644 --- a/backend/src/payments/payments.service.ts +++ b/backend/src/payments/payments.service.ts @@ -3,6 +3,7 @@ import { ConflictException, ForbiddenException, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -17,8 +18,13 @@ import { NON_TERMINAL_PAYMENT_STATUSES, PaymentStatus, } from './enums/payment-status.enum'; +import { PaymentRail } from './enums/payment-rail.enum'; import { assertValidTransition } from './payment-state-machine'; -import { PaymentRailRegistry } from './payment-rail-registry'; +import { + PaymentRailRegistry, + PaymentRailResolution, +} from './payment-rail-registry'; +import { withRequestId } from '../common/request-context'; const USER_IDEMPOTENCY_KEY_CONSTRAINT = 'uq_payments_user_id_idempotency_key'; const BOOKING_NON_TERMINAL_CONSTRAINT = 'uq_payments_booking_id_non_terminal'; @@ -31,6 +37,8 @@ const BLOCKING_STATUSES_FOR_NEW_PAYMENT = [ @Injectable() export class PaymentsService { + private readonly logger = new Logger(PaymentsService.name); + constructor( @InjectRepository(Payment) private readonly paymentRepository: Repository, @@ -55,11 +63,17 @@ export class PaymentsService { await this.assertBookingAvailable(dto.bookingId); - const payment = this.buildInitiatedPayment(userId, idempotencyKey, dto); + const resolution = this.railRegistry.resolve(dto.rail); + const payment = this.buildInitiatedPayment( + userId, + idempotencyKey, + dto, + resolution, + ); try { const saved = await this.paymentRepository.save(payment); - return await this.progressToAwaitingConfirmation(saved); + return await this.progressToAwaitingConfirmation(saved, resolution); } catch (error) { return this.handleInsertConflict(error, userId, idempotencyKey); } @@ -110,7 +124,7 @@ export class PaymentsService { existing.bookingId === dto.bookingId && existing.amount === dto.amount && existing.currency === dto.currency.toUpperCase() && - existing.rail === dto.rail; + this.requestedRailFor(existing) === dto.rail; if (!samePayload) { throw new ConflictException( @@ -120,6 +134,22 @@ export class PaymentsService { return existing; } + /** + * Returns the rail the caller originally requested, even when initiation + * persisted a fallback rail. Without this lookup an idempotency replay of + * the original request would look like a different payload and be rejected. + */ + private requestedRailFor(payment: Payment): PaymentRail { + const requestedRail = payment.metadata?.requestedRail; + const isKnownRail = + requestedRail === PaymentRail.FIAT || + requestedRail === PaymentRail.STELLAR_CUSTODIAL || + requestedRail === PaymentRail.STELLAR_EXTERNAL; + return isKnownRail && requestedRail !== payment.rail + ? requestedRail + : payment.rail; + } + private async assertBookingAvailable(bookingId: string): Promise { const blocking = await this.paymentRepository.findOne({ where: { @@ -140,29 +170,63 @@ export class PaymentsService { userId: string, idempotencyKey: string, dto: InitiatePaymentDto, + resolution: PaymentRailResolution, ): Payment { const ttlMinutes = this.config.get( 'PAYMENT_INITIATED_TTL_MINUTES', 30, ); + const requestedRail = dto.rail; + const usedFallback = + resolution.usedFallback && resolution.rail !== requestedRail; return this.paymentRepository.create({ bookingId: dto.bookingId, userId, amount: dto.amount, currency: dto.currency.toUpperCase(), - rail: dto.rail, + rail: resolution.rail, provider: dto.provider ?? null, status: PaymentStatus.INITIATED, idempotencyKey, - metadata: dto.metadata ?? null, + metadata: usedFallback + ? { ...(dto.metadata ?? {}), requestedRail } + : dto.metadata ?? null, expiresAt: new Date(Date.now() + ttlMinutes * 60_000), }); } + /** + * This is the only point where failover may change a Payment's rail. The + * rail persisted here is the rail whose adapter actually received the + * initiation call; the requested rail is retained in metadata for audit and + * idempotency replay. Once this method has selected a rail, webhook, + * reconciliation, and refund paths must continue to use strict + * `get(payment.rail)` — they must never re-resolve or rewrite a stored + * Payment's rail, or confirmation could be delivered to the wrong adapter. + */ private async progressToAwaitingConfirmation( payment: Payment, + resolution: PaymentRailResolution = this.railRegistry.resolve( + payment.rail, + ), ): Promise { - const result = await this.railRegistry.get(payment.rail).initiate(payment); + const requestedRail = this.requestedRailFor(payment); + if (resolution.usedFallback && resolution.rail !== requestedRail) { + payment.rail = resolution.rail; + payment.metadata = { + ...(payment.metadata ?? {}), + requestedRail, + }; + this.logger.warn( + withRequestId( + `Payment ${payment.id} requested rail ${requestedRail} but rail ` + + `${resolution.rail} is being used because the requested adapter ` + + 'is unavailable', + ), + ); + } + + const result = await resolution.adapter.initiate(payment); payment.providerReference = result.providerReference; this.transitionStatus(payment, PaymentStatus.AWAITING_CONFIRMATION); return this.paymentRepository.save(payment); diff --git a/backend/src/payments/reconciliation.service.spec.ts b/backend/src/payments/reconciliation.service.spec.ts index aed0ba4d..c164ed75 100644 --- a/backend/src/payments/reconciliation.service.spec.ts +++ b/backend/src/payments/reconciliation.service.spec.ts @@ -2,7 +2,9 @@ import { NotFoundException, UnprocessableEntityException, } from '@nestjs/common'; +import * as nodemailer from 'nodemailer'; import { FindOperator } from 'typeorm'; +import { runWithRequestContext } from '../common/request-context'; import { ReconciliationService } from './reconciliation.service'; import { Payment } from './entities/payment.entity'; import { PaymentRail } from './enums/payment-rail.enum'; @@ -112,8 +114,8 @@ function makePaymentRepository(seed: Payment[]) { }; } -function makeConfigService(overrides: Record = {}) { - const values: Record = { +function makeConfigService(overrides: Record = {}) { + const values: Record = { PAYMENT_RECONCILE_DUE_AFTER_MINUTES: 5, PAYMENT_RECONCILE_BACKOFF_BASE_MINUTES: 5, PAYMENT_RECONCILE_BACKOFF_MAX_MINUTES: 60, @@ -124,7 +126,9 @@ function makeConfigService(overrides: Record = {}) { ...overrides, }; return { - get: jest.fn((key: string, fallback?: number) => values[key] ?? fallback), + get: jest.fn( + (key: string, fallback?: number | string) => values[key] ?? fallback, + ), }; } @@ -151,7 +155,7 @@ describe('ReconciliationService', () => { function build( seed: Payment[], - configOverrides: Record = {}, + configOverrides: Record = {}, ) { const paymentRepository = makePaymentRepository(seed); const config = makeConfigService(configOverrides); @@ -457,6 +461,41 @@ describe('ReconciliationService', () => { }); }); + describe('outbound request correlation', () => { + it('attaches the active request id to manual-review SMTP alerts', async () => { + const sendMail = jest.fn().mockResolvedValue(undefined); + const createTransport = jest + .spyOn(nodemailer, 'createTransport') + .mockReturnValue({ sendMail } as any); + const { service } = build([], { + SMTP_HOST: 'smtp.example.test', + SMTP_PORT: 587, + SMTP_SECURE: 'false', + SMTP_USER: 'alerts@example.test', + SMTP_PASS: 'secret', + SMTP_FROM_EMAIL: 'alerts@example.test', + SUPPORT_EMAIL: 'support@example.test', + }); + + try { + await runWithRequestContext({ requestId: 'request-smtp-1' }, () => + (service as any).sendManualReviewAlert(21, 20), + ); + + expect(sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + 'x-request-id': 'request-smtp-1', + 'x-correlation-id': 'request-smtp-1', + }, + }), + ); + } finally { + createTransport.mockRestore(); + } + }); + }); + describe('admin recovery actions', () => { it('forceReconcileNow reconciles a payment immediately, bypassing the due schedule', async () => { const now = new Date(); diff --git a/backend/src/payments/reconciliation.service.ts b/backend/src/payments/reconciliation.service.ts index 26be2615..235cce23 100644 --- a/backend/src/payments/reconciliation.service.ts +++ b/backend/src/payments/reconciliation.service.ts @@ -19,7 +19,10 @@ import { PaymentsGateway } from './payments.gateway'; import { PaymentRailRegistry } from './payment-rail-registry'; import { withTimeout } from './utils/with-timeout'; import { MetricsService } from '../common/metrics.service'; -import { withRequestId } from '../common/request-context'; +import { + outboundRequestHeaders, + withRequestId, +} from '../common/request-context'; import { createTransport } from 'nodemailer'; import Handlebars from 'handlebars'; @@ -232,6 +235,13 @@ export class ReconciliationService { }; } + /** + * Sends the manual-review alert over SMTP. Nodemailer accepts custom + * message headers, so the active request id is attached as both correlation + * names accepted by this backend's request middleware. Providers that do + * not expose a custom-header API cannot receive this metadata; the message + * remains useful without it, but no placeholder id is invented. + */ private async sendManualReviewAlert( depth: number, threshold: number, @@ -260,6 +270,7 @@ export class ReconciliationService { from, to: supportEmail, subject: `${this.config.get('COMPANY_NAME', 'ManageHub')} manual review queue alert`, + headers: outboundRequestHeaders(), text: template({ company: this.config.get('COMPANY_NAME', 'ManageHub'), depth, diff --git a/backend/src/payments/soroban/soroban-rpc-client.spec.ts b/backend/src/payments/soroban/soroban-rpc-client.spec.ts index 82f65b7b..c3d01908 100644 --- a/backend/src/payments/soroban/soroban-rpc-client.spec.ts +++ b/backend/src/payments/soroban/soroban-rpc-client.spec.ts @@ -1,4 +1,8 @@ -import { SorobanRpcClient, SorobanRpcServerLike } from './soroban-rpc-client'; +import { + SorobanRpcClient, + SorobanRpcServerLike, + SorobanRpcTimeoutError, +} from './soroban-rpc-client'; function makeServer(overrides: Partial = {}): SorobanRpcServerLike { return { @@ -70,4 +74,44 @@ describe('SorobanRpcClient', () => { 'down-2', ); }); + + it('abandons a hung endpoint after the timeout and fails over', async () => { + const primary = makeServer({ + getAccount: jest.fn(() => new Promise(() => {})), + }); + const secondary = makeServer({ + getAccount: jest.fn().mockResolvedValue({ id: 'account-from-secondary' }), + }); + const client = new SorobanRpcClient([primary, secondary], 5); + + await expect(client.getAccount('GADDR')).resolves.toEqual({ + id: 'account-from-secondary', + }); + expect(primary.getAccount).toHaveBeenCalledTimes(2); + expect(secondary.getAccount).toHaveBeenCalledWith('GADDR'); + }); + + it('leaves a response that arrives within the budget unaffected', async () => { + const primary = makeServer({ + getTransaction: jest.fn().mockResolvedValue({ status: 'SUCCESS' }), + }); + const secondary = makeServer(); + const client = new SorobanRpcClient([primary, secondary], 50); + + await expect(client.getTransaction('hash-1')).resolves.toEqual({ + status: 'SUCCESS', + }); + expect(secondary.getTransaction).not.toHaveBeenCalled(); + }); + + it('surfaces the timeout error when every endpoint hangs', async () => { + const server = makeServer({ + simulateTransaction: jest.fn(() => new Promise(() => {})), + }); + const client = new SorobanRpcClient([server], 5); + + await expect(client.simulateTransaction({} as any)).rejects.toBeInstanceOf( + SorobanRpcTimeoutError, + ); + }); }); diff --git a/backend/src/payments/soroban/soroban-rpc-client.ts b/backend/src/payments/soroban/soroban-rpc-client.ts index 9871fef2..bf96e238 100644 --- a/backend/src/payments/soroban/soroban-rpc-client.ts +++ b/backend/src/payments/soroban/soroban-rpc-client.ts @@ -1,5 +1,10 @@ import { Logger } from '@nestjs/common'; import { SorobanRpc } from '@stellar/stellar-sdk'; +import { + outboundRequestHeaders, + withRequestId, +} from '../../common/request-context'; +import { TimeoutError, withTimeout } from '../utils/with-timeout'; import { retryWithBackoff } from '../utils/retry-with-backoff'; /** @@ -15,28 +20,153 @@ export interface SorobanRpcServerLike { getTransaction(hash: string): Promise; } +interface SdkAxiosClientLike { + interceptors?: { + request?: { + use: (handler: (config: any) => any) => unknown; + }; + }; +} + +let requestIdInterceptorInstalled = false; + +export const DEFAULT_SOROBAN_RPC_TIMEOUT_MS = 10_000; + +/** + * SDK 11.3 accepts static `headers` on `SorobanRpc.Server` and exports the + * Axios client used by its JSON-RPC transport. The servers in this module are + * created during application bootstrap, before an HTTP request context can + * exist, so constructor headers alone cannot carry an AsyncLocalStorage id. + * A single interceptor on that exported client supplies the current context + * at actual request time instead. If a future SDK removes that export, the + * type guard below simply leaves the SDK untouched; Soroban client logs still + * carry the id through `withRequestId`, while third-party transport-level + * propagation is explicitly unavailable rather than guessed. + */ +function installRequestIdHeaderInterceptor(): void { + if (requestIdInterceptorInstalled) { + return; + } + + const sdkAxiosClient = ( + SorobanRpc as unknown as { AxiosClient?: SdkAxiosClientLike } + ).AxiosClient; + const requestInterceptor = sdkAxiosClient?.interceptors?.request; + if (!requestInterceptor || typeof requestInterceptor.use !== 'function') { + return; + } + + try { + requestInterceptor.use((config: any) => { + const headers = outboundRequestHeaders(); + if (Object.keys(headers).length === 0) { + return config; + } + config.headers = config.headers ?? {}; + Object.assign(config.headers, headers); + return config; + }); + requestIdInterceptorInstalled = true; + } catch { + return; + } +} + +/** + * Creates one SDK RPC server and attaches the request-correlation mechanism + * supported by the pinned SDK. The `headers` option is retained for SDK + * versions that apply constructor headers directly; the interceptor above is + * what makes those headers reflect the active request rather than bootstrap + * state. Neither path emits an empty or placeholder id. + */ export function createSorobanRpcServer(url: string): SorobanRpcServerLike { - return new SorobanRpc.Server(url, { + installRequestIdHeaderInterceptor(); + // Assigned to a variable before the call so TypeScript's excess-property + // check never runs: the correlation header is a forward-compatible extra + // that older SDK typings simply do not declare, and a literal inline here + // would fail the build if `headers` is absent from the pinned options type. + const options = { allowHttp: url.startsWith('http://'), - }) as unknown as SorobanRpcServerLike; + headers: outboundRequestHeaders(), + } as ConstructorParameters[1]; + return new SorobanRpc.Server(url, options) as unknown as SorobanRpcServerLike; } const RETRYABLE_SEND_STATUSES = new Set(['TRY_AGAIN_LATER']); +/** A transient endpoint failure that may be retried or failed over. */ +export class TransientRpcError extends Error {} + +/** + * A bounded RPC call is indeterminate rather than a terminal provider error. + * The endpoint may still complete the request after our timer fires, so it is + * treated like a connection outage: retry within that endpoint's budget and + * then try the next configured endpoint. + */ +export class SorobanRpcTimeoutError extends TransientRpcError { + constructor( + readonly endpointIndex: number, + readonly endpointCount: number, + readonly timeoutMs: number, + ) { + super( + `Soroban RPC endpoint ${endpointIndex}/${endpointCount} timed out after ${timeoutMs}ms`, + ); + this.name = 'SorobanRpcTimeoutError'; + } +} + +/** + * Parses the constructor/environment timeout without accepting fractional or + * unsafe millisecond values. `undefined`, `null`, and an empty environment + * value select the documented default; malformed explicit values fail fast + * during module construction rather than creating an unbounded client. + */ +export function parseSorobanRpcTimeoutMs(value: unknown): number { + if ( + value === undefined || + value === null || + (typeof value === 'string' && value.trim() === '') + ) { + return DEFAULT_SOROBAN_RPC_TIMEOUT_MS; + } + + let parsed: number; + if (typeof value === 'number') { + parsed = value; + } else if (typeof value === 'string') { + parsed = Number(value); + } else { + parsed = Number.NaN; + } + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error( + `SOROBAN_RPC_TIMEOUT_MS must be a positive safe integer, got ${String(value)}`, + ); + } + return parsed; +} + /** * RPC resilience (issue #1574): tries each configured endpoint in order, - * with retry/backoff (reusing #1572's utility) on transient failures - * within an endpoint before failing over to the next one. A endpoint that - * is simply down (connection error) fails over immediately rather than - * exhausting its retry budget. + * with retry/backoff (reusing #1572's utility) on transient failures within + * an endpoint before failing over to the next one. Every call is wrapped in + * the shared timeout utility, so a hung endpoint cannot hold a request + * forever. A timed-out endpoint is logged as transient and is retried and + * failed over exactly like a connection error. */ export class SorobanRpcClient { private readonly logger = new Logger(SorobanRpcClient.name); + private readonly timeoutMs: number; - constructor(private readonly servers: SorobanRpcServerLike[]) { + constructor( + private readonly servers: SorobanRpcServerLike[], + timeoutMs: number = DEFAULT_SOROBAN_RPC_TIMEOUT_MS, + ) { if (servers.length === 0) { throw new Error('SorobanRpcClient needs at least one RPC endpoint'); } + this.timeoutMs = parseSorobanRpcTimeoutMs(timeoutMs); } getAccount(publicKey: string): Promise { @@ -67,22 +197,50 @@ export class SorobanRpcClient { let lastError: unknown; for (const [index, server] of this.servers.entries()) { try { - return await retryWithBackoff((attempt) => call(server), { - maxAttempts: index === this.servers.length - 1 ? 3 : 2, - baseDelayMs: 200, - maxDelayMs: 2000, - isRetryable: (error) => error instanceof TransientRpcError, - }); + return await retryWithBackoff( + () => this.callWithTimeout(call, server, index), + { + maxAttempts: index === this.servers.length - 1 ? 3 : 2, + baseDelayMs: 200, + maxDelayMs: 2000, + isRetryable: (error) => error instanceof TransientRpcError, + }, + ); } catch (error) { lastError = error; this.logger.warn( - `Soroban RPC endpoint ${index + 1}/${this.servers.length} failed: ` + - (error instanceof Error ? error.message : String(error)), + withRequestId( + `Soroban RPC endpoint ${index + 1}/${this.servers.length} failed: ` + + (error instanceof Error ? error.message : String(error)), + ), ); } } throw lastError; } -} -export class TransientRpcError extends Error {} + private async callWithTimeout( + call: (server: SorobanRpcServerLike) => Promise, + server: SorobanRpcServerLike, + index: number, + ): Promise { + try { + return await withTimeout(call(server), this.timeoutMs); + } catch (error) { + if (error instanceof TimeoutError) { + const timeoutError = new SorobanRpcTimeoutError( + index + 1, + this.servers.length, + this.timeoutMs, + ); + this.logger.warn( + withRequestId( + `${timeoutError.message}; treating the endpoint as transient and failing over`, + ), + ); + throw timeoutError; + } + throw error; + } + } +} diff --git a/backend/src/payments/utils/minor-units.spec.ts b/backend/src/payments/utils/minor-units.spec.ts new file mode 100644 index 00000000..bdacefae --- /dev/null +++ b/backend/src/payments/utils/minor-units.spec.ts @@ -0,0 +1,164 @@ +import { + apportionByBasisPoints, + assertMinorUnits, + MinorUnitsError, + multiplyMinorUnits, + TOTAL_BASIS_POINTS, +} from './minor-units'; + +const sum = (values: readonly number[]): number => + values.reduce((total, value) => total + value, 0); + +function seededRandom(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state; + }; +} + +describe('assertMinorUnits', () => { + it('accepts zero and safe non-negative integers', () => { + expect(assertMinorUnits(0, 'amount')).toBe(0); + expect(assertMinorUnits(Number.MAX_SAFE_INTEGER, 'amount')).toBe( + Number.MAX_SAFE_INTEGER, + ); + }); + + it('rejects fractional, negative, non-finite, and unsafe values', () => { + const invalidValues: Array<[string, number]> = [ + ['a fractional value', 1.5], + ['a negative value', -1], + ['NaN', Number.NaN], + ['infinity', Number.POSITIVE_INFINITY], + ['an unsafe integer', Number.MAX_SAFE_INTEGER + 1], + ]; + + for (const [, value] of invalidValues) { + expect(() => assertMinorUnits(value, 'amount')).toThrow(MinorUnitsError); + expect(() => assertMinorUnits(value, 'amount')).toThrow( + /amount must be a non-negative safe integer/, + ); + } + }); +}); + +describe('multiplyMinorUnits', () => { + it('multiplies by an integer basis-point ratio and floors the result', () => { + expect(multiplyMinorUnits(1001, 2500, TOTAL_BASIS_POINTS)).toBe(250); + expect(multiplyMinorUnits(1, 1, TOTAL_BASIS_POINTS)).toBe(0); + expect(multiplyMinorUnits(1, TOTAL_BASIS_POINTS, TOTAL_BASIS_POINTS)).toBe( + 1, + ); + }); + + it('rejects a scaling product outside the safe integer range', () => { + expect(() => + multiplyMinorUnits( + Number.MAX_SAFE_INTEGER, + TOTAL_BASIS_POINTS - 1, + TOTAL_BASIS_POINTS, + ), + ).toThrow(MinorUnitsError); + expect(() => + multiplyMinorUnits( + Number.MAX_SAFE_INTEGER, + TOTAL_BASIS_POINTS, + TOTAL_BASIS_POINTS, + ), + ).toThrow(MinorUnitsError); + }); + + it('rejects invalid ratio denominators and shares', () => { + expect(() => multiplyMinorUnits(100, 1, 0)).toThrow(/greater than zero/); + expect(() => multiplyMinorUnits(100, 10001, 10000)).toThrow( + /cannot exceed/, + ); + expect(() => multiplyMinorUnits(100.5, 1, 10000)).toThrow( + MinorUnitsError, + ); + expect(() => multiplyMinorUnits(100, -1, 10000)).toThrow(MinorUnitsError); + }); +}); + +describe('apportionByBasisPoints', () => { + it('returns exact proportional allocations for a divisible amount', () => { + expect(apportionByBasisPoints(10_000, [1500, 8000, 500])).toEqual([ + 1500, 8000, 500, + ]); + }); + + it('returns zeroes for a zero amount', () => { + expect(apportionByBasisPoints(0, [3333, 3333, 3334])).toEqual([0, 0, 0]); + }); + + it('assigns a single minor unit to exactly one recipient', () => { + const allocations = apportionByBasisPoints(1, [1000, 1000, 1000, 1000]); + expect(sum(allocations)).toBe(1); + expect(allocations.filter((amount) => amount === 1)).toHaveLength(1); + expect(allocations.every((amount) => Number.isInteger(amount))).toBe(true); + }); + + it('keeps a maximum safely scalable amount exact', () => { + const amount = Math.floor( + Number.MAX_SAFE_INTEGER / TOTAL_BASIS_POINTS, + ); + const allocations = apportionByBasisPoints(amount, [1, 9999]); + expect(sum(allocations)).toBe(amount); + expect(allocations.every((value) => Number.isInteger(value))).toBe(true); + }); + + it('rejects fractional, non-finite, negative, and unsafe inputs', () => { + expect(() => apportionByBasisPoints(1.5, [10000])).toThrow( + MinorUnitsError, + ); + expect(() => apportionByBasisPoints(1, [10000.5])).toThrow( + MinorUnitsError, + ); + expect(() => apportionByBasisPoints(Number.NaN, [10000])).toThrow( + MinorUnitsError, + ); + expect(() => apportionByBasisPoints(1, [-1, 10001])).toThrow( + MinorUnitsError, + ); + expect(() => + apportionByBasisPoints(Number.MAX_SAFE_INTEGER, [5000, 5000]), + ).toThrow(/too large to scale exactly/); + }); + + it('rejects an empty or incomplete basis-point policy', () => { + expect(() => apportionByBasisPoints(100, [])).toThrow(/at least one/); + expect(() => apportionByBasisPoints(100, [5000, 4000])).toThrow( + /must sum to 10000/, + ); + }); + + it('does not accumulate rounding drift over a large seeded range', () => { + const random = seededRandom(0x1785_2026); + const maximumAmount = Math.floor( + Number.MAX_SAFE_INTEGER / TOTAL_BASIS_POINTS, + ); + + for (let iteration = 0; iteration < 2_000; iteration += 1) { + const amount = random() % (maximumAmount + 1); + const recipientCount = 2 + (random() % 8); + const basisPoints: number[] = []; + let remaining = TOTAL_BASIS_POINTS; + + for (let index = 0; index < recipientCount - 1; index += 1) { + const share = + index === recipientCount - 2 + ? remaining + : random() % (remaining + 1); + basisPoints.push(share); + remaining -= share; + } + basisPoints.push(remaining); + + const allocations = apportionByBasisPoints(amount, basisPoints); + expect(sum(allocations)).toBe(amount); + expect(allocations.every((value) => Number.isInteger(value))).toBe(true); + expect(allocations.every((value) => value >= 0)).toBe(true); + } + }); +}); diff --git a/backend/src/payments/utils/minor-units.ts b/backend/src/payments/utils/minor-units.ts new file mode 100644 index 00000000..02e9c411 --- /dev/null +++ b/backend/src/payments/utils/minor-units.ts @@ -0,0 +1,179 @@ +/** + * Strict integer arithmetic for payment amounts expressed in minor units. + * + * Currency must never pass through a fractional representation. JavaScript + * numbers can represent safe integers exactly, but neither a decimal amount + * nor a multiplication whose product exceeds `Number.MAX_SAFE_INTEGER` is a + * safe currency value. These helpers reject those cases before arithmetic + * begins and use only integer operations for allocation. + */ +export const TOTAL_BASIS_POINTS = 10_000; + +/** Typed failure for invalid currency integers or an unsafe scaling operation. */ +export class MinorUnitsError extends Error { + constructor(message: string) { + super(message); + this.name = 'MinorUnitsError'; + } +} + +/** + * Validates one non-negative, safe integer used as a currency or ratio value. + * + * `Number.isSafeInteger` deliberately rejects `NaN`, infinities, fractions, + * and values beyond the exactly representable integer range. The explicit + * `typeof` check also makes the boundary safe for JSON or database values + * that reached this helper without TypeScript's compile-time guarantee. + * The input is returned so callers can use validation and normalization in a + * single expression. + */ +export function assertMinorUnits(value: number, label = 'value'): number { + if ( + typeof value !== 'number' || + !Number.isSafeInteger(value) || + value < 0 + ) { + throw new MinorUnitsError( + `${label} must be a non-negative safe integer, got ${String(value)}`, + ); + } + return value; +} + +/** + * Multiplies a minor-unit amount by an integer basis-point ratio and floors + * the result to a whole minor unit. + * + * `totalBasisPoints` is explicit so this primitive can be used for both the + * conventional 10,000 basis points in 100% and a caller's other integer + * denominator. The scaled product is guarded before division; accepting an + * unsafe product would make the quotient and remainder unreliable. A zero + * ratio is valid, but a zero denominator and a share larger than its + * denominator are rejected as malformed currency policy. + */ +export function multiplyMinorUnits( + amount: number, + basisPoints: number, + totalBasisPoints: number, +): number { + assertMinorUnits(amount, 'amount'); + assertMinorUnits(basisPoints, 'basisPoints'); + assertMinorUnits(totalBasisPoints, 'totalBasisPoints'); + + if (totalBasisPoints === 0) { + throw new MinorUnitsError('totalBasisPoints must be greater than zero'); + } + if (basisPoints > totalBasisPoints) { + throw new MinorUnitsError( + `basisPoints (${basisPoints}) cannot exceed totalBasisPoints (${totalBasisPoints})`, + ); + } + if (basisPoints === 0) { + return 0; + } + + const scaled = amount * basisPoints; + if (!Number.isSafeInteger(scaled)) { + throw new MinorUnitsError( + `amount ${amount} cannot be scaled exactly by ${basisPoints}/${totalBasisPoints}`, + ); + } + + return Math.floor(scaled / totalBasisPoints); +} + +interface AllocationEntry { + index: number; + amount: number; + remainder: number; +} + +/** + * Splits an amount across non-negative basis-point shares whose sum is + * exactly 10,000 (100%). The result is in input order. + * + * Every share first receives `floor(amount * share / 10000)`. Any leftover + * minor units are then assigned by the largest-remainder rule, with input + * position as the deterministic tie-breaker. This is the same conservation + * property required for a balanced ledger: the returned integers always sum + * exactly to `amount`, including when the amount is one unit or a recipient + * would otherwise round to zero. The amount is rejected when the scaled + * integer domain could exceed `Number.MAX_SAFE_INTEGER`, rather than + * approximating a value that cannot be represented exactly. + */ +export function apportionByBasisPoints( + amount: number, + basisPoints: readonly number[], +): number[] { + assertMinorUnits(amount, 'amount'); + if (!Array.isArray(basisPoints) || basisPoints.length === 0) { + throw new MinorUnitsError('basisPoints must contain at least one share'); + } + + let totalBasisPoints = 0; + for (const [index, share] of basisPoints.entries()) { + assertMinorUnits(share, `basisPoints[${index}]`); + const nextTotal = totalBasisPoints + share; + if (!Number.isSafeInteger(nextTotal)) { + throw new MinorUnitsError( + 'basisPoints total exceeds the safe integer range', + ); + } + totalBasisPoints = nextTotal; + } + if (totalBasisPoints !== TOTAL_BASIS_POINTS) { + throw new MinorUnitsError( + `basisPoints must sum to ${TOTAL_BASIS_POINTS}, got ${totalBasisPoints}`, + ); + } + const maximumScalableAmount = Math.floor( + Number.MAX_SAFE_INTEGER / TOTAL_BASIS_POINTS, + ); + if (amount > maximumScalableAmount) { + throw new MinorUnitsError( + `amount ${amount} is too large to scale exactly by ${TOTAL_BASIS_POINTS}`, + ); + } + + const entries: AllocationEntry[] = basisPoints.map((share, index) => { + const scaled = amount * share; + if (!Number.isSafeInteger(scaled)) { + throw new MinorUnitsError( + `amount ${amount} cannot be scaled exactly for basisPoints[${index}]`, + ); + } + return { + index, + amount: Math.floor(scaled / TOTAL_BASIS_POINTS), + remainder: scaled % TOTAL_BASIS_POINTS, + }; + }); + + let leftover = + amount - + entries.reduce((sum, entry) => sum + entry.amount, 0); + const byRemainder = [...entries].sort( + (left, right) => + right.remainder - left.remainder || left.index - right.index, + ); + + for (const entry of byRemainder) { + if (leftover <= 0) { + break; + } + entry.amount += 1; + leftover -= 1; + } + + if (leftover !== 0) { + throw new MinorUnitsError( + `Unable to apportion ${amount} minor units exactly`, + ); + } + + const result = new Array(entries.length); + for (const entry of entries) { + result[entry.index] = entry.amount; + } + return result; +}