@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/square.ts ADDED
@@ -0,0 +1,231 @@
1
+ // Square's signature is base64 HMAC-SHA256 of `<notification URL><raw body>` with the subscription's signature key, in
2
+ // `x-square-hmacsha256-signature`. It carries no time, so a replay is stopped by the ledger's event ids. A payment's event
3
+ // doesn't carry the order's metadata, so after the signature checks out the rail reads the order once to learn who and what.
4
+ // Square sells one-time payments here: subscriptions and a customer portal are Stripe's (PAY022).
5
+
6
+ import {
7
+ PayError,
8
+ type CheckoutLine,
9
+ type CheckoutRequest,
10
+ type PayRail,
11
+ type PurchaseState,
12
+ type RailEvent,
13
+ } from './index'
14
+
15
+ /** Options of `squareRail()`. Secrets default to the environment, which is where they belong. */
16
+ export interface SquareOptions {
17
+ /** `sandbox` talks to squareupsandbox.com. Default `process.env.SQUARE_ENV`, else production. */
18
+ environment?: 'sandbox' | 'production'
19
+ /** The API's origin: point it at a local mock. Default from `environment`. */
20
+ api?: string
21
+ /** Pinned, so an account's default version can't reshape these calls. */
22
+ apiVersion?: string
23
+ /** Default `process.env.SQUARE_ACCESS_TOKEN`. */
24
+ accessToken?: string
25
+ /** Default `process.env.SQUARE_LOCATION_ID`: the shop the payments land in. */
26
+ locationId?: string
27
+ /** Default `process.env.SQUARE_WEBHOOK_SIGNATURE_KEY`. */
28
+ signatureKey?: string
29
+ /** The exact URL given to Square for the webhook; it's part of what's signed. Default `process.env.SQUARE_WEBHOOK_URL`. */
30
+ notificationUrl?: string
31
+ /** Default the global one. */
32
+ fetch?: typeof fetch
33
+ }
34
+
35
+ interface Money {
36
+ amount?: number
37
+ currency?: string
38
+ }
39
+ interface SquarePayment {
40
+ id: string
41
+ order_id?: string
42
+ status?: string
43
+ updated_at?: string
44
+ created_at?: string
45
+ customer_id?: string
46
+ total_money?: Money
47
+ amount_money?: Money
48
+ refunded_money?: Money
49
+ }
50
+ interface SquareEvent {
51
+ event_id?: string
52
+ type?: string
53
+ created_at?: string
54
+ data?: { object?: { payment?: SquarePayment } }
55
+ }
56
+
57
+ const PAYMENT: Record<string, PurchaseState> = {
58
+ APPROVED: 'pending',
59
+ PENDING: 'pending',
60
+ COMPLETED: 'active',
61
+ CANCELED: 'expired',
62
+ FAILED: 'expired',
63
+ }
64
+
65
+ const encoder = new TextEncoder()
66
+ const env = (name: string) => (typeof process === 'undefined' ? undefined : process.env[name])
67
+
68
+ async function signatureOk(key: string, text: string, given: string) {
69
+ const k = await crypto.subtle.importKey(
70
+ 'raw',
71
+ encoder.encode(key),
72
+ { name: 'HMAC', hash: 'SHA-256' },
73
+ false,
74
+ ['verify'],
75
+ )
76
+ let bytes: Uint8Array<ArrayBuffer>
77
+ try {
78
+ bytes = Uint8Array.from(atob(given), (c) => c.charCodeAt(0))
79
+ } catch {
80
+ return false
81
+ }
82
+ return crypto.subtle.verify('HMAC', k, bytes, encoder.encode(text))
83
+ }
84
+
85
+ /** One Square payment as a ledger state: a whole refund is final, a part refund keeps what was bought. */
86
+ function stateOf(p: SquarePayment): PurchaseState | null {
87
+ const paid = p.total_money?.amount ?? p.amount_money?.amount ?? 0
88
+ if (p.refunded_money?.amount && paid && p.refunded_money.amount >= paid) return 'refunded'
89
+ return PAYMENT[p.status ?? ''] ?? null
90
+ }
91
+
92
+ function squareCall(options: SquareOptions, fetcher: typeof fetch) {
93
+ const sandbox = (options.environment ?? env('SQUARE_ENV')) === 'sandbox'
94
+ const api =
95
+ options.api ??
96
+ (sandbox ? 'https://connect.squareupsandbox.com' : 'https://connect.squareup.com')
97
+ const version = options.apiVersion ?? '2025-10-16'
98
+ return async function call(path: string, body?: unknown) {
99
+ const token = options.accessToken ?? env('SQUARE_ACCESS_TOKEN')
100
+ if (!token) throw new PayError('PAY003', '`SQUARE_ACCESS_TOKEN` is not set on the server', 500)
101
+ const res = await fetcher(`${api}${path}`, {
102
+ method: body === undefined ? 'GET' : 'POST',
103
+ headers: {
104
+ authorization: `Bearer ${token}`,
105
+ 'content-type': 'application/json',
106
+ 'square-version': version,
107
+ },
108
+ ...(body !== undefined && { body: JSON.stringify(body) }),
109
+ })
110
+ const data = (await res.json().catch(() => null)) as Record<string, unknown> | null
111
+ if (!res.ok) {
112
+ const errors = (data?.errors as { detail?: string }[] | undefined) ?? []
113
+ throw new PayError(
114
+ 'PAY030',
115
+ `Square refused ${path}: ${errors[0]?.detail ?? res.status}`,
116
+ res.status === 429 ? 503 : 502,
117
+ )
118
+ }
119
+ return data ?? {}
120
+ }
121
+ }
122
+
123
+ const upper = (c: string) => c.toUpperCase()
124
+ const lineItem = (l: CheckoutLine) => ({
125
+ name: l.name,
126
+ quantity: String(l.quantity),
127
+ base_price_money: { amount: l.amount, currency: upper(l.currency) },
128
+ })
129
+
130
+ type Call = ReturnType<typeof squareCall>
131
+
132
+ /** One payment link for a checkout's lines, with the metadata its order carries back to `verify`. */
133
+ async function paymentLink(call: Call, options: SquareOptions, request: CheckoutRequest) {
134
+ const { line, user } = request
135
+ if (line.mode !== 'payment')
136
+ throw new PayError(
137
+ 'PAY022',
138
+ 'The Square rail takes one-time payments: sell subscriptions with Stripe',
139
+ 501,
140
+ )
141
+ const location = options.locationId ?? env('SQUARE_LOCATION_ID')
142
+ if (!location) throw new PayError('PAY003', '`SQUARE_LOCATION_ID` is not set on the server', 500)
143
+ const data = (await call('/v2/online-checkout/payment-links', {
144
+ idempotency_key: request.idempotencyKey.slice(0, 45),
145
+ order: {
146
+ location_id: location,
147
+ ...(line.plan && line.product === 'order' && { reference_id: line.plan.slice(0, 40) }),
148
+ line_items: (request.lines ?? [line]).map(lineItem),
149
+ metadata: {
150
+ weft_user: user.slice(0, 255),
151
+ weft_product: line.product,
152
+ ...(line.plan && { weft_plan: line.plan }),
153
+ },
154
+ },
155
+ checkout_options: { redirect_url: request.successUrl },
156
+ ...(request.email && { pre_populated_data: { buyer_email: request.email } }),
157
+ })) as { payment_link?: { url?: string } }
158
+ const url = data.payment_link?.url
159
+ if (typeof url !== 'string')
160
+ throw new PayError('PAY030', 'Square answered a link with no url', 502)
161
+ return { url }
162
+ }
163
+
164
+ /** The Square `pay.rail` provider: hosted payment links and webhooks verified here. One-time payments only. */
165
+ export function squareRail(options: SquareOptions = {}): PayRail {
166
+ const fetcher = options.fetch ?? ((...args: Parameters<typeof fetch>) => fetch(...args))
167
+ const call = squareCall(options, fetcher)
168
+
169
+ async function identity(orderId: string) {
170
+ const { order } = (await call(`/v2/orders/${encodeURIComponent(orderId)}`)) as {
171
+ order?: { metadata?: Record<string, string> }
172
+ }
173
+ const m = order?.metadata ?? {}
174
+ return {
175
+ ...(m.weft_user && { user: m.weft_user }),
176
+ ...(m.weft_product && { product: m.weft_product }),
177
+ ...(m.weft_plan && { plan: m.weft_plan }),
178
+ }
179
+ }
180
+
181
+ return {
182
+ id: 'square',
183
+ modes: ['payment'],
184
+ checkout: (request) => paymentLink(call, options, request),
185
+ portal() {
186
+ return Promise.reject(
187
+ new PayError('PAY022', 'Square has no customer portal: a buyer manages nothing here', 501),
188
+ )
189
+ },
190
+ async verify({ body, headers }) {
191
+ const key = options.signatureKey ?? env('SQUARE_WEBHOOK_SIGNATURE_KEY')
192
+ const url = options.notificationUrl ?? env('SQUARE_WEBHOOK_URL')
193
+ if (!key || !url)
194
+ throw new PayError(
195
+ 'PAY011',
196
+ '`SQUARE_WEBHOOK_SIGNATURE_KEY` and `SQUARE_WEBHOOK_URL` must both be set: refusing every webhook rather than trusting one',
197
+ )
198
+ const given = headers.get('x-square-hmacsha256-signature') ?? ''
199
+ if (!given || !(await signatureOk(key, url + body, given)))
200
+ throw new PayError('PAY011', 'the signature does not match this body and URL')
201
+ const event = JSON.parse(body) as SquareEvent
202
+ const p = event.data?.object?.payment
203
+ if (!event.event_id || !p?.order_id || !event.type?.startsWith('payment.')) return []
204
+ const state = stateOf(p)
205
+ if (!state) return []
206
+ const at = Date.parse(p.updated_at ?? event.created_at ?? '') || Date.now()
207
+ const out: RailEvent = {
208
+ id: event.event_id,
209
+ at,
210
+ purchase: p.order_id,
211
+ state,
212
+ ...(await identity(p.order_id)),
213
+ ...(p.customer_id && { customer: p.customer_id }),
214
+ }
215
+ return [out]
216
+ },
217
+ async refund({ purchase }) {
218
+ const { order } = (await call(`/v2/orders/${encodeURIComponent(purchase)}`)) as {
219
+ order?: { tenders?: { payment_id?: string }[]; total_money?: Money }
220
+ }
221
+ const payment = order?.tenders?.[0]?.payment_id
222
+ if (!payment || !order?.total_money)
223
+ throw new PayError('PAY024', `order \`${purchase}\` has no payment to refund`, 404)
224
+ await call('/v2/refunds', {
225
+ idempotency_key: `refund-${purchase}`.slice(0, 45),
226
+ payment_id: payment,
227
+ amount_money: order.total_money,
228
+ })
229
+ },
230
+ }
231
+ }
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 CheckoutLine,
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
 
