Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4be4b2c346 | ||
|
|
6258495b67 |
@@ -235,7 +235,7 @@
|
|||||||
},
|
},
|
||||||
"packages/auth": {
|
"packages/auth": {
|
||||||
"name": "@wrnexus/auth",
|
"name": "@wrnexus/auth",
|
||||||
"version": "0.8.17",
|
"version": "0.8.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/authz": "workspace:*",
|
"@wrnexus/authz": "workspace:*",
|
||||||
"@wrnexus/captcha": "workspace:*",
|
"@wrnexus/captcha": "workspace:*",
|
||||||
@@ -342,7 +342,7 @@
|
|||||||
},
|
},
|
||||||
"packages/csr": {
|
"packages/csr": {
|
||||||
"name": "@wrnexus/csr",
|
"name": "@wrnexus/csr",
|
||||||
"version": "0.8.31",
|
"version": "0.8.32",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*",
|
"@wrnexus/core": "workspace:*",
|
||||||
},
|
},
|
||||||
@@ -501,7 +501,7 @@
|
|||||||
},
|
},
|
||||||
"packages/metering": {
|
"packages/metering": {
|
||||||
"name": "@wrnexus/metering",
|
"name": "@wrnexus/metering",
|
||||||
"version": "0.8.2",
|
"version": "0.8.3",
|
||||||
},
|
},
|
||||||
"packages/mobile": {
|
"packages/mobile": {
|
||||||
"name": "@wrnexus/mobile",
|
"name": "@wrnexus/mobile",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/auth",
|
"name": "@wrnexus/auth",
|
||||||
"version": "0.8.17",
|
"version": "0.8.18",
|
||||||
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
|
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@@ -1672,6 +1672,11 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
|||||||
(typeof metadata.deviceId === "string" ? metadata.deviceId : undefined),
|
(typeof metadata.deviceId === "string" ? metadata.deviceId : undefined),
|
||||||
trusted: Boolean(rememberedDevice) || input.session?.trusted === true,
|
trusted: Boolean(rememberedDevice) || input.session?.trusted === true,
|
||||||
fingerprint: typeof metadata.fingerprint === "string" ? metadata.fingerprint : undefined,
|
fingerprint: typeof metadata.fingerprint === "string" ? metadata.fingerprint : undefined,
|
||||||
|
metadata: {
|
||||||
|
...(input.session?.metadata ?? {}),
|
||||||
|
mfaVerifiedAt: now(),
|
||||||
|
mfaMethod: input.method,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
user.lastLoginAt = now();
|
user.lastLoginAt = now();
|
||||||
user.updatedAt = now();
|
user.updatedAt = now();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/csr",
|
"name": "@wrnexus/csr",
|
||||||
"version": "0.8.31",
|
"version": "0.8.32",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -19,6 +19,21 @@ export { ACTION_RUNTIME } from "./action-runtime.ts";
|
|||||||
export { createApiClient } from "./api-client.ts";
|
export { createApiClient } from "./api-client.ts";
|
||||||
export type { ApiClientOptions, ApiRequest } from "./api-client.ts";
|
export type { ApiClientOptions, ApiRequest } from "./api-client.ts";
|
||||||
|
|
||||||
|
/** Public client transport used by `.wrn` client functions for dynamic URLs. */
|
||||||
|
export function useFetch<T = unknown>(
|
||||||
|
path: string,
|
||||||
|
methodOrOptions?:
|
||||||
|
string | { method?: string; query?: unknown; params?: unknown; body?: unknown; data?: unknown },
|
||||||
|
input?: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
const client = (
|
||||||
|
globalThis as typeof globalThis & { useFetch?: (...args: unknown[]) => Promise<T> }
|
||||||
|
).useFetch;
|
||||||
|
if (!client)
|
||||||
|
return Promise.reject(new Error("WRN-CSR: useFetch is only available in the browser runtime"));
|
||||||
|
return client(path, methodOrOptions, input);
|
||||||
|
}
|
||||||
|
|
||||||
const CONTROLLER_SECTIONS = ["PRIMARY", "UI", "PIN"] as const;
|
const CONTROLLER_SECTIONS = ["PRIMARY", "UI", "PIN"] as const;
|
||||||
|
|
||||||
function runtimeForMode(development: boolean): string {
|
function runtimeForMode(development: boolean): string {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/metering",
|
"name": "@wrnexus/metering",
|
||||||
"version": "0.8.2",
|
"version": "0.8.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -90,6 +90,10 @@ export function defineMeter(options: MeterOptions) {
|
|||||||
Number.isSafeInteger(units) && units > 0
|
Number.isSafeInteger(units) && units > 0
|
||||||
? null
|
? null
|
||||||
: { ok: false, reason: `${label} must be a positive whole number` };
|
: { ok: false, reason: `${label} must be a positive whole number` };
|
||||||
|
const validSigned = (units: number): MeterResult | null =>
|
||||||
|
Number.isSafeInteger(units) && units !== 0
|
||||||
|
? null
|
||||||
|
: { ok: false, reason: `${label} adjustment must be a non-zero whole number` };
|
||||||
const fault = (error: unknown, operation: string): MeterResult => {
|
const fault = (error: unknown, operation: string): MeterResult => {
|
||||||
options.onFault?.(error, operation);
|
options.onFault?.(error, operation);
|
||||||
return {
|
return {
|
||||||
@@ -138,7 +142,15 @@ export function defineMeter(options: MeterOptions) {
|
|||||||
add("purchase", subjectId, units, reason, reference),
|
add("purchase", subjectId, units, reason, reference),
|
||||||
refund: (subjectId: string, units: number, reason: string, reference = "") =>
|
refund: (subjectId: string, units: number, reason: string, reference = "") =>
|
||||||
add("refund", subjectId, units, reason, reference),
|
add("refund", subjectId, units, reason, reference),
|
||||||
adjust: (subjectId: string, units: number, reason: string, reference = "") =>
|
async adjust(subjectId: string, units: number, reason: string, reference = "") {
|
||||||
add("adjust", subjectId, units, reason, reference),
|
const invalid = validSigned(units);
|
||||||
|
if (invalid) return invalid;
|
||||||
|
try {
|
||||||
|
await options.store.write(subjectId, units, "adjust", reason, reference);
|
||||||
|
return { ok: true } as const;
|
||||||
|
} catch (error) {
|
||||||
|
return fault(error, "adjust");
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,3 +40,27 @@ test("meter distinguishes refusals from storage faults", async () => {
|
|||||||
fault: true,
|
fault: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("adjust accepts signed non-zero amounts while other additions stay positive", async () => {
|
||||||
|
const writes: number[] = [];
|
||||||
|
const meter = defineMeter({
|
||||||
|
store: {
|
||||||
|
balance: async () => 0,
|
||||||
|
reserve: async () => true,
|
||||||
|
write: async (_subject, units) => {
|
||||||
|
writes.push(units);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await meter.adjust("u1", -25, "correction")).toEqual({ ok: true });
|
||||||
|
expect(await meter.adjust("u1", 10, "correction")).toEqual({ ok: true });
|
||||||
|
expect(await meter.adjust("u1", 0, "correction")).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "units adjustment must be a non-zero whole number",
|
||||||
|
});
|
||||||
|
expect(await meter.grant("u1", -1, "invalid")).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "units must be a positive whole number",
|
||||||
|
});
|
||||||
|
expect(writes).toEqual([-25, 10]);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user