@gpmpay/sdk
Version:
Official Node.js SDK for GPM Pay — VietQR codes, transaction webhooks, and payment reconciliation.
2,121 lines • 69.7 kB
JavaScript
#!/usr/bin/env node
import { parseArgs } from 'util';
import { randomUUID, createHmac, timingSafeEqual, randomBytes } from 'crypto';
import { readFileSync } from 'fs';
import { createServer } from 'http';
// 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/version.ts
var VERSION = "0.4.0";
// src/cli/args.ts
function requireArg(value, name) {
if (value === void 0 || value === "") {
throw new GpmPayConfigError(
`Missing required option --${name}.`,
"invalid_argument"
);
}
return value;
}
function enumArg(value, name, allowed) {
if (value === void 0 || value === "") return void 0;
const match = allowed.find(
(candidate) => candidate.toLowerCase() === value.toLowerCase()
);
if (match === void 0) {
throw new GpmPayConfigError(
`Invalid --${name} "${value}". Expected one of: ${allowed.join(" | ")}.`,
"invalid_argument"
);
}
return match;
}
function intArg(value, name) {
if (value === void 0 || value === "") return void 0;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1) {
throw new GpmPayConfigError(
`Invalid --${name} "${value}". Expected a positive integer.`,
"invalid_argument"
);
}
return parsed;
}
// 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/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 ?? 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_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/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 = 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);
}
};
// src/cli/context.ts
var NO_TOKEN_MESSAGE = [
"No API token found.",
"",
" Set one of:",
" export GPMPAY_API_TOKEN=gpm_xxxxxxxx_yyyyyyyyyyyyyyyyyyyyyyyy",
" gpmpay ping --token gpm_...",
"",
" Create a token at https://app.gpmpay.com/api-tokens",
" (Node 20.6+: you can also load a .env file with `node --env-file=.env`.)"
].join("\n");
function clientFromFlags(flags) {
const token = flags.token ?? process.env.GPMPAY_API_TOKEN;
if (token === void 0 || token.trim() === "") {
throw new GpmPayConfigError(NO_TOKEN_MESSAGE, "missing_api_token");
}
const baseUrl = flags.baseUrl ?? (flags.sandbox === true ? SANDBOX_BASE_URL : void 0) ?? process.env.GPMPAY_API_URL;
return new GpmPay({
apiToken: token,
...baseUrl === void 0 ? {} : { baseUrl },
userAgent: "gpmpay-cli"
});
}
var NO_SECRET_MESSAGE = "Missing webhook secret. Pass --secret, or set GPMPAY_WEBHOOK_SECRET.\n This is the `authorizationSecret` you configured on the webhook setting.";
function secretFromFlags(flags) {
const secret = flags.secret ?? process.env.GPMPAY_WEBHOOK_SECRET;
if (secret === void 0 || secret === "") {
throw new GpmPayConfigError(NO_SECRET_MESSAGE, "invalid_argument");
}
return secret;
}
// src/cli/ui.ts
var EXIT = {
OK: 0,
ERROR: 1,
USAGE: 2,
AUTH: 3,
CONNECTION: 4
};
function colorsEnabled() {
if (process.env.NO_COLOR !== void 0 && process.env.NO_COLOR !== "") {
return false;
}
if (process.env.FORCE_COLOR !== void 0 && process.env.FORCE_COLOR !== "") {
return true;
}
return process.stdout.isTTY === true;
}
var ESC = "\x1B";
function wrap(open, close) {
return (text) => colorsEnabled() ? `${ESC}[${open}m${text}${ESC}[${close}m` : text;
}
var style = {
bold: wrap("1", "22"),
dim: wrap("2", "22"),
red: wrap("31", "39"),
green: wrap("32", "39"),
yellow: wrap("33", "39"),
blue: wrap("34", "39"),
cyan: wrap("36", "39")
};
var symbols = {
ok: () => style.green("\u2713"),
fail: () => style.red("\u2717"),
warn: () => style.yellow("!")
};
function out(message = "") {
process.stdout.write(`${message}
`);
}
function err(message = "") {
process.stderr.write(`${message}
`);
}
function fields(rows, indent = " ") {
const width = rows.reduce((max, [key]) => Math.max(max, key.length), 0);
return rows.map(([key, value]) => `${indent}${style.dim(key.padEnd(width))} ${value}`).join("\n");
}
function money(amount) {
return formatVnd(amount);
}
function relativeTime(iso) {
if (iso === null || iso === void 0) return style.dim("never");
const then = Date.parse(iso);
if (Number.isNaN(then)) return iso;
const seconds = Math.round((Date.now() - then) / 1e3);
const abs = Math.abs(seconds);
const suffix = seconds >= 0 ? "ago" : "from now";
if (abs < 60) return `${String(abs)}s ${suffix}`;
if (abs < 3600) return `${String(Math.round(abs / 60))}m ${suffix}`;
if (abs < 86400) return `${String(Math.round(abs / 3600))}h ${suffix}`;
return `${String(Math.round(abs / 86400))}d ${suffix}`;
}
// src/cli/redact.ts
var SECRET_KEY = /secret|token|password|authorization|verificationcode/i;
var ALLOWED_KEYS = /* @__PURE__ */ new Set([
"tokenPrefix",
"authorizationType",
"authorizationHeaderName"
]);
var REDACTED = "[redacted]";
function isPlainObject(value) {
return typeof value === "object" && value !== null && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
}
function walk(value, seen) {
if (Array.isArray(value)) {
if (seen.has(value)) return REDACTED;
seen.add(value);
return value.map((item) => walk(item, seen));
}
if (!isPlainObject(value)) return value;
if (seen.has(value)) return REDACTED;
seen.add(value);
const result = {};
for (const [key, item] of Object.entries(value)) {
if (typeof item === "string" && item !== "" && SECRET_KEY.test(key) && !ALLOWED_KEYS.has(key)) {
result[key] = REDACTED;
continue;
}
result[key] = walk(item, seen);
}
return result;
}
function redactSecrets(value) {
return walk(value, /* @__PURE__ */ new WeakSet());
}
function printJson(value) {
out(JSON.stringify(redactSecrets(value), null, 2));
}
// src/cli/commands/accounts.ts
var STATUSES = [
"ACTIVE",
"SUSPENDED",
"DELETED",
"PENDING_VERIFICATION"
];
function bankLabel(account) {
return account.bank?.code ?? account.bank?.shortName ?? "\u2014";
}
function statusCell(status) {
return status === "ACTIVE" ? status : style.yellow(status);
}
async function accountsCommand(subcommand, positional, flags) {
const client = clientFromFlags(flags);
switch (subcommand) {
case "list": {
const status = enumArg(flags.status, "status", STATUSES);
const limit = intArg(flags.limit, "limit");
const page = await client.bankAccounts.list({
...status === void 0 ? {} : { status },
...limit === void 0 ? {} : { limit }
});
if (flags.json === true) {
printJson(page);
return EXIT.OK;
}
if (page.data.length === 0) {
out(style.dim("No bank accounts on this token."));
out();
out(
style.dim(
" Add one at https://app.gpmpay.com/bank-accounts \u2014 transactions\n and webhooks are always scoped to a bank account."
)
);
return EXIT.OK;
}
out(`${symbols.ok()} ${String(page.data.length)} bank account(s)`);
out();
for (const account of page.data) {
out(
` ${account.id} ${bankLabel(account).padEnd(6)} ${account.accountNumber.padEnd(16)} ${account.ownerName.slice(0, 20).padEnd(22)} ` + statusCell(account.status)
);
}
out();
out(
style.dim(
" Copy an id into: gpmpay simulate tx --account <id> --amount 50000 --content REF1"
)
);
return EXIT.OK;
}
case "get": {
const id = requireArg(positional[0], "account-id");
const account = await client.bankAccounts.retrieve(id);
if (flags.json === true) {
printJson(account);
return EXIT.OK;
}
const bank = account.bank;
out(
fields([
["ID", account.id],
[
"Bank",
bank ? `${bank.code} \u2014 ${bank.name} ${style.dim(`(BIN ${bank.bin})`)}` : style.dim("unknown")
],
["Number", account.accountNumber],
["Holder", account.ownerName],
["Status", statusCell(account.status)],
["Expires", relativeTime(account.expiresAt)],
["Created", relativeTime(account.createdAt)]
])
);
return EXIT.OK;
}
default:
throw new GpmPayConfigError(
`Unknown subcommand "accounts ${subcommand ?? ""}". Expected: list | get.`,
"invalid_argument"
);
}
}
// src/cli/commands/ping.ts
async function pingCommand(flags) {
const client = clientFromFlags(flags);
const result = await client.ping();
if (flags.json === true) {
printJson(result);
return EXIT.OK;
}
out(
`${symbols.ok()} Connected to ${style.cyan(result.baseUrl)} ${style.dim(
`(${String(result.latencyMs)} ms)`
)}`
);
const { granted, denied } = result.scopes;
const rows = [
["Token", `${style.bold(result.tokenPrefix)}${style.dim("\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022")}`],
[
"Scopes",
granted.length > 0 ? style.green(granted.join(", ")) : style.yellow("none granted")
]
];
if (denied.length > 0) {
rows.push(["Missing", style.dim(denied.join(", "))]);
}
out(fields(rows));
if (granted.length === 0) {
out();
out(
style.dim(
" This token carries no usable scope. Regenerate it at\n https://app.gpmpay.com/api-tokens with the scopes you need."
)
);
}
return EXIT.OK;
}
// src/cli/commands/simulate.ts
function simulatorOptions(flags) {
if (flags.allowProduction !== true) return {};
err(
`${symbols.warn()} ${style.yellow("--allow-production")} \u2014 writing a simulated transaction to the live ledger.`
);
err(
style.dim(
" It is tagged source=SIMULATED, but it stays visible in the merchant\n dashboard and fires webhooks for every endpoint with fireOnSimulated on."
)
);
return { allowOnProduction: true };
}
var AFTER_SIMULATE = [
" Delivery is asynchronous \u2014 give it a moment.",
" Webhooks fire only for endpoints with fireOnSimulated enabled:",
" gpmpay webhook settings"
].join("\n");
async function simulateCommand(subcommand, flags) {
const client = clientFromFlags(flags);
switch (subcommand) {
case "tx": {
const bankAccountId = requireArg(flags.account, "account");
const amount = intArg(
requireArg(flags.amount, "amount"),
"amount"
);
const transferContent = requireArg(
flags.content ?? flags.desc,
"content"
);
const type = enumArg(flags.type, "type", ["IN", "OUT"]) ?? "IN";
const options = simulatorOptions(flags);
const result = await client.simulator.createTransaction(
{ bankAccountId, amount, transferContent, type },
options
);
if (flags.json === true) {
printJson(result);
return EXIT.OK;
}
const { transaction, historyIds } = result;
out(`${symbols.ok()} Simulated ${type} ${money(amount)}`);
out(
fields([
["Transaction", transaction.id],
["Account", bankAccountId],
["Content", transferContent],
["Source", transaction.source],
// 0 here is the answer to "why didn't my handler fire?" — no endpoint
// has fireOnSimulated on, so nothing was ever queued.
["Webhooks", `${historyIds.length} queued`]
])
);
out();
out(style.dim(AFTER_SIMULATE));
return EXIT.OK;
}
default:
throw new GpmPayConfigError(
`Unknown subcommand "simulate ${subcommand ?? ""}". Expected: tx.`,
"invalid_argument"
);
}
}
// src/cli/commands/transactions.ts
var TYPES = ["IN", "OUT"];
async function transactionsCommand(subcommand, flags) {
if (subcommand !== "list") {
throw new GpmPayConfigError(
`Unknown subcommand "transactions ${subcommand ?? ""}". Expected: list.`,
"invalid_argument"
);
}
const client = clientFromFlags(flags);
const limit = intArg(flags.limit, "limit");
const type = enumArg(flags.type, "type", TYPES);
const page = await client.transactions.list({
...limit === void 0 ? {} : { limit },
...flags.account === void 0 ? {} : { bankAccountId: flags.account },
...type === void 0 ? {} : { type }
});
if (flags.json === true) {
printJson(page);
return EXIT.OK;
}
if (page.data.length === 0) {
out(style.dim("No transactions found."));
return EXIT.OK;
}
for (const txn of page.data) {
const direction = txn.type === "IN" ? style.green("IN ") : style.red("OUT");
out(
`${direction} ${money(txn.amount).padStart(14)} ${style.dim(
relativeTime(txn.transactionTime).padEnd(14)
)} ${txn.transferContent.slice(0, 48)}`
);
}
out(
style.dim(
`
${String(page.data.length)} of ${String(page.meta.totalItems)} transaction(s) \u2014 page ${String(page.meta.page)}/${String(page.meta.totalPages)}`
)
);
return EXIT.OK;
}
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 = createHmac("sha256", secret).update(Buffer.concat([Buffer.from(`${String(timestamp)}.`, "utf8"), raw])).digest();
const provided = Buffer.from(v1, "hex");
if (expected.length !== provided.length || !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 signWebhookPayload(params) {
const timestamp = params.timestamp ?? Math.floor(Date.now() / 1e3);
const raw = toBuffer(params.rawBody);
const digest = 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/cli/commands/webhook-admin.ts
var DRIVERS = [
"HTTP",
"TELEGRAM",
"WORDPRESS",
"GOOGLE_SHEETS"
];
var DELIVERY_STATUSES = [
"PENDING",
"DELIVERED",
"RETRYING",
"FAILED"
];
function authLabel(setting) {
switch (setting.authorizationType) {
case "HMAC":
return setting.authorizationSecret == null ? style.yellow("HMAC (no secret \u2014 deliveries are unsigned)") : `HMAC (secret set, header ${setting.authorizationHeaderName ?? "X-GPMPay-Signature"})`;
case "API_KEY":
return setting.authorizationSecret == null ? style.yellow("API_KEY (no secret \u2014 no header is sent)") : `API_KEY (header ${setting.authorizationHeaderName ?? "Authorization"})`;
case "NONE":
return style.yellow("NONE (no auth header at all)");
}
}
function scopeLabel(setting) {
if (setting.scope === "ALL") return "scope ALL";
const count = setting.bankAccounts?.length ?? 0;
return `scope SPECIFIC (${String(count)} account${count === 1 ? "" : "s"})`;
}
async function webhookSettings(flags) {
const client = clientFromFlags(flags);
const driver = enumArg(flags.driver, "driver", DRIVERS);
const limit = intArg(flags.limit, "limit");
const page = await client.webhookSettings.list({
...driver === void 0 ? {} : { driver },
...limit === void 0 ? {} : { limit }
});
if (flags.json === true) {
printJson(page);
return EXIT.OK;
}
if (page.data.length === 0) {
out(style.dim("No webhook endpoints registered."));
out();
out(
style.dim(
' Register one:\n await client.webhookSettings.createHmacEndpoint({ url: "https://\u2026" })'
)
);
return EXIT.OK;
}
out(`${symbols.ok()} ${String(page.data.length)} webhook endpoint(s)`);
out();
for (const setting of page.data) {
out(
` ${setting.isActive ? symbols.ok() : symbols.fail()} ${setting.id} ${style.cyan(setting.url)}`
);
out(
style.dim(
` ${setting.driver} \xB7 ${authLabel(setting)} \xB7 ${scopeLabel(setting)} \xB7 ${String(setting.maxRetries)} retries \xB7 sim ${setting.fireOnSimulated ? "on" : "OFF"}` + (setting.autoDisabledAt === null ? "" : ` \xB7 auto-disabled ${relativeTime(setting.autoDisabledAt)}`)
)
);
}
return EXIT.OK;
}
function historyMarker(status) {
if (status === "DELIVERED") return symbols.ok();
if (status === "FAILED") return symbols.fail();
if (status === "RETRYING") return symbols.warn();
return " ";
}
function oneLine(body) {
return body.replace(/\s+/g, " ").trim().slice(0, 80);
}
async function webhookHistory(flags) {
const client = clientFromFlags(flags);
const status = enumArg(flags.status, "status", DELIVERY_STATUSES);
const limit = intArg(flags.limit, "limit");
const page = await client.webhookHistories.list({
...status === void 0 ? {} : { status },
...limit === void 0 ? {} : { limit },
...flags.setting === void 0 ? {} : { settingId: flags.setting }
});
if (flags.json === true) {
printJson(page);
return EXIT.OK;
}
if (page.data.length === 0) {
out(style.dim("No delivery attempts match."));
return EXIT.OK;
}
out(`${symbols.ok()} ${String(page.data.length)} delivery attempt(s)`);
out();
for (const item of page.data) {
out(
` ${historyMarker(item.status)} ${item.status.padEnd(10)} ${String(item.responseStatus ?? "\u2014").padStart(3)} ${String(item.attemptCount)} attempt${item.attemptCount === 1 ? " " : "s"} ${(item.durationMs === null ? "\u2014" : `${String(item.durationMs)}ms`).padStart(7)} ${relativeTime(item.createdAt).padEnd(12)} ` + style.dim(item.setting?.url ?? item.settingId)
);
if (item.status !== "DELIVERED" && item.responseBody) {
out(style.dim(` ${oneLine(item.responseBody)}`));
}
if (item.status === "RETRYING" && item.nextAttemptAt !== null) {
out(style.dim(` next attempt ${relativeTime(item.nextAttemptAt)}`));
}
if (item.status === "FAILED") {
out(style.dim(` gpmpay webhook retry ${item.id}`));
}
}
return EXIT.OK;
}
function retryHint(message) {
if (message.includes("\u0111\xE3 giao th\xE0nh c\xF4ng")) {
return "This delivery already succeeded \u2014 there is nothing to retry.";
}
if (message.includes("\u0111ang t\u1EAFt")) {
return "The webhook setting is disabled. Re-enable it, then retry.";
}
return void 0;
}
async function webhookRetry(id, flags) {
const client = clientFromFlags(flags);
const historyId = requireArg(id, "history-id");
let history;
try {
history = await client.webhookHistories.retry(historyId);
} catch (error) {
if (error instanceof GpmPayBadRequestError) {
out(`${symbols.fail()} ${style.red(error.message)}`);
const hint = retryHint(error.message);
if (hint !== void 0) out(style.dim(` ${hint}`));
return EXIT.ERROR;
}
throw error;
}
if (flags.json === true) {
printJson(history);
return EXIT.OK;
}
out(`${symbols.ok()} Delivery ${style.bold(history.id)} re-queued`);
out(
fields([
["Status", history.status],
["Attempts", String(history.attemptCount)],
["Endpoint", history.setting?.url ?? history.settingId]
])
);
return EXIT.OK;
}
// src/types/enums.ts
var WEBHOOK_DELIVERY_TIMEOUT_MS = 5e3;
// src/cli/commands/webhook-send.ts
var DEFAULT_AMOUNT = 5e4;
var DEFAULT_CONTENT = "CT DEN:0123456789 SHOP0000001 thanh toan don hang";
var LOCAL_HOST = /^(localhost|127\.0\.0\.1|\[::1\]|.*\.local)$/i;
function buildSamplePayload(flags) {
const content = flags.content ?? DEFAULT_CONTENT;
const type = enumArg(flags.type, "type", ["IN", "OUT"]) ?? "IN";
const amount = intArg(flags.amount, "amount") ?? DEFAULT_AMOUNT;
return {
id: randomUUID(),
gateway: "MB",
transactionDate: (/* @__PURE__ */ new Date()).toISOString(),
accountNumber: "0123456789",
subAccount: null,
content,
transferType: type === "IN" ? "in" : "out",
transferAmount: amount,
accumulated: null,
// The BANK's own transfer id — not a GPM Pay code, and not yours.
referenceCode: `FT${String(Date.now())}`,
source: "SIMULATED"
};
}
function corrupt(signature) {
const last = signature.slice(-1);
return signature.slice(0, -1) + (last === "0" ? "1" : "0");
}
function hostOf(url) {
try {
return new URL(url).hostname;
} catch {
throw new GpmPayConfigError(
`Invalid --url "${url}". Expected an absolute URL, e.g. http://localhost:3000/webhooks/gpmpay.`,
"invalid_argument"
);
}
}
async function sendWebhook(flags) {
const url = requireArg(flags.url, "url");
const secret = secretFromFlags(flags);
const host = hostOf(url);
const rawBody = flags.file === void 0 ? JSON.stringify(buildSamplePayload(flags)) : readFileSync(flags.file, "utf8");
if (flags.file !== void 0) {
try {
JSON.parse(rawBody);
} catch {
throw new GpmPayConfigError(
`--file ${flags.file} is not valid JSON.`,
"invalid_argument"
);
}
}
const skew = intArg(flags.skew, "skew");
const timestamp = Math.floor(Date.now() / 1e3) - (skew ?? 0);
const signed = signWebhookPayload({ rawBody, secret, timestamp });
const signature = flags.badSignature === true ? corrupt(signed) : signed;
const event = flags.event ?? "transaction.created";
const parsed = JSON.parse(rawBody);
if (flags.json !== true) {
out(`${style.dim("\u2192 POST")} ${style.cyan(url)}`);
out(
fields([
["Event", event],
// Not a secret: it rides in a header, and printing it lets you replay
// the exact request with curl. The signing secret is never printed.
["Signature", style.dim(signature)],
[
"Body",
`${String(Buffer.byteLength(rawBody))} bytes` + (parsed.transferAmount === void 0 ? "" : ` \xB7 ${money(parsed.transferAmount)} ${String(parsed.transferType ?? "").toUpperCase()}`)
]
])
);
if (flags.badSignature === true) {
out(`${symbols.warn()} ${style.yellow("--bad-signature")} \u2014 a correct handler must reject this.`);
}
if (skew !== void 0) {
out(
`${symbols.warn()} ${style.yellow(`--skew ${String(skew)}s`)} \u2014 outside the \xB1300s window, a correct handler must reject this.`
);
}
if (!LOCAL_HOST.test(host)) {
out(
`${symbols.warn()} ${style.yellow(host)} is not local \u2014 this is a forged event. A handler that trusts it will act as if a real payment arrived.`
);
}
out();
}
const startedAt = Date.now();
let response;
try {
response = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
[SIGNATURE_HEADER]: signature,
[EVENT_HEADER]: event,
"user-agent": `gpmpay-cli/${VERSION}`
},
body: rawBody,
signal: AbortSignal.timeout(WEBHOOK_DELIVERY_TIMEOUT_MS)
});
} catch (error) {
const timedOut = error.name === "TimeoutError";
out(
`${symbols.fail()} ${style.red(timedOut ? "timed out" : "could not connect")} \u2014 ${url}`
);
out(
style.dim(
timedOut ? ` No response within ${String(WEBHOOK_DELIVERY_TIMEOUT_MS)} ms. GPM Pay aborts real deliveries
at the same budget, so this would be recorded as failed in production.` : " Is your server running, and is the path right?"
)
);
return EXIT.CONNECTION;
}
const durationMs = Date.now() - startedAt;
const responseBody = await response.text();
if (flags.json === true) {
printJson({
url,
event,
signature,
requestBody: parsed,
status: response.status,
ok: response.ok,
durationMs,
responseBody
});
return response.ok ? EXIT.OK : EXIT.ERROR;
}
const line = `${String(response.status)} ${response.statusText} ${style.dim(`(${String(durationMs)} ms)`)}`;
out(response.ok ? `${symbols.ok()} ${line}` : `${symbols.fail()} ${style.red(line)}`);
if (responseBody !== "") out(style.dim(` ${responseBody.slice(0, 200)}`));
if (!response.ok && flags.badSignature !== true && skew === void 0) {
out();
out(
style.dim(
" Your handler rejected the delivery. Check that it verifies the RAW body\n bytes \u2014 not JSON.stringify(req.body) \u2014 and uses the same secret."
)
);
}
if (response.ok) {
out();
out(
style.dim(
" source=SIMULATED \u2014 if your handler skips simulated traffic, pass --file\n with a body of your own."
)
);
}
return response.ok ? EXIT.OK : EXIT.ERROR;
}
// src/cli/commands/webhook.ts
function readStdin() {
return new Promise((resolve, reject) => {
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
data += typeof chunk === "string" ? chunk : chunk.toString("utf8");
});
process.stdin.on("end", () => resolve(data));
process.stdin.on("error", reject);
});
}
async function verifyOnce(flags) {
const secret = secretFromFlags(flags);
if (flags.signature === void 0 || flags.signature === "") {
throw new GpmPayConfigError(
`Missing --signature. Pass the ${SIGNATURE_HEADER} header value, e.g. "t=1785600000,v1=abc\u2026".`,
"invalid_argument"
);
}
const rawBody = flags.file === void 0 ? await readStdin() : readFileSync(flags.file, "utf8");
try {
const { timestamp } = assertWebhookSignature({
rawBody,
signature: flags.signature,
secret
});
out(`${symbols.ok()} Signature valid ${style.dim(`(t=${String(timestamp)})`)}`);
return EXIT.OK;
} catch (error) {
if (error instanceof GpmPayWebhookSignatureError) {
out(`${symbols.fail()} ${style.red(error.reason)}: ${error.message}`);
return EXIT.ERROR;
}
throw error;
}
}
function listen(flags) {
const secret = secretFromFlags(flags);
const port = Number(flags.port ?? "4444");
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => {
const rawBody = Buffer.concat(chunks);
const signature = req.headers[SIGNATURE_HEADER.toLowerCase()];
try {
const event = constructWebhookEvent({
rawBody,
signature: Array.isArray(signature) ? signature[0] ?? "" : signature ?? "",
secret,
headers: req.headers
});
const payload = event.payload;
out("");
out(`${symbols.ok()} ${style.green(event.type)}`);
out(
fields([
["Transaction", payload.id],
["Gateway", payload.gateway],
["Direction", payload.transferType === "in" ? style.green("IN") : "OUT"],
["Amount", money(payload.transferAmount)],
["Content", payload.content],
["Reference", payload.referenceCode],
["Source", payload.source]
])
);
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ received: true }));
} catch (error) {
const reason = error instanceof GpmPayWebhookSignatureError ? error.reason : "error";
out("");
out(`${symbols.fail()} ${style.red("rejected")} \u2014 ${reason}`);
out(style.dim(` ${error.message}`));
res.writeHead(401, { "content-type": "application/json" });
res.end(JSON.stringify({ error: reason }));
}
});
});
server.on("error", reject);
server.listen(port, () => {
out(`${symbols.ok()} Listening for GPM Pay webhooks on ${style.cyan(`http://localhost:${String(port)}`)}`);
out("");
out(style.dim(" Expose it and register the public URL, e.g.:"));
out(style.dim(` ngrok http ${String(port)}`));
out(
style.dim(
' await client.webhookSettings.createHmacEndpoint({ url: "https://<id>.ngrok.app" })'
)
);
out("");
out(style.dim(" Press Ctrl+C to stop."));
});
process.on("SIGINT", () => {
server.close(() => resolve(EXIT.OK));
});
});
}
async function webhookCommand(subcommand, positional, flags) {
switch (subcommand) {
case "verify":
return await verifyOnce(flags);
case "listen":
return await listen(flags);
case "send":
return await sendWebhook(flags);
case "settings":
return await webhookSettings(flags);
case "history":
return await webhookHistory(flags);
case "retry":
return await webhookRetry(positional[0], flags);
default:
throw new GpmPayConfigError(
`Unknown subcommand "webhook ${subcommand ?? ""}". Expected: send | listen | verify | settings | history | retry.`,
"invalid_argument"
);
}
}
// src/cli/run.ts
var helpText = () => `
${style.bold("gpmpay")} \u2014 CLI for the GPM Pay API ${style.dim(`v${VERSION}`)}
${style.bold("Usage")}
gpmpay <command> [options]
${style.bold("Commands")}
ping Validate the API token and show its real scopes
accounts list List your bank accounts \u2014 where --account comes from
accounts get <id> Show one bank account
transactions list List recent transactions
simulate tx Fake an inbound transfer (non-production only)
webhook send --url <url> Sign a sample payload and POST it to your handler
webhook listen Local receiver that verifies signatures live
webhook verify Verify one signature (body from --file or stdin)
webhook settings List registered webhook endpoints
webhook history Recent delivery attempts
webhook retry <id> Re-queue a failed delivery
${style.bold("Global options")}
--token <gpm_...> Overrides GPMPAY_API_TOKEN
--base-url <url> Point at another API host
--sandbox Use https://sandbox-api.gpmpay.com
--json Machine-readable output (secrets redacted)
--no-color Disable ANSI colours
-h, --help Show this help
-v, --version Show the SDK version
${style.bold("Command options")}
accounts list [--status <ACTIVE|SUSPENDED|PENDING_VERIFICATION>] [--limit <n>]
transactions [--limit <n>] [--account <uuid>] [--type <IN|OUT>]
simulate tx --account <uuid> --amount <vnd> --content <text>
[--type <IN|OUT>] [--allow-production]
webhook send --url <url> [--secret <s>] [--amount <vnd>] [--content <text>]
[--file <body.json>] [--skew <seconds>] [--bad-signature]
webhook settings [--driver <HTTP|TELEGRAM|WORDPRESS|GOOGLE_SHEETS>] [--limit <n>]
webhook history [--status <PENDING|DELIVERED|RETRYING|FAILED>]
[--setting <id>] [--limit <n>]
webhook verify --secret <s> --signature "t=..,v1=.." [--file <body.json>]
webhook listen --secret <s> [--port <n>]
${style.bold("Environment")}
GPMPAY_API_TOKEN Your API token (every command except "webhook send")
GPMPAY_WEBHOOK_SECRET Webhook signing secret (webhook send/verify/listen)
${style.dim("Create a token at https://app.gpmpay.com/api-tokens")}
`.trimStart();
var OPTIONS = {
token: { type: "string" },
"base-url": { type: "string" },
sandbox: { type: "boolean" },
json: { type: "boolean" },
// Registered under its literal name. `node:util.parseArgs` has no `--no-`
// negation, so declaring `color` here made `--no-color` an "Unknown option"
// in every position while four docs and --help advertised it.
"no-color": { type: "boolean" },
help: { type: "boolean", short: "h" },
version: { type: "boolean", short: "v" },
account: { type: "string" },
amount: { type: "string" },
desc: { type: "string" },
limit: { type: "string" },
type: { type: "string" },
secret: { type: "string" },
file: { type: "string" },
signature: { type: "string" },
port: { type: "string" },
status: { type: "string" },
driver: { type: "string" },
setting: { type: "string" },
url: { type: "string" },
content: { type: "string" },
event: { type: "string" },
skew: { type: "string" },
"bad-signature": { type: "boolean" },
"allow-production": { type: "boolean" }
};
function exitCodeFor(error) {
if (error instanceof GpmPayAuthenticationError) return EXIT.AUTH;
if (error instanceof GpmPayConnectionError) return EXIT.CONNECTION;
if (error instanceof GpmPayTimeoutError) return EXIT.CONNECTION;
if (error instanceof GpmPayConfigError) return EXIT.USAGE;
return EXIT.ERROR;
}
async function run(argv) {
if (argv.includes("--no-color")) process.env.NO_COLOR = "1";
let parsed;
try {
parsed = parseArgs({
args: argv,
options: OPTIONS,
allowPositionals: true,
strict: true
});
} catch (error) {
err(`${style.red("\u2717")} ${error.message}`);
err();
err(helpText());
return EXIT.USAGE;
}
const { values, positionals } = parsed;
if (values.version === true) {
out(VERSION);
return EXIT.OK;
}
if (values.help === true) {
out(helpText());
return EXIT.OK;
}
if (positionals.length === 0) {
out(helpText());
return EXIT.USAGE;
}
const flags = {
token: values.token,
baseUrl: values["base-url"],
sandbox: values.sandbox,
json: values.json,
account: values.account,
amount: values.amount,
desc: values.desc,
limit: values.limit,
type: values.type,
secret: values.secret,
file: values.file,
signature: values.signature,
port: values.port,
status: values.status,
driver: values.driver,
setting: values.setting,
url: values.url,
content: values.content,
event: values.event,
skew: values.skew,
badSignature: values["bad-signature"],
allowProduction: values["allow-production"]
};
const [command, subcommand, ...rest] = positionals;
switch (command) {
case "ping":
return await pingCommand(flags);
case "accounts":
case "bank-accounts":
return await accountsCommand(subcommand ?? "list", rest, flags);
case "transactions":
case "tx":
return await transactionsCommand(subcommand ?? "list", flags);
case "simulate":
case "sim":
return await simulateCommand(subcommand, flags);
case "webhook":
case "webhooks":
return await webhookCommand(subcommand, rest, flags);
case "help":
out(helpText());
return EXIT.OK;
default:
err(`${style.red("\u2717")} Unknown command "${command ?? ""}".`);
err();
err(helpText());
return EXIT.USAGE;
}
}
async function main(argv = process.argv.slice(2)) {
try {
return await run(argv);
} catch (error) {
const code = exitCodeFor(error);
if (GpmPayError.isGpmPayError(error)) {
err(`${style.red("\u2717")} ${error.message}`);
if (error instanceof GpmPayAuthenticationError) {
err(
style.dim(
` request id: ${error.requestId} \u2014 quote this when contacting support.`
)
);
}
} else {
err(`${style.red("\u2717")} ${error.message}`);
}
return code;
}
}
// src/cli/index.ts
process.exitCode = await main();
//# sourceMappingURL=index.js.map
//# sourceMappingURL=index.js.map