@weft-js/pay 0.1.1 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,660 @@
1
- # @weft-js/pay
1
+ <p align="center">
2
+ <a href="https://github.com/Ravonus/weft">
3
+ <img src="https://raw.githubusercontent.com/Ravonus/weft/main/site/public/logo.svg" width="88" height="88" alt="Weft">
4
+ </a>
5
+ </p>
2
6
 
3
- > **Early release (0.1.1).** APIs will change between minor versions.
7
+ <h1 align="center">@weft-js/pay</h1>
4
8
 
5
- Products, one ledger for every rail, and entitlements. weft's reference plugin: read it to build your own.
9
+ <p align="center">
10
+ <strong>Payments for Weft apps: products, one ledger for every rail, and entitlements you enforce on the server.</strong><br>
11
+ App code names products, never a provider. Stripe is one line in <code>plugins</code>, and a grant only ever comes from a verified event.
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="https://www.npmjs.com/package/@weft-js/pay"><img src="https://img.shields.io/npm/v/@weft-js/pay?color=145c3c&label=npm" alt="npm version"></a>
16
+ <a href="https://github.com/Ravonus/weft/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-145c3c" alt="MIT license"></a>
17
+ <a href="https://github.com/Ravonus/weft/actions"><img src="https://img.shields.io/github/actions/workflow/status/Ravonus/weft/quality.yml?branch=main&label=checks" alt="checks"></a>
18
+ </p>
19
+
20
+ <p align="center">
21
+ <a href="#quick-start">Quick start</a> ·
22
+ <a href="https://github.com/Ravonus/weft/tree/main/docs">Docs</a> ·
23
+ <a href="https://github.com/Ravonus/weft/tree/main/examples">Examples</a> ·
24
+ <a href="https://github.com/Ravonus/weft/blob/main/CHANGELOG.md">Changelog</a> ·
25
+ <a href="https://github.com/Ravonus/weft">GitHub</a>
26
+ </p>
27
+
28
+ > [!IMPORTANT]
29
+ > **Early release (0.1.x).** APIs can change between minor versions. Pin the version and read the
30
+ > [changelog](https://github.com/Ravonus/weft/blob/main/CHANGELOG.md) before you upgrade.
31
+
32
+ `@weft-js/pay` sells subscriptions, one-time purchases and consumables from a Weft app. You define products once, in a
33
+ catalog that is safe to ship to the browser. A checkout opens the payment provider's hosted page. The provider's signed
34
+ webhook is verified on the exact bytes, written to one ledger, and turned into entitlements that `ctx.entitlements` and
35
+ `requires: [...]` check on the server.
36
+
37
+ It is also Weft's reference plugin. Nothing in `@weft-js/start` or the CLI names it, so reading
38
+ [`plugin.mjs`](https://github.com/Ravonus/weft/blob/main/packages/pay/plugin.mjs) next to
39
+ [`weft docs plugins`](https://github.com/Ravonus/weft/blob/main/packages/cli/docs/plugins.md) shows how to build your own.
40
+
41
+ ## Highlights
42
+
43
+ - **Products, not price ids.** `product()` takes whole minor units with an ISO currency (`{ usd: 999 }`) and rejects floats
44
+ and missing currencies (PAY008). A rail's ids never reach client code (PAY012).
45
+ - **A grant comes only from a verified event.** `checkout()` returns a URL and grants nothing. The rail's webhook at
46
+ `/_weft/x/pay/<rail>` is the only way a purchase changes.
47
+ - **At-least-once, any order.** Each event is applied by the rail's own clock, and its id is recorded after it applies, so a
48
+ redelivery is a no-op and an old event changes nothing.
49
+ - **Enforced on the server.** `requires: ['pro']` on any `query` or `mutation` answers a typed 403
50
+ (`ENTITLEMENT_REQUIRED`). `await ctx.entitlements.has('pro')` works in any `query`, `mutation` or `load`.
51
+ - **Live by default.** A verified event pokes the `entitlements:<user>` tag, so a page reading entitlements with `.live()`
52
+ updates without a reload.
53
+ - **One rail is one line.** `stripe()` from `@weft-js/pay/stripe/plugin` provides the `pay.rail` slot. Any plugin that
54
+ implements `PayRail` and passes `payRailSuite` replaces it, and app code stays the same.
55
+ - **A real fake.** `fakePay` from `@weft-js/pay/test` signs its events and sends them through your own webhook and ledger, with
56
+ renewals, refunds, pauses and disputes on demand. No key, no network.
57
+ - **No Stripe code by default.** The `.`, `./server`, `./drizzle` and `./test` entries reach no Stripe file (a test holds
58
+ that), and no page loads Stripe.js.
59
+
60
+ ## Contents
61
+
62
+ - [Install](#install)
63
+ - [Quick start](#quick-start)
64
+ - [Products and prices](#products-and-prices)
65
+ - [The ledger: `definePay`](#the-ledger-definepay)
66
+ - [Checkout, portal and webhooks](#checkout-portal-and-webhooks)
67
+ - [Entitlements](#entitlements)
68
+ - [Stripe](#stripe)
69
+ - [Storing the ledger](#storing-the-ledger)
70
+ - [Writing another rail](#writing-another-rail)
71
+ - [Testing with `fakePay`](#testing-with-fakepay)
72
+ - [API reference](#api-reference)
73
+ - [Recipes](#recipes)
74
+ - [Limits](#limits)
75
+ - [Troubleshooting](#troubleshooting)
76
+ - [Related packages](#related-packages)
77
+
78
+ ## Install
6
79
 
7
80
  ```bash
81
+ # npm
8
82
  npm install @weft-js/pay
83
+
84
+ # pnpm
85
+ pnpm add @weft-js/pay
86
+
87
+ # bun
88
+ bun add @weft-js/pay
89
+ ```
90
+
91
+ Or let the `weft` CLI install it and add it to `weft.config.ts` in one step:
92
+
93
+ ```bash
94
+ npx weft add pay
95
+ ```
96
+
97
+ `weft add pay` needs a database and auth in the app, and stops without changes if either is missing. It also adds the
98
+ Stripe rail (`@weft-js/pay/stripe`) to `plugins`, so `weft.config.ts` ends up with:
99
+
100
+ ```ts
101
+ // weft.config.ts
102
+ import { defineConfig } from '@weft-js/core/config'
103
+ import pay from '@weft-js/pay/plugin'
104
+ import stripe from '@weft-js/pay/stripe/plugin'
105
+
106
+ export default defineConfig({
107
+ plugins: [pay(), stripe()],
108
+ })
109
+ ```
110
+
111
+ `weft add` writes a default import named after the package. Both plugin modules also have a named export (`{ pay }`,
112
+ `{ stripe }`), which works the same.
113
+
114
+ ### What `weft add pay` writes
115
+
116
+ | File | What it holds |
117
+ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------- |
118
+ | `src/pay/catalog.ts` | A `pro` subscription: `month` at `{ usd: 999 }`, `year` at `{ usd: 7999, trialDays: 7 }`, granting `pro`. Client-safe. |
119
+ | `src/pay.server.ts` | `export default definePay({ catalog, store, urls: { success: '/', cancel: '/' } })` |
120
+ | `src/server/pay.ts` | The `entitlements` query and the `checkout` and `portal` mutations. |
121
+ | `src/db/pay.schema.ts` | The `purchases` and `pay_events` tables, for SQLite, Postgres or MySQL. Drizzle apps only. |
122
+ | `src/env.server.ts` | `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` added to the `server` block. |
123
+ | `.env`, `.env.example` | `STRIPE_SECRET_KEY=sk_test_placeholder`, `STRIPE_WEBHOOK_SECRET=whsec_placeholder` |
124
+
125
+ On Drizzle, `src/pay.server.ts` uses `drizzlePayStore(db, { purchases, payEvents })`; then run `npx weft db generate` and
126
+ the migration applies on the next request. On another data layer it starts on `memoryPayStore()`, and you write a
127
+ `PayStore` before production (PAY016).
128
+
129
+ ### Setup checklist
130
+
131
+ | Need | Where | Notes |
132
+ | ------------------------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
133
+ | A database | `weft add` refuses without one | Holds the ledger. |
134
+ | Auth | `weft add` refuses without it | A purchase belongs to `ctx.user.id`. |
135
+ | `STRIPE_SECRET_KEY` | `.env`, server only | Replace the placeholder with a real test key. The placeholder is refused before anything is sent (PAY003). |
136
+ | `STRIPE_WEBHOOK_SECRET` | `.env`, server only | Without it every webhook is refused (PAY011). |
137
+ | A Stripe webhook endpoint | Stripe dashboard | URL `https://<your-app>/_weft/x/pay/stripe`. Events: `customer.subscription.*`, `checkout.session.*`, `charge.refunded`, `charge.dispute.*`. |
138
+
139
+ Nothing calls Stripe until a user checks out.
140
+
141
+ ## Quick start
142
+
143
+ The catalog names what you sell. It reaches the client, so it holds names and amounts only:
144
+
145
+ ```ts
146
+ // src/pay/catalog.ts
147
+ import { product } from '@weft-js/pay'
148
+
149
+ export const pro = product({
150
+ id: 'pro',
151
+ kind: 'subscription',
152
+ use: 'digital',
153
+ name: 'Pro',
154
+ plans: { month: { usd: 999 }, year: { usd: 7999, trialDays: 7 } },
155
+ grants: ['pro'],
156
+ })
157
+ ```
158
+
159
+ The ledger is the default export of `src/pay.server.ts`:
160
+
161
+ ```ts
162
+ // src/pay.server.ts
163
+ import { drizzlePayStore } from '@weft-js/pay/drizzle'
164
+ import { definePay } from '@weft-js/pay/server'
165
+ import { db } from '#/db/index.server'
166
+ import { payEvents, purchases } from '#/db/schema'
167
+ import * as catalog from './pay/catalog'
168
+
169
+ // Served at /_weft/x/pay/<rail>, the webhook URL to give your
170
+ // provider.
171
+ export default definePay({
172
+ catalog,
173
+ store: drizzlePayStore(db, { purchases, payEvents }),
174
+ urls: { success: '/', cancel: '/' },
175
+ })
176
+ ```
177
+
178
+ Server functions expose it to pages:
179
+
180
+ ```ts
181
+ // src/server/pay.ts
182
+ import { entitlementTag } from '@weft-js/pay'
183
+ import { mutation, query, redirect } from '@weft-js/start'
184
+ import pay from '../pay.server'
185
+
186
+ export const entitlements = query({
187
+ auth: 'user',
188
+ tags: (_, ctx) => [entitlementTag(ctx.user.id)],
189
+ run: ({ ctx }) => ctx.entitlements.list(),
190
+ })
191
+
192
+ export const checkout = mutation({
193
+ auth: 'user',
194
+ input: pay.input,
195
+ run: async ({ input, ctx }) =>
196
+ redirect(await pay.checkout(ctx, input), 303),
197
+ })
198
+
199
+ export const portal = mutation({
200
+ auth: 'user',
201
+ run: async ({ ctx }) => redirect(await pay.portal(ctx), 303),
202
+ })
203
+ ```
204
+
205
+ A page sells and reads, by product name:
206
+
207
+ ```tsx
208
+ // src/routes/pricing.tsx
209
+ import { price } from '@weft-js/pay'
210
+ import { pro } from '#/pay/catalog'
211
+ import { checkout, entitlements, portal } from '#/server/pay'
212
+
213
+ export default function Pricing() {
214
+ const mine = entitlements.live()
215
+ return (
216
+ <section>
217
+ <button
218
+ type="button"
219
+ onClick={() => checkout({ product: 'pro', plan: 'year' })}
220
+ >
221
+ Go Pro · {price(pro, 'year').display}
222
+ </button>
223
+ <button type="button" onClick={() => portal()}>
224
+ Manage
225
+ </button>
226
+ {mine.value?.includes('pro') && <p>You have Pro.</p>}
227
+ </section>
228
+ )
229
+ }
230
+ ```
231
+
232
+ And the server enforces it:
233
+
234
+ ```ts
235
+ // src/server/report.ts
236
+ import { query } from '@weft-js/start'
237
+
238
+ export const report = query({
239
+ auth: 'user',
240
+ requires: ['pro'], // a typed 403, ENTITLEMENT_REQUIRED, without it
241
+ run: () => 'the numbers',
242
+ })
243
+ ```
244
+
245
+ The buyer clicks, lands on Stripe Checkout, pays, and comes back to `urls.success`. Stripe posts the event to
246
+ `/_weft/x/pay/stripe`, the ledger records it, and the `.live()` reader on the page shows Pro.
247
+
248
+ ## Products and prices
249
+
250
+ `product(def)` checks a definition and returns it frozen. It is the only place products are defined.
251
+
252
+ | Field | Type | Default | What it does |
253
+ | ---------- | ---------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------- |
254
+ | `id` | `string` | required | Lowercase letters, digits and dashes (PAY021 otherwise). What `checkout` and `requires` name. |
255
+ | `kind` | `'subscription' \| 'one-time' \| 'consumable'` | required | A subscription renews, a one-time purchase stays, a consumable is used up. |
256
+ | `use` | `'digital' \| 'physical' \| 'service'` | required | What the buyer gets. |
257
+ | `name` | `string` | the `id` | Display name at checkout. |
258
+ | `plans` | `Record<string, Plan>` | none | Subscriptions only, and required for them. Each plan is `Amounts` plus optional `trialDays` and `interval`. |
259
+ | `price` | `Amounts` | none | One-time and consumable products only. |
260
+ | `grants` | `string[]` | `[]` | Entitlements a live purchase gives. |
261
+ | `shipping` | `boolean` | `false` | Collect a shipping address at checkout. |
262
+
263
+ `Amounts` is `Record<currency, number>`: lowercase ISO codes, non-negative whole minor units. `{ usd: 999 }` is $9.99.
264
+ A plan's interval is its name when the name is `day`, `week`, `month` or `year`; any other name needs `interval`.
265
+
266
+ ```ts
267
+ import { product } from '@weft-js/pay'
268
+
269
+ export const team = product({
270
+ id: 'team',
271
+ kind: 'subscription',
272
+ use: 'service',
273
+ plans: { quarterly: { usd: 2500, eur: 2300, interval: 'month' } },
274
+ grants: ['pro', 'team'],
275
+ })
276
+
277
+ export const credits = product({
278
+ id: 'credits',
279
+ kind: 'consumable',
280
+ use: 'digital',
281
+ price: { usd: 500 },
282
+ grants: ['credits'],
283
+ })
284
+ ```
285
+
286
+ `price(product, plan?, { currency?, locale? })` returns `{ amount, currency, display, plan?, interval?, trialDays? }`. It
287
+ uses the first currency listed unless you name one, and formats by the currency's own exponent (JPY has none, KWD has
288
+ three) for the reader's locale. Show `price(pro, 'month').display` instead of typing `$9.99` into a page (PAY005).
289
+
290
+ ## The ledger: `definePay`
291
+
292
+ `definePay(options)` from `@weft-js/pay/server` is the app's one ledger. `src/pay.server.ts` default-exports it, and the
293
+ plugin mounts it as a `server.extension` at `/_weft/x/pay/...`. An app with no `src/pay.server.ts` has no route and no code
294
+ from any of this.
295
+
296
+ | Option | Type | Default | What it does |
297
+ | --------- | ---------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------- |
298
+ | `catalog` | `Product[]` or `Record<string, Product>` | required | `import * as catalog from './pay/catalog'` works. |
299
+ | `store` | `PayStore` | required | Where purchases and seen events live. |
300
+ | `urls` | `{ success, cancel, portal? }` | required | Paths or URLs a buyer returns to. Never from the client. The portal returns to `portal`, else `cancel`. |
301
+ | `user` | `(ctx) => string \| null \| undefined` | `ctx.user?.id` | Who a request is. |
302
+ | `rail` | `string` | the only bound rail | Which rail takes payments when several are bound (PAY022 otherwise). |
303
+ | `origin` | `string` | the request's origin | Absolute origin for `urls`, behind a proxy. |
304
+ | `leeway` | `number` (ms) | 3 days | How long past a paid period access lasts, while a late renewal event arrives. |
305
+ | `slot` | `() => Promise<…>` | `weft:slot/pay.rail` | Where rails come from. Leave it alone outside tests. |
306
+
307
+ What it returns, besides the `server.extension` members Weft uses:
308
+
309
+ | Member | What it does |
310
+ | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
311
+ | `pay.input` | A Standard Schema validator for a checkout mutation's `input`: `{ product, plan?, currency?, quantity? }`, checked against the catalog. `quantity` is a whole number under 1000. |
312
+ | `pay.checkout(ctx, input)` | The hosted checkout URL for this buyer. Grants nothing. |
313
+ | `pay.portal(ctx)` | Where this buyer manages or cancels. |
314
+ | `pay.entitlements(userId)` | `{ has(id), list() }` for any user, as a job or webhook would read it. |
315
+ | `pay.forContext(ctx)` | The same, for a request: what `ctx.entitlements` is. |
316
+ | `pay.purchases(userId)` | The user's `Purchase` rows. |
317
+ | `pay.refund(purchaseId)` | Asks the purchase's rail to refund it. |
318
+ | `pay.webhook(rail, request, report?)` | One verified delivery: `{ response, tags }`. |
319
+ | `pay.ingest(rail, events, report?)` | Apply events that were already verified. Anything else goes through `webhook`. |
320
+ | `pay.use(rail)` / `pay.reset()` | Swap in a rail directly (what `fakePay` does), and go back to the slot. |
321
+ | `pay.catalog` | The products, as a list. |
322
+
323
+ ## Checkout, portal and webhooks
324
+
325
+ **Checkout.** `pay.checkout(ctx, input)` needs a signed-in user (PAY010, 401), resolves the line from the catalog, and
326
+ asks the rail for a hosted page. A subscription the user already holds live answers PAY023 (409). The same click within a
327
+ minute reuses one session: the idempotency key is a hash of the user, the line and the minute. Amounts always come from the
328
+ catalog; the client sends names only.
329
+
330
+ **Portal.** `pay.portal(ctx)` finds the user's customer id on the current rail. A user who has bought nothing there gets
331
+ PAY024 (404).
332
+
333
+ **Webhooks.** The rail posts to `/_weft/x/pay/<rail>`. For each delivery:
334
+
335
+ | Step | Result |
336
+ | ---------------------------------------------------- | -------------------------------------------------------------------------------- |
337
+ | Not a `POST` | 405 |
338
+ | No rail bound under that name | 404 |
339
+ | Body over 1 MiB | 413 |
340
+ | `verify` throws (bad or missing signature, a replay) | 400, and the reason goes to the server log, not the response |
341
+ | `ingest` fails | 500, so the rail delivers it again |
342
+ | Otherwise | `{"received":true}`, and the affected `entitlements:<user>` tags are invalidated |
343
+
344
+ An event applies when it is newer than the purchase's last one by the rail's clock. On a tie, a terminal state wins: a
345
+ refund and a renewal in the same second is a refund. The event id is recorded after the purchase is saved, so a crash in
346
+ between means a redelivery, not a lost event.
347
+
348
+ ### Purchase states
349
+
350
+ The same on every rail:
351
+
352
+ | State | Gives access |
353
+ | ---------------------------------------------------------------------- | ---------------------------------- |
354
+ | `active`, `trial`, `grace` | Yes, until `expires` plus `leeway` |
355
+ | `pending`, `billing-retry`, `paused`, `expired`, `refunded`, `revoked` | No |
356
+
357
+ ## Entitlements
358
+
359
+ Entitlements are the union of `grants` over a user's live purchases. On the server, in any `query`, `mutation` or `load`:
360
+
361
+ ```ts
362
+ import { redirect } from '@weft-js/start'
363
+
364
+ // Await it: a promise is truthy, so without `await` everyone passes
365
+ // (PAY015).
366
+ if (!(await ctx.entitlements.has('pro'))) redirect('/pricing')
367
+ ```
368
+
369
+ | Way | Where | What happens without the entitlement |
370
+ | -------------------------------- | --------------------------- | ----------------------------------------------------------- |
371
+ | `requires: ['pro']` | any `query` or `mutation` | A 403 with code `ENTITLEMENT_REQUIRED` |
372
+ | `await ctx.entitlements.has(id)` | `query`, `mutation`, `load` | Whatever you do: redirect, hide, refuse |
373
+ | `await ctx.entitlements.list()` | same | The sorted list |
374
+ | `entitlementTag(userId)` | a query's `tags` | The tag a verified event invalidates: `entitlements:<user>` |
375
+
376
+ What the client shows is decoration; a check only on the client is PAY013. A query with `cache: { life }` needs
377
+ `scope: 'user'` to read entitlements.
378
+
379
+ ## Stripe
380
+
381
+ `stripe(options?)` from `@weft-js/pay/stripe/plugin` provides the `pay.rail` slot under the key `stripe`. It uses Stripe
382
+ Checkout and the billing portal over Stripe's REST API, server side only, and verifies webhooks itself with WebCrypto
383
+ HMAC-SHA256 on `<t>.<raw body>`, accepting any of several `v1` signatures while a secret rolls.
384
+
385
+ | Option | Type | Default | What it does |
386
+ | ------------------- | ------------------------ | ------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
387
+ | `prices` | `Record<string, string>` | none | Stripe Price ids by `<product>.<plan>` or `<product>`. Without an entry the amount comes from the catalog as `price_data`. |
388
+ | `api` | `string` | `https://api.stripe.com` | The API origin; point it at a local mock. |
389
+ | `apiVersion` | `string` | `2025-09-30.clover` | Pinned, so an account's default version can't reshape the calls. |
390
+ | `tolerance` | `number` (s) | `300` | How old a signed delivery may be before it is refused as a replay. |
391
+ | `shippingCountries` | `string[]` | `['US', 'CA']` | Where a `shipping` product delivers. |
392
+
393
+ Keys come from the server environment, `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET`. Don't pass a key as an option:
394
+ plugin options are written into `.weft/slots/`, a generated file (PAY003).
395
+
396
+ ```ts
397
+ // weft.config.ts
398
+ import { defineConfig } from '@weft-js/core/config'
399
+ import pay from '@weft-js/pay/plugin'
400
+ import stripe from '@weft-js/pay/stripe/plugin'
401
+
402
+ export default defineConfig({
403
+ plugins: [
404
+ pay(),
405
+ stripe({
406
+ prices: { 'pro.month': 'price_…', 'pro.year': 'price_…' },
407
+ }),
408
+ ],
409
+ })
410
+ ```
411
+
412
+ How Stripe events map to the ledger:
413
+
414
+ | Stripe event | Ledger effect |
415
+ | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
416
+ | `customer.subscription.*` | `active`, `trialing` → `trial`, `past_due` → `grace`, `unpaid` → `billing-retry`, `paused`, `canceled` and `incomplete_expired` → `expired`, `incomplete` → `pending`; `expires` from the period end |
417
+ | `checkout.session.completed`, `…async_payment_succeeded`, `…async_payment_failed` | One-time payments only: `active`, `pending` while unpaid, or `expired` on failure |
418
+ | `charge.refunded` | `refunded`, for a full refund only; a partial refund keeps what was bought |
419
+ | `charge.dispute.created` / `.closed` | `revoked`, then `active` again if the dispute is won |
420
+
421
+ Who bought what travels in metadata (`weft_user`, `weft_product`, `weft_plan`) on the session, the subscription and the
422
+ payment intent. `pay.refund()` refunds one-time payments (`pi_…`); a subscription is cancelled in the portal.
423
+
424
+ ## Storing the ledger
425
+
426
+ A `PayStore` is five methods:
427
+
428
+ | Method | What it does |
429
+ | ------------------- | ------------------------------------- |
430
+ | `seen(eventId)` | Whether an event was already applied. |
431
+ | `mark(eventId, at)` | Record that it was. |
432
+ | `purchase(id)` | One `Purchase` by id, or `null`. |
433
+ | `save(purchase)` | Insert or replace by `id`. |
434
+ | `byUser(user)` | Every purchase of a user. |
435
+
436
+ `drizzlePayStore(db, { purchases, payEvents })` from `@weft-js/pay/drizzle` implements it on Postgres, SQLite or MySQL, with
437
+ the tables `weft add pay` writes (times are milliseconds, so they need a 64-bit integer column on Postgres and MySQL). It
438
+ marks both tables as written by the webhook rather than by a server function.
439
+
440
+ `memoryPayStore()` is for dev and tests. A production server refuses to start with it (PAY016).
441
+
442
+ A `Purchase` row is `{ id, rail, user, product, plan, state, at, expires, customer }`, where `id` is `<rail>:<rail's id>`
443
+ and `user`, `product` and `plan` stay `null` until an event names them (a dispute can arrive before its payment).
444
+
445
+ ## Writing another rail
446
+
447
+ A rail is a plugin that provides the `pay.rail` slot for the `server` side and implements `PayRail` from `@weft-js/pay`:
448
+
449
+ | Member | What it does |
450
+ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
451
+ | `id` | The rail's name, and its webhook path: `/_weft/x/pay/<id>`. |
452
+ | `checkout(request)` | Open a hosted page for a `CheckoutRequest` (user, optional customer, the catalog line, return URLs, an idempotency key). Returns `{ url }`. |
453
+ | `portal({ customer, returnUrl })` | Returns `{ url }`. |
454
+ | `verify({ body, headers })` | Gets the exact bytes received and returns normalised `RailEvent`s, or throws `PayError('PAY011', …)` for anything it can't prove. |
455
+ | `refund({ purchase })` | Optional. |
456
+
457
+ Hold it to the contract with `payRailSuite` from `@weft-js/pay/test`:
458
+
459
+ ```ts
460
+ import { payRailSuite } from '@weft-js/pay/test'
461
+ import { myRail, signedDelivery } from '../src/rail'
462
+
463
+ test('my rail passes pay.rail@1', async () => {
464
+ const checks = await payRailSuite({
465
+ rail: myRail({ fetch: mockFetch }),
466
+ // A delivery the rail would accept, `age` ms old
467
+ delivery: (age) => signedDelivery(age),
468
+ tolerance: 300_000, // optional: runs the replay check
469
+ })
470
+ expect(checks.filter((c) => !c.ok)).toEqual([])
471
+ })
472
+ ```
473
+
474
+ It checks that a genuine delivery verifies, that events have an id, a millisecond time and a purchase, that the same
475
+ delivery gives the same id, that a changed byte and a missing signature are refused, that an old delivery is refused as a
476
+ replay (with `tolerance`), and that `checkout` and `portal` answer absolute URLs. A failed check is P119.
477
+
478
+ Swap rails by replacing `stripe()` in `plugins`. Nothing in app code changes.
479
+
480
+ ## Testing with `fakePay`
481
+
482
+ `fakePay` from `@weft-js/pay/test` is a rail with no key and no network. Its events are signed and go through your own
483
+ webhook and ledger, and it pokes live queries, so a test sees what a user would:
484
+
485
+ ```tsx
486
+ import { net, render, screen, user } from '@weft-js/core/test'
487
+ import { fakePay } from '@weft-js/pay/test'
488
+ import pay from '#/pay.server'
489
+
490
+ test('checkout grants nothing; the verified event does', async () => {
491
+ // Your `definePay`, or fakePay({ catalog })
492
+ const fake = fakePay(pay)
493
+ net.context({ user: { id: 'u1' } })
494
+ render(Paywall)
495
+ await net.settle()
496
+ await user.click(screen.getByRole('button', { name: /Go Pro/ }))
497
+ await net.settle()
498
+ expect(screen.getByLabelText('plan').textContent).toBe('Free')
499
+
500
+ await fake.complete() // the buyer finishes the hosted page
501
+ expect(screen.getByLabelText('plan').textContent).toBe('Pro')
502
+
503
+ await fake.simulate('refund')
504
+ expect(screen.getByLabelText('plan').textContent).toBe('Free')
505
+ })
506
+ ```
507
+
508
+ | Member | What it does |
509
+ | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
510
+ | `complete(user?)` | The buyer finishes the newest open checkout, or that user's. Returns the purchase id. |
511
+ | `abandon(user?)` | The buyer closes the page. Nothing is delivered. |
512
+ | `buy(user, product, plan?)` | A whole purchase with no page in the way. |
513
+ | `simulate(what, purchase?)` | `renewal`, `renewal-failed`, `grace-expired`, `recovered`, `cancel`, `pause`, `resume`, `refund`, `revoke`. Defaults to the newest purchase. `renewal` and `renewal-failed` move `clock` to the period's end when it is installed. |
514
+ | `redeliver()` | The last event again. |
515
+ | `deliver(event, { forged? })` | Any event, in any order. `forged: true` signs with the wrong key, so it answers 400. |
516
+ | `sessions`, `deliveries` | Every checkout opened, and every delivery with the status the app answered. |
517
+ | `entitlements(user)`, `purchases(user)` | Read the ledger. |
518
+ | `pay`, `rail` | The ledger in use and the fake rail. |
519
+
520
+ `fakePay` registers the ledger with `net.extension`, which resets it after each test. A live key in a test is PAY031.
521
+
522
+ ## API reference
523
+
524
+ | Entry | Side | Exports |
525
+ | ---------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
526
+ | `@weft-js/pay` | client and server | `product`, `price`, `entitlementTag`, `PayError`; types `Product`, `ProductDef`, `ProductKind`, `ProductUse`, `Plan`, `Amounts`, `Interval`, `Price`, `PurchaseState`, `PayRail`, `RailEvent`, `CheckoutRequest`, `CheckoutLine` |
527
+ | `@weft-js/pay/server` | server | `definePay`, `memoryPayStore`, `extension`; types `Pay`, `PayOptions`, `PayStore`, `Purchase`, `Entitlements`, `CheckoutInput`, `PayContext`, `PayReport` |
528
+ | `@weft-js/pay/drizzle` | server | `drizzlePayStore` |
529
+ | `@weft-js/pay/test` | tests | `fakePay`, `payRailSuite`; types `FakePay`, `PayScenario`, `PayRailSuiteOptions`, `Check` |
530
+ | `@weft-js/pay/plugin` | config | `pay` (default and named): the `server.extension`, the PAY checks, `weft docs pay` and `weft add pay` |
531
+ | `@weft-js/pay/stripe` | server | `stripeRail`; type `StripeOptions` |
532
+ | `@weft-js/pay/stripe/plugin` | config | `stripe` (default and named): the `pay.rail` provider |
533
+
534
+ Importing `@weft-js/pay/server` or `@weft-js/pay/drizzle` from client code is PAY017.
535
+
536
+ <details>
537
+ <summary><code>stripeRail(options)</code>: options the plugin doesn't document</summary>
538
+
539
+ `stripeRail` is what the plugin loads on the server. Besides the plugin options above, it accepts `secretKey` (default
540
+ `process.env.STRIPE_SECRET_KEY`), `webhookSecret` (default `process.env.STRIPE_WEBHOOK_SECRET`) and `fetch` (default the
541
+ global one). Use those in tests of the rail itself; in an app, keys stay in the environment.
542
+
543
+ </details>
544
+
545
+ <details>
546
+ <summary><code>PayError</code></summary>
547
+
548
+ `new PayError(code, message, status = 400)`. Every error this package throws has a stable `code`. `error.answer` is
549
+ `{ code, message, status }`, the shape `@weft-js/start` sends a plugin's error to the client in.
550
+
551
+ </details>
552
+
553
+ ## Recipes
554
+
555
+ **Gate a page in `load`.**
556
+
557
+ ```ts
558
+ import { redirect } from '@weft-js/start'
559
+
560
+ export const load = async ({ ctx }) => {
561
+ if (!(await ctx.entitlements.has('pro'))) redirect('/pricing')
562
+ }
563
+ ```
564
+
565
+ **Sell a one-time product with shipping.**
566
+
567
+ ```ts
568
+ import { product } from '@weft-js/pay'
569
+
570
+ export const poster = product({
571
+ id: 'poster',
572
+ kind: 'one-time',
573
+ use: 'physical',
574
+ price: { usd: 2500 },
575
+ shipping: true,
576
+ })
9
577
  ```
578
+
579
+ Then `checkout({ product: 'poster', quantity: 2 })`. Stripe collects an address in `shippingCountries`.
580
+
581
+ **Read entitlements from a job.** Outside a request, use the ledger directly:
582
+
583
+ ```ts
584
+ import pay from '#/pay.server'
585
+
586
+ const isPro = await pay.entitlements(userId).has('pro')
587
+ ```
588
+
589
+ **Charge in another currency.** `checkout({ product: 'pro', plan: 'month', currency: 'eur' })` uses the plan's `eur`
590
+ amount; `price(pro, 'month', { currency: 'eur' }).display` shows it.
591
+
592
+ ## Limits
593
+
594
+ - Stripe is the only rail that ships. Native app-store rails, store policy and `weft pay sync` aren't built.
595
+ - Delivery is at-least-once and unordered by design; ordering uses the rail's clock, so an event without a newer timestamp
596
+ doesn't change state.
597
+ - A webhook body over 1 MiB is refused (413).
598
+ - `quantity` is a whole number from 1 to 999. A subscription is always quantity 1.
599
+ - Only a full Stripe refund ends access. Only one-time payments refund through `pay.refund()`.
600
+ - An event for a purchase nobody has seen, with no user or product on it, is stored without an owner and grants nothing
601
+ until a later event names them.
602
+ - A purchase for a product no longer in the catalog grants nothing, and the server logs PAY021 as a warning.
603
+
604
+ ## Troubleshooting
605
+
606
+ `weft check` and `weft build` run the PAY checks. `weft docs PAY012` prints any code's page.
607
+
608
+ | Code | When | Fix |
609
+ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
610
+ | PAY003 | A Stripe key in source, a server secret read in client code, a key in plugin options, or the placeholder key still in `.env` at run time | Move it to the server env; roll any real key that was committed |
611
+ | PAY004 | Paid state written on the client after `checkout()` | Read `entitlements`: a grant arrives from a verified event |
612
+ | PAY005 | A price typed into a page that sells something (warning) | `price(product, plan).display` |
613
+ | PAY008 | A float or currency-less amount in `product()` | Whole minor units: `{ usd: 999 }` |
614
+ | PAY010 | A checkout mutation without `auth: 'user'`, or a checkout with no signed-in user (401) | Add `auth: 'user'` |
615
+ | PAY011 | A webhook handler with no signature check, a `verify` that ignores `headers`, no `STRIPE_WEBHOOK_SECRET`, or a delivery that fails verification (400) | Delete the handler: `/_weft/x/pay/<rail>` verifies. Set the secret |
616
+ | PAY012 | A rail's price or product id in client code | Name the catalog; map ids in `stripe({ prices })` |
617
+ | PAY013 | An entitlement checked only on the client | `requires: [...]`, or `await ctx.entitlements.has(...)` |
618
+ | PAY014 | The plugin with no `src/pay.server.ts`: no webhook is mounted | Default-export `definePay({ … })` from it |
619
+ | PAY015 | `ctx.entitlements.has()` without `await` | Await it (`weft fix` does, in an `async` function) |
620
+ | PAY016 | `memoryPayStore()` in `src/pay.server.ts` (warning); a production server refuses to start | `drizzlePayStore` or your own `PayStore` |
621
+ | PAY017 | `@weft-js/pay/server` or `/drizzle` imported in client code | Import them in a server module |
622
+ | PAY018 | `src/pay.server.ts` with no `pay.rail` provider | `weft add pay-stripe` |
623
+ | PAY031 | A live key in a test | `fakePay` |
624
+
625
+ At run time:
626
+
627
+ | Code | Status | Means |
628
+ | ---------------------- | --------------- | -------------------------------------------------------------------------------------------------------- |
629
+ | PAY021 | 404 | No such product, plan or currency in the catalog (also thrown by `product()` for a bad definition) |
630
+ | PAY022 | 500 / 501 / 400 | No rail chosen while several are bound, a named rail isn't bound, or the rail can't refund this purchase |
631
+ | PAY023 | 409 | The user already holds this subscription |
632
+ | PAY024 | 404 | Nothing to manage or refund |
633
+ | PAY030 | 502 / 503 | Stripe refused the call (503 when it rate-limited) |
634
+ | P101 | 500 | `pay.rail` has no provider in `plugins` |
635
+ | `ENTITLEMENT_REQUIRED` | 403 | A `requires` entitlement is missing |
636
+ | `E_PAY` | 500 | Applying a verified delivery failed; the rail will redeliver |
637
+
638
+ ## Related packages
639
+
640
+ | Package | What it is |
641
+ | ---------------------------------------------------------------- | ------------------------------------------------------------------------------- |
642
+ | [`@weft-js/start`](https://www.npmjs.com/package/@weft-js/start) | Server functions, `ctx`, `server.extension` and live queries that pay builds on |
643
+ | [`@weft-js/core`](https://www.npmjs.com/package/@weft-js/core) | The plugin API (`definePlugin`, slots) and the test kit (`net`, `clock`) |
644
+ | [`@weft-js/db`](https://www.npmjs.com/package/@weft-js/db) | The database layer `drizzlePayStore` runs on |
645
+ | [`@weft-js/cli`](https://www.npmjs.com/package/@weft-js/cli) | `weft add pay`, `weft check`, `weft docs pay` and `weft docs plugins` |
646
+
647
+ ## Contributing
648
+
649
+ See [CONTRIBUTING.md](https://github.com/Ravonus/weft/blob/main/CONTRIBUTING.md). From the repository root:
650
+
651
+ ```bash
652
+ pnpm --filter @weft-js/pay test
653
+ ```
654
+
655
+ The tests run the ledger end to end over `fakePay`, the Stripe rail against a mocked API, the PAY checks, and the
656
+ `drizzlePayStore` on SQLite. None reach a network.
657
+
658
+ ## License
659
+
660
+ [MIT](https://github.com/Ravonus/weft/blob/main/LICENSE)