@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.
- package/package.json +34 -0
- package/src/drivers/index.ts +6 -0
- package/src/drivers/mock_driver.ts +534 -0
- package/src/drivers/omise/index.ts +56 -0
- package/src/drivers/omise/omise_config.ts +19 -0
- package/src/drivers/omise/omise_driver.ts +576 -0
- package/src/drivers/omise/omise_mappers.ts +180 -0
- package/src/drivers/omise/omise_method_spec.ts +88 -0
- package/src/drivers/omise/omise_next_action_mapper.ts +89 -0
- package/src/drivers/omise/omise_price_spec.ts +85 -0
- package/src/drivers/omise/omise_provider.ts +33 -0
- package/src/drivers/omise/omise_schedule_mapper.ts +156 -0
- package/src/drivers/omise/omise_webhook.ts +162 -0
- package/src/drivers/payment_method_helpers.ts +35 -0
- package/src/drivers/stripe/index.ts +40 -0
- package/src/drivers/stripe/mappers/stripe_mappers.ts +312 -0
- package/src/drivers/stripe/mappers/stripe_method_spec.ts +77 -0
- package/src/drivers/stripe/mappers/stripe_next_action_mapper.ts +163 -0
- package/src/drivers/stripe/stripe_config.ts +18 -0
- package/src/drivers/stripe/stripe_driver.ts +650 -0
- package/src/drivers/stripe/stripe_provider.ts +38 -0
- package/src/drivers/stripe/webhook/stripe_normalize.ts +139 -0
- package/src/drivers/unsupported.ts +20 -0
- package/src/dto/index.ts +72 -0
- package/src/dto/payment_charge.ts +158 -0
- package/src/dto/payment_checkout.ts +46 -0
- package/src/dto/payment_customer.ts +52 -0
- package/src/dto/payment_event.ts +83 -0
- package/src/dto/payment_invoice.ts +39 -0
- package/src/dto/payment_link.ts +81 -0
- package/src/dto/payment_method.ts +43 -0
- package/src/dto/payment_price.ts +47 -0
- package/src/dto/payment_product.ts +40 -0
- package/src/dto/payment_subscription.ts +71 -0
- package/src/index.ts +78 -0
- package/src/ledger/apply_payment_ledger_migration.ts +106 -0
- package/src/ledger/index.ts +13 -0
- package/src/ledger/payment_ledger.ts +260 -0
- package/src/ledger/payment_ledger_models.ts +66 -0
- package/src/ledger/schemas/payment_customer_schema.ts +34 -0
- package/src/ledger/schemas/payment_invoice_schema.ts +39 -0
- package/src/ledger/schemas/payment_subscription_schema.ts +34 -0
- package/src/payment_capabilities.ts +91 -0
- package/src/payment_driver.ts +167 -0
- package/src/payment_error.ts +159 -0
- package/src/payment_manager.ts +174 -0
- package/src/payment_provider.ts +93 -0
- package/src/tenant_metadata.ts +60 -0
- package/src/types.ts +49 -0
- package/src/webhook/index.ts +8 -0
- package/src/webhook/payment_webhook.ts +190 -0
- package/src/webhook/payment_webhook_event.ts +22 -0
- package/src/webhook/payment_webhook_event_repository.ts +65 -0
- package/src/webhook/payment_webhook_event_schema.ts +40 -0
- 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
|
+
}
|