diff --git a/docs/proposals/2026-08-23-wrnexus-payment.md b/docs/proposals/2026-08-23-wrnexus-payment.md new file mode 100644 index 00000000..f8ef4579 --- /dev/null +++ b/docs/proposals/2026-08-23-wrnexus-payment.md @@ -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 ``, 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 { + readonly id: string; // "stripe" | "razorpay" | "paypal" | ... + readonly capabilities: GatewayCapabilities; + readonly supports: { currencies: string[] | "any"; countries: string[] | "any" }; + + createIntent(input: CreateIntentInput): Promise; + fetchIntent(intentRef: string): Promise; + cancelIntent(intentRef: string, reason: string): Promise; + refund(input: RefundInput): Promise; + verifyWebhook(req: Request, secret: string): Promise; // signature check + + capture?(intentRef: string, amount?: Money): Promise; // authorize-then-capture + listMethods?(customerRef: string): Promise; + attachMethod?(customerRef: string, token: string): Promise; + detachMethod?(methodRef: string): Promise; + createCustomer?(input: CustomerInput): Promise; + + readonly clientConfig: (intent: GatewayIntent) => Record; // 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 // amount, currency, subject, metadata, idempotencyKey +confirmPayment(id): Promise // server-side confirm where the gateway needs it +capturePayment(id, amount?): Promise// for auth-then-capture flows +cancelPayment(id, reason): Promise +checkPayment(id): Promise // derived from events, never from a client claim +syncPayment(id): Promise // authoritative re-read from the gateway + +// Refunds +refundPayment({ id, amount?, reason, idempotencyKey }): Promise // partial by default +listRefunds(id): Promise +refundableAmount(id): Promise // amount minus refunds already settled + +// Stored methods +paymentMethods(userId): Promise +attachPaymentMethod(userId, token): Promise +detachPaymentMethod(methodId): Promise +setDefaultPaymentMethod(userId, methodId): Promise + +// Records and reporting +getPayment(id) / listPayments(filter) // filter by user, status, gateway, date range +paymentTotals(filter): Promise<{ captured, refunded, net, byCurrency }> +reconcilePayments(range): Promise // 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