ตรวจผลชำระเงินจาก gateway ก่อนเชื่อ และอัปเดตคำสั่งซื้อให้รับผลซ้ำได้

ผลชำระเงินมาถึงระบบได้หลายทาง ทั้ง callback จาก gateway หน้าเว็บที่ลูกค้ากลับมา และการถามสถานะของเราเอง เชื่อได้เฉพาะข้อความที่พิสูจน์ได้ว่ามาจาก gateway และทุกทางต้องจบที่จุดเดียวที่เปลี่ยนคำสั่งซื้อเป็นจ่ายแล้วได้ครั้งเดียว

เผยแพร่
เวลาอ่าน
5 นาที
ในโพสต์นี้ 7 หัวข้อ
  1. ผลชำระเงินมาถึงระบบได้สามทาง
  2. เชื่อเฉพาะข้อความที่พิสูจน์ได้ว่ามาจาก gateway
  3. ตอบ gateway ทันที แล้วอ่านผลใน queue
  4. ถามซ้ำด้วย inquiry เมื่อยังไม่แน่ใจ
  5. จุดเดียวที่เปลี่ยนคำสั่งซื้อเป็นจ่ายแล้ว
  6. เช็กยอดก่อนตัดสถานะ และไม่ให้ผลที่มาช้าย้อนสถานะ
  7. กรณีที่อาจเจอในงานจริง

ต่อจาก เชื่อม payment gateway กับ Laravel ที่ระบบขอ payment token แล้วพาลูกค้าไปจ่ายเงิน โพสต์นี้ดูอีกครึ่งหนึ่ง คือรับผลกลับมา แล้วตัดสินว่าคำสั่งซื้อจ่ายแล้วหรือยัง ส่วนนี้เองที่ถ้าพลาด ร้านจะเห็นคำสั่งซื้อที่ลูกค้าจ่ายแล้วแต่ค้างรอชำระ หรือลูกค้าได้แต้มกับอีเมลยืนยันซ้ำสองรอบ ตัวอย่างยังเป็น 2C2P แต่หลักเดียวกันใช้ได้กับ gateway ทุกเจ้า และกับ webhook ของบริการอื่นด้วย

ผลชำระเงินมาถึงระบบได้สามทาง

  • callback ฝั่งเซิร์ฟเวอร์ gateway เรียก URL ของร้านตรง ๆ เมื่อรายการจบ ลูกค้าไม่เกี่ยว ปิดเบราว์เซอร์ไปแล้วก็ยังมา
  • เบราว์เซอร์กลับมา หลังลูกค้าจ่ายเสร็จ gateway พาเบราว์เซอร์กลับมาที่หน้าเว็บของร้านพร้อมผลย่อ ๆ ทางนี้มาถึงก็ต่อเมื่อลูกค้ายังเปิดหน้าอยู่
  • ระบบถามเอง เรียก Payment Inquiry ด้วยเลขคำสั่งซื้อ เช่นตอนหน้าเว็บรอลูกค้าสแกน QR หรือตอนแอดมินกดเช็กสถานะในหลังร้าน

สามทางนี้มาไม่พร้อมกัน ลำดับไม่แน่นอน และอาจมาซ้ำ callback อาจมาถึงก่อนเบราว์เซอร์ หรือมาถึงพร้อมกันพอดีกับตอนแอดมินกดเช็ก ระบบจึงต้องออกแบบไว้ให้รับผลของคำสั่งซื้อเดียวกันหลายครั้งได้ตั้งแต่แรก

ผลชำระเงินมาได้สามทาง แต่จบที่จุดเดียว

ทุกทางวิ่งเข้าจุดเดียว ผลที่มากับเบราว์เซอร์แค่ทำให้ระบบไปถาม gateway อีกครั้ง

เชื่อเฉพาะข้อความที่พิสูจน์ได้ว่ามาจาก gateway

ใครก็ส่ง request มาที่ URL ของร้านได้ สิ่งที่ต้องถามจึงเป็นว่าระบบพิสูจน์ได้ไหมว่าเนื้อหาเป็นของ gateway

