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:
@@ -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
|
||||||
Reference in New Issue
Block a user