Skip to content

รับ fill หลายก้อน ​

หน้านี้คือจุดที่โค้ดที่ย้ายมาจาก gateway เจ้าอื่นพังบ่อยที่สุด

1 รายการถอน ไม่ได้เท่ากับ 1 webhook

ถอน 1,000 บาท ถูกเติมด้วย 300 + 400 + 300 คุณจะได้

withdrawal.filled   ← ก้อนที่ 1   300 บาท
withdrawal.filled   ← ก้อนที่ 2   400 บาท
withdrawal.filled   ← ก้อนที่ 3   300 บาท
withdrawal.settled  ← ใบสรุป      ครบ 1,000 บาท

สี่ใบ ไม่ใช่ใบเดียว และถ้านับ withdrawal.matched ที่ยิงทุกครั้งที่จองผู้โอนได้ด้วย ใบนี้ใบเดียวจะสร้าง webhook รวม 8 ใบ (created 1 + matched 3 + filled 3 + settled 1)

โค้ดแบบ if (webhook) closeWithdrawal() จะปิดรายการตั้งแต่ก้อนแรก ⇒ ผู้ใช้เห็นว่า "ถอนสำเร็จ 1,000" ทั้งที่เพิ่งได้ 300

ทำไมถึงเป็นแบบนี้ ​

เราไม่ได้จ่ายจากบัญชีกลาง ผู้ฝากคนละคนโอนเข้าบัญชีปลายทางของผู้ถอนโดยตรง ทีละคน

ผู้ฝาก A  โอน 300  →  บัญชีปลายทางของผู้ใช้คุณ     (สลิป 1 ใบ)
ผู้ฝาก B  โอน 400  →  บัญชีปลายทางของผู้ใช้คุณ     (สลิป 1 ใบ)
ผู้ฝาก C  โอน 300  →  บัญชีปลายทางของผู้ใช้คุณ     (สลิป 1 ใบ)

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

⇒ webhook ก็ต้องมาเป็นรายก้อนให้ตรงกับความจริงในสมุดบัญชี ถ้าเราส่งใบเดียวตอนจบ คุณจะกระทบยอดกับ statement ไม่ได้เลย และผู้ใช้ที่เห็นเงินเข้า 300 บาทแล้วก็จะโทรมาถามว่าที่เหลือหายไปไหน

การตรวจสลิปเกิด "หลัง" เงินเข้าบัญชีจริง

POST /v1/pay/deposits/{id}/slip ฝั่งผู้ฝากตอบ 202 เมื่อ durable intake สำเร็จ — รับสลิปไว้ก่อนแล้วค่อยตรวจ ไม่มี 409 duplicate_slip แบบทันที; ถ้า storage/queue ยังยืนยันไม่ครบตอบ retryable 503

ผลที่ตกมาถึงฝั่งถอนของคุณ:

  • withdrawal.filled มาถึง ช้ากว่า จังหวะที่เงินเข้าบัญชีจริงเล็กน้อย (รอ pipeline ตรวจจบ) ⇒ ผู้ใช้อาจเห็น SMS ธนาคารก่อนเห็นสถานะในแอปคุณ ให้ข้อความ UI รองรับเรื่องนี้
  • ก้อนที่ filled ไปแล้ว ยังถูกกลับรายการได้ ถ้าตรวจย้อนหลังพบว่าสลิปใบนั้นใช้ไม่ได้ ⇒ ต้องมี handler ของ withdrawal.fill_reversed (🔴 event นี้ปิดการรับไม่ได้)

Timeline จริงของใบเดียว ​

รายการ wdr_01K3PBQ7X4M2TN9YR0HDVKW6SC · ถอน 1,000.00 (100000) · ค่าธรรมเนียม 26.75 (2675) · ล็อกไป 102675

เวลา (UTC)eventversionfilled_minorremaining_minorstatus
03:02:11.508withdrawal.created10100000open
03:14:26.118withdrawal.matched (จอง 300)2070000open
03:19:47.985withdrawal.filled #133000070000partially_filled
03:21:05.220withdrawal.matched (จอง 400)43000030000partially_filled
03:33:12.741withdrawal.filled #257000030000partially_filled
03:40:58.771withdrawal.matched (จอง 300)6700000partially_filled
03:52:40.096withdrawal.filled #371000000settled
03:52:40.102withdrawal.settled81000000settled

สังเกตสองอย่างที่ขัดสัญชาตญาณ:

  1. remaining_minor ไม่ลดตอน filled แต่ลดตอน matched — remaining คือ "ยอดที่ยังไม่มีใครรับปาก" · ตอนจองผู้โอนได้ ยอดนั้นย้ายไป "จองแล้ว" ทันที · พอโอนถึงจริง ยอดย้ายจาก "จองแล้ว" ไป filled_minor โดย remaining ไม่ขยับ
  2. status เปลี่ยนเป็น settled ตั้งแต่ filled ใบที่ 3 — ใบสรุป withdrawal.settled มาทีหลังด้วย version ที่สูงกว่า อย่ารอใบสรุปแล้วค่อยอ่าน status

