@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/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 {
|
|
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',
|
|
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
|
|
|
147
|
-
|
|
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: {
|
|
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 = {
|
|
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: (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
|
-
|
|
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_'))
|
|
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
|
}
|