@weft-js/pay 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/stripe.ts CHANGED
@@ -1,12 +1,14 @@
1
- // `pay.rail@1` on Stripe (research 10 §5): hosted Checkout and the customer portal, over `fetch` with no SDK, and webhooks
2
- // checked here rather than left to the app. Nothing runs in the browser: `checkout` is a redirect, so a page needs neither
3
- // Stripe.js nor a publishable key.
4
- //
5
1
  // Stripe's signature is HMAC-SHA256 of `<t>.<raw body>`, in a header `t=…,v1=…` that can carry several `v1` while a secret
6
2
  // rolls. Stripe has no ordering guarantee, so each event carries the object's `created` or the event's own time, and the
7
3
  // ledger orders by that. A price is the catalog's unless `prices` maps `pro.month` to a Stripe id, on the server.
8
4
 
9
- import { PayError, type CheckoutRequest, type PayRail, type PurchaseState, type RailEvent } from './index'
5
+ import {
6
+ PayError,
7
+ type CheckoutRequest,
8
+ type PayRail,
9
+ type PurchaseState,
10
+ type RailEvent,
11
+ } from './index'
10
12
 
11
13
  /** Options of `stripeRail()`. The keys default to the environment, which is where they belong. */
12
14
  export interface StripeOptions {
@@ -29,22 +31,36 @@ export interface StripeOptions {
29
31
  }
30
32
 
31
33
  const STATES: Record<string, PurchaseState> = {
32
- active: 'active', trialing: 'trial', past_due: 'grace', unpaid: 'billing-retry', paused: 'paused', canceled: 'expired', incomplete_expired: 'expired', incomplete: 'pending',
34
+ active: 'active',
35
+ trialing: 'trial',
36
+ past_due: 'grace',
37
+ unpaid: 'billing-retry',
38
+ paused: 'paused',
39
+ canceled: 'expired',
40
+ incomplete_expired: 'expired',
41
+ incomplete: 'pending',
33
42
  }
34
43
 
35
44
  const API = 'https://api.stripe.com'
36
45
  const encoder = new TextEncoder()
37
46
 
38
- const fromHex = (s: string) => (/^[0-9a-f]{64}$/.test(s) ? Uint8Array.from(s.match(/../g)!, b => parseInt(b, 16)) : undefined)
47
+ const fromHex = (s: string) =>
48
+ /^[0-9a-f]{64}$/.test(s) ? Uint8Array.from(s.match(/../g)!, (b) => parseInt(b, 16)) : undefined
39
49
 
40
- /** The runtime's own constant-time compare, in native code. A wasm build was measured against it: see research 38. */
50
+ /** The runtime's constant-time compare. */
41
51
  async function verified(secret: string, text: string, signatures: string[]) {
42
- const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify'])
52
+ const key = await crypto.subtle.importKey(
53
+ 'raw',
54
+ encoder.encode(secret),
55
+ { name: 'HMAC', hash: 'SHA-256' },
56
+ false,
57
+ ['verify'],
58
+ )
43
59
  const message = encoder.encode(text)
44
60
  let ok = false
45
61
  for (const s of signatures) {
46
62
  const bytes = fromHex(s)
47
- if (bytes && await crypto.subtle.verify('HMAC', key, bytes, message)) ok = true
63
+ if (bytes && (await crypto.subtle.verify('HMAC', key, bytes, message))) ok = true
48
64
  }
49
65
  return ok
50
66
  }
@@ -76,17 +92,28 @@ interface StripeObject {
76
92
  current_period_end?: number | null
77
93
  items?: { data?: { current_period_end?: number | null }[] } | null
78
94
  }
79
- interface StripeEvent { id?: string; type: string; created: number; data?: { object?: StripeObject } }
95
+ interface StripeEvent {
96
+ id?: string
97
+ type: string
98
+ created: number
99
+ data?: { object?: StripeObject }
100
+ }
80
101
 
81
102
  /** A reference that Stripe expands into its object on some API versions. */
82
- const id = (x: unknown): string | undefined => (typeof x === 'string' ? x : (x as { id?: string } | null | undefined)?.id)
103
+ const id = (x: unknown): string | undefined =>
104
+ typeof x === 'string' ? x : (x as { id?: string } | null | undefined)?.id
83
105
  const identity = (object: StripeObject) => {
84
106
  const m = object?.metadata ?? {}
85
- return { ...(m.weft_user && { user: m.weft_user }), ...(m.weft_product && { product: m.weft_product }), ...(m.weft_plan && { plan: m.weft_plan }) }
107
+ return {
108
+ ...(m.weft_user && { user: m.weft_user }),
109
+ ...(m.weft_product && { product: m.weft_product }),
110
+ ...(m.weft_plan && { plan: m.weft_plan }),
111
+ }
86
112
  }
87
113
 
88
114
  /** A subscription's paid-through time, which moved onto its items in 2025 API versions. */
89
- const periodEnd = (sub: StripeObject) => seconds(sub.current_period_end ?? sub.items?.data?.[0]?.current_period_end)
115
+ const periodEnd = (sub: StripeObject) =>
116
+ seconds(sub.current_period_end ?? sub.items?.data?.[0]?.current_period_end)
90
117
 
91
118
  /** One Stripe event as the ledger's, or null for one that says nothing about access. */
92
119
  function normalise(event: StripeEvent): RailEvent | null {
@@ -99,19 +126,36 @@ function normalise(event: StripeEvent): RailEvent | null {
99
126
  if (!state) return null
100
127
  const customer = id(object.customer)
101
128
  const expires = periodEnd(object)
102
- return { ...base, purchase: object.id, state, ...identity(object), ...(customer && { customer }), ...(expires && { expires }) }
129
+ return {
130
+ ...base,
131
+ purchase: object.id,
132
+ state,
133
+ ...identity(object),
134
+ ...(customer && { customer }),
135
+ ...(expires && { expires }),
136
+ }
103
137
  }
104
- if (type === 'checkout.session.completed' || type === 'checkout.session.async_payment_succeeded' || type === 'checkout.session.async_payment_failed') {
138
+ if (
139
+ type === 'checkout.session.completed' ||
140
+ type === 'checkout.session.async_payment_succeeded' ||
141
+ type === 'checkout.session.async_payment_failed'
142
+ ) {
105
143
  if (object.mode !== 'payment') return null // `customer.subscription.*` says what a subscription is doing, trial included
106
144
  const purchase = id(object.payment_intent)
107
145
  if (!purchase) return null
108
146
  const customer = id(object.customer)
109
- const state: PurchaseState = type.endsWith('failed') ? 'expired' : object.payment_status === 'unpaid' ? 'pending' : 'active'
147
+ const state: PurchaseState = type.endsWith('failed')
148
+ ? 'expired'
149
+ : object.payment_status === 'unpaid'
150
+ ? 'pending'
151
+ : 'active'
110
152
  return { ...base, purchase, state, ...identity(object), ...(customer && { customer }) }
111
153
  }
112
154
  if (type === 'charge.refunded') {
113
155
  const purchase = id(object.payment_intent)
114
- return purchase && object.refunded ? { ...base, purchase, state: 'refunded', ...identity(object) } : null // `refunded` is whole: a part refund keeps what was bought
156
+ return purchase && object.refunded
157
+ ? { ...base, purchase, state: 'refunded', ...identity(object) }
158
+ : null // `refunded` is whole: a part refund keeps what was bought
115
159
  }
116
160
  if (type === 'charge.dispute.created' || type === 'charge.dispute.closed') {
117
161
  const purchase = id(object.payment_intent)
@@ -122,34 +166,92 @@ function normalise(event: StripeEvent): RailEvent | null {
122
166
  return null
123
167
  }
124
168
 
125
- /** The Stripe `pay.rail` provider: hosted Checkout, the customer portal, and webhooks verified here. */
126
- export function stripeRail(options: StripeOptions = {}): PayRail {
127
- const { prices = {}, api = API, apiVersion = '2025-09-30.clover', tolerance = 300 } = options
128
- const fetcher = options.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args))
169
+ function stripeCall(
170
+ options: StripeOptions,
171
+ api: string,
172
+ apiVersion: string,
173
+ fetcher: typeof fetch,
174
+ ) {
129
175
  const secret = () => {
130
176
  const key = options.secretKey ?? process.env.STRIPE_SECRET_KEY
131
177
  if (!key) throw new PayError('PAY003', '`STRIPE_SECRET_KEY` is not set on the server', 500)
132
- if (api === API && /placeholder/i.test(key)) throw new PayError('PAY003', '`STRIPE_SECRET_KEY` is still the placeholder `weft add pay` wrote: nothing was sent to Stripe', 500) // a mock may take one
178
+ if (api === API && /placeholder/i.test(key))
179
+ throw new PayError(
180
+ 'PAY003',
181
+ '`STRIPE_SECRET_KEY` is still the placeholder `weft add pay` wrote: nothing was sent to Stripe',
182
+ 500,
183
+ ) // a mock may take one
133
184
  return key
134
185
  }
135
186
 
136
187
  async function call(path: string, body: Record<string, unknown>, idempotencyKey?: string) {
137
188
  const res = await fetcher(`${api}${path}`, {
138
189
  method: 'POST',
139
- headers: { authorization: `Bearer ${secret()}`, 'content-type': 'application/x-www-form-urlencoded', 'stripe-version': apiVersion, ...(idempotencyKey && { 'idempotency-key': idempotencyKey }) },
190
+ headers: {
191
+ authorization: `Bearer ${secret()}`,
192
+ 'content-type': 'application/x-www-form-urlencoded',
193
+ 'stripe-version': apiVersion,
194
+ ...(idempotencyKey && { 'idempotency-key': idempotencyKey }),
195
+ },
140
196
  body: form(body),
141
197
  })
142
- const data = await res.json().catch(() => null) as { url?: string; error?: { message?: string } } | null
143
- if (!res.ok) throw new PayError('PAY030', `Stripe refused ${path}: ${data?.error?.message ?? res.status}`, res.status === 429 ? 503 : 502)
198
+ const data = (await res.json().catch(() => null)) as {
199
+ url?: string
200
+ error?: { message?: string }
201
+ } | null
202
+ if (!res.ok)
203
+ throw new PayError(
204
+ 'PAY030',
205
+ `Stripe refused ${path}: ${data?.error?.message ?? res.status}`,
206
+ res.status === 429 ? 503 : 502,
207
+ )
144
208
  return data
145
209
  }
146
210
 
211
+ return call
212
+ }
213
+
214
+ async function verifyStripe(
215
+ options: StripeOptions,
216
+ tolerance: number,
217
+ { body, headers }: Parameters<PayRail['verify']>[0],
218
+ ) {
219
+ const signing = options.webhookSecret ?? process.env.STRIPE_WEBHOOK_SECRET
220
+ if (!signing)
221
+ throw new PayError(
222
+ 'PAY011',
223
+ '`STRIPE_WEBHOOK_SECRET` is not set: refusing every webhook rather than trusting one',
224
+ )
225
+ const parts = (headers.get('stripe-signature') ?? '').split(',').map((p) => p.trim().split('='))
226
+ const t = Number(parts.find(([k]) => k === 't')?.[1])
227
+ const given = parts.filter(([k]) => k === 'v1').map(([, v]) => v ?? '')
228
+ if (!Number.isInteger(t) || !given.length)
229
+ throw new PayError('PAY011', 'no `stripe-signature` to check')
230
+ if (!(await verified(signing, `${t}.${body}`, given)))
231
+ throw new PayError('PAY011', 'the signature does not match this body')
232
+ if (Math.abs(Date.now() / 1000 - t) > tolerance)
233
+ throw new PayError('PAY011', 'the signature is too old: a replay, or a clock adrift')
234
+ const event = normalise(JSON.parse(body) as StripeEvent)
235
+ return event ? [event] : []
236
+ }
237
+
238
+ /** The Stripe `pay.rail` provider: hosted Checkout, the customer portal, and webhooks verified here. */
239
+ export function stripeRail(options: StripeOptions = {}): PayRail {
240
+ const { prices = {}, api = API, apiVersion = '2025-09-30.clover', tolerance = 300 } = options
241
+ const fetcher = options.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args))
242
+ const call = stripeCall(options, api, apiVersion, fetcher)
243
+
147
244
  function lineItem({ line }: CheckoutRequest) {
148
245
  const known = prices[line.plan ? `${line.product}.${line.plan}` : line.product]
149
246
  if (known) return { price: known, quantity: line.quantity }
150
247
  return {
151
248
  quantity: line.quantity,
152
- price_data: { currency: line.currency, unit_amount: line.amount, product_data: { name: line.name }, ...(line.interval && { recurring: { interval: line.interval } }) },
249
+ price_data: {
250
+ currency: line.currency,
251
+ unit_amount: line.amount,
252
+ product_data: { name: line.name },
253
+ ...(line.interval && { recurring: { interval: line.interval } }),
254
+ },
153
255
  }
154
256
  }
155
257
 
@@ -157,41 +259,52 @@ export function stripeRail(options: StripeOptions = {}): PayRail {
157
259
  id: 'stripe',
158
260
  async checkout(request) {
159
261
  const { line, user, customer } = request
160
- const metadata = { weft_user: user, weft_product: line.product, ...(line.plan && { weft_plan: line.plan }) }
161
- const session = await call('/v1/checkout/sessions', {
162
- mode: line.mode,
163
- line_items: [lineItem(request)],
164
- success_url: request.successUrl,
165
- cancel_url: request.cancelUrl,
166
- client_reference_id: user,
167
- customer,
168
- metadata,
169
- // What the later events carry: a charge and a subscription don't inherit the session's metadata.
170
- ...(line.mode === 'subscription'
171
- ? { subscription_data: { metadata, trial_period_days: line.trialDays } }
172
- : { payment_intent_data: { metadata }, customer_creation: customer ? undefined : 'always', ...(line.shipping && { shipping_address_collection: { allowed_countries: options.shippingCountries ?? ['US', 'CA'] } }) }),
173
- }, request.idempotencyKey)
174
- if (typeof session?.url !== 'string') throw new PayError('PAY030', 'Stripe answered a session with no url', 502)
262
+ const metadata = {
263
+ weft_user: user,
264
+ weft_product: line.product,
265
+ ...(line.plan && { weft_plan: line.plan }),
266
+ }
267
+ const session = await call(
268
+ '/v1/checkout/sessions',
269
+ {
270
+ mode: line.mode,
271
+ line_items: [lineItem(request)],
272
+ success_url: request.successUrl,
273
+ cancel_url: request.cancelUrl,
274
+ client_reference_id: user,
275
+ customer,
276
+ metadata,
277
+ // What the later events carry: a charge and a subscription don't inherit the session's metadata.
278
+ ...(line.mode === 'subscription'
279
+ ? { subscription_data: { metadata, trial_period_days: line.trialDays } }
280
+ : {
281
+ payment_intent_data: { metadata },
282
+ customer_creation: customer ? undefined : 'always',
283
+ ...(line.shipping && {
284
+ shipping_address_collection: {
285
+ allowed_countries: options.shippingCountries ?? ['US', 'CA'],
286
+ },
287
+ }),
288
+ }),
289
+ },
290
+ request.idempotencyKey,
291
+ )
292
+ if (typeof session?.url !== 'string')
293
+ throw new PayError('PAY030', 'Stripe answered a session with no url', 502)
175
294
  return { url: session.url }
176
295
  },
177
296
  async portal({ customer, returnUrl }) {
178
297
  const session = await call('/v1/billing_portal/sessions', { customer, return_url: returnUrl })
179
298
  return { url: session!.url! }
180
299
  },
181
- async verify({ body, headers }) {
182
- const signing = options.webhookSecret ?? process.env.STRIPE_WEBHOOK_SECRET
183
- if (!signing) throw new PayError('PAY011', '`STRIPE_WEBHOOK_SECRET` is not set: refusing every webhook rather than trusting one')
184
- const parts = (headers.get('stripe-signature') ?? '').split(',').map(p => p.trim().split('='))
185
- const t = Number(parts.find(([k]) => k === 't')?.[1])
186
- const given = parts.filter(([k]) => k === 'v1').map(([, v]) => v ?? '')
187
- if (!Number.isInteger(t) || !given.length) throw new PayError('PAY011', 'no `stripe-signature` to check')
188
- if (!(await verified(signing, `${t}.${body}`, given))) throw new PayError('PAY011', 'the signature does not match this body')
189
- if (Math.abs(Date.now() / 1000 - t) > tolerance) throw new PayError('PAY011', 'the signature is too old: a replay, or a clock adrift')
190
- const event = normalise(JSON.parse(body) as StripeEvent)
191
- return event ? [event] : []
192
- },
300
+ verify: (delivery) => verifyStripe(options, tolerance, delivery),
193
301
  async refund({ purchase }) {
194
- if (!purchase.startsWith('pi_')) throw new PayError('PAY022', 'Only a one-time payment refunds here: a subscription is cancelled in the portal', 400)
302
+ if (!purchase.startsWith('pi_'))
303
+ throw new PayError(
304
+ 'PAY022',
305
+ 'Only a one-time payment refunds here: a subscription is cancelled in the portal',
306
+ 400,
307
+ )
195
308
  await call('/v1/refunds', { payment_intent: purchase }, `refund:${purchase}`)
196
309
  },
197
310
  }
package/src/test.ts CHANGED
@@ -1,21 +1,47 @@
1
- // fakePay (research 10 §8): a second `pay.rail` provider, which keeps a rail's state machine in memory. Every event goes the
2
- // way a real one does, signed, through the app's own `webhook`, ledger and tag pokes, so a test asserts what the user sees
3
- // and what the server grants. It holds no key and opens no connection, and it reaches @weft-js/core/test only through `net` and
4
- // `clock`, which any plugin can use. `payRailSuite` is what a rail, this one included, is held to.
5
-
6
1
  import { createHmac, timingSafeEqual } from 'node:crypto'
7
2
  import { clock, net } from '@weft-js/core/test'
8
3
  import { definePay, memoryPayStore, type Pay, type PayOptions, type Purchase } from './server'
9
- import { PayError, type CheckoutRequest, type Interval, type PayRail, type Product, type PurchaseState, type RailEvent } from './index'
4
+ import {
5
+ PayError,
6
+ type CheckoutRequest,
7
+ type Interval,
8
+ type PayRail,
9
+ type Product,
10
+ type PurchaseState,
11
+ type RailEvent,
12
+ } from './index'
10
13
 
11
14
  const DAY = 86_400_000
12
- const PERIOD: Record<Interval, number> = { day: DAY, week: 7 * DAY, month: 30 * DAY, year: 365 * DAY }
13
- const TERMS = { 'renewal-failed': 'grace', 'grace-expired': 'expired', recovered: 'active', cancel: 'expired', pause: 'paused', resume: 'active', refund: 'refunded', revoke: 'revoked' } as const
15
+ const PERIOD: Record<Interval, number> = {
16
+ day: DAY,
17
+ week: 7 * DAY,
18
+ month: 30 * DAY,
19
+ year: 365 * DAY,
20
+ }
21
+ const TERMS = {
22
+ 'renewal-failed': 'grace',
23
+ 'grace-expired': 'expired',
24
+ recovered: 'active',
25
+ cancel: 'expired',
26
+ pause: 'paused',
27
+ resume: 'active',
28
+ refund: 'refunded',
29
+ revoke: 'revoked',
30
+ } as const
14
31
 
15
32
  /** What `simulate` can make the rail do next. */
16
33
  export type PayScenario = 'renewal' | keyof typeof TERMS
17
34
 
18
- interface Bought { id: string; user: string; product: string; plan?: string; interval?: Interval; state: PurchaseState; expires?: number; customer: string }
35
+ interface Bought {
36
+ id: string
37
+ user: string
38
+ product: string
39
+ plan?: string
40
+ interval?: Interval
41
+ state: PurchaseState
42
+ expires?: number
43
+ customer: string
44
+ }
19
45
 
20
46
  /** A rail in memory and the handles a test drives it by, as `fakePay` returns them. */
21
47
  export interface FakePay {
@@ -44,16 +70,15 @@ export interface FakePay {
44
70
  purchases(user: string): Promise<Purchase[]>
45
71
  }
46
72
 
47
- const sign = (secret: string, t: number, body: string) => createHmac('sha256', secret).update(`${t}.${body}`).digest('hex')
73
+ const sign = (secret: string, t: number, body: string) =>
74
+ createHmac('sha256', secret).update(`${t}.${body}`).digest('hex')
48
75
  const SECRET = 'fake-pay-signing-key'
49
76
 
50
- /** `fakePay(pay)` with the app's own `definePay`, or `fakePay({ catalog })`. It backs `ctx.entitlements` for this test. */
51
- export function fakePay(app: Pay | (Pick<PayOptions<Product>, 'catalog'> & Partial<PayOptions<Product>>)): FakePay {
52
- const sessions: CheckoutRequest[] = [], deliveries: FakePay['deliveries'] = []
53
- const purchases = new Map<string, Bought>()
54
- let seq = 0, last: RailEvent | undefined
55
- const next = (prefix: string) => `${prefix}_${++seq}`
56
-
77
+ function fakeRail(
78
+ sessions: CheckoutRequest[],
79
+ next: (prefix: string) => string,
80
+ refund: (purchase: string) => Promise<void>,
81
+ ): PayRail {
57
82
  const rail: PayRail = {
58
83
  id: 'fake',
59
84
  checkout(request) {
@@ -63,77 +88,149 @@ export function fakePay(app: Pay | (Pick<PayOptions<Product>, 'catalog'> & Parti
63
88
  portal: ({ customer }) => Promise.resolve({ url: `https://fake-pay.test/portal/${customer}` }),
64
89
  verify({ body, headers }) {
65
90
  const [t, v1] = (headers.get('x-fake-signature') ?? '').split(',')
66
- const want = Buffer.from(sign(SECRET, Number(t), body)), got = Buffer.from(v1 ?? '')
67
- if (want.length !== got.length || !timingSafeEqual(want, got)) throw new PayError('PAY011', 'no valid fake signature')
91
+ const want = Buffer.from(sign(SECRET, Number(t), body)),
92
+ got = Buffer.from(v1 ?? '')
93
+ if (want.length !== got.length || !timingSafeEqual(want, got))
94
+ throw new PayError('PAY011', 'no valid fake signature')
68
95
  return JSON.parse(body)
69
96
  },
70
97
  async refund({ purchase }) {
71
- await simulate('refund', purchase)
98
+ await refund(purchase)
72
99
  },
73
100
  }
74
101
 
75
- const pay: Pay = 'webhook' in app ? app : definePay({ store: memoryPayStore(), urls: { success: '/', cancel: '/' }, ...app })
76
- pay.use(rail)
77
- net.extension(pay)
102
+ return rail
103
+ }
78
104
 
79
- async function post(event: RailEvent, secret: string) {
80
- const body = JSON.stringify([event]), t = Date.now()
81
- const request = new Request('http://localhost/_weft/pay/fake', { method: 'POST', body, headers: { 'x-fake-signature': `${t},${sign(secret, t, body)}` } })
82
- const { response, tags } = await pay.webhook('fake', request)
83
- deliveries.push({ event, status: response.status })
84
- if (tags.length && net.subscriptions().length) await net.poke(...tags)
85
- return response.status
105
+ async function post(pay: Pay, deliveries: FakePay['deliveries'], event: RailEvent, secret: string) {
106
+ const body = JSON.stringify([event]),
107
+ t = Date.now()
108
+ const request = new Request('http://localhost/_weft/pay/fake', {
109
+ method: 'POST',
110
+ body,
111
+ headers: { 'x-fake-signature': `${t},${sign(secret, t, body)}` },
112
+ })
113
+ const { response, tags } = await pay.webhook('fake', request)
114
+ deliveries.push({ event, status: response.status })
115
+ if (tags.length && net.subscriptions().length) await net.poke(...tags)
116
+ return response.status
117
+ }
118
+
119
+ async function open(
120
+ s: CheckoutRequest,
121
+ purchases: Map<string, Bought>,
122
+ next: (prefix: string) => string,
123
+ deliver: FakePay['deliver'],
124
+ event: (b: Bought) => RailEvent,
125
+ ) {
126
+ const { line, user } = s
127
+ const b: Bought = {
128
+ id: next(line.mode === 'subscription' ? 'sub' : 'pay'),
129
+ user,
130
+ product: line.product,
131
+ state: line.trialDays ? 'trial' : 'active',
132
+ customer: s.customer ?? next('cus'),
133
+ ...(line.plan && { plan: line.plan }),
134
+ ...(line.interval && { interval: line.interval }),
86
135
  }
87
- const deliver: FakePay['deliver'] = (event, options) => { if (!options?.forged) last = event; return post(event, options?.forged ? 'not-the-key' : SECRET) }
136
+ if (b.interval)
137
+ b.expires = Date.now() + (line.trialDays ? line.trialDays * DAY : PERIOD[b.interval])
138
+ purchases.set(b.id, b)
139
+ await deliver(event(b))
140
+ return b.id
141
+ }
88
142
 
89
- const event = (b: Bought): RailEvent => ({ id: next('evt'), at: Date.now(), purchase: b.id, state: b.state, user: b.user, product: b.product, ...(b.plan && { plan: b.plan }), customer: b.customer, ...(b.expires !== undefined && { expires: b.expires }) })
143
+ function take(sessions: CheckoutRequest[], user?: string) {
144
+ const i = user ? sessions.findLastIndex((s) => s.user === user) : sessions.length - 1
145
+ if (i < 0)
146
+ throw new Error(
147
+ `fakePay: no open checkout${user ? ` for ${user}` : ''}: call \`checkout\` first`,
148
+ )
149
+ return sessions.splice(i, 1)[0]!
150
+ }
90
151
 
91
- async function open(s: CheckoutRequest) {
92
- const { line, user } = s
93
- const b: Bought = { id: next(line.mode === 'subscription' ? 'sub' : 'pay'), user, product: line.product, state: line.trialDays ? 'trial' : 'active', customer: s.customer ?? next('cus'), ...(line.plan && { plan: line.plan }), ...(line.interval && { interval: line.interval }) }
94
- if (b.interval) b.expires = Date.now() + (line.trialDays ? line.trialDays * DAY : PERIOD[b.interval])
95
- purchases.set(b.id, b)
96
- await deliver(event(b))
97
- return b.id
98
- }
152
+ /** `fakePay(pay)` with the app's own `definePay`, or `fakePay({ catalog })`. It backs `ctx.entitlements` for this test. */
153
+ export function fakePay(
154
+ app: Pay | (Pick<PayOptions<Product>, 'catalog'> & Partial<PayOptions<Product>>),
155
+ ): FakePay {
156
+ const sessions: CheckoutRequest[] = [],
157
+ deliveries: FakePay['deliveries'] = []
158
+ const purchases = new Map<string, Bought>()
159
+ let seq = 0,
160
+ last: RailEvent | undefined
161
+ const next = (prefix: string) => `${prefix}_${++seq}`
162
+
163
+ const rail = fakeRail(sessions, next, (purchase) => simulate('refund', purchase))
99
164
 
100
- const take = (user?: string) => {
101
- const i = user ? sessions.findLastIndex(s => s.user === user) : sessions.length - 1
102
- if (i < 0) throw new Error(`fakePay: no open checkout${user ? ` for ${user}` : ''}: call \`checkout\` first`)
103
- return sessions.splice(i, 1)[0]!
165
+ const pay: Pay =
166
+ 'webhook' in app
167
+ ? app
168
+ : definePay({ store: memoryPayStore(), urls: { success: '/', cancel: '/' }, ...app })
169
+ pay.use(rail)
170
+ net.extension(pay)
171
+
172
+ const deliver: FakePay['deliver'] = (event, options) => {
173
+ if (!options?.forged) last = event
174
+ return post(pay, deliveries, event, options?.forged ? 'not-the-key' : SECRET)
104
175
  }
105
176
 
177
+ const event = (b: Bought): RailEvent => ({
178
+ id: next('evt'),
179
+ at: Date.now(),
180
+ purchase: b.id,
181
+ state: b.state,
182
+ user: b.user,
183
+ product: b.product,
184
+ ...(b.plan && { plan: b.plan }),
185
+ customer: b.customer,
186
+ ...(b.expires !== undefined && { expires: b.expires }),
187
+ })
188
+
106
189
  async function simulate(what: PayScenario, id?: string) {
107
190
  const b = id ? purchases.get(id) : [...purchases.values()].at(-1)
108
191
  if (!b) throw new Error(`fakePay: no purchase${id ? ` \`${id}\`` : ' yet'} to ${what}`)
109
192
  if (what === 'renewal' || what === 'renewal-failed') {
110
- if (!b.interval) throw new Error(`fakePay: \`${b.id}\` is one payment: only a subscription renews`)
193
+ if (!b.interval)
194
+ throw new Error(`fakePay: \`${b.id}\` is one payment: only a subscription renews`)
111
195
  if (clock.installed) await clock.advance(Math.max(0, b.expires! - Date.now()))
112
196
  }
113
- if (what === 'renewal') Object.assign(b, { state: 'active', expires: b.expires! + PERIOD[b.interval!] })
197
+ if (what === 'renewal')
198
+ Object.assign(b, { state: 'active', expires: b.expires! + PERIOD[b.interval!] })
114
199
  else b.state = TERMS[what]
115
200
  await deliver(event(b))
116
201
  }
117
202
 
118
203
  return {
119
- pay, rail, sessions, deliveries, deliver, simulate,
120
- complete: user => open(take(user)),
121
- abandon: user => void take(user),
204
+ pay,
205
+ rail,
206
+ sessions,
207
+ deliveries,
208
+ deliver,
209
+ simulate,
210
+ complete: (user) => open(take(sessions, user), purchases, next, deliver, event),
211
+ abandon: (user) => void take(sessions, user),
122
212
  async buy(user, product, plan) {
123
- await pay.checkout({ user: { id: user }, request: { url: 'http://localhost/' } }, { product, ...(plan && { plan }) })
124
- return open(take(user))
213
+ await pay.checkout(
214
+ { user: { id: user }, request: { url: 'http://localhost/' } },
215
+ { product, ...(plan && { plan }) },
216
+ )
217
+ return open(take(sessions, user), purchases, next, deliver, event)
125
218
  },
126
219
  redeliver() {
127
220
  if (!last) throw new Error('fakePay: nothing delivered yet')
128
- return post(last, SECRET)
221
+ return post(pay, deliveries, last, SECRET)
129
222
  },
130
- entitlements: user => pay.entitlements(user).list(),
131
- purchases: user => pay.purchases(user),
223
+ entitlements: (user) => pay.entitlements(user).list(),
224
+ purchases: (user) => pay.purchases(user),
132
225
  }
133
226
  }
134
227
 
135
228
  /** One requirement of a suite, and how the rail did. */
136
- export interface Check { name: string; ok: boolean; detail: string }
229
+ export interface Check {
230
+ name: string
231
+ ok: boolean
232
+ detail: string
233
+ }
137
234
 
138
235
  /** What `payRailSuite` needs: the rail, and a way to make a delivery it would accept. */
139
236
  export interface PayRailSuiteOptions {
@@ -145,23 +242,58 @@ export interface PayRailSuiteOptions {
145
242
  }
146
243
 
147
244
  /** One Check per requirement of `pay.rail@1`, against whatever `fetch` the rail was given. Any `ok: false` is P119. */
148
- export async function payRailSuite({ rail, delivery, tolerance }: PayRailSuiteOptions): Promise<Check[]> {
245
+ export async function payRailSuite({
246
+ rail,
247
+ delivery,
248
+ tolerance,
249
+ }: PayRailSuiteOptions): Promise<Check[]> {
149
250
  const out: Check[] = []
150
251
  const check = (name: string, ok: boolean, detail = '') => void out.push({ name, ok, detail })
151
252
  const refused = async (d: { body: string; headers: Headers }) => {
152
- try { await rail.verify(d); return false } catch (e) { return (e as { code?: string } | null)?.code === 'PAY011' }
253
+ try {
254
+ await rail.verify(d)
255
+ return false
256
+ } catch (e) {
257
+ return (e as { code?: string } | null)?.code === 'PAY011'
258
+ }
153
259
  }
154
260
  const good = delivery()
155
261
  const events = await rail.verify(good)
156
262
  const e = events[0]
157
263
  check('a genuine delivery verifies', events.length > 0, `${events.length} events`)
158
- check('an event has an id, a time in ms and a purchase', !!e && typeof e.id === 'string' && e.id !== '' && e.at > 1e11 && typeof e.purchase === 'string', JSON.stringify(e))
159
- check('the same delivery gives the same id, so a repeat is a no-op', (await rail.verify(good))[0]?.id === e?.id)
160
- check('a changed byte is refused', await refused({ body: `${good.body} `, headers: good.headers }))
264
+ check(
265
+ 'an event has an id, a time in ms and a purchase',
266
+ !!e && typeof e.id === 'string' && e.id !== '' && e.at > 1e11 && typeof e.purchase === 'string',
267
+ JSON.stringify(e),
268
+ )
269
+ check(
270
+ 'the same delivery gives the same id, so a repeat is a no-op',
271
+ (await rail.verify(good))[0]?.id === e?.id,
272
+ )
273
+ check(
274
+ 'a changed byte is refused',
275
+ await refused({ body: `${good.body} `, headers: good.headers }),
276
+ )
161
277
  check('no signature is refused', await refused({ body: good.body, headers: new Headers() }))
162
- if (tolerance) check('an old delivery is refused as a replay', await refused(delivery(tolerance + 60_000)))
163
- const line = { product: 'pro', name: 'Pro', amount: 999, currency: 'usd', mode: 'subscription', plan: 'month', interval: 'month', quantity: 1 } as const
164
- const { url } = await rail.checkout({ user: 'u1', line, successUrl: 'https://app.test/ok', cancelUrl: 'https://app.test/no', idempotencyKey: 'k1' })
278
+ if (tolerance)
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
290
+ const { url } = await rail.checkout({
291
+ user: 'u1',
292
+ line,
293
+ successUrl: 'https://app.test/ok',
294
+ cancelUrl: 'https://app.test/no',
295
+ idempotencyKey: 'k1',
296
+ })
165
297
  check('`checkout` answers an absolute URL', /^https?:\/\//.test(url), url)
166
298
  const portal = await rail.portal({ customer: 'c1', returnUrl: 'https://app.test/account' })
167
299
  check('`portal` answers an absolute URL', /^https?:\/\//.test(portal.url), portal.url)