UNPKG

@gpmpay/sdk

Version:

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

2,121 lines • 69.7 kB
#!/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