@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,240 @@
1
+ /**
2
+ * Xendit webhook verification + event normalization.
3
+ *
4
+ * Xendit doesn't HMAC-sign deliveries. Instead, every webhook delivery
5
+ * carries a static `x-callback-token` header that the dashboard exposes
6
+ * per-webhook. The driver compares it in constant time against the
7
+ * configured `webhookToken`. Apps that need higher assurance use the
8
+ * dashboard's IP allow-list AND token check (both layers in the same
9
+ * defence-in-depth posture as Stripe-signed deliveries).
10
+ *
11
+ * Event shapes vary by resource — `ewallet.*`, `qr.payment`,
12
+ * `payment_request.*`, `invoice.*`, `refund.*`, `customer.*`. We normalise
13
+ * the events that map cleanly onto the framework's `PaymentEventType`
14
+ * union; everything else returns `null` and the dispatcher skips user
15
+ * handlers (still records the dedup row).
16
+ */
17
+
18
+ import { timingSafeEqual } from 'node:crypto'
19
+ import type { NormalizedWebhookEvent, PaymentEventType } from '../../dto/payment_event.ts'
20
+ import { WebhookSignatureError } from '../../payment_error.ts'
21
+ import { readTenantId } from '../../tenant_metadata.ts'
22
+ import {
23
+ toPaymentChargeFromCard,
24
+ toPaymentChargeFromEwallet,
25
+ toPaymentChargeFromQr,
26
+ toPaymentCustomer,
27
+ toPaymentInvoice,
28
+ toPaymentRefund,
29
+ type XenditCardCharge,
30
+ type XenditCustomer,
31
+ type XenditEwalletCharge,
32
+ type XenditInvoice,
33
+ type XenditQrCharge,
34
+ type XenditRefund,
35
+ } from './xendit_mappers.ts'
36
+
37
+ export interface XenditEvent {
38
+ /** Top-level `id` is set on the newer event envelope (`/payment_request` events). */
39
+ id?: string
40
+ /** Some Xendit events use `event` as the type key; others put it in the body. */
41
+ event?: string
42
+ /** Older shape — e.g. `ewallet.capture` arrives with `status: 'SUCCEEDED'` on a charge object directly. */
43
+ status?: string
44
+ /** Older event ids land here on e-wallet / invoice payloads. */
45
+ external_id?: string
46
+ data?: unknown
47
+ created?: string
48
+ /** Wrapping object for events that carry the resource verbatim. */
49
+ [k: string]: unknown
50
+ }
51
+
52
+ const TYPE_MAP: Record<string, PaymentEventType> = {
53
+ // E-wallet
54
+ 'ewallet.capture': 'charge.succeeded',
55
+ 'ewallet.payment.succeeded': 'charge.succeeded',
56
+ 'ewallet.failure': 'charge.failed',
57
+ 'ewallet.payment.failed': 'charge.failed',
58
+ // QR
59
+ 'qr.payment': 'charge.succeeded',
60
+ 'qr.payment.succeeded': 'charge.succeeded',
61
+ // Card
62
+ 'credit_card_charge.succeeded': 'charge.succeeded',
63
+ 'credit_card_charge.failed': 'charge.failed',
64
+ // Newer Payments API
65
+ 'payment.succeeded': 'charge.succeeded',
66
+ 'payment.failed': 'charge.failed',
67
+ // Invoice
68
+ 'invoice.paid': 'invoice.paid',
69
+ 'invoice.expired': 'invoice.voided',
70
+ // Refund
71
+ 'refund.succeeded': 'charge.refunded',
72
+ // Customer
73
+ 'customer.created': 'customer.created',
74
+ 'customer.updated': 'customer.updated',
75
+ }
76
+
77
+ /**
78
+ * Compare `provided` to the configured token in constant time. Both inputs
79
+ * are treated as opaque ASCII; we don't impose an encoding.
80
+ */
81
+ export function xenditVerify(
82
+ rawBody: string,
83
+ callbackToken: string,
84
+ expectedToken: string | undefined,
85
+ ): XenditEvent {
86
+ if (!expectedToken) {
87
+ throw new WebhookSignatureError(
88
+ 'XenditPaymentDriver.webhook.verify: `webhookToken` is not set on the provider config.',
89
+ )
90
+ }
91
+ const a = new Uint8Array(Buffer.from(expectedToken, 'utf8'))
92
+ const b = new Uint8Array(Buffer.from(callbackToken.trim(), 'utf8'))
93
+ if (a.length === 0 || a.length !== b.length || !timingSafeEqual(a, b)) {
94
+ throw new WebhookSignatureError(
95
+ 'XenditPaymentDriver.webhook.verify: x-callback-token mismatch.',
96
+ )
97
+ }
98
+ try {
99
+ return JSON.parse(rawBody) as XenditEvent
100
+ } catch (cause) {
101
+ throw new WebhookSignatureError(
102
+ 'XenditPaymentDriver.webhook.verify: body is not valid JSON.',
103
+ { cause },
104
+ )
105
+ }
106
+ }
107
+
108
+ export function xenditNormalize(event: XenditEvent): NormalizedWebhookEvent | null {
109
+ const eventName = resolveEventName(event)
110
+ const type = TYPE_MAP[eventName ?? '']
111
+ if (!type) return null
112
+
113
+ const resource = extractResource(event)
114
+ const data: NormalizedWebhookEvent['data'] = {}
115
+ let fields: Record<string, unknown> | undefined
116
+
117
+ switch (type) {
118
+ case 'charge.succeeded':
119
+ case 'charge.failed':
120
+ case 'charge.refunded': {
121
+ const charge = mapChargeForEvent(eventName ?? '', resource)
122
+ if (charge) {
123
+ data.chargeId = charge.id
124
+ if (charge.customerId) data.customerId = charge.customerId
125
+ fields = { ...charge }
126
+ }
127
+ break
128
+ }
129
+ case 'invoice.paid':
130
+ case 'invoice.voided': {
131
+ if (isInvoice(resource)) {
132
+ const dto = toPaymentInvoice(resource)
133
+ data.invoiceId = dto.id
134
+ if (dto.customerId) data.customerId = dto.customerId
135
+ fields = { ...dto }
136
+ }
137
+ break
138
+ }
139
+ case 'customer.created':
140
+ case 'customer.updated': {
141
+ if (isCustomer(resource)) {
142
+ const dto = toPaymentCustomer(resource)
143
+ data.customerId = dto.id
144
+ fields = { ...dto }
145
+ }
146
+ break
147
+ }
148
+ }
149
+
150
+ const tenantId = readTenantId(
151
+ (resource as { metadata?: Record<string, unknown> } | undefined)?.metadata ?? null,
152
+ )
153
+
154
+ const normalized: NormalizedWebhookEvent = {
155
+ id: event.id ?? event.external_id ?? `xenev_${Date.now()}`,
156
+ type,
157
+ provider: 'xendit',
158
+ raw: event,
159
+ data,
160
+ ...(tenantId ? { tenantId } : {}),
161
+ }
162
+ if (fields) {
163
+ ;(normalized as { _fields?: unknown })._fields = fields
164
+ }
165
+ return normalized
166
+ }
167
+
168
+ function resolveEventName(event: XenditEvent): string | undefined {
169
+ if (typeof event.event === 'string') return event.event
170
+ // Older webhook deliveries put the event name in the URL path the
171
+ // dashboard configures; the body itself doesn't carry it. Apps that
172
+ // route everything to one URL pass the dashboard-configured event name
173
+ // through as `event` in their handler; nothing else to do here.
174
+ return undefined
175
+ }
176
+
177
+ function extractResource(event: XenditEvent): unknown {
178
+ // Newer envelope wraps under `data`. Older shapes ship the resource
179
+ // verbatim at the top level (the event IS the charge / invoice).
180
+ if (event.data !== undefined && event.data !== null) return event.data
181
+ return event
182
+ }
183
+
184
+ function mapChargeForEvent(eventName: string, resource: unknown): ReturnType<
185
+ typeof toPaymentChargeFromEwallet
186
+ > | null {
187
+ if (!resource || typeof resource !== 'object') return null
188
+ if (eventName.startsWith('ewallet.')) {
189
+ return toPaymentChargeFromEwallet(resource as XenditEwalletCharge)
190
+ }
191
+ if (eventName.startsWith('qr.')) {
192
+ return toPaymentChargeFromQr(resource as XenditQrCharge)
193
+ }
194
+ if (eventName.startsWith('credit_card_charge.')) {
195
+ return toPaymentChargeFromCard(resource as XenditCardCharge)
196
+ }
197
+ if (eventName === 'refund.succeeded') {
198
+ const refund = resource as XenditRefund
199
+ const dto = toPaymentRefund(refund)
200
+ // Coerce the refund DTO into the charge-shaped slot the dispatcher
201
+ // expects on `charge.refunded` events. Apps reach `.raw` for the
202
+ // refund-specific fields.
203
+ return {
204
+ id: dto.chargeId || dto.id,
205
+ provider: 'xendit',
206
+ customerId: null,
207
+ amount: dto.amount,
208
+ currency: dto.currency,
209
+ status: 'refunded',
210
+ paymentMethodId: null,
211
+ failureCode: null,
212
+ failureMessage: null,
213
+ nextAction: null,
214
+ metadata: {},
215
+ createdAt: dto.createdAt,
216
+ raw: refund,
217
+ }
218
+ }
219
+ // Newer Payments API events — we don't yet know the precise shape, so
220
+ // surface what we can without forcing the charge mapping.
221
+ return null
222
+ }
223
+
224
+ function isInvoice(resource: unknown): resource is XenditInvoice {
225
+ return (
226
+ typeof resource === 'object' &&
227
+ resource !== null &&
228
+ 'invoice_url' in resource &&
229
+ 'amount' in resource
230
+ )
231
+ }
232
+
233
+ function isCustomer(resource: unknown): resource is XenditCustomer {
234
+ return (
235
+ typeof resource === 'object' &&
236
+ resource !== null &&
237
+ 'id' in resource &&
238
+ ('individual_detail' in resource || 'business_detail' in resource || 'reference_id' in resource)
239
+ )
240
+ }
@@ -73,6 +73,28 @@ export type PaymentMethodSpec =
73
73
  | { kind: 'kakaopay' }