Payload จริงทั้ง 4 ใบ ​

1 — withdrawal.filled ก้อนที่ 1 (300 บาท) ​

json
{
  "id": "evt_01K3PBR3N5W9ZS2QT6HY0KDXVC",
  "object": "event",
  "type": "withdrawal.filled",
  "api_version": "2026-08-01",
  "livemode": true,
  "created_at": "2026-08-21T03:19:47.985Z",
  "sequence": 91018,
  "account": { "merchant_id": "mch_01K2G4H9RT", "white_label_id": "wl_01K1QZ2MSV" },
  "data": {
    "object": {
      "id": "wdr_01K3PBQ7X4M2TN9YR0HDVKW6SC",
      "object": "withdrawal_order",
      "version": 3,
      "status": "partially_filled",
      "merchant_reference": "WD-20260821-0007",
      "end_user_ref": "u_88213",
      "beneficiary_id": "ben_01K2WZ5T8Q3M7XR0VYD4NHF6BC",
      "currency": "THB",
      "amount_total_minor": 100000,
      "fee_total_minor": 2675,
      "total_locked_minor": 102675,
      "filled_minor": 30000,
      "remaining_minor": 70000,
      "fee_recognized_minor": 802,
      "settled_splits": 1,
      "max_splits": 4,
      "created_at": "2026-08-21T03:02:11.402Z",
      "expires_at": "2026-08-21T09:02:11.402Z",
      "metadata": { "user_id": "u_88213", "ticket": "WD-20260821-0007" }
    },
    "fill": {
      "leg_ref": "leg_4TQ2XM9K",
      "sequence": 1,
      "amount_minor": 30000,
      "fee_recognized_minor": 802,
      "settled_at": "2026-08-21T03:19:47.903Z",
      "balance_transaction_id": "btxn_01K3PBR2M4V8ZT1QW5HN0YDXR7",
      "auto_confirm_at": "2026-08-21T06:19:47.903Z"
    },
    "previous_attributes": {
      "version": 2,
      "status": "open",
      "filled_minor": 0,
      "fee_recognized_minor": 0,
      "settled_splits": 0
    }
  }
}

2 — withdrawal.filled ก้อนที่ 2 (400 บาท) ​

json
{
  "id": "evt_01K3PBS8T2X4ZM6QW9HR0NDVKC",
  "object": "event",
  "type": "withdrawal.filled",
  "api_version": "2026-08-01",
  "livemode": true,
  "created_at": "2026-08-21T03:33:12.741Z",
  "sequence": 91033,
  "account": { "merchant_id": "mch_01K2G4H9RT", "white_label_id": "wl_01K1QZ2MSV" },
  "data": {
    "object": {
      "id": "wdr_01K3PBQ7X4M2TN9YR0HDVKW6SC",
      "object": "withdrawal_order",
      "version": 5,
      "status": "partially_filled",
      "merchant_reference": "WD-20260821-0007",
      "end_user_ref": "u_88213",
      "beneficiary_id": "ben_01K2WZ5T8Q3M7XR0VYD4NHF6BC",
      "currency": "THB",
      "amount_total_minor": 100000,
      "fee_total_minor": 2675,
      "total_locked_minor": 102675,
      "filled_minor": 70000,
      "remaining_minor": 30000,
      "fee_recognized_minor": 1872,
      "settled_splits": 2,
      "max_splits": 4,
      "created_at": "2026-08-21T03:02:11.402Z",
      "expires_at": "2026-08-21T09:02:11.402Z",
      "metadata": { "user_id": "u_88213", "ticket": "WD-20260821-0007" }
    },
    "fill": {
      "leg_ref": "leg_7HB3ND5R",
      "sequence": 2,
      "amount_minor": 40000,
      "fee_recognized_minor": 1070,
      "settled_at": "2026-08-21T03:33:12.664Z",
      "balance_transaction_id": "btxn_01K3PBS7R1W3YQ0MT5KN8ZDXV6",
      "auto_confirm_at": "2026-08-21T06:33:12.664Z"
    },
    "previous_attributes": {
      "version": 4,
      "filled_minor": 30000,
      "remaining_minor": 70000,
      "fee_recognized_minor": 802,
      "settled_splits": 1
    }
  }
}

3 — withdrawal.filled ก้อนที่ 3 (300 บาท) — ก้อนที่ทำให้ครบ ​

