@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/README.md +658 -3
- package/docs/pay.md +28 -9
- package/package.json +13 -4
- package/plugin.mjs +131 -23
- package/src/checks/config.mjs +24 -0
- package/src/checks.mjs +219 -70
- package/src/drizzle.ts +33 -14
- package/src/index.ts +80 -25
- package/src/server/catalog.ts +80 -0
- package/src/server/ledger.ts +69 -0
- package/src/server/operations.ts +142 -0
- package/src/server/rails.ts +45 -0
- package/src/server.ts +74 -171
- package/src/setup.mjs +26 -9
- package/src/stripe.ts +168 -55
- package/src/test.ts +195 -63
- package/stripe-plugin.d.mts +6 -1
- package/stripe-plugin.mjs +24 -5
package/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 {
|
|
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',
|
|
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) =>
|
|
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
|
|
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(
|
|
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 {
|
|
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 =>
|
|
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 {
|
|
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) =>
|
|
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 {
|
|
129
|
+
return {
|
|
130
|
+
...base,
|
|
131
|
+
purchase: object.id,
|
|
132
|
+
state,
|
|
133
|
+
...identity(object),
|
|
134
|
+
...(customer && { customer }),
|
|
135
|
+
...(expires && { expires }),
|
|
136
|
+
}
|
|
103
137
|
}
|
|
104
|
-
if (
|
|
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')
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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))
|
|
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: {
|
|
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 {
|
|
143
|
-
|
|
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: {
|
|
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 = {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
:
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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_'))
|
|
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 {
|
|
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> = {
|
|
13
|
-
|
|
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 {
|
|
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) =>
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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)),
|
|
67
|
-
|
|
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
|
|
98
|
+
await refund(purchase)
|
|
72
99
|
},
|
|
73
100
|
}
|
|
74
101
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
net.extension(pay)
|
|
102
|
+
return rail
|
|
103
|
+
}
|
|
78
104
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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)
|
|
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')
|
|
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,
|
|
120
|
-
|
|
121
|
-
|
|
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(
|
|
124
|
-
|
|
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 {
|
|
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({
|
|
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 {
|
|
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(
|
|
159
|
-
|
|
160
|
-
|
|
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)
|
|
163
|
-
|
|
164
|
-
const
|
|
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)
|