@strav/payment 1.0.0-alpha.38 → 1.0.0-alpha.40

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.
@@ -0,0 +1,566 @@
1
+ /**
2
+ * `XenditPaymentDriver` — `PaymentDriver` for Xendit (Southeast-Asia PSP).
3
+ *
4
+ * Covers the SEA-payment surface Strav cares about — QRIS (Indonesia),
5
+ * GCash / PayMaya (Philippines), MoMo (Vietnam), OVO / Dana / LinkAja /
6
+ * AstraPay / ShopeePay (Indonesia), GrabPay (cross-SEA), Alipay / WeChat
7
+ * Pay (cross-border), and cards via Xendit.js token tokenisation. Hosted
8
+ * payment pages + payment links both ride Xendit's `/v2/invoices` resource.
9
+ *
10
+ * Capability scope:
11
+ * - **customers** CRUD (create / retrieve / update / list / delete).
12
+ * - **charges** create / retrieve / refund. Capture is unsupported
13
+ * (Xendit's e-wallet + QR flows are auth-and-settle;
14
+ * cards capture immediately).
15
+ * - **links** create / retrieve / list / deactivate via the
16
+ * invoices API. `deactivate` calls Xendit's
17
+ * "expire invoice" endpoint.
18
+ * - **checkout** create / retrieve — same invoice resource viewed
19
+ * under the hosted-checkout name.
20
+ * - **invoices** retrieve / list. No `finalize` (Xendit invoices
21
+ * are immediately open) — `void` maps to expire.
22
+ * - **webhook** verify (x-callback-token) + normalize.
23
+ *
24
+ * Out of v1:
25
+ * - **subscriptions** Xendit's recurring API exists but is
26
+ * multi-step (plan + cycles + cards) and
27
+ * doesn't fit the framework's
28
+ * SubscriptionOps shape cleanly.
29
+ * - **products / prices** No native Xendit concept — apps use the
30
+ * charge `description` + metadata instead.
31
+ * - **paymentMethods** Xendit's `/v2/payment_methods` resource
32
+ * is shaped for the newer Payments API
33
+ * which we don't use here. Card flows go
34
+ * through the legacy `/credit_card_charges`
35
+ * endpoint instead.
36
+ * - **fpx / direct_debit** Xendit's direct-debit flow requires
37
+ * linked-account onboarding (a multi-step
38
+ * dance). Apps call `driver.client` directly
39
+ * for now; the framework will add a typed
40
+ * wrapper in a later slice.
41
+ */
42
+
43
+ import type { PaymentCapability } from '../../payment_capabilities.ts'
44
+ import type {
45
+ CreateChargeInput,
46
+ CreateCustomerInput,
47
+ CreatePaymentLinkInput,
48
+ CreateRefundInput,
49
+ ListCustomersOptions,
50
+ ListPaymentLinksOptions,
51
+ NormalizedWebhookEvent,
52
+ PaginatedCustomers,
53
+ PaginatedInvoices,
54
+ PaginatedPaymentLinks,
55
+ PaymentCharge,
56
+ PaymentCheckoutSession,
57
+ PaymentCustomer,
58
+ PaymentLink,
59
+ PaymentMethodSpec,
60
+ PaymentRefund,
61
+ UpdateCustomerInput,
62
+ } from '../../dto/index.ts'
63
+ import { ProviderUnsupportedError } from '../../payment_error.ts'
64
+ import type {
65
+ ChargeOps,
66
+ CheckoutOps,
67
+ CustomerOps,
68
+ InvoiceOps,
69
+ LinkOps,
70
+ PaymentDriver,
71
+ PaymentMethodOps,
72
+ PriceOps,
73
+ ProductOps,
74
+ SubscriptionOps,
75
+ WebhookOps,
76
+ } from '../../payment_driver.ts'
77
+ import { unsupported } from '../unsupported.ts'
78
+ import {
79
+ XENDIT_DEFAULT_BASE_URL,
80
+ type XenditCountry,
81
+ type XenditProviderConfig,
82
+ } from './xendit_config.ts'
83
+ import { XenditClient } from './xendit_client.ts'
84
+ import {
85
+ toPaymentChargeFromCard,
86
+ toPaymentChargeFromEwallet,
87
+ toPaymentChargeFromQr,
88
+ toPaymentCustomer,
89
+ toPaymentInvoice,
90
+ toPaymentLink,
91
+ toPaymentRefund,
92
+ type XenditCardCharge,
93
+ type XenditCustomer,
94
+ type XenditEwalletCharge,
95
+ type XenditInvoice,
96
+ type XenditQrCharge,
97
+ type XenditRefund,
98
+ } from './xendit_mappers.ts'
99
+ import { planXenditCharge, XENDIT_SUPPORTED_KINDS } from './xendit_method_spec.ts'
100
+ import { xenditNormalize, xenditVerify, type XenditEvent } from './xendit_webhook.ts'
101
+
102
+ const PROVIDER = 'xendit'
103
+
104
+ const BASE_CAPS: readonly PaymentCapability[] = [
105
+ 'customers.create',
106
+ 'customers.retrieve',
107
+ 'customers.update',
108
+ 'customers.list',
109
+ 'customers.delete',
110
+ 'charges.create',
111
+ 'charges.refund',
112
+ 'charges.nextAction.redirect',
113
+ 'charges.nextAction.display_qr',
114
+ 'charges.nextAction.wait',
115
+ 'links.create',
116
+ 'links.deactivate',
117
+ 'invoices.list',
118
+ 'invoices.retrieve',
119
+ 'invoices.void',
120
+ 'webhook.verify',
121
+ 'webhook.normalize',
122
+ 'idempotency',
123
+ ]
124
+
125
+ export interface XenditDriverOptions {
126
+ instanceName: string
127
+ config: XenditProviderConfig
128
+ }
129
+
130
+ export class XenditPaymentDriver implements PaymentDriver {
131
+ readonly name = PROVIDER
132
+ readonly instanceName: string
133
+ readonly capabilities: ReadonlySet<PaymentCapability>
134
+
135
+ readonly client: XenditClient
136
+ private readonly cfg: XenditProviderConfig
137
+ private readonly country: XenditCountry
138
+
139
+ constructor(opts: XenditDriverOptions) {
140
+ this.instanceName = opts.instanceName
141
+ this.cfg = opts.config
142
+ this.country = opts.config.defaultCountry ?? 'ID'
143
+ this.client = new XenditClient({
144
+ secretKey: opts.config.secretKey,
145
+ baseUrl: opts.config.baseUrl ?? XENDIT_DEFAULT_BASE_URL,
146
+ fetchFn: opts.config.fetch ?? fetch,
147
+ })
148
+ const caps: PaymentCapability[] = [...BASE_CAPS]
149
+ for (const kind of XENDIT_SUPPORTED_KINDS) {
150
+ caps.push(`charges.method.${kind}` as PaymentCapability)
151
+ }
152
+ this.capabilities = new Set(caps)
153
+ }
154
+
155
+ // ─── customers ──────────────────────────────────────────────────────────────
156
+
157
+ readonly customers: CustomerOps = {
158
+ create: (input: CreateCustomerInput): Promise<PaymentCustomer> => this.createCustomer(input),
159
+ retrieve: (id) => this.client.request<XenditCustomer>({ method: 'GET', path: `/customers/${id}` }).then(toPaymentCustomer),
160
+ update: (id, input) => this.updateCustomer(id, input),
161
+ list: (options) => this.listCustomers(options),
162
+ delete: async (id) => {
163
+ // Xendit doesn't delete customers — we soft-archive by stamping
164
+ // `metadata.archived = 'true'`. Apps that want a hard delete call
165
+ // `driver.client` directly.
166
+ await this.updateCustomer(id, { metadata: { archived: 'true' } })
167
+ },
168
+ }
169
+
170
+ private async createCustomer(input: CreateCustomerInput): Promise<PaymentCustomer> {
171
+ const body: Record<string, unknown> = {
172
+ reference_id: input.metadata?.['reference_id'] ?? `ref_${Date.now()}_${input.email}`,
173
+ type: 'INDIVIDUAL',
174
+ email: input.email,
175
+ }
176
+ if (input.name) body['individual_detail'] = { given_names: input.name }
177
+ if (input.phone) body['mobile_number'] = input.phone
178
+ if (input.metadata) body['metadata'] = input.metadata
179
+ const req: { method: 'POST'; path: string; body: Record<string, unknown>; idempotencyKey?: string } = {
180
+ method: 'POST',
181
+ path: '/customers',
182
+ body,
183
+ }
184
+ if (input.idempotencyKey) req.idempotencyKey = input.idempotencyKey
185
+ const res = await this.client.request<XenditCustomer>(req)
186
+ return toPaymentCustomer(res)
187
+ }
188
+
189
+ private async updateCustomer(id: string, input: UpdateCustomerInput): Promise<PaymentCustomer> {
190
+ const body: Record<string, unknown> = {}
191
+ if (input.email) body['email'] = input.email
192
+ if (input.phone) body['mobile_number'] = input.phone
193
+ if (input.name) body['individual_detail'] = { given_names: input.name }
194
+ if (input.metadata) body['metadata'] = input.metadata
195
+ const res = await this.client.request<XenditCustomer>({
196
+ method: 'PATCH',
197
+ path: `/customers/${id}`,
198
+ body,
199
+ })
200
+ return toPaymentCustomer(res)
201
+ }
202
+
203
+ private async listCustomers(options?: ListCustomersOptions): Promise<PaginatedCustomers> {
204
+ const query: Record<string, string | number | undefined> = {}
205
+ if (options?.email) query['email'] = options.email
206
+ if (options?.cursor) query['after_id'] = options.cursor
207
+ if (options?.limit) query['limit'] = options.limit
208
+ const res = await this.client.request<{ data: XenditCustomer[]; has_more?: boolean }>({
209
+ method: 'GET',
210
+ path: '/customers',
211
+ query,
212
+ })
213
+ const data = (res.data ?? []).map(toPaymentCustomer)
214
+ const last = data[data.length - 1]
215
+ return {
216
+ data,
217
+ nextCursor: res.has_more && last ? last.id : null,
218
+ }
219
+ }
220
+
221
+ // ─── charges ────────────────────────────────────────────────────────────────
222
+
223
+ readonly charges: ChargeOps = {
224
+ create: (input) => this.createCharge(input),
225
+ retrieve: (id) => this.retrieveCharge(id),
226
+ capture: () => {
227
+ throw new ProviderUnsupportedError(PROVIDER, 'charges.capture', {
228
+ reason: "Xendit's flows authorise and settle in one step (or via webhook).",
229
+ })
230
+ },
231
+ refund: (input) => this.refundCharge(input),
232
+ }
233
+
234
+ private async createCharge(input: CreateChargeInput): Promise<PaymentCharge> {
235
+ const spec = this.resolveMethodSpec(input.paymentMethod)
236
+ const plan = planXenditCharge(spec, this.country)
237
+
238
+ const reference = `ch_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
239
+ const currency = input.currency ?? this.cfg.defaultCurrency ?? 'IDR'
240
+
241
+ if (plan.channel === 'unsupported') {
242
+ throw new ProviderUnsupportedError(PROVIDER, `charges.method.${spec.kind}`, {
243
+ reason:
244
+ spec.kind === 'fpx' || spec.kind === 'direct_debit'
245
+ ? 'Use Xendit\'s linked-account flow via driver.client (deferred to a later slice).'
246
+ : 'Method not supported by the Xendit driver.',
247
+ })
248
+ }
249
+
250
+ if (plan.channel === 'ewallet') {
251
+ const body: Record<string, unknown> = {
252
+ reference_id: reference,
253
+ currency,
254
+ amount: input.amount,
255
+ checkout_method: 'ONE_TIME_PAYMENT',
256
+ channel_code: plan.channelCode,
257
+ channel_properties: {
258
+ ...(plan.channelProperties ?? {}),
259
+ ...(input.returnUrl ? { success_redirect_url: input.returnUrl } : {}),
260
+ },
261
+ }
262
+ if (input.customer) body['customer_id'] = input.customer
263
+ if (input.metadata) body['metadata'] = input.metadata
264
+ const res = await this.client.request<XenditEwalletCharge>({
265
+ method: 'POST',
266
+ path: '/ewallets/charges',
267
+ body,
268
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
269
+ })
270
+ return toPaymentChargeFromEwallet(res)
271
+ }
272
+
273
+ if (plan.channel === 'qr_code') {
274
+ const res = await this.client.request<XenditQrCharge>({
275
+ method: 'POST',
276
+ path: '/qr_codes',
277
+ body: {
278
+ reference_id: reference,
279
+ type: 'DYNAMIC',
280
+ currency,
281
+ amount: input.amount,
282
+ channel_code: plan.qrChannelCode,
283
+ ...(input.metadata ? { metadata: input.metadata } : {}),
284
+ },
285
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
286
+ })
287
+ return toPaymentChargeFromQr(res)
288
+ }
289
+
290
+ // Card path.
291
+ if (spec.kind !== 'card') {
292
+ throw new ProviderUnsupportedError(PROVIDER, `charges.method.${spec.kind}`)
293
+ }
294
+ const body: Record<string, unknown> = {
295
+ token_id: spec.token,
296
+ external_id: reference,
297
+ amount: input.amount,
298
+ currency,
299
+ capture: input.capture !== false,
300
+ }
301
+ if (input.description) body['description'] = input.description
302
+ if (input.customer) body['customer_id'] = input.customer
303
+ if (input.metadata) body['metadata'] = input.metadata
304
+ const res = await this.client.request<XenditCardCharge>({
305
+ method: 'POST',
306
+ path: '/credit_card_charges',
307
+ body,
308
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
309
+ })
310
+ return toPaymentChargeFromCard(res)
311
+ }
312
+
313
+ private async retrieveCharge(id: string): Promise<PaymentCharge> {
314
+ // Xendit's resource endpoints disagree on charge id format. Tags
315
+ // disambiguate: `ewc_` / `ewallet_` → /ewallets/charges, `qr_` →
316
+ // /qr_codes, anything else → /credit_card_charges.
317
+ if (id.startsWith('ewc_') || id.startsWith('ewallet_')) {
318
+ const res = await this.client.request<XenditEwalletCharge>({
319
+ method: 'GET',
320
+ path: `/ewallets/charges/${id}`,
321
+ })
322
+ return toPaymentChargeFromEwallet(res)
323
+ }
324
+ if (id.startsWith('qr_')) {
325
+ const res = await this.client.request<XenditQrCharge>({
326
+ method: 'GET',
327
+ path: `/qr_codes/${id}`,
328
+ })
329
+ return toPaymentChargeFromQr(res)
330
+ }
331
+ const res = await this.client.request<XenditCardCharge>({
332
+ method: 'GET',
333
+ path: `/credit_card_charges/${id}`,
334
+ })
335
+ return toPaymentChargeFromCard(res)
336
+ }
337
+
338
+ private async refundCharge(input: CreateRefundInput): Promise<PaymentRefund> {
339
+ const body: Record<string, unknown> = {}
340
+ // Xendit routes refunds via different fields depending on the source
341
+ // charge — `payment_id` for e-wallets, `invoice_id` for invoices,
342
+ // `charge_id` for cards. Heuristic: probe by id prefix; apps that
343
+ // need explicit control supply the right key via metadata.
344
+ if (input.charge.startsWith('ewc_') || input.charge.startsWith('ewallet_')) {
345
+ body['payment_id'] = input.charge
346
+ } else if (input.charge.startsWith('inv-') || input.charge.startsWith('inv_')) {
347
+ body['invoice_id'] = input.charge
348
+ } else {
349
+ body['charge_id'] = input.charge
350
+ }
351
+ if (input.amount !== undefined) body['amount'] = input.amount
352
+ if (input.reason) body['reason'] = input.reason
353
+ if (input.metadata) body['metadata'] = input.metadata
354
+
355
+ const req: {
356
+ method: 'POST'
357
+ path: string
358
+ body: Record<string, unknown>
359
+ idempotencyKey?: string
360
+ } = { method: 'POST', path: '/refunds', body }
361
+ if (input.idempotencyKey) req.idempotencyKey = input.idempotencyKey
362
+ const res = await this.client.request<XenditRefund>(req)
363
+ return toPaymentRefund(res)
364
+ }
365
+
366
+ // ─── invoices / payment links / hosted checkout ─────────────────────────────
367
+ // All three views share Xendit's /v2/invoices resource.
368
+
369
+ readonly links: LinkOps = {
370
+ create: (input) => this.createLink(input),
371
+ retrieve: async (id) => toPaymentLink(await this.fetchInvoice(id)),
372
+ list: (options) => this.listLinks(options),
373
+ deactivate: async (id) => {
374
+ const res = await this.client.request<XenditInvoice>({
375
+ method: 'POST',
376
+ path: `/invoices/${id}/expire!`,
377
+ })
378
+ return toPaymentLink(res)
379
+ },
380
+ }
381
+
382
+ readonly invoices: InvoiceOps = {
383
+ retrieve: async (id) => toPaymentInvoice(await this.fetchInvoice(id)),
384
+ list: (options) => this.listInvoices(options),
385
+ finalize: () => {
386
+ throw new ProviderUnsupportedError(PROVIDER, 'invoices.finalize', {
387
+ reason: 'Xendit invoices are immediately open — no separate finalize step.',
388
+ })
389
+ },
390
+ void: async (id) => {
391
+ const res = await this.client.request<XenditInvoice>({
392
+ method: 'POST',
393
+ path: `/invoices/${id}/expire!`,
394
+ })
395
+ return toPaymentInvoice(res)
396
+ },
397
+ }
398
+
399
+ readonly checkout: CheckoutOps = {
400
+ // Xendit's hosted-checkout shape is invoice-based (single line, single
401
+ // amount); Stripe's `items: CheckoutLineItem[]` doesn't map cleanly.
402
+ // Apps build hosted-payment-page flows on `links.create` instead.
403
+ create: unsupported(
404
+ PROVIDER,
405
+ 'checkout.create',
406
+ 'Use links.create — Xendit hosted checkout rides /v2/invoices and does not take a CheckoutLineItem array.',
407
+ ),
408
+ retrieve: async (id) => {
409
+ const inv = await this.fetchInvoice(id)
410
+ return checkoutFromInvoice(inv)
411
+ },
412
+ }
413
+
414
+ private async createLink(input: CreatePaymentLinkInput): Promise<PaymentLink> {
415
+ const body = this.buildInvoiceBody({
416
+ amount: input.amount,
417
+ currency: input.currency,
418
+ ...(input.title ? { title: input.title } : {}),
419
+ ...(input.description ? { description: input.description } : {}),
420
+ ...(input.afterCompletionRedirect ? { successRedirect: input.afterCompletionRedirect } : {}),
421
+ metadata: input.metadata,
422
+ })
423
+ const res = await this.client.request<XenditInvoice>({
424
+ method: 'POST',
425
+ path: '/v2/invoices',
426
+ body,
427
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
428
+ })
429
+ return toPaymentLink(res)
430
+ }
431
+
432
+ private buildInvoiceBody(opts: {
433
+ amount?: number
434
+ currency?: string
435
+ title?: string
436
+ description?: string
437
+ successRedirect?: string
438
+ cancelRedirect?: string
439
+ metadata?: Record<string, string>
440
+ customer?: string
441
+ }): Record<string, unknown> {
442
+ if (opts.amount === undefined) {
443
+ throw new ProviderUnsupportedError(PROVIDER, 'links.create', {
444
+ reason: 'Xendit links/invoices require an explicit `amount`.',
445
+ })
446
+ }
447
+ const body: Record<string, unknown> = {
448
+ external_id: `inv_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
449
+ amount: opts.amount,
450
+ currency: opts.currency ?? this.cfg.defaultCurrency ?? 'IDR',
451
+ }
452
+ if (opts.title) body['description'] = opts.title
453
+ if (opts.description) body['description'] = opts.description
454
+ if (opts.successRedirect) body['success_redirect_url'] = opts.successRedirect
455
+ if (opts.cancelRedirect) body['failure_redirect_url'] = opts.cancelRedirect
456
+ if (opts.metadata) body['metadata'] = opts.metadata
457
+ if (opts.customer) {
458
+ body['customer'] = { id: opts.customer }
459
+ }
460
+ return body
461
+ }
462
+
463
+ private async fetchInvoice(id: string): Promise<XenditInvoice> {
464
+ return this.client.request<XenditInvoice>({ method: 'GET', path: `/v2/invoices/${id}` })
465
+ }
466
+
467
+ private async listLinks(options?: ListPaymentLinksOptions): Promise<PaginatedPaymentLinks> {
468
+ const inv = await this.listInvoices({ ...(options ?? {}) })
469
+ return {
470
+ data: inv.data.map((i) => toPaymentLink(i.raw as XenditInvoice)),
471
+ nextCursor: inv.nextCursor,
472
+ }
473
+ }
474
+
475
+ private async listInvoices(options?: {
476
+ cursor?: string
477
+ limit?: number
478
+ customer?: string
479
+ }): Promise<PaginatedInvoices> {
480
+ const query: Record<string, string | number | undefined> = {}
481
+ if (options?.cursor) query['after_id'] = options.cursor
482
+ if (options?.limit) query['limit'] = options.limit
483
+ const list = await this.client.request<XenditInvoice[]>({
484
+ method: 'GET',
485
+ path: '/v2/invoices',
486
+ query,
487
+ })
488
+ const data = (Array.isArray(list) ? list : []).map(toPaymentInvoice)
489
+ const last = data[data.length - 1]
490
+ return {
491
+ data,
492
+ nextCursor: data.length === (options?.limit ?? -1) && last ? last.id : null,
493
+ }
494
+ }
495
+
496
+ // ─── webhook ────────────────────────────────────────────────────────────────
497
+
498
+ readonly webhook: WebhookOps = {
499
+ verify: async (rawBody, signature) =>
500
+ xenditVerify(rawBody, signature, this.cfg.webhookToken),
501
+ normalize: (event): NormalizedWebhookEvent | null =>
502
+ xenditNormalize(event as XenditEvent),
503
+ }
504
+
505
+ // ─── unsupported placeholders ───────────────────────────────────────────────
506
+
507
+ readonly products: ProductOps = {
508
+ create: unsupported(PROVIDER, 'products.create'),
509
+ retrieve: unsupported(PROVIDER, 'products.retrieve'),
510
+ update: unsupported(PROVIDER, 'products.update'),
511
+ list: unsupported(PROVIDER, 'products.list'),
512
+ }
513
+
514
+ readonly prices: PriceOps = {
515
+ create: unsupported(PROVIDER, 'prices.create'),
516
+ retrieve: unsupported(PROVIDER, 'prices.retrieve'),
517
+ list: unsupported(PROVIDER, 'prices.list'),
518
+ }
519
+
520
+ // biome-ignore lint/suspicious/noExplicitAny: Ops interfaces have many call sites that
521
+ // expect typed returns; the `unsupported()` helper returns `never` and we cast through
522
+ // `unknown` to satisfy the contracts at the type level. Runtime always throws.
523
+ readonly subscriptions: SubscriptionOps = {
524
+ create: unsupported(PROVIDER, 'subscriptions.create') as unknown as SubscriptionOps['create'],
525
+ retrieve: unsupported(PROVIDER, 'subscriptions.retrieve') as unknown as SubscriptionOps['retrieve'],
526
+ update: unsupported(PROVIDER, 'subscriptions.update') as unknown as SubscriptionOps['update'],
527
+ cancel: unsupported(PROVIDER, 'subscriptions.cancel') as unknown as SubscriptionOps['cancel'],
528
+ list: unsupported(PROVIDER, 'subscriptions.list') as unknown as SubscriptionOps['list'],
529
+ }
530
+
531
+ readonly paymentMethods: PaymentMethodOps = {
532
+ attach: unsupported(PROVIDER, 'paymentMethods.attach') as unknown as PaymentMethodOps['attach'],
533
+ detach: unsupported(PROVIDER, 'paymentMethods.detach') as unknown as PaymentMethodOps['detach'],
534
+ list: unsupported(PROVIDER, 'paymentMethods.list') as unknown as PaymentMethodOps['list'],
535
+ }
536
+
537
+ // ─── internals ──────────────────────────────────────────────────────────────
538
+
539
+ private resolveMethodSpec(pm: CreateChargeInput['paymentMethod']): PaymentMethodSpec {
540
+ if (pm === undefined) {
541
+ throw new ProviderUnsupportedError(PROVIDER, 'charges.create', {
542
+ reason: 'Xendit charges require an explicit `paymentMethod` spec (qris, gcash, …, or card token).',
543
+ })
544
+ }
545
+ if (typeof pm === 'string') return { kind: 'card', token: pm }
546
+ return pm
547
+ }
548
+ }
549
+
550
+ function checkoutFromInvoice(inv: XenditInvoice): PaymentCheckoutSession {
551
+ const expiresAt = inv.expiry_date ? new Date(inv.expiry_date) : null
552
+ return {
553
+ id: inv.id,
554
+ provider: PROVIDER,
555
+ mode: 'payment',
556
+ url: inv.invoice_url ?? '',
557
+ status: inv.status === 'PAID' ? 'complete' : inv.status === 'EXPIRED' ? 'expired' : 'open',
558
+ customerId: null,
559
+ paymentIntentId: null,
560
+ subscriptionId: null,
561
+ expiresAt: expiresAt && !Number.isNaN(expiresAt.getTime()) ? expiresAt : null,
562
+ metadata: inv.metadata ?? {},
563
+ createdAt: new Date(inv.created ?? Date.now()),
564
+ raw: inv,
565
+ }
566
+ }