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

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ứ:

  1. 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í.
  2. 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.
  3. Đủ 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ạngTag thường dùngGUID / dấu hiệu nhận biết
NAPAS (VietQR)38A000000727
PromptPay (Thái)29 / 30A000000677010111 (29)
KHQR (Campuchia)29 / 30định danh của Bakong
UnionPay15 / 16dải EMVCo dành riêng cho UPI
Visa / Mastercard02–03 / 04–05dải EMVCo dành riêng
Các mạng khác26–51GUID 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:

  1. Wallet đọc TLV, tìm thấy template mạng mà nó hỗ trợ.
  2. Wallet gửi payload (hoặc phần định danh merchant trích từ payload) lên server của mình.
  3. 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.
  4. 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ọ.
  5. Khách xác nhận. Wallet trừ tiền khách (bằng CNY, THB...), gửi lệnh thanh toán.
  6. 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:

MCCNgành
5812Nhà hàng, quán ăn
5814Đồ ăn nhanh
5411Siêu thị, tạp hoá
5999Bán lẻ khác
7011Khách sạn, lưu trú
4121Taxi, xe công nghệ
5311Cửa hàng bách hoá
8062Bệnh viện
4900Điện, nước, dịch vụ tiện ích
6012Tổ 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àiGiá trịÝ nghĩa
000201Phiên bản
010212Mã động
38540010A000000727012600069704360112VCB0001234560206QRPUSHTemplate NAPAS, xem bên dưới
52045812MCC: nhà hàng
5303704VND
5406150000Số tiền
5802VNQuốc gia
5917QUAN CA PHE GEN9XTên merchant, hiện trên màn hình khách
6005HANOIThành phố merchant
62260113HD202609230010705POS01Dữ liệu bổ sung: số hoá đơn (01), terminal (07)
630450CECRC

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ớ:

  1. 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.
  2. Service code là QRPUSH.
  3. 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.
  4. Tag 62 dùng các sub-tag có nghĩa với đối soát: 01 số hoá đơn, 05 mã tham chiếu, 07 mã terminal, 03 mã cửa hàng. Sub-tag 08 "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 traApp ngân hàng ViệtWallet nước ngoài
CRC-16 đúng
Tag 52 MCC tồn tại và hợp lệThường bỏ quaBắt buộc
MCC không thuộc danh sách hạn chế của họKhông
Tag 59 tên merchant tồn tại, ASCII, ≤ 25 ký tựBỏ quaBắt buộc
Tag 60 thành phố tồn tại, ≤ 15 ký tựBỏ quaBắt buộc
Tag 53 là 704 và khớp với tiền tệ họ hỗ trợ cho VN
Tag 54 là số nguyên dương, không dấu phân cáchCó, chặt hơn
Tag 01 = 12 thì phải có tag 54, và ngược lạiLỏngChặt
Merchant ID tra được trong hệ thống acquirerQua NAPASQua acquirer/switch
Mọi ký tự trong dải ASCII in đượcLỏngChặt
Không có tag lạ ngoài chuẩnBỏ quaMộ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ải 150.000 hay 150,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:

  1. Đă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á.
  2. Dùng service code QRPUSH với Merchant ID, không dùng QRIBFTTA với số tài khoản.
  3. 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ự.
  4. Mã động cho từng đơn: tag 01 = 12 kèm tag 54 là số nguyên VND.
  5. Đư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.
  6. Payload sạch ASCII, tính độ dài và CRC trên byte UTF-8.
  7. 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.
  8. 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é.