147
- function lineItem({ line }: CheckoutRequest) {
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
+
244
+ function lineItem(line: CheckoutLine) {
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,53 @@ 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: (request.lines ?? [line]).map(lineItem),
272
+ success_url: request.successUrl,
273
+ cancel_url: request.cancelUrl,
274
+ client_reference_id: user,
275
+ customer,
276
+ customer_email: customer ? undefined : request.email,
277
+ metadata,
278
+ // What the later events carry: a charge and a subscription don't inherit the session's metadata.
279
+ ...(line.mode === 'subscription'
280
+ ? { subscription_data: { metadata, trial_period_days: line.trialDays } }
281
+ : {
282
+ payment_intent_data: { metadata },
283
+ customer_creation: customer ? undefined : 'always',
284
+ ...(line.shipping && {
285
+ shipping_address_collection: {
286
+ allowed_countries: options.shippingCountries ?? ['US', 'CA'],
287
+ },
288
+ }),
289
+ }),
290
+ },
291
+ request.idempotencyKey,
292
+ )
293
+ if (typeof session?.url !== 'string')
294
+ throw new PayError('PAY030', 'Stripe answered a session with no url', 502)
175
295
  return { url: session.url }
176
296
  },
177
297
  async portal({ customer, returnUrl }) {
178
298
  const session = await call('/v1/billing_portal/sessions', { customer, return_url: returnUrl })
179
299
  return { url: session!.url! }
180
300
  },
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
- },
301
+ verify: (delivery) => verifyStripe(options, tolerance, delivery),
193
302
  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)
303
+ if (!purchase.startsWith('pi_'))
304
+ throw new PayError(
305
+ 'PAY022',
306
+ 'Only a one-time payment refunds here: a subscription is cancelled in the portal',
307
+ 400,
308
+ )
195
309
  await call('/v1/refunds', { payment_intent: purchase }, `refund:${purchase}`)
196
310
  },
197
311
  }