ทาง มีลายเซ็นไหม ระบบใช้ทำอะไร
callback ฝั่งเซิร์ฟเวอร์ เป็น JWT เซ็นด้วย secret key ของร้าน อัปเดตคำสั่งซื้อได้
เบราว์เซอร์กลับมา ไม่มี เป็นแค่ JSON ที่เข้ารหัส base64 อ่านเลขคำสั่งซื้อ แล้วไปถาม gateway
inquiry ที่ระบบถามเอง คำตอบเป็น JWT เซ็นมาเหมือนกัน อัปเดตคำสั่งซื้อได้

callback ของ 2C2P เซ็นด้วย secret key ของร้านตามเอกสาร Payment Backend Response คนที่ไม่มี key ปลอมลายเซ็นไม่ได้ ถ้าลายเซ็นไม่ตรง ตัว decode จะโยน exception และระบบไม่ทำอะไรต่อ

ส่วนผลที่มากับเบราว์เซอร์ ลูกค้าหรือใครก็ตามแก้รหัสผลเป็น 0000 (สำเร็จ) แล้วส่งเองได้ ระบบจึงอ่านแค่เลขคำสั่งซื้อจากก้อนนั้น แล้วไปถาม gateway ใหม่ ผลที่มากับเบราว์เซอร์จึงทำได้มากที่สุดแค่ทำให้ระบบไปถามหนึ่งครั้ง เอกสาร Payment Flow Response Code ของ 2C2P เองก็บอกแนวเดียวกัน ร้านที่รับ callback ให้รอ callback แล้วเช็กสถานะในระบบของตัวเอง ร้านที่ไม่รับก็ให้เรียก Payment Inquiry ก่อนแสดงผล

ตอบ gateway ทันที แล้วอ่านผลใน queue

callback ใช้แบบเดียวกับ LINE webhook ระบบเก็บ body ดิบลงตาราง log ก่อนทำอย่างอื่น ตอบ OK กลับไปทันที แล้วให้ job ใน queue ไปตรวจลายเซ็นและอัปเดตคำสั่งซื้อทีหลัง

แบบนี้ได้สองเรื่อง gateway ได้คำตอบเร็ว ไม่ต้องรอระบบให้แต้มหรือส่งอีเมลเสร็จ และถ้าลายเซ็นผิด job จะหยุดตรงนั้น แต่ body ดิบยังอยู่ให้เปิดดูทีหลังว่าใครส่งอะไรมา

ถามซ้ำด้วย inquiry เมื่อยังไม่แน่ใจ

inquiry คือการถาม gateway ว่าคำสั่งซื้อเลขนี้จ่ายแล้วหรือยัง เป็นทางที่ระบบคุมเองได้ทั้งหมด จึงเป็นทางสำรองของทุกกรณีที่ callback อาจไม่มา หรือมาช้า

  • เบราว์เซอร์กลับมา ถามหนึ่งครั้ง แล้วพาลูกค้าไปหน้าคำสั่งซื้อที่สถานะอัปเดตแล้ว
  • หน้าแสดง QR ถามเป็นระยะระหว่างรอลูกค้าสแกน และหยุดเมื่อ QR หมดอายุ เอกสารของ 2C2P มี API เช็กสถานะแบบเบาไว้ให้หน้าเว็บถามถี่ ๆ แต่บอกชัดว่าห้ามใช้ยืนยันผล ผลที่ใช้เปลี่ยนคำสั่งซื้อต้องมาจาก Payment Inquiry ฝั่งเซิร์ฟเวอร์
  • ปุ่มเช็กสถานะในหลังร้าน ให้แอดมินกดเองเมื่อลูกค้าโทรมาถาม

จุดเดียวที่เปลี่ยนคำสั่งซื้อเป็นจ่ายแล้ว

callback และ inquiry ทุกแบบส่งผลเข้าจุดเดียวกัน จุดนี้บันทึกรายละเอียดจาก gateway แล้วถ้าผลคือสำเร็จ ค่อยเปลี่ยนสถานะ ให้แต้ม ตัดสต็อก และส่งอีเมล จุดนี้ต้อง idempotent คือเรียกกี่ครั้งก็ได้ผลเหมือนเรียกครั้งเดียว

ปัญหาคือ callback กับ inquiry อาจทำงานพร้อมกันพอดี แต่ละตัวโหลดคำสั่งซื้อมาตอนสถานะยังเป็นรอชำระเงิน ถ้าเช็กสถานะจากข้อมูลที่โหลดมาแล้ว ทั้งสองตัวจะเห็นว่ายังไม่จ่าย แล้วให้แต้มกับส่งอีเมลซ้ำ วิธีกันคือล็อกแถวของคำสั่งซื้อใน transaction แล้วอ่านสถานะใหม่จากแถวที่ล็อกไว้

