@gpmpay/sdk
Version:
Official Node.js SDK for GPM Pay — VietQR codes, transaction webhooks, and payment reconciliation.
206 lines (149 loc) • 7.31 kB
Markdown
# Thanh toán & đối soát
[English](../en/02-payments.md) · [Mục lục](./README.md)
---
## Mô hình
GPM Pay theo dõi tài khoản ngân hàng của bạn và POST một webhook cho **mọi**
giao dịch tiền vào, kèm số tiền và nội dung chuyển khoản. Đó là toàn bộ sản phẩm.
**Việc đối soát là của bạn.** Chỉ có một mô hình tích hợp duy nhất:
1. Bạn tự sinh mã thanh toán.
2. Bạn dựng VietQR mang mã đó làm nội dung chuyển khoản — thuần local, không gọi API.
3. Khách chuyển tiền. GPM Pay POST giao dịch về cho bạn.
4. Bạn dò mã của mình trong `payload.content`, tự so số tiền, rồi giao hàng.
GPM Pay không sinh mã, không giữ trạng thái đơn, không khớp lệnh thay bạn. Bảng
đơn hàng của chính bạn là nguồn sự thật duy nhất — thường thì đó cũng là điều
bạn muốn, vì không còn thực thể thứ hai phải giữ đồng bộ.
> **Nâng cấp từ 0.2.x?** Lớp `client.orders.*`, `waitForPayment()`,
> `payload.order`, `payload.code` và hai scope `orders:read`/`orders:write` đã bị
> gỡ khỏi hệ thống ở bản 0.3.0. Xem [CHANGELOG](../../CHANGELOG.md) để biết cách
> chuyển đổi.
---
## 1. Dựng QR với mã của bạn
```ts
import { buildPaymentInstructions } from '@gpmpay/sdk/vietqr';
const account = await client.bankAccounts.retrieve(bankAccountId);
// hoặc: (await client.bankAccounts.list({ status: 'ACTIVE' })).data[0]!
const code = `DH${localOrder.id}`;
const info = buildPaymentInstructions({
bankAccount: account, // phải kèm quan hệ `bank`
amount: Math.round(localOrder.total), // VND, số nguyên
transferContent: code,
});
// → { qrPayload, qrImageUrl, amount, transferContent,
// bankName, bankBin, accountNumber, accountName }
```
Lưu `code` vào đơn hàng local. Hiển thị QR, **và** hiển thị `code` dưới dạng chữ
copy được — đừng chỉ nhét nó trong ảnh.
### Chọn mã như thế nào
Đây là quyết định quyết định việc đối soát có chạy hay không.
| Nguyên tắc | Vì sao |
|---|---|
| **Ngắn, chỉ `A-Z0-9`** | VietQR cắt phần mô tả còn **25 ký tự**; ngân hàng bỏ dấu và loại ký tự đặc biệt |
| **Đặt ở đầu nội dung** | Một số ngân hàng chèn tiền tố riêng (`CT DEN:...`), phần đuôi là phần bị cắt |
| **Tiền tố cố định + id** (`DH123`) | Dò lại chỉ bằng một regex |
| **Duy nhất mỗi lần thanh toán, lưu lại** | Đây là thứ duy nhất nối giao dịch ngân hàng về đúng đơn |
| **Sinh ra từ id đơn** | Form bị submit hai lần sẽ ra **cùng** một mã, thay vì tạo thêm một khoản chờ mới |
Nếu bạn đã có sẵn BIN và số tài khoản thì bỏ qua bước lấy account:
```ts
import { buildVietQrPayload, buildVietQrImageUrl } from '@gpmpay/sdk/vietqr';
buildVietQrPayload({ bankBin: '970422', accountNumber: '1234567890', amount: 50_000, description: 'DH123' });
buildVietQrImageUrl({ bankBin: '970422', accountNumber: '1234567890', amount: 50_000, description: 'DH123' });
```
## 2. Đối soát khi webhook về
```ts
onEvent: async (event) => {
const { id: transactionId, content, transferAmount, transferType } = event.payload;
if (transferType !== 'in') return;
// Dùng regex, không dùng `content === code`: ngân hàng có chuẩn hoá nội dung.
const code = /DH(\d+)/.exec(content)?.[0];
if (!code) return; // tiền vào nhưng không mang mã của bạn
const order = await db.orders.findByCode(code);
if (!order) return;
// KHÔNG có ai so số tiền hộ bạn.
if (order.total !== transferAmount) {
await flagUnderpayment(order, transferAmount);
return;
}
await fulfil(order, transactionId);
}
```
Ba việc **bạn bắt buộc phải tự làm**, vì không có gì làm thay:
1. **Dò mã** — bằng regex, trên `payload.content`.
2. **So số tiền** — GPM Pay không biết đơn của bạn giá bao nhiêu.
3. **Hết hạn** — tự đặt luật cho các đơn chưa trả để lâu.
> `payload.content` chứa mã của bạn. `payload.referenceCode` là mã giao dịch
> **của ngân hàng**, không liên quan gì đến đơn hàng — dò theo nó thì không bao
> giờ khớp. Đây là lỗi tích hợp phổ biến nhất.
## 3. Đối soát bù bằng danh sách giao dịch
Cho job đối soát định kỳ, hoặc để cứu các webhook bị lỡ:
```ts
for await (const txn of client.transactions.listAll({
type: 'IN',
startDate: yesterday,
})) {
const code = /DH(\d+)/.exec(txn.transferContent)?.[0];
if (code) await reconcile(code, toVnd(txn.amount), txn.id);
}
```
Đây cũng là câu trả lời khi bạn không có URL public để nhận webhook: chạy đúng
logic dò mã trên theo lịch.
---
## Xem giao dịch
```ts
const page = await client.transactions.list({
type: 'IN',
startDate: '2026-08-01',
endDate: '2026-08-31',
bankAccountId,
});
console.log(page.data, page.meta); // { page, limit, totalItems, totalPages }
```
Duyệt hết mọi trang bằng async iterator:
```ts
for await (const txn of client.transactions.listAll({ type: 'IN' })) {
await recordInLedger(txn);
}
```
Cần biết:
- `limit` bị chặn tối đa **50** ở phía server. SDK tự clamp và cảnh báo một lần
thay vì cắt bớt âm thầm.
- Với transaction, `startDate`/`endDate` lọc theo `transactionTime`; tài nguyên
khác lọc theo `createdAt`.
- `search` khớp trên `referenceCode` và `transferContent`.
## Tiền: đọc ra string, gửi đi number
Cột tiền là `Decimal` trong DB nên khi đọc về là **string**, còn khi gửi đi phải
là **số nguyên**.
```ts
import { toVnd, formatVnd } from '@gpmpay/sdk';
txn.amount // "50000" ← string
toVnd(txn.amount) // 50000 ← number
formatVnd(txn.amount) // "50.000 ₫"
// Đừng làm thế này:
txn.amount + 1000 // "500001000" 😱
```
SDK **không** tự ép kiểu — biến đổi response âm thầm là loại bug khó lần hơn
nhiều. Cần tính toán thì gọi `toVnd()`.
Ngoại lệ: payload webhook vốn đã là number (`transferAmount`, `accumulated`).
## Chi tiết VietQR
QR tĩnh — hiện tài khoản, để khách tự nhập số tiền:
```ts
const payload = buildVietQrPayload({
bankBin: '970422',
accountNumber: '1234567890',
});
```
Đổi template ảnh:
```ts
buildVietQrImageUrl({
bankBin: '970422',
accountNumber: '1234567890',
amount: 50_000,
description: 'DH123',
template: 'qr_only', // 'compact' | 'compact2' | 'qr_only' | 'print'
});
```
`buildPaymentInstructions` ném `GpmPayConfigError` nếu bank account không kèm
quan hệ `bank` — mã BIN của NAPAS nằm trong đó, thiếu nó thì không dựng được
payload hợp lệ. Hãy lấy account qua `client.bankAccounts.*`, các method này luôn
kèm sẵn `bank`.
> Bản port VietQR của SDK được đối chiếu từng byte với backend qua fixture dùng
> chung, nên payload sinh ra ở hai phía là giống hệt nhau.