74
74
  | { kind: 'rabbit_linepay' }
75
75
  | { kind: 'konbini'; phoneNumber?: string }
76
+ // Xendit-flavoured SEA methods. `mobileNumber` is required where the
77
+ // provider needs to push a consent prompt to the customer's app
78
+ // (GCash / OVO / Dana / MoMo / ShopeePay). QRIS is offline-display.
79
+ | { kind: 'qris' }
80
+ | { kind: 'gcash'; mobileNumber?: string }
81
+ | { kind: 'paymaya'; mobileNumber?: string }
82
+ | { kind: 'momo'; mobileNumber?: string }
83
+ | { kind: 'ovo'; mobileNumber: string }
84
+ | { kind: 'dana'; mobileNumber?: string }
85
+ | { kind: 'shopeepay'; mobileNumber?: string }
86
+ | { kind: 'linkaja'; mobileNumber?: string }
87
+ | { kind: 'astrapay'; mobileNumber?: string }
88
+ // Malaysia online banking (FPX) — `bankCode` selects the issuing
89
+ // bank from Xendit's enumerated list (`'BCA'` / `'BRI'` / etc.
90
+ // for Indonesia; FPX banks for Malaysia). Provider validates the
91
+ // code at create time.
92
+ | { kind: 'fpx'; bankCode?: string }
93
+ | { kind: 'direct_debit'; channelCode: string; accountId?: string }
94
+ // BNPL — Atome is cross-SEA (SG / MY / ID / TH / PH / VN / HK).
95
+ // Omise and Xendit both bridge it as a redirect flow; the customer
96
+ // splits the charge into instalments inside the Atome app.
97
+ | { kind: 'atome' }
76
98
 
77
99
  export interface PaymentCharge {
78
100
  id: string
@@ -59,6 +59,18 @@ export type PaymentCapability =
59
59
  | 'charges.method.kakaopay'
60
60
  | 'charges.method.rabbit_linepay'
61
61
  | 'charges.method.konbini'
62
+ | 'charges.method.qris'
63
+ | 'charges.method.gcash'
64
+ | 'charges.method.paymaya'
65
+ | 'charges.method.momo'
66
+ | 'charges.method.ovo'
67
+ | 'charges.method.dana'
68
+ | 'charges.method.shopeepay'
69
+ | 'charges.method.linkaja'
70
+ | 'charges.method.astrapay'
71
+ | 'charges.method.fpx'
72
+ | 'charges.method.direct_debit'
73
+ | 'charges.method.atome'
62
74
  // charges — next-action kinds the driver can emit. Apps that
63
75
  // host their own QR / redirect UI check these to know what
64
76
  // shapes they need to handle.