json
{
  "id": "evt_01K3PBT5V9Z2QN7WM4HD0RXKYC",
  "object": "event",
  "type": "withdrawal.filled",
  "api_version": "2026-08-01",
  "livemode": true,
  "created_at": "2026-08-21T03:52:40.096Z",
  "sequence": 91050,
  "account": { "merchant_id": "mch_01K2G4H9RT", "white_label_id": "wl_01K1QZ2MSV" },
  "data": {
    "object": {
      "id": "wdr_01K3PBQ7X4M2TN9YR0HDVKW6SC",
      "object": "withdrawal_order",
      "version": 7,
      "status": "settled",
      "merchant_reference": "WD-20260821-0007",
      "end_user_ref": "u_88213",
      "beneficiary_id": "ben_01K2WZ5T8Q3M7XR0VYD4NHF6BC",
      "currency": "THB",
      "amount_total_minor": 100000,
      "fee_total_minor": 2675,
      "total_locked_minor": 102675,
      "filled_minor": 100000,
      "remaining_minor": 0,
      "fee_recognized_minor": 2675,
      "settled_splits": 3,
      "max_splits": 4,
      "created_at": "2026-08-21T03:02:11.402Z",
      "expires_at": "2026-08-21T09:02:11.402Z",
      "metadata": { "user_id": "u_88213", "ticket": "WD-20260821-0007" }
    },
    "fill": {
      "leg_ref": "leg_2WC8FJ6P",
      "sequence": 3,
      "amount_minor": 30000,
      "fee_recognized_minor": 803,
      "settled_at": "2026-08-21T03:52:40.019Z",
      "balance_transaction_id": "btxn_01K3PBT4U8Y1ZR6QN3KM0WDXV5",
      "auto_confirm_at": "2026-08-21T06:52:40.019Z"
    },
    "previous_attributes": {
      "version": 6,
      "status": "partially_filled",
      "filled_minor": 70000,
      "remaining_minor": 30000,
      "fee_recognized_minor": 1872,
      "settled_splits": 2
    }
  }
}

4 — withdrawal.settled ใบสรุป ​

json
{
  "id": "evt_01K3PBT6X1R3SM8QW5HN0ZDKVC",
  "object": "event",
  "type": "withdrawal.settled",
  "api_version": "2026-08-01",
  "livemode": true,
  "created_at": "2026-08-21T03:52:40.102Z",
  "sequence": 91051,
  "account": { "merchant_id": "mch_01K2G4H9RT", "white_label_id": "wl_01K1QZ2MSV" },
  "data": {
    "object": {
      "id": "wdr_01K3PBQ7X4M2TN9YR0HDVKW6SC",
      "object": "withdrawal_order",
      "version": 8,
      "status": "settled",
      "merchant_reference": "WD-20260821-0007",
      "end_user_ref": "u_88213",
      "beneficiary_id": "ben_01K2WZ5T8Q3M7XR0VYD4NHF6BC",
      "currency": "THB",
      "amount_total_minor": 100000,
      "fee_total_minor": 2675,
      "total_locked_minor": 102675,
      "filled_minor": 100000,
      "remaining_minor": 0,
      "refunded_minor": 0,
      "fee_recognized_minor": 2675,
      "settled_splits": 3,
      "max_splits": 4,
      "dust_absorbed": false,
      "created_at": "2026-08-21T03:02:11.402Z",
      "expires_at": "2026-08-21T09:02:11.402Z",
      "fills": [
        { "leg_ref": "leg_4TQ2XM9K", "amount_minor": 30000, "fee_recognized_minor": 802,
          "settled_at": "2026-08-21T03:19:47.903Z", "receipt_state": "confirmed" },
        { "leg_ref": "leg_7HB3ND5R", "amount_minor": 40000, "fee_recognized_minor": 1070,
          "settled_at": "2026-08-21T03:33:12.664Z", "receipt_state": "awaiting_confirm" },
        { "leg_ref": "leg_2WC8FJ6P", "amount_minor": 30000, "fee_recognized_minor": 803,
          "settled_at": "2026-08-21T03:52:40.019Z", "receipt_state": "awaiting_confirm" }
      ],
      "metadata": { "user_id": "u_88213", "ticket": "WD-20260821-0007" }
    },
    "previous_attributes": { "version": 7 }
  }
}

withdrawal.matched — ใบที่ "ยังไม่ใช่เงิน" ​

ยิงทุกครั้งที่ระบบจองผู้โอนให้ใบนี้ได้ ไม่ใช่แค่ครั้งแรก field ที่ต่างจาก event อื่นคือ matched_legs และ reserved_minor

json
{
  "type": "withdrawal.matched",
  "data": {
    "object": {
      "id": "wdr_01K3PBQ7X4M2TN9YR0HDVKW6SC",
      "object": "withdrawal_order",
      "version": 2,
      "status": "open",
      "filled_minor": 0,
      "remaining_minor": 70000,
      "reserved_minor": 30000,
      "matched_legs": 1
    },
    "previous_attributes": { "version": 1, "remaining_minor": 100000, "reserved_minor": 0 }
  }
}

🔴 payload ของ withdrawal.matched ไม่มี id ของรายการฝากฝั่งตรงข้าม และจะไม่มีวันมี — คุณเห็นก้อนของคุณผ่าน leg_ref เท่านั้น และ leg_ref ที่ฝั่งผู้ฝากได้รับเป็นคนละค่ากับของคุณ

เอา matched ไปทำอะไร

