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