@weft-js/pay 0.1.3 → 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
@@ -94,7 +94,6 @@ Or let the `weft` CLI install it and add it to `weft.config.ts` in one step:
94
94
  npx weft add pay
95
95
  ```
96
96
 
97
-
98
97
  `weft add pay` needs a database and auth in the app, and stops without changes if either is missing. It also adds the
99
98
  Stripe rail (`@weft-js/pay/stripe`) to `plugins`, so `weft.config.ts` ends up with:
100
99
 
@@ -114,14 +113,14 @@ export default defineConfig({
114
113
 
115
114
  ### What `weft add pay` writes
116
115
 
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` |
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` |
125
124
 
126
125
  On Drizzle, `src/pay.server.ts` uses `drizzlePayStore(db, { purchases, payEvents })`; then run `npx weft db generate` and
127
126
  the migration applies on the next request. On another data layer it starts on `memoryPayStore()`, and you write a
@@ -129,13 +128,13 @@ the migration applies on the next request. On another data layer it starts on `m
129
128
 
130
129
  ### Setup checklist
131
130
 
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.*`. |
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.*`. |
139
138
 
140
139
  Nothing calls Stripe until a user checks out.
141
140
 
@@ -221,10 +220,7 @@ export default function Pricing() {
221
220
  >
222
221
  Go Pro · {price(pro, 'year').display}
223
222
  </button>
224
- <button
225
- type="button"
226
- onClick={() => portal()}
227
- >
223
+ <button type="button" onClick={() => portal()}>
228
224
  Manage
229
225
  </button>
230
226
  {mine.value?.includes('pro') && <p>You have Pro.</p>}
@@ -253,16 +249,16 @@ The buyer clicks, lands on Stripe Checkout, pays, and comes back to `urls.succes
253
249
 
254
250
  `product(def)` checks a definition and returns it frozen. It is the only place products are defined.
255
251
 
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. |
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. |
266
262
 
267
263
  `Amounts` is `Record<currency, number>`: lowercase ISO codes, non-negative whole minor units. `{ usd: 999 }` is $9.99.
268
264
  A plan's interval is its name when the name is `day`, `week`, `month` or `year`; any other name needs `interval`.
@@ -297,32 +293,32 @@ three) for the reader's locale. Show `price(pro, 'month').display` instead of ty
297
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
298
294
  from any of this.
299
295
 
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. |
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. |
310
306
 
311
307
  What it returns, besides the `server.extension` members Weft uses:
312
308
 
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. |
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. |
326
322
 
327
323
  ## Checkout, portal and webhooks
328
324
 
@@ -336,14 +332,14 @@ PAY024 (404).
336
332
 
337
333
  **Webhooks.** The rail posts to `/_weft/x/pay/<rail>`. For each delivery:
338
334
 
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 |
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 |
347
343
 
348
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
349
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
@@ -353,10 +349,10 @@ between means a redelivery, not a lost event.
353
349
 
354
350
  The same on every rail:
355
351
 
356
- | State | Gives access |
357
- | --- | --- |
358
- | `active`, `trial`, `grace` | Yes, until `expires` plus `leeway` |
359
- | `pending`, `billing-retry`, `paused`, `expired`, `refunded`, `revoked` | No |
352
+ | State | Gives access |
353
+ | ---------------------------------------------------------------------- | ---------------------------------- |
354
+ | `active`, `trial`, `grace` | Yes, until `expires` plus `leeway` |
355
+ | `pending`, `billing-retry`, `paused`, `expired`, `refunded`, `revoked` | No |
360
356
 
361
357
  ## Entitlements
362
358
 
@@ -370,12 +366,12 @@ import { redirect } from '@weft-js/start'
370
366
  if (!(await ctx.entitlements.has('pro'))) redirect('/pricing')
371
367
  ```
372
368
 
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>` |
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>` |
379
375
 
380
376
  What the client shows is decoration; a check only on the client is PAY013. A query with `cache: { life }` needs
381
377
  `scope: 'user'` to read entitlements.
@@ -386,13 +382,13 @@ What the client shows is decoration; a check only on the client is PAY013. A que
386
382
  Checkout and the billing portal over Stripe's REST API, server side only, and verifies webhooks itself with WebCrypto
387
383
  HMAC-SHA256 on `<t>.<raw body>`, accepting any of several `v1` signatures while a secret rolls.
388
384
 
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. |
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. |
396
392
 
397
393
  Keys come from the server environment, `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET`. Don't pass a key as an option:
398
394
  plugin options are written into `.weft/slots/`, a generated file (PAY003).
@@ -415,12 +411,12 @@ export default defineConfig({
415
411
 
416
412
  How Stripe events map to the ledger:
417
413
 
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 |
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 |
424
420
 
425
421
  Who bought what travels in metadata (`weft_user`, `weft_product`, `weft_plan`) on the session, the subscription and the
426
422
  payment intent. `pay.refund()` refunds one-time payments (`pi_…`); a subscription is cancelled in the portal.
@@ -429,13 +425,13 @@ payment intent. `pay.refund()` refunds one-time payments (`pi_…`); a subscript
429
425
 
430
426
  A `PayStore` is five methods:
431
427
 
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. |
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. |
439
435
 
440
436
  `drizzlePayStore(db, { purchases, payEvents })` from `@weft-js/pay/drizzle` implements it on Postgres, SQLite or MySQL, with
441
437
  the tables `weft add pay` writes (times are milliseconds, so they need a 64-bit integer column on Postgres and MySQL). It
@@ -450,13 +446,13 @@ and `user`, `product` and `plan` stay `null` until an event names them (a disput
450
446
 
451
447
  A rail is a plugin that provides the `pay.rail` slot for the `server` side and implements `PayRail` from `@weft-js/pay`:
452
448
 
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. |
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. |
460
456
 
461
457
  Hold it to the contract with `payRailSuite` from `@weft-js/pay/test`:
462
458
 
@@ -509,31 +505,31 @@ test('checkout grants nothing; the verified event does', async () => {
509
505
  })
510
506
  ```
511
507
 
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. |
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. |
523
519
 
524
520
  `fakePay` registers the ledger with `net.extension`, which resets it after each test. A live key in a test is PAY031.
525
521
 
526
522
  ## API reference
527
523
 
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 |
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 |
537
533
 
538
534
  Importing `@weft-js/pay/server` or `@weft-js/pay/drizzle` from client code is PAY017.
539
535
 
@@ -609,44 +605,44 @@ amount; `price(pro, 'month', { currency: 'eur' }).display` shows it.
609
605
 
610
606
  `weft check` and `weft build` run the PAY checks. `weft docs PAY012` prints any code's page.
611
607
 
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'` |
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'` |
619
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 |
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` |
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` |
628
624
 
629
625
  At run time:
630
626
 
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 |
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 |
641
637
 
642
638
  ## Related packages
643
639
 
644
- | Package | What it is |
645
- | --- | --- |
640
+ | Package | What it is |
641
+ | ---------------------------------------------------------------- | ------------------------------------------------------------------------------- |
646
642
  | [`@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` |
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` |
650
646
 
651
647
  ## Contributing
652
648
 
package/docs/pay.md CHANGED
@@ -22,6 +22,7 @@ export const pro = product({
22
22
  ```
23
23
 
24
24
  <!-- example: skip needs what `weft add pay` writes -->
25
+
25
26
  ```tsx
26
27
  // a page
27
28
  import { price } from '@weft-js/pay'
@@ -56,9 +57,15 @@ verified on the exact bytes, applied to `purchases`, and pokes `entitlements:<us
56
57
  Delivery is at least once and in any order: a repeat is a no-op, and an old event changes nothing, by the rail's own clock.
57
58
  A bad signature answers 400; a failure answers 500, so the rail delivers it again.
58
59
 
60
+ **A cart the server prices.** `pay.order(ctx, { ref, lines: [{ name, amount, quantity? }], currency?, email?, title? })`
61
+ opens one payment for lines your server priced from its own tables, for a guest or a signed-in buyer. It grants nothing.
62
+ `definePay({ onOrder({ ref, state, purchase }) })` hears each verified state, before the event is marked, so a throw
63
+ redelivers. Each order event pokes `orders` and `orderTag(ref)`. `urls.order` and `urls.orderCancel` may hold `{ref}`.
64
+
59
65
  **Enforce on the server.** What the client shows is decoration (PAY013):
60
66
 
61
67
  <!-- example: skip fragments of a server module -->
68
+
62
69
  ```ts
63
70
  export const report = query({
64
71
  auth: 'user',
@@ -87,6 +94,11 @@ changes. A rail implements `PayRail` from `@weft-js/pay`: `checkout`, `portal`,
87
94
  `PayError('PAY011', …)` for anything it can't prove. An app with no `src/pay.server.ts` has no route and no code from any
88
95
  of this.
89
96
 
97
+ `square({ environment?, api?, apiVersion? })` from `@weft-js/pay/square/plugin` is the Square rail: one-time payments
98
+ through payment links, so web orders land beside the shop's card readers. Env: `SQUARE_ACCESS_TOKEN`, `SQUARE_LOCATION_ID`,
99
+ `SQUARE_WEBHOOK_SIGNATURE_KEY`, `SQUARE_WEBHOOK_URL` (the exact URL given to Square, which it signs), `SQUARE_ENV=sandbox`.
100
+ Subscribe the webhook to `payment.created` and `payment.updated`. Subscriptions and `portal` are PAY022 on it.
101
+
90
102
  `stripe({ prices?, api?, apiVersion?, tolerance?, shippingCountries? })` from `@weft-js/pay/stripe/plugin`. It reads
91
103
  `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` from the server env. Don't put a key in its options: they land in a
92
104
  generated file (PAY003).
@@ -116,22 +128,22 @@ installed. `fake.redeliver()`, `fake.deliver(event, { forged: true })`, `fake.de
116
128
 
117
129
  ## Diagnostics
118
130
 
119
- | Code | Catches |
120
- |---|---|
121
- | PAY003 | A key in the source, or a server secret in client code |
122
- | PAY004 | Paid state written on the client after `checkout()` |
123
- | PAY005 | A price typed into a page that sells something (warning) |
124
- | PAY008 | A float or a currency-less amount in `product()` |
125
- | PAY010 | A checkout mutation without `auth: 'user'` |
126
- | PAY011 | A webhook left unverified, or no `STRIPE_WEBHOOK_SECRET` |
127
- | PAY012 | A rail's price or product id in client code |
128
- | PAY013 | An entitlement checked only on the client |
129
- | PAY014 | A rail bound with no `src/pay.server.ts` |
130
- | PAY015 | `ctx.entitlements.has()` tested without `await` |
131
- | PAY016 | `memoryPayStore()` in `src/pay.server.ts` (warning) |
131
+ | Code | Catches |
132
+ | ------ | ----------------------------------------------------------- |
133
+ | PAY003 | A key in the source, or a server secret in client code |
134
+ | PAY004 | Paid state written on the client after `checkout()` |
135
+ | PAY005 | A price typed into a page that sells something (warning) |
136
+ | PAY008 | A float or a currency-less amount in `product()` |
137
+ | PAY010 | A checkout mutation without `auth: 'user'` |
138
+ | PAY011 | A webhook left unverified, or no `STRIPE_WEBHOOK_SECRET` |
139
+ | PAY012 | A rail's price or product id in client code |
140
+ | PAY013 | An entitlement checked only on the client |
141
+ | PAY014 | A rail bound with no `src/pay.server.ts` |
142
+ | PAY015 | `ctx.entitlements.has()` tested without `await` |
143
+ | PAY016 | `memoryPayStore()` in `src/pay.server.ts` (warning) |
132
144
  | PAY017 | `@weft-js/pay/server` or `/drizzle` imported in client code |
133
- | PAY018 | `src/pay.server.ts` with no `pay.rail` provider |
134
- | PAY031 | A live key in a test |
145
+ | PAY018 | `src/pay.server.ts` with no `pay.rail` provider |
146
+ | PAY031 | A live key in a test |
135
147
 
136
148
  At run time: PAY021 (not in the catalog), PAY022 (no rail chosen), PAY023 (already subscribed, 409), PAY024 (nothing to
137
149
  manage or refund), PAY030 (the rail refused). Native rails, store policy and `weft pay sync` aren't built.
package/package.json CHANGED
@@ -1,27 +1,20 @@
1
1
  {
2
2
  "name": "@weft-js/pay",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "@weft-js/pay: products, one ledger for every rail, and entitlements. weft's reference plugin: read it to build your own",
5
+ "keywords": [
6
+ "weft"
7
+ ],
8
+ "homepage": "https://github.com/Ravonus/weft/tree/main/packages/pay#readme",
9
+ "bugs": {
10
+ "url": "https://github.com/Ravonus/weft/issues"
11
+ },
5
12
  "license": "MIT",
6
13
  "repository": {
7
14
  "type": "git",
8
15
  "url": "git+https://github.com/Ravonus/weft.git",
9
16
  "directory": "packages/pay"
10
17
  },
11
- "homepage": "https://github.com/Ravonus/weft/tree/main/packages/pay#readme",
12
- "bugs": {
13
- "url": "https://github.com/Ravonus/weft/issues"
14
- },
15
- "type": "module",
16
- "exports": {
17
- ".": "./src/index.ts",
18
- "./server": "./src/server.ts",
19
- "./drizzle": "./src/drizzle.ts",
20
- "./test": "./src/test.ts",
21
- "./plugin": "./plugin.mjs",
22
- "./stripe": "./src/stripe.ts",
23
- "./stripe/plugin": "./stripe-plugin.mjs"
24
- },
25
18
  "files": [
26
19
  "src",
27
20
  "docs",
@@ -30,15 +23,32 @@
30
23
  "stripe-plugin.mjs",
31
24
  "stripe-plugin.d.mts",
32
25
  "README.md",
33
- "LICENSE"
26
+ "LICENSE",
27
+ "square-plugin.mjs",
28
+ "square-plugin.d.mts"
34
29
  ],
30
+ "type": "module",
31
+ "exports": {
32
+ ".": "./src/index.ts",
33
+ "./server": "./src/server.ts",
34
+ "./drizzle": "./src/drizzle.ts",
35
+ "./test": "./src/test.ts",
36
+ "./plugin": "./plugin.mjs",
37
+ "./stripe": "./src/stripe.ts",
38
+ "./stripe/plugin": "./stripe-plugin.mjs",
39
+ "./square": "./src/square.ts",
40
+ "./square/plugin": "./square-plugin.mjs"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
35
45
  "dependencies": {
36
46
  "typescript": "6.0.3"
37
47
  },
38
48
  "peerDependencies": {
39
- "@weft-js/start": "^0.1.3",
40
- "drizzle-orm": "^0.45.0",
41
- "@weft-js/core": "^0.1.3"
49
+ "@weft-js/core": "^0.1.4",
50
+ "@weft-js/start": "^0.1.4",
51
+ "drizzle-orm": "^0.45.0"
42
52
  },
43
53
  "peerDependenciesMeta": {
44
54
  "drizzle-orm": {
@@ -46,12 +56,6 @@
46
56
  }
47
57
  },
48
58
  "engines": {
49
- "node": ">=26"
50
- },
51
- "keywords": [
52
- "weft"
53
- ],
54
- "publishConfig": {
55
- "access": "public"
59
+ "node": ">=24"
56
60
  }
57
61
  }
@@ -0,0 +1,9 @@
1
+ import type { PluginInstance } from '@weft-js/core/plugin'
2
+
3
+ declare const plugin: (options?: {
4
+ environment?: 'sandbox' | 'production'
5
+ api?: string
6
+ apiVersion?: string
7
+ }) => PluginInstance<unknown>
8
+ /** `square()` for `weft.config.ts`: Square as a `pay.rail` provider. Tokens stay in the environment. */
9
+ export default plugin
@@ -0,0 +1,47 @@
1
+ // square() for weft.config.ts: Square as the `pay.rail` provider, so web orders land in the same account as the shop's
2
+ // card readers. Options are in the generated slot module, so they name env vars and never hold a token (PAY003).
3
+ import { definePlugin, defineSlot, provide } from '@weft-js/core/plugin'
4
+
5
+ const slot = defineSlot({
6
+ id: 'pay.rail',
7
+ version: 1,
8
+ cardinality: 'keyed',
9
+ sides: ['server', 'client', 'native'],
10
+ })
11
+
12
+ export const square = definePlugin({
13
+ name: '@weft-js/pay/square',
14
+ version: '0.0.0',
15
+ weft: '*',
16
+ permissions: {
17
+ net: ['connect.squareup.com', 'connect.squareupsandbox.com'],
18
+ env: [
19
+ 'SQUARE_ACCESS_TOKEN',
20
+ 'SQUARE_LOCATION_ID',
21
+ 'SQUARE_WEBHOOK_SIGNATURE_KEY',
22
+ 'SQUARE_WEBHOOK_URL',
23
+ 'SQUARE_ENV',
24
+ ],
25
+ },
26
+ contribute: (options) => ({
27
+ provides: [
28
+ provide(slot, {
29
+ key: 'square',
30
+ sides: { server: { module: '@weft-js/pay/square', export: 'squareRail', options } },
31
+ }),
32
+ ],
33
+ agents: {
34
+ title: 'Pay rail: Square',
35
+ text: 'Square payment links, one-time payments only (`pay.order`, one-time products): `SQUARE_ACCESS_TOKEN`, `SQUARE_LOCATION_ID`, and for `/_weft/x/pay/square` (subscribe it to `payment.created` and `payment.updated`) `SQUARE_WEBHOOK_SIGNATURE_KEY` plus `SQUARE_WEBHOOK_URL`, the exact URL given to Square. `SQUARE_ENV=sandbox` for test.',
36
+ },
37
+ map: [
38
+ {
39
+ want: 'Change the payment provider',
40
+ call: 'replace this plugin in `plugins`: app code stays',
41
+ have: 'Square payment links',
42
+ },
43
+ ],
44
+ }),
45
+ })
46
+
47
+ export default square
package/src/index.ts CHANGED
@@ -163,6 +163,9 @@ export function price<P extends string>(
163
163
  /** What a query reads, tagged so a verified event pokes it: `tags: (_, ctx) => [entitlementTag(ctx.user.id)]`. */
164
164
  export const entitlementTag = (user: string) => `entitlements:${user}`
165
165
 
166
+ /** What a verified event pokes for one order: `tags: (input) => [orderTag(input.ref)]`. Every order also pokes `orders`. */
167
+ export const orderTag = (ref: string) => `order:${ref}`
168
+
166
169
  // ---- pay.rail@1 ---------------------------------------------------------------------------------------------------
167
170
 
168
171
  /** What a purchase is doing now, on any rail. */
@@ -198,6 +201,10 @@ export interface CheckoutRequest {
198
201
  /** This user's id on the rail, when a purchase gave one. */
199
202
  customer?: string
200
203
  line: CheckoutLine
204
+ /** An order's own lines, priced on the server (`pay.order`): charged in place of `line`, which sums them. */
205
+ lines?: readonly CheckoutLine[]
206
+ /** A guest's email, for the receipt, when the rail has no customer for them. */
207
+ email?: string
201
208
  successUrl: string
202
209
  cancelUrl: string
203
210
  /** The same click twice gives one session. */
@@ -228,6 +235,8 @@ export interface RailEvent {
228
235
  */
229
236
  export interface PayRail {
230
237
  readonly id: string
238
+ /** What it can sell. Default both; a rail without `subscription` has no portal to offer. */
239
+ readonly modes?: readonly ('payment' | 'subscription')[]
231
240
  checkout(request: CheckoutRequest): Promise<{ url: string }>
232
241
  /** Where a buyer manages or cancels. */
233
242
  portal(request: { customer: string; returnUrl: string }): Promise<{ url: string }>
@@ -1,14 +1,23 @@
1
- import { entitlementTag, type Product, type PurchaseState, type RailEvent } from '../index'
2
- import type { Entitlements, PayReport, PayStore, Purchase } from '../server'
1
+ import {
2
+ entitlementTag,
3
+ orderTag,
4
+ type Product,
5
+ type PurchaseState,
6
+ type RailEvent,
7
+ } from '../index'
8
+ import type { Entitlements, OrderHook, PayReport, PayStore, Purchase } from '../server'
3
9
 
4
10
  const ACCESS: ReadonlySet<PurchaseState> = new Set(['active', 'trial', 'grace'])
5
11
  const FINAL: ReadonlySet<PurchaseState> = new Set(['refunded', 'revoked'])
12
+ /** The product id every `pay.order` is recorded under; its plan is the app's order ref. */
13
+ export const ORDER = 'order'
6
14
 
7
15
  /** Derive access and apply verified events against one store. */
8
16
  export function makeLedger<P extends Product>(
9
17
  store: PayStore,
10
18
  byId: Map<string, P>,
11
19
  leeway: number,
20
+ onOrder?: OrderHook,
12
21
  ) {
13
22
  const live = (p: Purchase) =>
14
23
  ACCESS.has(p.state) && (p.expires === null || Date.now() < p.expires + leeway)
@@ -27,7 +36,7 @@ export function makeLedger<P extends Product>(
27
36
  }
28
37
 
29
38
  /** One event onto its purchase. Identity always merges; state moves only forward on the rail's clock. */
30
- async function apply(rail: string, e: RailEvent, report?: PayReport): Promise<string | null> {
39
+ async function apply(rail: string, e: RailEvent, report?: PayReport): Promise<string[]> {
31
40
  const id = `${rail}:${e.purchase}`
32
41
  const had = await store.purchase(id)
33
42
  const newer =
@@ -44,13 +53,19 @@ export function makeLedger<P extends Product>(
44
53
  expires: newer ? (e.expires ?? had?.expires ?? null) : had.expires,
45
54
  }
46
55
  await store.save(next)
56
+ // An order (`pay.order`) grants nothing: the app hears its state, before the event is marked, so a throw redelivers.
57
+ if (next.product === ORDER && next.plan) {
58
+ if (newer && had?.state !== next.state)
59
+ await onOrder?.({ ref: next.plan, state: next.state, purchase: next })
60
+ return ['orders', orderTag(next.plan)]
61
+ }
47
62
  if (next.product && !byId.has(next.product))
48
63
  report?.({
49
64
  code: 'PAY021',
50
65
  severity: 'warning',
51
66
  message: `purchase \`${id}\` is for \`${next.product}\`, which isn't in the catalog: it grants nothing`,
52
67
  })
53
- return next.user && entitlementTag(next.user)
68
+ return next.user ? [entitlementTag(next.user)] : []
54
69
  }
55
70
 
56
71
  async function ingest(rail: string, events: RailEvent[], report?: PayReport) {
@@ -58,9 +73,9 @@ export function makeLedger<P extends Product>(
58
73
  for (const e of events) {
59
74
  const key = `${rail}:${e.id}`
60
75
  if (await store.seen(key)) continue
61
- const tag = await apply(rail, e, report)
76
+ const poked = await apply(rail, e, report)
62
77
  await store.mark(key, e.at)
63
- if (tag) tags.add(tag)
78
+ for (const t of poked) tags.add(t)
64
79
  }
65
80
  return [...tags]
66
81
  }
@@ -1,6 +1,7 @@
1
- import { PayError, type PayRail, type Product, type RailEvent } from '../index'
1
+ import { PayError, type CheckoutLine, type PayRail, type Product, type RailEvent } from '../index'
2
2
  import type {
3
3
  CheckoutInput,
4
+ OrderInput,
4
5
  PayContext,
5
6
  PayOptions,
6
7
  PayReport,
@@ -8,6 +9,7 @@ import type {
8
9
  Purchase,
9
10
  } from '../server'
10
11
  import { lineOf } from './catalog'
12
+ import { ORDER } from './ledger'
11
13
 
12
14
  const BODY = 1 << 20
13
15
  const hex = async (s: string) =>
@@ -140,3 +142,74 @@ export function webhookFor(
140
142
  }
141
143
  return webhook
142
144
  }
145
+
146
+ const REF = /^[A-Za-z0-9_-]{1,64}$/
147
+
148
+ /** Check an order's lines, which the app priced on the server, and sum them into the ledger's one line. */
149
+ export function orderLines(input: OrderInput): { line: CheckoutLine; lines: CheckoutLine[] } {
150
+ const { ref, currency = 'usd' } = input
151
+ if (!REF.test(ref))
152
+ throw new PayError(
153
+ 'PAY021',
154
+ `order ref \`${ref}\` must be 1–64 letters, digits, \`-\` or \`_\``,
155
+ )
156
+ if (!/^[a-z]{3}$/.test(currency))
157
+ throw new PayError('PAY008', `\`${currency}\` isn't a lowercase ISO currency`)
158
+ if (!input.lines.length || input.lines.length > 100)
159
+ throw new PayError('PAY021', `order \`${ref}\` needs 1 to 100 lines`)
160
+ const lines = input.lines.map((l): CheckoutLine => {
161
+ const quantity = l.quantity ?? 1
162
+ if (!Number.isSafeInteger(l.amount) || l.amount < 0)
163
+ throw new PayError(
164
+ 'PAY008',
165
+ `\`${l.name}\`: ${l.amount} isn't whole minor units (999 is $9.99)`,
166
+ )
167
+ if (!Number.isInteger(quantity) || quantity < 1 || quantity > 999)
168
+ throw new PayError('PAY021', `\`${l.name}\`: quantity ${quantity} is out of range`)
169
+ return {
170
+ product: ORDER,
171
+ plan: ref,
172
+ name: l.name.slice(0, 250),
173
+ amount: l.amount,
174
+ currency,
175
+ mode: 'payment',
176
+ quantity,
177
+ }
178
+ })
179
+ const total = lines.reduce((sum, l) => sum + l.amount * l.quantity, 0)
180
+ if (total <= 0) throw new PayError('PAY021', `order \`${ref}\` totals nothing`)
181
+ return {
182
+ line: {
183
+ product: ORDER,
184
+ plan: ref,
185
+ name: input.title ?? `Order ${ref}`,
186
+ amount: total,
187
+ currency,
188
+ mode: 'payment',
189
+ quantity: 1,
190
+ },
191
+ lines,
192
+ }
193
+ }
194
+
195
+ /** Create the order operation: one payment for lines the app priced, signed in or not. */
196
+ export function orderFor<P extends Product>({ options, urls, userOf, railFor }: Services<P>) {
197
+ async function order(ctx: PayContext, input: OrderInput) {
198
+ const { line, lines } = orderLines(input)
199
+ const rail = await railFor()
200
+ const origin = options.origin ?? new URL(ctx.request.url).origin
201
+ const back = (path: string) => new URL(path.replaceAll('{ref}', input.ref), origin).href
202
+ const { url } = await rail.checkout({
203
+ user: userOf(ctx) ?? `guest:${input.ref}`,
204
+ line,
205
+ lines,
206
+ ...(input.email && { email: input.email }),
207
+ successUrl: back(urls.order ?? urls.success),
208
+ cancelUrl: back(urls.orderCancel ?? urls.cancel),
209
+ // One session per order: paying twice for the same ref can't happen from a double click.
210
+ idempotencyKey: await hex(JSON.stringify(['order', input.ref, line.amount])),
211
+ })
212
+ return url
213
+ }
214
+ return order
215
+ }
package/src/server.ts CHANGED
@@ -4,9 +4,9 @@
4
4
 
5
5
  import type { Context } from '@weft-js/start'
6
6
  import { makeRails } from './server/rails'
7
- import { makeLedger } from './server/ledger'
7
+ import { makeLedger, ORDER } from './server/ledger'
8
8
  import { checkoutInput } from './server/catalog'
9
- import { checkoutFor, portalFor, webhookFor } from './server/operations'
9
+ import { checkoutFor, orderFor, portalFor, webhookFor } from './server/operations'
10
10
  import type { ServerExtension } from '@weft-js/start/extension'
11
11
  import { PayError, type PayRail, type Product, type PurchaseState, type RailEvent } from './index'
12
12
 
@@ -91,8 +91,10 @@ export interface PayOptions<P extends Product> {
91
91
  /** `import * as catalog from './pay/catalog'`, or a list. */
92
92
  catalog: readonly P[] | Record<string, P>
93
93
  store: PayStore
94
- /** Paths or URLs a buyer comes back to. They never come from the client. */
95
- urls: { success: string; cancel: string; portal?: string }
94
+ /** Paths or URLs a buyer comes back to. They never come from the client. `order` and `orderCancel` may hold `{ref}`. */
95
+ urls: { success: string; cancel: string; portal?: string; order?: string; orderCancel?: string }
96
+ /** Hears each state an order (`pay.order`) moves to, from verified events only: mark it paid here. A throw redelivers. */
97
+ onOrder?: OrderHook
96
98
  /** Who a request is. Default `ctx.user?.id`. */
97
99
  user?: (ctx: PayContext) => string | null | undefined
98
100
  /** With several rails bound, the one that takes payments. One rail needs no choosing. */
@@ -116,6 +118,34 @@ export interface CheckoutInput<P extends Product = Product> {
116
118
  quantity?: number
117
119
  }
118
120
 
121
+ /** One line of an order, priced by the app on the server: never from the client. */
122
+ export interface OrderLine {
123
+ name: string
124
+ /** Whole minor units, each. */
125
+ amount: number
126
+ quantity?: number
127
+ }
128
+
129
+ /** What `pay.order` takes: the app's own order id and the lines it priced. */
130
+ export interface OrderInput {
131
+ /** The app's order id, 1–64 of `A-Z a-z 0-9 - _`. It comes back in `onOrder` and in `orderTag(ref)`. */
132
+ ref: string
133
+ lines: readonly OrderLine[]
134
+ /** Lowercase ISO. Default `usd`. */
135
+ currency?: string
136
+ /** What the ledger's one line is called. Default `Order <ref>`. */
137
+ title?: string
138
+ /** A guest's email, for the rail's receipt. */
139
+ email?: string
140
+ }
141
+
142
+ /** `onOrder`: an order's new state, and its row in the ledger. */
143
+ export type OrderHook = (order: {
144
+ ref: string
145
+ state: PurchaseState
146
+ purchase: Purchase
147
+ }) => Promise<void> | void
148
+
119
149
  /** What `definePay` returns: the ledger's calls, and a `server.extension` that carries them to the server. */
120
150
  export interface Pay<P extends Product = Product> extends ServerExtension {
121
151
  readonly catalog: readonly P[]
@@ -130,6 +160,8 @@ export interface Pay<P extends Product = Product> extends ServerExtension {
130
160
  }
131
161
  /** The hosted checkout's URL, for this buyer. Grants nothing. */
132
162
  checkout(ctx: PayContext, input: CheckoutInput<P>): Promise<string>
163
+ /** The hosted page for one order the app priced, signed in or a guest. Pays nothing until `onOrder` hears `active`. */
164
+ order(ctx: PayContext, input: OrderInput): Promise<string>
133
165
  /** Where this buyer manages or cancels. */
134
166
  portal(ctx: PayContext): Promise<string>
135
167
  /** Read for a user id, as a webhook or a job would. In a request, `ctx.entitlements`. */
@@ -160,6 +192,11 @@ export function definePay<P extends Product>(options: PayOptions<P>): Pay<P> {
160
192
  Array.isArray(options.catalog) ? options.catalog : Object.values(options.catalog)
161
193
  ) as P[]
162
194
  const byId = new Map(catalog.map((p) => [p.id, p]))
195
+ if (byId.has(ORDER))
196
+ throw new PayError(
197
+ 'PAY021',
198
+ `\`${ORDER}\` is the id \`pay.order\` records under: name the product something else`,
199
+ )
163
200
  const { store, urls, leeway = 3 * DAY } = options
164
201
  const userOf = options.user ?? ((ctx) => ctx?.user?.id)
165
202
  const slot =
@@ -172,12 +209,13 @@ export function definePay<P extends Product>(options: PayOptions<P>): Pay<P> {
172
209
  const rails = makeRails(options, slot)
173
210
  const { railFor } = rails
174
211
 
175
- const { live, entitlements, ingest } = makeLedger(store, byId, leeway)
212
+ const { live, entitlements, ingest } = makeLedger(store, byId, leeway, options.onOrder)
176
213
 
177
214
  const pay: Pay<P> = {
178
215
  catalog,
179
216
  input: checkoutInput(byId),
180
217
  checkout: checkoutFor({ options, urls, userOf, store, railFor, live, byId }),
218
+ order: orderFor({ options, urls, userOf, store, railFor, live, byId }),
181
219
  portal: portalFor({ options, urls, userOf, store, railFor, live, byId }),
182
220
  entitlements,
183
221
  forContext: (ctx) => entitlements(userOf(ctx)),
package/src/square.ts ADDED
@@ -0,0 +1,231 @@
1
+ // Square's signature is base64 HMAC-SHA256 of `<notification URL><raw body>` with the subscription's signature key, in
2
+ // `x-square-hmacsha256-signature`. It carries no time, so a replay is stopped by the ledger's event ids. A payment's event
3
+ // doesn't carry the order's metadata, so after the signature checks out the rail reads the order once to learn who and what.
4
+ // Square sells one-time payments here: subscriptions and a customer portal are Stripe's (PAY022).
5
+
6
+ import {
7
+ PayError,
8
+ type CheckoutLine,
9
+ type CheckoutRequest,
10
+ type PayRail,
11
+ type PurchaseState,
12
+ type RailEvent,
13
+ } from './index'
14
+
15
+ /** Options of `squareRail()`. Secrets default to the environment, which is where they belong. */
16
+ export interface SquareOptions {
17
+ /** `sandbox` talks to squareupsandbox.com. Default `process.env.SQUARE_ENV`, else production. */
18
+ environment?: 'sandbox' | 'production'
19
+ /** The API's origin: point it at a local mock. Default from `environment`. */
20
+ api?: string
21
+ /** Pinned, so an account's default version can't reshape these calls. */
22
+ apiVersion?: string
23
+ /** Default `process.env.SQUARE_ACCESS_TOKEN`. */
24
+ accessToken?: string
25
+ /** Default `process.env.SQUARE_LOCATION_ID`: the shop the payments land in. */
26
+ locationId?: string
27
+ /** Default `process.env.SQUARE_WEBHOOK_SIGNATURE_KEY`. */
28
+ signatureKey?: string
29
+ /** The exact URL given to Square for the webhook; it's part of what's signed. Default `process.env.SQUARE_WEBHOOK_URL`. */
30
+ notificationUrl?: string
31
+ /** Default the global one. */
32
+ fetch?: typeof fetch
33
+ }
34
+
35
+ interface Money {
36
+ amount?: number
37
+ currency?: string
38
+ }
39
+ interface SquarePayment {
40
+ id: string
41
+ order_id?: string
42
+ status?: string
43
+ updated_at?: string
44
+ created_at?: string
45
+ customer_id?: string
46
+ total_money?: Money
47
+ amount_money?: Money
48
+ refunded_money?: Money
49
+ }
50
+ interface SquareEvent {
51
+ event_id?: string
52
+ type?: string
53
+ created_at?: string
54
+ data?: { object?: { payment?: SquarePayment } }
55
+ }
56
+
57
+ const PAYMENT: Record<string, PurchaseState> = {
58
+ APPROVED: 'pending',
59
+ PENDING: 'pending',
60
+ COMPLETED: 'active',
61
+ CANCELED: 'expired',
62
+ FAILED: 'expired',
63
+ }
64
+
65
+ const encoder = new TextEncoder()
66
+ const env = (name: string) => (typeof process === 'undefined' ? undefined : process.env[name])
67
+
68
+ async function signatureOk(key: string, text: string, given: string) {
69
+ const k = await crypto.subtle.importKey(
70
+ 'raw',
71
+ encoder.encode(key),
72
+ { name: 'HMAC', hash: 'SHA-256' },
73
+ false,
74
+ ['verify'],
75
+ )
76
+ let bytes: Uint8Array<ArrayBuffer>
77
+ try {
78
+ bytes = Uint8Array.from(atob(given), (c) => c.charCodeAt(0))
79
+ } catch {
80
+ return false
81
+ }
82
+ return crypto.subtle.verify('HMAC', k, bytes, encoder.encode(text))
83
+ }
84
+
85
+ /** One Square payment as a ledger state: a whole refund is final, a part refund keeps what was bought. */
86
+ function stateOf(p: SquarePayment): PurchaseState | null {
87
+ const paid = p.total_money?.amount ?? p.amount_money?.amount ?? 0
88
+ if (p.refunded_money?.amount && paid && p.refunded_money.amount >= paid) return 'refunded'
89
+ return PAYMENT[p.status ?? ''] ?? null
90
+ }
91
+
92
+ function squareCall(options: SquareOptions, fetcher: typeof fetch) {
93
+ const sandbox = (options.environment ?? env('SQUARE_ENV')) === 'sandbox'
94
+ const api =
95
+ options.api ??
96
+ (sandbox ? 'https://connect.squareupsandbox.com' : 'https://connect.squareup.com')
97
+ const version = options.apiVersion ?? '2025-10-16'
98
+ return async function call(path: string, body?: unknown) {
99
+ const token = options.accessToken ?? env('SQUARE_ACCESS_TOKEN')
100
+ if (!token) throw new PayError('PAY003', '`SQUARE_ACCESS_TOKEN` is not set on the server', 500)
101
+ const res = await fetcher(`${api}${path}`, {
102
+ method: body === undefined ? 'GET' : 'POST',
103
+ headers: {
104
+ authorization: `Bearer ${token}`,
105
+ 'content-type': 'application/json',
106
+ 'square-version': version,
107
+ },
108
+ ...(body !== undefined && { body: JSON.stringify(body) }),
109
+ })
110
+ const data = (await res.json().catch(() => null)) as Record<string, unknown> | null
111
+ if (!res.ok) {
112
+ const errors = (data?.errors as { detail?: string }[] | undefined) ?? []
113
+ throw new PayError(
114
+ 'PAY030',
115
+ `Square refused ${path}: ${errors[0]?.detail ?? res.status}`,
116
+ res.status === 429 ? 503 : 502,
117
+ )
118
+ }
119
+ return data ?? {}
120
+ }
121
+ }
122
+
123
+ const upper = (c: string) => c.toUpperCase()
124
+ const lineItem = (l: CheckoutLine) => ({
125
+ name: l.name,
126
+ quantity: String(l.quantity),
127
+ base_price_money: { amount: l.amount, currency: upper(l.currency) },
128
+ })
129
+
130
+ type Call = ReturnType<typeof squareCall>
131
+
132
+ /** One payment link for a checkout's lines, with the metadata its order carries back to `verify`. */
133
+ async function paymentLink(call: Call, options: SquareOptions, request: CheckoutRequest) {
134
+ const { line, user } = request
135
+ if (line.mode !== 'payment')
136
+ throw new PayError(
137
+ 'PAY022',
138
+ 'The Square rail takes one-time payments: sell subscriptions with Stripe',
139
+ 501,
140
+ )
141
+ const location = options.locationId ?? env('SQUARE_LOCATION_ID')
142
+ if (!location) throw new PayError('PAY003', '`SQUARE_LOCATION_ID` is not set on the server', 500)
143
+ const data = (await call('/v2/online-checkout/payment-links', {
144
+ idempotency_key: request.idempotencyKey.slice(0, 45),
145
+ order: {
146
+ location_id: location,
147
+ ...(line.plan && line.product === 'order' && { reference_id: line.plan.slice(0, 40) }),
148
+ line_items: (request.lines ?? [line]).map(lineItem),
149
+ metadata: {
150
+ weft_user: user.slice(0, 255),
151
+ weft_product: line.product,
152
+ ...(line.plan && { weft_plan: line.plan }),
153
+ },
154
+ },
155
+ checkout_options: { redirect_url: request.successUrl },
156
+ ...(request.email && { pre_populated_data: { buyer_email: request.email } }),
157
+ })) as { payment_link?: { url?: string } }
158
+ const url = data.payment_link?.url
159
+ if (typeof url !== 'string')
160
+ throw new PayError('PAY030', 'Square answered a link with no url', 502)
161
+ return { url }
162
+ }
163
+
164
+ /** The Square `pay.rail` provider: hosted payment links and webhooks verified here. One-time payments only. */
165
+ export function squareRail(options: SquareOptions = {}): PayRail {
166
+ const fetcher = options.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args))
167
+ const call = squareCall(options, fetcher)
168
+
169
+ async function identity(orderId: string) {
170
+ const { order } = (await call(`/v2/orders/${encodeURIComponent(orderId)}`)) as {
171
+ order?: { metadata?: Record<string, string> }
172
+ }
173
+ const m = order?.metadata ?? {}
174
+ return {
175
+ ...(m.weft_user && { user: m.weft_user }),
176
+ ...(m.weft_product && { product: m.weft_product }),
177
+ ...(m.weft_plan && { plan: m.weft_plan }),
178
+ }
179
+ }
180
+
181
+ return {
182
+ id: 'square',
183
+ modes: ['payment'],
184
+ checkout: (request) => paymentLink(call, options, request),
185
+ portal() {
186
+ return Promise.reject(
187
+ new PayError('PAY022', 'Square has no customer portal: a buyer manages nothing here', 501),
188
+ )
189
+ },
190
+ async verify({ body, headers }) {
191
+ const key = options.signatureKey ?? env('SQUARE_WEBHOOK_SIGNATURE_KEY')
192
+ const url = options.notificationUrl ?? env('SQUARE_WEBHOOK_URL')
193
+ if (!key || !url)
194
+ throw new PayError(
195
+ 'PAY011',
196
+ '`SQUARE_WEBHOOK_SIGNATURE_KEY` and `SQUARE_WEBHOOK_URL` must both be set: refusing every webhook rather than trusting one',
197
+ )
198
+ const given = headers.get('x-square-hmacsha256-signature') ?? ''
199
+ if (!given || !(await signatureOk(key, url + body, given)))
200
+ throw new PayError('PAY011', 'the signature does not match this body and URL')
201
+ const event = JSON.parse(body) as SquareEvent
202
+ const p = event.data?.object?.payment
203
+ if (!event.event_id || !p?.order_id || !event.type?.startsWith('payment.')) return []
204
+ const state = stateOf(p)
205
+ if (!state) return []
206
+ const at = Date.parse(p.updated_at ?? event.created_at ?? '') || Date.now()
207
+ const out: RailEvent = {
208
+ id: event.event_id,
209
+ at,
210
+ purchase: p.order_id,
211
+ state,
212
+ ...(await identity(p.order_id)),
213
+ ...(p.customer_id && { customer: p.customer_id }),
214
+ }
215
+ return [out]
216
+ },
217
+ async refund({ purchase }) {
218
+ const { order } = (await call(`/v2/orders/${encodeURIComponent(purchase)}`)) as {
219
+ order?: { tenders?: { payment_id?: string }[]; total_money?: Money }
220
+ }
221
+ const payment = order?.tenders?.[0]?.payment_id
222
+ if (!payment || !order?.total_money)
223
+ throw new PayError('PAY024', `order \`${purchase}\` has no payment to refund`, 404)
224
+ await call('/v2/refunds', {
225
+ idempotency_key: `refund-${purchase}`.slice(0, 45),
226
+ payment_id: payment,
227
+ amount_money: order.total_money,
228
+ })
229
+ },
230
+ }
231
+ }
package/src/stripe.ts CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import {
6
6
  PayError,
7
- type CheckoutRequest,
7
+ type CheckoutLine,
8
8
  type PayRail,
9
9
  type PurchaseState,
10
10
  type RailEvent,
@@ -241,7 +241,7 @@ export function stripeRail(options: StripeOptions = {}): PayRail {
241
241
  const fetcher = options.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args))
242
242
  const call = stripeCall(options, api, apiVersion, fetcher)
243
243
 
244
- function lineItem({ line }: CheckoutRequest) {
244
+ function lineItem(line: CheckoutLine) {
245
245
  const known = prices[line.plan ? `${line.product}.${line.plan}` : line.product]
246
246
  if (known) return { price: known, quantity: line.quantity }
247
247
  return {
@@ -268,11 +268,12 @@ export function stripeRail(options: StripeOptions = {}): PayRail {
268
268
  '/v1/checkout/sessions',
269
269
  {
270
270
  mode: line.mode,
271
- line_items: [lineItem(request)],
271
+ line_items: (request.lines ?? [line]).map(lineItem),
272
272
  success_url: request.successUrl,
273
273
  cancel_url: request.cancelUrl,
274
274
  client_reference_id: user,
275
275
  customer,
276
+ customer_email: customer ? undefined : request.email,
276
277
  metadata,
277
278
  // What the later events carry: a charge and a subscription don't inherit the session's metadata.
278
279
  ...(line.mode === 'subscription'
package/src/test.ts CHANGED
@@ -277,16 +277,26 @@ export async function payRailSuite({
277
277
  check('no signature is refused', await refused({ body: good.body, headers: new Headers() }))
278
278
  if (tolerance)
279
279
  check('an old delivery is refused as a replay', await refused(delivery(tolerance + 60_000)))
280
- const line = {
281
- product: 'pro',
282
- name: 'Pro',
283
- amount: 999,
284
- currency: 'usd',
285
- mode: 'subscription',
286
- plan: 'month',
287
- interval: 'month',
288
- quantity: 1,
289
- } as const
280
+ const subscriptions = rail.modes?.includes('subscription') ?? true
281
+ const line = subscriptions
282
+ ? ({
283
+ product: 'pro',
284
+ name: 'Pro',
285
+ amount: 999,
286
+ currency: 'usd',
287
+ mode: 'subscription',
288
+ plan: 'month',
289
+ interval: 'month',
290
+ quantity: 1,
291
+ } as const)
292
+ : ({
293
+ product: 'pie',
294
+ name: 'Pie',
295
+ amount: 2400,
296
+ currency: 'usd',
297
+ mode: 'payment',
298
+ quantity: 1,
299
+ } as const)
290
300
  const { url } = await rail.checkout({
291
301
  user: 'u1',
292
302
  line,
@@ -295,7 +305,9 @@ export async function payRailSuite({
295
305
  idempotencyKey: 'k1',
296
306
  })
297
307
  check('`checkout` answers an absolute URL', /^https?:\/\//.test(url), url)
298
- const portal = await rail.portal({ customer: 'c1', returnUrl: 'https://app.test/account' })
299
- check('`portal` answers an absolute URL', /^https?:\/\//.test(portal.url), portal.url)
308
+ if (subscriptions) {
309
+ const portal = await rail.portal({ customer: 'c1', returnUrl: 'https://app.test/account' })
310
+ check('`portal` answers an absolute URL', /^https?:\/\//.test(portal.url), portal.url)
311
+ }
300
312
  return out
301
313
  }