@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,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.
|