VietQR và nội dung chuyển khoản

Mỗi yêu cầu thanh toán có một mã ngắn như TT7K3M9Q. Người trả ghi mã này trong nội dung chuyển khoản, hoặc quét mã VietQR để app ngân hàng tự điền. Tiền về, BankOpenAPI đọc mã và biết khoản tiền thuộc đơn nào.

Mã TT… là gì

  • Gồm TT và 6 ký tự lấy từ 23456789ABCDEFGHJKMNPQRSTUVWXYZ. Không có 0, O, 1, I, L để người trả gõ tay không nhầm.
  • Mỗi yêu cầu một mã riêng, nằm ở code và transfer.content của đối tượng.
  • Mã chỉ có chữ in hoa và số, 8 ký tự: vừa giới hạn độ dài nội dung của mọi ngân hàng, không bị mất khi ngân hàng bỏ dấu tiếng Việt.

Cách khớp tiền về

Khi có tiền vào tài khoản đã liên kết, BankOpenAPI đổi nội dung chuyển khoản sang chữ in hoa, bỏ mọi ký tự không phải chữ hoặc số, rồi tìm các đoạn TT + 6 ký tự hợp lệ. Ngân hàng hay thêm tên người chuyển, mã giao dịch hay đổi chữ hoa thường, nên cách đọc này chịu được các thay đổi đó:

Nội dung ngân hàng gửi vềKết quả với mã TT7K3M9Q
TT7K3M9QKhớp
tt7k3m9qKhớp (không phân biệt hoa thường)
NGUYEN VAN BINH chuyen tien TT7K3M9QKhớp (mã nằm ở đâu cũng được)
MBVCB.1234.TT7K3M9Q.CT tu 0123...Khớp (ngân hàng thêm tiền tố, hậu tố)
TT 7K3M-9QKhớp (bỏ dấu cách, dấu chấm, gạch)
TT7K3M9Không khớp (thiếu ký tự)
DH1028Không khớp (chỉ có mã đơn của bạn)

Một khoản tiền chỉ khớp với yêu cầu khi đủ cả ba điều kiện:

  • Nội dung có mã của yêu cầu.
  • Tiền vào đúng tài khoản ngân hàng của yêu cầu (transfer.accountNumber).
  • Yêu cầu còn nhận tiền: trạng thái pending, expired hoặc underpaid.

Khoản tiền không khớp vẫn được ghi nhận và gửi sự kiện transaction.received với paymentRequestId: null. Bạn khớp tay trong trang Đối soát của app, hoặc tự xử lý trong hệ thống của mình.

Chuyển thiếu, thừa, quá hạn

  • Đủ tiền: trạng thái paid, webhook payment_request.paid.
  • Thiếu: trạng thái underpaid, webhook payment_request.underpaid. transfer.amount giảm còn số thiếu. Người trả chuyển thêm với cùng mã, đủ thì thành paid.
  • Thừa: trạng thái overpaid, vẫn gửi payment_request.paid. Phần thừa bạn tự hoàn lại cho người trả; BankOpenAPI không giữ tiền nên không hoàn hộ được.
  • Quá hạn: sau expiresAt, yêu cầu chưa nhận tiền hiện là expired. Tiền về sau đó vẫn được khớp, vì người trả đã chuyển thật. Không muốn nhận nữa thì hủy yêu cầu.

Dựng mã VietQR

VietQR là chuẩn QR chuyển khoản liên ngân hàng của NAPAS, app của hầu hết ngân hàng Việt Nam đều quét được. Mã chứa mã BIN ngân hàng, số tài khoản, số tiền và nội dung, lấy từ trường transfer:

Trường transfer của yêu cầu
{
  "transfer": {
    "bankBin": "970422",
    "accountNumber": "0123456789",
    "accountName": "NGUYEN VAN AN",
    "amount": 150000,
    "content": "TT7K3M9Q"
  }
}

Các hàm dưới đây dựng chuỗi VietQR (chuẩn EMVCo, có mã kiểm tra CRC16) ngay trong hệ thống của bạn, không cần gọi dịch vụ ngoài, rồi vẽ thành ảnh bằng thư viện QR:

vietqr(transfer)
import QRCode from "qrcode"; // npm install qrcode

// Chuỗi VietQR (chuẩn EMVCo của NAPAS) từ trường transfer của yêu cầu thanh toán
const tlv = (id, value) => id + String(value.length).padStart(2, "0") + value;

function crc16(s) {
  let crc = 0xffff;
  for (let i = 0; i < s.length; i++) {
    crc ^= s.charCodeAt(i) << 8;
    for (let j = 0; j < 8; j++) crc = (crc & 0x8000 ? (crc << 1) ^ 0x1021 : crc << 1) & 0xffff;
  }
  return crc.toString(16).toUpperCase().padStart(4, "0");
}

export function vietqr({ bankBin, accountNumber, amount, content }) {
  const merchant = tlv("00", "A000000727") + tlv("01", tlv("00", bankBin) + tlv("01", accountNumber)) + tlv("02", "QRIBFTTA");
  const payload =
    tlv("00", "01") +
    tlv("01", amount > 0 ? "12" : "11") +
    tlv("38", merchant) +
    tlv("53", "704") +
    (amount > 0 ? tlv("54", String(amount)) : "") +
    tlv("58", "VN") +
    (content ? tlv("62", tlv("08", content)) : "") +
    "6304";
  return payload + crc16(payload);
}

// Vẽ ảnh QR (data URL) để đặt vào thẻ <img>
const dataUrl = await QRCode.toDataURL(vietqr(paymentRequest.transfer), { width: 320, margin: 2 });
// <img src={dataUrl} alt="Mã VietQR">

Có số tiền thì mã là QR động (trường 01 = 12): app ngân hàng điền sẵn số tiền và nội dung. Thử nhanh bằng tay với công cụ Tạo mã VietQR (đã điền sẵn dữ liệu mẫu ở trên), hoặc dán chuỗi vào Giải mã VietQR để kiểm tra.

Hiển thị cho người trả

  • Hiện mã QR, kèm từng dòng chữ: ngân hàng, số tài khoản, tên chủ tài khoản, số tiền, nội dung. Mỗi dòng có nút sao chép.
  • Ghi rõ "Giữ nguyên nội dung TT7K3M9Q": một số app ngân hàng cho sửa nội dung sau khi quét, sửa mất mã thì đơn không tự khớp.
  • Hiện thời hạn theo expiresAt. Trang chờ thanh toán đọc trạng thái từ backend của bạn (đã được webhook cập nhật), không gọi thẳng BankOpenAPI từ trình duyệt.
  • Đơn thiếu tiền: hiện lại QR với transfer.amount mới (số còn thiếu).

Không muốn tự làm phần này, gửi hostedUrl của yêu cầu cho người trả: trang thanh toán dựng sẵn đã có đủ các điểm trên.

Lưu ý theo ngân hàng

Khớp theo QR động riêng cho từng đơn (không cần mã trong nội dung) ở các ngân hàng hỗ trợ: Sắp có

Danh sách ngân hàng và loại tài khoản liên kết được ở trang Ngân hàng hỗ trợ.

Bắt đầu với BankOpenAPI

Đăng ký tài khoản, liên kết tài khoản ngân hàng và nhận webhook báo có đầu tiên. Cần tư vấn thì nhắn chúng tôi.