Skip to content

State machine ​

หน้านี้คือแผนที่ของทุกสถานะที่ webhook และ REST response จะส่งมาให้คุณ ถ้าคุณอ่านหน้าเดียวในหมวดอ้างอิง ให้อ่านหน้านี้

กฎข้อเดียวที่ห้ามลืม

status = "settled" เท่านั้นที่แปลว่าเครดิตได้ · ทุกค่าอื่นห้ามปล่อยของ ห้ามบวกยอดผู้ใช้ ห้ามส่งของ

slip_processing และ under_review แปลว่า เงินโอนไปแล้ว แต่ยังตรวจไม่จบ ไม่ใช่ "ล้มเหลว" และไม่ใช่ "สำเร็จ"

สถานะมี 3 ระดับ — merchant ตัดสินใจได้จาก 2 ระดับเท่านั้น ​

ระดับอ่านจากฟิลด์ไหนจำนวนค่าใช้ตัดสินใจเรื่องเงินได้ไหม
รายการฝากdata.object.status ของ object deposit_order9✅ settled เท่านั้น = เครดิตผู้ใช้ได้
รายการถอนdata.object.status ของ object withdrawal_order + filled_minor / remaining_minor7✅ 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) ​

ไดอะแกรม ​

mermaid
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_reviewnon-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 ได้จริง ใช้ได้ตรง ๆ

statusmerchant ต้องทำข้อความไทยที่ผู้ใช้ปลายทางควรเห็น
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 ไม่เปิด reviewend_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_UNAVAILABLEsupport
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) ​

ไดอะแกรม ​

mermaid
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
settledfilled_minor = amount_total_minorปล่อยครบแล้ว❌
expiredเลย expires_at โดยยังเหลือคืน refunded_minor + ค่าธรรมเนียมที่ยังไม่ถูกใช้❌
canceledยกเลิกส่วนที่เหลือโดยเจตนาเหมือน expired❌
rejectedback 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 ต้องทำอะไร และควรบอกผู้ใช้ปลายทางว่าอะไร ​

statusmerchant ต้องทำข้อความไทยที่ผู้ใช้ปลายทางควรเห็น
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_terminaldeposit_orderexpired/canceled ที่ is_terminal: false ยังรับสลิปได้อีก 24 ชม.
late_slip_accepted_untildeposit_order (อาจเป็น null)นาฬิกาเดียวของหน้าต่างอัปสลิปย้อนหลัง · null = ไม่เคยมีปลายทางให้โอน ⇒ ปิดได้เลย
partialdeposit.settledtrue = รับ short-pay ตามยอดจริง (A=V<X); settled_via:'auto' เมื่อ non-amount 5/5 ผ่าน และเป็น 'review' เฉพาะเมื่อมีเหตุอื่นแล้วถูก resolve · overpay เป็น false พร้อม overpayment{}
settled_viadeposit.settledauto | review — ใช้ทำรายงาน ไม่ใช่ใช้ตัดสินใจ
clearing_available_atdeposit.settledเวลาที่ยอดนี้ย้ายจาก pending_clearing ไป available (T+72 ชม.) ⇒ ก่อนเวลานี้สร้างรายการถอนจะได้ 402
review.escalateddeposit_order.reviewเกินเวลาแล้วถูกยกระดับ · version ขยับ แต่ สถานะและเงินไม่ขยับ
review.estimated_resolution_bucketdeposit_order.reviewunder_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_minorwithdrawal_orderแหล่งความจริงเดียวของยอดที่เติมแล้ว — 🔴 ห้ามคำนวณเองด้วยการบวกจาก event ที่ได้รับ

แล้วต้องเขียนโค้ดยังไง ​

ทั้งหมดลงเหลือ 3 บรรทัดนี้

  1. อ่านสถานะจาก data.object.status ไม่ใช่จาก event.type — event.type บอกแค่ว่า "มีอะไรเกิดขึ้น"
  2. เขียนสถานะลง DB ด้วย UPDATE เดียวที่มี version guard ไม่ใช่ SELECT แล้ว if แล้ว UPDATE
  3. ผูก side effect กับ ผลของ UPDATE (rowCount) ไม่ใช่กับ event type

ตัวอย่าง UPDATE ด้านล่างสมมติว่าคุณ insert แถว deposit และ pg_deposit_correlations จาก response 201/202 ตอนสร้างรายการแล้ว. ถ้าไม่ได้รับประกัน precondition นี้ ให้ใช้ atomic UPSERT จากหน้า ลำดับ event; ห้ามตี rowCount=0 ของแถวที่ยังไม่มีว่าเป็น event เก่า

sql
-- 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;
js
// รันใน 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_resolved

event 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

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