Tự viết bộ tạo và đọc mã VietQR bằng JavaScript: TLV, CRC16 và 6 lỗi hay gặp
Mã QR chuyển khoản có sẵn số tiền và nội dung giúp khách không phải gõ tay. Hệ thống của bạn cũng dễ khớp đơn hơn vì nội dung chuyển khoản đúng định dạng. Có nhiều API sinh sẵn ảnh QR, nhưng chuỗi bên trong mã thực chất chỉ là văn bản theo một chuẩn công khai.
Hiểu cấu trúc chuỗi đó giúp bạn tự sinh mã mà không phụ thuộc bên thứ ba. Bạn cũng debug được khi app ngân hàng báo "mã QR không hợp lệ", và kiểm tra được mã trước khi hiển thị cho khách.
Trong bài này mình viết một module khoảng 80 dòng, không dùng thư viện, gồm:
buildVietQR(): sinh chuỗi payload từ thông tin tài khoản, số tiền, nội dung. decodeVietQR(): đọc ngược payload và kiểm tra CRC. Bộ test chạy với 2 payload mẫu công khai để chắc chắn code sinh ra đúng từng ký tự. 1. VietQR là một chuỗi TLV
VietQR dựa trên chuẩn EMVCo cho mã QR do bên nhận tiền hiển thị (Merchant-Presented Mode). Toàn bộ payload là một chuỗi các trường TLV (Tag – Length – Value):
- Tag (ID): 2 chữ số.
- Length: 2 chữ số, là độ dài phần giá trị.
- Value: giá trị của trường. Giá trị có thể chứa các trường TLV con.
Ví dụ: mã chuyển 150.000đ vào tài khoản 0123456789 (BIN 970423), nội dung DH123456:
00020101021238540010A00000072701240006970423011001234567890208QRIBFTTA530370454061500005802VN62120808DH123456630479AB
Tách ra từng trường:

