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