@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
JavaScript
;
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