UNPKG

@gpmpay/sdk

Version:

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

347 lines (277 loc) • 9.98 kB
# Errors & testing [Tiếng Việt](../vi/04-errors-and-testing.md) · [Index](./README.md) --- ## The error tree ``` GpmPayError .code, GpmPayError.isGpmPayError() ├── GpmPayConfigError local error, NO network call made │ .code: missing_api_token | invalid_api_token │ | invalid_api_token_format | invalid_base_url | invalid_argument ├── GpmPayConnectionError DNS / TCP / TLS .syscallCode, .cause ├── GpmPayTimeoutError .timeoutMs ├── GpmPayWebhookSignatureError .reason └── GpmPayAPIError .status, .requestId, .rawBody, .rawMessage ├── GpmPayBadRequestError 400 .validationMessages: string[] ├── GpmPayAuthenticationError 401 .reason ├── GpmPayPermissionError 403 .missingScope, .reason ├── GpmPayNotFoundError 404 .resource ├── GpmPayRateLimitError 429 .retryAfterSeconds └── GpmPayServerError 5xx ``` > There is no dedicated class for **409**. Every route this SDK exposes is > either a read or a write with no uniqueness constraint, so nothing can produce > a conflict. A 409 seen through `client.request()` arrives as a > `GpmPayAPIError` with `.status === 409`. ## Catch by class, not by string ```ts import { GpmPayPermissionError, GpmPayAuthenticationError, GpmPayBadRequestError, GpmPayServerError, GpmPayError, } from '@gpmpay/sdk'; try { await client.webhookSettings.create({ url, driver: 'HTTP' }); } catch (error) { if (error instanceof GpmPayPermissionError) { // .reason: 'scope' | 'endpoint' | 'ownership' if (error.reason === 'endpoint') { throw new Error('That endpoint is dashboard-only — no scope unlocks it.'); } logger.error(`Token is missing scope: ${error.missingScope}`); throw new Error('Payment misconfigured'); } if (error instanceof GpmPayAuthenticationError) { // token_expired | token_inactive | invalid_token | user_inactive | ... alertOps(`GPM Pay token is broken: ${error.reason}`); throw error; } if (error instanceof GpmPayBadRequestError) { logger.warn({ messages: error.validationMessages }, 'invalid payload'); } if (GpmPayError.isGpmPayError(error)) { logger.error({ code: error.code, requestId: (error as any).requestId }); } throw error; } ``` > If the project could end up with both a CJS and an ESM copy of the SDK, > `instanceof` breaks. Use `GpmPayError.isGpmPayError(error)` for the catch-all > branch. ## Always log `requestId` Every `GpmPayAPIError` carries `.requestId` (the SDK sends it as the `X-GPMPay-Request-Id` header). Quote it in support tickets to pinpoint the request in server logs. ```ts logger.error({ requestId: error.requestId, status: error.status, code: error.code, }, 'GPM Pay API error'); ``` ## 401 `.reason` values | `.reason` | Meaning | What to do | |---|---|---| | `token_expired` | past the token's `expiresAt` | create a new token | | `token_inactive` | owner paused the token | re-enable it in the dashboard | | `invalid_token` | does not exist / wrong secret | create a new token | | `invalid_format` | not a `gpm_…` token | wrong environment variable | | `user_inactive` | GPM Pay account disabled | contact support | | `missing_bearer` | no Authorization header | internal SDK bug, report it | The distinction lets you alert correctly: `token_expired` is an action item; `invalid_token` may just be a bad deploy env. ## Retries: what the SDK already does | Case | Behaviour | |---|---| | `GET`/`HEAD`/`DELETE` on 408/425/429/5xx or a network error | retried, full-jitter exponential backoff, capped at 8s | | `POST`/`PATCH` on **429** | retried (429 means the request was never processed) | | `POST`/`PATCH` on 5xx or a network error | **not** retried | | `Retry-After` present | honoured, overriding the computed backoff | Why POST is not retried on 5xx: the response can be lost *after* the server already applied the write, so a blind retry would duplicate it. Make the request safe to repeat yourself (derive identifiers deterministically) rather than retrying blindly. Tuning: ```ts new GpmPay({ apiToken, maxRetries: 3, // default 2; 0 disables retryBaseDelayMs: 500, timeoutMs: 30_000, }); ``` ## Cancellation ```ts const controller = new AbortController(); setTimeout(() => controller.abort(), 5000); try { await client.transactions.list({ signal: controller.signal }); } catch (error) { if (error instanceof AbortError) { /* you cancelled */ } if (error instanceof GpmPayTimeoutError) { /* the SDK's own timeout */ } } ``` The SDK distinguishes the two: a timeout becomes `GpmPayTimeoutError`, and `AbortError` only when *you* aborted. ## Observability ```ts new GpmPay({ apiToken, onRequest: ({ method, url, requestId, attempt }) => { logger.debug({ method, url, requestId, attempt }, 'gpmpay →'); }, onResponse: ({ requestId, status, durationMs, attempt }) => { metrics.timing('gpmpay.request', durationMs, { status }); }, }); ``` The hooks **never** receive the token. `client.toString()` and `console.log(client)` only ever show the public prefix. --- # Testing ## End-to-end in the sandbox ```ts const client = new GpmPay({ apiToken: process.env.GPMPAY_SANDBOX_TOKEN!, sandbox: true }); // The happy path: your code, your amount. await client.simulator.createTransaction({ bankAccountId, amount: 50_000, transferContent: 'DH123', }); ``` Your webhook endpoint then receives a real delivery — provided the endpoint has `fireOnSimulated` enabled. Construct the unhappy paths too, because your handler owns all of them now: ```ts // Underpayment — your handler must NOT fulfil await client.simulator.createTransaction({ bankAccountId, amount: 49_000, transferContent: 'DH123', }); // No code at all — your handler must ignore it await client.simulator.createTransaction({ bankAccountId, amount: 50_000, transferContent: 'random transfer', }); // A bank that prepends its own prefix — your regex must still find the code await client.simulator.createTransaction({ bankAccountId, amount: 50_000, transferContent: 'CT DEN:0123456789 DH123 NguyenVanA', }); ``` The simulator refuses to run against production (a base URL without `localhost`/`sandbox`) unless passed `{ allowOnProduction: true }`. ## Unit tests: inject `fetch` No msw or nock needed — the SDK accepts a `fetch` implementation. ```ts import { GpmPay } from '@gpmpay/sdk'; const TEST_TOKEN = 'gpm_TESTpub1_abcdefghijklmnopqrstuvwx'; // well-formed, not real function fakeFetch(body: unknown, status = 200) { return async () => new Response(JSON.stringify({ statusCode: status, message: '', data: body }), { status, headers: { 'content-type': 'application/json' }, }); } it('retrieves a transaction', async () => { const client = new GpmPay({ apiToken: TEST_TOKEN, fetch: fakeFetch({ id: 'tx_1', amount: '50000', transferContent: 'DH123' }), }); const txn = await client.transactions.retrieve('tx_1'); expect(txn.transferContent).toBe('DH123'); }); ``` Remember every API response is wrapped in `{ statusCode, message, data }`; the SDK unwraps it. Simulating an error: ```ts const client = new GpmPay({ apiToken: TEST_TOKEN, fetch: async () => new Response( JSON.stringify({ statusCode: 403, message: 'Missing scope: webhooks:manage' }), { status: 403, headers: { 'content-type': 'application/json' } }, ), }); const error = await client.webhookSettings.list().catch((e) => e); expect(error).toBeInstanceOf(GpmPayPermissionError); expect(error.missingScope).toBe('webhooks:manage'); ``` ## Testing a webhook handler Sign a body yourself instead of needing a live server: ```ts import { signWebhookPayload } from '@gpmpay/sdk/webhooks'; const secret = 'whsec_test'; const rawBody = JSON.stringify({ id: 'tx_1', gateway: 'MB', transferType: 'in', transferAmount: 50_000, content: 'CT DEN:0123456789 DH123 NguyenVanA', referenceCode: 'FT2508071234', source: 'SIMULATED', }); const response = await fetch('http://localhost:3000/webhooks/gpmpay', { method: 'POST', headers: { 'content-type': 'application/json', 'x-gpmpay-signature': signWebhookPayload({ rawBody, secret }), }, body: rawBody, }); expect(response.status).toBe(200); ``` Cover the unhappy paths too: ```ts // bad signature → must 401 and must NOT fulfil // same payload.id twice → must fulfil exactly once ``` Make time deterministic by injecting `now`: ```ts import { verifyWebhookSignature } from '@gpmpay/sdk/webhooks'; verifyWebhookSignature({ rawBody, signature, secret, now: () => 1785600000 * 1000, // freeze the clock so skew never interferes }); ``` ## Testing the back-fill job The reconciliation sweep is ordinary code over `transactions.listAll()`, so test it with an injected `fetch` that returns two pages and assert every matching transaction was reconciled exactly once: ```ts const seen: string[] = []; for await (const txn of client.transactions.listAll({ type: 'IN' })) { const code = /DH(\d+)/.exec(txn.transferContent)?.[0]; if (code) seen.push(code); } expect(seen).toEqual(['DH123', 'DH124']); ``` Worth covering: a transaction already processed by the webhook (must not double fulfil), and a page boundary landing mid-way through the results. ## Token checks in CI ```bash npx gpmpay ping --json ``` ```json { "ok": true, "baseUrl": "https://api.gpmpay.com/api/v1", "tokenPrefix": "gpm_a1b2c3d4", "latencyMs": 182, "scopes": { "granted": ["transactions:read", "bank-accounts:read", "webhooks:manage"], "denied": [] } } ``` Add it to a pipeline to catch an expiring token before it breaks production: ```bash npx gpmpay ping --json > /dev/null || exit 1 ``` Exit codes: `0` ok · `2` missing token / bad usage · `3` token rejected · `4` network failure.