Skip to content

เช็กลิสต์ก่อน go-live ​

ทุกข้อในหน้านี้มาจากเคสจริงที่เคยทำให้ merchant เสียเงินหรือเสียลูกค้า ไม่มีข้อไหนใส่ไว้เพื่อความครบถ้วน

วิธีใช้: ไล่ทีละหมวด แต่ละข้อมี "พิสูจน์ยังไง" ต่อท้าย ⇒ ถ้าพิสูจน์ไม่ได้ แปลว่ายังไม่ผ่าน ไม่ใช่ผ่านแบบมั่นใจ

5 เงื่อนไขที่ระบบตรวจอัตโนมัติ ไม่ใช่ให้ติ๊กเอง

ก่อนออก sk_live_* ให้คุณ ระบบจะตรวจว่าคุณผ่านเส้นทางเหล่านี้ใน sandbox จริงหรือยัง

  1. ยอด 103.00 — settled แล้วถูก reversed ที่ +60 วินาที (พิสูจน์ว่าคุณ handle event ที่ปิดไม่ได้)
  2. ยอด 108.00 — โอนซ้ำ ⇒ deposit.overpaid ที่ +45 วินาที (พิสูจน์ว่าคุณไม่ย้อนสถานะออร์เดอร์ที่สำเร็จแล้ว)
  3. ยอด 109.00 — กดยกเลิกแล้วเผลอโอน ⇒ canceled ที่ is_terminal: false แล้วตามด้วย under_review
  4. ยอด 116.00 — same-slip overpay ที่อีก 5 มิติผ่าน ⇒ deposit.settled ใบเดียว, เครดิต A = X, เปิด flag/claim ให้ติดต่อ support และไม่คืนอัตโนมัติ
  5. POST /v1/test/webhooks/replay-out-of-order — พิสูจน์ว่า version guard ของคุณทำงานจริง

ไม่ผ่าน 5 ข้อนี้ = ยังออก key จริงให้ไม่ได้


หมวด 1 — การเซ็นและการยิง request ​

  • [ ] canonical string ครบ 9 บรรทัด — พิสูจน์: ผ่าน test vector ทั้ง 3 ชุดจาก GET /v1/webhooks/test-vectors (query ≥ 2 ตัว · unicode ไทย · emoji)
  • [ ] Idempotency-Key และ PG-Version อยู่ใน canonical จริง — พิสูจน์: เปลี่ยน Idempotency-Key แล้วยิงด้วย signature เดิม ต้องได้ 401 signature_invalid
  • [ ] PG-Nonce ใหม่ทุก attempt แต่ Idempotency-Key คงเดิมตลอด logical operation — พิสูจน์: retry 3 ครั้งติดแล้วได้ deposit_id เดียว ไม่ใช่ 3 ใบ
  • [ ] key เขียนลง DB ก่อนยิง request — พิสูจน์: kill process กลาง request แล้ว retry ยังใช้ key เดิมได้
  • [ ] ยิง POST /v1/pay/deposits ซ้ำด้วย key เดิม ได้ deposit_id เดิม — พิสูจน์: response มี header Idempotent-Replay: true
  • [ ] handle 429 ด้วย backoff + jitter + Retry-After — พิสูจน์: ยิงชน bucket แล้วระบบไม่ยิงเป็นลูปแน่น
  • [ ] handle 503 โดยไม่ค้าง — พิสูจน์: หน้าจอขึ้น "ระบบหนาแน่น ลองใหม่" ไม่ใช่ spinner ค้าง
  • [ ] sk_live_* อยู่ใน secret manager หมุนได้โดยไม่ต้อง deploy — พิสูจน์: หมุน key ใน staging แล้วระบบยังทำงานต่อโดยไม่ต้อง build
  • [ ] ส่ง client{device_fingerprint, ip, user_agent} ทุกครั้ง — พิสูจน์: ไม่มี request ไหนใน log ที่ขาดฟิลด์นี้ (ขาด = เพดานรายการเปิดพร้อมกันเหลือ 1 และตกไป bucket rate limit รวมของทั้ง merchant)

