@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 CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "@strav/payment",
3
- "version": "1.0.0-alpha.39",
4
- "description": "Strav payment module — provider-agnostic payment abstraction. Normalized DTOs, multi-provider routing, ledger schema, webhook dispatcher, Cashier-style Billable mixin. Stripe and Omise drivers ship as subpath imports (`@strav/payment/stripe`, `@strav/payment/omise`). Paddle support comes in a later release.",
3
+ "version": "1.0.0-alpha.42",
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.39",
25
- "@strav/http": "1.0.0-alpha.39",
26
- "@strav/kernel": "1.0.0-alpha.39",
25
+ "@strav/database": "1.0.0-alpha.42",
26
+ "@strav/http": "1.0.0-alpha.42",
27
+ "@strav/kernel": "1.0.0-alpha.42",
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'