@weft-js/pay 0.1.3 → 0.1.5-nightly.20261111.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 +144 -148
- package/docs/pay.md +27 -15
- package/package.json +30 -26
- package/square-plugin.d.mts +9 -0
- package/square-plugin.mjs +47 -0
- package/src/index.ts +9 -0
- package/src/server/ledger.ts +21 -6
- package/src/server/operations.ts +74 -1
- package/src/server.ts +43 -5
- package/src/square.ts +231 -0
- package/src/stripe.ts +4 -3
- package/src/test.ts +24 -12
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
|
|
118
|
-
|
|
|
119
|
-
| `src/pay/catalog.ts`
|
|
120
|
-
| `src/pay.server.ts`
|
|
121
|
-
| `src/server/pay.ts`
|
|
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`
|
|
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
|
|
133
|
-
|
|
|
134
|
-
| A database
|
|
135
|
-
| Auth
|
|
136
|
-
| `STRIPE_SECRET_KEY`
|
|
137
|
-
| `STRIPE_WEBHOOK_SECRET`
|
|
138
|
-
| A Stripe webhook endpoint | Stripe dashboard
|
|
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
|
|
257
|
-
|
|
|
258
|
-
| `id`
|
|
259
|
-
| `kind`
|
|
260
|
-
| `use`
|
|
261
|
-
| `name`
|
|
262
|
-
| `plans`
|
|
263
|
-
| `price`
|
|
264
|
-
| `grants`
|
|
265
|
-
| `shipping` | `boolean`
|
|
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
|
|
301
|
-
|
|
|
302
|
-
| `catalog` | `Product[]` or `Record<string, Product>` | required
|
|
303
|
-
| `store`
|
|
304
|
-
| `urls`
|
|
305
|
-
| `user`
|
|
306
|
-
| `rail`
|
|
307
|
-
| `origin`
|
|
308
|
-
| `leeway`
|
|
309
|
-
| `slot`
|
|
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
|
|
314
|
-
|
|
|
315
|
-
| `pay.input`
|
|
316
|
-
| `pay.checkout(ctx, input)`
|
|
317
|
-
| `pay.portal(ctx)`
|
|
318
|
-
| `pay.entitlements(userId)`
|
|
319
|
-
| `pay.forContext(ctx)`
|
|
320
|
-
| `pay.purchases(userId)`
|
|
321
|
-
| `pay.refund(purchaseId)`
|
|
322
|
-
| `pay.webhook(rail, request, report?)` | One verified delivery: `{ response, tags }`.
|
|
323
|
-
| `pay.ingest(rail, events, report?)`
|
|
324
|
-
| `pay.use(rail)` / `pay.reset()`
|
|
325
|
-
| `pay.catalog`
|
|
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
|
|
340
|
-
|
|
|
341
|
-
| Not a `POST`
|
|
342
|
-
| No rail bound under that name
|
|
343
|
-
| Body over 1 MiB
|
|
344
|
-
| `verify` throws (bad or missing signature, a replay) | 400, and the reason goes to the server log, not the response
|
|
345
|
-
| `ingest` fails
|
|
346
|
-
| Otherwise
|
|
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
|
|
357
|
-
|
|
|
358
|
-
| `active`, `trial`, `grace`
|
|
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
|
|
374
|
-
|
|
|
375
|
-
| `requires: ['pro']`
|
|
376
|
-
| `await ctx.entitlements.has(id)` | `query`, `mutation`, `load` | Whatever you do: redirect, hide, refuse
|
|
377
|
-
| `await ctx.entitlements.list()`
|
|
378
|
-
| `entitlementTag(userId)`
|
|
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
|
|
390
|
-
|
|
|
391
|
-
| `prices`
|
|
392
|
-
| `api`
|
|
393
|
-
| `apiVersion`
|
|
394
|
-
| `tolerance`
|
|
395
|
-
| `shippingCountries` | `string[]`
|
|
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
|
|
419
|
-
|
|
|
420
|
-
| `customer.subscription.*`
|
|
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`
|
|
423
|
-
| `charge.dispute.created` / `.closed`
|
|
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
|
|
433
|
-
|
|
|
434
|
-
| `seen(eventId)`
|
|
435
|
-
| `mark(eventId, at)` | Record that it was.
|
|
436
|
-
| `purchase(id)`
|
|
437
|
-
| `save(purchase)`
|
|
438
|
-
| `byUser(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
|
|
454
|
-
|
|
|
455
|
-
| `id`
|
|
456
|
-
| `checkout(request)`
|
|
457
|
-
| `portal({ customer, returnUrl })` | Returns `{ url }`.
|
|
458
|
-
| `verify({ body, headers })`
|
|
459
|
-
| `refund({ purchase })`
|
|
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
|
|
513
|
-
|
|
|
514
|
-
| `complete(user?)`
|
|
515
|
-
| `abandon(user?)`
|
|
516
|
-
| `buy(user, product, plan?)`
|
|
517
|
-
| `simulate(what, purchase?)`
|
|
518
|
-
| `redeliver()`
|
|
519
|
-
| `deliver(event, { forged? })`
|
|
520
|
-
| `sessions`, `deliveries`
|
|
521
|
-
| `entitlements(user)`, `purchases(user)` | Read the ledger.
|
|
522
|
-
| `pay`, `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
|
|
529
|
-
|
|
|
530
|
-
| `@weft-js/pay`
|
|
531
|
-
| `@weft-js/pay/server`
|
|
532
|
-
| `@weft-js/pay/drizzle`
|
|
533
|
-
| `@weft-js/pay/test`
|
|
534
|
-
| `@weft-js/pay/plugin`
|
|
535
|
-
| `@weft-js/pay/stripe`
|
|
536
|
-
| `@weft-js/pay/stripe/plugin` | config
|
|
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
|
|
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
|
|
615
|
-
| PAY004 | Paid state written on the client after `checkout()`
|
|
616
|
-
| PAY005 | A price typed into a page that sells something (warning)
|
|
617
|
-
| PAY008 | A float or currency-less amount in `product()`
|
|
618
|
-
| PAY010 | A checkout mutation without `auth: 'user'`, or a checkout with no signed-in user (401)
|
|
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
|
|
621
|
-
| PAY013 | An entitlement checked only on the client
|
|
622
|
-
| PAY014 | The plugin with no `src/pay.server.ts`: no webhook is mounted
|
|
623
|
-
| PAY015 | `ctx.entitlements.has()` without `await`
|
|
624
|
-
| PAY016 | `memoryPayStore()` in `src/pay.server.ts` (warning); a production server refuses to start
|
|
625
|
-
| PAY017 | `@weft-js/pay/server` or `/drizzle` imported in client code
|
|
626
|
-
| PAY018 | `src/pay.server.ts` with no `pay.rail` provider
|
|
627
|
-
| PAY031 | A live key in a test
|
|
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
|
|
632
|
-
|
|
|
633
|
-
| PAY021
|
|
634
|
-
| PAY022
|
|
635
|
-
| PAY023
|
|
636
|
-
| PAY024
|
|
637
|
-
| PAY030
|
|
638
|
-
| P101
|
|
639
|
-
| `ENTITLEMENT_REQUIRED` | 403
|
|
640
|
-
| `E_PAY`
|
|
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
|
|
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)
|
|
648
|
-
| [`@weft-js/db`](https://www.npmjs.com/package/@weft-js/db)
|
|
649
|
-
| [`@weft-js/cli`](https://www.npmjs.com/package/@weft-js/cli)
|
|
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
|
|
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.5-nightly.20261111.3",
|
|
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/
|
|
40
|
-
"
|
|
41
|
-
"
|
|
49
|
+
"@weft-js/core": "^0.1.5-nightly.20261111.3",
|
|
50
|
+
"@weft-js/start": "^0.1.5-nightly.20261111.3",
|
|
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": ">=
|
|
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 }>
|
package/src/server/ledger.ts
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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
|
|
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
|
|
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
|
|
76
|
+
const poked = await apply(rail, e, report)
|
|
62
77
|
await store.mark(key, e.at)
|
|
63
|
-
|
|
78
|
+
for (const t of poked) tags.add(t)
|
|
64
79
|
}
|
|
65
80
|
return [...tags]
|
|
66
81
|
}
|
package/src/server/operations.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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:
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
299
|
-
|
|
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
|
}
|