รับ 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) | event | version | filled_minor | remaining_minor | status |
|---|---|---|---|---|---|
| 03:02:11.508 | withdrawal.created | 1 | 0 | 100000 | open |
| 03:14:26.118 | withdrawal.matched (จอง 300) | 2 | 0 | 70000 | open |
| 03:19:47.985 | withdrawal.filled #1 | 3 | 30000 | 70000 | partially_filled |
| 03:21:05.220 | withdrawal.matched (จอง 400) | 4 | 30000 | 30000 | partially_filled |
| 03:33:12.741 | withdrawal.filled #2 | 5 | 70000 | 30000 | partially_filled |
| 03:40:58.771 | withdrawal.matched (จอง 300) | 6 | 70000 | 0 | partially_filled |
| 03:52:40.096 | withdrawal.filled #3 | 7 | 100000 | 0 | settled |
| 03:52:40.102 | withdrawal.settled | 8 | 100000 | 0 | settled |
สังเกตสองอย่างที่ขัดสัญชาตญาณ:
remaining_minorไม่ลดตอนfilledแต่ลดตอนmatched—remainingคือ "ยอดที่ยังไม่มีใครรับปาก" · ตอนจองผู้โอนได้ ยอดนั้นย้ายไป "จองแล้ว" ทันที · พอโอนถึงจริง ยอดย้ายจาก "จองแล้ว" ไปfilled_minorโดยremainingไม่ขยับstatusเปลี่ยนเป็นsettledตั้งแต่filledใบที่ 3 — ใบสรุปwithdrawal.settledมาทีหลังด้วยversionที่สูงกว่า อย่ารอใบสรุปแล้วค่อยอ่านstatus
Payload จริงทั้ง 4 ใบ
1 — withdrawal.filled ก้อนที่ 1 (300 บาท)
{
"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 บาท)
{
"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 บาท) — ก้อนที่ทำให้ครบ
{
"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 ใบสรุป
{
"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
{
"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 ของก้อน | สะสมที่ระดับใบ |
|---|---|---|---|
| #1 | 30000 | 802 | 802 |
| #2 | 40000 | 1070 | 1872 |
| #3 | 30000 | 803 | 2675 |
| 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 NOTHING | balance_transaction เป็น immutable ไม่มี version ⇒ กันซ้ำด้วย id |
| event ที่เคยรับแล้ว | pg_webhook_inbox (PK = event_id) | inbox + outbox commit ใน transaction เดียว | กัน replay · เก็บอย่างน้อย 31 วัน |
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)
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 นี้ไหม":
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 ผ่านไปโดยไม่มีใครมารับก้อนสุดท้าย
{
"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 |
