@weft-js/pay 0.1.1 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,25 +242,72 @@ 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 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)
300
+ const { url } = await rail.checkout({
301
+ user: 'u1',
302
+ line,
303
+ successUrl: 'https://app.test/ok',
304
+ cancelUrl: 'https://app.test/no',
305
+ idempotencyKey: 'k1',
306
+ })
165
307
  check('`checkout` answers an absolute URL', /^https?:\/\//.test(url), url)
166
- const portal = await rail.portal({ customer: 'c1', returnUrl: 'https://app.test/account' })
167
- check('`portal` answers an absolute URL', /^https?:\/\//.test(portal.url), portal.url)
308
+ if (subscriptions) {
309
+ const portal = await rail.portal({ customer: 'c1', returnUrl: 'https://app.test/account' })
310
+ check('`portal` answers an absolute URL', /^https?:\/\//.test(portal.url), portal.url)
311
+ }
168
312
  return out
169
313
  }
@@ -1,5 +1,10 @@
1
1
  import type { PluginInstance } from '@weft-js/core/plugin'
2
2
 
3
- declare const plugin: (options?: { prices?: Record<string, string>; api?: string; apiVersion?: string; tolerance?: number }) => PluginInstance<unknown>
3
+ declare const plugin: (options?: {
4
+ prices?: Record<string, string>
5
+ api?: string
6
+ apiVersion?: string
7
+ tolerance?: number
8
+ }) => PluginInstance<unknown>
4
9
  /** `stripe()` for `weft.config.ts`: Stripe as a `pay.rail` provider. Keys stay in the environment. */
5
10
  export default plugin
package/stripe-plugin.mjs CHANGED
@@ -2,17 +2,36 @@
2
2
  // module, so they name env vars and never hold a key (PAY003). Another provider is this one line swapped for another.
3
3
  import { definePlugin, defineSlot, provide } from '@weft-js/core/plugin'
4
4
 
5
- const slot = defineSlot({ id: 'pay.rail', version: 1, cardinality: 'keyed', sides: ['server', 'client', 'native'] })
5
+ const slot = defineSlot({
6
+ id: 'pay.rail',
7
+ version: 1,
8
+ cardinality: 'keyed',
9
+ sides: ['server', 'client', 'native'],
10
+ })
6
11
 
7
12
  export const stripe = definePlugin({
8
13
  name: '@weft-js/pay/stripe',
9
14
  version: '0.0.0',
10
15
  weft: '*',
11
16
  permissions: { net: ['api.stripe.com'], env: ['STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET'] },
12
- contribute: options => ({
13
- provides: [provide(slot, { key: 'stripe', sides: { server: { module: '@weft-js/pay/stripe', export: 'stripeRail', options } } })],
14
- agents: { title: 'Pay rail: Stripe', text: 'Stripe Checkout, server side only: `STRIPE_SECRET_KEY`, and `STRIPE_WEBHOOK_SECRET` for `/_weft/x/pay/stripe`, the webhook URL to give Stripe.' },
15
- map: [{ want: 'Change the payment provider', call: 'replace this plugin in `plugins`: app code stays', have: 'Stripe Checkout' }],
17
+ contribute: (options) => ({
18
+ provides: [
19
+ provide(slot, {
20
+ key: 'stripe',
21
+ sides: { server: { module: '@weft-js/pay/stripe', export: 'stripeRail', options } },
22
+ }),
23
+ ],
24
+ agents: {
25
+ title: 'Pay rail: Stripe',
26
+ text: 'Stripe Checkout, server side only: `STRIPE_SECRET_KEY`, and `STRIPE_WEBHOOK_SECRET` for `/_weft/x/pay/stripe`, the webhook URL to give Stripe.',
27
+ },
28
+ map: [
29
+ {
30
+ want: 'Change the payment provider',
31
+ call: 'replace this plugin in `plugins`: app code stays',
32
+ have: 'Stripe Checkout',
33
+ },
34
+ ],
16
35
  }),
17
36
  })
18
37