ใช้บอกผู้ใช้ว่า "มีคนกำลังโอนให้ 300 บาท" เพื่อลดการโทรถาม — ห้ามเอาไปบวกยอด ห้ามเอาไปปิดรายการ ผู้โอนอาจไม่โอนจริง แล้วก้อนนั้นจะถูกปล่อยกลับเข้า pool เงียบ ๆ โดยไม่มี event แจ้ง (remaining_minor จะกลับขึ้นมาเองใน event ถัดไป)

ค่าธรรมเนียมของแต่ละก้อน — ทำไมถึงไม่เท่ากัน ​

ก้อนยอดfee_recognized_minor ของก้อนสะสมที่ระดับใบ
#130000802802
#24000010701872
#3300008032675
100000รวม 2675= fee_total_minor เป๊ะ

ก้อนที่ 1 กับก้อนที่ 3 ยอดเท่ากัน (300 บาท) แต่ค่าธรรมเนียมต่างกัน 1 สตางค์ เพราะระบบคิดแบบ สะสมแล้วหาส่วนต่าง ไม่ใช่คิดทีละก้อน:

fee สะสมหลังก้อนนี้ = floor( fee_total_minor × filled_minor หลังก้อนนี้ ÷ amount_total_minor )
fee ของก้อนนี้      = fee สะสมหลังก้อนนี้ − fee สะสมก่อนหน้า

ผลคือเศษปัดลงไม่หายไปไหน มันไปกองที่ก้อนสุดท้าย ⇒ ผลรวมตรงกับ fee_total_minor เสมอเมื่อเติมครบ

อย่าคำนวณค่าธรรมเนียมต่อก้อนเอง

ถ้าคุณเขียน fee_ก้อนนี้ = fee_total × ยอดก้อน / ยอดรวม คุณจะได้ 802 + 1070 + 802 = 2674 ⇒ ขาดไป 1 สตางค์ทุกใบ และงานกระทบยอดสิ้นเดือนจะไม่ลง

ใช้ fee_recognized_minor ที่ส่งมาให้ตรง ๆ เท่านั้น · ค่าธรรมเนียมแบบคงที่ (fixed_fee) ผูกกับ ใบ ไม่ใช่กับก้อน ⇒ ซอย 4 ครั้งไม่ได้เสีย fixed fee 4 เท่า

เก็บอะไรลง DB อย่างไร ​

หัวใจคือแยกให้ชัดว่าอะไร เขียนทับ อะไร เพิ่มบรรทัด

ข้อมูลเก็บที่คำสั่งที่ถูกเหตุผล
ยอดรวมของใบ (status, filled_minor, remaining_minor, settled_splits, fee_recognized_minor)pg_withdrawals (1 แถวต่อใบ)UPDATE ... WHERE pg_version < $new🔴 เขียนทับเฉพาะเมื่อ version สูงกว่าที่มี — ลำดับ webhook ไม่การันตี
ก้อนย่อยแต่ละก้อนpg_withdrawal_fills (PK = leg_ref)INSERT ... ON CONFLICT (leg_ref) DO NOTHING🔴 เพิ่มบรรทัด ไม่เขียนทับ · delivery ซ้ำต้องไม่เพิ่มบรรทัดซ้ำ
รายการเคลื่อนไหวของกระเป๋าpg_balance_txns (PK = btxn_id)INSERT ... ON CONFLICT (btxn_id) DO NOTHINGbalance_transaction เป็น immutable ไม่มี version ⇒ กันซ้ำด้วย id
event ที่เคยรับแล้วpg_webhook_inbox (PK = event_id)inbox + outbox commit ใน transaction เดียวกัน replay · เก็บอย่างน้อย 31 วัน
sql
CREATE TABLE pg_withdrawals (
  pg_withdrawal_id     text PRIMARY KEY,
  pg_version           bigint  NOT NULL,
  status               text    NOT NULL,
  amount_total_minor   bigint  NOT NULL,
  fee_total_minor      bigint  NOT NULL,
  total_locked_minor   bigint  NOT NULL,
  filled_minor         bigint  NOT NULL DEFAULT 0,
  remaining_minor      bigint  NOT NULL,
  refunded_minor       bigint  NOT NULL DEFAULT 0,
  fee_recognized_minor bigint  NOT NULL DEFAULT 0,
  settled_splits       smallint NOT NULL DEFAULT 0,
  max_splits           smallint,
  our_ref              text    NOT NULL,
  closed_at            timestamptz,          -- non-null = ปิดในระบบเราแล้ว กันปิดซ้ำ
  replacement_withdrawal_id text,             -- ใบใหม่ที่เปิดแทนหลัง W14 (ถ้ามี)
  updated_at           timestamptz NOT NULL DEFAULT now()
);

-- ตารางนี้มีไว้ทำ timeline / audit / กระทบยอดกับ statement เท่านั้น
-- 🔴 ห้ามใช้ SUM(amount_minor) ของตารางนี้ขับ business logic
CREATE TABLE pg_withdrawal_fills (
  leg_ref                text PRIMARY KEY,
  pg_withdrawal_id       text NOT NULL REFERENCES pg_withdrawals,
  sequence               smallint,
  amount_minor           bigint NOT NULL,
  fee_recognized_minor   bigint NOT NULL,
  settled_at             timestamptz NOT NULL,
  balance_transaction_id text NOT NULL,
  auto_confirm_at        timestamptz,
  reversed_at            timestamptz            -- ตั้งเมื่อได้ withdrawal.fill_reversed
);

