@meith/plugin-dues 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.md +165 -0
- package/README.md +182 -0
- package/package.json +31 -0
- package/src/codes.ts +47 -0
- package/src/config.ts +153 -0
- package/src/definition.tsx +292 -0
- package/src/demo.ts +526 -0
- package/src/entitlement.ts +371 -0
- package/src/handlers-admin.ts +405 -0
- package/src/handlers.ts +397 -0
- package/src/index.ts +18 -0
- package/src/money.ts +42 -0
- package/src/period.ts +76 -0
- package/src/plans.ts +183 -0
- package/src/schema.ts +153 -0
- package/src/store.ts +966 -0
- package/src/stripe/client.ts +303 -0
- package/src/stripe/events.ts +189 -0
- package/src/stripe/webhook.ts +64 -0
- package/src/tasks.ts +156 -0
- package/src/ui/admin.tsx +925 -0
- package/src/ui/pages.tsx +557 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
|
|
2
|
+
export interface StripeClientOptions {
|
|
3
|
+
readonly secretKey: string
|
|
4
|
+
readonly apiVersion: string
|
|
5
|
+
readonly apiBase?: string
|
|
6
|
+
readonly fetchImpl?: typeof fetch
|
|
7
|
+
readonly timeoutMs?: number
|
|
8
|
+
readonly maxAttempts?: number
|
|
9
|
+
readonly backoff?: (attempt: number) => Promise<void>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class StripeError extends Error {
|
|
13
|
+
constructor(
|
|
14
|
+
message: string,
|
|
15
|
+
readonly status: number,
|
|
16
|
+
readonly code: string | null,
|
|
17
|
+
readonly type: string | null,
|
|
18
|
+
) {
|
|
19
|
+
super(message)
|
|
20
|
+
this.name = 'StripeError'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get transient(): boolean {
|
|
24
|
+
return this.status === 429 || this.status >= 500
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function encodeStripeForm(body: Record<string, unknown>): string {
|
|
29
|
+
const pairs: string[] = []
|
|
30
|
+
|
|
31
|
+
const walk = (prefix: string, value: unknown): void => {
|
|
32
|
+
if (value === undefined || value === null) return
|
|
33
|
+
if (Array.isArray(value)) {
|
|
34
|
+
value.forEach((entry, index) => walk(`${prefix}[${index}]`, entry))
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
if (typeof value === 'object') {
|
|
38
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
39
|
+
walk(prefix === '' ? key : `${prefix}[${key}]`, entry)
|
|
40
|
+
}
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
pairs.push(`${encodeURIComponent(prefix)}=${encodeURIComponent(String(value))}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
walk('', body)
|
|
47
|
+
return pairs.join('&')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CheckoutSessionState {
|
|
51
|
+
readonly id: string
|
|
52
|
+
readonly url: string | null
|
|
53
|
+
readonly status: 'open' | 'complete' | 'expired'
|
|
54
|
+
readonly paymentStatus: 'paid' | 'unpaid' | 'no_payment_required'
|
|
55
|
+
readonly amountTotal: number | null
|
|
56
|
+
readonly currency: string | null
|
|
57
|
+
readonly subscriptionId: string | null
|
|
58
|
+
readonly paymentIntentId: string | null
|
|
59
|
+
readonly customerId: string | null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SubscriptionState {
|
|
63
|
+
readonly id: string
|
|
64
|
+
readonly status: string
|
|
65
|
+
readonly cancelAtPeriodEnd: boolean
|
|
66
|
+
readonly currentPeriodEnd: Date | null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function str(value: unknown): string | null {
|
|
70
|
+
if (typeof value === 'string') return value
|
|
71
|
+
if (typeof value === 'object' && value !== null && typeof (value as { id?: unknown }).id === 'string') {
|
|
72
|
+
return (value as { id: string }).id
|
|
73
|
+
}
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function sessionFromApi(body: Record<string, unknown>): CheckoutSessionState {
|
|
78
|
+
return {
|
|
79
|
+
id: String(body.id),
|
|
80
|
+
url: typeof body.url === 'string' ? body.url : null,
|
|
81
|
+
status: (body.status ?? 'open') as CheckoutSessionState['status'],
|
|
82
|
+
paymentStatus: (body.payment_status ?? 'unpaid') as CheckoutSessionState['paymentStatus'],
|
|
83
|
+
amountTotal: typeof body.amount_total === 'number' ? body.amount_total : null,
|
|
84
|
+
currency: typeof body.currency === 'string' ? body.currency : null,
|
|
85
|
+
subscriptionId: str(body.subscription),
|
|
86
|
+
paymentIntentId: str(body.payment_intent),
|
|
87
|
+
customerId: str(body.customer),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function subscriptionFromApi(body: Record<string, unknown>): SubscriptionState {
|
|
92
|
+
let periodEnd: number | null = typeof body.current_period_end === 'number' ? body.current_period_end : null
|
|
93
|
+
|
|
94
|
+
if (periodEnd === null) {
|
|
95
|
+
const items = (body.items as { data?: unknown } | undefined)?.data
|
|
96
|
+
if (Array.isArray(items)) {
|
|
97
|
+
for (const item of items) {
|
|
98
|
+
const end = (item as { current_period_end?: unknown }).current_period_end
|
|
99
|
+
if (typeof end === 'number' && (periodEnd === null || end > periodEnd)) periodEnd = end
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
id: String(body.id),
|
|
106
|
+
status: String(body.status ?? 'unknown'),
|
|
107
|
+
cancelAtPeriodEnd: body.cancel_at_period_end === true,
|
|
108
|
+
currentPeriodEnd: periodEnd === null ? null : new Date(periodEnd * 1000),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface StripeClient {
|
|
113
|
+
createCustomer(input: {
|
|
114
|
+
readonly metadata: Readonly<Record<string, string>>
|
|
115
|
+
}): Promise<{ readonly id: string }>
|
|
116
|
+
|
|
117
|
+
createCoupon(input: {
|
|
118
|
+
readonly percentOff: number
|
|
119
|
+
readonly name: string
|
|
120
|
+
readonly reference: string
|
|
121
|
+
}): Promise<{ readonly id: string }>
|
|
122
|
+
|
|
123
|
+
createProduct(input: {
|
|
124
|
+
readonly name: string
|
|
125
|
+
readonly reference: string
|
|
126
|
+
}): Promise<{ readonly id: string }>
|
|
127
|
+
|
|
128
|
+
createPrice(input: {
|
|
129
|
+
readonly productId: string
|
|
130
|
+
readonly unitAmount: number
|
|
131
|
+
readonly currency: string
|
|
132
|
+
readonly interval: 'month' | 'year'
|
|
133
|
+
readonly reference: string
|
|
134
|
+
}): Promise<{ readonly id: string }>
|
|
135
|
+
|
|
136
|
+
createCheckoutSession(input: Record<string, unknown>): Promise<CheckoutSessionState>
|
|
137
|
+
getCheckoutSession(id: string): Promise<CheckoutSessionState>
|
|
138
|
+
|
|
139
|
+
getSubscription(id: string): Promise<SubscriptionState>
|
|
140
|
+
setCancelAtPeriodEnd(id: string, cancel: boolean): Promise<SubscriptionState>
|
|
141
|
+
|
|
142
|
+
createBillingPortalSession(input: {
|
|
143
|
+
readonly customer: string
|
|
144
|
+
readonly returnUrl: string
|
|
145
|
+
}): Promise<{ readonly url: string }>
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const DEFAULT_BASE = 'https://api.stripe.com'
|
|
149
|
+
const DEFAULT_TIMEOUT_MS = 10_000
|
|
150
|
+
const DEFAULT_ATTEMPTS = 3
|
|
151
|
+
|
|
152
|
+
export function createStripeClient(options: StripeClientOptions): StripeClient {
|
|
153
|
+
const base = (options.apiBase ?? DEFAULT_BASE).replace(/\/$/, '')
|
|
154
|
+
const doFetch = options.fetchImpl ?? fetch
|
|
155
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
156
|
+
const maxAttempts = options.maxAttempts ?? DEFAULT_ATTEMPTS
|
|
157
|
+
const backoff =
|
|
158
|
+
options.backoff ??
|
|
159
|
+
((attempt: number) =>
|
|
160
|
+
new Promise<void>((resolve) =>
|
|
161
|
+
setTimeout(resolve, 250 * 2 ** attempt + Math.floor(Math.random() * 100)),
|
|
162
|
+
))
|
|
163
|
+
|
|
164
|
+
async function request(
|
|
165
|
+
method: 'GET' | 'POST',
|
|
166
|
+
path: string,
|
|
167
|
+
body?: Record<string, unknown>,
|
|
168
|
+
idempotencyKey?: string,
|
|
169
|
+
): Promise<Record<string, unknown>> {
|
|
170
|
+
let lastError: unknown = null
|
|
171
|
+
|
|
172
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
173
|
+
if (attempt > 0) await backoff(attempt)
|
|
174
|
+
|
|
175
|
+
let response: Response
|
|
176
|
+
try {
|
|
177
|
+
response = await doFetch(`${base}${path}`, {
|
|
178
|
+
method,
|
|
179
|
+
headers: {
|
|
180
|
+
authorization: `Bearer ${options.secretKey}`,
|
|
181
|
+
'stripe-version': options.apiVersion,
|
|
182
|
+
...(body === undefined
|
|
183
|
+
? {}
|
|
184
|
+
: { 'content-type': 'application/x-www-form-urlencoded' }),
|
|
185
|
+
...(idempotencyKey === undefined ? {} : { 'idempotency-key': idempotencyKey }),
|
|
186
|
+
},
|
|
187
|
+
...(body === undefined ? {} : { body: encodeStripeForm(body) }),
|
|
188
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
189
|
+
})
|
|
190
|
+
} catch (error) {
|
|
191
|
+
lastError = error
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const text = await response.text()
|
|
196
|
+
let parsed: Record<string, unknown>
|
|
197
|
+
try {
|
|
198
|
+
parsed = JSON.parse(text) as Record<string, unknown>
|
|
199
|
+
} catch {
|
|
200
|
+
parsed = {}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (response.ok) return parsed
|
|
204
|
+
|
|
205
|
+
const detail = (parsed.error ?? {}) as {
|
|
206
|
+
message?: string
|
|
207
|
+
code?: string
|
|
208
|
+
type?: string
|
|
209
|
+
}
|
|
210
|
+
const error = new StripeError(
|
|
211
|
+
detail.message ?? `Stripe answered ${response.status}`,
|
|
212
|
+
response.status,
|
|
213
|
+
detail.code ?? null,
|
|
214
|
+
detail.type ?? null,
|
|
215
|
+
)
|
|
216
|
+
if (!error.transient) throw error
|
|
217
|
+
lastError = error
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
throw lastError instanceof Error
|
|
221
|
+
? lastError
|
|
222
|
+
: new StripeError('Stripe was unreachable', 0, null, null)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
async createCustomer(input) {
|
|
227
|
+
const body = await request('POST', '/v1/customers', { metadata: input.metadata })
|
|
228
|
+
return { id: String(body.id) }
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
async createCoupon(input) {
|
|
232
|
+
const body = await request(
|
|
233
|
+
'POST',
|
|
234
|
+
'/v1/coupons',
|
|
235
|
+
{ percent_off: input.percentOff, duration: 'once', name: input.name },
|
|
236
|
+
`dues-coupon-${input.reference}`,
|
|
237
|
+
)
|
|
238
|
+
return { id: String(body.id) }
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
async createProduct(input) {
|
|
242
|
+
const body = await request(
|
|
243
|
+
'POST',
|
|
244
|
+
'/v1/products',
|
|
245
|
+
{ name: input.name },
|
|
246
|
+
`dues-product-${input.reference}`,
|
|
247
|
+
)
|
|
248
|
+
return { id: String(body.id) }
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
async createPrice(input) {
|
|
252
|
+
const body = await request(
|
|
253
|
+
'POST',
|
|
254
|
+
'/v1/prices',
|
|
255
|
+
{
|
|
256
|
+
product: input.productId,
|
|
257
|
+
unit_amount: input.unitAmount,
|
|
258
|
+
currency: input.currency,
|
|
259
|
+
recurring: { interval: input.interval },
|
|
260
|
+
},
|
|
261
|
+
`dues-price-${input.reference}`,
|
|
262
|
+
)
|
|
263
|
+
return { id: String(body.id) }
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
async createCheckoutSession(input) {
|
|
267
|
+
const idempotencyKey =
|
|
268
|
+
typeof input.client_reference_id === 'string'
|
|
269
|
+
? `dues-session-${input.client_reference_id}`
|
|
270
|
+
: undefined
|
|
271
|
+
return sessionFromApi(await request('POST', '/v1/checkout/sessions', input, idempotencyKey))
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
async getCheckoutSession(id) {
|
|
275
|
+
return sessionFromApi(await request('GET', `/v1/checkout/sessions/${encodeURIComponent(id)}`))
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
async getSubscription(id) {
|
|
279
|
+
return subscriptionFromApi(
|
|
280
|
+
await request('GET', `/v1/subscriptions/${encodeURIComponent(id)}`),
|
|
281
|
+
)
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
async setCancelAtPeriodEnd(id, cancel) {
|
|
285
|
+
return subscriptionFromApi(
|
|
286
|
+
await request(
|
|
287
|
+
'POST',
|
|
288
|
+
`/v1/subscriptions/${encodeURIComponent(id)}`,
|
|
289
|
+
{ cancel_at_period_end: cancel },
|
|
290
|
+
`dues-cancel-${id}-${cancel ? 'on' : 'off'}`,
|
|
291
|
+
),
|
|
292
|
+
)
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
async createBillingPortalSession(input) {
|
|
296
|
+
const body = await request('POST', '/v1/billing_portal/sessions', {
|
|
297
|
+
customer: input.customer,
|
|
298
|
+
return_url: input.returnUrl,
|
|
299
|
+
})
|
|
300
|
+
return { url: String(body.url) }
|
|
301
|
+
},
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
|
|
2
|
+
export interface StripeEventEnvelope {
|
|
3
|
+
readonly id: string
|
|
4
|
+
readonly type: string
|
|
5
|
+
readonly object: Record<string, unknown>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function parseEventEnvelope(payload: unknown): StripeEventEnvelope | null {
|
|
9
|
+
if (typeof payload !== 'object' || payload === null) return null
|
|
10
|
+
const event = payload as Record<string, unknown>
|
|
11
|
+
if (typeof event.id !== 'string' || typeof event.type !== 'string') return null
|
|
12
|
+
const data = event.data as { object?: unknown } | undefined
|
|
13
|
+
const object = data?.object
|
|
14
|
+
if (typeof object !== 'object' || object === null) return null
|
|
15
|
+
return { id: event.id, type: event.type, object: object as Record<string, unknown> }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type InternalEvent =
|
|
19
|
+
| {
|
|
20
|
+
readonly kind: 'checkout-settled'
|
|
21
|
+
readonly sessionId: string
|
|
22
|
+
readonly paid: boolean
|
|
23
|
+
readonly amountTotal: number | null
|
|
24
|
+
readonly currency: string | null
|
|
25
|
+
readonly subscriptionId: string | null
|
|
26
|
+
readonly paymentIntentId: string | null
|
|
27
|
+
readonly customerId: string | null
|
|
28
|
+
}
|
|
29
|
+
| { readonly kind: 'checkout-failed'; readonly sessionId: string }
|
|
30
|
+
| { readonly kind: 'checkout-expired'; readonly sessionId: string }
|
|
31
|
+
| {
|
|
32
|
+
readonly kind: 'invoice-paid'
|
|
33
|
+
readonly subscriptionId: string | null
|
|
34
|
+
readonly periodEnd: Date | null
|
|
35
|
+
readonly amountPaid: number
|
|
36
|
+
readonly currency: string | null
|
|
37
|
+
readonly billingReason: string | null
|
|
38
|
+
}
|
|
39
|
+
| { readonly kind: 'invoice-failed'; readonly subscriptionId: string | null }
|
|
40
|
+
| {
|
|
41
|
+
readonly kind: 'subscription-updated'
|
|
42
|
+
readonly subscriptionId: string
|
|
43
|
+
readonly cancelAtPeriodEnd: boolean
|
|
44
|
+
readonly periodEnd: Date | null
|
|
45
|
+
readonly status: string
|
|
46
|
+
}
|
|
47
|
+
| { readonly kind: 'subscription-deleted'; readonly subscriptionId: string }
|
|
48
|
+
| {
|
|
49
|
+
readonly kind: 'payment-reversed'
|
|
50
|
+
readonly reversal: 'refund' | 'chargeback'
|
|
51
|
+
readonly paymentIntentId: string | null
|
|
52
|
+
readonly amountMinor: number
|
|
53
|
+
readonly currency: string | null
|
|
54
|
+
readonly stripeRef: string | null
|
|
55
|
+
}
|
|
56
|
+
| { readonly kind: 'unhandled'; readonly type: string }
|
|
57
|
+
|
|
58
|
+
function idOf(value: unknown): string | null {
|
|
59
|
+
if (typeof value === 'string') return value
|
|
60
|
+
if (typeof value === 'object' && value !== null) {
|
|
61
|
+
const id = (value as { id?: unknown }).id
|
|
62
|
+
if (typeof id === 'string') return id
|
|
63
|
+
}
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function invoiceSubscriptionId(invoice: Record<string, unknown>): string | null {
|
|
68
|
+
const direct = idOf(invoice.subscription)
|
|
69
|
+
if (direct !== null) return direct
|
|
70
|
+
|
|
71
|
+
const parent = invoice.parent as
|
|
72
|
+
| { subscription_details?: { subscription?: unknown } }
|
|
73
|
+
| undefined
|
|
74
|
+
return idOf(parent?.subscription_details?.subscription)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function invoicePeriodEnd(invoice: Record<string, unknown>): Date | null {
|
|
78
|
+
const lines = (invoice.lines as { data?: unknown } | undefined)?.data
|
|
79
|
+
let latest: number | null = null
|
|
80
|
+
if (Array.isArray(lines)) {
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
const end = ((line as { period?: { end?: unknown } }).period ?? {}).end
|
|
83
|
+
if (typeof end === 'number' && (latest === null || end > latest)) latest = end
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (latest === null && typeof invoice.period_end === 'number') latest = invoice.period_end
|
|
87
|
+
return latest === null ? null : new Date(latest * 1000)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function subscriptionPeriodEnd(subscription: Record<string, unknown>): Date | null {
|
|
91
|
+
if (typeof subscription.current_period_end === 'number') {
|
|
92
|
+
return new Date(subscription.current_period_end * 1000)
|
|
93
|
+
}
|
|
94
|
+
const items = (subscription.items as { data?: unknown } | undefined)?.data
|
|
95
|
+
let latest: number | null = null
|
|
96
|
+
if (Array.isArray(items)) {
|
|
97
|
+
for (const item of items) {
|
|
98
|
+
const end = (item as { current_period_end?: unknown }).current_period_end
|
|
99
|
+
if (typeof end === 'number' && (latest === null || end > latest)) latest = end
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return latest === null ? null : new Date(latest * 1000)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function toInternalEvent(envelope: StripeEventEnvelope): InternalEvent {
|
|
106
|
+
const object = envelope.object
|
|
107
|
+
|
|
108
|
+
switch (envelope.type) {
|
|
109
|
+
case 'checkout.session.completed':
|
|
110
|
+
case 'checkout.session.async_payment_succeeded':
|
|
111
|
+
return {
|
|
112
|
+
kind: 'checkout-settled',
|
|
113
|
+
sessionId: String(object.id),
|
|
114
|
+
paid: object.payment_status === 'paid' || object.payment_status === 'no_payment_required',
|
|
115
|
+
amountTotal: typeof object.amount_total === 'number' ? object.amount_total : null,
|
|
116
|
+
currency: typeof object.currency === 'string' ? object.currency : null,
|
|
117
|
+
subscriptionId: idOf(object.subscription),
|
|
118
|
+
paymentIntentId: idOf(object.payment_intent),
|
|
119
|
+
customerId: idOf(object.customer),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
case 'checkout.session.async_payment_failed':
|
|
123
|
+
return { kind: 'checkout-failed', sessionId: String(object.id) }
|
|
124
|
+
|
|
125
|
+
case 'checkout.session.expired':
|
|
126
|
+
return { kind: 'checkout-expired', sessionId: String(object.id) }
|
|
127
|
+
|
|
128
|
+
case 'invoice.paid':
|
|
129
|
+
return {
|
|
130
|
+
kind: 'invoice-paid',
|
|
131
|
+
subscriptionId: invoiceSubscriptionId(object),
|
|
132
|
+
periodEnd: invoicePeriodEnd(object),
|
|
133
|
+
amountPaid: typeof object.amount_paid === 'number' ? object.amount_paid : 0,
|
|
134
|
+
currency: typeof object.currency === 'string' ? object.currency : null,
|
|
135
|
+
billingReason: typeof object.billing_reason === 'string' ? object.billing_reason : null,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
case 'invoice.payment_failed':
|
|
139
|
+
return { kind: 'invoice-failed', subscriptionId: invoiceSubscriptionId(object) }
|
|
140
|
+
|
|
141
|
+
case 'customer.subscription.updated':
|
|
142
|
+
return {
|
|
143
|
+
kind: 'subscription-updated',
|
|
144
|
+
subscriptionId: String(object.id),
|
|
145
|
+
cancelAtPeriodEnd: object.cancel_at_period_end === true,
|
|
146
|
+
periodEnd: subscriptionPeriodEnd(object),
|
|
147
|
+
status: String(object.status ?? 'unknown'),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
case 'customer.subscription.deleted':
|
|
151
|
+
return { kind: 'subscription-deleted', subscriptionId: String(object.id) }
|
|
152
|
+
|
|
153
|
+
case 'charge.refunded':
|
|
154
|
+
return {
|
|
155
|
+
kind: 'payment-reversed',
|
|
156
|
+
reversal: 'refund',
|
|
157
|
+
paymentIntentId: idOf(object.payment_intent),
|
|
158
|
+
amountMinor: typeof object.amount_refunded === 'number' ? object.amount_refunded : 0,
|
|
159
|
+
currency: typeof object.currency === 'string' ? object.currency : null,
|
|
160
|
+
stripeRef: typeof object.id === 'string' ? object.id : null,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
case 'charge.dispute.created':
|
|
164
|
+
return {
|
|
165
|
+
kind: 'payment-reversed',
|
|
166
|
+
reversal: 'chargeback',
|
|
167
|
+
paymentIntentId: idOf(object.payment_intent),
|
|
168
|
+
amountMinor: typeof object.amount === 'number' ? object.amount : 0,
|
|
169
|
+
currency: typeof object.currency === 'string' ? object.currency : null,
|
|
170
|
+
stripeRef: typeof object.id === 'string' ? object.id : null,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
default:
|
|
174
|
+
return { kind: 'unhandled', type: envelope.type }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export const SUBSCRIBED_EVENT_TYPES = [
|
|
179
|
+
'checkout.session.completed',
|
|
180
|
+
'checkout.session.async_payment_succeeded',
|
|
181
|
+
'checkout.session.async_payment_failed',
|
|
182
|
+
'checkout.session.expired',
|
|
183
|
+
'invoice.paid',
|
|
184
|
+
'invoice.payment_failed',
|
|
185
|
+
'customer.subscription.updated',
|
|
186
|
+
'customer.subscription.deleted',
|
|
187
|
+
'charge.refunded',
|
|
188
|
+
'charge.dispute.created',
|
|
189
|
+
] as const
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export const STRIPE_SIGNATURE_HEADER = 'stripe-signature'
|
|
4
|
+
export const SIGNATURE_TOLERANCE_SECONDS = 300
|
|
5
|
+
|
|
6
|
+
export type SignatureVerdict =
|
|
7
|
+
| { readonly ok: true; readonly timestamp: number }
|
|
8
|
+
| { readonly ok: false; readonly reason: 'missing' | 'malformed' | 'stale' | 'mismatch' }
|
|
9
|
+
|
|
10
|
+
export function verifyStripeSignature(
|
|
11
|
+
rawBody: Uint8Array,
|
|
12
|
+
header: string | null | undefined,
|
|
13
|
+
secret: string,
|
|
14
|
+
now: Date = new Date(),
|
|
15
|
+
toleranceSeconds: number = SIGNATURE_TOLERANCE_SECONDS,
|
|
16
|
+
): SignatureVerdict {
|
|
17
|
+
if (header === null || header === undefined || header === '') {
|
|
18
|
+
return { ok: false, reason: 'missing' }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let timestamp: number | null = null
|
|
22
|
+
const signatures: string[] = []
|
|
23
|
+
|
|
24
|
+
for (const part of header.split(',')) {
|
|
25
|
+
const eq = part.indexOf('=')
|
|
26
|
+
if (eq === -1) continue
|
|
27
|
+
const key = part.slice(0, eq).trim()
|
|
28
|
+
const value = part.slice(eq + 1).trim()
|
|
29
|
+
if (key === 't') timestamp = Number(value)
|
|
30
|
+
if (key === 'v1' && /^[0-9a-f]{64}$/.test(value)) signatures.push(value)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (timestamp === null || !Number.isFinite(timestamp) || signatures.length === 0) {
|
|
34
|
+
return { ok: false, reason: 'malformed' }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const age = Math.abs(now.getTime() / 1000 - timestamp)
|
|
38
|
+
if (age > toleranceSeconds) return { ok: false, reason: 'stale' }
|
|
39
|
+
|
|
40
|
+
const mac = createHmac('sha256', secret)
|
|
41
|
+
mac.update(`${timestamp}.`, 'utf8')
|
|
42
|
+
mac.update(rawBody)
|
|
43
|
+
const expected = mac.digest()
|
|
44
|
+
|
|
45
|
+
for (const candidate of signatures) {
|
|
46
|
+
const presented = Buffer.from(candidate, 'hex')
|
|
47
|
+
if (presented.length === expected.length && timingSafeEqual(presented, expected)) {
|
|
48
|
+
return { ok: true, timestamp }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { ok: false, reason: 'mismatch' }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function signStripePayload(
|
|
56
|
+
rawBody: Uint8Array | string,
|
|
57
|
+
secret: string,
|
|
58
|
+
timestamp: number,
|
|
59
|
+
): string {
|
|
60
|
+
const mac = createHmac('sha256', secret)
|
|
61
|
+
mac.update(`${timestamp}.`, 'utf8')
|
|
62
|
+
mac.update(typeof rawBody === 'string' ? Buffer.from(rawBody, 'utf8') : rawBody)
|
|
63
|
+
return `t=${timestamp},v1=${mac.digest('hex')}`
|
|
64
|
+
}
|