@gpmpay/sdk
Version:
Official Node.js SDK for GPM Pay — VietQR codes, transaction webhooks, and payment reconciliation.
209 lines (152 loc) • 6.7 kB
Markdown
# Payments & reconciliation
[Tiếng Việt](../vi/02-payments.md) · [Index](./README.md)
## The model
GPM Pay watches your bank account and POSTs a webhook for **every** incoming
transaction, carrying the amount and the transfer content. That is the whole
product.
**Reconciliation is yours.** There is one integration shape:
1. You mint a payment code.
2. You build a VietQR carrying that code as the transfer content — locally, no
API call.
3. The payer transfers. GPM Pay POSTs you the transaction.
4. You find your code in `payload.content`, compare the amount, and fulfil.
GPM Pay mints no codes, holds no order state, and matches nothing on your
behalf. Your own orders table is the source of truth, which is usually what you
wanted anyway — there is no second entity to keep in sync.
> **Upgrading from 0.2.x?** The `client.orders.*` layer, `waitForPayment()`,
> `payload.order`, `payload.code` and the `orders:read`/`orders:write` scopes
> were removed from the platform in 0.3.0. See the [CHANGELOG](../../CHANGELOG.md)
> for the migration.
## 1. Build the QR with your own code
```ts
import { buildPaymentInstructions } from '@gpmpay/sdk/vietqr';
const account = await client.bankAccounts.retrieve(bankAccountId);
// or: (await client.bankAccounts.list({ status: 'ACTIVE' })).data[0]!
const code = `DH${localOrder.id}`;
const info = buildPaymentInstructions({
bankAccount: account, // must carry its `bank` relation
amount: Math.round(localOrder.total), // integer VND
transferContent: code,
});
// → { qrPayload, qrImageUrl, amount, transferContent,
// bankName, bankBin, accountNumber, accountName }
```
Store `code` on the local order. Show the QR, and show `code` as the transfer
content the payer must include — as copyable text, not only inside the image.
### Choosing the code
This one decision determines whether reconciliation works.
| Rule | Why |
|---|---|
| **Short and `A-Z0-9`** | VietQR truncates the description to **25 characters**; banks fold diacritics and strip punctuation |
| **At the front of the content** | Some banks prepend their own prefix (`CT DEN:...`); trailing text is what gets cut |
| **A fixed prefix + id** (`DH123`) | Makes extraction a one-line regex |
| **Unique per payment, stored locally** | It is the only thing tying a transfer back to an order |
| **Derived from the order id** | A double-submitted checkout then reuses the same code instead of creating a second pending payment |
If you already hold the BIN and account number, skip the account lookup:
```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. Match on the webhook
```ts
onEvent: async (event) => {
const { id: transactionId, content, transferAmount, transferType } = event.payload;
if (transferType !== 'in') return;
// A regex, not `content === code`: banks normalize the content.
const code = /DH(\d+)/.exec(content)?.[0];
if (!code) return; // money in that doesn't carry your code
const order = await db.orders.findByCode(code);
if (!order) return;
// NOTHING compares the amount for you.
if (order.total !== transferAmount) {
await flagUnderpayment(order, transferAmount);
return;
}
await fulfil(order, transactionId);
}
```
Three things **you must implement yourself**, because nothing else does:
1. **The code match** — with a regex, over `payload.content`.
2. **The amount check** — GPM Pay does not know what your order costs.
3. **Expiry** — your own rule for unpaid orders that go stale.
> `payload.content` holds your code. `payload.referenceCode` is the **bank's**
> transfer id and has nothing to do with your order — matching on it never
> works. This is the single most common integration bug.
## 3. Back-fill with the transaction list
For a periodic reconciliation job, or to recover from missed webhooks:
```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);
}
```
This is also the answer when you have no public URL for webhooks: run the same
matching logic on a schedule.
## Inspecting transactions
```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 }
```
Walk every page with the async iterator:
```ts
for await (const txn of client.transactions.listAll({ type: 'IN' })) {
await recordInLedger(txn);
}
```
Things to know:
- `limit` is capped at **50** server-side. The SDK clamps and warns once rather
than truncating silently.
- For transactions, `startDate`/`endDate` filter on `transactionTime`; other
resources filter on `createdAt`.
- `search` matches `referenceCode` and `transferContent`.
## Money: string on read, number on write
Money columns are `Decimal` in the database, so they serialize as **strings**
on read but must be sent as **integers** on write.
```ts
import { toVnd, formatVnd } from '@gpmpay/sdk';
txn.amount // "50000" ← string
toVnd(txn.amount) // 50000 ← number
formatVnd(txn.amount) // "50.000 ₫"
// Do not do this:
txn.amount + 1000 // "500001000" 😱
```
The SDK does **not** coerce silently — quietly transforming responses is a
harder-to-debug surprise. Call `toVnd()` when you need arithmetic.
Exception: webhook payloads already use numbers (`transferAmount`,
`accumulated`).
## VietQR details
A static QR — show the account and let the payer type the amount:
```ts
const payload = buildVietQrPayload({
bankBin: '970422',
accountNumber: '1234567890',
});
```
Switching image template:
```ts
buildVietQrImageUrl({
bankBin: '970422',
accountNumber: '1234567890',
amount: 50_000,
description: 'DH123',
template: 'qr_only', // 'compact' | 'compact2' | 'qr_only' | 'print'
});
```
`buildPaymentInstructions` throws `GpmPayConfigError` if the bank account has no
`bank` relation — the NAPAS BIN lives there, and without it there is no valid
payload to build. Fetch the account through `client.bankAccounts.*`, which
always includes it.
> The SDK's VietQR port is byte-for-byte cross-validated against the backend via
> shared fixtures, so payloads generated on either side are identical.