หมวด 2 — Webhook ​

  • [ ] verify Ed25519 บน raw body ก่อน parse — พิสูจน์: negative test แก้ 1 ไบต์ในตัว body ต้องได้ 401
  • [ ] parse X-PG-Signature แบบ key=value ไม่ใช่ตำแหน่ง — พิสูจน์: สลับลำดับ kid กับ t แล้ว verify ยังผ่าน
  • [ ] decode signature เป็น base64url (ไม่มี padding =) — พิสูจน์: verify ผ่านทั้ง 3 test vector
  • [ ] ปฏิเสธเมื่อ timestamp ห่างเกิน 300 วินาที — พิสูจน์: ยิง vector เก่าที่ t ล้าสมัยแล้วได้ 400
  • [ ] event.id จาก body ต้องตรงกับ X-PG-Event-Id — พิสูจน์: ทดสอบ body ที่ไม่ตรงกับ header แล้วถูกปฏิเสธ
  • [ ] ตรวจ livemode ให้ตรงกับ environment — พิสูจน์: ยิง event ของ sandbox เข้า endpoint จริงแล้วถูกปฏิเสธ
  • [ ] แคช public key ≤ 24 ชั่วโมง · เจอ kid ใหม่ให้ดึงใหม่ 1 ครั้ง (throttle) — พิสูจน์: สลับ kid ใน sandbox (ยอด 114.00) แล้วระบบตามทันภายใน 60 วินาที
  • [ ] subscribe webhook.key.rotating และมีขั้นตอนรับ kid ใหม่ — พิสูจน์: มี runbook ที่คนอื่นในทีมทำตามได้
  • [ ] transactional inbox มี UNIQUE(event_id) เก็บ ≥ 31 วัน · เจอซ้ำตอบ 200 — พิสูจน์: สั่งส่งซ้ำ event เดิมแล้วยอดไม่ขยับ และ response เป็น 200
  • [ ] version guard เป็น atomic UPDATE … WHERE version < $new ไม่ใช่ SELECT แล้ว if แล้ว UPDATE — พิสูจน์: ผ่าน POST /v1/test/webhooks/replay-out-of-order ทั้งเคส A และ B
  • [ ] balance_transaction กันซ้ำด้วย btxn_id แล้ว สะสม ไม่ใช่เอา balance_after มาเขียนทับ — พิสูจน์: ส่ง event สลับลำดับแล้วยอดยังถูก
  • [ ] มี default: ในทุก switch ของ status / reasons[].code / balance_transaction.type / ประเภทค่าธรรมเนียม — พิสูจน์: ยิง code ที่ไม่รู้จักเข้าไปแล้วระบบไม่ throw
  • [ ] ตอบ 2xx ภายใน 3 วินาที งานหนักเข้า queue — พิสูจน์: p99 ของ handler < 3 วินาทีใน load test
  • [ ] ไม่ตอบ 3xx · TLS ใช้ได้จริง (self-signed ไม่ผ่าน) · ไม่พึ่ง IP allowlist — พิสูจน์: เราไม่มี IP คงที่ให้ allowlist
  • [ ] มี handler ครบของ event 5 ตัวที่ปิดไม่ได้ — deposit.reversed · withdrawal.fill_reversed · balance.debited · webhook.key.rotating · webhook.endpoint.disabled

