คำนวณค่าธรรมเนียมล่วงหน้า
หน้าจอถอนของคุณต้องตอบผู้ใช้ได้สองอย่างก่อนที่เขาจะกดยืนยัน
- เขาจะได้รับเงินเท่าไร — เท่ากับยอดที่เขากรอก เต็มจำนวน
- จะถูกหักจากกระเป๋าคุณเท่าไร — ยอดถอน บวก ค่าธรรมเนียม
GET /v1/pay/quote คือตัวที่ตอบข้อ 2 ให้ก่อนที่จะมีการล็อกเงินจริง
เราไม่หักค่าธรรมเนียมออกจากยอดถอน — เราบวกเพิ่ม
เจ้าอื่นในตลาดจำนวนมากทำแบบนี้: ถอน 1,000 → หักค่าธรรมเนียม 26.75 → ผู้ใช้ได้รับ 973.25
ของเราตรงกันข้าม: ถอน 1,000 → ผู้ใช้ได้รับ 1,000 เต็ม → กระเป๋าคุณถูกหัก 1,026.75
เหตุผล: ปลายทางคือบัญชีธนาคารของคนจริง ผู้ฝากโอนตามยอดที่เห็นบนหน้าจอ ถ้ายอดโอนไม่ตรงกับยอดในรายการ การจับคู่สลิปจะไม่ตรง ⇒ ยอดถอนต้องเป็นตัวเลขที่โอนกันจริงเสมอ
สิ่งที่คุณต้องทำเพิ่ม: ทุกที่ที่โค้ดเดิมของคุณเขียนว่า if (userBalance >= amount) ต้องแก้เป็น if (userBalance >= amount + fee) — ทั้งหน้าจอ ทั้ง validation ฝั่ง backend
Endpoint
GET /v1/pay/quote?channel=withdrawal&amount_minor=100000| query | ค่า |
|---|---|
channel | withdrawal (ฝั่งถอน) หรือ deposit (ฝั่งฝาก) |
amount_minor | ยอดในหน่วยสตางค์ · 1,000.00 บาท = 100000 |
เป็น GET ⇒ ไม่ต้องมี Idempotency-Key แต่ยังต้องเซ็น PG-Signature ตามปกติ (query ต้องเข้า canonical string ด้วย ดู การยืนยันตัวตนของ request)
Rate limit: bucket banks_quote = 3,000 ครั้ง/นาที ต่อ api_key — เรียกทุกครั้งที่ผู้ใช้พิมพ์ยอดใหม่ได้สบาย ๆ แต่ควร debounce ~300 มิลลิวินาที
ตัวอย่าง
curl
curl -sS 'https://api.example-wl.com/v1/pay/quote?amount_minor=100000&channel=withdrawal' \
-H 'Authorization: Bearer sk_live_9f2c1ab6e0d74f' \
-H 'PG-Timestamp: 1755745102' \
-H 'PG-Nonce: 01K3PA4R6T8V0X2Z4B6D8F0H2J' \
-H 'PG-Version: 2026-08-01' \
-H 'PG-Signature: v1=2c5d3e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d'Response 200
{
"channel": "withdrawal",
"amount_minor": 100000,
"fee_total_minor": 2675,
"maximum_fee_minor": 2675,
"fee_breakdown": [
{ "component": "processing_fee", "amount_minor": 1500, "rate_bps": 150 },
{ "component": "fixed_fee", "amount_minor": 1000, "rate_bps": 0 },
{ "component": "vat", "amount_minor": 175, "rate_bps": 700 }
],
"source_fee_options": [],
"pricing_matrix_version": "pmv_01K3Q9S7Y2",
"total_minor": 102675
}| field | ความหมาย | เอาไปทำอะไร |
|---|---|---|
amount_minor | ยอดที่ผู้ใช้ปลายทางจะได้รับเต็ม | แสดงว่า "คุณจะได้รับ 1,000.00 บาท" |
fee_total_minor | ค่าธรรมเนียมรวม | แสดงเป็นบรรทัดแยก |
maximum_fee_minor | เท่ากับ fee_total_minor สำหรับ withdrawal | ใช้โค้ดร่วมกับ deposit quote ได้ |
fee_breakdown[] | แยกตาม ประเภท ของค่าธรรมเนียม | เก็บดิบไว้ใน DB ใช้ตอนเถียงเรื่องยอด |
source_fee_options[] | array ว่างสำหรับ withdrawal | depositเท่านั้นที่มีหลาย source option |
pricing_matrix_version | version ราคาแบบ opaque | เก็บประกอบ log; orderที่สร้างจริงยังเป็น source of truth |
total_minor | amount_minor + fee_total_minor | 🔴 ตัวเลขที่ต้องเทียบกับ available_minor |
Node (fetch)
import { pgFetch } from './pg-client.js'
export async function quoteWithdrawal(amountMinor) {
const res = await pgFetch('GET', '/v1/pay/quote', {
query: { channel: 'withdrawal', amount_minor: String(amountMinor) },
})
if (res.status !== 200) throw new PgError(res.body.error)
const q = res.body
return {
userReceivesMinor: q.amount_minor, // ผู้ใช้ได้เต็ม
feeMinor: q.fee_total_minor,
walletDebitMinor: q.total_minor, // กระเป๋าคุณถูกหักเท่านี้
lines: q.fee_breakdown,
}
}
// แสดงผลเป็นบาท: อย่าใช้ float คูณหาร ให้แปลงตอนแสดงอย่างเดียว
export const toBaht = (minor) =>
(minor / 100).toLocaleString('th-TH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })PHP
<?php
function quote_withdrawal(int $amountMinor): array
{
[$status, $body] = pg_request('GET', '/v1/pay/quote', null, null, [
'channel' => 'withdrawal',
'amount_minor' => (string) $amountMinor,
]);
if ($status !== 200) {
throw new PgError($body['error']);
}
return [
'user_receives_minor' => $body['amount_minor'],
'fee_minor' => $body['fee_total_minor'],
'wallet_debit_minor' => $body['total_minor'],
'lines' => $body['fee_breakdown'],
];
}
// 100000 → "1,000.00" · หารด้วย 100 ตอนแสดงเท่านั้น เก็บใน DB เป็น integer เสมอ
function to_baht(int $minor): string
{
return number_format($minor / 100, 2);
}ประกอบกับยอดคงเหลือ — ตรรกะของปุ่ม "ถอน"
const [q, bal] = await Promise.all([
quoteWithdrawal(amountMinor),
pgFetch('GET', '/v1/pay/balance').then((r) => r.body),
])
// 🔴 เทียบกับ available_minor เท่านั้น — pending_clearing ยังถอนไม่ได้
const canWithdraw = bal.available_minor >= q.walletDebitMinor
// ข้อความที่ควรขึ้นเมื่อถอนไม่ได้แต่หน้าจอยังโชว์ว่ามีเงิน
if (!canWithdraw && bal.available_minor + bal.pending_clearing_minor >= q.walletDebitMinor) {
show('ยอดบางส่วนอยู่ระหว่างรอเคลียร์ ยังถอนไม่ได้')
}GET /v1/pay/balance คืน {available_minor, pending_clearing_minor, reserved_minor, currency, as_of}
| bucket | หมายถึง | ถอนได้ไหม |
|---|---|---|
available_minor | เคลียร์แล้ว | ✅ |
pending_clearing_minor | เพิ่งได้จากรายการฝาก รอ 72 ชั่วโมง | ❌ |
reserved_minor | ถูกล็อกไว้กับรายการถอนที่ยังไม่ปิด | ❌ |
กฎ 4 ข้อของค่าธรรมเนียมที่ทำให้คนคำนวณเองแล้วพลาด
อย่าคำนวณค่าธรรมเนียมเองในโค้ดของคุณ
1 · quote คือตัวเลข "ตอนนี้" ไม่ใช่สัญญา ค่าธรรมเนียมที่ผูกกับรายการจริงคือ fee_total_minor ใน response ของ POST /v1/pay/withdrawals ซึ่งถูก ล็อกตอนสร้างใบ และไม่เปลี่ยนอีก · quote ใช้เพื่อ "แสดงผลก่อนกด" เท่านั้น หลังสร้างใบแล้วให้เก็บและอ้างอิงค่าจาก response ของการสร้างเสมอ
2 · ค่าธรรมเนียมแบบคงที่ผูกกับ "ใบ" ไม่ใช่ "ก้อน" รายการถอน 1 ใบอาจถูกเติมเป็นหลายก้อน (max_splits) — ซอย 4 ครั้ง ไม่ได้ เสีย fixed_fee 4 เท่า ค่าธรรมเนียมทั้งก้อนถูก recognize ตามสัดส่วนที่จ่ายจริง
3 · ค่าธรรมเนียมของแต่ละก้อนไม่ใช่ fee_total ÷ จำนวนก้อน ระบบคิดแบบสะสมแล้วหาส่วนต่าง ⇒ ก้อนที่มียอดเท่ากันอาจได้ตัวเลขไม่เท่ากัน แต่ผลรวมตรงเป๊ะเมื่อครบ (ดูตัวเลขจริงใน รับ fill หลายก้อน)
4 · component เป็น open enum วันนี้เห็น processing_fee · fixed_fee · vat — พรุ่งนี้อาจมีตัวใหม่โดยไม่นับเป็น breaking change ⇒ switch (component) ต้องมี default: และ UI ต้องแสดงบรรทัดที่ไม่รู้จักได้ ไม่ใช่ซ่อนทิ้ง
ตัวอย่างตัวเลขสองชุดที่ใช้ทั้งเอกสารชุดนี้
| ยอดถอน | amount_minor | fee_total_minor | total_minor | ต้องมี available_minor อย่างน้อย |
|---|---|---|---|---|
| 1,000.00 บาท | 100000 | 2675 (26.75) | 102675 | 102675 |
| 5,000.00 บาท | 500000 | 9095 (90.95) | 509095 | 509095 |
รายละเอียดของยอด 5,000.00:
{
"amount_minor": 500000,
"fee_total_minor": 9095,
"fee_breakdown": [
{ "component": "processing_fee", "amount_minor": 7500, "rate_bps": 150 },
{ "component": "fixed_fee", "amount_minor": 1000, "rate_bps": 0 },
{ "component": "vat", "amount_minor": 595, "rate_bps": 700 }
],
"total_minor": 509095
}เก็บ fee_breakdown[] ดิบลง DB
เวลาลูกค้าหรือฝ่ายบัญชีถามว่า "ทำไมโดนหัก 90.95" คำตอบอยู่ในบรรทัดพวกนี้ · parse ด้วย component เสมอ อย่าเดาจากลำดับใน array
ฝั่งฝากใช้ endpoint เดียวกัน
channel=deposit คืนรูปแบบเดียวกัน แต่ความหมายต่างกันคนละขั้ว — ฝั่งฝาก ผู้ใช้โอนเต็มจำนวน แล้วกระเป๋าคุณได้ยอดหลังหักค่าธรรมเนียม:
ฝั่งฝาก ธนาคารยืนยัน transferred_amount_minor (V) → ระบบรับ amount_received_minor (A) → กระเป๋าคุณ +credited_amount_minor
ถ้า V>A ส่วนเกิน E อยู่ใน overpayment claim ไม่เข้ากระเป๋าและไม่คืนอัตโนมัติ
ฝั่งถอน ผู้ใช้ได้รับ amount_total_minor → กระเป๋าคุณ −total_locked_minor (มากกว่า เพราะบวกค่าธรรมเนียม)🔴 amount_received_minor (ยอดที่ต้องเครดิตให้ผู้ใช้ปลายทาง) กับ credited_amount_minor (ยอดที่กระเป๋า merchant ขยับ) เป็น คนละตัวเลข — สลับสองตัวนี้คือความผิดพลาดที่พบบ่อยที่สุดในการต่อครั้งแรก ดู พจนานุกรม field
ขั้นต่อไป
| ถ้าคุณกำลังจะ | ไปที่ |
|---|---|
| สร้างรายการถอนใบจริง | สร้างรายการถอน |
| เตรียมบัญชีปลายทางให้ผ่าน verify ก่อน | บัญชีปลายทาง |
| รู้ว่าค่าธรรมเนียมถูก recognize ทีละก้อนอย่างไร | รับ fill หลายก้อน |
| ดูชื่อ field ทั้งหมดที่เกี่ยวกับเงิน | พจนานุกรม field |
