@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
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@strav/payment",
3
+ "version": "1.0.0-alpha.24",
4
+ "description": "Strav payment module — provider-agnostic payment abstraction. Normalized DTOs, multi-provider routing, ledger schema, webhook dispatcher. Stripe and Omise drivers ship as subpath imports (`@strav/payment/stripe`, `@strav/payment/omise`). Paddle support comes in a later release.",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./stripe": "./src/drivers/stripe/index.ts",
11
+ "./omise": "./src/drivers/omise/index.ts"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "README.md"
16
+ ],
17
+ "engines": {
18
+ "bun": ">=1.3.14"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "dependencies": {
24
+ "@strav/database": "1.0.0-alpha.24",
25
+ "@strav/http": "1.0.0-alpha.24",
26
+ "@strav/kernel": "1.0.0-alpha.24",
27
+ "stripe": "^18.0.0",
28
+ "omise": "^0.12.0"
29
+ },
30
+ "peerDependencies": {
31
+ "@types/bun": ">=1.3.14"
32
+ },
33
+ "devDependencies": null
34
+ }
@@ -0,0 +1,6 @@
1
+ export { MockDriver, type MockDriverOptions } from './mock_driver.ts'
2
+ export {
3
+ extractCardToken,
4
+ paymentMethodKind,
5
+ } from './payment_method_helpers.ts'
6
+ export { unsupported } from './unsupported.ts'
@@ -0,0 +1,534 @@
1
+ /**
2
+ * `MockDriver` — in-memory driver used by unit tests and as the
3
+ * reference implementation for the `PaymentDriver` contract.
4
+ *
5
+ * Round-trips every operation through plain Maps; webhooks are
6
+ * "verified" by string comparison against a configured secret.
7
+ * Apps that need a mock for tests register this via
8
+ * `manager.extend('mock', () => new MockDriver({ instanceName }))`
9
+ * — or use `manager.useDriver(name, mockDriver)` to bypass the
10
+ * factory.
11
+ *
12
+ * Capability set: full. The mock declares every capability so
13
+ * apps testing capability-gated UI see the "happy path" code.
14
+ * Tests that exercise `ProviderUnsupportedError` should
15
+ * instantiate with `capabilities: new Set(...)` overrides.
16
+ */
17
+
18
+ import { ulid } from '@strav/kernel'
19
+ import { WebhookSignatureError } from '../payment_error.ts'
20
+ import type { PaymentCapability } from '../payment_capabilities.ts'
21
+ import { extractCardToken, paymentMethodKind } from './payment_method_helpers.ts'
22
+ import type {
23
+ ChargeOps,
24
+ CheckoutOps,
25
+ CustomerOps,
26
+ InvoiceOps,
27
+ LinkOps,
28
+ PaymentDriver,
29
+ PaymentMethodOps,
30
+ PriceOps,
31
+ ProductOps,
32
+ SubscriptionOps,
33
+ WebhookOps,
34
+ } from '../payment_driver.ts'
35
+ import type {
36
+ CancelSubscriptionOptions,
37
+ CreateChargeInput,
38
+ CreateCheckoutInput,
39
+ CreateCustomerInput,
40
+ CreatePaymentLinkInput,
41
+ CreatePriceInput,
42
+ CreateProductInput,
43
+ CreateRefundInput,
44
+ CreateSubscriptionInput,
45
+ ListInvoicesOptions,
46
+ ListPaymentLinksOptions,
47
+ ListPaymentMethodsOptions,
48
+ NormalizedWebhookEvent,
49
+ PaymentCharge,
50
+ PaymentCheckoutSession,
51
+ PaymentCustomer,
52
+ PaymentInvoice,
53
+ PaymentLink,
54
+ PaymentMethod,
55
+ PaymentMethodSpec,
56
+ PaymentNextAction,
57
+ PaymentPrice,
58
+ PaymentProduct,
59
+ PaymentRefund,
60
+ PaymentSubscription,
61
+ UpdateCustomerInput,
62
+ UpdateSubscriptionInput,
63
+ } from '../dto/index.ts'
64
+
65
+ const ALL_CAPS: readonly PaymentCapability[] = [
66
+ 'customers.create', 'customers.update', 'customers.retrieve', 'customers.list', 'customers.delete',
67
+ 'products.create', 'products.update', 'products.list',
68
+ 'prices.create', 'prices.list',
69
+ 'subscriptions.create', 'subscriptions.retrieve', 'subscriptions.update',
70
+ 'subscriptions.cancel', 'subscriptions.changePlan', 'subscriptions.trials',
71
+ 'paymentMethods.attach', 'paymentMethods.detach', 'paymentMethods.list',
72
+ 'charges.create', 'charges.refund', 'charges.capture',
73
+ // mock supports every payment method spec so apps exercising
74
+ // capability-gated UI see the happy path. Override via
75
+ // `MockDriverOptions.capabilities` for tests that need a narrow
76
+ // set.
77
+ 'charges.method.card', 'charges.method.promptpay', 'charges.method.paynow',
78
+ 'charges.method.fps', 'charges.method.truemoney', 'charges.method.alipay',
79
+ 'charges.method.wechat_pay', 'charges.method.grabpay', 'charges.method.kakaopay',
80
+ 'charges.method.rabbit_linepay', 'charges.method.konbini',
81
+ 'charges.nextAction.display_qr', 'charges.nextAction.redirect',
82
+ 'charges.nextAction.authorize', 'charges.nextAction.voucher',
83
+ 'charges.nextAction.wait',
84
+ 'invoices.list', 'invoices.retrieve', 'invoices.finalize', 'invoices.void',
85
+ 'checkout.create', 'checkout.retrieve',
86
+ 'links.create', 'links.deactivate',
87
+ 'idempotency',
88
+ 'webhook.verify', 'webhook.normalize',
89
+ ]
90
+
91
+ export interface MockDriverOptions {
92
+ instanceName?: string
93
+ /** Override the capability set. Defaults to "all". */
94
+ capabilities?: ReadonlySet<PaymentCapability>
95
+ /** Webhook secret used for "verify" — header value must match. */
96
+ webhookSecret?: string
97
+ }
98
+
99
+ export class MockDriver implements PaymentDriver {
100
+ readonly name = 'mock'
101
+ readonly instanceName: string
102
+ readonly capabilities: ReadonlySet<PaymentCapability>
103
+
104
+ private readonly webhookSecret: string
105
+ private readonly customersById = new Map<string, PaymentCustomer>()
106
+ private readonly productsById = new Map<string, PaymentProduct>()
107
+ private readonly pricesById = new Map<string, PaymentPrice>()
108
+ private readonly subscriptionsById = new Map<string, PaymentSubscription>()
109
+ private readonly paymentMethodsById = new Map<string, PaymentMethod>()
110
+ private readonly chargesById = new Map<string, PaymentCharge>()
111
+ private readonly invoicesById = new Map<string, PaymentInvoice>()
112
+ private readonly checkoutsById = new Map<string, PaymentCheckoutSession>()
113
+ private readonly linksById = new Map<string, PaymentLink>()
114
+
115
+ constructor(options: MockDriverOptions = {}) {
116
+ this.instanceName = options.instanceName ?? 'mock'
117
+ this.capabilities = options.capabilities ?? new Set(ALL_CAPS)
118
+ this.webhookSecret = options.webhookSecret ?? 'whsec_mock'
119
+ }
120
+
121
+ readonly customers: CustomerOps = {
122
+ create: async (input: CreateCustomerInput): Promise<PaymentCustomer> => {
123
+ const customer: PaymentCustomer = {
124
+ id: `cus_${ulid()}`,
125
+ provider: this.name,
126
+ email: input.email,
127
+ ...(input.name !== undefined ? { name: input.name } : {}),
128
+ ...(input.phone !== undefined ? { phone: input.phone } : {}),
129
+ metadata: input.metadata ?? {},
130
+ createdAt: new Date(),
131
+ raw: { ...input, mock: true },
132
+ }
133
+ this.customersById.set(customer.id, customer)
134
+ return customer
135
+ },
136
+ retrieve: async (id: string): Promise<PaymentCustomer> => {
137
+ const c = this.customersById.get(id)
138
+ if (!c) throw new Error(`MockDriver: customer "${id}" not found`)
139
+ return c
140
+ },
141
+ update: async (id: string, input: UpdateCustomerInput): Promise<PaymentCustomer> => {
142
+ const c = await this.customers.retrieve(id)
143
+ const next: PaymentCustomer = {
144
+ ...c,
145
+ ...(input.email !== undefined ? { email: input.email } : {}),
146
+ ...(input.name !== undefined ? { name: input.name } : {}),
147
+ ...(input.phone !== undefined ? { phone: input.phone } : {}),
148
+ metadata: { ...c.metadata, ...(input.metadata ?? {}) },
149
+ }
150
+ this.customersById.set(id, next)
151
+ return next
152
+ },
153
+ list: async () => ({ data: [...this.customersById.values()], nextCursor: null }),
154
+ delete: async (id: string) => {
155
+ this.customersById.delete(id)
156
+ },
157
+ }
158
+
159
+ readonly products: ProductOps = {
160
+ create: async (input: CreateProductInput): Promise<PaymentProduct> => {
161
+ const p: PaymentProduct = {
162
+ id: `prod_${ulid()}`,
163
+ provider: this.name,
164
+ name: input.name,
165
+ ...(input.description !== undefined ? { description: input.description } : {}),
166
+ active: input.active ?? true,
167
+ metadata: input.metadata ?? {},
168
+ createdAt: new Date(),
169
+ raw: { ...input, mock: true },
170
+ }
171
+ this.productsById.set(p.id, p)
172
+ return p
173
+ },
174
+ retrieve: async (id: string) => {
175
+ const p = this.productsById.get(id)
176
+ if (!p) throw new Error(`MockDriver: product "${id}" not found`)
177
+ return p
178
+ },
179
+ update: async (id: string, input: Partial<CreateProductInput>) => {
180
+ const p = await this.products.retrieve(id)
181
+ const next: PaymentProduct = { ...p, ...input, metadata: { ...p.metadata, ...(input.metadata ?? {}) } }
182
+ this.productsById.set(id, next)
183
+ return next
184
+ },
185
+ list: async () => ({ data: [...this.productsById.values()], nextCursor: null }),
186
+ }
187
+
188
+ readonly prices: PriceOps = {
189
+ create: async (input: CreatePriceInput): Promise<PaymentPrice> => {
190
+ const p: PaymentPrice = {
191
+ id: `price_${ulid()}`,
192
+ provider: this.name,
193
+ productId: input.product,
194
+ amount: input.amount,
195
+ currency: input.currency,
196
+ type: input.type ?? 'one_time',
197
+ ...(input.interval !== undefined ? { interval: input.interval } : {}),
198
+ ...(input.intervalCount !== undefined ? { intervalCount: input.intervalCount } : {}),
199
+ active: input.active ?? true,
200
+ metadata: input.metadata ?? {},
201
+ createdAt: new Date(),
202
+ raw: { ...input, mock: true },
203
+ }
204
+ this.pricesById.set(p.id, p)
205
+ return p
206
+ },
207
+ retrieve: async (id: string) => {
208
+ const p = this.pricesById.get(id)
209
+ if (!p) throw new Error(`MockDriver: price "${id}" not found`)
210
+ return p
211
+ },
212
+ list: async () => ({ data: [...this.pricesById.values()], nextCursor: null }),
213
+ }
214
+
215
+ readonly subscriptions: SubscriptionOps = {
216
+ create: async (input: CreateSubscriptionInput): Promise<PaymentSubscription> => {
217
+ const now = new Date()
218
+ const trialMs = (input.trialDays ?? 0) * 86_400_000
219
+ const periodStart = trialMs > 0 ? new Date(now.getTime() + trialMs) : now
220
+ const periodEnd = new Date(periodStart.getTime() + 30 * 86_400_000)
221
+ const sub: PaymentSubscription = {
222
+ id: `sub_${ulid()}`,
223
+ provider: this.name,
224
+ customerId: input.customer,
225
+ priceId: input.price,
226
+ status: trialMs > 0 ? 'trialing' : 'active',
227
+ currentPeriodStart: periodStart,
228
+ currentPeriodEnd: periodEnd,
229
+ cancelAt: null,
230
+ canceledAt: null,
231
+ trialStart: trialMs > 0 ? now : null,
232
+ trialEnd: trialMs > 0 ? periodStart : null,
233
+ metadata: input.metadata ?? {},
234
+ createdAt: now,
235
+ raw: { ...input, mock: true },
236
+ }
237
+ this.subscriptionsById.set(sub.id, sub)
238
+ return sub
239
+ },
240
+ retrieve: async (id: string) => {
241
+ const s = this.subscriptionsById.get(id)
242
+ if (!s) throw new Error(`MockDriver: subscription "${id}" not found`)
243
+ return s
244
+ },
245
+ update: async (id: string, input: UpdateSubscriptionInput) => {
246
+ const s = await this.subscriptions.retrieve(id)
247
+ const next: PaymentSubscription = {
248
+ ...s,
249
+ ...(input.price !== undefined ? { priceId: input.price } : {}),
250
+ metadata: { ...s.metadata, ...(input.metadata ?? {}) },
251
+ }
252
+ this.subscriptionsById.set(id, next)
253
+ return next
254
+ },
255
+ cancel: async (id: string, options: CancelSubscriptionOptions = {}) => {
256
+ const s = await this.subscriptions.retrieve(id)
257
+ const at = options.at ?? 'period_end'
258
+ const next: PaymentSubscription = at === 'now'
259
+ ? { ...s, status: 'canceled', canceledAt: new Date(), cancelAt: new Date() }
260
+ : { ...s, cancelAt: s.currentPeriodEnd }
261
+ this.subscriptionsById.set(id, next)
262
+ return next
263
+ },
264
+ list: async () => ({ data: [...this.subscriptionsById.values()], nextCursor: null }),
265
+ }
266
+
267
+ readonly paymentMethods: PaymentMethodOps = {
268
+ attach: async (paymentMethodId: string, customerId: string) => {
269
+ const existing = this.paymentMethodsById.get(paymentMethodId)
270
+ const pm: PaymentMethod = existing
271
+ ? { ...existing, customerId }
272
+ : {
273
+ id: paymentMethodId,
274
+ provider: this.name,
275
+ customerId,
276
+ kind: 'card',
277
+ brand: 'visa',
278
+ last4: '4242',
279
+ metadata: {},
280
+ createdAt: new Date(),
281
+ raw: { mock: true },
282
+ }
283
+ this.paymentMethodsById.set(paymentMethodId, pm)
284
+ return pm
285
+ },
286
+ detach: async (paymentMethodId: string, _customerId?: string) => {
287
+ const pm = this.paymentMethodsById.get(paymentMethodId)
288
+ if (!pm) throw new Error(`MockDriver: payment method "${paymentMethodId}" not found`)
289
+ const next: PaymentMethod = { ...pm, customerId: null }
290
+ this.paymentMethodsById.set(paymentMethodId, next)
291
+ return next
292
+ },
293
+ list: async (customerId: string, _options?: ListPaymentMethodsOptions) => {
294
+ const data = [...this.paymentMethodsById.values()].filter((pm) => pm.customerId === customerId)
295
+ return { data, nextCursor: null }
296
+ },
297
+ }
298
+
299
+ readonly charges: ChargeOps = {
300
+ create: async (input: CreateChargeInput): Promise<PaymentCharge> => {
301
+ // Idempotency: if the caller supplied a key and an earlier
302
+ // call wrote a charge stamped with it, return that one.
303
+ // Persists per-driver-instance only — production drivers
304
+ // (Stripe) get real server-side dedup; this is just enough
305
+ // for tests to exercise the contract.
306
+ if (input.idempotencyKey) {
307
+ const prior = [...this.chargesById.values()].find(
308
+ (c) => c.metadata.__idempotencyKey === input.idempotencyKey,
309
+ )
310
+ if (prior) return prior
311
+ }
312
+ const kind = paymentMethodKind(input.paymentMethod)
313
+ const cardToken = extractCardToken(input.paymentMethod)
314
+ const nextAction = mockNextActionFor(input.paymentMethod, input.returnUrl)
315
+ let status: PaymentCharge['status']
316
+ if (kind === 'card' || kind === 'unspecified') {
317
+ status = input.capture === false ? 'requires_action' : 'succeeded'
318
+ } else {
319
+ // Async methods always start in `requires_action` so the
320
+ // caller drives the next step.
321
+ status = 'requires_action'
322
+ }
323
+ const metadataWithKey = input.idempotencyKey
324
+ ? { ...(input.metadata ?? {}), __idempotencyKey: input.idempotencyKey }
325
+ : input.metadata ?? {}
326
+ const charge: PaymentCharge = {
327
+ id: `ch_${ulid()}`,
328
+ provider: this.name,
329
+ customerId: input.customer ?? null,
330
+ amount: input.amount,
331
+ currency: input.currency,
332
+ status,
333
+ paymentMethodId: cardToken,
334
+ failureCode: null,
335
+ failureMessage: null,
336
+ nextAction,
337
+ metadata: metadataWithKey,
338
+ createdAt: new Date(),
339
+ raw: { ...input, mock: true },
340
+ }
341
+ this.chargesById.set(charge.id, charge)
342
+ return charge
343
+ },
344
+ retrieve: async (id: string) => {
345
+ const c = this.chargesById.get(id)
346
+ if (!c) throw new Error(`MockDriver: charge "${id}" not found`)
347
+ return c
348
+ },
349
+ capture: async (id: string) => {
350
+ const c = await this.charges.retrieve(id)
351
+ const next: PaymentCharge = { ...c, status: 'succeeded', nextAction: null }
352
+ this.chargesById.set(id, next)
353
+ return next
354
+ },
355
+ refund: async (input: CreateRefundInput): Promise<PaymentRefund> => {
356
+ const charge = await this.charges.retrieve(input.charge)
357
+ const refundAmount = input.amount ?? charge.amount
358
+ const isFull = refundAmount >= charge.amount
359
+ const next: PaymentCharge = {
360
+ ...charge,
361
+ status: isFull ? 'refunded' : 'partial_refunded',
362
+ }
363
+ this.chargesById.set(charge.id, next)
364
+ return {
365
+ id: `re_${ulid()}`,
366
+ provider: this.name,
367
+ chargeId: charge.id,
368
+ amount: refundAmount,
369
+ currency: charge.currency,
370
+ status: 'succeeded',
371
+ reason: input.reason ?? null,
372
+ createdAt: new Date(),
373
+ raw: { mock: true },
374
+ }
375
+ },
376
+ }
377
+
378
+ readonly invoices: InvoiceOps = {
379
+ retrieve: async (id: string) => {
380
+ const inv = this.invoicesById.get(id)
381
+ if (!inv) throw new Error(`MockDriver: invoice "${id}" not found`)
382
+ return inv
383
+ },
384
+ list: async (_options?: ListInvoicesOptions) => ({
385
+ data: [...this.invoicesById.values()],
386
+ nextCursor: null,
387
+ }),
388
+ finalize: async (id: string) => {
389
+ const inv = await this.invoices.retrieve(id)
390
+ const next: PaymentInvoice = { ...inv, status: 'open' }
391
+ this.invoicesById.set(id, next)
392
+ return next
393
+ },
394
+ void: async (id: string) => {
395
+ const inv = await this.invoices.retrieve(id)
396
+ const next: PaymentInvoice = { ...inv, status: 'void' }
397
+ this.invoicesById.set(id, next)
398
+ return next
399
+ },
400
+ }
401
+
402
+ readonly checkout: CheckoutOps = {
403
+ create: async (input: CreateCheckoutInput): Promise<PaymentCheckoutSession> => {
404
+ const session: PaymentCheckoutSession = {
405
+ id: `cs_${ulid()}`,
406
+ provider: this.name,
407
+ mode: input.mode,
408
+ status: 'open',
409
+ url: `https://mock.checkout/${ulid()}`,
410
+ customerId: input.customer ?? null,
411
+ paymentIntentId: null,
412
+ subscriptionId: null,
413
+ expiresAt: new Date(Date.now() + 86_400_000),
414
+ metadata: input.metadata ?? {},
415
+ createdAt: new Date(),
416
+ raw: { ...input, mock: true },
417
+ }
418
+ this.checkoutsById.set(session.id, session)
419
+ return session
420
+ },
421
+ retrieve: async (id: string) => {
422
+ const s = this.checkoutsById.get(id)
423
+ if (!s) throw new Error(`MockDriver: checkout "${id}" not found`)
424
+ return s
425
+ },
426
+ }
427
+
428
+ readonly links: LinkOps = {
429
+ create: async (input: CreatePaymentLinkInput): Promise<PaymentLink> => {
430
+ const link: PaymentLink = {
431
+ id: `plink_${ulid()}`,
432
+ provider: this.name,
433
+ url: `https://mock.payment/link/${ulid()}`,
434
+ amount: input.amount ?? null,
435
+ currency: input.currency ? input.currency.toLowerCase() : null,
436
+ active: true,
437
+ reusable: input.reusable ?? true,
438
+ ...(input.title ? { title: input.title } : {}),
439
+ ...(input.description ? { description: input.description } : {}),
440
+ metadata: input.metadata ?? {},
441
+ createdAt: new Date(),
442
+ raw: { ...input, mock: true },
443
+ }
444
+ this.linksById.set(link.id, link)
445
+ return link
446
+ },
447
+ retrieve: async (id: string): Promise<PaymentLink> => {
448
+ const link = this.linksById.get(id)
449
+ if (!link) throw new Error(`MockDriver: payment link "${id}" not found`)
450
+ return link
451
+ },
452
+ list: async (_options?: ListPaymentLinksOptions) => ({
453
+ data: [...this.linksById.values()],
454
+ nextCursor: null,
455
+ }),
456
+ deactivate: async (id: string): Promise<PaymentLink> => {
457
+ const link = await this.links.retrieve(id)
458
+ const next: PaymentLink = { ...link, active: false }
459
+ this.linksById.set(id, next)
460
+ return next
461
+ },
462
+ }
463
+
464
+ readonly webhook: WebhookOps = {
465
+ verify: async (rawBody: string, signature: string): Promise<unknown> => {
466
+ if (signature !== this.webhookSecret) {
467
+ throw new WebhookSignatureError(
468
+ `MockDriver.webhook.verify: signature mismatch.`,
469
+ )
470
+ }
471
+ try {
472
+ return JSON.parse(rawBody)
473
+ } catch (cause) {
474
+ throw new WebhookSignatureError(
475
+ `MockDriver.webhook.verify: body is not valid JSON.`,
476
+ { cause },
477
+ )
478
+ }
479
+ },
480
+ normalize: (event: unknown): NormalizedWebhookEvent | null => {
481
+ return mockNormalize(event, this.name)
482
+ },
483
+ }
484
+ }
485
+
486
+ // ─── Helpers ────────────────────────────────────────────────────────────
487
+
488
+ function mockNextActionFor(
489
+ pm: string | PaymentMethodSpec | undefined,
490
+ returnUrl: string | undefined,
491
+ ): PaymentNextAction | null {
492
+ if (pm === undefined || typeof pm === 'string' || pm.kind === 'card') {
493
+ return null
494
+ }
495
+ const url = returnUrl ?? 'https://mock.payment/return'
496
+ switch (pm.kind) {
497
+ case 'promptpay':
498
+ case 'paynow':
499
+ case 'fps':
500
+ return {
501
+ kind: 'display_qr',
502
+ qrData: `mock-qr:${pm.kind}:${ulid()}`,
503
+ qrImageUrl: `https://mock.payment/qr/${ulid()}.png`,
504
+ }
505
+ case 'konbini':
506
+ return { kind: 'voucher', reference: `KON-${ulid().slice(-8)}` }
507
+ case 'truemoney':
508
+ case 'alipay':
509
+ case 'wechat_pay':
510
+ case 'grabpay':
511
+ case 'kakaopay':
512
+ case 'rabbit_linepay':
513
+ return { kind: 'redirect', url }
514
+ default:
515
+ return { kind: 'wait' }
516
+ }
517
+ }
518
+
519
+ function mockNormalize(event: unknown, provider: string): NormalizedWebhookEvent | null {
520
+ if (!event || typeof event !== 'object') return null
521
+ const obj = event as { id?: unknown; type?: unknown; data?: unknown; _fields?: unknown }
522
+ if (typeof obj.id !== 'string' || typeof obj.type !== 'string') return null
523
+ const normalized: NormalizedWebhookEvent = {
524
+ id: obj.id,
525
+ type: obj.type as NormalizedWebhookEvent['type'],
526
+ provider,
527
+ raw: event,
528
+ data: (obj.data as NormalizedWebhookEvent['data']) ?? {},
529
+ }
530
+ if (obj._fields && typeof obj._fields === 'object') {
531
+ ;(normalized as { _fields?: unknown })._fields = obj._fields
532
+ }
533
+ return normalized
534
+ }
@@ -0,0 +1,56 @@
1
+ // Public API of `@strav/payment/omise`.
2
+ //
3
+ // Subpath barrel for the Omise driver. Apps import the
4
+ // ServiceProvider and register it in `bootstrap/providers.ts`:
5
+ //
6
+ // ```ts
7
+ // import { OmisePaymentProvider } from '@strav/payment/omise'
8
+ //
9
+ // export default [PaymentProvider, OmisePaymentProvider, ...]
10
+ // ```
11
+ //
12
+ // Capability scope is narrower than Stripe — products / prices /
13
+ // subscriptions / invoices / checkout throw
14
+ // `ProviderUnsupportedError`. See `docs/payment/omise.md` (when
15
+ // it lands) for the full capability matrix.
16
+
17
+ export type { OmiseProviderConfig } from './omise_config.ts'
18
+ export {
19
+ OmisePaymentDriver,
20
+ type OmiseDriverOptions,
21
+ } from './omise_driver.ts'
22
+ export {
23
+ toPaymentCharge,
24
+ toPaymentCustomer,
25
+ toPaymentLink,
26
+ toPaymentMethod,
27
+ } from './omise_mappers.ts'
28
+ export {
29
+ buildOmiseMethodSpec,
30
+ OMISE_SUPPORTED_METHOD_KINDS,
31
+ omiseSourceFlowFor,
32
+ type OmiseMethodBuildResult,
33
+ type OmiseSourceRequest,
34
+ } from './omise_method_spec.ts'
35
+ export {
36
+ omiseNextAction,
37
+ type OmiseChargeLike,
38
+ type OmiseSourceLike,
39
+ } from './omise_next_action_mapper.ts'
40
+ export {
41
+ OMISE_PRICE_SPEC_PREFIX,
42
+ omisePriceSpec,
43
+ parseOmisePriceSpec,
44
+ type OmisePeriod,
45
+ type OmisePriceSpec,
46
+ } from './omise_price_spec.ts'
47
+ export {
48
+ toPaymentSubscription as toPaymentSubscriptionFromSchedule,
49
+ type OmiseSchedule,
50
+ } from './omise_schedule_mapper.ts'
51
+ export { OmisePaymentProvider } from './omise_provider.ts'
52
+ export {
53
+ omiseNormalize,
54
+ omiseVerify,
55
+ type OmiseEvent,
56
+ } from './omise_webhook.ts'
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Omise-specific provider config.
3
+ */
4
+
5
+ import type { ProviderConfig } from '../../types.ts'
6
+
7
+ export interface OmiseProviderConfig extends ProviderConfig {
8
+ driver: 'omise'
9
+ /** `pkey_test_...` / `pkey_live_...` — required for client-side token issuance. */
10
+ publicKey: string
11
+ /** `skey_test_...` / `skey_live_...` — required for the server-side API. */
12
+ secretKey: string
13
+ /** Webhook signing secret from the Omise Dashboard. Required for the webhook route. */
14
+ webhookSecret?: string
15
+ /** Pin the Omise API version (e.g. `'2019-05-29'`). */
16
+ omiseVersion?: string
17
+ /** Optional: pass a pre-built `Omise` instance (tests). */
18
+ client?: unknown
19
+ }