-- reversal เป็น tombstone แยก เพื่อรับ reversal-before-fill ได้โดยไม่ต้องสร้าง fill ปลอม
CREATE TABLE pg_withdrawal_fill_reversals (
  leg_ref                         text PRIMARY KEY,
  pg_withdrawal_id                text NOT NULL REFERENCES pg_withdrawals,
  reason_code                     text NOT NULL,
  reverses_balance_transaction_id text NOT NULL UNIQUE,
  reversal_balance_transaction_id text NOT NULL UNIQUE,
  reversed_at                     timestamptz NOT NULL,
  replacement_withdrawal_id       text,
  effect_enqueued_at              timestamptz
);

Handler ที่ถูก (Node) ​

js
export async function applyWithdrawalEvent(event) {
  const o = event.data.object

  // inbox + version guard + tombstone + outbox + processed_at ต้อง commit พร้อมกัน
  await db.tx(async (t) => {
    // receiver ต้อง verify signature ก่อนเรียกฟังก์ชันนี้; PK(event_id) กัน replay ของ delivery เดิม
    const inbox = await t.query(
      `INSERT INTO pg_webhook_inbox (event_id, received_at)
       VALUES ($1, now()) ON CONFLICT (event_id) DO NOTHING`, [event.id])
    if (inbox.rowCount === 0) return

    // 1) ยอดรวม — snapshot ที่ version สูงกว่าชนะเสมอ
    const aggregate = await t.query(
      `INSERT INTO pg_withdrawals (pg_withdrawal_id, pg_version, status,
          amount_total_minor, fee_total_minor, total_locked_minor,
          filled_minor, remaining_minor, refunded_minor, fee_recognized_minor,
          settled_splits, max_splits, our_ref)
       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
       ON CONFLICT (pg_withdrawal_id) DO UPDATE
         SET pg_version = EXCLUDED.pg_version,
             status = EXCLUDED.status,
             filled_minor = EXCLUDED.filled_minor,
             remaining_minor = EXCLUDED.remaining_minor,
             refunded_minor = EXCLUDED.refunded_minor,
             fee_recognized_minor = EXCLUDED.fee_recognized_minor,
             settled_splits = EXCLUDED.settled_splits,
             updated_at = now()
         WHERE pg_withdrawals.pg_version < EXCLUDED.pg_version`,
      [o.id, o.version, o.status, o.amount_total_minor, o.fee_total_minor, o.total_locked_minor,
       o.filled_minor, o.remaining_minor, o.refunded_minor ?? 0, o.fee_recognized_minor ?? 0,
       o.settled_splits ?? 0, o.max_splits ?? null, o.metadata.ticket]
    )

    const f = event.data.fill // sibling ของ data.object ตาม OpenAPI

    // 2) fill เป็น append-only; ถ้า reversal มาก่อน ค่อย sync reversed_at จาก tombstone ด้านล่าง
    if (event.type === 'withdrawal.filled') {
      await t.query(
        `INSERT INTO pg_withdrawal_fills
           (leg_ref, pg_withdrawal_id, sequence, amount_minor, fee_recognized_minor,
            settled_at, balance_transaction_id, auto_confirm_at)
         VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
         ON CONFLICT (leg_ref) DO NOTHING`,
        [f.leg_ref, o.id, f.sequence ?? null, f.amount_minor, f.fee_recognized_minor,
         f.settled_at, f.balance_transaction_id, f.auto_confirm_at ?? null]
      )
      await t.query(
        `UPDATE pg_withdrawal_fills f SET reversed_at = r.reversed_at
           FROM pg_withdrawal_fill_reversals r
          WHERE f.leg_ref = $1 AND r.leg_ref = f.leg_ref
            AND r.pg_withdrawal_id = f.pg_withdrawal_id AND f.reversed_at IS NULL`, [f.leg_ref])
    }

    // 3) reversal เป็น financial delta แยกจาก aggregate version guard:
    //    event เก่ากว่า snapshot ปัจจุบันก็ยังต้อง compensate ครั้งเดียว
    if (event.type === 'withdrawal.fill_reversed') {
      const r = event.data.reversal
      const allowed = new Set(['BANK_RECALL', 'ACCOUNT_FROZEN', 'SLIP_INVALIDATED_AFTER_SETTLEMENT'])
      if (!allowed.has(r.reason_code)) throw new Error('invalid withdrawal reversal reason')
      if (r.reverses_balance_transaction_id !== f.balance_transaction_id)
        throw new Error('reversal does not point to the fill balance transaction')

      await t.query(
        `INSERT INTO pg_withdrawal_fill_reversals
           (leg_ref, pg_withdrawal_id, reason_code, reverses_balance_transaction_id,
            reversal_balance_transaction_id, reversed_at, replacement_withdrawal_id)
         VALUES ($1,$2,$3,$4,$5,$6,$7)
         ON CONFLICT (leg_ref) DO NOTHING`,
        [f.leg_ref, o.id, r.reason_code, r.reverses_balance_transaction_id,
         r.balance_transaction_id, event.created_at, event.data.replacement_withdrawal_id ?? null]
      )

      // ON CONFLICT อาจเป็น replay ที่ถูกต้องหรือ payload ชนกัน ต้องแยกสองกรณีนี้
      const { rows: [stored] } = await t.query(
        `SELECT * FROM pg_withdrawal_fill_reversals WHERE leg_ref = $1 FOR UPDATE`, [f.leg_ref])
      if (!stored || stored.pg_withdrawal_id !== o.id || stored.reason_code !== r.reason_code ||
          stored.reverses_balance_transaction_id !== r.reverses_balance_transaction_id ||
          stored.reversal_balance_transaction_id !== r.balance_transaction_id ||
          stored.replacement_withdrawal_id !== (event.data.replacement_withdrawal_id ?? null))
        throw new Error('conflicting withdrawal fill reversal')

      await t.query(
        `UPDATE pg_withdrawal_fills SET reversed_at = COALESCE(reversed_at, $2)
          WHERE leg_ref = $1 AND pg_withdrawal_id = $3`, [f.leg_ref, event.created_at, o.id])
      await t.query(
        `UPDATE pg_withdrawals
            SET closed_at = NULL,
                replacement_withdrawal_id = COALESCE($2, replacement_withdrawal_id),
                updated_at = now()
          WHERE pg_withdrawal_id = $1`, [o.id, event.data.replacement_withdrawal_id ?? null])

      const effect = await t.query(
        `UPDATE pg_withdrawal_fill_reversals SET effect_enqueued_at = now()
          WHERE leg_ref = $1 AND effect_enqueued_at IS NULL
          RETURNING leg_ref`, [f.leg_ref])
      if (effect.rowCount === 1) await enqueueEffect(
        t, `withdrawal-fill:${f.leg_ref}:reverse`, 'compensate_reversed_fill', {
          withdrawal_id: o.id,
          leg_ref: f.leg_ref,
          amount_minor: f.amount_minor,
          reason_code: r.reason_code,
          reverses_balance_transaction_id: r.reverses_balance_transaction_id,
          reversal_balance_transaction_id: r.balance_transaction_id,
          replacement_withdrawal_id: event.data.replacement_withdrawal_id ?? null
        })

      await t.query(`UPDATE pg_webhook_inbox SET processed_at = now() WHERE event_id = $1`, [event.id])
      return
    }

    if (aggregate.rowCount === 0) {
      await t.query(`UPDATE pg_webhook_inbox SET processed_at = now() WHERE event_id = $1`, [event.id])
      return // aggregate เก่ากว่า แต่ fill/tombstone ที่ไม่เคยเห็นยังถูกบันทึกแล้ว
    }

    // 4) side effect ของสถานะผูกกับผล version-guarded aggregate update
    switch (o.status) {
      case 'partially_filled':
        await enqueueEffect(t, event.id, 'show_progress', {
          ticket: o.metadata.ticket, filled_minor: o.filled_minor, amount_total_minor: o.amount_total_minor })
        break
      case 'settled':
        if (o.remaining_minor === 0) {
          const closed = await t.query(
            `UPDATE pg_withdrawals SET closed_at = now()
              WHERE pg_withdrawal_id = $1 AND status = 'settled'
                AND remaining_minor = 0 AND closed_at IS NULL`, [o.id])
          if (closed.rowCount === 1) await enqueueEffect(
            t, `withdrawal:${o.id}`, 'close_withdrawal', {
              withdrawal_id: o.id, ticket: o.metadata.ticket })
        }
        break
      case 'expired':
      case 'canceled': {
        const refundQueued = await t.query(
          `UPDATE pg_withdrawals SET refund_enqueued_at = now()
            WHERE pg_withdrawal_id = $1 AND status IN ('expired', 'canceled')
              AND refund_enqueued_at IS NULL`, [o.id])
        if (refundQueued.rowCount === 1) await enqueueEffect(
          t, `withdrawal:${o.id}`, 'refund_unfilled', {
            withdrawal_id: o.id, ticket: o.metadata.ticket, refunded_minor: o.refunded_minor })
        break
      }
      case 'rejected': {
        const lockReleased = await t.query(
          `UPDATE pg_withdrawals SET user_lock_released_at = now()
            WHERE pg_withdrawal_id = $1 AND status = 'rejected'
              AND user_lock_released_at IS NULL`, [o.id])
        if (lockReleased.rowCount === 1) await enqueueEffect(
          t, `withdrawal:${o.id}`, 'release_user_lock', {
            withdrawal_id: o.id, ticket: o.metadata.ticket })
        break
      }
      default:
        await enqueueEffect(t, event.id, 'update_local_status', {
          ticket: o.metadata.ticket, status: o.status })
    }
    await t.query(`UPDATE pg_webhook_inbox SET processed_at = now() WHERE event_id = $1`, [event.id])
  })
}

