@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
package/package.json
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/payment",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
4
|
-
"description": "Strav payment module — provider-agnostic payment abstraction. Normalized DTOs, multi-provider routing, ledger schema, webhook dispatcher, Cashier-style Billable mixin. Stripe and
|
|
3
|
+
"version": "1.0.0-alpha.40",
|
|
4
|
+
"description": "Strav payment module — provider-agnostic payment abstraction. Normalized DTOs, multi-provider routing, ledger schema, webhook dispatcher, Cashier-style Billable mixin. Stripe, Omise, and Xendit drivers ship as subpath imports (`@strav/payment/stripe`, `@strav/payment/omise`, `@strav/payment/xendit`). Paddle support comes in a later release.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
7
7
|
"types": "./src/index.ts",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": "./src/index.ts",
|
|
10
10
|
"./stripe": "./src/drivers/stripe/index.ts",
|
|
11
|
-
"./omise": "./src/drivers/omise/index.ts"
|
|
11
|
+
"./omise": "./src/drivers/omise/index.ts",
|
|
12
|
+
"./xendit": "./src/drivers/xendit/index.ts"
|
|
12
13
|
},
|
|
13
14
|
"files": [
|
|
14
15
|
"src",
|
|
@@ -21,9 +22,9 @@
|
|
|
21
22
|
"access": "public"
|
|
22
23
|
},
|
|
23
24
|
"dependencies": {
|
|
24
|
-
"@strav/database": "1.0.0-alpha.
|
|
25
|
-
"@strav/http": "1.0.0-alpha.
|
|
26
|
-
"@strav/kernel": "1.0.0-alpha.
|
|
25
|
+
"@strav/database": "1.0.0-alpha.40",
|
|
26
|
+
"@strav/http": "1.0.0-alpha.40",
|
|
27
|
+
"@strav/kernel": "1.0.0-alpha.40",
|
|
27
28
|
"stripe": "^18.0.0",
|
|
28
29
|
"omise": "^0.12.0"
|
|
29
30
|
},
|
|
@@ -78,6 +78,10 @@ const ALL_CAPS: readonly PaymentCapability[] = [
|
|
|
78
78
|
'charges.method.fps', 'charges.method.truemoney', 'charges.method.alipay',
|
|
79
79
|
'charges.method.wechat_pay', 'charges.method.grabpay', 'charges.method.kakaopay',
|
|
80
80
|
'charges.method.rabbit_linepay', 'charges.method.konbini',
|
|
81
|
+
'charges.method.qris', 'charges.method.gcash', 'charges.method.paymaya',
|
|
82
|
+
'charges.method.momo', 'charges.method.ovo', 'charges.method.dana',
|
|
83
|
+
'charges.method.shopeepay', 'charges.method.linkaja', 'charges.method.astrapay',
|
|
84
|
+
'charges.method.fpx', 'charges.method.direct_debit', 'charges.method.atome',
|
|
81
85
|
'charges.nextAction.display_qr', 'charges.nextAction.redirect',
|
|
82
86
|
'charges.nextAction.authorize', 'charges.nextAction.voucher',
|
|
83
87
|
'charges.nextAction.wait',
|
|
@@ -497,6 +501,7 @@ function mockNextActionFor(
|
|
|
497
501
|
case 'promptpay':
|
|
498
502
|
case 'paynow':
|
|
499
503
|
case 'fps':
|
|
504
|
+
case 'qris':
|
|
500
505
|
return {
|
|
501
506
|
kind: 'display_qr',
|
|
502
507
|
qrData: `mock-qr:${pm.kind}:${ulid()}`,
|
|
@@ -510,6 +515,17 @@ function mockNextActionFor(
|
|
|
510
515
|
case 'grabpay':
|
|
511
516
|
case 'kakaopay':
|
|
512
517
|
case 'rabbit_linepay':
|
|
518
|
+
case 'gcash':
|
|
519
|
+
case 'paymaya':
|
|
520
|
+
case 'momo':
|
|
521
|
+
case 'ovo':
|
|
522
|
+
case 'dana':
|
|
523
|
+
case 'shopeepay':
|
|
524
|
+
case 'linkaja':
|
|
525
|
+
case 'astrapay':
|
|
526
|
+
case 'fpx':
|
|
527
|
+
case 'direct_debit':
|
|
528
|
+
case 'atome':
|
|
513
529
|
return { kind: 'redirect', url }
|
|
514
530
|
default:
|
|
515
531
|
return { kind: 'wait' }
|
|
@@ -43,6 +43,9 @@ const OMISE_TYPES: Partial<Record<PaymentMethodSpec['kind'], string>> = {
|
|
|
43
43
|
wechat_pay: 'wechat_pay',
|
|
44
44
|
grabpay: 'grabpay',
|
|
45
45
|
rabbit_linepay: 'rabbit_linepay',
|
|
46
|
+
// Atome BNPL — Omise bridged in 2024. Redirect flow; the
|
|
47
|
+
// customer completes the instalment split inside the Atome app.
|
|
48
|
+
atome: 'atome',
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
export function buildOmiseMethodSpec(
|
|
@@ -71,6 +74,7 @@ export function omiseSourceFlowFor(kind: PaymentMethodSpec['kind']): 'offline' |
|
|
|
71
74
|
case 'wechat_pay':
|
|
72
75
|
case 'grabpay':
|
|
73
76
|
case 'rabbit_linepay':
|
|
77
|
+
case 'atome':
|
|
74
78
|
return 'redirect'
|
|
75
79
|
default:
|
|
76
80
|
return 'unknown'
|
|
@@ -85,4 +89,5 @@ export const OMISE_SUPPORTED_METHOD_KINDS: ReadonlyArray<PaymentMethodSpec['kind
|
|
|
85
89
|
'wechat_pay',
|
|
86
90
|
'grabpay',
|
|
87
91
|
'rabbit_linepay',
|
|
92
|
+
'atome',
|
|
88
93
|
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// `@strav/payment/xendit` — SEA-coverage driver (Xendit / Opn Payments).
|
|
2
|
+
|
|
3
|
+
export { XenditClient, type XenditClientOptions, type XenditRequestOptions } from './xendit_client.ts'
|
|
4
|
+
export {
|
|
5
|
+
XENDIT_DEFAULT_BASE_URL,
|
|
6
|
+
type XenditCountry,
|
|
7
|
+
type XenditProviderConfig,
|
|
8
|
+
} from './xendit_config.ts'
|
|
9
|
+
export {
|
|
10
|
+
type XenditDriverOptions,
|
|
11
|
+
XenditPaymentDriver,
|
|
12
|
+
} from './xendit_driver.ts'
|
|
13
|
+
export type {
|
|
14
|
+
XenditCardCharge,
|
|
15
|
+
XenditCustomer,
|
|
16
|
+
XenditEwalletCharge,
|
|
17
|
+
XenditInvoice,
|
|
18
|
+
XenditQrCharge,
|
|
19
|
+
XenditRefund,
|
|
20
|
+
} from './xendit_mappers.ts'
|
|
21
|
+
export {
|
|
22
|
+
type XenditChannel,
|
|
23
|
+
type XenditMethodPlan,
|
|
24
|
+
XENDIT_SUPPORTED_KINDS,
|
|
25
|
+
planXenditCharge,
|
|
26
|
+
} from './xendit_method_spec.ts'
|
|
27
|
+
export {
|
|
28
|
+
xenditEwalletNextAction,
|
|
29
|
+
type XenditEwalletResponse,
|
|
30
|
+
type XenditQrResponse,
|
|
31
|
+
xenditQrNextAction,
|
|
32
|
+
} from './xendit_next_action_mapper.ts'
|
|
33
|
+
export { XenditPaymentProvider } from './xendit_provider.ts'
|
|
34
|
+
export {
|
|
35
|
+
type XenditEvent,
|
|
36
|
+
xenditNormalize,
|
|
37
|
+
xenditVerify,
|
|
38
|
+
} from './xendit_webhook.ts'
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Xendit REST client — HTTP Basic auth, JSON envelopes, structured
|
|
3
|
+
* error wrapping. We intentionally do NOT depend on the official `xendit-node`
|
|
4
|
+
* SDK: it's CommonJS, has a heavy class-per-resource layout that doesn't
|
|
5
|
+
* compose well with the framework's driver pattern, and lags real API
|
|
6
|
+
* surfaces (e-wallets, QR codes) by months on its public release schedule.
|
|
7
|
+
*
|
|
8
|
+
* Auth: `Authorization: Basic base64(secret:)`. Yes, the trailing colon is
|
|
9
|
+
* required — Xendit follows the HTTP Basic convention even though only the
|
|
10
|
+
* username slot is used.
|
|
11
|
+
*
|
|
12
|
+
* Errors: Xendit returns 4xx/5xx with a JSON envelope:
|
|
13
|
+
*
|
|
14
|
+
* { "error_code": "INVALID_API_KEY", "message": "..." }
|
|
15
|
+
*
|
|
16
|
+
* We surface this verbatim on `cause` and pick a typed `PaymentProviderError`
|
|
17
|
+
* subclass based on the error_code class.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { PaymentProviderError, ProviderUnsupportedError } from '../../payment_error.ts'
|
|
21
|
+
|
|
22
|
+
export interface XenditClientOptions {
|
|
23
|
+
secretKey: string
|
|
24
|
+
baseUrl: string
|
|
25
|
+
fetchFn: typeof fetch
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface XenditRequestOptions {
|
|
29
|
+
method: 'GET' | 'POST' | 'PATCH' | 'DELETE'
|
|
30
|
+
path: string
|
|
31
|
+
body?: Record<string, unknown>
|
|
32
|
+
query?: Record<string, string | number | undefined>
|
|
33
|
+
headers?: Record<string, string>
|
|
34
|
+
/** Sent on POST as `idempotency-key`. */
|
|
35
|
+
idempotencyKey?: string
|
|
36
|
+
/** Required by Xendit's e-wallet + QR APIs for the "for-user" sub-account flow. */
|
|
37
|
+
forUserId?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface XenditErrorEnvelope {
|
|
41
|
+
error_code?: string
|
|
42
|
+
message?: string
|
|
43
|
+
errors?: unknown
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class XenditClient {
|
|
47
|
+
private readonly auth: string
|
|
48
|
+
private readonly baseUrl: string
|
|
49
|
+
private readonly fetchFn: typeof fetch
|
|
50
|
+
|
|
51
|
+
constructor(options: XenditClientOptions) {
|
|
52
|
+
if (!options.secretKey || options.secretKey.length === 0) {
|
|
53
|
+
throw new ProviderUnsupportedError(
|
|
54
|
+
'xendit',
|
|
55
|
+
'init',
|
|
56
|
+
{ reason: 'secretKey is required on the provider config.' },
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
this.auth = `Basic ${btoa(`${options.secretKey}:`)}`
|
|
60
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, '')
|
|
61
|
+
this.fetchFn = options.fetchFn
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async request<T = unknown>(options: XenditRequestOptions): Promise<T> {
|
|
65
|
+
const url = this.buildUrl(options.path, options.query)
|
|
66
|
+
const headers: Record<string, string> = {
|
|
67
|
+
authorization: this.auth,
|
|
68
|
+
'content-type': 'application/json',
|
|
69
|
+
accept: 'application/json',
|
|
70
|
+
...(options.headers ?? {}),
|
|
71
|
+
}
|
|
72
|
+
if (options.idempotencyKey) headers['idempotency-key'] = options.idempotencyKey
|
|
73
|
+
if (options.forUserId) headers['for-user-id'] = options.forUserId
|
|
74
|
+
const init: RequestInit = {
|
|
75
|
+
method: options.method,
|
|
76
|
+
headers,
|
|
77
|
+
}
|
|
78
|
+
if (options.body !== undefined && options.method !== 'GET') {
|
|
79
|
+
init.body = JSON.stringify(options.body)
|
|
80
|
+
}
|
|
81
|
+
const res = await this.fetchFn(url, init)
|
|
82
|
+
if (!res.ok) throw await this.toError(res, options)
|
|
83
|
+
if (res.status === 204) return undefined as T
|
|
84
|
+
return (await res.json()) as T
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private buildUrl(path: string, query?: Record<string, string | number | undefined>): string {
|
|
88
|
+
const url = new URL(`${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`)
|
|
89
|
+
if (query) {
|
|
90
|
+
for (const [k, v] of Object.entries(query)) {
|
|
91
|
+
if (v !== undefined) url.searchParams.set(k, String(v))
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return url.toString()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private async toError(res: Response, req: XenditRequestOptions): Promise<Error> {
|
|
98
|
+
let body: XenditErrorEnvelope | string
|
|
99
|
+
try {
|
|
100
|
+
body = (await res.json()) as XenditErrorEnvelope
|
|
101
|
+
} catch {
|
|
102
|
+
body = await res.text()
|
|
103
|
+
}
|
|
104
|
+
const code =
|
|
105
|
+
typeof body === 'object' && body && 'error_code' in body
|
|
106
|
+
? String(body.error_code ?? '')
|
|
107
|
+
: ''
|
|
108
|
+
const message =
|
|
109
|
+
typeof body === 'object' && body && 'message' in body
|
|
110
|
+
? String(body.message ?? '')
|
|
111
|
+
: `Xendit ${req.method} ${req.path} returned ${res.status}.`
|
|
112
|
+
|
|
113
|
+
const baseCtx: Record<string, unknown> = {
|
|
114
|
+
provider: 'xendit',
|
|
115
|
+
status: res.status,
|
|
116
|
+
path: req.path,
|
|
117
|
+
method: req.method,
|
|
118
|
+
}
|
|
119
|
+
if (code) baseCtx['errorCode'] = code
|
|
120
|
+
|
|
121
|
+
return new PaymentProviderError(message, {
|
|
122
|
+
provider: 'xendit',
|
|
123
|
+
operation: `${req.method} ${req.path}`,
|
|
124
|
+
status: res.status >= 500 ? 502 : res.status,
|
|
125
|
+
context: baseCtx,
|
|
126
|
+
cause: body,
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Xendit-specific provider config.
|
|
3
|
+
*
|
|
4
|
+
* Xendit's REST API uses HTTP Basic auth: the `secretKey` is the user, the
|
|
5
|
+
* password is empty (note the trailing colon in the Authorization header).
|
|
6
|
+
* Webhooks are *not* HMAC-signed — instead Xendit echoes a static
|
|
7
|
+
* `x-callback-token` header set in the dashboard, which the driver compares
|
|
8
|
+
* against `webhookToken` in constant time.
|
|
9
|
+
*
|
|
10
|
+
* `defaultCountry` is the ISO 3166-1 alpha-2 country code used as a default
|
|
11
|
+
* for endpoints that require one (e-wallet charges, QRIS). Apps that route
|
|
12
|
+
* payments per market pass `countryCode` on each create call to override.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ProviderConfig } from '../../types.ts'
|
|
16
|
+
|
|
17
|
+
export interface XenditProviderConfig extends ProviderConfig {
|
|
18
|
+
driver: 'xendit'
|
|
19
|
+
/** `xnd_development_...` / `xnd_production_...` — server-side secret. */
|
|
20
|
+
secretKey: string
|
|
21
|
+
/**
|
|
22
|
+
* Static webhook verification token from the Xendit dashboard. Compared
|
|
23
|
+
* verbatim against the `x-callback-token` header in constant time.
|
|
24
|
+
* Required for the webhook route; without it `webhook.verify` throws.
|
|
25
|
+
*/
|
|
26
|
+
webhookToken?: string
|
|
27
|
+
/**
|
|
28
|
+
* Default country for the multi-country APIs (e-wallets, QRIS, retail
|
|
29
|
+
* outlets). Per-call overrides via `paymentMethod` specifics still win.
|
|
30
|
+
* Defaults to `'ID'` when omitted — Xendit's largest market.
|
|
31
|
+
*/
|
|
32
|
+
defaultCountry?: XenditCountry
|
|
33
|
+
/**
|
|
34
|
+
* Default currency for charges. Indonesian Rupiah (`'IDR'`) when omitted.
|
|
35
|
+
* Set explicitly when the provider is bound for PH/MY/VN/TH/SG so apps
|
|
36
|
+
* don't have to repeat the currency on every call.
|
|
37
|
+
*/
|
|
38
|
+
defaultCurrency?: string
|
|
39
|
+
/** Override the API base URL (tests; sandbox vs production endpoints are the same URL). */
|
|
40
|
+
baseUrl?: string
|
|
41
|
+
/** Injectable `fetch` for tests. */
|
|
42
|
+
fetch?: typeof fetch
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type XenditCountry = 'ID' | 'PH' | 'VN' | 'MY' | 'TH' | 'SG'
|
|
46
|
+
|
|
47
|
+
export const XENDIT_DEFAULT_BASE_URL = 'https://api.xendit.co'
|