# 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.
