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