หมวด 3 — เงินและการกระทบยอด ​

  • [ ] เครดิตผู้ใช้ปลายทางด้วย amount_received_minor และผูกกับเงื่อนไข credited_user_at IS NULL — พิสูจน์: ส่ง deposit.settled ซ้ำ 3 ครั้งแล้วยอดผู้ใช้ขยับครั้งเดียว
  • [ ] ขยับกระเป๋า merchant ด้วย credited_amount_minor — พิสูจน์: assert amount_received_minor − fee_total_minor = credited_amount_minor ทุกใบ
  • [ ] เก็บ 4 ยอดหลักแยกกัน — ordered_amount_minor (X) · transferred_amount_minor (V) · amount_received_minor (A) · overpayment.amount_minor (E, หรือ 0) — พิสูจน์: ทดสอบ 111.00 และ 116.00 แล้วแยก short/over ได้ถูก
  • [ ] same-slip overpay สำเร็จตามยอดที่สร้าง — พิสูจน์: ยอด 116.00 ส่ง deposit.settled เพียงใบเดียว, A = X, V = A + E, เครดิตผู้ใช้ครั้งเดียว, แสดง claim/ปุ่มติดต่อ support และไม่มี auto-refund
  • [ ] เก็บ fee_breakdown[] ดิบไว้ และ parse ด้วย component — พิสูจน์: เปิด row ใดก็ได้แล้วเห็น array เต็ม (ใช้ตอนโต้แย้งเรื่องค่าธรรมเนียม)
  • [ ] แยก pending_clearing กับ available ในบัญชีภายใน และคำนวณเงินถอนได้จาก available_minor เท่านั้น — พิสูจน์: ทดสอบยอด 113.00 แล้วได้ 402 พร้อม pending_clearing_available_at
  • [ ] handle balance.cleared โดยไม่บวกยอดซ้ำ — พิสูจน์: หลัง T+72 ชั่วโมงในทดสอบ ยอดรวมไม่เปลี่ยน เปลี่ยนแค่ถัง
  • [ ] ยอดถอนอ่านจาก filled_minor / remaining_minor ผ่าน version guard ไม่ใช่ SUM() และไม่ใช่ += — พิสูจน์: ทดสอบยอด 107.00 (ซอย 3 ก้อน) แล้วยอดถูกทุกลำดับที่มาถึง
  • [ ] handle 402 insufficient_balance โดยปลดล็อกยอดผู้ใช้คืนทันที — พิสูจน์: ไม่มียอดผู้ใช้ค้างล็อกหลังทดสอบ (ตอน 402 ไม่มีออร์เดอร์ถูกสร้าง และไม่มีการล็อกเงินฝั่งเรา)
  • [ ] ปิดใบถอนเมื่อ status = "settled" และ remaining_minor = 0 เท่านั้น — พิสูจน์: ยอด 107.00 ไม่ถูกปิดตั้งแต่ก้อนแรก
  • [ ] คืนเงินใบที่ expired ตาม refunded_minor ไม่ใช่ amount_total_minor — พิสูจน์: ทดสอบใบที่ fill บางส่วนแล้วหมดอายุ
  • [ ] 🔴 มี reconcile job รายวัน ผ่าน GET /v1/pay/events?since_sequence= + GET /v1/pay/deposits + GET /v1/pay/balance/transactions — พิสูจน์: job รันจริงและตรงติดกัน 3 วันก่อนขึ้น 100%

ledger คือความจริง webhook คือการแจ้งเตือน

webhook ไม่ใช่แหล่งความจริง — endpoint ล่ม 25 ชั่วโมงถูกปิดอัตโนมัติได้ และ event ที่ยัง retry อยู่จะทำให้ sequence มีช่องว่างชั่วคราว

reconcile job รายวันไม่ใช่ nice-to-have แต่เป็นเงื่อนไขบังคับก่อน go-live · ช่องว่างของ sequence ที่ค้างเกิน 4 วันคือของหายจริง ⇒ ต้องมี alert

