@weft-js/pay 0.0.0-stage → 0.1.0
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/LICENSE +16 -0
- package/README.md +8 -2
- package/docs/pay.md +118 -0
- package/package.json +46 -4
- package/plugin.d.mts +5 -0
- package/plugin.mjs +50 -0
- package/src/checks.mjs +230 -0
- package/src/drizzle.ts +38 -0
- package/src/index.ts +181 -0
- package/src/server.ts +328 -0
- package/src/setup.mjs +99 -0
- package/src/stripe.ts +198 -0
- package/src/test.ts +169 -0
- package/stripe-plugin.d.mts +5 -0
- package/stripe-plugin.mjs +19 -0
package/src/stripe.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
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
|
+
// Stripe's signature is HMAC-SHA256 of `<t>.<raw body>`, in a header `t=…,v1=…` that can carry several `v1` while a secret
|
|
6
|
+
// rolls. Stripe has no ordering guarantee, so each event carries the object's `created` or the event's own time, and the
|
|
7
|
+
// ledger orders by that. A price is the catalog's unless `prices` maps `pro.month` to a Stripe id, on the server.
|
|
8
|
+
|
|
9
|
+
import { PayError, type CheckoutRequest, type PayRail, type PurchaseState, type RailEvent } from './index'
|
|
10
|
+
|
|
11
|
+
/** Options of `stripeRail()`. The keys default to the environment, which is where they belong. */
|
|
12
|
+
export interface StripeOptions {
|
|
13
|
+
/** Stripe Price ids by `<product>.<plan>` or `<product>`, from your own config. Default: amounts from the catalog. */
|
|
14
|
+
prices?: Record<string, string>
|
|
15
|
+
/** The API's origin: point it at a local mock. */
|
|
16
|
+
api?: string
|
|
17
|
+
/** Pinned, so an account's default version can't reshape these calls. */
|
|
18
|
+
apiVersion?: string
|
|
19
|
+
/** Seconds a signed delivery stays acceptable. Default 300, Stripe's own. */
|
|
20
|
+
tolerance?: number
|
|
21
|
+
/** ISO countries a `shipping` product delivers to. Default US and CA. */
|
|
22
|
+
shippingCountries?: string[]
|
|
23
|
+
/** Default `process.env.STRIPE_SECRET_KEY`. */
|
|
24
|
+
secretKey?: string
|
|
25
|
+
/** Default `process.env.STRIPE_WEBHOOK_SECRET`. */
|
|
26
|
+
webhookSecret?: string
|
|
27
|
+
/** Default the global one. */
|
|
28
|
+
fetch?: typeof fetch
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
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',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const API = 'https://api.stripe.com'
|
|
36
|
+
const encoder = new TextEncoder()
|
|
37
|
+
|
|
38
|
+
const fromHex = (s: string) => (/^[0-9a-f]{64}$/.test(s) ? Uint8Array.from(s.match(/../g)!, b => parseInt(b, 16)) : undefined)
|
|
39
|
+
|
|
40
|
+
/** The runtime's own constant-time compare, in native code. A wasm build was measured against it: see research 38. */
|
|
41
|
+
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'])
|
|
43
|
+
const message = encoder.encode(text)
|
|
44
|
+
let ok = false
|
|
45
|
+
for (const s of signatures) {
|
|
46
|
+
const bytes = fromHex(s)
|
|
47
|
+
if (bytes && await crypto.subtle.verify('HMAC', key, bytes, message)) ok = true
|
|
48
|
+
}
|
|
49
|
+
return ok
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Stripe's form encoding: `a[b][0]=c`. */
|
|
53
|
+
function form(body: Record<string, unknown>) {
|
|
54
|
+
const out = new URLSearchParams()
|
|
55
|
+
const put = (key: string, v: unknown) => {
|
|
56
|
+
if (v === undefined || v === null) return
|
|
57
|
+
if (Array.isArray(v)) v.forEach((x, i) => put(`${key}[${i}]`, x))
|
|
58
|
+
else if (typeof v === 'object') for (const [k, x] of Object.entries(v)) put(`${key}[${k}]`, x)
|
|
59
|
+
else out.append(key, `${v as string | number | boolean}`)
|
|
60
|
+
}
|
|
61
|
+
for (const [k, v] of Object.entries(body)) put(k, v)
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const seconds = (s?: number | null) => (s ? s * 1000 : undefined)
|
|
66
|
+
/** The fields of a Stripe object that an event can carry, whichever kind of object it is. */
|
|
67
|
+
interface StripeObject {
|
|
68
|
+
id: string
|
|
69
|
+
status: string
|
|
70
|
+
customer?: unknown
|
|
71
|
+
metadata?: Record<string, string> | null
|
|
72
|
+
mode?: string
|
|
73
|
+
payment_intent?: unknown
|
|
74
|
+
payment_status?: string
|
|
75
|
+
refunded?: boolean
|
|
76
|
+
current_period_end?: number | null
|
|
77
|
+
items?: { data?: { current_period_end?: number | null }[] } | null
|
|
78
|
+
}
|
|
79
|
+
interface StripeEvent { id?: string; type: string; created: number; data?: { object?: StripeObject } }
|
|
80
|
+
|
|
81
|
+
/** 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)
|
|
83
|
+
const identity = (object: StripeObject) => {
|
|
84
|
+
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 }) }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 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)
|
|
90
|
+
|
|
91
|
+
/** One Stripe event as the ledger's, or null for one that says nothing about access. */
|
|
92
|
+
function normalise(event: StripeEvent): RailEvent | null {
|
|
93
|
+
const object = event.data?.object
|
|
94
|
+
if (!event.id || !object) return null
|
|
95
|
+
const base = { id: event.id, at: event.created * 1000 }
|
|
96
|
+
const { type } = event
|
|
97
|
+
if (type.startsWith('customer.subscription.')) {
|
|
98
|
+
const state = STATES[object.status]
|
|
99
|
+
if (!state) return null
|
|
100
|
+
const customer = id(object.customer)
|
|
101
|
+
const expires = periodEnd(object)
|
|
102
|
+
return { ...base, purchase: object.id, state, ...identity(object), ...(customer && { customer }), ...(expires && { expires }) }
|
|
103
|
+
}
|
|
104
|
+
if (type === 'checkout.session.completed' || type === 'checkout.session.async_payment_succeeded' || type === 'checkout.session.async_payment_failed') {
|
|
105
|
+
if (object.mode !== 'payment') return null // `customer.subscription.*` says what a subscription is doing, trial included
|
|
106
|
+
const purchase = id(object.payment_intent)
|
|
107
|
+
if (!purchase) return null
|
|
108
|
+
const customer = id(object.customer)
|
|
109
|
+
const state: PurchaseState = type.endsWith('failed') ? 'expired' : object.payment_status === 'unpaid' ? 'pending' : 'active'
|
|
110
|
+
return { ...base, purchase, state, ...identity(object), ...(customer && { customer }) }
|
|
111
|
+
}
|
|
112
|
+
if (type === 'charge.refunded') {
|
|
113
|
+
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
|
|
115
|
+
}
|
|
116
|
+
if (type === 'charge.dispute.created' || type === 'charge.dispute.closed') {
|
|
117
|
+
const purchase = id(object.payment_intent)
|
|
118
|
+
if (!purchase) return null
|
|
119
|
+
if (type.endsWith('created')) return { ...base, purchase, state: 'revoked' }
|
|
120
|
+
return object.status === 'won' ? { ...base, purchase, state: 'active' } : null
|
|
121
|
+
}
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
|
|
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))
|
|
129
|
+
const secret = () => {
|
|
130
|
+
const key = options.secretKey ?? process.env.STRIPE_SECRET_KEY
|
|
131
|
+
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
|
|
133
|
+
return key
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function call(path: string, body: Record<string, unknown>, idempotencyKey?: string) {
|
|
137
|
+
const res = await fetcher(`${api}${path}`, {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: { authorization: `Bearer ${secret()}`, 'content-type': 'application/x-www-form-urlencoded', 'stripe-version': apiVersion, ...(idempotencyKey && { 'idempotency-key': idempotencyKey }) },
|
|
140
|
+
body: form(body),
|
|
141
|
+
})
|
|
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)
|
|
144
|
+
return data
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function lineItem({ line }: CheckoutRequest) {
|
|
148
|
+
const known = prices[line.plan ? `${line.product}.${line.plan}` : line.product]
|
|
149
|
+
if (known) return { price: known, quantity: line.quantity }
|
|
150
|
+
return {
|
|
151
|
+
quantity: line.quantity,
|
|
152
|
+
price_data: { currency: line.currency, unit_amount: line.amount, product_data: { name: line.name }, ...(line.interval && { recurring: { interval: line.interval } }) },
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
id: 'stripe',
|
|
158
|
+
async checkout(request) {
|
|
159
|
+
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)
|
|
175
|
+
return { url: session.url }
|
|
176
|
+
},
|
|
177
|
+
async portal({ customer, returnUrl }) {
|
|
178
|
+
const session = await call('/v1/billing_portal/sessions', { customer, return_url: returnUrl })
|
|
179
|
+
return { url: session!.url! }
|
|
180
|
+
},
|
|
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
|
+
},
|
|
193
|
+
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)
|
|
195
|
+
await call('/v1/refunds', { payment_intent: purchase }, `refund:${purchase}`)
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
}
|
package/src/test.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
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
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
7
|
+
import { clock, net } from '@weft-js/core/test'
|
|
8
|
+
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'
|
|
10
|
+
|
|
11
|
+
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
|
|
14
|
+
|
|
15
|
+
/** What `simulate` can make the rail do next. */
|
|
16
|
+
export type PayScenario = 'renewal' | keyof typeof TERMS
|
|
17
|
+
|
|
18
|
+
interface Bought { id: string; user: string; product: string; plan?: string; interval?: Interval; state: PurchaseState; expires?: number; customer: string }
|
|
19
|
+
|
|
20
|
+
/** A rail in memory and the handles a test drives it by, as `fakePay` returns them. */
|
|
21
|
+
export interface FakePay {
|
|
22
|
+
readonly pay: Pay
|
|
23
|
+
readonly rail: PayRail
|
|
24
|
+
/** Every checkout opened, newest last. */
|
|
25
|
+
readonly sessions: CheckoutRequest[]
|
|
26
|
+
/** Every delivery and how the app answered. */
|
|
27
|
+
readonly deliveries: { event: RailEvent; status: number }[]
|
|
28
|
+
/** The buyer finishes the hosted page: the newest open session, or the one for `user`. */
|
|
29
|
+
complete(user?: string): Promise<string>
|
|
30
|
+
/** The buyer closes the page. Nothing happens, as nothing does on a real rail. */
|
|
31
|
+
abandon(user?: string): void
|
|
32
|
+
/** A whole purchase, with no checkout screen in the way. */
|
|
33
|
+
buy(user: string, product: string, plan?: string): Promise<string>
|
|
34
|
+
/**
|
|
35
|
+
* What the rail does next: `renewal` moves a clock to the period's end first. `purchase` defaults to the newest.
|
|
36
|
+
* `renewal-failed`, `grace-expired`, `recovered`, `cancel`, `pause`, `resume`, `refund`, `revoke`.
|
|
37
|
+
*/
|
|
38
|
+
simulate(what: PayScenario, purchase?: string): Promise<void>
|
|
39
|
+
/** The last event again: a redelivery. */
|
|
40
|
+
redeliver(): Promise<number>
|
|
41
|
+
/** An event by hand, in any order. `{ forged: true }` signs it with the wrong key. */
|
|
42
|
+
deliver(event: RailEvent, options?: { forged?: boolean }): Promise<number>
|
|
43
|
+
entitlements(user: string): Promise<string[]>
|
|
44
|
+
purchases(user: string): Promise<Purchase[]>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const sign = (secret: string, t: number, body: string) => createHmac('sha256', secret).update(`${t}.${body}`).digest('hex')
|
|
48
|
+
const SECRET = 'fake-pay-signing-key'
|
|
49
|
+
|
|
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
|
+
|
|
57
|
+
const rail: PayRail = {
|
|
58
|
+
id: 'fake',
|
|
59
|
+
checkout(request) {
|
|
60
|
+
sessions.push(request)
|
|
61
|
+
return Promise.resolve({ url: `https://fake-pay.test/checkout/${next('cs')}` })
|
|
62
|
+
},
|
|
63
|
+
portal: ({ customer }) => Promise.resolve({ url: `https://fake-pay.test/portal/${customer}` }),
|
|
64
|
+
verify({ body, headers }) {
|
|
65
|
+
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')
|
|
68
|
+
return JSON.parse(body)
|
|
69
|
+
},
|
|
70
|
+
async refund({ purchase }) {
|
|
71
|
+
await simulate('refund', purchase)
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const pay: Pay = 'webhook' in app ? app : definePay({ store: memoryPayStore(), urls: { success: '/', cancel: '/' }, ...app })
|
|
76
|
+
pay.use(rail)
|
|
77
|
+
net.extension(pay)
|
|
78
|
+
|
|
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
|
|
86
|
+
}
|
|
87
|
+
const deliver: FakePay['deliver'] = (event, options) => { if (!options?.forged) last = event; return post(event, options?.forged ? 'not-the-key' : SECRET) }
|
|
88
|
+
|
|
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 }) })
|
|
90
|
+
|
|
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
|
+
}
|
|
99
|
+
|
|
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]!
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function simulate(what: PayScenario, id?: string) {
|
|
107
|
+
const b = id ? purchases.get(id) : [...purchases.values()].at(-1)
|
|
108
|
+
if (!b) throw new Error(`fakePay: no purchase${id ? ` \`${id}\`` : ' yet'} to ${what}`)
|
|
109
|
+
if (what === 'renewal' || what === 'renewal-failed') {
|
|
110
|
+
if (!b.interval) throw new Error(`fakePay: \`${b.id}\` is one payment: only a subscription renews`)
|
|
111
|
+
if (clock.installed) await clock.advance(Math.max(0, b.expires! - Date.now()))
|
|
112
|
+
}
|
|
113
|
+
if (what === 'renewal') Object.assign(b, { state: 'active', expires: b.expires! + PERIOD[b.interval!] })
|
|
114
|
+
else b.state = TERMS[what]
|
|
115
|
+
await deliver(event(b))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
pay, rail, sessions, deliveries, deliver, simulate,
|
|
120
|
+
complete: user => open(take(user)),
|
|
121
|
+
abandon: user => void take(user),
|
|
122
|
+
async buy(user, product, plan) {
|
|
123
|
+
await pay.checkout({ user: { id: user }, request: { url: 'http://localhost/' } }, { product, ...(plan && { plan }) })
|
|
124
|
+
return open(take(user))
|
|
125
|
+
},
|
|
126
|
+
redeliver() {
|
|
127
|
+
if (!last) throw new Error('fakePay: nothing delivered yet')
|
|
128
|
+
return post(last, SECRET)
|
|
129
|
+
},
|
|
130
|
+
entitlements: user => pay.entitlements(user).list(),
|
|
131
|
+
purchases: user => pay.purchases(user),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** One requirement of a suite, and how the rail did. */
|
|
136
|
+
export interface Check { name: string; ok: boolean; detail: string }
|
|
137
|
+
|
|
138
|
+
/** What `payRailSuite` needs: the rail, and a way to make a delivery it would accept. */
|
|
139
|
+
export interface PayRailSuiteOptions {
|
|
140
|
+
rail: PayRail
|
|
141
|
+
/** A delivery the rail would accept, `age` ms old, for a purchase event. Omit `age` for one just sent. */
|
|
142
|
+
delivery: (age?: number) => { body: string; headers: Headers }
|
|
143
|
+
/** How old a delivery may be before a rail refuses it as a replay. Without it the replay check isn't run. */
|
|
144
|
+
tolerance?: number
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 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[]> {
|
|
149
|
+
const out: Check[] = []
|
|
150
|
+
const check = (name: string, ok: boolean, detail = '') => void out.push({ name, ok, detail })
|
|
151
|
+
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' }
|
|
153
|
+
}
|
|
154
|
+
const good = delivery()
|
|
155
|
+
const events = await rail.verify(good)
|
|
156
|
+
const e = events[0]
|
|
157
|
+
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 }))
|
|
161
|
+
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' })
|
|
165
|
+
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)
|
|
168
|
+
return out
|
|
169
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { PluginInstance } from '@weft-js/core/plugin'
|
|
2
|
+
|
|
3
|
+
declare const plugin: (options?: { prices?: Record<string, string>; api?: string; apiVersion?: string; tolerance?: number }) => PluginInstance<unknown>
|
|
4
|
+
/** `stripe()` for `weft.config.ts`: Stripe as a `pay.rail` provider. Keys stay in the environment. */
|
|
5
|
+
export default plugin
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// stripe() for weft.config.ts: this package as the `pay.rail` provider for the web. Options are in the generated slot
|
|
2
|
+
// module, so they name env vars and never hold a key (PAY003). Another provider is this one line swapped for another.
|
|
3
|
+
import { definePlugin, defineSlot, provide } from '@weft-js/core/plugin'
|
|
4
|
+
|
|
5
|
+
const slot = defineSlot({ id: 'pay.rail', version: 1, cardinality: 'keyed', sides: ['server', 'client', 'native'] })
|
|
6
|
+
|
|
7
|
+
export const stripe = definePlugin({
|
|
8
|
+
name: '@weft-js/pay/stripe',
|
|
9
|
+
version: '0.0.0',
|
|
10
|
+
weft: '*',
|
|
11
|
+
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' }],
|
|
16
|
+
}),
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
export default stripe
|