@gpmpay/sdk
Version:
Official Node.js SDK for GPM Pay — VietQR codes, transaction webhooks, and payment reconciliation.
425 lines (306 loc) • 18.4 kB
Markdown
# @gpmpay/sdk
SDK Node.js chính thức cho [GPM Pay](https://gpmpay.com) — dựng QR VietQR, nhận webhook giao dịch, tự đối soát thanh toán.
**Zero dependency** · Node >= 18.17 · TypeScript sẵn có · ESM + CommonJS · MIT
🇬🇧 [English](https://unpkg.com/browse/@gpmpay/sdk/README.en.md) · 📚 [Tài liệu trực tuyến](https://app.gpmpay.com/docs#nodejs-sdk)
> **Tài liệu chuyên sâu nằm ngay trong package.** Đã cài rồi thì mở
> `node_modules/@gpmpay/sdk/docs/vi/` (5 guide) · `AGENTS.md` (chỉ dẫn cho AI agent) · `examples/` (ví dụ chạy được).
> Chưa cài thì đọc thẳng trên web: **[duyệt toàn bộ file của package](https://unpkg.com/browse/@gpmpay/sdk/)** — hoặc xem bảng "Tài liệu" ở cuối trang.
> ⚠️ **Chỉ dùng phía server.** API token là secret. Đưa nó ra trình duyệt hoặc app mobile đồng nghĩa trao quyền truy cập tài khoản GPM Pay của bạn cho bất kỳ ai xem được mã nguồn.
---
## Cài đặt
```bash
pnpm add @gpmpay/sdk # hoặc: npm i @gpmpay/sdk / yarn add @gpmpay/sdk
```
## GPM Pay làm gì
Không có cổng thanh toán nào giữ tiền. Khách chuyển khoản bình thường vào tài khoản của bạn; GPM Pay theo dõi biến động số dư và bắn webhook cho **mọi** giao dịch tiền vào, kèm số tiền và nội dung chuyển khoản.
**Việc đối soát là của bạn.** Bạn tự sinh mã đơn, nhét vào nội dung chuyển khoản, rồi khi webhook về thì dò lại mã đó trong `payload.content` và so số tiền. GPM Pay không sinh mã, không giữ đơn, không khớp lệnh thay bạn — nó là đường ống báo giao dịch.
Toàn bộ mô hình, kèm các bẫy đối soát thực tế: [`docs/vi/02-payments.md`](https://unpkg.com/browse/@gpmpay/sdk/docs/vi/02-payments.md).
## Bắt đầu trong 60 giây
**1.** Tạo API token tại <https://app.gpmpay.com/api-tokens> với scope `webhooks:manage` và `bank-accounts:read`.
**2.** Cài đặt và kiểm tra token:
```bash
export GPMPAY_API_TOKEN=gpm_xxxxxxxx_yyyyyyyyyyyyyyyyyyyyyyyy
pnpm add @gpmpay/sdk
npx gpmpay ping
```
```
✓ Connected to https://api.gpmpay.com/api/v1 (182 ms)
Token gpm_a1b2c3d4••••••••
Scopes bank-accounts:read, webhooks:manage
Missing transactions:read
```
**3.** Dựng QR với mã của bạn:
```ts
import { GpmPay } from '@gpmpay/sdk';
import { buildPaymentInstructions } from '@gpmpay/sdk/vietqr';
const client = new GpmPay({ apiToken: process.env.GPMPAY_API_TOKEN! });
const account = (await client.bankAccounts.list({ status: 'ACTIVE' })).data[0]!;
const code = `DH${localOrder.id}`; // mã của bạn — khách phải ghi chuỗi này
const { qrImageUrl, transferContent } = buildPaymentInstructions({
bankAccount: account,
amount: Math.round(localOrder.total), // VND, số nguyên
transferContent: code,
});
```
**4.** Đối chiếu khi webhook về:
```ts
onEvent: async (event) => {
const code = /DH(\d+)/.exec(event.payload.content)?.[0];
const order = code && await db.orders.findByCode(code);
if (order && order.total === event.payload.transferAmount) {
await giaoHang(order);
}
}
```
Cách dựng webhook đầy đủ ở [mục dưới](#webhook).
---
## Cấu hình
```ts
const client = new GpmPay({
apiToken: process.env.GPMPAY_API_TOKEN!, // BẮT BUỘC
sandbox: false, // true → môi trường thử nghiệm
timeoutMs: 30_000,
maxRetries: 2,
userAgent: 'my-shop/2.1',
defaultHeaders: { 'X-Trace-Id': traceId }, // không ghi đè được Authorization
onRequest: (e) => logger.debug(e), // không bao giờ nhận token
onResponse: (e) => metrics.timing(e.durationMs),
});
const client = GpmPay.fromEnv(); // đọc GPMPAY_API_TOKEN
```
SDK **không khởi tạo được nếu thiếu token** — sai cấu hình lộ ra lúc deploy, không phải lúc khách đầu tiên bấm thanh toán:
```ts
new GpmPay({ apiToken: 'sk_live_x' }); // ném GpmPayConfigError — 'invalid_api_token_format'
```
SDK **không bao giờ in token ra**. `client.toString()` và `console.log(client)` chỉ hiện prefix công khai (`gpm_a1b2c3d4••••••••`), an toàn để log và dán vào ticket hỗ trợ.
### Bảng scope
Backend chỉ còn **ba** scope:
| Scope | Method dùng được |
|---|---|
| `webhooks:manage` | `webhookSettings.*`, `webhookHistories.*` |
| `bank-accounts:read` | `bankAccounts.list`, `bankAccounts.retrieve`, `banks.list` |
| `transactions:read` | `transactions.list`, `transactions.listAll`, `transactions.retrieve`, `simulator.createTransaction` |
Token thiếu scope sẽ nhận `GpmPayPermissionError` với `.missingScope` chỉ đúng scope còn thiếu.
> ⚠️ **`ApiTokenGuard` của backend là fail-closed.** Endpoint nào không khai báo scope thì **mọi** API token đều bị chặn (403), bất kể sở hữu tài khoản. Vì vậy SDK chỉ mô hình hoá đúng những route API token gọi được — vd `client.apiTokens` chỉ có `remove()`, vì các route quản lý token còn lại là dashboard-only. Trường hợp này trả `GpmPayPermissionError` với `.reason === 'endpoint'`.
---
## Webhook
### Xác thực chữ ký
Header gửi kèm **phụ thuộc vào `authorizationType`** bạn đặt trên webhook setting — ba chế độ dùng ba header hoàn toàn khác nhau:
| `authorizationType` | Header GPM Pay gửi | Cách kiểm tra |
|---|---|---|
| `HMAC` *(mặc định)* | `X-GPMPay-Signature: t=<unix>,v1=<hex>` — đổi tên được qua `authorizationHeaderName` | `constructWebhookEvent()` |
| `API_KEY` | Header bạn tự đặt, **mặc định `Authorization`**; giá trị là **secret thô, không có prefix `Bearer`** | `verifyApiKeyHeader(received, expected)` |
| `NONE` | **Không có header xác thực nào** | Không xác thực được — chỉ dùng cho endpoint nội bộ |
Ba điểm hay bị hiểu nhầm:
- **Không tồn tại header `X-GPMPay-Timestamp`.** Timestamp nằm trong `t=` bên trong giá trị chữ ký.
- **Driver `HTTP` không gửi `X-GPMPay-Event`** — chỉ driver WordPress gửi. `event.type` là giá trị mặc định phía SDK.
- Enum là `HMAC`, **không phải** `HMAC_SHA256`. Thuật toán là SHA-256, tên enum thì không.
Với `HMAC`: chữ ký ký trên chuỗi `` `${t}.${rawBody}` ``, cửa sổ lệch giờ ±300 giây.
```ts
import { constructWebhookEvent } from '@gpmpay/sdk/webhooks';
const event = constructWebhookEvent({
rawBody, // BYTE THÔ, không phải object đã parse
signature: headers['x-gpmpay-signature'],
secret: process.env.GPMPAY_WEBHOOK_SECRET!,
});
if (event.payload.transferType === 'in') {
await doiSoat(event.payload);
}
```
Với `API_KEY`:
```ts
import { verifyApiKeyHeader } from '@gpmpay/sdk/webhooks';
if (!verifyApiKeyHeader(headers.authorization, process.env.GPMPAY_WEBHOOK_SECRET!)) {
return res.status(401).end();
}
```
> ⚠️ **Phải dùng raw body.** `JSON.stringify(req.body)` làm đổi thứ tự key và khoảng trắng, chữ ký sẽ **luôn** sai. SDK phát hiện và báo lỗi rõ ràng thay vì để bạn ngồi đoán.
### Express
```ts
import express from 'express';
import { gpmpayWebhook } from '@gpmpay/sdk/webhooks';
app.post(
'/webhooks/gpmpay',
express.raw({ type: 'application/json' }), // ← bắt buộc
gpmpayWebhook({
secret: process.env.GPMPAY_WEBHOOK_SECRET!,
onEvent: async (event) => {
const code = /DH(\d+)/.exec(event.payload.content)?.[0];
if (code) await doiSoat(code, event.payload.transferAmount);
},
}),
);
```
Nếu `express.json()` đã chạy toàn cục, bắt raw body bằng hook `verify`:
```ts
app.use(express.json({ verify: (req, _res, buf) => { (req as any).rawBody = buf; } }));
```
**Next.js (App + Pages Router), Fastify, Hono / Cloudflare Workers / Deno, và framework bất kỳ:** [`docs/vi/03-webhooks.md` §4](https://unpkg.com/browse/@gpmpay/sdk/docs/vi/03-webhooks.md).
### Đăng ký endpoint và lấy secret
```ts
const { setting, secret } = await client.webhookSettings.createHmacEndpoint({
url: 'https://shop.example.com/webhooks/gpmpay',
});
console.log(secret); // ← chỉ hiện MỘT LẦN, lưu ngay vào GPMPAY_WEBHOOK_SECRET
```
### Retry và idempotency
GPM Pay huỷ delivery sau **5 giây** và thử lại theo lịch `10s → 30s → 2m → 10m → 1h → 6h`, tối đa 6 lần.
- Trả `200` **nhanh**, xử lý sau (`gpmpayWebhook` mặc định làm vậy — `respondEarly: true`).
- Endpoint của bạn **phải idempotent theo `payload.id`** — cùng một giao dịch có thể tới nhiều lần.
```ts
import { WEBHOOK_RETRY_SCHEDULE_SECONDS, WEBHOOK_MAX_ATTEMPTS } from '@gpmpay/sdk/webhooks';
```
### Payload
```ts
event.payload.id // id giao dịch — DÙNG LÀM KHOÁ IDEMPOTENCY
event.payload.content // nội dung chuyển khoản, cắt còn 100 ký tự — MÃ CỦA BẠN Ở ĐÂY
event.payload.transferAmount // number, không phải string
event.payload.referenceCode // mã giao dịch CỦA NGÂN HÀNG — không phải mã đơn của bạn
```
Đủ 11 field, kèm chỗ dễ nhầm giữa `content` và `referenceCode`: [`docs/vi/03-webhooks.md` §2](https://unpkg.com/browse/@gpmpay/sdk/docs/vi/03-webhooks.md).
---
## VietQR
Toàn bộ phần này chạy **thuần client, không gọi mạng** — backend không có endpoint VietQR nào.
```ts
import {
buildPaymentInstructions,
buildVietQrPayload,
buildVietQrImageUrl,
} from '@gpmpay/sdk/vietqr';
const account = await client.bankAccounts.retrieve(bankAccountId);
// Cách gọn nhất: một lần gọi ra đủ thứ trang thanh toán cần.
const info = buildPaymentInstructions({
bankAccount: account, // phải kèm quan hệ `bank` (BIN nằm trong đó)
amount: 250_000,
transferContent: 'DH1042', // MÃ CỦA BẠN — tự sinh, tự đối soát
});
// → { qrPayload, qrImageUrl, amount, transferContent, bankName, bankBin, accountNumber, accountName }
// Hoặc dựng từng phần nếu bạn đã có sẵn BIN và số tài khoản:
buildVietQrPayload({ bankBin: '970422', accountNumber: '1234567890', amount: 250_000, description: 'DH1042' });
buildVietQrImageUrl({ bankBin: '970422', accountNumber: '1234567890', amount: 250_000, description: 'DH1042' });
```
> ⚠️ **Mã đối soát là của bạn.** GPM Pay không sinh mã nào cả. Hãy chọn mã ngắn, không dấu, và **đặt ở đầu** nội dung chuyển khoản — VietQR cắt phần mô tả còn 25 ký tự, và một số ngân hàng còn chèn thêm tiền tố của riêng họ vào `content`. Nên dò bằng regex thay vì so bằng `===`.
---
## CLI
```
gpmpay ping Kiểm tra token + probe từng scope xem có thật không
gpmpay accounts list Liệt kê tài khoản ngân hàng — nguồn của --account
gpmpay accounts get <id>
gpmpay transactions list [--limit <n>] [--account <uuid>] [--type IN|OUT]
gpmpay simulate tx --account <uuid> --amount <vnd> --content <text>
gpmpay webhook send --url <url> Ký payload mẫu rồi POST vào handler của bạn
gpmpay webhook listen [--port 4444] [--secret <s>]
gpmpay webhook verify --signature "t=..,v1=.." [--file body.json]
gpmpay webhook settings Endpoint đã đăng ký + chế độ xác thực của từng cái
gpmpay webhook history Lịch sử giao, mã lỗi, số lần thử
gpmpay webhook retry <id> Đẩy lại một lần giao thất bại
--token --sandbox --json --no-color -h -v
```
Exit code: `0` OK · `1` lỗi chung · `2` sai cú pháp / thiếu token · `3` xác thực thất bại (401) · `4` mạng/timeout. `--json` tự che mọi trường secret.
### Test luồng mà không cần tiền thật
```bash
npx gpmpay accounts list # copy một uuid ra
npx gpmpay webhook listen --port 4444 --secret $GPMPAY_WEBHOOK_SECRET
# terminal khác — bắn một event đã ký vào handler của bạn.
# Không cần API token, không gọi API GPM Pay:
npx gpmpay webhook send --url http://localhost:4444 --secret $GPMPAY_WEBHOOK_SECRET
# handler phải TỪ CHỐI hai lệnh này:
npx gpmpay webhook send --url http://localhost:4444 --secret $GPMPAY_WEBHOOK_SECRET --bad-signature
npx gpmpay webhook send --url http://localhost:4444 --secret $GPMPAY_WEBHOOK_SECRET --skew 600
```
Muốn chính GPM Pay bắn webhook thật (thay vì CLI giả lập) thì dùng `simulate` trên sandbox:
```bash
npx gpmpay simulate tx --sandbox --account <uuid> --amount 50000 --content DH1042
npx gpmpay webhook history --sandbox # xem đã giao chưa, mã lỗi là gì
```
> `simulate` từ chối chạy trên production trừ khi truyền `--allow-production`. Webhook chỉ bắn cho endpoint bật `fireOnSimulated` — kiểm tra bằng `gpmpay webhook settings`.
---
## Xử lý lỗi
```
GpmPayError
├── GpmPayConfigError lỗi cục bộ, chưa gọi mạng
├── GpmPayConnectionError DNS/TCP/TLS (.syscallCode)
├── GpmPayTimeoutError (.timeoutMs)
├── GpmPayWebhookSignatureError (.reason)
└── GpmPayAPIError (.status, .requestId, .rawBody)
├── GpmPayBadRequestError 400 (.validationMessages)
├── GpmPayAuthenticationError 401 (.reason)
├── GpmPayPermissionError 403 (.missingScope, .reason)
├── GpmPayNotFoundError 404 (.resource)
├── GpmPayRateLimitError 429 (.retryAfterSeconds)
└── GpmPayServerError 5xx
```
`GpmPayPermissionError.reason` phân biệt ba kiểu 403:
| `.reason` | Nghĩa |
|---|---|
| `'scope'` | Token hợp lệ nhưng thiếu scope — xem `.missingScope` |
| `'endpoint'` | Route này dashboard-only, **không** scope nào mở được |
| `'ownership'` | Tài nguyên tồn tại nhưng thuộc tài khoản khác |
```ts
try {
await client.webhookSettings.create({ ... });
} catch (error) {
if (error instanceof GpmPayPermissionError) {
console.error('403:', error.reason, error.missingScope);
}
}
```
Mọi `GpmPayAPIError` đều mang `.requestId` — dán vào ticket hỗ trợ để tra log server. Nếu lẫn bản CJS và ESM trong cùng tiến trình, `instanceof` có thể sai; dùng `GpmPayError.isGpmPayError(error)`.
---
## Kiểu dữ liệu — 3 điểm cần nhớ
| | Đọc về | Gửi đi |
|---|---|---|
| **Tiền** | `string` (`"50000"`, do Prisma `Decimal`) | `number` nguyên (`50000`) |
| **Thời gian** | ISO `string` | `string \| Date` |
| **Enum** | string-literal union, không phải TS `enum` | |
```ts
import { toVnd, formatVnd } from '@gpmpay/sdk';
toVnd(transaction.amount); // 50000
formatVnd(transaction.amount); // '50.000 ₫'
```
**Phân trang:** `limit` bị API giới hạn tối đa 50, SDK tự clamp và cảnh báo một lần. Cần duyệt hết thì dùng `transactions.listAll()` — nó tự đi từng trang.
---
## Sandbox & testing
```ts
const client = new GpmPay({ apiToken, sandbox: true });
// Trả envelope, không phải Transaction trần.
const { transaction, historyIds } = await client.simulator.createTransaction({
bankAccountId,
amount: 50_000,
transferContent: 'DH1042', // đúng mã bạn sẽ đối soát
});
// historyIds rỗng = chưa endpoint nào bật fireOnSimulated, handler sẽ không được gọi.
console.log(transaction.id, historyIds.length);
```
`simulator` từ chối chạy trên production trừ khi truyền `{ allowOnProduction: true }`. Trong unit test thì inject `fetch` (`new GpmPay({ apiToken, fetch: myMockFetch })`) thay vì gọi mạng thật — chi tiết ở [`docs/vi/04-errors-and-testing.md`](https://unpkg.com/browse/@gpmpay/sdk/docs/vi/04-errors-and-testing.md).
---
## Chuyển từ `fetch` thô
| Trước | Sau |
|---|---|
| `JSON.parse(res).data.data` + `.meta` | `client.transactions.list()` → `{ data, meta }` |
| `if (res.status === 403) { ... }` | `catch (e) { if (e instanceof GpmPayPermissionError) ... }` |
| Tự viết HMAC verify | `constructWebhookEvent()` |
| Tự nối chuỗi EMVCo + CRC16 | `buildVietQrPayload()` |
| `Number(tx.amount)` rải rác | `toVnd(tx.amount)` |
---
## Tài liệu
Mọi file dưới đây ship kèm package (có sẵn trong `node_modules/@gpmpay/sdk/`) và
đọc được ngay trên web mà không cần cài gì:
| Tài liệu | Nội dung |
|---|---|
| [`docs/vi/01-getting-started.md`](https://unpkg.com/browse/@gpmpay/sdk/docs/vi/01-getting-started.md) | Từ 0 đến khoản thanh toán đầu tiên |
| [`docs/vi/02-payments.md`](https://unpkg.com/browse/@gpmpay/sdk/docs/vi/02-payments.md) | Mô hình tự đối soát, VietQR, các bẫy khớp mã |
| [`docs/vi/03-webhooks.md`](https://unpkg.com/browse/@gpmpay/sdk/docs/vi/03-webhooks.md) | Đăng ký, 3 chế độ xác thực, Express/Next/Fastify/Hono, retry, debug |
| [`docs/vi/04-errors-and-testing.md`](https://unpkg.com/browse/@gpmpay/sdk/docs/vi/04-errors-and-testing.md) | Cây lỗi, retry, sandbox, unit test |
| [`docs/vi/05-api-reference.md`](https://unpkg.com/browse/@gpmpay/sdk/docs/vi/05-api-reference.md) | Mọi option, method, kiểu dữ liệu |
| [`AGENTS.md`](https://unpkg.com/browse/@gpmpay/sdk/AGENTS.md) | Chỉ dẫn cho AI coding agent |
| [`examples/`](https://unpkg.com/browse/@gpmpay/sdk/examples/) | Ví dụ chạy được |
| [`docs/en/`](https://unpkg.com/browse/@gpmpay/sdk/docs/en/) | Toàn bộ nội dung trên, bản tiếng Anh |
Duyệt toàn bộ file của package: <https://unpkg.com/browse/@gpmpay/sdk/> · Trang docs: <https://app.gpmpay.com/docs#nodejs-sdk>. Cần markdown thô (cho AI agent, `curl`, script) thì đổi `unpkg.com/browse/` thành `cdn.jsdelivr.net/npm/`.
## Tương thích
- Node >= 18.17 (cần `fetch`, `AbortSignal.timeout`, `node:util.parseArgs`)
- ESM và CommonJS đều dùng được
- TypeScript: khai báo kiểu đi kèm, không cần `@types/*`
- **Không** hỗ trợ trình duyệt — API token là secret phía server
## License
MIT © GPM Softwares