หมวด 4 — หน้าจอและสถานะ ​

  • [ ] UI รองรับครบ 9 สถานะของรายการฝาก และ 7 ของรายการถอน — พิสูจน์: มีหน้าจอจริงทุกค่า ไม่ใช่ fallback เป็น "ไม่สำเร็จ"
  • [ ] under_review เป็นสีเหลืองหรือฟ้า ไม่ใช่สีแดง + แสดง review.reasons[0].display_th + ข้อความ "เงินไม่หาย ไม่ต้องโอนซ้ำ" — พิสูจน์: ยอด 101.00 และ 106.00
  • [ ] 🔴 under_review ไม่มีปุ่ม "โอนใหม่" — พิสูจน์: ตรวจหน้าจอด้วยตา (ปุ่มนี้ทำให้ผู้ใช้เสียเงินสองต่อ)
  • [ ] expired และ canceled ที่ is_terminal: false ยังมีปุ่มอัปสลิป จนถึง late_slip_accepted_until — พิสูจน์: ยอด 102.00 และ 109.00
  • [ ] เช็ค is_terminal ไม่ใช่ชื่อสถานะ ตอนตัดสินใจปิดออร์เดอร์ — พิสูจน์: grep หาโค้ดที่เทียบ status === 'expired' แล้วปิดออร์เดอร์
  • [ ] awaiting_liquidity มีหน้าจอรอ ไม่ใช่หน้า error + แสดง liquidity.suggested_amounts_minor — พิสูจน์: ยอด 105.00
  • [ ] partially_filled แสดงเป็น progress หลายก้อน ไม่ใช่ป้ายสถานะเดียว — พิสูจน์: ยอด 107.00
  • [ ] กล่อง "โอนจากบัญชีนี้เท่านั้น" เด่นกว่า QR + ปุ่ม "เปลี่ยนบัญชีผู้โอน" + ข้อความ "QR นี้ใช้ได้ครั้งเดียวสำหรับรายการนี้" — พิสูจน์: ตรวจหน้าจอด้วยตา
  • [ ] ฟอร์มผู้โอน autofill จาก GET /v1/pay/senders และเทาปุ่มธนาคารตาม GET /v1/pay/banks — พิสูจน์: ผู้ใช้เดิม เลือก ไม่ใช่ พิมพ์
  • [ ] validate account_name_th ว่าไม่มีคำนำหน้า (นาย/นาง/น.ส./คุณ) — พิสูจน์: กรอกชื่อมีคำนำหน้าแล้วถูกเตือนที่ฝั่ง client ก่อนยิง
  • [ ] รองรับ legs.length > 1 ด้วย UI แบบ "ขั้นที่ 1 จาก 2" — พิสูจน์: ยอด 104.00
  • [ ] ใช้ expires_at จาก API ไม่ hardcode countdown และ re-sync ทุกครั้งที่ผู้ใช้กลับเข้าหน้า — พิสูจน์: ยอด 104.00 (หมดอายุจริงใน ~4 นาที)
  • [ ] อัปสลิปแล้วขึ้น "กำลังตรวจสอบ" ไม่ใช่ "สำเร็จ" — พิสูจน์: 202 ถูกตีความเป็นสำเร็จ ไม่ใช่ error และไม่ใช่ "เติมเงินเรียบร้อย"
  • [ ] 🔴 ไม่ log qr_payload และไม่เก็บลง DB · ไม่ log ข้อมูลบัญชีปลายทางแบบเต็ม — พิสูจน์: grep ใน log ของ staging แล้วไม่เจอ
  • [ ] แสดง deposit_id / withdrawal_id ให้ผู้ใช้ copy ได้ — พิสูจน์: เปิดหน้าออร์เดอร์แล้วมีปุ่ม copy

หมวด 5 — ปฏิบัติการและการเฝ้าระวัง ​

  • [ ] ทีมสนับสนุนมี playbook ว่า under_review = รอตรวจ ไม่ใช่ error และ 🔴 ห้ามแนะนำให้ผู้ใช้โอนซ้ำ — พิสูจน์: เอกสารมีอยู่จริงและทีมอ่านแล้ว
  • [ ] alert เมื่ออัตรา under_review เกินเกณฑ์ (สัญญาณว่าหน้าจอผู้โอนมีปัญหา) — พิสูจน์: ตั้ง alert ที่ 10% แล้วยิง test alert
  • [ ] alert เมื่อ webhook verify ไม่ผ่าน หรือ handler ตอบ non-2xx เกิน 1% ใน 5 นาที — พิสูจน์: ยิง test alert
  • [ ] alert เมื่อช่องว่างของ sequence ค้างเกิน 4 วัน — พิสูจน์: reconcile job รายงานช่องว่างได้
  • [ ] log event.id + X-PG-Delivery-Id + X-PG-Attempt ทุกครั้ง และ log request_id ของทุก error — delivery headers ใช้เพื่อ observability เท่านั้น
  • [ ] ตั้ง technical contact ที่มีคนอ่านจริง — ใช้แจ้งตอน endpoint ถูกปิดอัตโนมัติและตอนหมุนกุญแจ
  • [ ] รู้ว่า endpoint ถูกปิดอัตโนมัติเมื่อไหร่ — ล่มติดกัน 24 ชั่วโมง (success = 0 และล้มเหลว ≥ 50 ครั้ง) ⇒ ปิด แล้ว ต้องกดเปิดคืนเอง จาก dashboard
  • [ ] ฝ่ายการเงินรับทราบเรื่อง pending_clearing 72 ชั่วโมง — 🔴 กระแสเงินสดเปลี่ยน ต้องบอกก่อน go-live ไม่ใช่หลัง

