State machine
หน้านี้คือแผนที่ของทุกสถานะที่ webhook และ REST response จะส่งมาให้คุณ ถ้าคุณอ่านหน้าเดียวในหมวดอ้างอิง ให้อ่านหน้านี้
กฎข้อเดียวที่ห้ามลืม
status = "settled" เท่านั้นที่แปลว่าเครดิตได้ · ทุกค่าอื่นห้ามปล่อยของ ห้ามบวกยอดผู้ใช้ ห้ามส่งของ
slip_processing และ under_review แปลว่า เงินโอนไปแล้ว แต่ยังตรวจไม่จบ ไม่ใช่ "ล้มเหลว" และไม่ใช่ "สำเร็จ"
สถานะมี 3 ระดับ — merchant ตัดสินใจได้จาก 2 ระดับเท่านั้น
| ระดับ | อ่านจากฟิลด์ไหน | จำนวนค่า | ใช้ตัดสินใจเรื่องเงินได้ไหม |
|---|---|---|---|
| รายการฝาก | data.object.status ของ object deposit_order | 9 | ✅ settled เท่านั้น = เครดิตผู้ใช้ได้ |
| รายการถอน | data.object.status ของ object withdrawal_order + filled_minor / remaining_minor | 7 | ✅ settled และ remaining_minor = 0 เท่านั้น = ปิดใบได้ |
| ก้อนย่อย | legs[].leg_status (ฝั่งฝาก) · fills[] (ฝั่งถอน) | 2 (pending | complete) | 🔴 ไม่ได้ ใช้ทำ progress bar เท่านั้น |
leg_status = "complete" ไม่ได้แปลว่าเงินเข้า
legs[] คือ "ปลายทางที่ผู้ใช้ต้องโอนไป" ไม่ใช่ "สถานะของเงิน" · รายการฝากหนึ่งใบอาจถูกซอยเป็น 2 ปลายทาง (leg_count: 2) และ leg ที่ 1 ขึ้น complete แล้ว แต่ทั้งรายการยังไม่ settle
ตัวที่แปลว่าเงินเข้าคือ status = "settled" ตัวเดียว
ทุก object ที่เปลี่ยนสถานะจะมี version (integer) เพิ่มขึ้นทุกครั้ง และ ไม่มีทางที่ event สองใบของ object เดียวกันจะมี version เท่ากัน — ใช้ค่านี้กันไม่ให้ event เก่ามาทับของใหม่ (ดู ลำดับไม่การันตี — version guard)
รายการฝาก (deposit)
ไดอะแกรม
stateDiagram-v2
direction TB
[*] --> awaiting_liquidity: POST /v1/pay/deposits ตอบ 202 (ยังไม่มีปลายทาง)
[*] --> awaiting_payment: POST /v1/pay/deposits ตอบ 201 (จับคู่ได้ทันที)
awaiting_liquidity --> awaiting_payment: หาปลายทางได้ (deposit.matched)
awaiting_liquidity --> canceled: หมดทางจริง (no_liquidity_available)
awaiting_payment --> slip_processing: consumer claim receipt หลัง HTTP 202
awaiting_payment --> expired: เลย expires_at โดยไม่มีสลิป
awaiting_payment --> canceled: กดยกเลิก (ต้องยังไม่มีสลิป)
slip_processing --> settled: exact ผ่าน 6 มิติ หรือ amount-only short/overpay ที่อีก 5 มิติผ่าน
slip_processing --> under_review: มีเหตุ non-amount / ambiguity / policy จริง เข้าคิวเจ้าหน้าที่
slip_processing --> awaiting_payment: รูปอ่านไม่ออก และยังไม่หมดเวลา
slip_processing --> expired: รูปอ่านไม่ออก และหมดเวลาแล้ว
expired --> slip_processing: อัปสลิปย้อนหลังภายใน 24 ชม.
canceled --> slip_processing: อัปสลิปย้อนหลัง (บังคับเข้าคิวเสมอ)
expired --> under_review: ช่องทางสำรองยืนยันหลังปิดรายการ
canceled --> under_review: ช่องทางสำรองยืนยันหลังปิดรายการ
under_review --> under_review: เกินเวลา escalate (version ใหม่ ไม่แตะเงิน)
under_review --> settled: เจ้าหน้าที่อนุมัติ
under_review --> rejected: เจ้าหน้าที่ไม่รับสลิปใบนี้
settled --> reversed: ธนาคารเรียกคืน / อายัด / แพ้ข้อพิพาท
rejected --> [*]
reversed --> [*]
note right of settled
จุดเดียวที่เครดิตได้
deposit.settled + balance.credited 2 ใบ
(deposit_settled และ fee)
end note
note left of expired
expired ไม่ใช่การปฏิเสธเงิน
หน้าต่างอัปสลิปย้อนหลังปิดที่
late_slip_accepted_until
end note
note right of under_review
ไม่มีเส้น "หมดเวลา แล้วอนุมัติเอง"
และจะไม่มีวันมี
end noteแต่ละสถานะแปลว่าอะไร
status | แปลว่าอะไร | 💰 เงินอยู่ที่ไหน | จบจริงหรือยัง |
|---|---|---|---|
awaiting_liquidity | สร้างรายการแล้ว แต่ระบบยังหาปลายทางให้โอนไม่ได้ · ยังไม่มี QR ไม่มีเลขบัญชี | ยังไม่โอน | ไม่ |
awaiting_payment | จับคู่ปลายทางได้แล้ว มี legs[] และ expires_at | ยังไม่โอน (อยู่ที่ผู้ใช้) | ไม่ |
slip_processing | รับสลิปแล้ว กำลังตรวจกับธนาคาร | โอนไปแล้ว รอตรวจ | ไม่ |
under_review | non-amount ไม่ผ่าน / กำกวม / ติด policy / ต้องใช้คนตัดสิน · เหตุผลอยู่ใน review.reasons[] · amount-only short-pay/overpay ที่อีก 5 มิติผ่านไม่เข้าคิว | โอนไปแล้ว รอตรวจ | ไม่ |
settled | เครดิตแล้ว มี balance_transaction_id | เข้าระบบแล้ว · ถอนได้จริงหลัง clearing_available_at (T+72 ชม.) | เกือบ — ออกได้ทางเดียวคือ reversed |
expired | เลยเวลาโดยไม่มีสลิป · 🔴 ไม่ใช่การปฏิเสธเงิน | ปกติยังไม่โอน · เคสอัปสลิปย้อนหลัง = โอนแล้ว | ดู is_terminal |
canceled | ปิดก่อนโอน — merchant/ผู้ใช้กดยกเลิก หรือระบบหมดทางจัดหาปลายทาง | ปกติยังไม่โอน | ดู is_terminal |
rejected | เจ้าหน้าที่ตัดสินไม่รับสลิปใบนี้ · เหตุผลอยู่ใน rejection.display_th | ไม่เครดิต ไม่มีรายการเงิน | ✅ จบ |
reversed | เคย settled แล้วถูกกลับรายการ | หักคืนแล้ว (balance.debited) | ✅ จบ |
rejected ไม่ได้แปลว่าคืนเงิน
rejected แปลว่า "สลิปใบนี้ไม่ถูกใช้เครดิตรายการนี้" ถ้าเงินจริงออกจากบัญชีผู้ใช้ไปแล้ว เรื่องนั้นไปทาง claims ไม่ใช่จบที่สถานะนี้
merchant ต้องทำอะไร และควรบอกผู้ใช้ปลายทางว่าอะไร
ข้อความไทยด้านล่างคือสิ่งที่ทดสอบแล้วว่าลดคำถามเข้า support ได้จริง ใช้ได้ตรง ๆ
status | merchant ต้องทำ | ข้อความไทยที่ผู้ใช้ปลายทางควรเห็น |
|---|---|---|
awaiting_liquidity | หน้าจอรอ + spinner · เสนอยอดจาก liquidity.suggested_amounts_minor[] · 🔴 ห้ามแสดง QR ห้ามบอกให้โอน ห้ามขึ้นว่าล้มเหลว | "กำลังจัดหาช่องทางโอน กรุณารอสักครู่" |
awaiting_payment | แสดง legs[] ครบทุก leg + countdown จาก expires_at + กล่อง "โอนจากบัญชีที่แจ้งไว้เท่านั้น" ที่เด่นกว่า QR | "โอนเงิน 5,000 บาท จากบัญชีที่แจ้งไว้ ภายใน 08:24 น." |
slip_processing | ปิดปุ่มอัปสลิปซ้ำ · 🔴 ห้ามปล่อยของ | "กำลังตรวจสอบสลิปกับธนาคาร ใช้เวลาไม่เกิน 1 นาที" |
under_review | แสดง review.reasons[0].display_th ตรง ๆ · สีเหลืองหรือฟ้า ไม่ใช่สีแดง · 🔴 ห้ามมีปุ่ม "โอนใหม่" | ใช้ review.reasons[0].display_th ที่เราส่งมา — ทุกข้อความมีครบ 3 ประโยค: ได้รับสลิปแล้ว / ติดเพราะอะไร / เงินไม่หาย ไม่ต้องโอนซ้ำ |
settled | เครดิตผู้ใช้ด้วย amount_received_minor · ขยับกระเป๋าตัวเองด้วย credited_amount_minor | "เติมเงินสำเร็จ 5,000 บาท" |
settled + partial: true | เครดิตตามที่ได้รับจริง แล้วเสนอสร้างรายการใหม่สำหรับส่วนต่าง | "ได้รับยอดที่โอนจริง 3,000 บาท จากรายการ 5,000 บาท" |
expired (is_terminal: false) | 🔴 ต้องเหลือปุ่มอัปสลิปไว้ จนถึง late_slip_accepted_until ห้ามปิดออร์เดอร์ | "หมดเวลาแล้ว ถ้าคุณโอนไปแล้ว อัปโหลดสลิปได้ภายใน 24 ชั่วโมง" |
canceled + มี late_slip_accepted_until | 🔴 เหมือน expired ทุกประการ (เคสกดยกเลิกแล้วเผลอโอน) | "ยกเลิกรายการแล้ว ถ้าคุณเผลอโอนไปแล้ว อัปโหลดสลิปได้ถึง …" |
canceled + is_terminal: true | ปิดออร์เดอร์ได้ · มีปุ่มสร้างรายการใหม่ | "ยกเลิกรายการแล้ว" |
rejected | แสดง rejection.display_th + deposit_id แบบ copy ได้ | "รายการไม่ผ่านการตรวจสอบ ติดต่อฝ่ายบริการพร้อมเลขที่รายการ" |
reversed | 🔴 ต้องหักยอดผู้ใช้คืน และแจ้งเสมอ — event นี้ปิดไม่ได้ | "รายการถูกยกเลิกภายหลัง ยอดถูกปรับกลับ" |
มี overpayment{} (status ยัง settled) | เครดิตเฉพาะ amount_received_minor; แสดงยอดเกิน + claim_id + ปุ่มติดต่อ support · ห้ามสัญญาว่าคืนแล้ว | "รายการสำเร็จตามยอดที่สร้าง แต่พบยอดโอนเกิน กรุณาติดต่อฝ่ายบริการพร้อมเลขเรื่อง" |
สองสถานะที่ merchant ปิดออร์เดอร์เร็วเกินไปที่สุด
expired และ canceled ไม่ใช่จุดจบเสมอไป — ให้เช็ค is_terminal ไม่ใช่ชื่อสถานะ
ตราบใดที่ is_terminal เป็น false และยังไม่ถึง late_slip_accepted_until ผู้ใช้ยังอัปสลิปย้อนหลังได้ และคุณจะได้ deposit.settled ของออร์เดอร์ที่คุณ mark เป็น failed ไปแล้ว · handler ต้องรับ settled ได้จากทุกสถานะภายในของคุณ
ข้อความของ under_review แต่ละเหตุผล
review.reasons[] เรียงตามความสำคัญแล้ว — ถ้าจอมีที่แสดงข้อความเดียว ให้ใช้ reasons[0]
code | ใจความที่ผู้ใช้จะเห็น | ใครแก้ได้ (actionable_by) |
|---|---|---|
AMOUNT_MISMATCH | ยอดที่โอนไม่ตรงและมีเหตุ review อื่นร่วมด้วย; amount-only mismatch ไม่เปิด review | end_user |
TIME_WINDOW | เวลาที่โอนอยู่นอกช่วงของรายการนี้ | support |
SENDER_BANK_MISMATCH | ธนาคารที่โอนไม่ตรงกับที่แจ้งไว้ | end_user |
SENDER_ACCOUNT_MISMATCH | บัญชีที่โอนไม่ตรงกับที่แจ้งไว้ | end_user |
SENDER_NAME_MISMATCH | ชื่อบัญชีผู้โอนไม่ตรงกับที่แจ้งไว้ | end_user |
RECEIVER_MISMATCH | โอนไปบัญชีปลายทางผิด กรุณาตรวจสอบกับฝ่ายบริการ (🔴 ไม่มีรายละเอียดบัญชีให้ และห้ามเดาเอง) | end_user |
BANK_DISABLED | ธนาคารต้นทางปิดรับตรวจสอบอัตโนมัติชั่วคราว ทีมงานตรวจด้วยตนเอง | support |
DUPLICATE_SLIP | สลิปนี้ถูกใช้ไปแล้ว | end_user |
SLIP_INVALID | สลิปตรวจสอบกับธนาคารไม่ผ่าน ทีมงานกำลังตรวจสอบ | support |
SLIP_UNREADABLE | รูปสลิปอ่านไม่ออก (มาใน deposit.slip_rejected ไม่ใช่ under_review) | end_user |
AMBIGUOUS_MATCH | ระบบจับคู่รายการไม่ชัดเจน ทีมงานกำลังตรวจสอบ | support |
PROVIDER_UNAVAILABLE | ระบบตรวจอัตโนมัติใช้งานไม่ได้ชั่วคราว ทีมงานตรวจด้วยตนเอง | support |
SENDER_ACCOUNT_UNAVAILABLE | เหมือน PROVIDER_UNAVAILABLE | support |
LATE_SLIP | สลิปมาหลังรายการหมดอายุ ทีมงานกำลังตรวจสอบ | support |
CANCELED_ORDER_SLIP | สลิปมาหลังรายการถูกยกเลิก ทีมงานกำลังตรวจสอบ | support |
ALLOCATION_CONFLICT | รายการซ้อนทับกัน ทีมงานกำลังตรวจสอบ (เปิดข้อพิพาทแล้ว) | support |
SETTLE_CONFLICT | ระบบตรวจอัตโนมัติใช้งานไม่ได้ชั่วคราว ทีมงานตรวจด้วยตนเอง | support |
ROUTE_VERIFICATION_PENDING | ช่องทางสำรองต้องตรวจเพิ่ม ทีมงานกำลังตรวจสอบ | support |
LATE_ROUTE_CONFIRMATION | ช่องทางสำรองยืนยันหลังปิดรายการ ทีมงานกำลังตรวจสอบ | support |
NO_SLIP_IMAGE | ได้รับเลขอ้างอิงแล้ว ทีมงานกำลังตรวจสอบกับธนาคาร | support |
RISK_HOLD | รายการอยู่ระหว่างตรวจสอบเพื่อความปลอดภัย | support |
ไม่ต้องเขียนข้อความเอง
ทุก reason มี display_th และ display_en มาให้ในตัว payload แล้ว · ReviewReasonCode เป็น open enum — เราเพิ่ม code ใหม่ได้โดยไม่นับเป็น breaking change ⇒ โค้ดของคุณต้องมี default: ที่ fallback ไปแสดง display_th เสมอ
รายการถอน (withdrawal)
ไดอะแกรม
stateDiagram-v2
direction TB
[*] --> pending_approval: POST /v1/pay/withdrawals (ต้องอนุมัติ) · ล็อกเงิน amount + fee
[*] --> open: POST /v1/pay/withdrawals (ไม่ต้องอนุมัติ) · ล็อกเงิน amount + fee
pending_approval --> open: back office อนุมัติ
pending_approval --> rejected: back office ปฏิเสธ · คืนเงินเต็ม
pending_approval --> canceled: ยกเลิก / หมดเวลาอนุมัติ 24 ชม. · คืนเงินเต็ม
open --> open: จับคู่ผู้โอนได้ (withdrawal.matched · status ไม่ขยับ)
open --> partially_filled: ก้อนแรกเข้า (withdrawal.filled)
partially_filled --> partially_filled: ก้อนถัดไปเข้า (withdrawal.filled อีกครั้ง)
open --> settled: ก้อนเดียวเต็มยอด
partially_filled --> settled: filled_minor = amount_total_minor
open --> expired: เลย expires_at โดยยังเหลือ remaining
partially_filled --> expired: เลย expires_at โดยยังเหลือ remaining
open --> canceled: ยกเลิกเฉพาะส่วนที่เหลือ
partially_filled --> canceled: ยกเลิกเฉพาะส่วนที่เหลือ
settled --> settled: ก้อนถูกกลับรายการ (withdrawal.fill_reversed · status ไม่ถอย)
partially_filled --> partially_filled: ก้อนถูกกลับรายการ (status ไม่ถอย)
settled --> [*]
expired --> [*]
canceled --> [*]
rejected --> [*]
note right of partially_filled
1 ใบรับได้หลายก้อน
withdrawal.filled มาหลายครั้ง
ปิดใบเมื่อ settled AND remaining_minor = 0
end noteแต่ละสถานะแปลว่าอะไร
status | แปลว่าอะไร | 💰 เงินของผู้ถอน | ยังเข้าคิวจับคู่ได้ไหม |
|---|---|---|---|
pending_approval | สร้างแล้ว รอ back office อนุมัติ | หัก amount_total_minor + fee_total_minor จากยอดที่ถอนได้แล้ว จ่ายออก 0 | ❌ |
open | อนุมัติแล้ว อยู่ในคิวจับคู่ ยังไม่มีก้อนไหน settle | ล็อกไว้เต็มยอด · อาจมี reserved_minor > 0 | ✅ ถ้ายังไม่ถึง expires_at |
partially_filled | มีอย่างน้อย 1 ก้อนที่ settle แล้ว ยังไม่ครบ | จ่ายออกแล้ว filled_minor · ที่เหลือยังล็อก | ✅ ถ้า remaining_minor > 0 |
settled | filled_minor = amount_total_minor | ปล่อยครบแล้ว | ❌ |
expired | เลย expires_at โดยยังเหลือ | คืน refunded_minor + ค่าธรรมเนียมที่ยังไม่ถูกใช้ | ❌ |
canceled | ยกเลิกส่วนที่เหลือโดยเจตนา | เหมือน expired | ❌ |
rejected | back office ปฏิเสธตอน pending_approval (ทางเดียวที่เข้าสถานะนี้) | คืนเต็ม | ❌ |
expired / canceled ไม่ได้แปลว่าไม่มีการจ่ายเลย
ถอน 10,000 บาท จ่ายไปแล้ว 4,000 แล้วหมดอายุ = status: "expired" ที่มี filled_minor: 400000
⇒ ห้ามคืนเงินผู้ใช้เต็มจำนวนเมื่อเห็น expired ให้อ่าน refunded_minor ซึ่งเป็นยอดที่คืนจริง · ทุก payload มี amount_total_minor / filled_minor / refunded_minor / fee_recognized_minor แยกกันเสมอ
merchant ต้องทำอะไร และควรบอกผู้ใช้ปลายทางว่าอะไร
status | merchant ต้องทำ | ข้อความไทยที่ผู้ใช้ปลายทางควรเห็น |
|---|---|---|
pending_approval | ยังให้กดยกเลิกได้ · 🔴 ห้ามให้แก้ยอดหรือบัญชีปลายทาง (ต้องยกเลิกแล้วสร้างใหม่) | "คำขอถอนอยู่ระหว่างอนุมัติ" |
open | แสดง total_locked_minor ที่ล็อกไว้ | "กำลังจัดหาผู้โอน" |
partially_filled | 🔴 แสดงเป็น progress หลายก้อน ไม่ใช่ป้ายสถานะเดียว · อ่านยอดจาก filled_minor / remaining_minor | "ได้รับแล้ว 4,000 จาก 10,000 บาท กำลังรอส่วนที่เหลือ" |
settled | ปิดรายการได้ ก็ต่อเมื่อ remaining_minor = 0 ด้วย | "ถอนสำเร็จ 10,000 บาท" |
expired | คืนยอดตาม refunded_minor ไม่ใช่ amount_total_minor | "ส่วนที่เหลือ 6,000 บาท ถูกคืนเข้าบัญชีของคุณแล้ว" |
canceled | เหมือน expired | "ยกเลิกรายการถอนแล้ว ยอดที่เหลือถูกคืนแล้ว" |
rejected | แสดง rejection.display_th · สร้างใบใหม่ได้ | "คำขอถอนไม่ได้รับอนุมัติ" |
มี dispute{} (status ไม่เปลี่ยน) | ส่งหลักฐานก่อน dispute.respond_by ผ่าน POST /v1/pay/disputes/{id}/evidence | "เราได้รับแจ้งว่ายังไม่ได้รับเงิน กำลังตรวจสอบ" |
สิ่งที่ต่างจากตลาดมากที่สุดในฝั่งถอน
ผู้ให้บริการทั่วไป: 1 รายการถอน = 1 callback ตอนจบ ของเรา: 1 รายการถอนอาจได้ withdrawal.filled 7 ครั้ง เพราะยอดถูกเติมจากผู้ฝากหลายคน
โค้ดที่เขียน if (callback) closeWithdrawal() จะปิดรายการตั้งแต่ก้อนแรก · เงื่อนไขปิดที่ถูกคือ status === 'settled' && remaining_minor === 0 · รายละเอียดที่ หนึ่งใบรับได้หลายก้อน
มิติที่ไม่ใช่ status แต่เปลี่ยนคำตอบ
สถานะไม่ได้บอกทุกอย่าง ฟิลด์เหล่านี้ขนานไปกับ status และเปลี่ยนสิ่งที่ UI ต้องทำ
| ฟิลด์ | อยู่ที่ไหน | ทำไมสำคัญ |
|---|---|---|
is_terminal | deposit_order | expired/canceled ที่ is_terminal: false ยังรับสลิปได้อีก 24 ชม. |
late_slip_accepted_until | deposit_order (อาจเป็น null) | นาฬิกาเดียวของหน้าต่างอัปสลิปย้อนหลัง · null = ไม่เคยมีปลายทางให้โอน ⇒ ปิดได้เลย |
partial | deposit.settled | true = รับ short-pay ตามยอดจริง (A=V<X); settled_via:'auto' เมื่อ non-amount 5/5 ผ่าน และเป็น 'review' เฉพาะเมื่อมีเหตุอื่นแล้วถูก resolve · overpay เป็น false พร้อม overpayment{} |
settled_via | deposit.settled | auto | review — ใช้ทำรายงาน ไม่ใช่ใช้ตัดสินใจ |
clearing_available_at | deposit.settled | เวลาที่ยอดนี้ย้ายจาก pending_clearing ไป available (T+72 ชม.) ⇒ ก่อนเวลานี้สร้างรายการถอนจะได้ 402 |
review.escalated | deposit_order.review | เกินเวลาแล้วถูกยกระดับ · version ขยับ แต่ สถานะและเงินไม่ขยับ |
review.estimated_resolution_bucket | deposit_order.review | under_15m | under_1h | under_4h | extended — เอาไปบอกผู้ใช้ได้ |
overpayment{} | deposit_order | ยอดเกินในสลิปเดียวหรือ transfer เพิ่ม · 🔴 status ยังเป็น settled; presence คือ flag, Claim คือสถานะ support ล่าสุด |
dispute{} | withdrawal_order | ผู้ถอนแจ้งว่ายังไม่ได้รับเงิน · status ไม่เปลี่ยน · funds_on_hold: true |
filled_minor / remaining_minor | withdrawal_order | แหล่งความจริงเดียวของยอดที่เติมแล้ว — 🔴 ห้ามคำนวณเองด้วยการบวกจาก event ที่ได้รับ |
แล้วต้องเขียนโค้ดยังไง
ทั้งหมดลงเหลือ 3 บรรทัดนี้
- อ่านสถานะจาก
data.object.statusไม่ใช่จากevent.type—event.typeบอกแค่ว่า "มีอะไรเกิดขึ้น" - เขียนสถานะลง DB ด้วย UPDATE เดียวที่มี version guard ไม่ใช่ SELECT แล้ว if แล้ว UPDATE
- ผูก side effect กับ ผลของ UPDATE (
rowCount) ไม่ใช่กับ event type
ตัวอย่าง UPDATE ด้านล่างสมมติว่าคุณ insert แถว deposit และ pg_deposit_correlations จาก response 201/202 ตอนสร้างรายการแล้ว. ถ้าไม่ได้รับประกัน precondition นี้ ให้ใช้ atomic UPSERT จากหน้า ลำดับ event; ห้ามตี rowCount=0 ของแถวที่ยังไม่มีว่าเป็น event เก่า
-- version guard: event เก่ามาทีหลังจะไม่ทับของใหม่ (rowCount = 0 ⇒ ตอบ 200 แล้วไม่ทำอะไร)
UPDATE deposits
SET status = $2,
version = $3,
is_terminal = $4,
late_slip_accepted_until = $5,
ordered_amount_minor = $6,
transferred_amount_minor = $7,
amount_received_minor = $8,
fee_total_minor = $9,
credited_amount_minor = $10,
overpayment = $11,
updated_at = now()
WHERE deposit_id = $1
AND version < $3
RETURNING status, amount_received_minor, credited_user_at;// รันใน inbox worker: version + เงินใน DB + outbox + processed_at commit พร้อมกัน
await db.tx(async (t) => {
const { rows: [correlation] } = await t.query(
`SELECT our_order_id, our_user_id
FROM pg_deposit_correlations
WHERE pg_deposit_id = $1`, [obj.id]);
if (!correlation) throw new Error('missing deposit correlation; retry webhook');
const { rowCount, rows } = await t.query(UPDATE_SQL, [
obj.id, obj.status, obj.version, obj.is_terminal, obj.late_slip_accepted_until ?? null,
obj.ordered_amount_minor ?? null, obj.transferred_amount_minor ?? null,
obj.amount_received_minor ?? null, obj.fee_total_minor ?? null,
obj.credited_amount_minor ?? null, JSON.stringify(obj.overpayment ?? null)
]);
// ต้องทำแม้ snapshot version นี้เก่ากว่า: D17 แต่ละใบมี claim_id คนละค่า
if (obj.overpayment) await db.appendOrValidateOverpaymentFlag(t, obj)
if (rowCount === 0) {
await t.query(`UPDATE pg_webhook_inbox SET processed_at = now() WHERE event_id = $1`, [event.id]);
return; // event เก่า — ปลอดภัยที่จะทิ้ง
}
if (obj.status === 'settled' && rows[0].credited_user_at === null) {
const credited = await t.query(
`UPDATE deposits SET credited_user_at = now()
WHERE deposit_id = $1 AND status = 'settled'
AND credited_user_at IS NULL`, [obj.id]);
if (credited.rowCount === 1) {
await t.query(`UPDATE users SET balance_minor = balance_minor + $1 WHERE id = $2`,
[rows[0].amount_received_minor, correlation.our_user_id]);
await enqueueEffect(t, `deposit:${obj.id}`, 'release_goods', {
order_id: correlation.our_order_id });
}
}
if (obj.status === 'reversed') {
const reversed = await t.query(
`UPDATE deposits SET reversed_at = now()
WHERE deposit_id = $1 AND status = 'reversed'
AND credited_user_at IS NOT NULL AND reversed_at IS NULL
RETURNING amount_received_minor`, [obj.id]);
if (reversed.rowCount === 1) {
await t.query(`UPDATE users SET balance_minor = balance_minor - $1 WHERE id = $2`,
[reversed.rows[0].amount_received_minor, correlation.our_user_id]);
await enqueueEffect(t, `deposit:${obj.id}`, 'hold_goods', {
order_id: correlation.our_order_id });
}
}
await t.query(`UPDATE pg_webhook_inbox SET processed_at = now() WHERE event_id = $1`, [event.id]);
});pg_deposit_correlations ต้องถูกบันทึกจาก create request + response ก่อนรับ webhook ตาม หน้า ordering เพราะ metadata เป็น optional และอาจไม่มีทั้ง object. ถ้า correlation ยังไม่มา ให้ rollback และตอบ non-2xx เพื่อรับ retry — ห้าม commit version แล้วค่อยหา user ทีหลัง
appendOrValidateOverpaymentFlag ต้องใช้ insert-or-validate ของ pg_deposit_overpayment_flags จากหน้า ordering และต้องรัน ก่อน stale-version return เสมอ. overpayment บนแถว deposit เป็นเพียง latest projection; ห้ามใช้แทนประวัติ claim หลายใบ
enqueueEffect คือ transactional outbox helper จากหน้าเดียวกัน ห้ามเรียก service ภายนอกใน DB transaction งาน one-shot ใช้ resource scope (deposit:${obj.id}) และ enqueue เฉพาะเมื่อ business guard ได้ rowCount = 1 เท่านั้น
สี่ยอด X/V/A/E ที่คนละความหมาย
ordered_amount_minor (X) = ยอดที่สร้างรายการ
transferred_amount_minor (V) = ยอดที่ธนาคารยืนยันว่าโอนจริง
amount_received_minor (A) = ยอดที่ระบบรับเข้า order → นี่คือตัวที่เครดิตให้ผู้ใช้ปลายทาง; กรณี overpay จะเท่ากับ X
overpayment.amount_minor (E) = ส่วนเกินที่เปิด claim ให้ติดต่อ support · same-slip overpay ต้องเป็น V = A + E; ห้ามเครดิตหรือคืนอัตโนมัติ
credited_amount_minor = ยอดหลังหักค่าธรรมเนียม → นี่คือตัวที่ขยับกระเป๋าของ merchant
amount_received_minor − fee_total_minor = credited_amount_minor เสมอ ทุกใบ · ใช้สลับกันเมื่อไหร่ ผู้ใช้จะได้เงินขาดเท่ากับค่าธรรมเนียมทุกออร์เดอร์ หรือยอดในระบบคุณจะสูงกว่าจริงทั้งเดือน
แผนที่ transition → event
ใช้เช็คว่า handler ของคุณครอบคลุมครบหรือยัง
รายการฝาก
─────────
(สร้าง) → awaiting_liquidity ⇒ deposit.created + deposit.awaiting_liquidity
(สร้าง) → awaiting_payment ⇒ deposit.created + deposit.matched
awaiting_liquidity → awaiting_payment ⇒ deposit.matched
awaiting_liquidity → canceled ⇒ deposit.canceled (no_liquidity_available)
awaiting_payment → slip_processing ⇒ deposit.slip_received
awaiting_payment → expired ⇒ deposit.expired (is_terminal=false)
awaiting_payment → canceled ⇒ deposit.canceled (is_terminal=false)
slip_processing → awaiting_payment ⇒ deposit.slip_rejected (retryable=true)
slip_processing → expired ⇒ deposit.slip_rejected + deposit.expired
slip_processing → settled ⇒ deposit.settled + balance.credited ×2
slip_processing → under_review ⇒ deposit.under_review
expired | canceled → slip_processing ⇒ deposit.slip_received (is_late=true)
expired | canceled → under_review ⇒ deposit.under_review (LATE_ROUTE_CONFIRMATION)
under_review [escalate] under_review ⇒ deposit.under_review (escalated=true, version ใหม่)
under_review → settled ⇒ deposit.settled + balance.credited ×2
under_review → rejected ⇒ deposit.rejected
[ก้อนย่อยขยับ] (status ไม่เปลี่ยน) ⇒ deposit.leg_updated
settled [T+72 ชม.] (status ไม่เปลี่ยน) ⇒ balance.cleared
settled → reversed ⇒ deposit.reversed + balance.debited
settled [สลิปใบที่ 2] (status ไม่เปลี่ยน) ⇒ deposit.overpaid
expired | canceled [ปิดหน้าต่าง] (status ไม่เปลี่ยน) ⇒ deposit.expired | deposit.canceled (is_terminal=true)
🔴 ไม่มีเส้น "หมดเวลาแล้วอนุมัติเอง" และจะไม่มีวันมี
🔴 ไม่มีเส้น awaiting_liquidity → expired (ออร์เดอร์ที่ไม่เคยมีปลายทางจะเป็น canceled เสมอ)
รายการถอน
─────────
(สร้าง) → pending_approval | open ⇒ withdrawal.created + balance.debited
pending_approval → open ⇒ (ไม่มี event เฉพาะ — อ่านจาก data.object.status)
pending_approval → rejected ⇒ withdrawal.rejected + balance.credited
pending_approval → canceled ⇒ withdrawal.canceled + balance.credited
open [ทุกครั้งที่จับคู่ได้] open ⇒ withdrawal.matched
open → partially_filled ⇒ withdrawal.filled + balance.debited
partially_filled → partially_filled ⇒ withdrawal.filled + balance.debited
open | partially_filled → settled ⇒ withdrawal.filled + withdrawal.settled
open | partially_filled → expired ⇒ withdrawal.expired + balance.credited
open | partially_filled → canceled ⇒ withdrawal.canceled + balance.credited
[ก้อนถูกกลับรายการ] (status ไม่ถอย) ⇒ withdrawal.fill_reversed + balance.credited
[ผู้ถอนยืนยันรับเงิน] (status ไม่เปลี่ยน) ⇒ withdrawal.receipt_confirmed
[เปิดข้อพิพาท] (status ไม่เปลี่ยน) ⇒ withdrawal.disputed → withdrawal.dispute_resolvedevent 5 ตัวที่ปิดไม่ได้
deposit.reversed · withdrawal.fill_reversed · balance.debited · webhook.key.rotating · webhook.endpoint.disabled
สามตัวแรกคือการเอาเงินออกจากกระเป๋าคุณ — ระบบบังคับให้ endpoint ทุกอันรับ 5 ตัวนี้ ปิดไม่ได้ ต้องมี handler ตั้งแต่วันแรก
ขั้นต่อไป
| ถ้าคุณกำลัง… | ไปที่ |
|---|---|
| อยากเห็นรายละเอียดของทุกสถานะฝั่งฝากพร้อมตัวอย่าง payload | สถานะและ state ทั้ง 9 ค่า |
ต้องรับ withdrawal.filled หลายครั้งให้ถูก | หนึ่งใบรับได้หลายก้อน (partial fill) |
| เจอสถานะถอยหลัง / ยอดถอนแสดงผิด | ลำดับไม่การันตี — version guard |
| อยากรู้ว่าฟิลด์ไหนแปลว่าอะไร มีเสมอหรือไม่ | พจนานุกรม field |
| ได้ HTTP error แล้วไม่รู้ว่าควร retry ไหม | Error code ทั้งหมด |
| กำลังจะขึ้นจริง | เช็กลิสต์ก่อน go-live |
