A plugin architecture for payment backends. A new processor is a drop-in adapter: implement one interface, tag it with a decorator, add it as a provider — it auto-registers and is immediately routable. Two real adapters ship today, Stellar (official SDK, testnet) and Grantfox (HTTP gateway), and they run side-by-side without interference, selectable per request.
src/payments/
├── interfaces/payment-processor.interface.ts # IPaymentProcessor contract + shared types
├── decorators/register-payment-processor.decorator.ts # @RegisterPaymentProcessor() discovery marker
├── registry/payment-processor.registry.ts # runtime registry + enable/disable (DB seam)
├── payment-processor.factory.ts # per-request processor selection
├── payments.service.ts # thin orchestrator (delegates to factory)
├── payments.controller.ts # REST API (JWT + KYC; admin toggles)
├── payments.module.ts # wiring + auto-discovery + Horizon provider
├── dto/ # class-validator request DTOs
└── adapters/
├── stellar/ stellar.adapter.ts + stellar.constants.ts
├── grantfox/ grantfox.adapter.ts
└── __shared__/ payment-processor.contract.spec.ts # contract compliance (all adapters)
Every processor implements IPaymentProcessor:
| Method | Purpose |
|---|---|
initialize(config?) |
Optional runtime (re)configuration. |
createPayment(request) |
Build a payment; returns { paymentId, status, unsignedTransaction? }. |
signTransaction(created) |
Sign it (server-side or a no-op for hosted gateways). |
submitTransaction(signed) |
Broadcast/settle; returns { transactionHash, status }. |
getStatus(paymentId) |
Fetch current status. |
refund(request) |
Full or partial refund. |
Plus name (stable key), displayName, and capabilities
(supportsPartialRefund, requiresClientSideSigning, currencies).
The interface is generic — IPaymentProcessor<TConfig, TCreate, TCreated> —
so an adapter can narrow its config and native request/response payload types
while the registry and factory operate against the base type. Amounts are
always decimal strings (e.g. "10.5"), never floats.
The create → sign → submit flow is checkpointed in payment_operations.
Retries replay completed steps, and interrupted submissions are checked with
the processor before any retry. The exact signed payload is retained to make a
Stellar retry use the same transaction hash.
PaymentProcessorFactory.resolve() picks the processor by this precedence:
- Explicit selector — the
X-Payment-Processorheader (or?processor=). - Env default —
PAYMENTS_DEFAULT_PROCESSOR. - Sole enabled — if exactly one processor is enabled, use it.
- Otherwise →
400 Bad Requestasking the caller to choose.
Unknown selector → 400; disabled selector → 409 Conflict.
-
Create the adapter under
adapters/<name>/<name>.adapter.ts:import { Injectable } from "@nestjs/common"; import { RegisterPaymentProcessor } from "../../decorators/register-payment-processor.decorator"; import { IPaymentProcessor, /* …types… */ } from "../../interfaces/payment-processor.interface"; @Injectable() @RegisterPaymentProcessor() // ← makes it auto-register on startup export class AcmeAdapter implements IPaymentProcessor { readonly name = "acme"; // ← stable selector key readonly displayName = "ACME Pay"; readonly capabilities = { supportsPartialRefund: true, requiresClientSideSigning: false, currencies: ["USD"], }; async initialize() {} async createPayment(request) { /* … */ } async signTransaction(created) { /* … */ } async submitTransaction(signed) { /* … */ } async getStatus(paymentId) { /* … */ } async refund(request) { /* … */ } }
-
Register it as a provider in
payments.module.ts(addAcmeAdaptertoproviders). The@RegisterPaymentProcessor()marker is discovered ononModuleInitand registered automatically — you do not touch the registry, factory, controller, or DTOs. -
Add config to
src/config/env.validation.tsfor any env your adapter reads (@IsOptional() @IsString()). -
Add a contract case in
adapters/__shared__/payment-processor.contract.spec.tsso the full lifecycle is verified against mocked clients.
That's it — the endpoints below immediately work for X-Payment-Processor: acme.
Discovery note: matching is by our own metadata key, so it never collides with the DeFi module's adapter discovery (which matches
*Adapterclasses that duck-typegetPosition/supportedChains).
All routes are under the global prefix (/api/v1) and require a JWT (plus the
global KYC guard). The processor is chosen via the X-Payment-Processor header
or ?processor=.
| Method | Path | Access | Body |
|---|---|---|---|
POST |
/payments |
JWT | CreatePaymentDto |
POST |
/payments/:id/sign |
JWT | SignTransactionDto |
POST |
/payments/:id/submit |
JWT | SubmitTransactionDto |
GET |
/payments/:id/status |
JWT | — |
GET |
/payments/:id/recovery |
JWT | — |
POST |
/payments/:id/recovery/resume |
JWT | — |
GET |
/payments/recovery/stuck |
ADMIN | olderThanMinutes (optional) |
POST |
/payments/:id/refund |
JWT | RefundDto (omit amount for full) |
GET |
/payments/processors |
JWT | — (list + enabled state) |
POST |
/payments/processors/:name/enable |
ADMIN | — |
POST |
/payments/processors/:name/disable |
ADMIN | — |
The feature spec names Stellar-specific endpoints. These three aliases pin the
processor to Stellar — no X-Payment-Processor header needed, and
PAYMENTS_DEFAULT_PROCESSOR is ignored:
| Method | Path | Body | Maps to |
|---|---|---|---|
POST |
/payments/stellar/create |
CreatePaymentDto |
create, forced to Stellar |
POST |
/payments/stellar/submit |
StellarSubmitDto |
server-side sign + submit (or submit a client-signed payload) |
GET |
/payments/stellar/status?id=<hash> |
— | status by on-chain transaction hash |
Because Stellar signs server-side, submit takes the created payment's
unsignedTransaction, signs it, and submits — so the create → submit flow needs
no separate sign step. Pass signedPayload instead to submit a client-signed
XDR as-is. paymentId travels in the body (there is no :id in the path).
These are thin aliases over the generic endpoints, declared in a dedicated controller registered before the generic one so the static
/payments/stellar/{submit,status}paths take precedence over the dynamic/payments/:id/{submit,status}routes (Express matches in registration order).
# Create via the env-default processor
curl -X POST /api/v1/payments -H 'Authorization: Bearer <jwt>' \
-H 'Content-Type: application/json' \
-d '{"amount":"10","currency":"XLM","destination":"G...","source":"G...","idempotencyKey":"abc-1"}'
# Force a specific processor
curl -X POST /api/v1/payments -H 'Authorization: Bearer <jwt>' \
-H 'X-Payment-Processor: grantfox' -H 'Content-Type: application/json' \
-d '{"amount":"25.00","currency":"USD","destination":"acct_1","idempotencyKey":"abc-2"}'
# Operator disables a processor at runtime (admin only)
curl -X POST /api/v1/payments/processors/grantfox/disable -H 'Authorization: Bearer <admin-jwt>'All variables are optional (validated in env.validation.ts). Add to .env:
# Default processor when no X-Payment-Processor header is supplied
PAYMENTS_DEFAULT_PROCESSOR=stellar
# ── Stellar ──────────────────────────────────────────────
STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org
STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015
STELLAR_SIGNING_SECRET=S... # server-side signing seed (secret!)
# ── Grantfox ─────────────────────────────────────────────
GRANTFOX_API_URL=https://api.your-grantfox.example
GRANTFOX_API_KEY=... # sent as: Authorization: Bearer <key>Secrets (
STELLAR_SIGNING_SECRET,GRANTFOX_API_KEY) are env-only — never commit them.
- Server-side signing: the server holds
STELLAR_SIGNING_SECRETand signs the unsigned XDR returned bycreatePayment. getStatus(id)/refund(id)treatidas the on-chain transaction hash (there is no hash until aftersubmitTransaction).- Stellar has no native refund — a refund is a reverse payment from the original recipient back to the payer (partial refunds supported). This assumes the signing key controls the original recipient account.
- Non-native assets require
metadata.assetIssuerin the request; otherwise the payment is in native XLM.
- Grantfox is a hosted gateway that signs/submits server-side, so
signTransaction/submitTransactionare pass-throughs. - The request/response mapping is isolated in
mapCreateRequest()andmapPaymentResponse()(both marked// ADAPT:). If your Grantfox endpoint's shape differs, those two methods are the only things to change.
SandboxPaymentAdapter
implements the same IPaymentProcessor contract with deterministic,
credential-free responses served from
src/sandbox/fixtures/sandbox.fixtures.ts.
It is the payment seam of integration sandbox mode (issue #57).
Unlike StellarAdapter/GrantfoxAdapter it is not tagged with
@RegisterPaymentProcessor(). Instead SandboxProcessorRegistrar registers it
on init only while SANDBOX_MODE=true, so production never lists or routes
to a fake processor. Every entry point also fails closed with
412 Precondition Failed when the flag is off or NODE_ENV=production.
Select it per request like any other processor:
curl -X POST /api/v1/payments \
-H 'X-Payment-Processor: sandbox' -H 'Content-Type: application/json' \
-d '{"amount":"10","currency":"XLM","destination":"GBSANDBOX","idempotencyKey":"sandbox-1"}'See docs/SANDBOX_MODE.md for scenarios,
configuration, determinism guarantees and limitations, and run
npm test -- src/sandbox for its tests.
Each payment operation moves through CREATING → CREATED → SIGNED → SUBMITTING → SUBMITTED. Ambiguous create or submit failures become
RECOVERY_REQUIRED; callers should use the same idempotency key or signed
payload and must not start a new payment. A repeated create request with the
same processor, key, and request returns its saved response. Reusing a key for
a different request returns 409 Conflict.
POST /payments/:id/submit records SUBMITTING before calling the processor.
On retry, the API checks status first. It only repeats submission when the
processor declares the exact payload safe to retry; Stellar hashes are derived
before broadcast, and replays use the identical signed transaction. Other
processors fail closed when status cannot be confirmed. The recovery endpoint
returns the current state and next action without exposing signed transaction
data. Admins can inspect stale, nonterminal operations through
GET /payments/recovery/stuck.
The resume endpoint uses the stored signed payload or signs the saved create
response when the selected processor supports server-side signing, so clients
can recover even after losing their browser state.
Apply the CreatePaymentOperations1790467200000 migration before deploying this
version. Checkpoint rows retain the create response and signed payload needed
for recovery; protect the database and its backups accordingly.
npm run test -- src/payments # unit + integration, fully offlineregistry.spec/factory.spec— registration, selection precedence, errors.adapters/__shared__/payment-processor.contract.spec— contract compliance: every adapter is driven through the full lifecycle against mocked network clients.stellar.adapter.spec/grantfox.adapter.spec— per-adapter behaviour with mocked HorizonServer/HttpService.payments.integration.spec— side-by-side: routing by header/env, two processors isolated in one test, and disabling one leaving the other working.- Recovery coverage verifies idempotent creation, retrying a timed-out submit after reconciliation, and returning the saved result after completion.
stellar-payments.controller.spec— the/payments/stellar/*aliases: pinning to Stellar under agrantfoxenv default, server-side sign+submit, and the route-precedence guarantee over the generic/payments/:id/*routes.
Network clients are always mocked/injected, so no test touches the network.