ยอดทดสอบใน sandbox ​

ทุกยอดด้านล่างใช้ในโหมด sandbox แล้วได้เส้นทางตามที่ระบุแบบกำหนดได้ ไม่ต้องรอโชค

ยอด (บาท)amount_minorเส้นทางที่ได้
100.0010000จับคู่ได้ → สลิปผ่านอัตโนมัติ → settled ใน ~5 วินาที
101.0010100→ under_review ['SENDER_NAME_MISMATCH'] (ค้างจนกดอนุมัติหรือปฏิเสธ)
102.0010200→ expired → อัปสลิปย้อนหลังอัตโนมัติที่ +60 วินาที → under_review['LATE_SLIP'] → settled
103.0010300settled → reversed ที่ +60 วินาที 🔴 บังคับ
104.0010400ช่องทางสำรอง 2 leg (หมดอายุจริงใน ~4 นาที)
105.0010500awaiting_liquidity เต็ม 25 วินาที แล้วจึงจับคู่ได้
106.0010600→ under_review ['BANK_DISABLED']
107.0010700ใบถอนฝั่งตรงข้ามถูกซอยเป็น 3 ก้อน
108.0010800โอนซ้ำ — settled แล้วตามด้วย deposit.overpaid ที่ +45 วินาที 🔴 บังคับ
109.0010900กดยกเลิกแล้วเผลอโอน — canceled (is_terminal: false) → อัปสลิปย้อนหลัง → under_review['CANCELED_ORDER_SLIP'] 🔴 บังคับ
110.0011000ALLOCATION_CONFLICT — อัปสลิปย้อนหลังชนกับรายการอื่น + ฝั่งตรงข้ามได้ withdrawal.disputed
111.0011100clean short-pay อัตโนมัติ → settled ที่ A=V<X, partial:true, settled_via:'auto' โดยไม่เข้า review
112.0011200หมดหน้าต่างอัปสลิปย้อนหลัง → 410 late_slip_window_closed + claim_url
113.0011300402 insufficient_balance ตอนสร้างรายการถอน (ยอดยังอยู่ใน pending_clearing)
114.0011400หมุนกุญแจ — webhook.key.rotating แล้วสลับ kid ใน 60 วินาที
115.0011500idempotency ชนกัน — key เดิม body ต่าง ⇒ 409 idempotency_key_reused
116.0011600same-slip overpay — อีก 5 มิติผ่าน → deposit.settled ใบเดียวที่ A = X, V > X, มี overpayment{status:'open',action_required:'contact_support'} และไม่มี auto-refund 🔴 บังคับ

รายละเอียดการเปิด sandbox ที่ Sandbox


20 ข้อผิดพลาดที่ merchant ทำบ่อยที่สุด ​

ทุกข้อมาจากเหตุการณ์จริง เรียงตามความเสียหาย