enqueueEffect insert ลง transactional outbox เท่านั้น ห้ามเรียก network ใน transaction; ถ้า compensation ขยับยอดใน DB ของคุณเอง ให้ทำ SQL ใน transaction เดียวกัน pg_withdrawal_fill_reversals.effect_enqueued_at กับ stable key withdrawal-fill:{leg_ref}:reverse ทำให้ replay, reversal-before-fill และการมาถึงสลับลำดับไม่ชดเชยซ้ำ สำหรับ W14 handler เปิด local order ที่เคยปิดด้วย closed_at = NULL และเก็บ replacement_withdrawal_id; สถานะ API ของใบเดิมยังเป็น settled และห้ามเขียนย้อน

สร้าง marker สำหรับ transition แบบ one-shot แล้วผูกกับคอลัมน์ ไม่ใช่ผูกกับ "เคยเห็น event นี้ไหม":

sql
ALTER TABLE pg_withdrawals
  ADD COLUMN IF NOT EXISTS refund_enqueued_at timestamptz,
  ADD COLUMN IF NOT EXISTS user_lock_released_at timestamptz;
-- closed_at, refund_enqueued_at และ user_lock_released_at ต้องเขียนใน transaction เดียวกับ outbox

สามข้อห้ามที่ทำให้ยอดผิด ​

