@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/README.md +654 -3
- package/docs/pay.md +55 -24
- package/package.json +35 -22
- package/plugin.mjs +131 -23
- package/square-plugin.d.mts +9 -0
- package/square-plugin.mjs +47 -0
- package/src/checks/config.mjs +24 -0
- package/src/checks.mjs +219 -70
- package/src/drizzle.ts +33 -14
- package/src/index.ts +89 -25
- package/src/server/catalog.ts +80 -0
- package/src/server/ledger.ts +84 -0
- package/src/server/operations.ts +215 -0
- package/src/server/rails.ts +45 -0
- package/src/server.ts +113 -172
- package/src/setup.mjs +26 -9
- package/src/square.ts +231 -0
- package/src/stripe.ts +170 -56
- package/src/test.ts +209 -65
- package/stripe-plugin.d.mts +6 -1
- package/stripe-plugin.mjs +24 -5
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,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({
|
|
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 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
|
-
|
|
167
|
-
|
|
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
|
}
|
package/stripe-plugin.d.mts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { PluginInstance } from '@weft-js/core/plugin'
|
|
2
2
|
|
|
3
|
-
declare const plugin: (options?: {
|
|
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({
|
|
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: [
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|