Skip to content

คำนวณค่าธรรมเนียมล่วงหน้า ​

หน้าจอถอนของคุณต้องตอบผู้ใช้ได้สองอย่างก่อนที่เขาจะกดยืนยัน

  1. เขาจะได้รับเงินเท่าไร — เท่ากับยอดที่เขากรอก เต็มจำนวน
  2. จะถูกหักจากกระเป๋าคุณเท่าไร — ยอดถอน บวก ค่าธรรมเนียม

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ค่า
channelwithdrawal (ฝั่งถอน) หรือ 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 ​

bash
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 ​

json
{
  "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 ว่างสำหรับ withdrawaldepositเท่านั้นที่มีหลาย source option
pricing_matrix_versionversion ราคาแบบ opaqueเก็บประกอบ log; orderที่สร้างจริงยังเป็น source of truth
total_minoramount_minor + fee_total_minor🔴 ตัวเลขที่ต้องเทียบกับ available_minor

Node (fetch) ​

js
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
<?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);
}

ประกอบกับยอดคงเหลือ — ตรรกะของปุ่ม "ถอน" ​

js
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_minorfee_total_minortotal_minorต้องมี available_minor อย่างน้อย
1,000.00 บาท1000002675 (26.75)102675102675
5,000.00 บาท5000009095 (90.95)509095509095

รายละเอียดของยอด 5,000.00:

json
{
  "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

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