- Published on
QR xuyên biên giới: vì sao WeChat Pay quét được mã của ngân hàng Việt?
- Authors
- Name
- Gen9X
- X
- @gen9xdotcom
Một khách du lịch Trung Quốc vào quán cà phê ở Hà Nội. Quán chỉ có một mã QR của Vietcombank dán trên quầy. Khách mở WeChat Pay, quét, thấy số tiền hiện bằng nhân dân tệ, bấm xác nhận. Vài giây sau, chủ quán nhận thông báo tiền về bằng VND. Hai ứng dụng của hai quốc gia, hai hệ thống ngân hàng khác nhau, một mã QR. Chuyện gì đã xảy ra ở giữa?
Bài trước mình đã bóc từng byte của một mã VietQR. Bài này đi tiếp một bước: vì sao cùng cấu trúc ấy lại có thể được một wallet nước ngoài đọc hiểu và thanh toán, và tại sao khi bạn tự sinh mã cho merchant, các wallet đó lại soi kỹ hơn nhiều so với app ngân hàng Việt. Khi làm Tingee, đội mình đã có lúc thấy mã quét ngon lành bằng app ngân hàng trong nước nhưng bị wallet nước ngoài từ chối thẳng, và lý do cuối cùng nằm ở bốn con số nhỏ gọi là MCC. Mình sẽ đi đến đó.
Ba điều kiện để một mã QR "đi qua biên giới"
Muốn wallet A (nước ngoài) trả tiền cho merchant của ngân hàng B (Việt Nam) qua mã QR, cần đủ ba thứ:
- Cùng ngữ pháp. Wallet A phải đọc được mã. Cả hai bên đều dùng chuẩn EMVCo Merchant-Presented QR, nên phần này gần như miễn phí.
- Có đường đi cho tiền. Sau khi đọc mã, wallet A phải biết gửi giao dịch tới đâu để tiền cuối cùng về ngân hàng B. Đây là phần kết nối giữa các mạng thanh toán, tốn nhiều năm đàm phán hơn là code.
- Đủ thông tin để wallet A dám trả. Wallet A chịu trách nhiệm với người dùng của họ và với cơ quan quản lý nước họ, nên họ đòi mã phải khai rõ merchant là ai, bán gì, ở đâu. Đây là chỗ VietQR cá nhân không đủ và merchant QR mới đủ.
Đi từng điều kiện một.
Điều kiện 1: EMVCo là ngôn ngữ chung
Nếu bạn đã đọc bài trước, bạn biết một mã VietQR là một chuỗi TLV theo chuẩn EMVCo. Điều mình chưa nói: PromptPay của Thái Lan, KHQR của Campuchia, LAO QR, SGQR của Singapore, DuitNow của Malaysia, và cả mã QR của UnionPay, Alipay, WeChat Pay ở chế độ merchant-presented đều dùng đúng chuẩn đó. Cùng tag 00 mở đầu, cùng tag 53 cho tiền tệ, cùng tag 63 CRC ở cuối.
Điểm khác nhau nằm ở tag chứa thông tin tài khoản nhận (dải 02–51). EMVCo cấp mỗi mạng một số riêng, và trong tag đó, sub-tag 00 luôn là một GUID để nhận diện mạng:
| Mạng | Tag thường dùng | GUID / dấu hiệu nhận biết |
|---|---|---|
| NAPAS (VietQR) | 38 | A000000727 |
| PromptPay (Thái) | 29 / 30 | A000000677010111 (29) |
| KHQR (Campuchia) | 29 / 30 | định danh của Bakong |
| UnionPay | 15 / 16 | dải EMVCo dành riêng cho UPI |
| Visa / Mastercard | 02–03 / 04–05 | dải EMVCo dành riêng |
| Các mạng khác | 26–51 | GUID tự khai, dạng reverse-domain hoặc AID |
Khi WeChat Pay quét một mã, việc đầu tiên nó làm là chạy vòng lặp TLV, nhặt hết các tag trong dải 02–51, và nhìn GUID của từng tag để hỏi: "mạng này mình có kết nối không?". Nếu thấy A000000727 và WeChat có đường tới NAPAS (hoặc tới ngân hàng phát hành mã), nó đi tiếp. Nếu không thấy mạng nào quen, nó báo "mã không được hỗ trợ".
Chi tiết hay: một mã có thể mang nhiều tag mạng cùng lúc. Chuẩn cho phép, và các nước làm "QR quốc gia" tận dụng triệt để. SGQR của Singapore là ví dụ nổi tiếng, một mã chứa hàng chục template để bất kỳ wallet nào cũng tìm được mạng của mình. Ở Việt Nam, khi một ngân hàng cần mã vừa quét được bằng app trong nước vừa quét được bằng wallet chưa kết nối NAPAS, họ có thể nhét thêm template của mạng kia vào cùng mã. Người dùng chỉ thấy một ô vuông, nhưng bên trong là nhiều "địa chỉ" cho nhiều mạng.
Điều kiện 2: đường đi của tiền
Đây là phần khó nhất và cũng là phần ít được viết ra nhất, vì nó là chuyện hợp đồng giữa các tổ chức. Theo hiểu biết của mình, có hai mô hình đang tồn tại song song, và một ngân hàng lớn có thể dùng cả hai.
Mô hình A: switch nối switch
Mỗi nước có một "tổng đài" thanh toán nội địa (switch): NAPAS ở Việt Nam, NITMX ở Thái Lan, Bakong của NBC ở Campuchia, LAPNet ở Lào. Hai switch ký kết nối với nhau, thống nhất định dạng message, tỷ giá, cách bù trừ. Sau đó mọi ngân hàng thành viên của hai bên tự động được hưởng.
WeChat / app ngân hàng Thái → switch nước họ → NAPAS → ngân hàng acquirer VN → merchant
Đây là cách VietQR nối với Thái Lan, Campuchia, Lào những năm gần đây. Ưu điểm: một lần kết nối, cả hệ thống dùng chung. Nhược điểm: đàm phán lâu, và hai switch phải thống nhất được bộ luật chung cho mã QR.
Mô hình B: acquirer nối thẳng wallet
Một ngân hàng Việt Nam (đóng vai acquirer, tức bên chấp nhận thanh toán cho merchant) ký thẳng với WeChat Pay, Alipay+, hoặc UnionPay để trở thành đối tác chấp nhận thanh toán của họ tại Việt Nam. Merchant của ngân hàng đó được đăng ký sang phía wallet, mã QR của merchant được wallet nhận diện qua GUID/định danh của ngân hàng hoặc qua NAPAS, và giao dịch đi thẳng:
WeChat Pay (TQ) → hệ thống cross-border của WeChat → ngân hàng acquirer VN → merchant
Trường hợp OneQR của Vietcombank mà WeChat Pay quét được nhiều khả năng đi theo đường này hoặc kết hợp cả hai. Mình không có tài liệu nội bộ của họ nên không khẳng định, nhưng cơ chế bên dưới thì không có gì khác: wallet nhận diện được mã, biết gọi ai, và bên được gọi biết merchant là ai.
Dù theo mô hình nào, chuỗi sự kiện khi khách bấm "quét" cũng giống nhau:
- Wallet đọc TLV, tìm thấy template mạng mà nó hỗ trợ.
- Wallet gửi payload (hoặc phần định danh merchant trích từ payload) lên server của mình.
- Server wallet chuyển tiếp tới switch hoặc acquirer, hỏi: "merchant này là ai, có hợp lệ không, số tiền bao nhiêu?". Với mã động, số tiền nằm sẵn trong tag 54. Với mã tĩnh, khách nhập.
- Bên Việt Nam trả về thông tin merchant. Wallet tính tỷ giá, hiện cho khách số tiền bằng tiền nước họ.
- Khách xác nhận. Wallet trừ tiền khách (bằng CNY, THB...), gửi lệnh thanh toán.
- Acquirer ghi có cho merchant bằng VND. Hai mạng bù trừ với nhau sau, theo lô, không phải từng giao dịch.
Khách thấy 3 giây. Bên dưới là ít nhất bốn hệ thống nói chuyện với nhau.
Điều kiện 3: vì sao mã cá nhân không đủ, và MCC ở đâu ra
Đến phần mà mình đã đau đầu thật sự.
Mã VietQR bạn hay gặp nhất là mã chuyển khoản cá nhân: tag 38 với service code QRIBFTTA, bên trong chỉ có BIN ngân hàng và số tài khoản. App ngân hàng Việt đọc xong tra tên chủ tài khoản qua NAPAS, hiện lên, bạn chuyển. Đơn giản, và vì thế mã có thể tối giản: không cần tên, không cần thành phố, không cần biết người nhận bán gì.
Wallet nước ngoài không chấp nhận kiểu đó. Với họ, đây không phải chuyển khoản giữa hai cá nhân mà là thanh toán cho merchant, và thanh toán merchant kéo theo cả một bộ nghĩa vụ:
- Phí và chia phí khác nhau theo ngành hàng.
- Ngành cấm hoặc hạn chế: cờ bạc, tiền ảo, một số dịch vụ tài chính. Wallet phải từ chối được từ lúc quét.
- Chống rửa tiền và báo cáo: cơ quan quản lý nước họ muốn biết công dân của họ trả tiền cho loại hình kinh doanh nào ở nước ngoài.
- Trải nghiệm: khách phải thấy tên quán, thành phố, để chắc mình không quét nhầm mã ai đó dán đè lên.
Vì thế, mã dành cho merchant (NAPAS gọi là QRPUSH, phân biệt với QRIBFTTA) buộc phải có thêm những trường mà mã cá nhân bỏ qua. Trường quan trọng nhất là tag 52: Merchant Category Code.
MCC là gì
MCC là mã 4 chữ số theo ISO 18245, phân loại ngành nghề của merchant. Nó sinh ra từ thế giới thẻ Visa/Mastercard từ hàng chục năm trước và được EMVCo QR kế thừa nguyên xi. Vài mã bạn sẽ gặp thường xuyên:
| MCC | Ngành |
|---|---|
| 5812 | Nhà hàng, quán ăn |
| 5814 | Đồ ăn nhanh |
| 5411 | Siêu thị, tạp hoá |
| 5999 | Bán lẻ khác |
| 7011 | Khách sạn, lưu trú |
| 4121 | Taxi, xe công nghệ |
| 5311 | Cửa hàng bách hoá |
| 8062 | Bệnh viện |
| 4900 | Điện, nước, dịch vụ tiện ích |
| 6012 | Tổ chức tài chính (bị nhiều wallet hạn chế) |
Trong chuẩn EMVCo, tag 52 là bắt buộc trong mọi mã merchant-presented. Các app ngân hàng Việt Nam thường dễ tính: thiếu tag 52 vẫn quét được, vì họ chủ yếu xử lý mã QRIBFTTA và tra thông tin qua NAPAS. Wallet nước ngoài thì làm đúng sách: không có MCC là từ chối, hoặc có MCC nằm trong danh sách hạn chế là từ chối. Đó chính là hiện tượng đội mình gặp: mã quét ngon bằng app Việt, wallet nước ngoài báo lỗi, và mãi mới nhận ra là thiếu bốn con số.
Một mã merchant trông như thế nào
Đây là một mã merchant động mình sinh cho quán cà phê giả tưởng, thanh toán 150.000đ, có MCC 5812:
00020101021238540010A000000727012600069704360112VCB0001234560206QRPUSH52045812530370454061500005802VN5917QUAN CA PHE GEN9X6005HANOI62260113HD202609230010705POS01630450CE
Bóc ra:
| Tag | Độ dài | Giá trị | Ý nghĩa |
|---|---|---|---|
| 00 | 02 | 01 | Phiên bản |
| 01 | 02 | 12 | Mã động |
| 38 | 54 | 0010A000000727012600069704360112VCB0001234560206QRPUSH | Template NAPAS, xem bên dưới |
| 52 | 04 | 5812 | MCC: nhà hàng |
| 53 | 03 | 704 | VND |
| 54 | 06 | 150000 | Số tiền |
| 58 | 02 | VN | Quốc gia |
| 59 | 17 | QUAN CA PHE GEN9X | Tên merchant, hiện trên màn hình khách |
| 60 | 05 | HANOI | Thành phố merchant |
| 62 | 26 | 0113HD202609230010705POS01 | Dữ liệu bổ sung: số hoá đơn (01), terminal (07) |
| 63 | 04 | 50CE | CRC |
Và tag 38 bên trong:
00 10 A000000727 ← GUID NAPAS
01 26 00069704360112VCB000123456 ← BIN 970436 + Merchant ID "VCB000123456"
02 06 QRPUSH ← dịch vụ thanh toán merchant (không phải QRIBFTTA)
So với mã cá nhân ở bài trước, có bốn khác biệt bạn nên nhớ:
- Sub-tag 01 trong tag 38 giờ là Merchant ID do ngân hàng cấp khi merchant đăng ký, không còn là số tài khoản trần.
- Service code là
QRPUSH. - Có tag 52 (MCC), 59 (tên), 60 (thành phố). Ba trường này với mã cá nhân là tuỳ chọn, với mã merchant là bắt buộc.
- Tag 62 dùng các sub-tag có nghĩa với đối soát:
01số hoá đơn,05mã tham chiếu,07mã terminal,03mã cửa hàng. Sub-tag08"nội dung chuyển khoản" của mã cá nhân ít khi xuất hiện ở đây.
Bộ luật validate của wallet nước ngoài
Từ kinh nghiệm bị từ chối vài lần, mình gom lại những gì các wallet quốc tế thường kiểm tra mà app trong nước bỏ qua. Không phải wallet nào cũng soi đủ mọi mục, nhưng nếu mã của bạn qua được hết danh sách này thì gần như không còn gì để bị bắt:
| Kiểm tra | App ngân hàng Việt | Wallet nước ngoài |
|---|---|---|
| CRC-16 đúng | Có | Có |
| Tag 52 MCC tồn tại và hợp lệ | Thường bỏ qua | Bắt buộc |
| MCC không thuộc danh sách hạn chế của họ | Không | Có |
| Tag 59 tên merchant tồn tại, ASCII, ≤ 25 ký tự | Bỏ qua | Bắt buộc |
| Tag 60 thành phố tồn tại, ≤ 15 ký tự | Bỏ qua | Bắt buộc |
Tag 53 là 704 và khớp với tiền tệ họ hỗ trợ cho VN | Có | Có |
| Tag 54 là số nguyên dương, không dấu phân cách | Có | Có, chặt hơn |
| Tag 01 = 12 thì phải có tag 54, và ngược lại | Lỏng | Chặt |
| Merchant ID tra được trong hệ thống acquirer | Qua NAPAS | Qua acquirer/switch |
| Mọi ký tự trong dải ASCII in được | Lỏng | Chặt |
| Không có tag lạ ngoài chuẩn | Bỏ qua | Một số wallet từ chối |
Hai dòng cuối đáng nói thêm. Với ký tự: bài trước mình kể chữ "đ" làm sai CRC; với wallet nước ngoài, kể cả khi CRC đúng, một ký tự ngoài ASCII trong tên merchant vẫn có thể bị từ chối vì họ parse nghiêm ngặt theo kiểu dữ liệu ans của EMVCo. Với tag lạ: một số hệ thống thêm tag riêng (ví dụ tag 80–99 là dải "dành cho tương lai") để nhét dữ liệu nội bộ; wallet nghiêm sẽ coi đó là mã không hợp chuẩn.
Tiền tệ và tỷ giá: ai quy đổi?
Tag 53 trong mã luôn là tiền tệ của merchant, tức 704 với merchant Việt Nam. Mã không bao giờ chứa giá bằng CNY hay THB. Việc quy đổi là của wallet: sau khi tra được merchant và số tiền, wallet áp tỷ giá của họ (thường kèm một khoản phí chênh lệch), hiện cho khách con số bằng tiền nước họ, và cam kết trả cho phía Việt Nam đúng số VND trong mã.
Điều này có hai hệ quả cho người làm kỹ thuật:
- Đừng bao giờ làm tròn hay format số tiền theo locale trước khi đưa vào tag 54.
150000, không phải150.000hay150,000.00. - Với mã tĩnh (tag 01 = 11, không có 54), wallet sẽ hỏi khách nhập số tiền bằng VND, rồi mới quy đổi. Trải nghiệm này kém hơn nhiều so với mã động, nên nếu bạn có POS, hãy sinh mã động cho từng hoá đơn.
Chiều ngược lại: người Việt quét mã ở nước ngoài
Mọi thứ ở trên đối xứng. Khi bạn cầm app ngân hàng Việt sang Thái Lan và quét mã PromptPay ở quán ăn đường phố, app của bạn làm đúng những gì WeChat làm ở Hà Nội: đọc TLV, thấy GUID của PromptPay ở tag 29, biết NAPAS có kết nối với NITMX, gửi giao dịch đi, hiện số tiền quy đổi ra VND, bạn xác nhận, chủ quán nhận THB. NAPAS gọi năng lực này là VietQR Global và mở rộng dần theo từng nước ký kết. Nếu app của bạn báo "không hỗ trợ mã này" ở một nước, khả năng cao là điều kiện số 2 chưa có, không phải mã sai.
Checklist cho dev: sinh mã merchant qua được cửa quốc tế
Nếu bạn đang xây hệ thống sinh mã cho merchant (POS, cổng thanh toán, phần mềm quản lý bán hàng), đây là danh sách mình dùng:
- Đăng ký merchant với ngân hàng acquirer để có Merchant ID và MCC chính thức. Đừng tự bịa MCC; ngân hàng gán dựa trên hồ sơ kinh doanh, và MCC sai có thể khiến giao dịch bị hoàn hoặc merchant bị khoá.
- Dùng service code
QRPUSHvới Merchant ID, không dùngQRIBFTTAvới số tài khoản. - Luôn có tag 52, 59, 60. Tên và thành phố viết không dấu, in hoa cho an toàn, trong giới hạn 25 và 15 ký tự.
- Mã động cho từng đơn: tag 01 = 12 kèm tag 54 là số nguyên VND.
- Đưa số hoá đơn vào tag 62 sub-tag 01 và mã terminal vào sub-tag 07. Đây là thứ giúp bạn đối soát khi tiền về theo lô từ nước ngoài, có thể trễ một hai ngày so với giao dịch nội địa.
- Payload sạch ASCII, tính độ dài và CRC trên byte UTF-8.
- Không thêm tag ngoài chuẩn. Dữ liệu nội bộ để ở hệ thống của bạn, tra bằng số hoá đơn.
- Test với ít nhất một wallet nước ngoài thật trước khi ship. App ngân hàng Việt quét được không có nghĩa là mã đúng chuẩn.
Và nếu bạn muốn tự tay sinh thử, đây là hàm ở bài trước, thêm đúng ba dòng cho MCC, tên và thành phố:
const tlv = (id: string, value: string) =>
id + String(new TextEncoder().encode(value).length).padStart(2, '0') + value
type MerchantInput = {
bankBin: string
merchantId: string
mcc: string
merchantName: string
merchantCity: string
amount: number
billNumber?: string
terminalId?: string
}
export function buildMerchantQR(m: MerchantInput) {
const beneficiary = tlv('00', m.bankBin) + tlv('01', m.merchantId)
const napas = tlv('00', 'A000000727') + tlv('01', beneficiary) + tlv('02', 'QRPUSH')
const additional =
(m.billNumber ? tlv('01', m.billNumber) : '') + (m.terminalId ? tlv('07', m.terminalId) : '')
const body =
tlv('00', '01') +
tlv('01', '12') +
tlv('38', napas) +
tlv('52', m.mcc) + // <- trường mà app trong nước bỏ qua, wallet nước ngoài bắt buộc
tlv('53', '704') +
tlv('54', String(m.amount)) +
tlv('58', 'VN') +
tlv('59', m.merchantName) +
tlv('60', m.merchantCity) +
(additional ? tlv('62', additional) : '') +
'6304'
return body + crc16(new TextEncoder().encode(body))
}
Gọi với bankBin: '970436', merchantId: 'VCB000123456', mcc: '5812', merchantName: 'QUAN CA PHE GEN9X', merchantCity: 'HANOI', amount: 150000, billNumber: 'HD20260923001', terminalId: 'POS01' bạn sẽ nhận lại đúng chuỗi kết thúc bằng 50CE ở trên. Hàm crc16 lấy nguyên từ bài trước.
Tóm lại
QR xuyên biên giới không cần công nghệ mới. Nó cần ba thứ: một chuẩn chung (EMVCo, đã có sẵn từ lâu), những đường kết nối giữa các mạng thanh toán (việc của NAPAS, các ngân hàng và đối tác như WeChat Pay, Alipay+, UnionPay), và một mã QR khai đủ thông tin để bên nước ngoài dám trả tiền. Cái thứ ba là phần rơi vào tay lập trình viên, và điểm hay bị hụt nhất là MCC cùng hai trường tên, thành phố mà app trong nước quá dễ tính nên ta quen bỏ qua.
Lần tới thấy một mã quét được bằng app Việt nhưng WeChat báo lỗi, hãy mở payload ra và tìm 5204 trước. Rất có thể nó không có ở đó.
Có gì bạn thấy mình viết chưa đúng với thực tế ở ngân hàng hay mạng thanh toán bạn đang làm, mình rất muốn nghe. Liên hệ qua các kênh ở trang About nhé.