@gpmpay/sdk
Version:
Official Node.js SDK for GPM Pay — VietQR codes, transaction webhooks, and payment reconciliation.
347 lines (277 loc) • 9.98 kB
Markdown
# 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.