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