@strav/payment 1.0.0-alpha.24

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.
Files changed (55) hide show
  1. package/package.json +34 -0
  2. package/src/drivers/index.ts +6 -0
  3. package/src/drivers/mock_driver.ts +534 -0
  4. package/src/drivers/omise/index.ts +56 -0
  5. package/src/drivers/omise/omise_config.ts +19 -0
  6. package/src/drivers/omise/omise_driver.ts +576 -0
  7. package/src/drivers/omise/omise_mappers.ts +180 -0
  8. package/src/drivers/omise/omise_method_spec.ts +88 -0
  9. package/src/drivers/omise/omise_next_action_mapper.ts +89 -0
  10. package/src/drivers/omise/omise_price_spec.ts +85 -0
  11. package/src/drivers/omise/omise_provider.ts +33 -0
  12. package/src/drivers/omise/omise_schedule_mapper.ts +156 -0
  13. package/src/drivers/omise/omise_webhook.ts +162 -0
  14. package/src/drivers/payment_method_helpers.ts +35 -0
  15. package/src/drivers/stripe/index.ts +40 -0
  16. package/src/drivers/stripe/mappers/stripe_mappers.ts +312 -0
  17. package/src/drivers/stripe/mappers/stripe_method_spec.ts +77 -0
  18. package/src/drivers/stripe/mappers/stripe_next_action_mapper.ts +163 -0
  19. package/src/drivers/stripe/stripe_config.ts +18 -0
  20. package/src/drivers/stripe/stripe_driver.ts +650 -0
  21. package/src/drivers/stripe/stripe_provider.ts +38 -0
  22. package/src/drivers/stripe/webhook/stripe_normalize.ts +139 -0
  23. package/src/drivers/unsupported.ts +20 -0
  24. package/src/dto/index.ts +72 -0
  25. package/src/dto/payment_charge.ts +158 -0
  26. package/src/dto/payment_checkout.ts +46 -0
  27. package/src/dto/payment_customer.ts +52 -0
  28. package/src/dto/payment_event.ts +83 -0
  29. package/src/dto/payment_invoice.ts +39 -0
  30. package/src/dto/payment_link.ts +81 -0
  31. package/src/dto/payment_method.ts +43 -0
  32. package/src/dto/payment_price.ts +47 -0
  33. package/src/dto/payment_product.ts +40 -0
  34. package/src/dto/payment_subscription.ts +71 -0
  35. package/src/index.ts +78 -0
  36. package/src/ledger/apply_payment_ledger_migration.ts +106 -0
  37. package/src/ledger/index.ts +13 -0
  38. package/src/ledger/payment_ledger.ts +260 -0
  39. package/src/ledger/payment_ledger_models.ts +66 -0
  40. package/src/ledger/schemas/payment_customer_schema.ts +34 -0
  41. package/src/ledger/schemas/payment_invoice_schema.ts +39 -0
  42. package/src/ledger/schemas/payment_subscription_schema.ts +34 -0
  43. package/src/payment_capabilities.ts +91 -0
  44. package/src/payment_driver.ts +167 -0
  45. package/src/payment_error.ts +159 -0
  46. package/src/payment_manager.ts +174 -0
  47. package/src/payment_provider.ts +93 -0
  48. package/src/tenant_metadata.ts +60 -0
  49. package/src/types.ts +49 -0
  50. package/src/webhook/index.ts +8 -0
  51. package/src/webhook/payment_webhook.ts +190 -0
  52. package/src/webhook/payment_webhook_event.ts +22 -0
  53. package/src/webhook/payment_webhook_event_repository.ts +65 -0
  54. package/src/webhook/payment_webhook_event_schema.ts +40 -0
  55. package/src/webhook/payment_webhook_registry.ts +65 -0
