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
TTvà 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 ở
codevàtransfer.contentcủ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 |
|---|---|
TT7K3M9Q | Khớp |
tt7k3m9q | Khớp (không phân biệt hoa thường) |
NGUYEN VAN BINH chuyen tien TT7K3M9Q | Khớ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-9Q | Khớp (bỏ dấu cách, dấu chấm, gạch) |
TT7K3M9 | Không khớp (thiếu ký tự) |
DH1028 | Khô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,expiredhoặcunderpaid.
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, webhookpayment_request.paid. - Thiếu: trạng thái
underpaid, webhookpayment_request.underpaid.transfer.amountgiảm còn số thiếu. Người trả chuyển thêm với cùng mã, đủ thì thànhpaid. - Thừa: trạng thái
overpaid, vẫn gửipayment_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:
{
"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:
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"><?php
// Chuỗi VietQR (chuẩn EMVCo của NAPAS) từ trường transfer của yêu cầu thanh toán
function tlv(string $id, string $value): string
{
return $id . str_pad((string) strlen($value), 2, '0', STR_PAD_LEFT) . $value;
}
function crc16(string $s): string
{
$crc = 0xFFFF;
for ($i = 0; $i < strlen($s); $i++) {
$crc ^= ord($s[$i]) << 8;
for ($j = 0; $j < 8; $j++) {
$crc = ($crc & 0x8000 ? ($crc << 1) ^ 0x1021 : $crc << 1) & 0xFFFF;
}
}
return sprintf('%04X', $crc);
}
function vietqr(array $t): string
{
$merchant = tlv('00', 'A000000727') . tlv('01', tlv('00', $t['bankBin']) . tlv('01', $t['accountNumber'])) . tlv('02', 'QRIBFTTA');
$payload = tlv('00', '01')
. tlv('01', $t['amount'] > 0 ? '12' : '11')
. tlv('38', $merchant)
. tlv('53', '704')
. ($t['amount'] > 0 ? tlv('54', (string) $t['amount']) : '')
. tlv('58', 'VN')
. ($t['content'] ? tlv('62', tlv('08', $t['content'])) : '')
. '6304';
return $payload . crc16($payload);
}
$qr = vietqr($paymentRequest['transfer']);
// Vẽ ảnh bằng thư viện QR bất kỳ, vd. composer require chillerlan/php-qrcodeimport qrcode # pip install "qrcode[pil]"
# Chuỗi VietQR (chuẩn EMVCo của NAPAS) từ trường transfer của yêu cầu thanh toán
def tlv(id: str, value: str) -> str:
return f"{id}{len(value):02d}{value}"
def crc16(s: str) -> str:
crc = 0xFFFF
for ch in s.encode("ascii"):
crc ^= ch << 8
for _ in range(8):
crc = ((crc << 1) ^ 0x1021 if crc & 0x8000 else crc << 1) & 0xFFFF
return f"{crc:04X}"
def vietqr(t: dict) -> str:
merchant = tlv("00", "A000000727") + tlv("01", tlv("00", t["bankBin"]) + tlv("01", t["accountNumber"])) + tlv("02", "QRIBFTTA")
payload = (
tlv("00", "01")
+ tlv("01", "12" if t["amount"] > 0 else "11")
+ tlv("38", merchant)
+ tlv("53", "704")
+ (tlv("54", str(t["amount"])) if t["amount"] > 0 else "")
+ tlv("58", "VN")
+ (tlv("62", tlv("08", t["content"])) if t["content"] else "")
+ "6304"
)
return payload + crc16(payload)
# Vẽ ảnh QR
qrcode.make(vietqr(payment_request["transfer"])).save("vietqr.png")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.amountmớ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ợ.