DANGER

🔴 ห้าม SUM(pg_withdrawal_fills.amount_minor) มาขับ logic — event อาจยังมาไม่ครบ (retry ได้ยาวถึง ~92 ชั่วโมง) คุณจะได้ยอดต่ำกว่าความจริงแล้วปล่อยให้ผู้ใช้ถอนซ้ำ

🔴 ห้าม filled_minor += fill.amount_minor — webhook ส่งซ้ำเป็นเรื่องปกติ บวกทับ = ยอดเกิน

🔴 ห้ามอ่านยอดคงเหลือจาก balance_after ใน balance_transaction — เป็น snapshot ณ ตอนที่รายการนั้นเกิด ถ้า event สลับลำดับ การเขียนทับจะได้ยอดเก่า · ใช้ตรวจสอบได้ ยอดจริงดึงจาก GET /v1/pay/balance

แหล่งความจริงมีอันเดียว: data.object.filled_minor และ data.object.remaining_minor ที่ผ่าน version guard แล้ว ตาราง fill มีไว้ทำ timeline และกระทบยอดกับ statement เท่านั้น

ถ้า event สลับลำดับ ​

มาถึงจริง:   filled #2 (v5)  →  filled #1 (v3)  →  settled (v8)

v5 มาก่อน → pg_version 0 < 5 → เขียน filled_minor = 70000  ✓
v3 ตามมา  → pg_version 5 < 3 เป็นเท็จ → rowCount = 0 → ยอดรวมไม่ถูกทับด้วยของเก่า ✓
             แต่บรรทัด leg_4TQ2XM9K ยังถูก INSERT เข้าตาราง fill ✓ (timeline ครบ)
v8 ตามมา  → 5 < 8 → เขียน filled_minor = 100000, status = settled → ปิดรายการ ✓

ตอบ 200 ทุกกรณีรวมถึงตอน rowCount = 0 — ถ้าตอบ non-2xx ระบบจะ retry ใบเดิมซ้ำอีก 11 รอบโดยไม่มีประโยชน์

UI ที่ถูกต้อง ​

❌ แถบสถานะเดียว           "กำลังดำเนินการ..."
✅ timeline หลายบรรทัด      ได้รับแล้ว 700.00 จาก 1,000.00 บาท
                            ├ 03:19  +300.00 บาท
                            ├ 03:33  +400.00 บาท
                            └ รอส่วนที่เหลือ 300.00 บาท
statusข้อความที่ควรขึ้น
pending_approvalคำขอถอนอยู่ระหว่างอนุมัติ
openกำลังจัดหาผู้โอน
partially_filledได้รับแล้ว 700 จาก 1,000 บาท กำลังรอส่วนที่เหลือ
settledถอนสำเร็จ 1,000 บาท
expiredส่วนที่เหลือ 300 บาท ถูกคืนเข้าบัญชีของคุณแล้ว
canceledยกเลิกรายการถอนแล้ว ยอดที่เหลือถูกคืนแล้ว
rejectedคำขอถอนไม่ได้รับอนุมัติ

🔴 leg_ref เป็นกลไกภายใน ใช้ทำลำดับ "ก้อนที่ 1 / 2 / 3" ได้ แต่ห้ามเอาไปแสดงเป็นข้อความสถานะเงิน

เคสสำคัญ — ได้ไม่ครบแล้วหมดเวลา ​

รายการเดิม 1,000 บาท เติมได้ 300 + 400 = 700 แล้ว expires_at ผ่านไปโดยไม่มีใครมารับก้อนสุดท้าย

