@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.
- package/package.json +7 -6
- package/src/drivers/mock_driver.ts +16 -0
- package/src/drivers/omise/omise_method_spec.ts +5 -0
- package/src/drivers/xendit/index.ts +38 -0
- package/src/drivers/xendit/xendit_client.ts +129 -0
- package/src/drivers/xendit/xendit_config.ts +47 -0
- package/src/drivers/xendit/xendit_driver.ts +566 -0
- package/src/drivers/xendit/xendit_mappers.ts +294 -0
- package/src/drivers/xendit/xendit_method_spec.ts +104 -0
- package/src/drivers/xendit/xendit_next_action_mapper.ts +71 -0
- package/src/drivers/xendit/xendit_provider.ts +32 -0
- package/src/drivers/xendit/xendit_webhook.ts +240 -0
- package/src/dto/payment_charge.ts +22 -0
- package/src/payment_capabilities.ts +12 -0
|
@@ -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
|
+
}
|