@@ -0,0 +1,650 @@
1
+ /**
2
+ * `StripePaymentDriver` — the `PaymentDriver` for Stripe.
3
+ *
4
+ * Holds one configured `Stripe` SDK instance; resource ops
5
+ * delegate into it and map every result through the
6
+ * normalized-DTO mappers. The Stripe SDK is concurrent-safe +
7
+ * HTTP/2-backed, so one shared instance per process is the
8
+ * right shape. Tests inject a stub via `config.client`.
9
+ *
10
+ * Capability set: full for v1 — Stripe covers every method the
11
+ * framework declares. `ProviderUnsupportedError` is reserved
12
+ * for drivers that genuinely can't fulfil a method (Omise's
13
+ * `subscriptions.changePlan`, etc.).
14
+ *
15
+ * Error mapping: Stripe SDK errors propagate verbatim through
16
+ * `.cause`. Apps that want vendor-specific recovery
17
+ * (`StripeRateLimitError`, declined cards) `instanceof`-check
18
+ * `error.cause`.
19
+ */
20
+
21
+ // biome-ignore lint/style/useImportType: Stripe is a value import — `new Stripe(...)`.
22
+ import Stripe from 'stripe'
23
+ import { extractCardToken, paymentMethodKind } from '../payment_method_helpers.ts'
24
+ import { ProviderUnsupportedError, WebhookSignatureError } from '../../payment_error.ts'
25
+ import type { PaymentCapability } from '../../payment_capabilities.ts'
26
+ import {
27
+ buildStripeMethodWiring,
28
+ STRIPE_SUPPORTED_METHOD_KINDS,
29
+ } from './mappers/stripe_method_spec.ts'
30
+ import { stripeNextAction } from './mappers/stripe_next_action_mapper.ts'
31
+ import type {
32
+ ChargeOps,
33
+ CheckoutOps,
34
+ CustomerOps,
35
+ InvoiceOps,
36
+ LinkOps,
37
+ PaymentDriver,
38
+ PaymentMethodOps,
39
+ PriceOps,
40
+ ProductOps,
41
+ SubscriptionOps,
42
+ WebhookOps,
43
+ } from '../../payment_driver.ts'
44
+ import type {
45
+ CancelSubscriptionOptions,
46
+ CreateChargeInput,
47
+ CreateCheckoutInput,
48
+ CreateCustomerInput,
49
+ CreatePaymentLinkInput,
50
+ CreatePriceInput,
51
+ CreateProductInput,
52
+ CreateRefundInput,
53
+ CreateSubscriptionInput,
54
+ ListCustomersOptions,
55
+ ListInvoicesOptions,
56
+ ListPaymentLinksOptions,
57
+ ListPaymentMethodsOptions,
58
+ ListPricesOptions,
59
+ ListProductsOptions,
60
+ ListSubscriptionsOptions,
61
+ NormalizedWebhookEvent,
62
+ PaginatedCustomers,
63
+ PaginatedInvoices,
64
+ PaginatedPaymentLinks,
65
+ PaginatedPaymentMethods,
66
+ PaginatedPrices,
67
+ PaginatedProducts,
68
+ PaginatedSubscriptions,
69
+ PaymentCharge,
70
+ PaymentCheckoutSession,
71
+ PaymentCustomer,
72
+ PaymentInvoice,
73
+ PaymentLink,
74
+ PaymentMethod,
75
+ PaymentPrice,
76
+ PaymentProduct,
77
+ PaymentRefund,
78
+ PaymentSubscription,
79
+ UpdateCustomerInput,
80
+ UpdateSubscriptionInput,
81
+ } from '../../dto/index.ts'
82
+ import {
83
+ toPaymentCharge,
84
+ toPaymentCheckoutSession,
85
+ toPaymentCustomer,
86
+ toPaymentInvoice,
87
+ toPaymentLink,
88
+ toPaymentMethod,
89
+ toPaymentPrice,
90
+ toPaymentProduct,
91
+ toPaymentSubscription,
92
+ } from './mappers/stripe_mappers.ts'
93
+ import type { StripeProviderConfig } from './stripe_config.ts'
94
+ import { stripeNormalize } from './webhook/stripe_normalize.ts'
95
+
96
+ const PROVIDER = 'stripe'
97
+
98
+ const ALL_CAPS: readonly PaymentCapability[] = [
99
+ 'customers.create', 'customers.update', 'customers.retrieve', 'customers.list', 'customers.delete',
100
+ 'products.create', 'products.update', 'products.list',
101
+ 'prices.create', 'prices.list',
102
+ 'subscriptions.create', 'subscriptions.retrieve', 'subscriptions.update',
103
+ 'subscriptions.cancel', 'subscriptions.changePlan', 'subscriptions.trials',
104
+ 'paymentMethods.attach', 'paymentMethods.detach', 'paymentMethods.list',
105
+ 'charges.create', 'charges.refund', 'charges.capture',
106
+ // Async payment methods Stripe supports — wired in slice 7.2 via
107
+ // PaymentIntent `payment_method_data.type`. The kinds we DON'T
108
+ // declare (`truemoney`, `fps`, `rabbit_linepay`) are Omise/
109
+ // regional specialties Stripe doesn't offer; calls throw
110
+ // `ProviderUnsupportedError`.
111
+ ...STRIPE_SUPPORTED_METHOD_KINDS.map(
112
+ (k) => `charges.method.${k}` as PaymentCapability,
113
+ ),
114
+ // Next-action shapes the driver can emit, sourced from Stripe's
115
+ // PaymentIntent.NextAction discriminator.
116
+ 'charges.nextAction.display_qr',
117
+ 'charges.nextAction.redirect',
118
+ 'charges.nextAction.authorize',
119
+ 'charges.nextAction.voucher',
120
+ 'charges.nextAction.wait',
121
+ 'invoices.list', 'invoices.retrieve', 'invoices.finalize', 'invoices.void',
122
+ 'checkout.create', 'checkout.retrieve',
123
+ 'links.create', 'links.deactivate',
124
+ // Stripe natively supports the Idempotency-Key header on every
125
+ // POST endpoint we use. Apps pass `idempotencyKey` on any
126
+ // create-style input; the driver forwards via the SDK's
127
+ // RequestOptions slot.
128
+ 'idempotency',
129
+ 'webhook.verify', 'webhook.normalize',
130
+ ]
131
+
132
+ /** Build a Stripe SDK RequestOptions object when an idempotency key is set. */
133
+ function idem(key: string | undefined): Stripe.RequestOptions | undefined {
134
+ return key ? { idempotencyKey: key } : undefined
135
+ }
136
+
137
+ export interface StripeDriverOptions {
138
+ instanceName: string
139
+ config: StripeProviderConfig
140
+ }
141
+
142
+ export class StripePaymentDriver implements PaymentDriver {
143
+ readonly name = PROVIDER
144
+ readonly instanceName: string
145
+ readonly capabilities: ReadonlySet<PaymentCapability> = new Set(ALL_CAPS)
146
+
147
+ /** The raw `Stripe` SDK instance — apps reach this for behaviour the framework doesn't wrap. */
148
+ readonly client: Stripe
149
+ private readonly config: StripeProviderConfig
150
+
151
+ constructor(options: StripeDriverOptions) {
152
+ this.instanceName = options.instanceName
153
+ this.config = options.config
154
+ this.client =
155
+ (options.config.client as Stripe | undefined) ??
156
+ new Stripe(options.config.secret, {
157
+ ...(options.config.apiVersion !== undefined
158
+ ? { apiVersion: options.config.apiVersion as Stripe.LatestApiVersion }
159
+ : {}),
160
+ })
161
+ }
162
+
163
+ readonly customers: CustomerOps = {
164
+ create: async (input: CreateCustomerInput): Promise<PaymentCustomer> => {
165
+ const c = await this.client.customers.create(
166
+ {
167
+ email: input.email,
168
+ ...(input.name !== undefined ? { name: input.name } : {}),
169
+ ...(input.phone !== undefined ? { phone: input.phone } : {}),
170
+ ...(input.metadata ? { metadata: input.metadata } : {}),
171
+ },
172
+ idem(input.idempotencyKey),
173
+ )
174
+ return toPaymentCustomer(c)
175
+ },
176
+ retrieve: async (id: string): Promise<PaymentCustomer> => {
177
+ const c = await this.client.customers.retrieve(id)
178
+ if ((c as Stripe.DeletedCustomer).deleted) {
179
+ throw new Error(`Stripe customer "${id}" is deleted.`)
180
+ }
181
+ return toPaymentCustomer(c as Stripe.Customer)
182
+ },
183
+ update: async (id: string, input: UpdateCustomerInput): Promise<PaymentCustomer> => {
184
+ const c = await this.client.customers.update(id, {
185
+ ...(input.email !== undefined ? { email: input.email } : {}),
186
+ ...(input.name !== undefined ? { name: input.name } : {}),
187
+ ...(input.phone !== undefined ? { phone: input.phone } : {}),
188
+ ...(input.metadata ? { metadata: input.metadata } : {}),
189
+ })
190
+ return toPaymentCustomer(c)
191
+ },
192
+ list: async (options: ListCustomersOptions = {}): Promise<PaginatedCustomers> => {
193
+ const page = await this.client.customers.list({
194
+ ...(options.limit !== undefined ? { limit: options.limit } : {}),
195
+ ...(options.cursor ? { starting_after: options.cursor } : {}),
196
+ ...(options.email ? { email: options.email } : {}),
197
+ })
198
+ return {
199
+ data: page.data.map(toPaymentCustomer),
200
+ nextCursor: page.has_more ? (page.data[page.data.length - 1]?.id ?? null) : null,
201
+ }
202
+ },
203
+ delete: async (id: string): Promise<void> => {
204
+ await this.client.customers.del(id)
205
+ },
206
+ }
207
+
208
+ readonly products: ProductOps = {
209
+ create: async (input: CreateProductInput): Promise<PaymentProduct> => {
210
+ const p = await this.client.products.create({
211
+ name: input.name,
212
+ ...(input.description !== undefined ? { description: input.description } : {}),
213
+ ...(input.active !== undefined ? { active: input.active } : {}),
214
+ ...(input.metadata ? { metadata: input.metadata } : {}),
215
+ })
216
+ return toPaymentProduct(p)
217
+ },
218
+ retrieve: async (id: string): Promise<PaymentProduct> => {
219
+ return toPaymentProduct(await this.client.products.retrieve(id))
220
+ },
221
+ update: async (
222
+ id: string,
223
+ input: Partial<CreateProductInput>,
224
+ ): Promise<PaymentProduct> => {
225
+ const p = await this.client.products.update(id, {
226
+ ...(input.name !== undefined ? { name: input.name } : {}),
227
+ ...(input.description !== undefined ? { description: input.description } : {}),
228
+ ...(input.active !== undefined ? { active: input.active } : {}),
229
+ ...(input.metadata ? { metadata: input.metadata } : {}),
230
+ })
231
+ return toPaymentProduct(p)
232
+ },
233
+ list: async (options: ListProductsOptions = {}): Promise<PaginatedProducts> => {
234
+ const page = await this.client.products.list({
235
+ ...(options.limit !== undefined ? { limit: options.limit } : {}),
236
+ ...(options.cursor ? { starting_after: options.cursor } : {}),
237
+ ...(options.active !== undefined ? { active: options.active } : {}),
238
+ })
239
+ return {
240
+ data: page.data.map(toPaymentProduct),
241
+ nextCursor: page.has_more ? (page.data[page.data.length - 1]?.id ?? null) : null,
242
+ }
243
+ },
244
+ }
245
+
246
+ readonly prices: PriceOps = {
247
+ create: async (input: CreatePriceInput): Promise<PaymentPrice> => {
248
+ const params: Stripe.PriceCreateParams = {
249
+ product: input.product,
250
+ unit_amount: input.amount,
251
+ currency: input.currency,
252
+ ...(input.active !== undefined ? { active: input.active } : {}),
253
+ ...(input.metadata ? { metadata: input.metadata } : {}),
254
+ }
255
+ if ((input.type ?? 'one_time') === 'recurring') {
256
+ params.recurring = {
257
+ interval: (input.interval ?? 'month') as Stripe.PriceCreateParams.Recurring.Interval,
258
+ ...(input.intervalCount ? { interval_count: input.intervalCount } : {}),
259
+ }
260
+ }
261
+ return toPaymentPrice(await this.client.prices.create(params))
262
+ },
263
+ retrieve: async (id: string): Promise<PaymentPrice> => {
264
+ return toPaymentPrice(await this.client.prices.retrieve(id))
265
+ },
266
+ list: async (options: ListPricesOptions = {}): Promise<PaginatedPrices> => {
267
+ const page = await this.client.prices.list({
268
+ ...(options.product ? { product: options.product } : {}),
269
+ ...(options.limit !== undefined ? { limit: options.limit } : {}),
270
+ ...(options.cursor ? { starting_after: options.cursor } : {}),
271
+ ...(options.active !== undefined ? { active: options.active } : {}),
272
+ })
273
+ return {
274
+ data: page.data.map(toPaymentPrice),
275
+ nextCursor: page.has_more ? (page.data[page.data.length - 1]?.id ?? null) : null,
276
+ }
277
+ },
278
+ }
279
+
280
+ readonly subscriptions: SubscriptionOps = {
281
+ create: async (input: CreateSubscriptionInput): Promise<PaymentSubscription> => {
282
+ const s = await this.client.subscriptions.create(
283
+ {
284
+ customer: input.customer,
285
+ items: [{ price: input.price }],
286
+ ...(input.trialDays ? { trial_period_days: input.trialDays } : {}),
287
+ ...(input.paymentMethod ? { default_payment_method: input.paymentMethod } : {}),
288
+ ...(input.metadata ? { metadata: input.metadata } : {}),
289
+ },
290
+ idem(input.idempotencyKey),
291
+ )
292
+ return toPaymentSubscription(s)
293
+ },
294
+ retrieve: async (id: string): Promise<PaymentSubscription> => {
295
+ return toPaymentSubscription(await this.client.subscriptions.retrieve(id))
296
+ },
297
+ update: async (
298
+ id: string,
299
+ input: UpdateSubscriptionInput,
300
+ ): Promise<PaymentSubscription> => {
301
+ const current = await this.client.subscriptions.retrieve(id)
302
+ const params: Stripe.SubscriptionUpdateParams = {
303
+ ...(input.metadata ? { metadata: input.metadata } : {}),
304
+ ...(input.paymentMethod ? { default_payment_method: input.paymentMethod } : {}),
305
+ }
306
+ if (input.price) {
307
+ const itemId = current.items.data[0]?.id
308
+ if (!itemId) {
309
+ throw new Error(`Stripe subscription "${id}" has no items; can't change price.`)
310
+ }
311
+ params.items = [{ id: itemId, price: input.price }]
312
+ }
313
+ const s = await this.client.subscriptions.update(id, params)
314
+ return toPaymentSubscription(s)
315
+ },
316
+ cancel: async (
317
+ id: string,
318
+ options: CancelSubscriptionOptions = {},
319
+ ): Promise<PaymentSubscription> => {
320
+ const at = options.at ?? 'period_end'
321
+ if (at === 'now') {
322
+ return toPaymentSubscription(await this.client.subscriptions.cancel(id))
323
+ }
324
+ return toPaymentSubscription(
325
+ await this.client.subscriptions.update(id, { cancel_at_period_end: true }),
326
+ )
327
+ },
328
+ list: async (
329
+ options: ListSubscriptionsOptions = {},
330
+ ): Promise<PaginatedSubscriptions> => {
331
+ const page = await this.client.subscriptions.list({
332
+ ...(options.customer ? { customer: options.customer } : {}),
333
+ ...(options.status
334
+ ? { status: options.status as Stripe.SubscriptionListParams.Status }
335
+ : {}),
336
+ ...(options.limit !== undefined ? { limit: options.limit } : {}),
337
+ ...(options.cursor ? { starting_after: options.cursor } : {}),
338
+ })
339
+ return {
340
+ data: page.data.map(toPaymentSubscription),
341
+ nextCursor: page.has_more ? (page.data[page.data.length - 1]?.id ?? null) : null,
342
+ }
343
+ },
344
+ }
345
+
346
+ readonly paymentMethods: PaymentMethodOps = {
347
+ attach: async (paymentMethodId: string, customerId: string): Promise<PaymentMethod> => {
348
+ const pm = await this.client.paymentMethods.attach(paymentMethodId, {
349
+ customer: customerId,
350
+ })
351
+ return toPaymentMethod(pm)
352
+ },
353
+ detach: async (paymentMethodId: string, _customerId?: string): Promise<PaymentMethod> => {
354
+ // Stripe resolves the owning customer from the payment-method id;
355
+ // `customerId` is part of the framework contract for Omise's benefit.
356
+ const pm = await this.client.paymentMethods.detach(paymentMethodId)
357
+ return toPaymentMethod(pm)
358
+ },
359
+ list: async (
360
+ customerId: string,
361
+ options: ListPaymentMethodsOptions = {},
362
+ ): Promise<PaginatedPaymentMethods> => {
363
+ const page = await this.client.paymentMethods.list({
364
+ customer: customerId,
365
+ ...(options.kind === 'card' ? { type: 'card' } : {}),
366
+ ...(options.limit !== undefined ? { limit: options.limit } : {}),
367
+ ...(options.cursor ? { starting_after: options.cursor } : {}),
368
+ })
369
+ return {
370
+ data: page.data.map(toPaymentMethod),
371
+ nextCursor: page.has_more ? (page.data[page.data.length - 1]?.id ?? null) : null,
372
+ }
373
+ },
374
+ }
375
+
376
+ readonly charges: ChargeOps = {
377
+ create: async (input: CreateChargeInput): Promise<PaymentCharge> => {
378
+ // Stripe steers new integrations to PaymentIntents; the
379
+ // legacy `charges.create` flow doesn't support most payment
380
+ // methods. We use PaymentIntent + auto-confirm and return
381
+ // either the settled charge (cards, instant) or a synthetic
382
+ // pending charge with `nextAction` populated.
383
+ const kind = paymentMethodKind(input.paymentMethod)
384
+ const cardToken = extractCardToken(input.paymentMethod)
385
+
386
+ // Build per-kind PaymentIntent params.
387
+ let methodParams: Partial<Stripe.PaymentIntentCreateParams> = {}
388
+ if (input.paymentMethod && typeof input.paymentMethod !== 'string' && input.paymentMethod.kind !== 'card') {
389
+ const result = buildStripeMethodWiring(input.paymentMethod)
390
+ if (result.kind !== 'wired') {
391
+ throw new ProviderUnsupportedError(
392
+ PROVIDER,
393
+ `charges.method.${kind}`,
394
+ {
395
+ reason: `Stripe does not support payment-method kind "${kind}". Use a provider with the matching capability (e.g. Omise for truemoney / fps / rabbit_linepay), or call \`driver.client.*\` for vendor-specific flows.`,
396
+ },
397
+ )
398
+ }
399
+ methodParams = {
400
+ payment_method_data: result.wiring.payment_method_data,
401
+ ...(result.wiring.payment_method_options
402
+ ? { payment_method_options: result.wiring.payment_method_options }
403
+ : {}),
404
+ }
405
+ } else if (cardToken) {
406
+ methodParams = { payment_method: cardToken }
407
+ }
408
+
409
+ // Redirect / authorize next-actions require a return_url.
410
+ // We pass it whenever the caller supplied one; Stripe
411
+ // ignores it for QR / voucher / sync card flows.
412
+ const needsReturnUrl =
413
+ input.paymentMethod !== undefined &&
414
+ typeof input.paymentMethod !== 'string' &&
415
+ input.paymentMethod.kind !== 'card'
416
+ if (needsReturnUrl && !input.returnUrl) {
417
+ throw new ProviderUnsupportedError(
418
+ PROVIDER,
419
+ `charges.method.${kind}`,
420
+ {
421
+ reason: `Stripe requires a \`returnUrl\` for async payment methods (where it sends the customer back after redirect / authorise). Set \`config.payment.returnUrl\` or pass \`returnUrl\` on the call.`,
422
+ },
423
+ )
424
+ }
425
+
426
+ const intent = await this.client.paymentIntents.create(
427
+ {
428
+ amount: input.amount,
429
+ currency: input.currency,
430
+ ...(input.customer ? { customer: input.customer } : {}),
431
+ ...methodParams,
432
+ ...(input.returnUrl ? { return_url: input.returnUrl } : {}),
433
+ ...(input.description !== undefined ? { description: input.description } : {}),
434
+ ...(input.metadata ? { metadata: input.metadata } : {}),
435
+ confirm: input.capture !== false,
436
+ capture_method: input.capture === false ? 'manual' : 'automatic',
437
+ },
438
+ idem(input.idempotencyKey),
439
+ )
440
+
441
+ // Settled in-line — return the canonical charge DTO.
442
+ const chargeId =
443
+ typeof intent.latest_charge === 'string'
444
+ ? intent.latest_charge
445
+ : intent.latest_charge?.id
446
+ if (chargeId && intent.status === 'succeeded') {
447
+ return toPaymentCharge(await this.client.charges.retrieve(chargeId))
448
+ }
449
+
450
+ // Pending (requires_action / processing / requires_confirmation)
451
+ // — build a synthetic charge from the intent + map next_action.
452
+ const status: PaymentCharge['status'] =
453
+ intent.status === 'requires_action' || intent.status === 'requires_confirmation'
454
+ ? 'requires_action'
455
+ : 'pending'
456
+ return {
457
+ id: intent.id,
458
+ provider: PROVIDER,
459
+ customerId:
460
+ typeof intent.customer === 'string'
461
+ ? intent.customer
462
+ : intent.customer
463
+ ? (intent.customer as { id: string }).id
464
+ : null,
465
+ amount: intent.amount,
466
+ currency: intent.currency,
467
+ status,
468
+ paymentMethodId:
469
+ typeof intent.payment_method === 'string'
470
+ ? intent.payment_method
471
+ : intent.payment_method
472
+ ? (intent.payment_method as { id: string }).id
473
+ : null,
474
+ failureCode: null,
475
+ failureMessage: null,
476
+ nextAction: stripeNextAction(intent.next_action),
477
+ metadata: Object.fromEntries(
478
+ Object.entries(intent.metadata ?? {}).filter(([, v]) => v !== null) as [string, string][],
479
+ ),
480
+ createdAt: new Date(intent.created * 1000),
481
+ raw: intent,
482
+ }
483
+ },
484
+ retrieve: async (id: string): Promise<PaymentCharge> => {
485
+ return toPaymentCharge(await this.client.charges.retrieve(id))
486
+ },
487
+ capture: async (
488
+ id: string,
489
+ options: { amount?: number } = {},
490
+ ): Promise<PaymentCharge> => {
491
+ const charge = await this.client.charges.capture(id, {
492
+ ...(options.amount !== undefined ? { amount: options.amount } : {}),
493
+ })
494
+ return toPaymentCharge(charge)
495
+ },
496
+ refund: async (input: CreateRefundInput): Promise<PaymentRefund> => {
497
+ const r = await this.client.refunds.create(
498
+ {
499
+ charge: input.charge,
500
+ ...(input.amount !== undefined ? { amount: input.amount } : {}),
501
+ ...(input.reason ? { reason: input.reason as Stripe.RefundCreateParams.Reason } : {}),
502
+ ...(input.metadata ? { metadata: input.metadata } : {}),
503
+ },
504
+ idem(input.idempotencyKey),
505
+ )
506
+ return {
507
+ id: r.id,
508
+ provider: PROVIDER,
509
+ chargeId: typeof r.charge === 'string' ? r.charge : (r.charge as { id: string } | null)?.id ?? input.charge,
510
+ amount: r.amount,
511
+ currency: r.currency,
512
+ status: (r.status as 'succeeded' | 'pending' | 'failed' | null) ?? 'pending',
513
+ reason: r.reason,
514
+ createdAt: new Date(r.created * 1000),
515
+ raw: r,
516
+ }
517
+ },
518
+ }
519
+
520
+ readonly invoices: InvoiceOps = {
521
+ retrieve: async (id: string): Promise<PaymentInvoice> => {
522
+ return toPaymentInvoice(await this.client.invoices.retrieve(id))
523
+ },
524
+ list: async (options: ListInvoicesOptions = {}): Promise<PaginatedInvoices> => {
525
+ const page = await this.client.invoices.list({
526
+ ...(options.customer ? { customer: options.customer } : {}),
527
+ ...(options.subscription ? { subscription: options.subscription } : {}),
528
+ ...(options.status ? { status: options.status as Stripe.InvoiceListParams.Status } : {}),
529
+ ...(options.limit !== undefined ? { limit: options.limit } : {}),
530
+ ...(options.cursor ? { starting_after: options.cursor } : {}),
531
+ })
532
+ return {
533
+ data: page.data.map(toPaymentInvoice),
534
+ nextCursor: page.has_more ? (page.data[page.data.length - 1]?.id ?? null) : null,
535
+ }
536
+ },
537
+ finalize: async (id: string): Promise<PaymentInvoice> => {
538
+ return toPaymentInvoice(await this.client.invoices.finalizeInvoice(id))
539
+ },
540
+ void: async (id: string): Promise<PaymentInvoice> => {
541
+ return toPaymentInvoice(await this.client.invoices.voidInvoice(id))
542
+ },
543
+ }
544
+
545
+ readonly checkout: CheckoutOps = {
546
+ create: async (input: CreateCheckoutInput): Promise<PaymentCheckoutSession> => {
547
+ const params: Stripe.Checkout.SessionCreateParams = {
548
+ mode: input.mode,
549
+ success_url: input.successUrl,
550
+ cancel_url: input.cancelUrl,
551
+ line_items: input.items.map((i) => ({
552
+ price: i.price,
553
+ quantity: i.quantity ?? 1,
554
+ })),
555
+ ...(input.customer ? { customer: input.customer } : {}),
556
+ ...(input.customerEmail ? { customer_email: input.customerEmail } : {}),
557
+ ...(input.metadata ? { metadata: input.metadata } : {}),
558
+ }
559
+ if (input.mode === 'subscription' && input.trialDays) {
560
+ params.subscription_data = { trial_period_days: input.trialDays }
561
+ }
562
+ return toPaymentCheckoutSession(
563
+ await this.client.checkout.sessions.create(params, idem(input.idempotencyKey)),
564
+ )
565
+ },
566
+ retrieve: async (id: string): Promise<PaymentCheckoutSession> => {
567
+ return toPaymentCheckoutSession(await this.client.checkout.sessions.retrieve(id))
568
+ },
569
+ }
570
+
571
+ readonly links: LinkOps = {
572
+ create: async (input: CreatePaymentLinkInput): Promise<PaymentLink> => {
573
+ // Stripe Payment Links require `line_items` with Price ids
574
+ // — ad-hoc amount+currency aren't supported. Apps that want
575
+ // a one-off link create a Price first, then pass it here.
576
+ if (!input.items || input.items.length === 0) {
577
+ throw new ProviderUnsupportedError(
578
+ PROVIDER,
579
+ 'links.create',
580
+ {
581
+ reason: 'Stripe Payment Links require `items` (catalogue Price ids); ad-hoc `amount`/`currency` is not supported. Call `payment.prices.create({...})` first, then pass the resulting price id via `items: [{ price: "price_xxx" }]`.',
582
+ },
583
+ )
584
+ }
585
+ const params: Stripe.PaymentLinkCreateParams = {
586
+ line_items: input.items.map((i) => ({
587
+ price: i.price,
588
+ quantity: i.quantity ?? 1,
589
+ })),
590
+ ...(input.metadata ? { metadata: input.metadata } : {}),
591
+ }
592
+ if (input.afterCompletionRedirect) {
593
+ params.after_completion = {
594
+ type: 'redirect',
595
+ redirect: { url: input.afterCompletionRedirect },
596
+ }
597
+ }
598
+ return toPaymentLink(
599
+ await this.client.paymentLinks.create(params, idem(input.idempotencyKey)),
600
+ )
601
+ },
602
+ retrieve: async (id: string): Promise<PaymentLink> => {
603
+ return toPaymentLink(await this.client.paymentLinks.retrieve(id))
604
+ },
605
+ list: async (
606
+ options: ListPaymentLinksOptions = {},
607
+ ): Promise<PaginatedPaymentLinks> => {
608
+ const page = await this.client.paymentLinks.list({
609
+ ...(options.limit !== undefined ? { limit: options.limit } : {}),
610
+ ...(options.cursor ? { starting_after: options.cursor } : {}),
611
+ ...(options.active !== undefined ? { active: options.active } : {}),
612
+ })
613
+ return {
614
+ data: page.data.map(toPaymentLink),
615
+ nextCursor: page.has_more ? (page.data[page.data.length - 1]?.id ?? null) : null,
616
+ }
617
+ },
618
+ deactivate: async (id: string): Promise<PaymentLink> => {
619
+ // Stripe doesn't have a dedicated "delete" — flipping
620
+ // `active: false` stops the link from accepting new
621
+ // payments. In-flight checkout sessions still settle.
622
+ return toPaymentLink(await this.client.paymentLinks.update(id, { active: false }))
623
+ },
624
+ }
625
+
626
+ readonly webhook: WebhookOps = {
627
+ verify: async (rawBody: string, signature: string): Promise<unknown> => {
628
+ if (!this.config.webhookSecret) {
629
+ throw new WebhookSignatureError(
630
+ 'StripePaymentDriver.webhook.verify: `webhookSecret` is not set on the provider config.',
631
+ )
632
+ }
633
+ try {
634
+ return await this.client.webhooks.constructEventAsync(
635
+ rawBody,
636
+ signature,
637
+ this.config.webhookSecret,
638
+ )
639
+ } catch (cause) {
640
+ throw new WebhookSignatureError(
641
+ `StripePaymentDriver.webhook.verify: signature verification failed.`,
642
+ { cause },
643
+ )
644
+ }
645
+ },
646
+ normalize: (event: unknown): NormalizedWebhookEvent | null => {
647
+ return stripeNormalize(event as Stripe.Event)
648
+ },
649
+ }
650
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `StripePaymentProvider` — `ServiceProvider` that registers the
3
+ * Stripe driver factory on the `PaymentManager`.
4
+ *
5
+ * Boot ordering: list AFTER `PaymentProvider` in
6
+ * `bootstrap/providers.ts`. `register()` here calls
7
+ * `manager.extend('stripe', factory)`; then `PaymentProvider.boot`
8
+ * eagerly resolves the manager. Driver instances are constructed
9
+ * on first `payment.use(name)` call (lazy), so misconfigured
10
+ * Stripe secrets surface on first use rather than at boot. Apps
11
+ * that want fail-fast-at-boot semantics call `payment.use('stripe')`
12
+ * from their own `boot()` step.
13
+ */
14
+
15
+ import { type Application, ServiceProvider } from '@strav/kernel'
16
+ import { PaymentManager } from '../../payment_manager.ts'
17
+ import { PaymentConfigError } from '../../payment_error.ts'
18
+ import type { StripeProviderConfig } from './stripe_config.ts'
19
+ import { StripePaymentDriver } from './stripe_driver.ts'
20
+
21
+ export class StripePaymentProvider extends ServiceProvider {
22
+ override readonly name = 'payment-stripe'
23
+ override readonly dependencies = ['payment']
24
+
25
+ override register(app: Application): void {
26
+ const manager = app.resolve(PaymentManager)
27
+ manager.extend('stripe', ({ instanceName, config }) => {
28
+ const cfg = config as StripeProviderConfig
29
+ if (!cfg.secret) {
30
+ throw new PaymentConfigError(
31
+ `StripePaymentProvider: \`config.payment.providers["${instanceName}"].secret\` is required.`,
32
+ { context: { instanceName } },
33
+ )
34
+ }
35
+ return new StripePaymentDriver({ instanceName, config: cfg })
36
+ })
37
+ }
38
+ }