ยอดโอนไม่ครบ: รับยอดจริงอัตโนมัติ
เมื่อหลักฐาน authoritative ยืนยัน 0<V<X และ non-amount อีก 5 มิติผ่านครบโดยไม่มี ambiguity/policy hold ระบบจะ settled อัตโนมัติที่ A=V, partial:true, settled_via:'auto' — ไม่เข้า review และไม่ต้องเรียก force-complete
ตัวอย่าง: รายการ 5,000.00 บาท ผู้ใช้โอนจริง 3,000.00 บาท (กดผิด หรือเงินในบัญชีไม่พอ) · สลิปตรวจแล้วยืนยันได้ว่าโอน 3,000.00 บาทจริงจากบัญชีที่แจ้งไว้
POST /v1/pay/deposits/{id}/force-complete เป็น compatibility endpoint สำหรับ short-pay ที่ อยู่ under_review เพราะเหตุ non-amount/ambiguity/policy อื่นอยู่แล้วเท่านั้น. หลัง support resolve เหตุอื่น จึงรับ A=V แบบ settled_via:'review' ได้
compatibility endpoint ไม่ใช่การอนุมัติ
force-complete คือการ ขอ ให้เจ้าหน้าที่ตัดสิน ไม่ใช่คำสั่งให้เครดิต · response คือ 202 พร้อม status: "under_review" เงินยังไม่เข้าจนกว่าจะมี webhook deposit.settled
โค้ดที่เขียนว่า "ยิง force-complete แล้วเครดิตผู้ใช้เลย" จะเครดิตของที่ยังไม่ได้รับจริง ถ้า clean short-pay ถูกปิดอัตโนมัติแล้ว การเรียก endpoint นี้จะได้ 409 deposit_not_reviewable — ให้อ่านรายการล่าสุดและเครดิตด้วย amount_received_minor
ใช้เมื่อไหร่
| สถานการณ์ | ใช้ endpoint นี้ไหม |
|---|---|
| ผู้ใช้โอนขาดอย่างเดียว และ non-amount 5/5 ผ่าน | ❌ ไม่ต้อง — ระบบ settled อัตโนมัติที่ A=V<X, partial:true, settled_via:'auto' |
ผู้ใช้โอนขาด และรายการติด under_review เพราะ sender/time/receiver/ambiguity/policy อื่น | ✅ ใช่ หลังแนบหลักฐาน; support ต้อง resolve เหตุอื่นก่อนอนุมัติ |
| ผู้ใช้โอนเกิน | ❌ ไม่ใช่ — ถ้ามิติอื่นผ่าน รายการจะ settled ทันทีตามยอดที่สร้าง และมี overpayment{claim_id, amount_minor, action_required:'contact_support'} เป็น flag; ส่วนเกินไม่ถูกเครดิตหรือคืนอัตโนมัติ |
| ผู้ใช้โอนสองครั้งสำหรับรายการเดียว | ❌ ไม่ใช่ — รายการเดิมจะ settled ตามปกติ แล้วส่วนเกินมาเป็น deposit.overpaid แยกต่างหาก 🔴 ห้ามแตะรายการเดิม |
| ยังไม่มีสลิปเลย | ❌ ไม่ได้ — ต้องมีสลิปที่ตรวจผ่านอย่างน้อย 1 ใบก่อน |
| ผู้ใช้ยังไม่ได้โอน แต่คุณอยากปิดรายการ | ❌ ใช้ cancel แทน |
Request
curl -X POST https://api.example-wl.com/v1/pay/deposits/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B/force-complete \
-H "Authorization: Bearer sk_live_xxx" \
-H "PG-Timestamp: 1755750240" \
-H "PG-Nonce: 01JQA5TE0R2T4V6X8Z0B2D4F6H" \
-H "PG-Version: 2026-08-01" \
-H "Idempotency-Key: force-ORD-9981-01" \
-H "PG-Signature: v1=6b21...ff84" \
-H "Content-Type: application/json" \
-d '{
"verified_amount_minor": 300000,
"note": "รายการติดตรวจชื่อผู้โอน ยืนยันยอดจริงจากสลิป 3,000 บาท และแนบหลักฐานให้ตรวจชื่อแล้ว"
}'| field | ชนิด | บังคับ | ข้อจำกัด |
|---|---|---|---|
verified_amount_minor | integer | ✅ | ยอดที่ merchant แจ้งเพื่อประกอบ review หน่วยสตางค์ · ไม่ใช่ authoritative; ระบบ derive V ใหม่จากหลักฐานและต้องได้ 0<V<X |
note | string | ✅ | 🔴 อย่างน้อย 20 ตัวอักษร สั้นกว่านั้นได้ 422 · เขียนให้คนที่ไม่รู้เรื่องมาก่อนอ่านแล้วเข้าใจ |
note ที่สั้นเกินจะถูกปฏิเสธที่ระดับฐานข้อมูล
"ok" · "โอนขาด" · "ลูกค้าแจ้ง" — ทั้งหมดนี้สั้นเกิน · เขียนให้ครบว่า เหตุ review อื่นคืออะไร · โอนเท่าไหร่ · หลักฐานคืออะไร เพราะเจ้าหน้าที่จะใช้ข้อความนี้ตัดสิน
Response
{
"id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"object": "deposit_order",
"version": 6,
"status": "under_review",
"is_terminal": false,
"merchant_reference": "ORD-9981",
"amount_minor": 500000,
"currency": "THB",
"review": {
"opened_at": "2026-08-21T04:24:10.552Z",
"escalated": false,
"four_eyes_required": false,
"estimated_resolution_bucket": "under_1h",
"reasons": [
{
"code": "SENDER_NAME_MISMATCH",
"display_th": "เราได้รับสลิปของคุณแล้ว ชื่อผู้โอนไม่ตรงกับที่แจ้งไว้ เจ้าหน้าที่กำลังตรวจสอบ เงินของคุณไม่หาย ไม่ต้องโอนซ้ำ",
"display_en": "We received your slip. The sender name needs review. Your money is safe; do not transfer again.",
"actionable_by": "support",
"detail": { "registered_name_masked": "ส*** ใ***" }
},
{
"code": "AMOUNT_MISMATCH",
"display_th": "เราได้รับสลิปของคุณแล้ว ยอดที่โอนไม่ตรงกับยอดของรายการนี้ เจ้าหน้าที่กำลังตรวจสอบ เงินของคุณไม่หาย ไม่ต้องโอนซ้ำ",
"display_en": "We received your slip. The transferred amount does not match this order. Our team is reviewing it.",
"actionable_by": "end_user",
"detail": { "expected_minor": 500000, "observed_minor": 300000 }
}
]
},
"metadata": { "campaign": "aug-cashback", "order_id": "ORD-9981" }
}เงื่อนไขที่ต้องผ่าน
| # | เงื่อนไข | ถ้าไม่ผ่าน |
|---|---|---|
| 1 | รายการอยู่ under_review เพราะเหตุ non-amount/ambiguity/policy อื่นจริง | 409 deposit_not_reviewable; clean short-pay ที่ปิดแล้วไม่ย้อนเข้า review |
| 2 | มีสลิปหรือ poll evidence authoritative ที่ตรวจแล้ว | คำขอไม่ผ่าน — ต้องอัปสลิปก่อน |
| 3 | note ยาว ≥ 20 ตัวอักษร | 422 |
| 4 | verified_amount_minor ที่แจ้งต้องน้อยกว่า amount_minor ของรายการ | 422 validation_error; field นี้ยังไม่ใช่ยอดที่ระบบเชื่อ |
| 5 | ตอนอนุมัติ ระบบ derive ยอดจริง V จากหลักฐาน ไม่เชื่อยอดใน request และต้องได้ 0 < V < X โดย X คือยอดที่จองเดิม | ถ้าหลักฐานไม่ตรง เงื่อนไข review ไม่ผ่าน |
ส่วนต่างกลับเข้าสภาพคล่องอย่างไร
เมื่ออนุมัติ ระบบทำรายการเดียวแบบ atomic: ปลด reservation เดิม X, เติมให้ withdrawal เท่ากับยอดจริง V และคืน X−V เข้า remaining ของ withdrawal เดิมเพื่อให้จับคู่ต่อได้ โดยคิด journal และ fee เฉพาะ V เท่านั้น — ไม่พักส่วนต่างใน SUSPENSE และไม่เปิดเส้นคืนเงินจาก force-complete หาก X−V กลายเป็น dust ระบบจะจัดการภายหลังด้วยนโยบาย W11 และเพดานที่กำหนด หรือรอ cancel/expire ตามปกติ
ผลต่อยอดที่เครดิต
ส่วนนี้เป็นตัวอย่าง reviewed short-pay หลัง support resolve เหตุ non-amount/ambiguity/policy อื่นแล้ว. Clean short-pay ส่ง field การเงินชุดเดียวกันแต่เป็น settled_via:"auto" และ previous_attributes.status:"slip_processing"
{
"id": "evt_01K3MC4T7V9XB2DQF0KHRZ6N8M",
"object": "event",
"type": "deposit.settled",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T04:52:41.318Z",
"sequence": 88296,
"account": { "merchant_id": "mch_01K2G4H9RT5VQ0", "white_label_id": "wl_01K1QZ2MNB4XD7" },
"data": {
"object": {
"id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
"object": "deposit_order",
"version": 8,
"status": "settled",
"is_terminal": false,
"merchant_reference": "ORD-9981",
"amount_minor": 500000,
"ordered_amount_minor": 500000,
"partial": true,
"settled_via": "review",
"liquidity_source": "internal",
"currency": "THB",
"transferred_amount_minor": 300000,
"amount_received_minor": 300000,
"fee_total_minor": 5300,
"fee_breakdown": [
{ "component": "processing_fee", "amount_minor": 3000, "rate_bps": 100 },
{ "component": "fixed_fee", "amount_minor": 2000 },
{ "component": "vat", "amount_minor": 300 }
],
"credited_amount_minor": 294700,
"overpayment": null,
"balance_transaction_id": "btxn_01K3MC4T6R8WA1CPE9JGQY5M7L",
"settled_at": "2026-08-21T04:52:41.104Z",
"clearing_available_at": "2026-08-24T04:52:41.104Z",
"metadata": { "campaign": "aug-cashback", "order_id": "ORD-9981" }
},
"previous_attributes": { "version": 7, "status": "under_review" }
}
}| field | ค่าในตัวอย่าง | ความหมาย |
|---|---|---|
partial | true | 🔴 ธงที่บอกว่านี่คือรายการที่รับตามจริง ไม่ใช่ยอดเต็ม; ใช้ได้ทั้ง auto และ reviewed short-pay |
ordered_amount_minor | 500000 | ยอดที่สั่งไว้ตอนสร้างรายการ |
amount_received_minor | 300000 | ยอดที่เครดิตให้ผู้ใช้ปลายทาง |
fee_total_minor | 5300 | ค่าธรรมเนียมคิดจาก ยอดที่ได้รับจริง ไม่ใช่จากยอดที่สั่งไว้ |
credited_amount_minor | 294700 | ยอดเข้ากระเป๋าคุณ = 300000 − 5300 |
เครดิตด้วยตัวไหน
ผู้ใช้ปลายทางได้ amount_received_minor = 3,000.00 บาท · กระเป๋าของคุณขยับด้วย credited_amount_minor = 2,947.00 บาท
🔴 อย่าใช้ ordered_amount_minor เครดิตผู้ใช้ — นั่นคือยอดที่เขาไม่ได้โอน
// Node — handler ที่รองรับทั้ง settled ปกติและ settled แบบ partial
async function onDepositSettled(evt) {
const d = evt.data.object
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`, [d.id])
if (!correlation) throw new Error('missing deposit correlation; retry webhook')
// helper นี้คือ UPSERT + `WHERE stored_version < incoming_version` จากหน้า webhook ordering
const applied = await db.applyDepositSnapshotIfNewer(t, d, correlation)
if (!applied) {
await db.markWebhookApplied(t, evt.id)
return
}
// helper ทุกตัวรับ transaction `t`: version + เครดิต A + mirror กระเป๋า + outbox commit พร้อมกัน
const credited = await db.creditUserOnce(
t, correlation.our_user_id, d.id, d.amount_received_minor)
if (credited) {
await db.applyMerchantBalanceOnce(
t, d.id, d.credited_amount_minor, d.balance_transaction_id)
await enqueueEffect(t, `deposit:${d.id}`, 'release_goods', {
order_id: correlation.our_order_id,
received_minor: d.amount_received_minor,
ordered_minor: d.ordered_amount_minor
})
if (d.partial) await enqueueEffect(t, `deposit:${d.id}`, 'notify_actual_amount', {
user_id: correlation.our_user_id,
received_minor: d.amount_received_minor,
ordered_minor: d.ordered_amount_minor,
shortfall_minor: d.ordered_amount_minor - d.amount_received_minor
})
}
await db.markWebhookApplied(t, evt.id)
})
}บันทึก pg_deposit_correlations จาก create request + response ไว้ก่อนตาม Webhook ordering และใช้ mapping นี้หา user/order เสมอ — metadata เป็น optional. ห้ามแยก applyIfNewer() ออกจากการเครดิต/เขียน outbox เพราะถ้า process ล้มหลัง commit version แต่ก่อนเครดิต retry จะถูก version guard ทิ้งและผู้ใช้ไม่ได้ยอดจริง
ข้อความที่ผู้ใช้ควรเห็น
| ขั้น | ข้อความ |
|---|---|
clean short-pay (settled + partial:true + settled_via:'auto') | ได้รับยอดที่โอนจริง 3,000.00 บาท จากรายการ 5,000.00 บาท |
reviewed short-pay หลังยิง compatibility force-complete (under_review) | ใช้ review.reasons[0].display_th ของ เหตุอื่นที่ทำให้ติด review · ห้ามบอกว่ายอดขาดอย่างเดียวกำลังรอคน |
reviewed short-pay อนุมัติแล้ว (settled + partial:true + settled_via:'review') | ได้รับยอดที่โอนจริง 3,000.00 บาท จากรายการ 5,000.00 บาท |
| เสนอทางต่อ | ส่วนที่เหลือ 2,000.00 บาท สร้างรายการใหม่ได้เลย |
202 ใช้เฉพาะ compatibility path และห้ามบอกว่าอนุมัติแล้ว
202 แปลว่าคำขอของรายการที่มีเหตุ review อื่นเข้าคิวแล้วเท่านั้น. Clean short-pay ไม่ผ่าน response นี้—รอ deposit.settled แล้วแสดงยอดจริงทันที
ส่งหลักฐานเพิ่มระหว่างที่ยังตรวจอยู่
ถ้ารายการอยู่ under_review แล้วผู้ใช้ส่งข้อมูลมาเพิ่ม ให้เก็บเอกสารในระบบที่ปลอดภัยของ merchant แล้วส่งเฉพาะ reference เข้าเรื่องเดิม ไม่ใช่สร้างรายการใหม่
curl -X POST https://api.example-wl.com/v1/pay/deposits/dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B/evidence \
-H "Authorization: Bearer sk_live_xxx" \
-H "PG-Timestamp: 1755750412" -H "PG-Nonce: 01JQA5TG7X9M4TB0YR6WCZ8V2N" \
-H "PG-Version: 2026-08-01" -H "Idempotency-Key: evidence-ORD-9981-01" \
-H "PG-Signature: v1=8c41...d97a" -H "Content-Type: application/json" \
-d '{"kind":"bank_statement_line","reference":"merchant-ledger-2026-08-21#line-004921","note":"บรรทัดรายการเดินบัญชีที่ merchant ตรวจสอบแล้ว"}'| field | ชนิด | บังคับ | หมายเหตุ |
|---|---|---|---|
kind | string | ✅ | enum ปิด: bank_statement_line · chat_transcript · id_document_ref · other |
reference | string | ✅ | reference ของหลักฐานในระบบ merchant · id_document_ref ไม่ใช่รูปบัตร |
note | string | ✅ | คำอธิบายประกอบให้เจ้าหน้าที่อ่าน |
🔴 endpoint นี้รับ application/json เท่านั้น ไม่รับ multipart, file, base64 หรือ URL ที่เปิดเอกสารได้โดยตรง
ตอบ 202 พร้อม object ของรายการฝากเต็มใบ
แนบหลักฐานไม่ได้ทำให้เวลาเริ่มนับใหม่ และไม่เปลี่ยนสถานะ
รายการยังอยู่ under_review เหมือนเดิม version ขยับได้แต่ status ไม่เปลี่ยน ⇒ ห้ามขึ้นหน้าจอว่า "ส่งเรื่องใหม่แล้ว" และห้ามยิง POST /v1/pay/deposits ใบใหม่ให้ผู้ใช้เพราะคิดว่าใบเดิมตันแล้ว
พ้นหน้าต่าง late-slip แล้ว endpoint นี้ไม่ใช่ทางออก
ถ้ารายการปิดไปแล้ว (410 late_slip_window_closed ตอนอัปสลิป) เรื่องต้องไปที่ claims ไม่ใช่ที่นี่ — endpoint นี้แนบเข้า เรื่องที่ยังเปิดอยู่ เท่านั้น
ถ้าเจ้าหน้าที่ไม่อนุมัติ
รายการจะไป rejected พร้อม rejection.display_th ⇒ แสดงข้อความนั้นตรง ๆ พร้อม deposit_id ให้ผู้ใช้ copy ไปติดต่อ support
🔴 rejected ไม่ได้แปลว่าเงินถูกคืนอัตโนมัติ — ถ้าเงินเข้าไปจริงแล้ว การคืนเงินเดินคนละเส้นกับสถานะนี้ อย่าเขียนโค้ดที่ auto-refund เมื่อเห็น rejected
ขั้นต่อไป
- อัปโหลดสลิป — เงื่อนไขข้อ 1 ที่ต้องผ่านก่อนใช้ endpoint นี้
- สถานะรายการฝาก — ความหมายของ
under_reviewและยอด X/V/A/E ของsettled - ลำดับ webhook ไม่การันตี — version guard ที่ handler ด้านบนพึ่งอยู่
- ยกเลิกรายการฝาก — ทางเลือกเมื่อผู้ใช้ยังไม่ได้โอน
- พจนานุกรม field —
ordered_amount_minor·transferred_amount_minor·amount_received_minor·overpayment.amount_minor
