@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,576 @@
1
+ /**
2
+ * `OmisePaymentDriver` — `PaymentDriver` for Omise (Opn Payments).
3
+ *
4
+ * Capability scope is intentionally narrower than Stripe:
5
+ *
6
+ * - **customers** full CRUD.
7
+ * - **charges** create / retrieve / capture / refund.
8
+ * No `update` (Omise charges are immutable
9
+ * beyond capture + refund).
10
+ * - **paymentMethods** list + detach (via cards on a customer).
11
+ * attach uses card tokens; apps create
12
+ * tokens client-side via Omise.js and
13
+ * pass the token id here.
14
+ * - **subscriptions** create / retrieve / cancel / list (via
15
+ * customer). Backed by Omise's schedules
16
+ * API. `update` throws — Omise schedules
17
+ * are immutable. The framework `price`
18
+ * field carries an `omise_spec:…` blob
19
+ * built by `omisePriceSpec({...})`;
20
+ * Omise has no separate price catalogue,
21
+ * so the spec encodes amount + currency +
22
+ * period inline.
23
+ *
24
+ * - **products / prices / invoices / checkout** throw
25
+ * `ProviderUnsupportedError`. Omise has sources + payment
26
+ * links but they don't map cleanly onto the framework's
27
+ * Stripe-flavored union in v1.
28
+ *
29
+ * Apps that need product/price catalogs alongside Omise charges
30
+ * use a separate Stripe provider entry just for the catalog and
31
+ * route by `payment.use(name)`.
32
+ *
33
+ * Webhook signature: HMAC SHA-256 over the raw body, `X-Omise-
34
+ * Signature` header. Implementation in `omise_webhook.ts`.
35
+ */
36
+
37
+ // biome-ignore lint/style/useImportType: Omise is a CJS value import.
38
+ import Omise from 'omise'
39
+ import type { PaymentCapability } from '../../payment_capabilities.ts'
40
+ import type {
41
+ ChargeOps,
42
+ CheckoutOps,
43
+ CustomerOps,
44
+ InvoiceOps,
45
+ LinkOps,
46
+ PaymentDriver,
47
+ PaymentMethodOps,
48
+ PriceOps,
49
+ ProductOps,
50
+ SubscriptionOps,
51
+ WebhookOps,
52
+ } from '../../payment_driver.ts'
53
+ import { extractCardToken, paymentMethodKind } from '../payment_method_helpers.ts'
54
+ import { ProviderUnsupportedError } from '../../payment_error.ts'
55
+ import type {
56
+ CancelSubscriptionOptions,
57
+ CreateChargeInput,
58
+ CreateCustomerInput,
59
+ CreatePaymentLinkInput,
60
+ CreateRefundInput,
61
+ CreateSubscriptionInput,
62
+ ListCustomersOptions,
63
+ ListPaymentLinksOptions,
64
+ ListPaymentMethodsOptions,
65
+ ListSubscriptionsOptions,
66
+ NormalizedWebhookEvent,
67
+ PaginatedCustomers,
68
+ PaginatedPaymentLinks,
69
+ PaginatedPaymentMethods,
70
+ PaginatedSubscriptions,
71
+ PaymentCharge,
72
+ PaymentCustomer,
73
+ PaymentLink,
74
+ PaymentMethod,
75
+ PaymentRefund,
76
+ PaymentSubscription,
77
+ UpdateCustomerInput,
78
+ } from '../../dto/index.ts'
79
+ import {
80
+ toPaymentCharge,
81
+ toPaymentCustomer,
82
+ toPaymentLink,
83
+ toPaymentMethod,
84
+ type OmiseCard,
85
+ type OmiseCharge,
86
+ type OmiseCustomer,
87
+ type OmiseLink,
88
+ type OmiseSource,
89
+ } from './omise_mappers.ts'
90
+ import {
91
+ buildOmiseMethodSpec,
92
+ OMISE_SUPPORTED_METHOD_KINDS,
93
+ } from './omise_method_spec.ts'
94
+ import type { OmiseProviderConfig } from './omise_config.ts'
95
+ import { parseOmisePriceSpec } from './omise_price_spec.ts'
96
+ import {
97
+ toPaymentSubscription as toPaymentSubscriptionFromSchedule,
98
+ type OmiseSchedule,
99
+ } from './omise_schedule_mapper.ts'
100
+ import { omiseNormalize, omiseVerify, type OmiseEvent } from './omise_webhook.ts'
101
+
102
+ const PROVIDER = 'omise'
103
+
104
+ const CAPS: readonly PaymentCapability[] = [
105
+ 'customers.create', 'customers.update', 'customers.retrieve', 'customers.list', 'customers.delete',
106
+ 'paymentMethods.attach', 'paymentMethods.detach', 'paymentMethods.list',
107
+ 'charges.create', 'charges.refund', 'charges.capture',
108
+ // Async payment methods backed by Omise Sources. PromptPay is
109
+ // the only QR-based one in this list; the rest are redirect
110
+ // flows. Stripe-only kinds (`paynow`, `kakaopay`, `konbini`,
111
+ // `fps`) throw — Omise's regional fit is TH / SEA wallets.
112
+ ...OMISE_SUPPORTED_METHOD_KINDS.map(
113
+ (k) => `charges.method.${k}` as PaymentCapability,
114
+ ),
115
+ 'charges.nextAction.display_qr',
116
+ 'charges.nextAction.redirect',
117
+ 'charges.nextAction.wait',
118
+ // Omise schedules: subscriptions.create / retrieve / cancel / list-by-customer.
119
+ // `update` and `changePlan` aren't supported — Omise schedules are immutable.
120
+ // `trials` aren't supported — schedules have no trial concept.
121
+ 'subscriptions.create', 'subscriptions.retrieve', 'subscriptions.cancel',
122
+ // Payment Links — Omise supports create / retrieve / list. No
123
+ // deactivate endpoint, so `links.deactivate` throws.
124
+ 'links.create',
125
+ 'webhook.verify', 'webhook.normalize',
126
+ ]
127
+
128
+ export interface OmiseDriverOptions {
129
+ instanceName: string
130
+ config: OmiseProviderConfig
131
+ }
132
+
133
+ interface OmiseClient {
134
+ customers: {
135
+ create(req: Record<string, unknown>): Promise<OmiseCustomer>
136
+ retrieve(id: string): Promise<OmiseCustomer>
137
+ update(id: string, req: Record<string, unknown>): Promise<OmiseCustomer>
138
+ destroy(id: string): Promise<{ deleted: boolean }>
139
+ list(params?: { limit?: number; offset?: number }): Promise<{ data: OmiseCustomer[]; total: number }>
140
+ listCards(
141
+ customerID: string,
142
+ params?: { limit?: number; offset?: number },
143
+ ): Promise<{ data: OmiseCard[] }>
144
+ destroyCard(customerID: string, cardID: string): Promise<OmiseCard>
145
+ schedules(
146
+ customerID: string,
147
+ params?: { limit?: number; offset?: number },
148
+ ): Promise<{ data: OmiseSchedule[] }>
149
+ }
150
+ charges: {
151
+ create(req: Record<string, unknown>): Promise<OmiseCharge>
152
+ retrieve(id: string): Promise<OmiseCharge>
153
+ capture(id: string): Promise<OmiseCharge>
154
+ createRefund(id: string, req: Record<string, unknown>): Promise<{
155
+ id: string
156
+ amount: number
157
+ currency: string
158
+ charge: string
159
+ created?: string
160
+ created_at?: string
161
+ voided?: boolean
162
+ }>
163
+ }
164
+ schedules: {
165
+ create(req: Record<string, unknown>): Promise<OmiseSchedule>
166
+ retrieve(id: string): Promise<OmiseSchedule>
167
+ destroy(id: string): Promise<{ deleted: boolean } | OmiseSchedule>
168
+ }
169
+ sources: {
170
+ create(req: Record<string, unknown>): Promise<OmiseSource>
171
+ retrieve(id: string): Promise<OmiseSource>
172
+ }
173
+ links: {
174
+ create(req: Record<string, unknown>): Promise<OmiseLink>
175
+ retrieve(id: string): Promise<OmiseLink>
176
+ list(params?: { limit?: number; offset?: number }): Promise<{ data: OmiseLink[] }>
177
+ }
178
+ }
179
+
180
+ function uns(op: string, reason: string): (...args: unknown[]) => never {
181
+ return () => {
182
+ throw new ProviderUnsupportedError(PROVIDER, op, { reason })
183
+ }
184
+ }
185
+
186
+ function todayIso(): string {
187
+ return new Date().toISOString().slice(0, 10)
188
+ }
189
+
190
+ function oneYearFromIso(isoDay: string): string {
191
+ const [y, m, d] = isoDay.split('-').map(Number) as [number, number, number]
192
+ // Roll forward one year. JS handles month/day correctly; leap-year
193
+ // Feb 29 wraps to Feb 28, which is fine for a schedule end-date.
194
+ return new Date(Date.UTC(y + 1, m - 1, d)).toISOString().slice(0, 10)
195
+ }
196
+
197
+ export class OmisePaymentDriver implements PaymentDriver {
198
+ readonly name = PROVIDER
199
+ readonly instanceName: string
200
+ readonly capabilities: ReadonlySet<PaymentCapability> = new Set(CAPS)
201
+
202
+ readonly client: OmiseClient
203
+ private readonly config: OmiseProviderConfig
204
+
205
+ constructor(options: OmiseDriverOptions) {
206
+ this.instanceName = options.instanceName
207
+ this.config = options.config
208
+ this.client =
209
+ (options.config.client as OmiseClient | undefined) ??
210
+ (Omise({
211
+ publicKey: options.config.publicKey,
212
+ secretKey: options.config.secretKey,
213
+ ...(options.config.omiseVersion ? { omiseVersion: options.config.omiseVersion } : {}),
214
+ }) as unknown as OmiseClient)
215
+ }
216
+
217
+ readonly customers: CustomerOps = {
218
+ create: async (input: CreateCustomerInput): Promise<PaymentCustomer> => {
219
+ const c = await this.client.customers.create({
220
+ email: input.email,
221
+ ...(input.name !== undefined ? { description: input.name } : {}),
222
+ ...(input.metadata ? { metadata: input.metadata } : {}),
223
+ })
224
+ return toPaymentCustomer(c)
225
+ },
226
+ retrieve: async (id: string): Promise<PaymentCustomer> => {
227
+ return toPaymentCustomer(await this.client.customers.retrieve(id))
228
+ },
229
+ update: async (id: string, input: UpdateCustomerInput): Promise<PaymentCustomer> => {
230
+ const c = await this.client.customers.update(id, {
231
+ ...(input.email !== undefined ? { email: input.email } : {}),
232
+ ...(input.name !== undefined ? { description: input.name } : {}),
233
+ ...(input.metadata ? { metadata: input.metadata } : {}),
234
+ })
235
+ return toPaymentCustomer(c)
236
+ },
237
+ list: async (options: ListCustomersOptions = {}): Promise<PaginatedCustomers> => {
238
+ const page = await this.client.customers.list({
239
+ ...(options.limit !== undefined ? { limit: options.limit } : {}),
240
+ })
241
+ // Omise pagination is offset-based — apps that need next-page
242
+ // fetch carry an offset in their own state; we don't surface
243
+ // a cursor for v1.
244
+ const filtered = options.email
245
+ ? page.data.filter((c: OmiseCustomer) => c.email === options.email)
246
+ : page.data
247
+ return {
248
+ data: filtered.map(toPaymentCustomer),
249
+ nextCursor: null,
250
+ }
251
+ },
252
+ delete: async (id: string): Promise<void> => {
253
+ await this.client.customers.destroy(id)
254
+ },
255
+ }
256
+
257
+ // ─── Catalog-style ops: not supported by Omise's flat-charge model ────
258
+
259
+ readonly products: ProductOps = {
260
+ create: uns('products.create', 'Omise has no Products catalog; pass amount + currency directly to charges.create.'),
261
+ retrieve: uns('products.retrieve', 'Omise has no Products catalog.'),
262
+ update: uns('products.update', 'Omise has no Products catalog.'),
263
+ list: uns('products.list', 'Omise has no Products catalog.'),
264
+ }
265
+
266
+ readonly prices: PriceOps = {
267
+ create: uns('prices.create', 'Omise has no Prices catalog; pass amount + currency directly to charges.create.'),
268
+ retrieve: uns('prices.retrieve', 'Omise has no Prices catalog.'),
269
+ list: uns('prices.list', 'Omise has no Prices catalog.'),
270
+ }
271
+
272
+ readonly subscriptions: SubscriptionOps = {
273
+ create: async (input: CreateSubscriptionInput): Promise<PaymentSubscription> => {
274
+ if (input.trialDays !== undefined) {
275
+ throw new ProviderUnsupportedError(
276
+ PROVIDER,
277
+ 'subscriptions.trials',
278
+ { reason: 'Omise schedules have no trial concept. Drop `trialDays` or use a one-off charge before the schedule starts.' },
279
+ )
280
+ }
281
+ const spec = parseOmisePriceSpec(input.price)
282
+ if (!spec) {
283
+ throw new ProviderUnsupportedError(
284
+ PROVIDER,
285
+ 'subscriptions.create',
286
+ { reason: 'Omise has no `price` catalog. Build the price inline with `omisePriceSpec({ amount, currency, period, every? })` and pass the result as `price`.' },
287
+ )
288
+ }
289
+ const startDate = todayIso()
290
+ const endDate = oneYearFromIso(startDate)
291
+ const charge: Record<string, unknown> = {
292
+ customer: input.customer,
293
+ amount: spec.amount,
294
+ currency: spec.currency,
295
+ ...(spec.description ? { description: spec.description } : {}),
296
+ ...(input.metadata ? { metadata: input.metadata } : {}),
297
+ }
298
+ const cardId = input.paymentMethod ?? spec.card
299
+ if (cardId) charge.card = cardId
300
+ const schedule = await this.client.schedules.create({
301
+ every: spec.every ?? 1,
302
+ period: spec.period,
303
+ start_date: startDate,
304
+ end_date: endDate,
305
+ charge,
306
+ })
307
+ return toPaymentSubscriptionFromSchedule(schedule)
308
+ },
309
+ retrieve: async (id: string): Promise<PaymentSubscription> => {
310
+ return toPaymentSubscriptionFromSchedule(await this.client.schedules.retrieve(id))
311
+ },
312
+ update: uns(
313
+ 'subscriptions.update',
314
+ 'Omise schedules are immutable. Cancel the current schedule and create a new one with the updated terms.',
315
+ ),
316
+ cancel: async (
317
+ id: string,
318
+ _options: CancelSubscriptionOptions = {},
319
+ ): Promise<PaymentSubscription> => {
320
+ // Omise has no "cancel at period end" — destroy stops the
321
+ // schedule immediately. The `_options.at` argument is
322
+ // accepted for API uniformity but cannot change Omise's
323
+ // behaviour.
324
+ const result = await this.client.schedules.destroy(id)
325
+ if (result && typeof result === 'object' && 'id' in result) {
326
+ return toPaymentSubscriptionFromSchedule(result as OmiseSchedule)
327
+ }
328
+ // SDK returned `{ deleted: true }` — rehydrate via retrieve so
329
+ // we have the post-destroy state to return.
330
+ return toPaymentSubscriptionFromSchedule(await this.client.schedules.retrieve(id))
331
+ },
332
+ list: async (
333
+ options: ListSubscriptionsOptions = {},
334
+ ): Promise<PaginatedSubscriptions> => {
335
+ if (!options.customer) {
336
+ throw new ProviderUnsupportedError(
337
+ PROVIDER,
338
+ 'subscriptions.list',
339
+ { reason: 'Omise only lists schedules per-customer. Pass `customer` to scope the listing.' },
340
+ )
341
+ }
342
+ const page = await this.client.customers.schedules(options.customer, {
343
+ ...(options.limit !== undefined ? { limit: options.limit } : {}),
344
+ })
345
+ const data = page.data.map(toPaymentSubscriptionFromSchedule)
346
+ const filtered = options.status
347
+ ? data.filter((s) => s.status === options.status)
348
+ : data
349
+ return { data: filtered, nextCursor: null }
350
+ },
351
+ }
352
+
353
+ readonly invoices: InvoiceOps = {
354
+ retrieve: uns('invoices.retrieve', 'Omise has no invoices.'),
355
+ list: uns('invoices.list', 'Omise has no invoices.'),
356
+ finalize: uns('invoices.finalize', 'Omise has no invoices.'),
357
+ void: uns('invoices.void', 'Omise has no invoices.'),
358
+ }
359
+
360
+ readonly checkout: CheckoutOps = {
361
+ create: uns('checkout.create', 'Omise uses Payment Links instead of multi-mode hosted checkout; not bridged in v1.'),
362
+ retrieve: uns('checkout.retrieve', 'Omise hosted checkout not supported.'),
363
+ }
364
+
365
+ // ─── Payment methods (Omise cards-on-customer) ────────────────────────
366
+
367
+ readonly paymentMethods: PaymentMethodOps = {
368
+ attach: async (paymentMethodId: string, customerId: string): Promise<PaymentMethod> => {
369
+ // Omise: pass the token id via `card`; the card joins the customer.
370
+ const updated = await this.client.customers.update(customerId, {
371
+ card: paymentMethodId,
372
+ })
373
+ const cards = (updated as { cards?: { data?: OmiseCard[] } }).cards
374
+ const card = cards?.data?.find((c) => c.id === paymentMethodId) ?? cards?.data?.[0]
375
+ if (!card) {
376
+ throw new ProviderUnsupportedError(
377
+ PROVIDER,
378
+ 'paymentMethods.attach',
379
+ { reason: 'Omise did not return the attached card on the customer payload.' },
380
+ )
381
+ }
382
+ return toPaymentMethod(card)
383
+ },
384
+ detach: async (paymentMethodId: string, customerId?: string): Promise<PaymentMethod> => {
385
+ if (!customerId) {
386
+ throw new ProviderUnsupportedError(
387
+ PROVIDER,
388
+ 'paymentMethods.detach',
389
+ { reason: 'Omise needs the owning customer id to detach a card. Call `paymentMethods.detach(cardId, customerId)`.' },
390
+ )
391
+ }
392
+ const card = await this.client.customers.destroyCard(customerId, paymentMethodId)
393
+ return toPaymentMethod(card)
394
+ },
395
+ list: async (
396
+ customerId: string,
397
+ options: ListPaymentMethodsOptions = {},
398
+ ): Promise<PaginatedPaymentMethods> => {
399
+ const page = await this.client.customers.listCards(customerId, {
400
+ ...(options.limit !== undefined ? { limit: options.limit } : {}),
401
+ })
402
+ return {
403
+ data: page.data.map(toPaymentMethod),
404
+ nextCursor: null,
405
+ }
406
+ },
407
+ }
408
+
409
+ // ─── Charges ──────────────────────────────────────────────────────────
410
+
411
+ readonly charges: ChargeOps = {
412
+ create: async (input: CreateChargeInput): Promise<PaymentCharge> => {
413
+ const kind = paymentMethodKind(input.paymentMethod)
414
+ const cardToken = extractCardToken(input.paymentMethod)
415
+ const spec =
416
+ input.paymentMethod && typeof input.paymentMethod !== 'string'
417
+ ? input.paymentMethod
418
+ : null
419
+
420
+ // Async two-step: build the source, then create the charge.
421
+ if (spec && spec.kind !== 'card') {
422
+ const build = buildOmiseMethodSpec(spec, input.amount, input.currency)
423
+ if (build.kind !== 'source') {
424
+ throw new ProviderUnsupportedError(
425
+ PROVIDER,
426
+ `charges.method.${kind}`,
427
+ {
428
+ reason: `Omise does not support payment-method kind "${kind}". Use the Stripe provider for paynow / kakaopay / konbini / fps, or call \`driver.client.sources.create\` directly for source types the framework hasn't bridged.`,
429
+ },
430
+ )
431
+ }
432
+ // Redirect-flow sources need a return_uri — Omise sends
433
+ // the customer back here after the wallet/redirect step.
434
+ const needsReturnUri = kind !== 'promptpay'
435
+ if (needsReturnUri && !input.returnUrl) {
436
+ throw new ProviderUnsupportedError(
437
+ PROVIDER,
438
+ `charges.method.${kind}`,
439
+ {
440
+ reason: `Omise requires a \`returnUrl\` for redirect-based payment methods (${kind}). Set \`config.payment.returnUrl\` or pass \`returnUrl\` on the call.`,
441
+ },
442
+ )
443
+ }
444
+ const source = await this.client.sources.create({
445
+ ...build.request,
446
+ amount: input.amount,
447
+ currency: input.currency,
448
+ })
449
+ const c = await this.client.charges.create({
450
+ amount: input.amount,
451
+ currency: input.currency,
452
+ source: source.id,
453
+ ...(input.customer ? { customer: input.customer } : {}),
454
+ ...(input.returnUrl ? { return_uri: input.returnUrl } : {}),
455
+ ...(input.description !== undefined ? { description: input.description } : {}),
456
+ ...(input.metadata ? { metadata: input.metadata } : {}),
457
+ })
458
+ // Re-attach the source we just created in case the charge
459
+ // payload didn't echo it back — the next-action mapper
460
+ // reads `scannable_code` off the source.
461
+ if (!c.source) c.source = source
462
+ return toPaymentCharge(c)
463
+ }
464
+
465
+ // Single-step card path (today's behaviour).
466
+ const c = await this.client.charges.create({
467
+ amount: input.amount,
468
+ currency: input.currency,
469
+ ...(input.customer ? { customer: input.customer } : {}),
470
+ ...(cardToken ? { card: cardToken } : {}),
471
+ ...(input.description !== undefined ? { description: input.description } : {}),
472
+ ...(input.metadata ? { metadata: input.metadata } : {}),
473
+ ...(input.capture !== undefined ? { capture: input.capture } : {}),
474
+ })
475
+ return toPaymentCharge(c)
476
+ },
477
+ retrieve: async (id: string): Promise<PaymentCharge> => {
478
+ return toPaymentCharge(await this.client.charges.retrieve(id))
479
+ },
480
+ capture: async (id: string): Promise<PaymentCharge> => {
481
+ return toPaymentCharge(await this.client.charges.capture(id))
482
+ },
483
+ refund: async (input: CreateRefundInput): Promise<PaymentRefund> => {
484
+ const refund = await this.client.charges.createRefund(input.charge, {
485
+ ...(input.amount !== undefined ? { amount: input.amount } : {}),
486
+ ...(input.metadata ? { metadata: input.metadata } : {}),
487
+ })
488
+ return {
489
+ id: refund.id,
490
+ provider: PROVIDER,
491
+ chargeId: refund.charge ?? input.charge,
492
+ amount: refund.amount,
493
+ currency: refund.currency.toLowerCase(),
494
+ status: refund.voided ? 'failed' : 'succeeded',
495
+ reason: input.reason ?? null,
496
+ createdAt: refund.created_at
497
+ ? new Date(refund.created_at)
498
+ : refund.created
499
+ ? new Date(refund.created)
500
+ : new Date(),
501
+ raw: refund,
502
+ }
503
+ },
504
+ }
505
+
506
+ readonly links: LinkOps = {
507
+ create: async (input: CreatePaymentLinkInput): Promise<PaymentLink> => {
508
+ if (input.items && input.items.length > 0) {
509
+ throw new ProviderUnsupportedError(
510
+ PROVIDER,
511
+ 'links.create',
512
+ {
513
+ reason: 'Omise has no Prices catalogue. Pass `amount`, `currency`, `title`, and `description` directly instead of `items`.',
514
+ },
515
+ )
516
+ }
517
+ if (
518
+ input.amount === undefined ||
519
+ !input.currency ||
520
+ !input.title ||
521
+ !input.description
522
+ ) {
523
+ throw new ProviderUnsupportedError(
524
+ PROVIDER,
525
+ 'links.create',
526
+ {
527
+ reason: 'Omise links require `amount`, `currency`, `title`, and `description`. All four are mandatory.',
528
+ },
529
+ )
530
+ }
531
+ const link = await this.client.links.create({
532
+ amount: input.amount,
533
+ currency: input.currency,
534
+ title: input.title,
535
+ description: input.description,
536
+ ...(input.reusable !== undefined ? { multiple: input.reusable } : {}),
537
+ ...(input.metadata ? { metadata: input.metadata } : {}),
538
+ })
539
+ return toPaymentLink(link)
540
+ },
541
+ retrieve: async (id: string): Promise<PaymentLink> => {
542
+ return toPaymentLink(await this.client.links.retrieve(id))
543
+ },
544
+ list: async (
545
+ options: ListPaymentLinksOptions = {},
546
+ ): Promise<PaginatedPaymentLinks> => {
547
+ const page = await this.client.links.list({
548
+ ...(options.limit !== undefined ? { limit: options.limit } : {}),
549
+ })
550
+ const data = page.data.map(toPaymentLink)
551
+ const filtered =
552
+ options.active !== undefined
553
+ ? data.filter((l) => l.active === options.active)
554
+ : data
555
+ return { data: filtered, nextCursor: null }
556
+ },
557
+ deactivate: async (_id: string): Promise<PaymentLink> => {
558
+ throw new ProviderUnsupportedError(
559
+ PROVIDER,
560
+ 'links.deactivate',
561
+ {
562
+ reason: 'Omise has no link-deactivation endpoint. Single-use links (`reusable: false`) auto-expire after first payment; multi-use links remain active until manually deleted from the Omise Dashboard.',
563
+ },
564
+ )
565
+ },
566
+ }
567
+
568
+ readonly webhook: WebhookOps = {
569
+ verify: async (rawBody: string, signature: string): Promise<unknown> => {
570
+ return omiseVerify(rawBody, signature, this.config.webhookSecret)
571
+ },
572
+ normalize: (event: unknown): NormalizedWebhookEvent | null => {
573
+ return omiseNormalize(event as OmiseEvent)
574
+ },
575
+ }
576
+ }