UNPKG

@gpmpay/sdk

Version:

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

1 lines • 22 kB
{"version":3,"sources":["../../src/vietqr/crc16.ts","../../src/core/errors.ts","../../src/vietqr/vietqr.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * CRC-16/CCITT-FALSE, as required by the EMVCo QR spec (tag 63).\n *\n * Byte-for-byte port of `apps/backend/src/modules/vietqr/crc16.util.ts`;\n * `vietqr.test.ts` asserts both stay in agreement via shared fixtures.\n */\nexport function crc16ccitt(input: string): string {\n let crc = 0xffff;\n for (let i = 0; i < input.length; i++) {\n crc ^= input.charCodeAt(i) << 8;\n for (let j = 0; j < 8; j++) {\n crc = crc & 0x8000 ? (crc << 1) ^ 0x1021 : crc << 1;\n crc &= 0xffff;\n }\n }\n return crc.toString(16).toUpperCase().padStart(4, '0');\n}\n","import type { ApiTokenScope } from '../types/enums.js';\n\n/**\n * Reason an API token was rejected. Derived from the exact messages the backend\n * emits in `api-tokens.service.ts` / `guards/api-token.guard.ts`.\n */\nexport type AuthFailureReason =\n | 'missing_bearer'\n | 'invalid_format'\n | 'invalid_token'\n | 'token_inactive'\n | 'token_expired'\n | 'user_inactive'\n | 'unknown';\n\n/** Why a 403 came back. See {@link GpmPayPermissionError}. */\nexport type PermissionFailureReason = 'scope' | 'endpoint' | 'ownership';\n\nexport type ConfigErrorCode =\n | 'missing_api_token'\n | 'invalid_api_token'\n | 'invalid_api_token_format'\n | 'invalid_base_url'\n | 'invalid_argument';\n\nexport type WebhookSignatureFailureReason =\n | 'missing_secret'\n | 'malformed_header'\n | 'timestamp_skew'\n | 'mismatch';\n\nconst BRAND = Symbol.for('gpmpay.sdk.error');\n\n/**\n * Base class for everything this SDK throws.\n *\n * Generic over `code` so subclasses can narrow it (and give consumers\n * autocomplete on `error.code`) without redeclaring the field.\n */\nexport class GpmPayError<TCode extends string = string> extends Error {\n readonly code: TCode;\n /** @internal Cross-realm brand — `instanceof` breaks across CJS/ESM copies. */\n readonly [BRAND] = true as const;\n\n constructor(message: string, code: TCode) {\n super(message);\n this.name = new.target.name;\n this.code = code;\n Object.setPrototypeOf(this, new.target.prototype);\n }\n\n /**\n * Prefer this over `instanceof` when a dual CJS/ESM install could put two\n * copies of the class in one process.\n */\n static isGpmPayError(value: unknown): value is GpmPayError {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as Record<symbol, unknown>)[BRAND] === true\n );\n }\n}\n\n/** Bad SDK usage — thrown before any network call happens. */\nexport class GpmPayConfigError extends GpmPayError<ConfigErrorCode> {\n constructor(message: string, code: ConfigErrorCode = 'invalid_argument') {\n super(message, code);\n }\n}\n\n/** DNS/TCP/TLS failure. `cause` holds the original error. */\nexport class GpmPayConnectionError extends GpmPayError {\n /** Overrides the standard `Error.cause` so it is always populated here. */\n override readonly cause: unknown;\n /** Node's `err.cause.code`, e.g. `ECONNREFUSED`, `ENOTFOUND`. */\n readonly syscallCode: string | undefined;\n\n constructor(message: string, cause: unknown, syscallCode?: string) {\n super(message, 'connection_error');\n this.cause = cause;\n this.syscallCode = syscallCode;\n }\n}\n\nexport class GpmPayTimeoutError extends GpmPayError {\n readonly timeoutMs: number;\n\n constructor(message: string, timeoutMs: number) {\n super(message, 'timeout');\n this.timeoutMs = timeoutMs;\n }\n}\n\nexport class GpmPayWebhookSignatureError extends GpmPayError {\n readonly reason: WebhookSignatureFailureReason;\n\n constructor(message: string, reason: WebhookSignatureFailureReason) {\n super(message, 'webhook_signature');\n this.reason = reason;\n }\n}\n\nexport interface ApiErrorContext {\n status: number;\n requestId: string;\n rawBody: unknown;\n rawMessage: string | string[];\n}\n\n/** Any non-2xx HTTP response. */\nexport class GpmPayAPIError extends GpmPayError {\n readonly status: number;\n /** Correlation id sent as `X-GPMPay-Request-Id`. Quote it to support. */\n readonly requestId: string;\n readonly rawBody: unknown;\n readonly rawMessage: string | string[];\n\n constructor(message: string, ctx: ApiErrorContext, code = 'api_error') {\n super(message, code);\n this.status = ctx.status;\n this.requestId = ctx.requestId;\n this.rawBody = ctx.rawBody;\n this.rawMessage = ctx.rawMessage;\n }\n}\n\n/** 400 — request body failed validation. */\nexport class GpmPayBadRequestError extends GpmPayAPIError {\n /** One entry per failed constraint, as produced by Nest's ValidationPipe. */\n readonly validationMessages: string[];\n\n constructor(\n message: string,\n ctx: ApiErrorContext & { validationMessages: string[] },\n ) {\n super(message, ctx, 'bad_request');\n this.validationMessages = ctx.validationMessages;\n }\n}\n\n/** 401 — the API token was rejected. */\nexport class GpmPayAuthenticationError extends GpmPayAPIError {\n readonly reason: AuthFailureReason;\n\n constructor(message: string, ctx: ApiErrorContext & { reason: AuthFailureReason }) {\n super(message, ctx, 'authentication_error');\n this.reason = ctx.reason;\n }\n}\n\n/**\n * 403 — one of three things:\n *\n * - `scope` — the token exists but lacks the scope this route requires.\n * - `endpoint` — the route accepts no API token at all (dashboard-only).\n * - `ownership` — the resource exists but belongs to another account.\n */\nexport class GpmPayPermissionError extends GpmPayAPIError {\n readonly missingScope: ApiTokenScope | undefined;\n readonly reason: PermissionFailureReason;\n\n constructor(\n message: string,\n ctx: ApiErrorContext & {\n missingScope?: ApiTokenScope;\n reason: PermissionFailureReason;\n },\n ) {\n super(message, ctx, 'permission_error');\n this.missingScope = ctx.missingScope;\n this.reason = ctx.reason;\n }\n}\n\nexport class GpmPayNotFoundError extends GpmPayAPIError {\n /** Resource name parsed out of e.g. `\"Order not found\"`. */\n readonly resource: string | undefined;\n\n constructor(message: string, ctx: ApiErrorContext & { resource?: string }) {\n super(message, ctx, 'not_found');\n this.resource = ctx.resource;\n }\n}\n\nexport class GpmPayRateLimitError extends GpmPayAPIError {\n readonly retryAfterSeconds: number | undefined;\n\n constructor(\n message: string,\n ctx: ApiErrorContext & { retryAfterSeconds?: number },\n ) {\n super(message, ctx, 'rate_limit');\n this.retryAfterSeconds = ctx.retryAfterSeconds;\n }\n}\n\nexport class GpmPayServerError extends GpmPayAPIError {\n constructor(message: string, ctx: ApiErrorContext) {\n super(message, ctx, 'server_error');\n }\n}\n\n/** Ordered most-specific first — `Invalid token` must lose to `Invalid token format`. */\nconst AUTH_REASONS: [RegExp, AuthFailureReason][] = [\n [/missing bearer token/i, 'missing_bearer'],\n [/invalid token format/i, 'invalid_format'],\n [/token is not active/i, 'token_inactive'],\n [/token[_ ]expired/i, 'token_expired'],\n [/user inactive/i, 'user_inactive'],\n [/invalid token/i, 'invalid_token'],\n];\n\nfunction extractMessage(body: unknown): string | string[] {\n if (typeof body === 'string' && body.trim() !== '') return body;\n if (body !== null && typeof body === 'object') {\n const b = body as { message?: unknown; error?: unknown };\n if (Array.isArray(b.message) || typeof b.message === 'string') {\n return b.message as string | string[];\n }\n if (typeof b.error === 'string') return b.error;\n }\n return '';\n}\n\n/**\n * Map an HTTP error response onto the typed error hierarchy.\n *\n * @remarks\n * The backend registers no global exception filter, so error bodies use Nest's\n * default shape (`{ statusCode, message, error }`) and are NOT wrapped in the\n * `{ statusCode, message, data }` success envelope.\n */\nexport function errorFromResponse(\n status: number,\n body: unknown,\n requestId: string,\n extra: { retryAfterSeconds?: number } = {},\n): GpmPayAPIError {\n const raw = extractMessage(body);\n const msg =\n (Array.isArray(raw) ? raw.join('; ') : raw) || `HTTP ${String(status)}`;\n const ctx: ApiErrorContext = {\n status,\n requestId,\n rawBody: body,\n rawMessage: raw === '' ? msg : raw,\n };\n\n switch (status) {\n case 400:\n return new GpmPayBadRequestError(msg, {\n ...ctx,\n validationMessages: Array.isArray(raw) ? raw : [msg],\n });\n\n case 401: {\n const reason =\n AUTH_REASONS.find(([re]) => re.test(msg))?.[1] ?? 'unknown';\n return new GpmPayAuthenticationError(\n `${msg} — check your API token is correct, ACTIVE and not expired.`,\n { ...ctx, reason },\n );\n }\n\n case 403: {\n const scope = /missing scope:\\s*(\\S+)/i.exec(msg)?.[1];\n if (scope) {\n return new GpmPayPermissionError(\n `API token is missing the \"${scope}\" scope. Regenerate the token with that scope enabled.`,\n { ...ctx, missingScope: scope as ApiTokenScope, reason: 'scope' },\n );\n }\n // `ApiTokenGuard` is fail-closed: a route that declares no `@ApiScopes()`\n // rejects API tokens outright, whoever owns the resource. Reporting that\n // as an ownership problem sends people hunting for the wrong bug.\n if (/not available to api tokens/i.test(msg)) {\n return new GpmPayPermissionError(\n `${msg} — this endpoint is dashboard-only and no API token scope grants it.`,\n { ...ctx, reason: 'endpoint' },\n );\n }\n return new GpmPayPermissionError(\n `${msg} — the resource exists but belongs to another account.`,\n { ...ctx, reason: 'ownership' },\n );\n }\n\n case 404: {\n const resource = /^(.*?)\\s+not found/i.exec(msg)?.[1];\n return new GpmPayNotFoundError(\n msg,\n resource === undefined ? ctx : { ...ctx, resource },\n );\n }\n\n // No 409 case: every route this SDK can reach is either a read or a write\n // with no uniqueness constraint, so a conflict has no source. A 409 from\n // `client.request()` falls through to the generic GpmPayAPIError below.\n\n case 429:\n return new GpmPayRateLimitError(\n msg,\n extra.retryAfterSeconds === undefined\n ? ctx\n : { ...ctx, retryAfterSeconds: extra.retryAfterSeconds },\n );\n\n default:\n return status >= 500\n ? new GpmPayServerError(msg, ctx)\n : new GpmPayAPIError(msg, ctx);\n }\n}\n","import { GpmPayConfigError } from '../core/errors.js';\nimport type { BankAccount, Bank } from '../types/bank.js';\nimport { crc16ccitt } from './crc16.js';\n\n/** VietQR truncates the addendum field; the backend applies the same limit. */\nconst DESCRIPTION_MAX_LENGTH = 25;\n\nexport interface VietQrInput {\n /** NAPAS BIN of the beneficiary bank (`bank.bin`). */\n bankBin: string;\n accountNumber: string;\n /** Omit for a static QR the payer fills in themselves. */\n amount?: number | string;\n /** Truncated to 25 chars, matching the server. */\n description?: string;\n serviceCode?: 'QRIBFTTA' | 'QRIBFTTC';\n}\n\nexport interface VietQrImageInput extends VietQrInput {\n template?: 'compact' | 'compact2' | 'qr_only' | 'print';\n accountName?: string;\n}\n\nconst tlv = (id: string, value: string): string =>\n id + value.length.toString().padStart(2, '0') + value;\n\nfunction isDynamic(amount: number | string | undefined): boolean {\n return amount !== undefined && amount !== null && amount !== '';\n}\n\nfunction trimDescription(description: string | undefined): string | undefined {\n return description === undefined\n ? undefined\n : description.slice(0, DESCRIPTION_MAX_LENGTH);\n}\n\n/**\n * Build an EMVCo/VietQR payload string.\n *\n * @remarks\n * This is the main entry point when you handle reconciliation yourself — the\n * common case. Put your own order code in `description`; it becomes the\n * transfer content, and you match it against `event.payload.content` when the\n * webhook arrives.\n *\n * ```ts\n * const payload = buildVietQrPayload({\n * bankBin: '970422', // account.bank.bin\n * accountNumber: '1234567890',\n * amount: 50_000,\n * description: 'DH123', // your own code — keep it <= 25 chars\n * });\n * ```\n *\n * Also fine for a static account QR: omit `amount` and the payer fills it in.\n *\n * `description` is what the payer ends up transferring, so it carries your\n * reconciliation code. VietQR truncates it to 25 characters and banks may\n * prepend their own prefix — keep the code short and at the front. For the\n * common case, {@link buildPaymentInstructions} wraps this up from a bank\n * account object.\n *\n * Byte-for-byte port of `apps/backend/src/modules/vietqr/vietqr.util.ts`.\n */\nexport function buildVietQrPayload(input: VietQrInput): string {\n const {\n bankBin,\n accountNumber,\n amount,\n description,\n serviceCode = 'QRIBFTTA',\n } = input;\n\n const beneficiaryOrg = tlv('00', bankBin) + tlv('01', accountNumber);\n const merchantAccountInfo =\n tlv('00', 'A000000727') + tlv('01', beneficiaryOrg) + tlv('02', serviceCode);\n\n const dynamic = isDynamic(amount);\n const trimmedDesc = trimDescription(description);\n const additionalData = trimmedDesc ? tlv('08', trimmedDesc) : '';\n\n const parts = [\n tlv('00', '01'),\n tlv('01', dynamic ? '12' : '11'),\n tlv('38', merchantAccountInfo),\n tlv('53', '704'),\n ...(dynamic ? [tlv('54', String(amount))] : []),\n tlv('58', 'VN'),\n ...(additionalData ? [tlv('62', additionalData)] : []),\n ];\n\n const base = parts.join('') + '6304';\n return base + crc16ccitt(base);\n}\n\n/**\n * Build an `img.vietqr.io` image URL.\n *\n * The server always emits the `compact` template; pass `template` to pick a\n * different one for your own checkout page.\n */\nexport function buildVietQrImageUrl(input: VietQrImageInput): string {\n const { bankBin, accountNumber, amount, template = 'compact' } = input;\n const trimmedDesc = trimDescription(input.description);\n\n const url = new URL(\n `https://img.vietqr.io/image/${bankBin}-${accountNumber}-${template}.png`,\n );\n if (isDynamic(amount)) url.searchParams.set('amount', String(amount));\n if (trimmedDesc) url.searchParams.set('addInfo', trimmedDesc);\n if (input.accountName !== undefined) {\n url.searchParams.set('accountName', input.accountName);\n }\n\n return url.toString();\n}\n\nexport interface PaymentRequest {\n /**\n * The destination account, as returned by\n * `client.bankAccounts.retrieve(id)`. Must carry its `bank` relation — the\n * NAPAS BIN lives there.\n */\n bankAccount: BankAccount & { bank?: Bank };\n /** VND. A number, or the string form the API returns for `Decimal`. */\n amount: number | string;\n /**\n * The transfer content you will look for later. Put **your own** order\n * reference in here — GPM Pay mints nothing and matches nothing. Truncated\n * to 25 chars inside the QR, so keep the code short and near the front.\n */\n transferContent: string;\n serviceCode?: 'QRIBFTTA' | 'QRIBFTTC';\n template?: VietQrImageInput['template'];\n}\n\nexport interface PaymentInstructions {\n /** EMVCo payload. Render this as the QR. */\n qrPayload: string;\n qrImageUrl: string;\n amount: number;\n /** What the payer MUST put in the transfer content. */\n transferContent: string;\n bankName: string | undefined;\n bankBin: string | undefined;\n accountNumber: string;\n accountName: string;\n}\n\n/**\n * Everything a checkout page needs, built entirely client-side.\n *\n * @remarks\n * There is no server-side order to read this from — GPM Pay has no\n * order/QR-minting endpoint. You choose the reference code, you render the QR,\n * and you reconcile it yourself when the transaction webhook arrives by looking\n * for that code in `payload.content`.\n *\n * @throws {GpmPayConfigError} when `bankAccount.bank.bin` is missing — without\n * the BIN there is no valid VietQR payload to build.\n *\n * @example\n * const account = await client.bankAccounts.retrieve(bankAccountId);\n * const ref = `SHOP${orderId}`;\n * const instructions = buildPaymentInstructions({\n * bankAccount: account,\n * amount: 250_000,\n * transferContent: ref,\n * });\n */\nexport function buildPaymentInstructions(\n request: PaymentRequest,\n): PaymentInstructions {\n const account = request.bankAccount;\n const bank = account.bank;\n\n if (bank === undefined || bank.bin === '') {\n throw new GpmPayConfigError(\n 'buildPaymentInstructions: bankAccount.bank.bin is required to build a ' +\n 'VietQR payload. Fetch the account with its `bank` relation, e.g. ' +\n '`await client.bankAccounts.retrieve(id)`.',\n );\n }\n const bankBin = bank.bin;\n\n const base: VietQrInput = {\n bankBin,\n accountNumber: account.accountNumber,\n amount: request.amount,\n description: request.transferContent,\n };\n if (request.serviceCode !== undefined) base.serviceCode = request.serviceCode;\n\n const imageInput: VietQrImageInput = {\n ...base,\n accountName: account.ownerName,\n };\n if (request.template !== undefined) imageInput.template = request.template;\n\n return {\n qrPayload: buildVietQrPayload(base),\n qrImageUrl: buildVietQrImageUrl(imageInput),\n amount: Number(request.amount),\n transferContent: request.transferContent,\n bankName: bank.shortName === '' ? bank.name : bank.shortName,\n bankBin,\n accountNumber: account.accountNumber,\n accountName: account.ownerName,\n };\n}\n"]}