UNPKG

@gpmpay/sdk

Version:

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

206 lines (149 loc) • 7.31 kB
# 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.