UNPKG

@gpmpay/sdk

Version:

Official Node.js SDK for GPM Pay — VietQR codes, transaction webhooks, and payment reconciliation.

1,271 lines (1,248 loc) • 41.3 kB
'use strict'; var crypto = require('crypto'); // src/resources/api-tokens.ts var ApiTokensResource = class { constructor(http) { this.http = http; } http; /** * Permanently delete a token. Any valid token may call this; the backend * still requires the token being deleted to belong to the caller. */ remove(id, options = {}) { return this.http.request( "DELETE", `/api-tokens/${encodeURIComponent(id)}`, options ); } }; // src/resources/bank-accounts.ts var BankAccountsResource = class { constructor(http) { this.http = http; } http; list(params = {}, options = {}) { return this.http.requestList("GET", "/bank-accounts", { ...options, query: params }); } retrieve(id, options = {}) { return this.http.request( "GET", `/bank-accounts/${encodeURIComponent(id)}`, options ); } }; // src/resources/banks.ts var BanksResource = class { constructor(http) { this.http = http; } http; /** List active banks. The endpoint returns a flat array, not a page. */ async list(options = {}) { const page = await this.http.requestList("GET", "/banks", options); return page.data; } }; // src/core/errors.ts var BRAND = /* @__PURE__ */ Symbol.for("gpmpay.sdk.error"); var GpmPayError = class extends Error { code; /** @internal Cross-realm brand — `instanceof` breaks across CJS/ESM copies. */ [BRAND] = true; constructor(message, code) { super(message); this.name = new.target.name; this.code = code; Object.setPrototypeOf(this, new.target.prototype); } /** * Prefer this over `instanceof` when a dual CJS/ESM install could put two * copies of the class in one process. */ static isGpmPayError(value) { return typeof value === "object" && value !== null && value[BRAND] === true; } }; var GpmPayConfigError = class extends GpmPayError { constructor(message, code = "invalid_argument") { super(message, code); } }; var GpmPayConnectionError = class extends GpmPayError { /** Overrides the standard `Error.cause` so it is always populated here. */ cause; /** Node's `err.cause.code`, e.g. `ECONNREFUSED`, `ENOTFOUND`. */ syscallCode; constructor(message, cause, syscallCode) { super(message, "connection_error"); this.cause = cause; this.syscallCode = syscallCode; } }; var GpmPayTimeoutError = class extends GpmPayError { timeoutMs; constructor(message, timeoutMs) { super(message, "timeout"); this.timeoutMs = timeoutMs; } }; var GpmPayWebhookSignatureError = class extends GpmPayError { reason; constructor(message, reason) { super(message, "webhook_signature"); this.reason = reason; } }; var GpmPayAPIError = class extends GpmPayError { status; /** Correlation id sent as `X-GPMPay-Request-Id`. Quote it to support. */ requestId; rawBody; rawMessage; constructor(message, ctx, code = "api_error") { super(message, code); this.status = ctx.status; this.requestId = ctx.requestId; this.rawBody = ctx.rawBody; this.rawMessage = ctx.rawMessage; } }; var GpmPayBadRequestError = class extends GpmPayAPIError { /** One entry per failed constraint, as produced by Nest's ValidationPipe. */ validationMessages; constructor(message, ctx) { super(message, ctx, "bad_request"); this.validationMessages = ctx.validationMessages; } }; var GpmPayAuthenticationError = class extends GpmPayAPIError { reason; constructor(message, ctx) { super(message, ctx, "authentication_error"); this.reason = ctx.reason; } }; var GpmPayPermissionError = class extends GpmPayAPIError { missingScope; reason; constructor(message, ctx) { super(message, ctx, "permission_error"); this.missingScope = ctx.missingScope; this.reason = ctx.reason; } }; var GpmPayNotFoundError = class extends GpmPayAPIError { /** Resource name parsed out of e.g. `"Order not found"`. */ resource; constructor(message, ctx) { super(message, ctx, "not_found"); this.resource = ctx.resource; } }; var GpmPayRateLimitError = class extends GpmPayAPIError { retryAfterSeconds; constructor(message, ctx) { super(message, ctx, "rate_limit"); this.retryAfterSeconds = ctx.retryAfterSeconds; } }; var GpmPayServerError = class extends GpmPayAPIError { constructor(message, ctx) { super(message, ctx, "server_error"); } }; var AUTH_REASONS = [ [/missing bearer token/i, "missing_bearer"], [/invalid token format/i, "invalid_format"], [/token is not active/i, "token_inactive"], [/token[_ ]expired/i, "token_expired"], [/user inactive/i, "user_inactive"], [/invalid token/i, "invalid_token"] ]; function extractMessage(body) { if (typeof body === "string" && body.trim() !== "") return body; if (body !== null && typeof body === "object") { const b = body; if (Array.isArray(b.message) || typeof b.message === "string") { return b.message; } if (typeof b.error === "string") return b.error; } return ""; } function errorFromResponse(status, body, requestId, extra = {}) { const raw = extractMessage(body); const msg = (Array.isArray(raw) ? raw.join("; ") : raw) || `HTTP ${String(status)}`; const ctx = { status, requestId, rawBody: body, rawMessage: raw === "" ? msg : raw }; switch (status) { case 400: return new GpmPayBadRequestError(msg, { ...ctx, validationMessages: Array.isArray(raw) ? raw : [msg] }); case 401: { const reason = AUTH_REASONS.find(([re]) => re.test(msg))?.[1] ?? "unknown"; return new GpmPayAuthenticationError( `${msg} \u2014 check your API token is correct, ACTIVE and not expired.`, { ...ctx, reason } ); } case 403: { const scope = /missing scope:\s*(\S+)/i.exec(msg)?.[1]; if (scope) { return new GpmPayPermissionError( `API token is missing the "${scope}" scope. Regenerate the token with that scope enabled.`, { ...ctx, missingScope: scope, reason: "scope" } ); } if (/not available to api tokens/i.test(msg)) { return new GpmPayPermissionError( `${msg} \u2014 this endpoint is dashboard-only and no API token scope grants it.`, { ...ctx, reason: "endpoint" } ); } return new GpmPayPermissionError( `${msg} \u2014 the resource exists but belongs to another account.`, { ...ctx, reason: "ownership" } ); } case 404: { const resource = /^(.*?)\s+not found/i.exec(msg)?.[1]; return new GpmPayNotFoundError( msg, resource === void 0 ? ctx : { ...ctx, resource } ); } // No 409 case: every route this SDK can reach is either a read or a write // with no uniqueness constraint, so a conflict has no source. A 409 from // `client.request()` falls through to the generic GpmPayAPIError below. case 429: return new GpmPayRateLimitError( msg, extra.retryAfterSeconds === void 0 ? ctx : { ...ctx, retryAfterSeconds: extra.retryAfterSeconds } ); default: return status >= 500 ? new GpmPayServerError(msg, ctx) : new GpmPayAPIError(msg, ctx); } } // src/types/common.ts var MAX_PAGE_SIZE = 50; // src/core/query.ts var warnedAboutLimit = false; function serializeValue(value) { if (value === void 0 || value === null || value === "") return null; if (value instanceof Date) return value.toISOString(); if (typeof value === "boolean") return value ? "true" : "false"; if (typeof value === "number") { return Number.isFinite(value) ? String(value) : null; } if (typeof value === "string") return value; return JSON.stringify(value); } function buildQuery(params) { if (!params) return ""; const search = new URLSearchParams(); for (const [key, rawValue] of Object.entries(params)) { if (rawValue === void 0 || rawValue === null) continue; if (key === "limit") { const limit = Number(rawValue); if (!Number.isFinite(limit)) continue; if (limit > MAX_PAGE_SIZE) { if (!warnedAboutLimit) { warnedAboutLimit = true; console.warn( `[@gpmpay/sdk] limit=${String(limit)} exceeds the API maximum of ${String(MAX_PAGE_SIZE)}; requesting ${String(MAX_PAGE_SIZE)} instead. Paginate with \`page\`, or use listAll().` ); } search.append("limit", String(MAX_PAGE_SIZE)); } else { search.append("limit", String(limit)); } continue; } if (key === "filters" && typeof rawValue === "object") { search.append("filters", JSON.stringify(rawValue)); continue; } if (Array.isArray(rawValue)) { for (const item of rawValue) { const serialized2 = serializeValue(item); if (serialized2 !== null) search.append(key, serialized2); } continue; } const serialized = serializeValue(rawValue); if (serialized !== null) search.append(key, serialized); } const qs = search.toString(); return qs === "" ? "" : `?${qs}`; } function toIsoString(value) { if (value === void 0 || value === null) return void 0; return value instanceof Date ? value.toISOString() : value; } // src/resources/simulator.ts function isNonProductionBaseUrl(baseUrl) { return /localhost|127\.0\.0\.1|sandbox|\.local(?::|\/|$)/i.test(baseUrl); } var SimulatorResource = class { constructor(http) { this.http = http; } http; assertSafeEnvironment(options) { if (options.allowOnProduction === true) return; if (isNonProductionBaseUrl(this.http.baseUrl)) return; throw new GpmPayConfigError( `simulator: refusing to create a simulated transaction against ${this.http.baseUrl}. Point the client at localhost or the sandbox (\`new GpmPay({ sandbox: true })\`), or pass { allowOnProduction: true } if you really mean it.` ); } /** * Create a simulated bank transaction. * * Returns the envelope the route actually sends — `{ transaction, * historyIds }`, not a bare `Transaction`. Read `result.transaction` for the * ledger row and `result.historyIds.length` to confirm a webhook was queued. * * `async` so guard failures reject rather than throwing synchronously. */ async createTransaction(params, options = {}) { this.assertSafeEnvironment(options); if (!Number.isInteger(params.amount) || params.amount < 1) { throw new GpmPayConfigError( `simulator: amount must be an integer number of VND >= 1, received ${String(params.amount)}.` ); } const body = { bankAccountId: params.bankAccountId, amount: params.amount, transferContent: params.transferContent }; if (params.type !== void 0) body.type = params.type; if (params.referenceCode !== void 0) { body.referenceCode = params.referenceCode; } if (params.counterAccount !== void 0) { body.counterAccount = params.counterAccount; } if (params.counterName !== void 0) body.counterName = params.counterName; const transactionTime = toIsoString(params.transactionTime); if (transactionTime !== void 0) body.transactionTime = transactionTime; const { allowOnProduction: _ignored, ...requestOptions } = options; return await this.http.request( "POST", "/simulator/transactions", { ...requestOptions, body } ); } }; // src/resources/transactions.ts var TransactionsResource = class { constructor(http) { this.http = http; } http; /** * List transactions across your bank accounts. Scope: `transactions:read`. * * @remarks * `limit` is capped at 50 server-side. `search` matches `referenceCode` and * `transferContent`; `startDate`/`endDate` filter on `transactionTime`. */ list(params = {}, options = {}) { return this.http.requestList("GET", "/transactions", { ...options, query: params }); } /** * Iterate every matching transaction, fetching pages as needed. * * @example * for await (const txn of client.transactions.listAll({ type: 'IN' })) { * console.log(txn.referenceCode); * } */ async *listAll(params = {}, options = {}) { let page = 1; for (; ; ) { const result = await this.list({ ...params, page }, options); for (const txn of result.data) yield txn; if (result.data.length === 0) return; if (page >= result.meta.totalPages) return; page++; } } /** Fetch one transaction. Scope: `transactions:read`. */ retrieve(id, options = {}) { return this.http.request( "GET", `/transactions/${encodeURIComponent(id)}`, options ); } }; // src/resources/webhook-histories.ts var WebhookHistoriesResource = class { constructor(http) { this.http = http; } http; list(params = {}, options = {}) { return this.http.requestList("GET", "/webhook-histories", { ...options, query: params }); } retrieve(id, options = {}) { return this.http.request( "GET", `/webhook-histories/${encodeURIComponent(id)}`, options ); } /** Re-enqueue a failed delivery. */ retry(id, options = {}) { return this.http.request( "POST", `/webhook-histories/${encodeURIComponent(id)}/retry`, options ); } }; var WebhookSettingsResource = class { constructor(http) { this.http = http; } http; list(params = {}, options = {}) { return this.http.requestList("GET", "/webhook-settings", { ...options, query: params }); } retrieve(id, options = {}) { return this.http.request( "GET", `/webhook-settings/${encodeURIComponent(id)}`, options ); } create(params, options = {}) { return this.http.request("POST", "/webhook-settings", { ...options, body: params }); } update(id, params, options = {}) { return this.http.request( "PATCH", `/webhook-settings/${encodeURIComponent(id)}`, { ...options, body: params } ); } remove(id, options = {}) { return this.http.request( "DELETE", `/webhook-settings/${encodeURIComponent(id)}`, options ); } /** * Register an HTTP endpoint with HMAC signing and hand back the secret. * * Pair the returned secret with `verifyWebhookSignature` from * `@gpmpay/sdk/webhooks`. */ async createHmacEndpoint(params, options = {}) { const secret = params.secret ?? crypto.randomBytes(32).toString("hex"); const base = { driver: "HTTP", url: params.url, authorizationType: "HMAC", authorizationSecret: secret, ...params.name === void 0 ? {} : { name: params.name } }; const body = params.scope === "SPECIFIC" ? { ...base, scope: "SPECIFIC", bankAccountIds: params.bankAccountIds ?? [] } : { ...base, scope: "ALL" }; const setting = await this.create(body, options); return { setting, secret }; } }; // src/core/token.ts var API_TOKEN_PREFIX = "gpm_"; var API_TOKEN_RE = /^gpm_[A-Za-z0-9_-]{8}_[A-Za-z0-9_-]{24}$/; var API_TOKEN_LENGTH = 37; var PREFIX_LENGTH = 12; var CREATE_TOKEN_URL = "https://app.gpmpay.com/api-tokens"; var MSG_MISSING = `apiToken is required \u2014 the GPM Pay SDK cannot be used without one. 1. Create a token at ${CREATE_TOKEN_URL} 2. Pass it explicitly: new GpmPay({ apiToken: "gpm_..." }) or set the GPMPAY_API_TOKEN environment variable and use GpmPay.fromEnv().`; function assertApiToken(raw, strict = true) { if (raw === void 0 || raw === null) { throw new GpmPayConfigError(MSG_MISSING, "missing_api_token"); } if (typeof raw !== "string") { throw new GpmPayConfigError( `apiToken must be a string, received ${typeof raw}. ${MSG_MISSING}`, "invalid_api_token" ); } const token = raw.trim(); if (token === "") { throw new GpmPayConfigError(MSG_MISSING, "missing_api_token"); } if (token.startsWith("Bearer ")) { throw new GpmPayConfigError( 'apiToken must not include the "Bearer " prefix \u2014 pass the raw gpm_\u2026 token; the SDK adds the Authorization header itself.', "invalid_api_token" ); } if (strict && !API_TOKEN_RE.test(token)) { throw new GpmPayConfigError( `apiToken has an unexpected format (got "${maskToken(token)}", ${String(token.length)} chars). Expected gpm_XXXXXXXX_\u2026 (${String(API_TOKEN_LENGTH)} chars). Create a fresh token at ${CREATE_TOKEN_URL}. If you are using a newer token format, pass { strictTokenFormat: false }.`, "invalid_api_token_format" ); } return token; } function tokenPrefix(token) { return token.slice(0, PREFIX_LENGTH); } function maskToken(token) { if (token.length <= PREFIX_LENGTH) return "gpm_***"; return `${token.slice(0, PREFIX_LENGTH)}${"\u2022".repeat(8)}`; } // src/core/config.ts var DEFAULT_BASE_URL = "https://api.gpmpay.com"; var SANDBOX_BASE_URL = "https://sandbox-api.gpmpay.com"; var DEFAULT_TIMEOUT_MS = 3e4; var DEFAULT_MAX_RETRIES = 2; var DEFAULT_RETRY_BASE_DELAY_MS = 500; function normalizeBaseUrl(input) { if (typeof input !== "string") { throw new GpmPayConfigError("baseUrl must be a string.", "invalid_base_url"); } let base = input.trim().replace(/\/+$/, ""); if (base === "") { throw new GpmPayConfigError("baseUrl is empty.", "invalid_base_url"); } if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(base)) { base = `https://${base}`; } let parsed; try { parsed = new URL(base); } catch { throw new GpmPayConfigError( `baseUrl is not a valid URL: ${input}`, "invalid_base_url" ); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw new GpmPayConfigError( `baseUrl must use http or https, got "${parsed.protocol}".`, "invalid_base_url" ); } if (/\/api\/v\d+$/.test(base)) return base; if (/\/api$/.test(base)) return `${base}/v1`; return `${base}/api/v1`; } function resolveFetch(injected) { if (injected) return injected; if (typeof globalThis.fetch === "function") { return globalThis.fetch.bind(globalThis); } throw new GpmPayConfigError( "global fetch is unavailable. @gpmpay/sdk requires Node 18.17+, or pass a fetch implementation via { fetch }.", "invalid_argument" ); } function resolveConfig(options) { if (options === void 0 || options === null) { return resolveConfig({ apiToken: void 0 }); } const apiToken = assertApiToken( options.apiToken, options.strictTokenFormat ?? true ); const rawBase = options.baseUrl ?? (options.sandbox === true ? SANDBOX_BASE_URL : DEFAULT_BASE_URL); const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { throw new GpmPayConfigError( `timeoutMs must be a positive number, got ${String(options.timeoutMs)}.`, "invalid_argument" ); } const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES; if (!Number.isInteger(maxRetries) || maxRetries < 0) { throw new GpmPayConfigError( `maxRetries must be a non-negative integer, got ${String(options.maxRetries)}.`, "invalid_argument" ); } const defaultHeaders = {}; for (const [key, value] of Object.entries(options.defaultHeaders ?? {})) { if (key.toLowerCase() === "authorization") continue; defaultHeaders[key] = value; } return { apiToken, baseUrl: normalizeBaseUrl(rawBase), timeoutMs, maxRetries, retryBaseDelayMs: options.retryBaseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS, fetchImpl: resolveFetch(options.fetch), userAgent: options.userAgent, defaultHeaders, onRequest: options.onRequest, onResponse: options.onResponse }; } // src/version.ts var VERSION = "0.4.0"; // src/core/envelope.ts function unwrapEnvelope(body) { if (body !== null && typeof body === "object" && !Array.isArray(body) && "statusCode" in body && "data" in body) { return body.data; } return body; } function syntheticMeta(count) { return { page: 1, limit: count, totalItems: count, totalPages: count > 0 ? 1 : 0 }; } function unwrapList(data) { if (Array.isArray(data)) { return { data, meta: syntheticMeta(data.length) }; } if (data !== null && typeof data === "object") { const obj = data; const items = Array.isArray(obj.data) ? obj.data : Array.isArray(obj.items) ? obj.items : null; if (items) { const meta = obj.meta; const fallback = syntheticMeta(items.length); return { data: items, meta: { page: meta?.page ?? fallback.page, limit: meta?.limit ?? fallback.limit, totalItems: meta?.totalItems ?? fallback.totalItems, totalPages: meta?.totalPages ?? fallback.totalPages } }; } } return { data: [], meta: { page: 1, limit: 0, totalItems: 0, totalPages: 0 } }; } function toVnd(amount) { if (amount === null || amount === void 0) return 0; const n = typeof amount === "number" ? amount : Number(amount); return Number.isFinite(n) ? n : 0; } function formatVnd(amount) { return new Intl.NumberFormat("vi-VN", { style: "currency", currency: "VND", maximumFractionDigits: 0 }).format(toVnd(amount)); } // src/core/retry.ts var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([408, 425, 429, 500, 502, 503, 504]); var IDEMPOTENT_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "DELETE"]); var MAX_BACKOFF_MS = 8e3; function shouldRetry(params) { const { method, status, isConnectionError, attempt, maxRetries } = params; if (attempt >= maxRetries) return false; const idempotent = IDEMPOTENT_METHODS.has(method); if (isConnectionError === true) return idempotent; if (status === void 0) return false; if (status === 429) return true; if (!RETRYABLE_STATUSES.has(status)) return false; return idempotent; } function parseRetryAfter(headerValue2, now = Date.now()) { if (headerValue2 === null || headerValue2 === void 0) return void 0; const trimmed = headerValue2.trim(); if (trimmed === "") return void 0; if (/^\d+$/.test(trimmed)) { return Number(trimmed); } const asDate = Date.parse(trimmed); if (Number.isNaN(asDate)) return void 0; return Math.max(0, Math.ceil((asDate - now) / 1e3)); } function backoffDelayMs(params) { const { attempt, baseDelayMs, retryAfterSeconds, random = Math.random } = params; if (retryAfterSeconds !== void 0) { return Math.max(0, retryAfterSeconds * 1e3); } const ceiling = Math.min(baseDelayMs * Math.pow(2, attempt), MAX_BACKOFF_MS); return Math.floor(random() * ceiling); } // src/core/signal.ts function linkSignals(external, timeoutMs) { const controller = new AbortController(); let reason = null; const onExternal = () => { if (reason === null) reason = "external"; controller.abort(); }; if (external?.aborted === true) { onExternal(); } else if (external) { external.addEventListener("abort", onExternal, { once: true }); } const timer = setTimeout(() => { if (reason === null) reason = "timeout"; controller.abort(); }, timeoutMs); timer.unref?.(); return { signal: controller.signal, getReason: () => reason, dispose: () => { clearTimeout(timer); external?.removeEventListener("abort", onExternal); } }; } var AbortError = class extends Error { name = "AbortError"; constructor(message = "The operation was aborted.") { super(message); } }; function abortableSleep(ms, signal) { return new Promise((resolve, reject) => { if (signal?.aborted === true) { reject(new AbortError()); return; } const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms); function onAbort() { clearTimeout(timer); reject(new AbortError()); } signal?.addEventListener("abort", onAbort, { once: true }); }); } // src/core/http.ts var REQUEST_ID_HEADER = "X-GPMPay-Request-Id"; function buildUserAgent(suffix) { const base = `@gpmpay/sdk/${VERSION} (node/${process.version}; ${process.platform})`; return suffix === void 0 || suffix === "" ? base : `${base} ${suffix}`; } function syscallCodeOf(error) { const cause = error?.cause; const code = cause?.code; return typeof code === "string" ? code : void 0; } var HttpClient = class { config; userAgent; constructor(config) { this.config = config; this.userAgent = buildUserAgent(config.userAgent); } get baseUrl() { return this.config.baseUrl; } /** Issue a request and return the unwrapped `data` payload. */ async request(method, path, options = {}) { const raw = await this.send(method, path, options); return unwrapEnvelope(raw); } /** Issue a list request and normalize it into a `Page<T>`. */ async requestList(method, path, options = {}) { const raw = await this.send(method, path, options); return unwrapList(unwrapEnvelope(raw)); } async send(method, path, options) { const url = `${this.config.baseUrl}${path}${buildQuery(options.query)}`; const requestId = crypto.randomUUID(); const timeoutMs = options.timeoutMs ?? this.config.timeoutMs; const hasBody = options.body !== void 0; const headers = { ...this.config.defaultHeaders, Accept: "application/json", "User-Agent": this.userAgent, [REQUEST_ID_HEADER]: requestId, ...options.headers, // Applied last: Authorization is not user-overridable. Authorization: `Bearer ${this.config.apiToken}` }; if (hasBody) headers["Content-Type"] = "application/json"; const init = { method, headers }; if (hasBody) init.body = JSON.stringify(options.body); for (let attempt = 0; ; attempt++) { const link = linkSignals(options.signal, timeoutMs); const startedAt = Date.now(); this.config.onRequest?.({ method, url, requestId, attempt }); let response; try { response = await this.config.fetchImpl(url, { ...init, signal: link.signal }); } catch (error) { link.dispose(); if (link.getReason() === "timeout") { if (shouldRetry({ method, isConnectionError: true, attempt, maxRetries: this.config.maxRetries })) { await this.waitBeforeRetry(attempt, void 0, options.signal); continue; } throw new GpmPayTimeoutError( `Request timed out after ${String(timeoutMs)}ms: ${method} ${path}`, timeoutMs ); } if (link.getReason() === "external") { throw new AbortError(`Request aborted by caller: ${method} ${path}`); } if (shouldRetry({ method, isConnectionError: true, attempt, maxRetries: this.config.maxRetries })) { await this.waitBeforeRetry(attempt, void 0, options.signal); continue; } const syscall = syscallCodeOf(error); throw new GpmPayConnectionError( `Could not reach the GPM Pay API at ${this.config.baseUrl}` + (syscall === void 0 ? "" : ` (${syscall})`) + ". Check the baseUrl and your network connection.", error, syscall ); } link.dispose(); this.config.onResponse?.({ requestId, status: response.status, durationMs: Date.now() - startedAt, attempt }); if (response.ok) { return await parseBody(response); } const retryAfterSeconds = parseRetryAfter( response.headers.get("retry-after") ); if (shouldRetry({ method, status: response.status, attempt, maxRetries: this.config.maxRetries })) { await this.waitBeforeRetry(attempt, retryAfterSeconds, options.signal); continue; } const errorBody = await parseBody(response).catch(() => void 0); throw errorFromResponse( response.status, errorBody, requestId, retryAfterSeconds === void 0 ? {} : { retryAfterSeconds } ); } } async waitBeforeRetry(attempt, retryAfterSeconds, signal) { const delay = backoffDelayMs({ attempt, baseDelayMs: this.config.retryBaseDelayMs, retryAfterSeconds }); if (delay > 0) await abortableSleep(delay, signal); } }; async function parseBody(response) { if (response.status === 204 || response.status === 205) return void 0; if (response.headers.get("content-length") === "0") return void 0; const contentType = response.headers.get("content-type") ?? ""; const text = await response.text(); if (text === "") return void 0; if (contentType.includes("json")) { return JSON.parse(text); } try { return JSON.parse(text); } catch { return text; } } // src/core/client.ts var SCOPE_PROBES = [ { scope: "transactions:read", path: "/transactions" }, { scope: "bank-accounts:read", path: "/bank-accounts" }, { scope: "webhooks:manage", path: "/webhook-settings" } ]; var GpmPay = class _GpmPay { transactions; bankAccounts; banks; webhookSettings; webhookHistories; apiTokens; simulator; http; token; /** @throws {GpmPayConfigError} when `apiToken` is missing or malformed. */ constructor(options) { const config = resolveConfig(options); this.token = config.apiToken; this.http = new HttpClient(config); this.transactions = new TransactionsResource(this.http); this.bankAccounts = new BankAccountsResource(this.http); this.banks = new BanksResource(this.http); this.webhookSettings = new WebhookSettingsResource(this.http); this.webhookHistories = new WebhookHistoriesResource(this.http); this.apiTokens = new ApiTokensResource(this.http); this.simulator = new SimulatorResource(this.http); } /** * Build a client from `GPMPAY_API_TOKEN` and `GPMPAY_API_URL`. * * @throws {GpmPayConfigError} when `GPMPAY_API_TOKEN` is not set. */ static fromEnv(overrides = {}) { const envToken = process.env.GPMPAY_API_TOKEN; const envUrl = process.env.GPMPAY_API_URL; const options = { ...overrides, apiToken: overrides.apiToken ?? envToken }; const baseUrl = overrides.baseUrl ?? envUrl; if (baseUrl !== void 0) options.baseUrl = baseUrl; return new _GpmPay(options); } /** Fully normalized API base, e.g. `https://api.gpmpay.com/api/v1`. */ get baseUrl() { return this.http.baseUrl; } /** First 12 chars of the token. Safe to log and quote in support tickets. */ get tokenPrefix() { return tokenPrefix(this.token); } toString() { return `GpmPay(${this.baseUrl}, ${maskToken(this.token)})`; } /** Ensures `console.log(client)` can never dump the token. */ [/* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom")]() { return this.toString(); } /** * Verify connectivity and authentication, and report the token's real scopes. * * Issues one minimal read per scope in parallel and infers the grant from the * outcome. The token's own record cannot be used for this: `GET /api-tokens` * declares no `@ApiScopes()`, so the fail-closed guard rejects every API * token that asks for it. * * A 401 from the first probe to complete propagates — an invalid token is a * hard failure, not "no scopes". */ async ping(options = {}) { const startedAt = Date.now(); const results = await Promise.all( SCOPE_PROBES.map(async ({ scope, path }) => { try { await this.http.requestList("GET", path, { ...options, query: { limit: 1 } }); return { scope, granted: true }; } catch (error) { if (error instanceof GpmPayPermissionError) { return { scope, granted: false }; } throw error; } }) ); const latencyMs = Date.now() - startedAt; return { ok: true, baseUrl: this.baseUrl, tokenPrefix: this.tokenPrefix, latencyMs, scopes: { granted: results.filter((r) => r.granted).map((r) => r.scope), denied: results.filter((r) => !r.granted).map((r) => r.scope) } }; } /** * Escape hatch for endpoints this SDK does not model yet. * * @example * await client.request('GET', '/some/new/endpoint'); */ request(method, path, options = {}) { return this.http.request(method, path, options); } }; var SIGNATURE_HEADER = "X-GPMPay-Signature"; var EVENT_HEADER = "X-GPMPay-Event"; var DEFAULT_TOLERANCE_SECONDS = 300; function toBuffer(input) { if (Buffer.isBuffer(input)) return input; if (typeof input === "string") return Buffer.from(input, "utf8"); return Buffer.from(input); } function parseSignatureHeader(signature) { const parts = {}; for (const segment of signature.split(",")) { const index = segment.indexOf("="); if (index > 0) { parts[segment.slice(0, index).trim()] = segment.slice(index + 1).trim(); } } return parts; } function assertWebhookSignature(input) { const { signature, secret, now = Date.now } = input; const tolerance = input.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS; if (typeof secret !== "string" || secret === "") { throw new GpmPayWebhookSignatureError( "Webhook secret is empty. Pass the secret you configured on the webhook setting (e.g. process.env.GPMPAY_WEBHOOK_SECRET).", "missing_secret" ); } if (typeof signature !== "string" || signature.trim() === "") { throw new GpmPayWebhookSignatureError( `Missing ${SIGNATURE_HEADER} header.`, "malformed_header" ); } const parts = parseSignatureHeader(signature); const timestamp = Number(parts.t); const v1 = parts.v1; if (!Number.isFinite(timestamp) || timestamp <= 0 || !v1) { throw new GpmPayWebhookSignatureError( `Malformed ${SIGNATURE_HEADER}: "${signature}". Expected "t=<unix_seconds>,v1=<hex>".`, "malformed_header" ); } if (tolerance > 0 && Math.abs(now() / 1e3 - timestamp) > tolerance) { throw new GpmPayWebhookSignatureError( `Webhook timestamp is outside the ${String(tolerance)}s tolerance (t=${String(timestamp)}). Check for clock skew between your server and GPM Pay.`, "timestamp_skew" ); } const raw = toBuffer(input.rawBody); const expected = crypto.createHmac("sha256", secret).update(Buffer.concat([Buffer.from(`${String(timestamp)}.`, "utf8"), raw])).digest(); const provided = Buffer.from(v1, "hex"); if (expected.length !== provided.length || !crypto.timingSafeEqual(expected, provided)) { throw new GpmPayWebhookSignatureError( "Webhook signature mismatch \u2014 wrong secret, or the body was modified in transit. Make sure you are verifying the raw request body, not a re-serialized object.", "mismatch" ); } return { timestamp }; } function verifyWebhookSignature(input) { try { assertWebhookSignature(input); return true; } catch { return false; } } function signWebhookPayload(params) { const timestamp = params.timestamp ?? Math.floor(Date.now() / 1e3); const raw = toBuffer(params.rawBody); const digest = crypto.createHmac("sha256", params.secret).update(Buffer.concat([Buffer.from(`${String(timestamp)}.`, "utf8"), raw])).digest("hex"); return `t=${String(timestamp)},v1=${digest}`; } function headerValue(headers, name) { if (!headers) return void 0; const target = name.toLowerCase(); for (const [key, value] of Object.entries(headers)) { if (key.toLowerCase() !== target) continue; return Array.isArray(value) ? value[0] : value; } return void 0; } function constructWebhookEvent(input) { const { timestamp } = assertWebhookSignature(input); const rawBody = toBuffer(input.rawBody).toString("utf8"); return { type: headerValue(input.headers, EVENT_HEADER) ?? "transaction.created", timestamp, payload: JSON.parse(rawBody), rawBody }; } // src/vietqr/crc16.ts function crc16ccitt(input) { let crc = 65535; for (let i = 0; i < input.length; i++) { crc ^= input.charCodeAt(i) << 8; for (let j = 0; j < 8; j++) { crc = crc & 32768 ? crc << 1 ^ 4129 : crc << 1; crc &= 65535; } } return crc.toString(16).toUpperCase().padStart(4, "0"); } // src/vietqr/vietqr.ts var DESCRIPTION_MAX_LENGTH = 25; var tlv = (id, value) => id + value.length.toString().padStart(2, "0") + value; function isDynamic(amount) { return amount !== void 0 && amount !== null && amount !== ""; } function trimDescription(description) { return description === void 0 ? void 0 : description.slice(0, DESCRIPTION_MAX_LENGTH); } function buildVietQrPayload(input) { const { bankBin, accountNumber, amount, description, serviceCode = "QRIBFTTA" } = input; const beneficiaryOrg = tlv("00", bankBin) + tlv("01", accountNumber); const merchantAccountInfo = tlv("00", "A000000727") + tlv("01", beneficiaryOrg) + tlv("02", serviceCode); const dynamic = isDynamic(amount); const trimmedDesc = trimDescription(description); const additionalData = trimmedDesc ? tlv("08", trimmedDesc) : ""; const parts = [ tlv("00", "01"), tlv("01", dynamic ? "12" : "11"), tlv("38", merchantAccountInfo), tlv("53", "704"), ...dynamic ? [tlv("54", String(amount))] : [], tlv("58", "VN"), ...additionalData ? [tlv("62", additionalData)] : [] ]; const base = parts.join("") + "6304"; return base + crc16ccitt(base); } function buildVietQrImageUrl(input) { const { bankBin, accountNumber, amount, template = "compact" } = input; const trimmedDesc = trimDescription(input.description); const url = new URL( `https://img.vietqr.io/image/${bankBin}-${accountNumber}-${template}.png` ); if (isDynamic(amount)) url.searchParams.set("amount", String(amount)); if (trimmedDesc) url.searchParams.set("addInfo", trimmedDesc); if (input.accountName !== void 0) { url.searchParams.set("accountName", input.accountName); } return url.toString(); } function buildPaymentInstructions(request) { const account = request.bankAccount; const bank = account.bank; if (bank === void 0 || bank.bin === "") { throw new GpmPayConfigError( "buildPaymentInstructions: bankAccount.bank.bin is required to build a VietQR payload. Fetch the account with its `bank` relation, e.g. `await client.bankAccounts.retrieve(id)`." ); } const bankBin = bank.bin; const base = { bankBin, accountNumber: account.accountNumber, amount: request.amount, description: request.transferContent }; if (request.serviceCode !== void 0) base.serviceCode = request.serviceCode; const imageInput = { ...base, accountName: account.ownerName }; if (request.template !== void 0) imageInput.template = request.template; return { qrPayload: buildVietQrPayload(base), qrImageUrl: buildVietQrImageUrl(imageInput), amount: Number(request.amount), transferContent: request.transferContent, bankName: bank.shortName === "" ? bank.name : bank.shortName, bankBin, accountNumber: account.accountNumber, accountName: account.ownerName }; } // src/types/enums.ts var ALL_API_SCOPES = [ "transactions:read", "bank-accounts:read", "webhooks:manage" ]; var WEBHOOK_RETRY_SCHEDULE_SECONDS = [ 10, 30, 120, 600, 3600, 21600 ]; var WEBHOOK_MAX_ATTEMPTS = 6; var WEBHOOK_DELIVERY_TIMEOUT_MS = 5e3; exports.ALL_API_SCOPES = ALL_API_SCOPES; exports.API_TOKEN_PREFIX = API_TOKEN_PREFIX; exports.API_TOKEN_RE = API_TOKEN_RE; exports.AbortError = AbortError; exports.DEFAULT_BASE_URL = DEFAULT_BASE_URL; exports.EVENT_HEADER = EVENT_HEADER; exports.GpmPay = GpmPay; exports.GpmPayAPIError = GpmPayAPIError; exports.GpmPayAuthenticationError = GpmPayAuthenticationError; exports.GpmPayBadRequestError = GpmPayBadRequestError; exports.GpmPayConfigError = GpmPayConfigError; exports.GpmPayConnectionError = GpmPayConnectionError; exports.GpmPayError = GpmPayError; exports.GpmPayNotFoundError = GpmPayNotFoundError; exports.GpmPayPermissionError = GpmPayPermissionError; exports.GpmPayRateLimitError = GpmPayRateLimitError; exports.GpmPayServerError = GpmPayServerError; exports.GpmPayTimeoutError = GpmPayTimeoutError; exports.GpmPayWebhookSignatureError = GpmPayWebhookSignatureError; exports.MAX_PAGE_SIZE = MAX_PAGE_SIZE; exports.SANDBOX_BASE_URL = SANDBOX_BASE_URL; exports.SIGNATURE_HEADER = SIGNATURE_HEADER; exports.WEBHOOK_DELIVERY_TIMEOUT_MS = WEBHOOK_DELIVERY_TIMEOUT_MS; exports.WEBHOOK_MAX_ATTEMPTS = WEBHOOK_MAX_ATTEMPTS; exports.WEBHOOK_RETRY_SCHEDULE_SECONDS = WEBHOOK_RETRY_SCHEDULE_SECONDS; exports.assertWebhookSignature = assertWebhookSignature; exports.buildPaymentInstructions = buildPaymentInstructions; exports.buildVietQrImageUrl = buildVietQrImageUrl; exports.buildVietQrPayload = buildVietQrPayload; exports.constructWebhookEvent = constructWebhookEvent; exports.formatVnd = formatVnd; exports.maskToken = maskToken; exports.normalizeBaseUrl = normalizeBaseUrl; exports.signWebhookPayload = signWebhookPayload; exports.toVnd = toVnd; exports.tokenPrefix = tokenPrefix; exports.verifyWebhookSignature = verifyWebhookSignature; //# sourceMappingURL=index.cjs.map //# sourceMappingURL=index.cjs.map