#ผิดยังไงอาการที่จะเห็นวิธีแก้
1เครดิตจาก event ที่ไม่ใช่ settled เช่น deposit.slip_received หรือเห็น leg_status: "complete"ยอดผู้ใช้บวกไปแล้ว รายการถูกปฏิเสธทีหลัง ยอดติดลบ ตามเก็บคืนไม่ได้เงื่อนไขเดียวคือ status === 'settled' และ credited_user_at IS NULL
2🔴 เครดิตผู้ใช้ด้วย credited_amount_minorผู้ใช้ได้เงินขาดเท่ากับค่าธรรมเนียมทุกออร์เดอร์ ลูกค้าร้องเรียนตั้งแต่วันแรกผู้ใช้ = amount_received_minor · กระเป๋า merchant = credited_amount_minor
3ไม่ตรวจลายเซ็น หรือตรวจหลัง JSON.parseใครก็ยิง settled ปลอมเข้ามาได้ / ตรวจไม่ผ่านแบบสุ่มเพราะลำดับ key เปลี่ยนverify บน raw bytes ก่อนแตะ body ทุกกรณี
4ไม่ทำ version guardสถานะถอยหลัง — settled ถูก under_review เก่าทับ ลูกค้าเห็นรายการกลับไปรอตรวจUPDATE … WHERE version < $new แล้วเช็ค rowCount · rowCount = 0 ⇒ ตอบ 200 ไม่ทำอะไร
5ใช้ amount_received_minor ขยับยอดกระเป๋ายอดในระบบสูงกว่าจริงเท่ากับค่าธรรมเนียมทั้งเดือน รู้ตอนกระทบยอดสิ้นเดือนassert amount_received_minor − fee_total_minor = credited_amount_minor ทุกใบ
6คิดว่ายอดที่เครดิตแล้วถอนได้ทันทีสร้างรายการถอนได้ 402 ทั้งที่ dashboard มีเงิน ทีมงานงงกันทั้งวันแยกถัง pending_clearing / available · ถอนได้จาก available_minor เท่านั้น (T+72 ชั่วโมง)
7poll ทุก 1 วินาทีทุกออร์เดอร์ชน 429 แล้ว backoff ทั้ง API key ⇒ ผู้ใช้คนอื่นสร้างรายการไม่ได้ทั้งระบบwebhook เป็นหลัก · poll เป็น fallback แบบ 3→5→10→30 วินาที · ใช้ GET /v1/pay/deposits?status= แบบ batch
8ไม่ handle 202โค้ด if (res.status !== 200) throw retry ซ้ำ · ผู้ใช้เห็น "อัปสลิปไม่สำเร็จ" ทั้งที่เข้าระบบแล้ว แล้วกดโอนซ้ำ2xx = สำเร็จทั้งหมด · 201 202 204 รวมอยู่ด้วย
9ไม่เก็บ deposit_id ใช้เลขอ้างอิงฝั่งตัวเองอย่างเดียวticket แก้ไม่ได้ · กระทบยอดไม่ได้ · support คุยกับเราไม่รู้เรื่องdeposit_id เป็น primary key ของฝั่งคุณด้วย · แสดงให้ผู้ใช้ copy ได้
10retry POST โดย generate Idempotency-Key ใหม่ออร์เดอร์ซ้ำ · QR สองใบ · ฝั่งถอนล็อกเงินเกินเขียน key ลง DB ก่อน ยิง · retry อ่าน key เดิมจาก DB · เปลี่ยนแค่ PG-Nonce
11ใช้ key เดิมกับ body ที่ต่างกัน409 idempotency_key_reused แบบไม่รู้สาเหตุเปลี่ยน body = เปลี่ยน key · key เป็นฟังก์ชันของ เนื้อหา ไม่ใช่ของ session
12ไม่ handle deposit.reversed / balance.debitedปล่อยของแล้วยอดถูกดึงกลับ รู้ตอนสิ้นเดือน ตามเก็บไม่ได้🔴 สอง event นี้ ปิดไม่ได้ ต้องมี handler ตั้งแต่วันแรก (ทดสอบด้วยยอด 103.00)
13ปิดออร์เดอร์ทันทีที่เห็น expiredผู้ใช้อัปสลิปตามมาแล้วได้ settled ของออร์เดอร์ที่ปิดไปแล้ว ระบบสองฝั่งไม่ตรงกันเช็ค is_terminal ไม่ใช่ชื่อสถานะ
14🔴 ปิดออร์เดอร์ทันทีที่เห็น canceledเคสกดยกเลิกแล้วเผลอโอน = ผู้ใช้เสียเงินจริง และเราหาที่ลงเงินไม่ได้canceled ก็มี late_slip_accepted_until ได้ ⇒ เหลือปุ่มอัปสลิปไว้ (ทดสอบยอด 109.00)
15hardcode TTL เอง เช่น 15 นาทีนับถอยหลังไม่ตรง · บางช่องทางหมดอายุจริงใน ~4 นาที ⇒ ผู้ใช้โอนหลังหมดเวลาใช้ expires_at จาก response และ re-sync ทุกครั้งที่กลับเข้าหน้า
16ไม่เทาปุ่มธนาคารตาม GET /v1/pay/banksผู้ใช้กรอกครบแล้วเจอ 409 sender_bank_unavailable ที่ขั้นสุดท้าย drop-off สูงอ่าน available และ message_th ตั้งแต่หน้าแรกของฟอร์ม
17ประมวลผล webhook แบบ synchronous ยาวtimeout → เรา retry → ประมวลผลซ้ำ → เครดิตซ้ำตอบ 200 ภายใน 3 วินาที แล้วทำงานหนักแบบ async
18ไม่กันซ้ำด้วย event.iddelivery ซ้ำทำให้ยอดเกินtransactional inbox มี UNIQUE(event_id) เก็บ ≥ 31 วัน · เจอซ้ำ ตอบ 200 ไม่ใช่ 409
19ถือว่า withdrawal.filled = ถอนเสร็จปิดรายการที่ผู้ใช้ได้เงินไม่ครบปิดเมื่อ status === 'settled' และ remaining_minor === 0
20ปล่อยให้ผู้ใช้พิมพ์ข้อมูลบัญชีผู้โอนเองทั้งหมดอัตรา under_review พุ่งเกิน 30% คิวเจ้าหน้าที่ล้น ผู้ใช้รอนานautofill จาก GET /v1/pay/senders · ให้ เลือก มากกว่าให้ พิมพ์

