เช็กลิสต์ก่อน go-live
ทุกข้อในหน้านี้มาจากเคสจริงที่เคยทำให้ merchant เสียเงินหรือเสียลูกค้า ไม่มีข้อไหนใส่ไว้เพื่อความครบถ้วน
วิธีใช้: ไล่ทีละหมวด แต่ละข้อมี "พิสูจน์ยังไง" ต่อท้าย ⇒ ถ้าพิสูจน์ไม่ได้ แปลว่ายังไม่ผ่าน ไม่ใช่ผ่านแบบมั่นใจ
5 เงื่อนไขที่ระบบตรวจอัตโนมัติ ไม่ใช่ให้ติ๊กเอง
ก่อนออก sk_live_* ให้คุณ ระบบจะตรวจว่าคุณผ่านเส้นทางเหล่านี้ใน sandbox จริงหรือยัง
- ยอด 103.00 —
settledแล้วถูกreversedที่ +60 วินาที (พิสูจน์ว่าคุณ handle event ที่ปิดไม่ได้) - ยอด 108.00 — โอนซ้ำ ⇒
deposit.overpaidที่ +45 วินาที (พิสูจน์ว่าคุณไม่ย้อนสถานะออร์เดอร์ที่สำเร็จแล้ว) - ยอด 109.00 — กดยกเลิกแล้วเผลอโอน ⇒
canceledที่is_terminal: falseแล้วตามด้วยunder_review - ยอด 116.00 — same-slip overpay ที่อีก 5 มิติผ่าน ⇒
deposit.settledใบเดียว, เครดิตA = X, เปิด flag/claim ให้ติดต่อ support และไม่คืนอัตโนมัติ 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 มี headerIdempotent-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— พิสูจน์: assertamount_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ทุกครั้ง และ logrequest_idของทุก error — delivery headers ใช้เพื่อ observability เท่านั้น - [ ] ตั้ง technical contact ที่มีคนอ่านจริง — ใช้แจ้งตอน endpoint ถูกปิดอัตโนมัติและตอนหมุนกุญแจ
- [ ] รู้ว่า endpoint ถูกปิดอัตโนมัติเมื่อไหร่ — ล่มติดกัน 24 ชั่วโมง (success = 0 และล้มเหลว ≥ 50 ครั้ง) ⇒ ปิด แล้ว ต้องกดเปิดคืนเอง จาก dashboard
- [ ] ฝ่ายการเงินรับทราบเรื่อง
pending_clearing72 ชั่วโมง — 🔴 กระแสเงินสดเปลี่ยน ต้องบอกก่อน go-live ไม่ใช่หลัง
ยอดทดสอบใน sandbox
ทุกยอดด้านล่างใช้ในโหมด sandbox แล้วได้เส้นทางตามที่ระบุแบบกำหนดได้ ไม่ต้องรอโชค
| ยอด (บาท) | amount_minor | เส้นทางที่ได้ |
|---|---|---|
| 100.00 | 10000 | จับคู่ได้ → สลิปผ่านอัตโนมัติ → settled ใน ~5 วินาที |
| 101.00 | 10100 | → under_review ['SENDER_NAME_MISMATCH'] (ค้างจนกดอนุมัติหรือปฏิเสธ) |
| 102.00 | 10200 | → expired → อัปสลิปย้อนหลังอัตโนมัติที่ +60 วินาที → under_review['LATE_SLIP'] → settled |
| 103.00 | 10300 | settled → reversed ที่ +60 วินาที 🔴 บังคับ |
| 104.00 | 10400 | ช่องทางสำรอง 2 leg (หมดอายุจริงใน ~4 นาที) |
| 105.00 | 10500 | awaiting_liquidity เต็ม 25 วินาที แล้วจึงจับคู่ได้ |
| 106.00 | 10600 | → under_review ['BANK_DISABLED'] |
| 107.00 | 10700 | ใบถอนฝั่งตรงข้ามถูกซอยเป็น 3 ก้อน |
| 108.00 | 10800 | โอนซ้ำ — settled แล้วตามด้วย deposit.overpaid ที่ +45 วินาที 🔴 บังคับ |
| 109.00 | 10900 | กดยกเลิกแล้วเผลอโอน — canceled (is_terminal: false) → อัปสลิปย้อนหลัง → under_review['CANCELED_ORDER_SLIP'] 🔴 บังคับ |
| 110.00 | 11000 | ALLOCATION_CONFLICT — อัปสลิปย้อนหลังชนกับรายการอื่น + ฝั่งตรงข้ามได้ withdrawal.disputed |
| 111.00 | 11100 | clean short-pay อัตโนมัติ → settled ที่ A=V<X, partial:true, settled_via:'auto' โดยไม่เข้า review |
| 112.00 | 11200 | หมดหน้าต่างอัปสลิปย้อนหลัง → 410 late_slip_window_closed + claim_url |
| 113.00 | 11300 | 402 insufficient_balance ตอนสร้างรายการถอน (ยอดยังอยู่ใน pending_clearing) |
| 114.00 | 11400 | หมุนกุญแจ — webhook.key.rotating แล้วสลับ kid ใน 60 วินาที |
| 115.00 | 11500 | idempotency ชนกัน — key เดิม body ต่าง ⇒ 409 idempotency_key_reused |
| 116.00 | 11600 | same-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 ชั่วโมง) |
| 7 | poll ทุก 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 ได้ |
| 10 | retry 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) |
| 15 | hardcode 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.id | delivery ซ้ำทำให้ยอดเกิน | 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 ชั่วโมงขั้นต่อไป
| ถ้าคุณกำลัง… | ไปที่ |
|---|---|
| ไล่ยอดทดสอบใน sandbox | Sandbox |
| ตรวจว่า handler ครอบคลุมทุกสถานะหรือยัง | State machine |
| ตรวจว่า handler ครอบคลุมทุก event หรือยัง | รายการ event ทั้งหมด |
| เขียน mapping ของ error | Error code ทั้งหมด |
| ตรวจว่าอ่านฟิลด์ถูกตัวหรือยัง | พจนานุกรม field |
| ย้ายมาจาก gateway เจ้าอื่น | ย้ายจากผู้ให้บริการเดิม |
| ตั้งค่า endpoint และการส่งซ้ำ | การส่งซ้ำและ endpoint ล่ม |
