docs: propose @wrnexus/payment with a multi-gateway adapter system

Gateways are adapters behind one interface, with capabilities DECLARED rather
than assumed -- because gateways are not interchangeable. Some have no
authorize-then-capture, some cannot refund partially, some have no vault. An
interface that pretends otherwise fails at the moment money should have moved.
So capabilities are declared, refused loudly when absent, and checked at build
time where the gateway is statically known.

Tier 1 is sandbox, Stripe, Razorpay and PayPal. Stripe and Razorpay are
deliberately the first real pair because they DIFFER on capture model, currency
spread and refund semantics -- one gateway does not prove an abstraction, and
two similar ones prove it badly. Tier 2 and a regional Tier 3 follow, and
defineGateway() makes a third-party adapter a first-class citizen held to the
same shared contract suite.

Two rules shape the package: it never touches a raw card number (hosted fields
keep an application in PCI SAQ-A rather than SAQ-D), and the signed webhook is
the source of truth rather than the browser redirect, which is a claim from an
untrusted client.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 18:47:41 +05:30
co-authored by Claude Opus 5
parent 631e1a2ddb
commit 6258495b67
@@ -0,0 +1,354 @@
# `@wrnexus/payment` — proposal
**Status:** draft for review
**Date:** 2026-08-23
One package that gives an application real payments: gateway configuration, initialisation,
capture, refunds, stored methods, an admin surface, and UI components — so a team never writes
this again.
## The one rule that shapes everything
**The package never touches a raw card number.**
Every supported gateway offers hosted fields or a hosted checkout that tokenises the card in
the customer's browser, against the gateway's domain, before anything reaches your server. That
is what keeps an application in PCI **SAQ-A** — roughly a self-assessment questionnaire —
instead of SAQ-D, which is an audit programme with a six-figure floor.
So the package's UI components mount the gateway's own fields. `PayNow` renders a button that
opens a gateway session; it never renders an `<input name="cardNumber">`, and the package
exposes no API that would accept one. A framework that makes the cheap wrong thing easy will
have it done by someone in a hurry.
## The second rule: the webhook is the truth
A browser redirect saying "payment succeeded" is a claim from an untrusted client. The
authoritative event is the gateway's signed webhook.
So `checkPayment()` reads local state that webhooks maintain, and the local record is only ever
advanced by a verified webhook or a server-side gateway query — never by a client callback. The
redirect is a UX affordance: it tells the customer where to look, not the system what happened.
This is the single most common way a payment integration leaks money, and it is a design
decision, not a runtime check.
## Money model — the same discipline as metering
Payments are **append-only**, exactly like `@wrnexus/metering`'s ledger, and for the same
reason: a balance you cannot explain is a balance nobody trusts.
| Table | Holds |
| ---------------------- | ----------------------------------------------------------------------------------- |
| `wrn_payment_intent` | one row per attempt: amount, currency, gateway, status, idempotency key |
| `wrn_payment_event` | append-only; every webhook and state transition, with the raw signed payload |
| `wrn_payment_refund` | one row per refund attempt, linked to its intent |
| `wrn_payment_method` | stored gateway tokens — never card data, never a PAN, at most a brand and last four |
| `wrn_payment_customer` | the mapping from your user id to each gateway's customer id |
A payment's current status is derived from its events, not from a mutable column that a race can
clobber. The `status` column on the intent is a cache of that derivation, and the package ships
a reconciliation command that recomputes it and reports disagreements.
**Idempotency is mandatory, not optional.** Every initialise and every refund carries a key; a
repeat with the same key returns the original result rather than charging twice. This is where a
double-click becomes a double-charge, and it should be impossible to opt out of.
## Gateways are adapters
```ts
export interface PaymentGateway<TConfig = unknown> {
readonly id: string; // "stripe" | "razorpay" | "paypal" | ...
readonly capabilities: GatewayCapabilities;
readonly supports: { currencies: string[] | "any"; countries: string[] | "any" };
createIntent(input: CreateIntentInput): Promise<GatewayIntent>;
fetchIntent(intentRef: string): Promise<GatewayIntent>;
cancelIntent(intentRef: string, reason: string): Promise<GatewayIntent>;
refund(input: RefundInput): Promise<GatewayRefund>;
verifyWebhook(req: Request, secret: string): Promise<WebhookEvent>; // signature check
capture?(intentRef: string, amount?: Money): Promise<GatewayIntent>; // authorize-then-capture
listMethods?(customerRef: string): Promise<GatewayMethod[]>;
attachMethod?(customerRef: string, token: string): Promise<GatewayMethod>;
detachMethod?(methodRef: string): Promise<void>;
createCustomer?(input: CustomerInput): Promise<string>;
readonly clientConfig: (intent: GatewayIntent) => Record<string, string>; // safe to send
}
```
`clientConfig` exists so the boundary is explicit: it returns exactly what the browser may see —
a publishable key and a session id, never a secret. Anything not returned by it cannot reach the
client, which is a structural guarantee rather than a code-review habit.
### Capabilities are declared, and the difference is refused loudly
This is the part that decides whether a multi-gateway abstraction holds. **Gateways are not
interchangeable.** Some have no authorise-then-capture. Some cannot do partial refunds. Some
have no stored-method vault. Some only settle in one currency.
An interface that pretends otherwise produces the worst failure mode there is: an application
calls `capturePayment()` against a gateway that has no such concept, and gets a confusing
gateway error at the moment money should have moved.
So every adapter declares what it can do, and the package refuses the rest **at call time with a
named reason** — and, better, at **build time** where the gateway is statically known:
```ts
export interface GatewayCapabilities {
authorizeThenCapture: boolean; // Stripe yes, Razorpay effectively no
partialRefund: boolean;
multipleRefunds: boolean;
storedMethods: boolean;
customerVault: boolean;
hostedFields: boolean; // inline hosted inputs
hostedCheckout: boolean; // full redirect/modal
webhookSignature: boolean; // an adapter without this is refused in production
payouts: boolean;
disputes: boolean;
}
```
`capabilitiesOf("razorpay").partialRefund` is a real answer an application can branch on, and
`RefundButton` reads it to decide whether to offer an amount field or only a full refund. A
capability an adapter does not declare is not merely unimplemented — it is refused, with a
message naming the gateway and the capability.
**`webhookSignature: false` is refused outright in production.** An adapter that cannot verify a
webhook cannot be trusted to tell you money moved, and the whole design rests on that.
### The roster
**Tier 1 — ship first, fully covered by the contract suite:**
| Adapter | Notes |
| ---------- | ---------------------------------------------------------------------------------- |
| `sandbox` | behaves like a real gateway, signed webhooks, injectable failure modes, no network |
| `stripe` | global; hosted fields, auth-then-capture, vault, partial and multiple refunds |
| `razorpay` | India-first; hosted checkout, INR-centric, no true auth-then-capture |
| `paypal` | global; hosted checkout, its own order/capture model |
Two real gateways prove the abstraction; one does not. Stripe and Razorpay are deliberately the
first pair because they **differ** — capture model, currency spread, refund semantics — so the
capability system is exercised rather than assumed.
**Tier 2 — same contract, added after the abstraction has survived Tier 1:**
`adyen`, `square`, `braintree`, `mollie`, `checkout.com`, `paddle` (merchant-of-record, so tax
and invoicing differ meaningfully).
**Tier 3 — regional, community-shaped:**
`payu`, `cashfree`, `phonepe`, `paytm` (India); `paystack`, `flutterwave` (Africa);
`midtrans`, `xendit` (South-East Asia); `authorize.net` (US legacy); `mercadopago` (LatAm).
### Any developer can add one
The roster is a starting set, not a ceiling. A custom adapter is a first-class citizen:
```ts
import { defineGateway } from "@wrnexus/payment";
export const acme = defineGateway({
id: "acme",
capabilities: { partialRefund: true, storedMethods: false, webhookSignature: true /* … */ },
supports: { currencies: ["INR", "USD"], countries: ["IN"] },
async createIntent(input) {
/* … */
},
async verifyWebhook(req, secret) {
/* … */
},
// …
});
```
`defineGateway` validates the shape at registration and runs the **shared contract suite** in
tests, so a third-party adapter is held to exactly the standard the built-in ones are. An adapter
that passes the suite behaves identically to Stripe's from the application's point of view; one
that does not, fails in CI rather than in production.
## Configuration
Config is **per-gateway and typed** — each adapter declares its own shape, so a missing
`webhookSecret` is a type error, not a 3am discovery:
```ts
// wrnexus.config.ts
payment: {
default: "stripe",
currency: "INR",
gateways: {
stripe: {
secretKey: env("STRIPE_SECRET_KEY"),
publishableKey: env("STRIPE_PUBLISHABLE_KEY"),
webhookSecret: env("STRIPE_WEBHOOK_SECRET"),
apiVersion: "2026-03-31",
captureMethod: "automatic", // or "manual" for auth-then-capture
},
razorpay: {
keyId: env("RAZORPAY_KEY_ID"),
keySecret: env("RAZORPAY_KEY_SECRET"),
webhookSecret: env("RAZORPAY_WEBHOOK_SECRET"),
theme: { color: "#0e7c86" },
},
acme: { apiKey: env("ACME_KEY"), webhookSecret: env("ACME_WEBHOOK_SECRET") },
},
// Optional: pick a gateway per payment instead of always using the default.
route(intent) {
if (intent.currency === "INR") return "razorpay";
if (intent.country === "US") return "stripe";
return "stripe";
},
sandbox: process.env.NODE_ENV !== "production",
}
```
Selection has three levels, most specific winning: an explicit `gateway` on the call, then
`route()`, then `default`. A `route()` that returns a gateway which is not configured, or which
cannot settle the intent's currency, is a startup error where it can be detected statically and
a named refusal where it cannot.
**Registering a custom adapter is a config line, not a fork:**
```ts
import { acme } from "./payments/acme.ts";
payment: { adapters: [acme], default: "acme", /* … */ }
```
**Refuse to boot on a misconfiguration rather than failing at the first payment.** A live secret
key with `sandbox: true`, or a missing webhook secret, is a startup error naming the variable —
the same lesson as `APP_ENCRYPTION_KEY` failing as an opaque WebCrypto error until it was made
explicit.
## The helper surface
```ts
// Lifecycle
initializePayment(input): Promise<PaymentIntent> // amount, currency, subject, metadata, idempotencyKey
confirmPayment(id): Promise<PaymentIntent> // server-side confirm where the gateway needs it
capturePayment(id, amount?): Promise<PaymentIntent>// for auth-then-capture flows
cancelPayment(id, reason): Promise<PaymentIntent>
checkPayment(id): Promise<PaymentStatus> // derived from events, never from a client claim
syncPayment(id): Promise<PaymentIntent> // authoritative re-read from the gateway
// Refunds
refundPayment({ id, amount?, reason, idempotencyKey }): Promise<Refund> // partial by default
listRefunds(id): Promise<Refund[]>
refundableAmount(id): Promise<Money> // amount minus refunds already settled
// Stored methods
paymentMethods(userId): Promise<PaymentMethod[]>
attachPaymentMethod(userId, token): Promise<PaymentMethod>
detachPaymentMethod(methodId): Promise<void>
setDefaultPaymentMethod(userId, methodId): Promise<void>
// Records and reporting
getPayment(id) / listPayments(filter) // filter by user, status, gateway, date range
paymentTotals(filter): Promise<{ captured, refunded, net, byCurrency }>
reconcilePayments(range): Promise<Discrepancy[]> // local vs gateway, the operator's safety net
// Webhooks
paymentWebhookHandler(gatewayId): RouteHandler // signature-verified, idempotent, replay-safe
```
Every function that moves money is **non-throwing and returns a result** with a `fault`
discriminator, matching `@wrnexus/metering`: a refusal's reason is safe to show a customer, a
fault's reason belongs only in the log. That distinction was learned the hard way — a naive
catch once answered "payment required" with a raw SQLite message.
### `refundableAmount` earns its place
Partial refunds are where integrations quietly go wrong: two concurrent partial refunds each
check "is there enough left?", both see yes, and together exceed the capture. `refundPayment`
must enforce the cap with a conditional write — the same shape as metering's reserve — not with
a read-then-write.
## Migrations ship with the package
`@wrnexus/authz` provisioning nothing is a real cost in this codebase: every application
hand-wires `ensureAuthzTables`, and the framework's own example copies DDL by hand and silently
drifts. Payment must not repeat that.
The package is a **plugin**. It contributes its migrations, its webhook route, and its authz
permissions (`payment:read`, `payment:refund`, `payment:configure`) on install. An application
adds a config block and gets a working, guarded, migrated payment system.
## UI components
Every component mounts gateway-hosted fields; none collects card data itself.
| Component | Does |
| ------------------- | -------------------------------------------------------------------------------- |
| `PayNow` | the button: opens a gateway session, shows pending/success/failure, emits `paid` |
| `PaymentSheet` | hosted fields inline, with the gateway's own validation surfaced |
| `PaymentMethodList` | stored methods, set-default, detach — brand and last four only |
| `PaymentStatus` | live status for one intent, driven by `checkPayment` |
| `RefundButton` | admin-side, requires a reason, shows `refundableAmount` |
| `PaymentHistory` | a customer's payments and refunds |
| `PaymentSummary` | totals by status and currency for a range |
`PayNow` needs to survive the customer closing the tab mid-payment, so its resolved state comes
from `checkPayment`, not from whether the callback fired.
**One caveat to state plainly:** these components will hit `GAP-01` today. A `PaymentStatus`
that first appears client-side renders as an empty placeholder, and a `PayNow` whose props change
after mount will not update. Until the client component runtime lands, these components must be
built server-rendered-first with real navigation, exactly as the Sendline admin console was.
## Admin surface
A payments console — list and filter, view one payment with its full event timeline, issue a
refund with a required reason, inspect webhook deliveries and replay a failed one, and run
reconciliation. Every privileged action writes an audit entry, so the package should either take
a dependency on an audit interface or define one.
## Verification — the standard this project now holds
Unit and integration tests are necessary and insufficient. The package is done when:
1. Every gateway adapter — built-in **and** third-party — passes one shared contract suite, so
behaviour cannot drift per gateway. An adapter declaring a capability it does not honour fails
the suite; an adapter honouring one it does not declare fails too, because a silent extra is
how an application comes to depend on something the next gateway lacks.
2. The sandbox adapter can drive a full lifecycle offline: initialise, webhook, capture, partial
refund, over-refund refused, reconciliation clean.
3. **A real payment is driven end to end in a browser against a gateway's test mode**, and the
money is confirmed in the gateway's own dashboard — not in our database. Phase 3 proved that a
row saying `sent` is not the same as an email arriving; a row saying `captured` is not the same
as money moving.
4. A replayed webhook, a duplicated initialise, and a double-clicked refund each change the
ledger exactly once.
5. Reconciliation over a deliberately corrupted local row reports the discrepancy rather than
hiding it.
## What this package deliberately does not do
- **No card data, ever.** No PAN, no CVV, no expiry, in any API, table, log or component.
- **No invented gateway.** The sandbox adapter is clearly a sandbox and says so in its UI.
- **No subscription billing in v1.** Recurring is a genuinely separate problem — plans, proration,
dunning, retries — and bolting it on would compromise both.
- **No currency conversion.** Store and settle in the currency charged; a converted number in a
ledger is a number nobody can reconcile.
- **No silent capture of an expired authorisation.** It fails loudly.
## Build order
1. Core: schema, append-only events, status derivation, idempotency, non-throwing results
2. The capability system, `defineGateway`, and the shared adapter contract suite
3. The sandbox adapter — first consumer of the contract suite, and the thing every later
adapter is checked against
4. **Stripe, then Razorpay.** Deliberately this pair, because they differ on capture model,
currency spread and refund semantics. Building them together is what stops the abstraction
quietly becoming "Stripe, with names changed"
5. Gateway selection: explicit, `route()`, default — with the misconfiguration errors
6. The webhook route: signature verification, replay safety, event storage
7. Refunds, including the concurrent partial-refund cap and the `partialRefund: false` path
8. Stored methods and customers, behind `storedMethods`
9. PayPal — the third gateway, and the real test of whether Tier 2 can be added by someone who
did not design the abstraction
10. Admin console and reconciliation
11. UI components, capability-aware
12. Browser verification against a gateway test mode