json
{
  "id": "evt_01K3PC5J2P8R4TN6QW0HZDXVKM",
  "object": "event",
  "type": "withdrawal.expired",
  "api_version": "2026-08-01",
  "livemode": true,
  "created_at": "2026-08-21T09:02:14.338Z",
  "sequence": 91788,
  "account": { "merchant_id": "mch_01K2G4H9RT", "white_label_id": "wl_01K1QZ2MSV" },
  "data": {
    "object": {
      "id": "wdr_01K3PBQ7X4M2TN9YR0HDVKW6SC",
      "object": "withdrawal_order",
      "version": 9,
      "status": "expired",
      "merchant_reference": "WD-20260821-0007",
      "end_user_ref": "u_88213",
      "currency": "THB",
      "amount_total_minor": 100000,
      "fee_total_minor": 2675,
      "total_locked_minor": 102675,
      "filled_minor": 70000,
      "remaining_minor": 30000,
      "refunded_minor": 30000,
      "fee_recognized_minor": 1872,
      "settled_splits": 2,
      "max_splits": 4,
      "released_balance_transaction_id": "btxn_01K3PC5H1N7Q3SM5PV9KY0WDXR",
      "created_at": "2026-08-21T03:02:11.402Z",
      "expires_at": "2026-08-21T09:02:11.402Z",
      "metadata": { "user_id": "u_88213", "ticket": "WD-20260821-0007" }
    },
    "previous_attributes": { "version": 8, "status": "partially_filled", "refunded_minor": 0 }
  }
}

เงินที่เข้าจริงไปไหน คืนเท่าไร ​

ก้อนจำนวนอยู่ที่ไหนตอนนี้เอากลับได้ไหม
ที่เติมสำเร็จแล้ว700.00 (70000)อยู่ในบัญชีธนาคารของผู้ใช้ปลายทางแล้ว — ผู้ฝากโอนเข้าไปจริง❌ จบแล้ว เป็นการโอนจริงระหว่างบัญชีบุคคล
เงินต้นที่ยังไม่ถูกเติม300.00 (30000)คืนเข้า available_minor ของคุณ✅ อัตโนมัติ
ค่าธรรมเนียมที่ถูกใช้จริง18.72 (1872)เก็บไปแล้วตามสัดส่วนที่จ่ายจริง 70%❌
ค่าธรรมเนียมที่ยังไม่ถูกใช้8.03 (2675 − 1872)คืนเข้า available_minor ของคุณ✅ อัตโนมัติ

รวมที่กลับเข้ากระเป๋าคุณ = 30000 + 803 = 30803 จากที่ล็อกไป 102675

ตัวเลขที่ต้องคืนให้ผู้ใช้ในระบบคุณ ไม่ใช่ refunded_minor ตัวเดียว

refunded_minor = 30000 คือ เงินต้น ที่ยังไม่ถูกเติม · ค่าธรรมเนียมส่วนที่ไม่ได้ใช้ (803) เป็นการคืนคนละก้อนกัน

ตอนสร้างรายการคุณล็อกยอดผู้ใช้ไว้ 102675 ⇒ ตอนนี้ต้องปลดล็อกคืนให้เขา 30803 และตัดจริง 71872 (เงินที่เขาได้รับ 70000 + ค่าธรรมเนียมที่ถูกใช้ 1872)

🔴 ยอดที่กลับเข้ากระเป๋าจริงให้กระทบยอดจาก balance.credited / GET /v1/pay/balance/transactions ไม่ใช่จาก field เดียวใน event นี้

expired ไม่ได้แปลว่าไม่มีการจ่ายเลย

กับดักที่ทุกคนติด: status = 'expired' หรือ 'canceled' แต่ filled_minor > 0 — เคยจ่ายไปแล้วบางส่วน · ทุก payload จึงมี amount_total_minor / filled_minor / refunded_minor / fee_recognized_minor แยกกันเสมอ อย่าอ่านแค่ status

ยอดเศษที่เล็กเกินกว่าจะหาผู้โอนได้ ​

ถ้าเหลือยอดเล็กมากจนไม่มีใครมารับ ระบบอาจปิดใบให้เต็มจำนวนเองโดยระบบเป็นผู้รับภาระส่วนต่าง — คุณจะได้ withdrawal.settled ที่มี "dust_absorbed": true และ filled_minor = amount_total_minor ตามปกติ ไม่ต้องทำอะไรพิเศษ ผู้ใช้ได้เงินครบ

ขั้นต่อไป ​

ถ้าคุณกำลังจะไปที่
เขียน version guard ให้ถูกตั้งแต่แรกลำดับไม่การันตี — version guard
ตรวจลายเซ็นก่อนแตะ payloadตรวจลายเซ็น Ed25519
ยกเลิกส่วนที่ยังไม่ถูกเติมยกเลิกรายการถอน
ดูว่าค่าธรรมเนียมมาจากไหนคำนวณค่าธรรมเนียมล่วงหน้า
ดู state ทั้งหมดของรายการถอนState machine ทั้งหมด
เตรียมขึ้น productionเช็กลิสต์ก่อน go-live

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