Files
WRNexusJS/docs/proposals/2026-08-23-wrnexus-payment.md
ClintchizandClaude Opus 5 6258495b67 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>
2026-08-23 18:47:41 +05:30

18 KiB

@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

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:

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:

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:

// 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:

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

// 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