# `@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