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 ให้ถูก

สรุปใน 1 นาที

อาการ/ปัญหา
อยากออก QR รับเงินพร้อมเพย์เองโดยไม่ต้องพึ่งไลบรารีหรือ API ธนาคาร
ใครควรอ่าน
คนทำระบบ POS หรือเว็บที่ต้องรับเงินด้วยพร้อมเพย์
ทางแก้แบบสั้น
ประกอบสตริง EMVCo แบบ TLV ต่อด้วย CRC16 ด้วย Python ล้วน แปลงเบอร์มือถือก่อนใส่ แล้วแปลงเป็นรูป QR
ข้ามไปขั้นตอน

พอดีผมต้องทำ 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

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