EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Linux / สร้าง QR พร้อมเพย์ (EMVCo) ด้วย Python ล้วน — ไม่ต้องพึ่งไลบรารีนอก
Linux

สร้าง QR พร้อมเพย์ (EMVCo) ด้วย Python ล้วน — ไม่ต้องพึ่งไลบรารีนอก

โดย Ton-Or ·15 ก.ค. 2026·อ่าน 8 นาที
สร้าง QR พร้อมเพย์ (EMVCo) ด้วย Python ล้วน — ไม่ต้องพึ่งไลบรารีนอก

QR พร้อมเพย์ที่แอปธนาคารสแกนได้ จริง ๆ แล้วเป็นแค่สตริงข้อความตามมาตรฐาน EMVCo ที่มี checksum ต่อท้าย ประกอบเองได้ในไม่กี่บรรทัด ไม่ต้องต่อ API ใคร ขอแค่เข้าใจโครงสร้าง TLV กับกฎ CRC ให้ถูก

พอดีผมต้องทำ QR รับเงินพร้อมเพย์ให้ขึ้นเองในระบบขายหน้าร้าน ไม่อยากต่อ payment gateway ให้ยุ่งยากเพราะแค่ต้องการโชว์ QR ให้ลูกค้าสแกนโอนตรงเข้าเบอร์/เลขบัญชีเราเอง ตอนแรกกะหาไลบรารีมาแปะ แต่พอไปนั่งอ่านสเปกจริง ๆ พบว่ามันเป็นแค่การประกอบสตริงตามมาตรฐาน EMVCo แล้วต่อ checksum — เขียนเองด้วย Python ล้วนได้ ไม่ต้องพึ่งอะไรเลย เลยเอามาเล่าไว้

QR พร้อมเพย์ = ข้อความ ไม่ใช่รูป

ตัว QR code เป็นแค่ตัวห่อของ "สตริง payload" ข้างใน สิ่งที่ต้องสร้างให้ถูกคือสตริงนั้น ส่วนการ render เป็นภาพเป็นขั้นตอนสุดท้ายที่ไลบรารี QR ตัวไหนก็ทำได้

โครงสร้าง: TLV (Tag-Length-Value)

payload ของไทยคิวอาร์เป็นชุดของ field เรียงต่อกัน แต่ละ field มีรูปแบบเดียวกันเป๊ะ: เลข tag 2 หลัก + ความยาว 2 หลัก + ค่าตามความยาวนั้น ยกตัวอย่าง field ที่บอกเวอร์ชัน 000201 อ่านว่า tag 00, length 02, value 01

โครงสร้าง 1 fieldTag2 หลัก+Length2 หลัก+Valueยาวตาม length

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 ตัวใหญ่ ค่อยเอาไปต่อท้ายจริง

CRC คำนวณครอบสตริงตั้งแต่ต้นจนถึง 6304 แล้วเอาผลมาต่อท้าย000201เวอร์ชัน010212ประเภท 122937...พร้อมเพย์5303764บาท5406100.00จำนวนเงิน5802THประเทศ6304tag+len CRCCRC 4 hex0016A0000006770101110113006681234567800 = Application IDของพร้อมเพย์01 = เบอร์มือถือ0066 + 9 หลักท้ายเอาสตริงใส่ QR ตรงๆ ทั้งก้อน ห้าม URL-encode ห้ามเติมช่องว่าง
ตัวอย่างจากเบอร์ 0812345678 จำนวน 100.00 ซึ่งเป็น QR แบบ dynamic ส่วน field 29 ขยายให้เห็น sub-field ข้างใน
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 จนตรงกันก่อน ถึงมั่นใจ

  1. สร้าง payload -> เทียบสตริงกับ reference ให้ตรงทุกตัวอักษร
  2. render PNG -> สแกนด้วยแอปธนาคารจริง 1-2 แอป
  3. เช็คว่าชื่อบัญชีปลายทางขึ้นถูก + จำนวนเงินตรง (ถ้าเป็น dynamic)
  4. ค่อยเอาขึ้นระบบ POS/เว็บ

สรุปคือพร้อมเพย์ QR ไม่ได้ลึกลับอย่างที่คิด เป็นแค่ TLV + CRC ที่ประกอบเองได้ ข้อดีของการทำเองคือไม่ต้องพึ่ง dependency ที่วันดีคืนดีเลิก maintain และเข้าใจทุกบิตที่ตัวเองส่งออกไป ใครจะเอาไปต่อในระบบขายของตัวเองก็ปรับได้ตามสะดวกครับ ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

เคยเจองานแบบนี้ไหม? ติดตรงไหน?

มาแลกวิธีกันต่อในบอร์ด LinuxThai

→ คุยต่อในบอร์ด

ความคิดเห็น 0

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