@strav/payment 1.0.0-alpha.39 → 1.0.0-alpha.42
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,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Xendit response → framework DTO mappers.
|
|
3
|
+
*
|
|
4
|
+
* The driver holds onto every raw Xendit response under `.raw`; the mappers
|
|
5
|
+
* here are best-effort surface translations for the fields the framework
|
|
6
|
+
* normalises. Anything driver-specific (settlement timing, fee breakdowns,
|
|
7
|
+
* channel-specific extras) stays on `.raw` for apps to dig into.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
PaymentCharge,
|
|
12
|
+
PaymentCustomer,
|
|
13
|
+
PaymentInvoice,
|
|
14
|
+
PaymentLink,
|
|
15
|
+
PaymentRefund,
|
|
16
|
+
ChargeStatus,
|
|
17
|
+
} from '../../dto/index.ts'
|
|
18
|
+
import {
|
|
19
|
+
xenditEwalletNextAction,
|
|
20
|
+
xenditQrNextAction,
|
|
21
|
+
type XenditEwalletResponse,
|
|
22
|
+
type XenditQrResponse,
|
|
23
|
+
} from './xendit_next_action_mapper.ts'
|
|
24
|
+
|
|
25
|
+
export interface XenditCustomer {
|
|
26
|
+
id: string
|
|
27
|
+
reference_id?: string
|
|
28
|
+
email?: string | null
|
|
29
|
+
mobile_number?: string | null
|
|
30
|
+
individual_detail?: { given_names?: string; surname?: string } | null
|
|
31
|
+
business_detail?: { business_name?: string } | null
|
|
32
|
+
metadata?: Record<string, string> | null
|
|
33
|
+
created?: string
|
|
34
|
+
updated?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface XenditEwalletCharge extends XenditEwalletResponse {
|
|
38
|
+
id: string
|
|
39
|
+
reference_id?: string
|
|
40
|
+
customer_id?: string | null
|
|
41
|
+
currency: string
|
|
42
|
+
charge_amount?: number
|
|
43
|
+
capture_amount?: number
|
|
44
|
+
amount?: number
|
|
45
|
+
channel_code?: string
|
|
46
|
+
failure_code?: string | null
|
|
47
|
+
metadata?: Record<string, string> | null
|
|
48
|
+
created?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface XenditQrCharge extends XenditQrResponse {
|
|
52
|
+
id: string
|
|
53
|
+
reference_id?: string
|
|
54
|
+
currency: string
|
|
55
|
+
amount: number
|
|
56
|
+
status?: string
|
|
57
|
+
channel_code?: string
|
|
58
|
+
metadata?: Record<string, string> | null
|
|
59
|
+
created?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface XenditCardCharge {
|
|
63
|
+
id: string
|
|
64
|
+
external_id?: string
|
|
65
|
+
status: string
|
|
66
|
+
charge_type?: string
|
|
67
|
+
card_brand?: string
|
|
68
|
+
bank_reconciliation_id?: string
|
|
69
|
+
masked_card_number?: string
|
|
70
|
+
failure_reason?: string | null
|
|
71
|
+
card_token_id?: string
|
|
72
|
+
amount: number
|
|
73
|
+
currency: string
|
|
74
|
+
customer_id?: string | null
|
|
75
|
+
metadata?: Record<string, string> | null
|
|
76
|
+
created?: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface XenditRefund {
|
|
80
|
+
id: string
|
|
81
|
+
reference_id?: string
|
|
82
|
+
payment_id?: string
|
|
83
|
+
invoice_id?: string
|
|
84
|
+
charge_id?: string
|
|
85
|
+
amount: number
|
|
86
|
+
currency: string
|
|
87
|
+
status: string
|
|
88
|
+
reason?: string | null
|
|
89
|
+
created?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface XenditInvoice {
|
|
93
|
+
id: string
|
|
94
|
+
external_id?: string
|
|
95
|
+
user_id?: string
|
|
96
|
+
status: string
|
|
97
|
+
merchant_name?: string
|
|
98
|
+
amount: number
|
|
99
|
+
payer_email?: string
|
|
100
|
+
description?: string
|
|
101
|
+
expiry_date?: string
|
|
102
|
+
invoice_url?: string
|
|
103
|
+
available_banks?: unknown[]
|
|
104
|
+
available_ewallets?: unknown[]
|
|
105
|
+
available_retail_outlets?: unknown[]
|
|
106
|
+
payment_methods?: string[]
|
|
107
|
+
currency: string
|
|
108
|
+
paid_amount?: number
|
|
109
|
+
paid_at?: string
|
|
110
|
+
metadata?: Record<string, string> | null
|
|
111
|
+
created?: string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── customers ───────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
export function toPaymentCustomer(x: XenditCustomer): PaymentCustomer {
|
|
117
|
+
const name =
|
|
118
|
+
x.individual_detail?.given_names && x.individual_detail.surname
|
|
119
|
+
? `${x.individual_detail.given_names} ${x.individual_detail.surname}`.trim()
|
|
120
|
+
: (x.individual_detail?.given_names ?? x.business_detail?.business_name ?? undefined)
|
|
121
|
+
const out: PaymentCustomer = {
|
|
122
|
+
id: x.id,
|
|
123
|
+
provider: 'xendit',
|
|
124
|
+
email: x.email ?? '',
|
|
125
|
+
metadata: x.metadata ?? {},
|
|
126
|
+
createdAt: parseDate(x.created) ?? new Date(),
|
|
127
|
+
raw: x,
|
|
128
|
+
}
|
|
129
|
+
if (name) out.name = name
|
|
130
|
+
if (x.mobile_number) out.phone = x.mobile_number
|
|
131
|
+
return out
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── charges ─────────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
const EWALLET_STATUS: Record<string, ChargeStatus> = {
|
|
137
|
+
SUCCEEDED: 'succeeded',
|
|
138
|
+
PENDING: 'requires_action',
|
|
139
|
+
FAILED: 'failed',
|
|
140
|
+
VOIDED: 'failed',
|
|
141
|
+
REFUNDED: 'refunded',
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const QR_STATUS: Record<string, ChargeStatus> = {
|
|
145
|
+
ACTIVE: 'requires_action',
|
|
146
|
+
INACTIVE: 'failed',
|
|
147
|
+
COMPLETED: 'succeeded',
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const CARD_STATUS: Record<string, ChargeStatus> = {
|
|
151
|
+
CAPTURED: 'succeeded',
|
|
152
|
+
AUTHORIZED: 'requires_action',
|
|
153
|
+
REVERSED: 'failed',
|
|
154
|
+
FAILED: 'failed',
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function toPaymentChargeFromEwallet(c: XenditEwalletCharge): PaymentCharge {
|
|
158
|
+
return {
|
|
159
|
+
id: c.id,
|
|
160
|
+
provider: 'xendit',
|
|
161
|
+
customerId: c.customer_id ?? null,
|
|
162
|
+
amount: c.charge_amount ?? c.capture_amount ?? c.amount ?? 0,
|
|
163
|
+
currency: c.currency,
|
|
164
|
+
status: EWALLET_STATUS[c.status ?? ''] ?? 'pending',
|
|
165
|
+
paymentMethodId: c.channel_code ?? null,
|
|
166
|
+
failureCode: c.failure_code ?? null,
|
|
167
|
+
failureMessage: c.failure_code ?? null,
|
|
168
|
+
nextAction: xenditEwalletNextAction(c),
|
|
169
|
+
metadata: c.metadata ?? {},
|
|
170
|
+
createdAt: parseDate(c.created) ?? new Date(),
|
|
171
|
+
raw: c,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function toPaymentChargeFromQr(c: XenditQrCharge): PaymentCharge {
|
|
176
|
+
return {
|
|
177
|
+
id: c.id,
|
|
178
|
+
provider: 'xendit',
|
|
179
|
+
customerId: null,
|
|
180
|
+
amount: c.amount,
|
|
181
|
+
currency: c.currency,
|
|
182
|
+
status: QR_STATUS[c.status ?? ''] ?? 'requires_action',
|
|
183
|
+
paymentMethodId: c.channel_code ?? 'QRIS',
|
|
184
|
+
failureCode: null,
|
|
185
|
+
failureMessage: null,
|
|
186
|
+
nextAction: xenditQrNextAction(c),
|
|
187
|
+
metadata: c.metadata ?? {},
|
|
188
|
+
createdAt: parseDate(c.created) ?? new Date(),
|
|
189
|
+
raw: c,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function toPaymentChargeFromCard(c: XenditCardCharge): PaymentCharge {
|
|
194
|
+
return {
|
|
195
|
+
id: c.id,
|
|
196
|
+
provider: 'xendit',
|
|
197
|
+
customerId: c.customer_id ?? null,
|
|
198
|
+
amount: c.amount,
|
|
199
|
+
currency: c.currency,
|
|
200
|
+
status: CARD_STATUS[c.status] ?? 'failed',
|
|
201
|
+
paymentMethodId: c.card_token_id ?? c.masked_card_number ?? null,
|
|
202
|
+
failureCode: c.failure_reason ?? null,
|
|
203
|
+
failureMessage: c.failure_reason ?? null,
|
|
204
|
+
nextAction: null,
|
|
205
|
+
metadata: c.metadata ?? {},
|
|
206
|
+
createdAt: parseDate(c.created) ?? new Date(),
|
|
207
|
+
raw: c,
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ─── refunds ─────────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
const REFUND_STATUS: Record<string, PaymentRefund['status']> = {
|
|
214
|
+
SUCCEEDED: 'succeeded',
|
|
215
|
+
PENDING: 'pending',
|
|
216
|
+
FAILED: 'failed',
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function toPaymentRefund(r: XenditRefund): PaymentRefund {
|
|
220
|
+
return {
|
|
221
|
+
id: r.id,
|
|
222
|
+
provider: 'xendit',
|
|
223
|
+
chargeId: r.payment_id ?? r.charge_id ?? r.invoice_id ?? '',
|
|
224
|
+
amount: r.amount,
|
|
225
|
+
currency: r.currency,
|
|
226
|
+
status: REFUND_STATUS[r.status] ?? 'pending',
|
|
227
|
+
reason: r.reason ?? null,
|
|
228
|
+
createdAt: parseDate(r.created) ?? new Date(),
|
|
229
|
+
raw: r,
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ─── invoices / payment links ────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
const INVOICE_STATUS: Record<string, PaymentInvoice['status']> = {
|
|
236
|
+
PENDING: 'open',
|
|
237
|
+
PAID: 'paid',
|
|
238
|
+
SETTLED: 'paid',
|
|
239
|
+
EXPIRED: 'void',
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function toPaymentInvoice(inv: XenditInvoice): PaymentInvoice {
|
|
243
|
+
const paid = inv.paid_amount ?? (inv.status === 'PAID' ? inv.amount : 0)
|
|
244
|
+
return {
|
|
245
|
+
id: inv.id,
|
|
246
|
+
provider: 'xendit',
|
|
247
|
+
// Xendit invoices aren't customer-scoped on the resource itself;
|
|
248
|
+
// apps stash the customer id in metadata when they need it. Surface
|
|
249
|
+
// it from there so subscription invoicing flows still link correctly.
|
|
250
|
+
customerId: typeof inv.metadata?.['customer_id'] === 'string' ? inv.metadata['customer_id'] : '',
|
|
251
|
+
subscriptionId: null,
|
|
252
|
+
status: INVOICE_STATUS[inv.status] ?? 'open',
|
|
253
|
+
amount: inv.amount,
|
|
254
|
+
amountPaid: paid,
|
|
255
|
+
amountDue: Math.max(0, inv.amount - paid),
|
|
256
|
+
currency: inv.currency,
|
|
257
|
+
hostedUrl: inv.invoice_url ?? null,
|
|
258
|
+
pdfUrl: null,
|
|
259
|
+
dueAt: parseDate(inv.expiry_date) ?? null,
|
|
260
|
+
paidAt: parseDate(inv.paid_at) ?? null,
|
|
261
|
+
metadata: inv.metadata ?? {},
|
|
262
|
+
createdAt: parseDate(inv.created) ?? new Date(),
|
|
263
|
+
raw: inv,
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Xendit's payment link is its `/v2/invoices` resource viewed under a
|
|
269
|
+
* different name — the `invoice_url` IS the link. We map both views from
|
|
270
|
+
* the same response shape.
|
|
271
|
+
*/
|
|
272
|
+
export function toPaymentLink(inv: XenditInvoice): PaymentLink {
|
|
273
|
+
return {
|
|
274
|
+
id: inv.id,
|
|
275
|
+
provider: 'xendit',
|
|
276
|
+
url: inv.invoice_url ?? '',
|
|
277
|
+
amount: inv.amount,
|
|
278
|
+
currency: inv.currency,
|
|
279
|
+
active: inv.status === 'PENDING',
|
|
280
|
+
// Xendit invoices are single-use by default — they expire after
|
|
281
|
+
// `invoice_duration` or after the first successful payment.
|
|
282
|
+
reusable: false,
|
|
283
|
+
...(inv.description ? { description: inv.description } : {}),
|
|
284
|
+
metadata: inv.metadata ?? {},
|
|
285
|
+
createdAt: parseDate(inv.created) ?? new Date(),
|
|
286
|
+
raw: inv,
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function parseDate(v: string | null | undefined): Date | undefined {
|
|
291
|
+
if (!v) return undefined
|
|
292
|
+
const d = new Date(v)
|
|
293
|
+
return Number.isNaN(d.getTime()) ? undefined : d
|
|
294
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route a `PaymentMethodSpec` to the Xendit endpoint + payload shape.
|
|
3
|
+
*
|
|
4
|
+
* Xendit fans charges out across three resource APIs depending on the
|
|
5
|
+
* payment method:
|
|
6
|
+
*
|
|
7
|
+
* - `/ewallets/charges` — every e-wallet (GCash, OVO, Dana, MoMo,
|
|
8
|
+
* ShopeePay, GrabPay, LinkAja, AstraPay,
|
|
9
|
+
* PayMaya, …). Multi-country via `channel_code`.
|
|
10
|
+
* - `/qr_codes` — QRIS (Indonesia universal QR).
|
|
11
|
+
* - `/credit_card_charges` — cards (requires a client-tokenised `token_id`).
|
|
12
|
+
*
|
|
13
|
+
* Direct-debit / FPX flows go through Xendit's Payments API
|
|
14
|
+
* (`/v2/payment_requests`) with linked-account onboarding — that's a
|
|
15
|
+
* multi-step flow that doesn't fit the single-shot `charges.create`
|
|
16
|
+
* contract, so the driver throws `ProviderUnsupportedError` for those
|
|
17
|
+
* kinds in v1 and points apps at `driver.client` for the raw call.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { PaymentMethodSpec } from '../../dto/index.ts'
|
|
21
|
+
import type { XenditCountry } from './xendit_config.ts'
|
|
22
|
+
|
|
23
|
+
export type XenditChannel = 'ewallet' | 'qr_code' | 'card' | 'unsupported'
|
|
24
|
+
|
|
25
|
+
export interface XenditMethodPlan {
|
|
26
|
+
channel: XenditChannel
|
|
27
|
+
/** Channel-code for e-wallet charges (e.g. `'PH_GCASH'`). Undefined for non-e-wallets. */
|
|
28
|
+
channelCode?: string
|
|
29
|
+
/** Optional `channel_properties` overlay that varies per method (mobile number, etc.). */
|
|
30
|
+
channelProperties?: Record<string, unknown>
|
|
31
|
+
/** `qr_codes` only — Xendit's QR `channel_code` (just `'QRIS'` today). */
|
|
32
|
+
qrChannelCode?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* E-wallet channel codes — `<country>_<wallet>`. We pick a default country
|
|
37
|
+
* when the method is unambiguous (PH for GCash / PayMaya, VN for MoMo,
|
|
38
|
+
* ID for OVO/Dana/LinkAja/AstraPay), and fall back to the provider's
|
|
39
|
+
* `defaultCountry` for wallets available in multiple markets (GrabPay,
|
|
40
|
+
* ShopeePay). Apps that need explicit country routing pass it via
|
|
41
|
+
* `extras.country` in `channelProperties` (Xendit ignores unknown keys).
|
|
42
|
+
*/
|
|
43
|
+
const EWALLET_CHANNEL: Partial<Record<PaymentMethodSpec['kind'], (c: XenditCountry) => string>> = {
|
|
44
|
+
gcash: () => 'PH_GCASH',
|
|
45
|
+
paymaya: () => 'PH_PAYMAYA',
|
|
46
|
+
ovo: () => 'ID_OVO',
|
|
47
|
+
dana: () => 'ID_DANA',
|
|
48
|
+
linkaja: () => 'ID_LINKAJA',
|
|
49
|
+
astrapay: () => 'ID_ASTRAPAY',
|
|
50
|
+
momo: () => 'VN_MOMO',
|
|
51
|
+
grabpay: (c) => `${c}_GRABPAY`,
|
|
52
|
+
shopeepay: (c) => `${c}_SHOPEEPAY`,
|
|
53
|
+
// Atome — Xendit fans across SG / MY / ID / TH / PH. Routes via
|
|
54
|
+
// the provider's `defaultCountry` like other multi-country wallets.
|
|
55
|
+
atome: (c) => `${c}_ATOME`,
|
|
56
|
+
alipay: () => 'CN_ALIPAY',
|
|
57
|
+
wechat_pay: () => 'CN_WECHATPAY',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function planXenditCharge(
|
|
61
|
+
spec: PaymentMethodSpec,
|
|
62
|
+
defaultCountry: XenditCountry,
|
|
63
|
+
): XenditMethodPlan {
|
|
64
|
+
if (spec.kind === 'card') {
|
|
65
|
+
return { channel: 'card' }
|
|
66
|
+
}
|
|
67
|
+
if (spec.kind === 'qris') {
|
|
68
|
+
return { channel: 'qr_code', qrChannelCode: 'QRIS' }
|
|
69
|
+
}
|
|
70
|
+
const channelCodeBuilder = EWALLET_CHANNEL[spec.kind]
|
|
71
|
+
if (!channelCodeBuilder) {
|
|
72
|
+
return { channel: 'unsupported' }
|
|
73
|
+
}
|
|
74
|
+
const channelCode = channelCodeBuilder(defaultCountry)
|
|
75
|
+
const channelProperties: Record<string, unknown> = {}
|
|
76
|
+
// Xendit accepts `mobile_number` for a handful of wallets — OVO mandates it,
|
|
77
|
+
// others optionally use it to short-circuit the consent step. We pass it
|
|
78
|
+
// along unconditionally when supplied; Xendit ignores it where unused.
|
|
79
|
+
if ('mobileNumber' in spec && spec.mobileNumber) {
|
|
80
|
+
channelProperties['mobile_number'] = spec.mobileNumber
|
|
81
|
+
}
|
|
82
|
+
return { channel: 'ewallet', channelCode, channelProperties }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Kinds the driver claims as `charges.method.<kind>` capabilities. Tests
|
|
87
|
+
* and the registry derive from this single list so they can't drift.
|
|
88
|
+
*/
|
|
89
|
+
export const XENDIT_SUPPORTED_KINDS: ReadonlyArray<PaymentMethodSpec['kind']> = [
|
|
90
|
+
'card',
|
|
91
|
+
'qris',
|
|
92
|
+
'gcash',
|
|
93
|
+
'paymaya',
|
|
94
|
+
'momo',
|
|
95
|
+
'ovo',
|
|
96
|
+
'dana',
|
|
97
|
+
'shopeepay',
|
|
98
|
+
'grabpay',
|
|
99
|
+
'linkaja',
|
|
100
|
+
'astrapay',
|
|
101
|
+
'atome',
|
|
102
|
+
'alipay',
|
|
103
|
+
'wechat_pay',
|
|
104
|
+
]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map a Xendit charge response onto `PaymentNextAction`.
|
|
3
|
+
*
|
|
4
|
+
* - **E-wallets**: `actions.desktop_web_checkout_url` (or `mobile_web_*`
|
|
5
|
+
* /`mobile_deeplink_*` when present) → `redirect`. Wallets that
|
|
6
|
+
* authorise via push notification (OVO) settle in `status: PENDING`
|
|
7
|
+
* with no actions — those map to `wait`.
|
|
8
|
+
* - **QR (QRIS)**: `qr_string` is the EMV string the app renders into a
|
|
9
|
+
* QR for the customer to scan. Xendit doesn't host a PNG; apps render
|
|
10
|
+
* it themselves (every Strav app already uses a QR component for
|
|
11
|
+
* PromptPay anyway).
|
|
12
|
+
* - **Cards**: synchronous — no next-action.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { PaymentNextAction } from '../../dto/index.ts'
|
|
16
|
+
|
|
17
|
+
export interface XenditEwalletResponse {
|
|
18
|
+
status?: string
|
|
19
|
+
actions?: {
|
|
20
|
+
desktop_web_checkout_url?: string | null
|
|
21
|
+
mobile_web_checkout_url?: string | null
|
|
22
|
+
mobile_deeplink_checkout_url?: string | null
|
|
23
|
+
qr_checkout_string?: string | null
|
|
24
|
+
}
|
|
25
|
+
expires_at?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface XenditQrResponse {
|
|
29
|
+
qr_string?: string
|
|
30
|
+
expires_at?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function xenditEwalletNextAction(
|
|
34
|
+
res: XenditEwalletResponse,
|
|
35
|
+
): PaymentNextAction | null {
|
|
36
|
+
// Pull whichever redirect Xendit produced. Mobile-deeplink wins on
|
|
37
|
+
// touch surfaces, but server-side we have no UA signal — apps that
|
|
38
|
+
// care construct the right URL from `charge.raw.actions`. Default is
|
|
39
|
+
// the desktop checkout URL, which works on every device.
|
|
40
|
+
const url =
|
|
41
|
+
res.actions?.desktop_web_checkout_url ??
|
|
42
|
+
res.actions?.mobile_web_checkout_url ??
|
|
43
|
+
res.actions?.mobile_deeplink_checkout_url
|
|
44
|
+
if (url) {
|
|
45
|
+
const action: PaymentNextAction = { kind: 'redirect', url }
|
|
46
|
+
const expiresAt = parseDate(res.expires_at)
|
|
47
|
+
if (expiresAt) action.expiresAt = expiresAt
|
|
48
|
+
return action
|
|
49
|
+
}
|
|
50
|
+
if (res.actions?.qr_checkout_string) {
|
|
51
|
+
return { kind: 'display_qr', qrData: res.actions.qr_checkout_string }
|
|
52
|
+
}
|
|
53
|
+
// Push-notification flows (OVO, sometimes Dana) — no actions returned,
|
|
54
|
+
// settlement arrives via webhook.
|
|
55
|
+
if (res.status === 'PENDING') return { kind: 'wait' }
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function xenditQrNextAction(res: XenditQrResponse): PaymentNextAction | null {
|
|
60
|
+
if (!res.qr_string) return null
|
|
61
|
+
const action: PaymentNextAction = { kind: 'display_qr', qrData: res.qr_string }
|
|
62
|
+
const expiresAt = parseDate(res.expires_at)
|
|
63
|
+
if (expiresAt) action.expiresAt = expiresAt
|
|
64
|
+
return action
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseDate(v: string | undefined | null): Date | undefined {
|
|
68
|
+
if (!v) return undefined
|
|
69
|
+
const d = new Date(v)
|
|
70
|
+
return Number.isNaN(d.getTime()) ? undefined : d
|
|
71
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `XenditPaymentProvider` — `ServiceProvider` that registers the
|
|
3
|
+
* Xendit driver factory on the `PaymentManager`.
|
|
4
|
+
*
|
|
5
|
+
* Apps list this AFTER `PaymentProvider` in `bootstrap/providers.ts`.
|
|
6
|
+
* Driver instances construct lazily on first `payment.use(name)` call.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { type Application, ServiceProvider } from '@strav/kernel'
|
|
10
|
+
import { PaymentManager } from '../../payment_manager.ts'
|
|
11
|
+
import { PaymentConfigError } from '../../payment_error.ts'
|
|
12
|
+
import type { XenditProviderConfig } from './xendit_config.ts'
|
|
13
|
+
import { XenditPaymentDriver } from './xendit_driver.ts'
|
|
14
|
+
|
|
15
|
+
export class XenditPaymentProvider extends ServiceProvider {
|
|
16
|
+
override readonly name = 'payment-xendit'
|
|
17
|
+
override readonly dependencies = ['payment']
|
|
18
|
+
|
|
19
|
+
override register(app: Application): void {
|
|
20
|
+
const manager = app.resolve(PaymentManager)
|
|
21
|
+
manager.extend('xendit', ({ instanceName, config }) => {
|
|
22
|
+
const cfg = config as XenditProviderConfig
|
|
23
|
+
if (!cfg.secretKey) {
|
|
24
|
+
throw new PaymentConfigError(
|
|
25
|
+
`XenditPaymentProvider: \`secretKey\` is required for provider "${instanceName}".`,
|
|
26
|
+
{ context: { instanceName } },
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
return new XenditPaymentDriver({ instanceName, config: cfg })
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
}
|