Trường 38 (thông tin người nhận) gồm 3 trường con:
2. CRC16: bước dễ sai nhất
4 ký tự cuối là mã kiểm tra CRC-16/CCITT-FALSE, với các tham số:
Đa thức 0x1021. Giá trị khởi tạo 0xFFFF. Không đảo bit. Kết quả là 4 ký tự hex in hoa.
Điểm hay quên: CRC được tính trên toàn bộ chuỗi phía trước, tính cả 6304 (ID và độ dài của chính trường CRC). Để kiểm tra hàm CRC của bạn có đúng biến thể này không, hãy chạy với chuỗi 123456789. Kết quả phải là 29B1.
3. Code
File vietqr.mjs, chạy được với Node.js 18 trở lên:
js
// CRC-16/CCITT-FALSE: poly 0x1021, init 0xFFFF
export function crc16(str) {
let crc = 0xffff;
for (const byte of Buffer.from(str, "utf8")) {
crc ^= byte << 8;
for (let i = 0; i < 8; i++) {
crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff;
}
}
return crc.toString(16).toUpperCase().padStart(4, "0");
}
// Một trường TLV: ID (2 ký tự) + độ dài (2 chữ số) + giá trị
function tlv(id, value) {
if (value.length > 99) throw new Error(`Trường ${id} dài quá 99 ký tự`);
return id + String(value.length).padStart(2, "0") + value;
}
export function buildVietQR({ bin, accountNo, amount, content }) {
const beneficiary = tlv("00", bin) + tlv("01", accountNo);
const merchant =
tlv("00", "A000000727") + tlv("01", beneficiary) + tlv("02", "QRIBFTTA");
let payload =
tlv("00", "01") +
tlv("01", amount ? "12" : "11") +
tlv("38", merchant) +
tlv("53", "704");
if (amount) payload += tlv("54", String(amount));
payload += tlv("58", "VN");
if (content) payload += tlv("62", tlv("08", content));
payload += "6304"; // CRC tính trên cả "6304"
return payload + crc16(payload);
}
export function parseTLV(str) {
const out = {};
let i = 0;
while (i < str.length) {
const id = str.slice(i, i + 2);
const len = Number(str.slice(i + 2, i + 4));
if (Number.isNaN(len)) throw new Error(`Độ dài không hợp lệ tại vị trí ${i}`);
out[id] = str.slice(i + 4, i + 4 + len);
i += 4 + len;
}
return out;
}
export function verifyCRC(payload) {
const body = payload.slice(0, -4);
return body.endsWith("6304") && crc16(body) === payload.slice(-4).toUpperCase();
}
export function decodeVietQR(payload) {
if (!verifyCRC(payload)) throw new Error("Sai CRC: mã QR bị lỗi hoặc đã bị sửa");
const root = parseTLV(payload);
const merchant = parseTLV(root["38"]);
const beneficiary = parseTLV(merchant["01"]);
const extra = root["62"] ? parseTLV(root["62"]) : {};
return {
dynamic: root["01"] === "12",
bin: beneficiary["00"],
accountNo: beneficiary["01"],
service: merchant["02"],
amount: root["54"] ? Number(root["54"]) : null,
content: extra["08"] ?? null,
};
}
export function toAscii(text) {
return text
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/đ/g, "d")
.replace(/Đ/g, "D")
.replace(/[^A-Za-z0-9 ]/g, "")
.trim();
}
Chuỗi payload sinh ra có thể đưa vào bất kỳ thư viện vẽ QR nào để ra ảnh, ví dụ gói qrcode trên npm.
4. Test với payload mẫu công khai
Mình dùng 2 payload mẫu được công bố công khai (xem phần Tham khảo): một mã tĩnh và một mã động. Code đúng phải kiểm tra được CRC của cả hai mã. Từ dữ liệu của mã động, code phải dựng lại được chuỗi giống hệt từng ký tự.
File vietqr.test.mjs:
js
import assert from "node:assert/strict";
import { crc16, buildVietQR, decodeVietQR, verifyCRC, toAscii } from "./vietqr.mjs";
// 1. CRC-16/CCITT-FALSE có "check value" chuẩn là 29B1
assert.equal(crc16("123456789"), "29B1");
// 2. Hai payload mẫu công khai (xem phần Tham khảo) phải qua được bước kiểm tra CRC
const staticQR =
"00020101021138540010A00000072701240006970407011096020919960208QRIBFTTA53037045802VN630434A0";
const dynamicQR =
"00020101021238530010A000000727012300069704230109mynamebvh0208QRIBFTTA53037045405500005802VN62080804test6304AB76";
assert.ok(verifyCRC(staticQR));
assert.ok(verifyCRC(dynamicQR));
// 3. Dựng lại payload động từ dữ liệu phải ra đúng từng ký tự
assert.equal(
buildVietQR({ bin: "970423", accountNo: "mynamebvh", amount: 50000, content: "test" }),
dynamicQR
);
// 4. Đọc ngược
assert.deepEqual(decodeVietQR(dynamicQR), {
dynamic: true, bin: "970423", accountNo: "mynamebvh",
service: "QRIBFTTA", amount: 50000, content: "test",
});
// 5. Sửa 1 ký tự trong số tài khoản -> CRC sai -> từ chối
const tampered = dynamicQR.replace("mynamebvh", "mynamebvx");
assert.equal(verifyCRC(tampered), false);
assert.throws(() => decodeVietQR(tampered));
// 6. Bỏ dấu tiếng Việt cho nội dung chuyển khoản
assert.equal(toAscii("Thanh toán đơn DH123456"), "Thanh toan don DH123456");
console.log("OK: tất cả test đều pass");
bash
node vietqr.test.mjs
OK: tất cả test đều pass
Test số 5 khá hữu ích. Chỉ cần sửa 1 ký tự trong số tài khoản là CRC sai ngay. Vì vậy nên chạy decodeVietQR() để kiểm tra mã trước khi hiển thị hoặc in ra.
5. Sáu lỗi hay gặp khi tự sinh VietQR Không đưa 6304 vào chuỗi tính CRC. Đây là lỗi phổ biến nhất: mã sinh ra trông bình thường nhưng app ngân hàng không đọc được. CRC thiếu số 0 ở đầu hoặc viết thường. Nếu CRC ra A3F thì phải viết thành 0A3F. Hàm trên dùng padStart(4, "0") và toUpperCase() cho việc này. Số tiền có dấu phân cách. Phải viết 150000, không viết 150.000 hay 150,000. Nội dung có dấu tiếng Việt. Ký tự có dấu chiếm nhiều byte khi mã hóa UTF-8. Nếu bên đọc đếm độ dài theo byte thì độ dài sẽ lệch. Cách an toàn là bỏ dấu bằng toAscii() trước khi đưa vào trường 62.08. Giá trị dài quá 99 ký tự. Độ dài chỉ có 2 chữ số nên một trường không thể vượt 99 ký tự. Trên thực tế, nên giữ nội dung chuyển khoản ngắn gọn (ví dụ chỉ mã đơn), vì mỗi ngân hàng có thể giới hạn độ dài riêng. Dùng nhầm mã tĩnh và mã động. Mã có số tiền riêng cho từng đơn nên dùng 12. Mã 11 không kèm số tiền, dùng lại được nhiều lần, ví dụ mã in dán tại quầy.
6. Dùng vào việc gì? Sinh mã theo từng đơn hàng. Nhúng mã đơn vào trường 62.08 để nội dung chuyển khoản luôn đúng định dạng, việc đối chiếu sau đó chính xác hơn. Kiểm tra trước khi hiển thị. Đọc ngược mã vừa sinh và so số tài khoản với tài khoản cấu hình, tránh trường hợp cấu hình sai mà không ai biết. Viết unit test cho service sinh QR. So sánh với payload mẫu như trên thay vì kiểm tra bằng mắt.
Code trong bài dùng để minh họa cấu trúc. Trước khi đưa lên production, bạn nên đối chiếu với đặc tả chính thức của NAPAS và quét thử bằng app của các ngân hàng mà khách hàng hay dùng.
Bạn từng gặp lỗi nào khi làm việc với VietQR? Chia sẻ ở phần bình luận để mọi người cùng tránh nhé.
All rights reserved