DB::transaction(function () use ($order) {
    $locked = Order::query()->lockForUpdate()->find($order->id);

    if ($locked->status !== OrderStatus::PendingPayment) {
        return; // มีผลอื่นมาตัดสถานะไปก่อนแล้ว
    }

    // เปลี่ยนสถานะ ให้แต้ม ตัดสต็อก เกิดครั้งเดียวตรงนี้
});

lockForUpdate() ทำให้ตัวที่มาทีหลังรอจนตัวแรก commit แล้วค่อยอ่าน พออ่านได้ก็เห็นว่าสถานะเปลี่ยนไปแล้ว จึงบันทึกแค่ข้อมูลการจ่ายแล้วจบ ส่วนอีเมลส่งหลัง commit เพื่อไม่ให้ลูกค้าได้อีเมลของ transaction ที่ rollback ไปแล้ว เหมือนที่อธิบายไว้ในโพสต์เรื่อง job หลัง commit

เช็กยอดก่อนตัดสถานะ และไม่ให้ผลที่มาช้าย้อนสถานะ

ผลที่เซ็นมาถูกต้องยังควรผ่านอีกสองด่านก่อนเปลี่ยนสถานะ

  • ยอดตรงกับคำสั่งซื้อ เทียบยอดและสกุลเงินในผลกับยอดของคำสั่งซื้อ ถ้าไม่ตรง ให้บันทึกผลไว้แล้วแจ้งพนักงาน อย่าเพิ่งตัดเป็นจ่ายแล้ว กรณีที่ยอดในลิงก์เก่าไม่ตรงกับยอดที่แก้ทีหลังจะโผล่ตรงนี้
  • สถานะเดินหน้าอย่างเดียว จุดตัดสถานะเปลี่ยนได้จากรอชำระเงินเท่านั้น ผลล้มเหลวที่มาถึงทีหลังผลสำเร็จจึงทำได้แค่บันทึกรหัส คำสั่งซื้อที่กำลังจัดส่งจะไม่ย้อนกลับไปเป็นรอชำระเงิน

สองเรื่องที่ควรมี test คุมไว้คือ callback ที่เซ็นด้วย key อื่นต้องไม่เปลี่ยนอะไรเลย และผลเดียวกันที่มาสองครั้งต้องให้แต้มครั้งเดียว เพราะพังแล้วเสียเงินจริงทั้งคู่ วิธีเขียนอยู่ใน ทดสอบระบบรับชำระเงินโดยไม่ตัดเงินใครจริง

กรณีที่อาจเจอในงานจริง

  • ลูกค้าสแกน QR จ่ายแล้วปิดแอปเลย ไม่มีเบราว์เซอร์กลับมา สถานะจะอัปเดตจาก callback หรือจากการถามซ้ำของหน้าแสดง QR ถ้าระบบพึ่งแค่หน้าเว็บที่ลูกค้ากลับมา คำสั่งซื้อนี้จะค้างรอชำระ
  • callback กับปุ่มเช็กสถานะของแอดมินมาพร้อมกัน ตรงนี้คือเหตุที่ต้องล็อกแถว อย่างตอนที่เราทำระบบหลังร้านของร้านค้าออนไลน์ ก็ต้องให้การเช็กสถานะอ่านจากแถวที่ล็อกไว้ เพราะแอดมินกดเช็กได้ทุกเมื่อ
  • gateway ใช้งานไม่ได้ชั่วคราว inquiry ที่ได้ error ไม่ได้บอกอะไรเกี่ยวกับการจ่ายเงิน จึงไม่ควรเปลี่ยนคำสั่งซื้อเป็นล้มเหลว ปล่อยไว้ตามเดิม แล้วให้ callback หรือการถามรอบถัดไปตัดสิน
  • มีคนยิง URL ของร้านด้วยข้อมูลสุ่ม ลายเซ็นไม่ผ่าน job หยุด ไม่มีอะไรเปลี่ยน log ดิบที่เก็บไว้ยังบอกได้ว่ามาจากไหน

ตอนถัดไปของซีรีส์ รับเงินในเว็บขายของออนไลน์

อ่านต่อ

ถูกอ้างถึงใน

ล่าสุด

ดูทั้งหมด →