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