@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.
@@ -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
+ }