สามข้อที่ถ้าพลาดแล้วเสียเงินจริง ไม่ใช่แค่ UX เพี้ยน

ข้อ 1 · ข้อ 2 · ข้อ 12 — ทั้งสามข้อทำให้ตัวเลขเงินฝั่งคุณกับฝั่งเราไม่ตรงกัน และไม่มีทางกู้อัตโนมัติ

ทางกันเดียวที่ได้ผลคือ reconcile job รายวันที่รันจริงตั้งแต่วันแรก ไม่ใช่ตั้งใจว่าจะทำทีหลัง


เอาไปแปะใน PR สุดท้ายก่อนขึ้นจริง ​

[ ] test vector ผ่านครบ 3 ชุด
[ ] เปลี่ยน Idempotency-Key แล้ว signature fail (พิสูจน์ว่าอยู่ใน canonical)
[ ] verify Ed25519 บน raw body · negative test แก้ 1 ไบต์ได้ 401
[ ] transactional inbox: UNIQUE(event_id) ≥ 31 วัน · เจอซ้ำตอบ 200
[ ] version guard เป็น atomic UPDATE · ผ่าน replay-out-of-order ทั้งเคส A และ B
[ ] เครดิตผู้ใช้ = amount_received_minor · กระเป๋า = credited_amount_minor
[ ] เก็บ X / V / A / E แยกกัน + assert A = min(V,X), 0 ≤ fee ≤ A และ A - fee = credited ทุกใบ
[ ] ผ่าน 116.00: same-slip overpay settle A=X ครั้งเดียว + open/contact_support flag · ไม่เครดิต/คืน E อัตโนมัติ
[ ] คำนวณเงินถอนจาก available_minor เท่านั้น
[ ] ปิดใบถอนเมื่อ settled AND remaining_minor = 0
[ ] handler ครบของ 5 event ที่ปิดไม่ได้
[ ] is_terminal คุมการปิดออร์เดอร์ ไม่ใช่ชื่อ status
[ ] อัปสลิปแล้วขึ้น "กำลังตรวจสอบ" ไม่ใช่ "สำเร็จ"
[ ] ฟอร์มผู้โอน autofill จาก /v1/pay/senders
[ ] reconcile job รายวันรันจริง ตรงติดกัน 3 วัน
[ ] alert: under_review เกินเกณฑ์ · webhook fail > 1% · sequence gap > 4 วัน
[ ] ฝ่ายการเงินรับทราบ pending_clearing 72 ชั่วโมง

ขั้นต่อไป ​

ถ้าคุณกำลัง…ไปที่
ไล่ยอดทดสอบใน sandboxSandbox
ตรวจว่า handler ครอบคลุมทุกสถานะหรือยังState machine
ตรวจว่า handler ครอบคลุมทุก event หรือยังรายการ event ทั้งหมด
เขียน mapping ของ errorError code ทั้งหมด
ตรวจว่าอ่านฟิลด์ถูกตัวหรือยังพจนานุกรม field
ย้ายมาจาก gateway เจ้าอื่นย้ายจากผู้ให้บริการเดิม
ตั้งค่า endpoint และการส่งซ้ำการส่งซ้ำและ endpoint ล่ม

เอกสารชุดนี้อิงสัญญา API เวอร์ชัน 2026-08-01