สร้าง QR พร้อมเพย์ (EMVCo) ด้วย Python ล้วน — ไม่ต้องพึ่งไลบรารีนอก
QR พร้อมเพย์ที่แอปธนาคารสแกนได้ จริง ๆ แล้วเป็นแค่สตริงข้อความตามมาตรฐาน EMVCo ที่มี checksum ต่อท้าย ประกอบเองได้ในไม่กี่บรรทัด ไม่ต้องต่อ API ใคร ขอแค่เข้าใจโครงสร้าง TLV กับกฎ CRC ให้ถูก
พอดีผมต้องทำ QR รับเงินพร้อมเพย์ให้ขึ้นเองในระบบขายหน้าร้าน ไม่อยากต่อ payment gateway ให้ยุ่งยากเพราะแค่ต้องการโชว์ QR ให้ลูกค้าสแกนโอนตรงเข้าเบอร์/เลขบัญชีเราเอง ตอนแรกกะหาไลบรารีมาแปะ แต่พอไปนั่งอ่านสเปกจริง ๆ พบว่ามันเป็นแค่การประกอบสตริงตามมาตรฐาน EMVCo แล้วต่อ checksum — เขียนเองด้วย Python ล้วนได้ ไม่ต้องพึ่งอะไรเลย เลยเอามาเล่าไว้
ตัว QR code เป็นแค่ตัวห่อของ "สตริง payload" ข้างใน สิ่งที่ต้องสร้างให้ถูกคือสตริงนั้น ส่วนการ render เป็นภาพเป็นขั้นตอนสุดท้ายที่ไลบรารี QR ตัวไหนก็ทำได้
โครงสร้าง: TLV (Tag-Length-Value)
payload ของไทยคิวอาร์เป็นชุดของ field เรียงต่อกัน แต่ละ field มีรูปแบบเดียวกันเป๊ะ: เลข tag 2 หลัก + ความยาว 2 หลัก + ค่าตามความยาวนั้น ยกตัวอย่าง field ที่บอกเวอร์ชัน 000201 อ่านว่า tag 00, length 02, value 01
field หลักที่ต้องมีในการโอนพร้อมเพย์:
00= เวอร์ชัน payload (ค่าคงที่01)01= ประเภท —11คือ QR แบบ static (จำนวนเงินไม่ล็อก สแกนแล้วกรอกเอง),12คือ dynamic (ล็อกจำนวนเงิน)29= ข้อมูลพร้อมเพย์ (ข้างในมี sub-field อีกชั้น — เบอร์/เลขบัตร ปชช./e-wallet)53= สกุลเงิน (764= บาท)54= จำนวนเงิน (ใส่เมื่อเป็น dynamic)58= ประเทศ (TH)63= checksum CRC (ต้องอยู่ท้ายสุดเสมอ)
tag 29 มี sub-field ซ้อนข้างใน — 00 = Application ID ของพร้อมเพย์ (A000000677010111) แล้วตามด้วยชนิดพร็อกซี: 01 = เบอร์มือถือ, 02 = เลขบัตรประชาชน/เลขผู้เสียภาษี, 03 = e-wallet ID
เบอร์มือถือต้องแปลงก่อน
พร้อมเพย์เก็บเบอร์เป็นรูปแบบสากล ตัด 0 หน้าออกแล้วเติมรหัสประเทศ 66 เข้าไป ให้ครบ 13 หลัก — 0812345678 กลายเป็น 0066812345678
def format_target(target):
t = target.replace('-', '').replace(' ', '')
if len(t) >= 15: # e-wallet id 15 หลัก
return '03', t
if len(t) == 13: # เลขบัตร ปชช. / เลขผู้เสียภาษี
return '02', t
# เบอร์มือถือ -> 0066 + 9 หลักท้าย
return '01', '0066' + t[-9:]
CRC16 — checksum ที่พลาดบ่อยที่สุด
field 63 คือ CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF) และจุดที่คนพลาดคือ ต้องคำนวณ CRC ครอบสตริงทั้งหมดรวม tag กับ length ของตัว 63 เองด้วย คือเติม 6304 ต่อท้าย payload ก่อน แล้วค่อยวิ่ง CRC ทั้งก้อน ผลลัพธ์ 4 หลัก hex ตัวใหญ่ ค่อยเอาไปต่อท้ายจริง
def crc16(data: str) -> str:
crc = 0xFFFF
for ch in data.encode('ascii'):
crc ^= ch << 8
for _ in range(8):
crc = ((crc << 1) ^ 0x1021) & 0xFFFF if (crc & 0x8000) else (crc << 1) & 0xFFFF
return format(crc, '04X')
ประกอบทั้งหมด
def field(tag, value):
return f'{tag}{len(value):02d}{value}'
def promptpay_payload(target, amount=None):
kind, acc = format_target(target)
merchant = field('00', 'A000000677010111') + field(kind, acc)
payload = (
field('00', '01') +
field('01', '12' if amount else '11') +
field('29', merchant) +
field('53', '764') +
(field('54', f'{amount:.2f}') if amount else '') +
field('58', 'TH')
)
payload += '6304' # tag+len ของ CRC ต้องรวมในตอนคำนวณ
return payload + crc16(payload)
if __name__ == '__main__':
import sys
tgt = sys.argv[1]
amt = float(sys.argv[2]) if len(sys.argv) > 2 else None
print(promptpay_payload(tgt, amt))
สตริงที่ได้ต้องเอาไปใส่ QR ตรง ๆ ทั้งก้อน ห้าม URL-encode ห้ามเอาไปแปะเป็นลิงก์ ห้ามเติมช่องว่าง — ถ้าไปดัดแปลง แอปธนาคารจะสแกนไม่ติดหรือขึ้น "QR ไม่ถูกต้อง" ทันที
แปลงเป็นรูป
ตัว payload พร้อมแล้ว render เป็น PNG ด้วยไลบรารีอะไรก็ได้ ผมใช้ segno เพราะเป็น pure-python ไม่ต้องลง Pillow:
pip install segno
import segno
qr = segno.make(promptpay_payload('0812345678', 100.00), error='m')
qr.save('pay.png', scale=8, border=2)
เทสยังไงให้ชัวร์
อย่าเดาว่าถูก — เปิดแอปธนาคารในมือถือสแกนจริงสักรอบก่อนเอาขึ้นระบบ ตรงนี้สำคัญเพราะ CRC ผิดตัวเดียวแอปก็เด้ง ผมเทียบ payload ที่ตัวเองสร้างกับตัวที่ generate จากเครื่องมือมาตรฐาน byte ต่อ byte จนตรงกันก่อน ถึงมั่นใจ
- สร้าง payload -> เทียบสตริงกับ reference ให้ตรงทุกตัวอักษร
- render PNG -> สแกนด้วยแอปธนาคารจริง 1-2 แอป
- เช็คว่าชื่อบัญชีปลายทางขึ้นถูก + จำนวนเงินตรง (ถ้าเป็น dynamic)
- ค่อยเอาขึ้นระบบ POS/เว็บ
สรุปคือพร้อมเพย์ QR ไม่ได้ลึกลับอย่างที่คิด เป็นแค่ TLV + CRC ที่ประกอบเองได้ ข้อดีของการทำเองคือไม่ต้องพึ่ง dependency ที่วันดีคืนดีเลิก maintain และเข้าใจทุกบิตที่ตัวเองส่งออกไป ใครจะเอาไปต่อในระบบขายของตัวเองก็ปรับได้ตามสะดวกครับ ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย