กระเป๋าเงินและยอดคงเหลือ
กระเป๋าของ merchant มี 3 ช่อง ไม่ใช่ยอดเดียว เงินที่เพิ่งเข้าจากรายการฝาก ยังถอนไม่ได้ทันที
สิ่งที่พังบ่อยที่สุดในหน้านี้
เอา credited_amount_minor ที่ได้จาก deposit.settled ไปคิดว่า "ถอนได้เลย" แล้วสร้างรายการถอน จะได้ 402 insufficient_balance ทั้งที่หน้าจอมีเงิน เงินที่เพิ่งเครดิตเข้าช่อง รอพ้นหน้าต่างข้อพิพาท ก่อนเสมอ ต้องรอถึง available_at ก่อน
3 ช่องคืออะไร
| ช่อง | field | ความหมาย | สร้างรายการถอนได้ไหม |
|---|---|---|---|
| พร้อมใช้ | available_minor | ถอนได้ทันที · ผ่านหน้าต่างข้อพิพาทมาแล้ว | ✅ |
| รอพ้นหน้าต่างข้อพิพาท | pending_clearing_minor | เครดิตแล้วจริง แต่ผู้ถอนฝั่งตรงข้ามยังเปิดข้อพิพาทได้ (72 ชม.) | ❌ |
| ล็อกไว้ | reserved_minor | กันไว้ให้รายการถอน/refund ที่ยังไม่ปิด (เงินต้น + ค่าธรรมเนียม) | ❌ |
ยอดที่ผู้ใช้ปลายทางเห็นในระบบของคุณเป็นคนละเรื่องกับ 3 ช่องนี้ — ผู้ใช้ถูกเครดิตด้วย amount_received_minor ส่วนกระเป๋า merchant ขยับด้วย credited_amount_minor (R1)
ชื่อ reserved_minor ถูกใช้ 2 ที่ ความหมายไม่เหมือนกัน
- ใน
GET /v1/pay/balance→ เงินในกระเป๋าคุณ ที่ถูกล็อกไว้ให้รายการถอนที่ยังไม่ปิด - ใน object ของรายการถอน (
GET /v1/pay/withdrawals/{id}) → ยอดของใบนั้น ที่มีคนกำลังโอนเข้ามาให้ ยังไม่ settle
อย่าเอาสองตัวนี้มาบวกกันหรือใช้แทนกัน
อ่านยอดคงเหลือ
curl -sS "https://api.example-wl.com/v1/pay/balance" \
-H "Authorization: Bearer sk_live_xxx" \
-H "PG-Timestamp: 1755749553" \
-H "PG-Nonce: 01JQA5T8V0X2Z4B6D8F0H2J4L6" \
-H "PG-Version: 2026-08-01" \
-H "PG-Signature: v1=8f2c1ab6e0d74f9b3c5a7e1d0f6b8a4c2e9d7b5a3c1f0e8d6b4a2c0e8f6d4b2a"{
"available_minor": 1284300,
"pending_clearing_minor": 492500,
"reserved_minor": 200000,
"currency": "THB",
"as_of": "2026-08-21T09:41:20.006Z"
}หน่วยเป็น สตางค์เสมอ — 1284300 คือ 12,843.00 บาท
แปลงหน่วยที่ขอบระบบเท่านั้น
เก็บเป็น integer สตางค์ในฐานข้อมูล คำนวณเป็น integer แล้วหารด้วย 100 ตอน render เท่านั้น อย่าใช้ float ระหว่างทาง — 500000 / 100 ปลอดภัย แต่ 5000.00 * 100 ไม่ปลอดภัย
Node (fetch)
import { createHmac, createHash, randomUUID } from 'node:crypto'
const BASE = 'https://api.example-wl.com'
const KEY = process.env.PG_SECRET_KEY // sk_live_*
const PG_VERSION = '2026-08-01'
// canonical string = 9 บรรทัด แต่ละบรรทัดปิดท้ายด้วย \n รวมบรรทัดสุดท้าย
function sign({ method, path, query = '', body = '', idempotencyKey = '' }) {
const ts = Math.floor(Date.now() / 1000).toString()
const nonce = randomUUID().replace(/-/g, '')
const bodyHash = createHash('sha256').update(body, 'utf8').digest('hex')
const canonical =
`v1\n${ts}\n${nonce}\n${method}\n${path}\n${query}\n${bodyHash}\n${idempotencyKey}\n${PG_VERSION}\n`
const sig = createHmac('sha256', KEY).update(canonical, 'utf8').digest('hex')
return {
'Authorization': `Bearer ${KEY}`,
'PG-Timestamp': ts,
'PG-Nonce': nonce,
'PG-Version': PG_VERSION,
'PG-Signature': `v1=${sig}`,
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {})
}
}
export async function getBalance() {
const path = '/v1/pay/balance'
const res = await fetch(BASE + path, { headers: sign({ method: 'GET', path }) })
if (!res.ok) throw new Error(`balance failed: ${res.status} ${await res.text()}`)
return res.json()
}PHP
<?php
const BASE = 'https://api.example-wl.com';
const PG_VERSION = '2026-08-01';
function pg_sign(string $method, string $path, string $query = '', string $body = '', string $idem = ''): array {
$key = getenv('PG_SECRET_KEY');
$ts = (string) time();
$nonce = bin2hex(random_bytes(16));
$canonical = "v1\n{$ts}\n{$nonce}\n{$method}\n{$path}\n{$query}\n"
. hash('sha256', $body) . "\n{$idem}\n" . PG_VERSION . "\n";
$headers = [
'Authorization: Bearer ' . $key,
'PG-Timestamp: ' . $ts,
'PG-Nonce: ' . $nonce,
'PG-Version: ' . PG_VERSION,
'PG-Signature: v1=' . hash_hmac('sha256', $canonical, $key),
];
if ($idem !== '') { $headers[] = 'Idempotency-Key: ' . $idem; }
return $headers;
}
function pg_get_balance(): array {
$ch = curl_init(BASE . '/v1/pay/balance');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => pg_sign('GET', '/v1/pay/balance'),
CURLOPT_TIMEOUT => 15,
]);
$raw = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) { throw new RuntimeException("balance failed: {$code} {$raw}"); }
return json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
}หน้ากระเป๋าเงินในระบบหลังบ้านของคุณเรียก GET /v1/bo/wallet ได้เช่นกัน — ตัวเลขชุดเดียวกัน แต่ ยอดที่ใช้ตัดสินใจเรื่องเงินให้ยึด GET /v1/pay/balance เพราะเป็นชุดเดียวกับที่ใช้กระทบยอด (§ รายงานและการกระทบยอด)
🔴 available_at — เงินที่เพิ่งเข้าถอนได้เมื่อไร
เงินจากรายการฝากเดินทาง 2 จังหวะ เสมอ
T+0 deposit.settled → balance.credited bucket_to = "pending_clearing"
balance_transaction มี available_at
T+72h (ไม่มีเหตุการณ์ทางธุรกิจ) → balance.cleared bucket_from = "pending_clearing"
bucket_to = "available"ชื่อฟิลด์ที่บอกเวลาเดียวกันนี้มี 2 ตัว ขึ้นกับว่าคุณกำลังอ่าน object ไหน
| อ่านจาก | ฟิลด์ | ตัวอย่าง |
|---|---|---|
deposit.settled (object = deposit_order) | clearing_available_at | "2026-08-24T04:12:33.881Z" |
balance.credited (object = balance_transaction) | available_at | "2026-08-24T04:12:33.881Z" |
ตัวอย่าง balance.credited เต็มใบ
{
"id": "evt_01K3M9ZZ5Q8RX3PJT1WVKD6N4A",
"object": "event",
"type": "balance.credited",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-21T04:12:33.902Z",
"sequence": 88215,
"account": { "merchant_id": "mch_01K2G4H9RT5V", "white_label_id": "wl_01K1QZ2MPD7S" },
"data": {
"object": {
"id": "btxn_01K3M9ZZ4P7QW2NHT0RXKD5V8C",
"object": "balance_transaction",
"type": "deposit_settled",
"direction": "credit",
"amount_minor": 500000,
"currency": "THB",
"bucket_from": null,
"bucket_to": "pending_clearing",
"available_at": "2026-08-24T04:12:33.881Z",
"balance_after": {
"available_minor": 1284300,
"pending_clearing_minor": 500000,
"reserved_minor": 200000
},
"source": { "type": "deposit", "id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B" },
"created_at": "2026-08-21T04:12:33.881Z"
}
}
}balance.cleared ไม่ใช่เงินเข้าใหม่
balance.cleared คือการ ย้ายช่อง จาก pending_clearing ไป available ไม่ใช่การเครดิตรอบสอง โค้ดที่เขียน wallet += event.data.object.amount_minor ทุก balance.* จะได้ยอด สองเท่า ทุกใบ
รูปแบบที่ถูก: balance.credited = บวกเข้า pending_clearing · balance.cleared = ลบจาก pending_clearing แล้วบวกเข้า available · balance.debited = ลบตาม bucket_from
{
"id": "evt_01K3TQ8W2N5RZ7YB4KDXCVH0PM",
"object": "event",
"type": "balance.cleared",
"api_version": "2026-08-01",
"livemode": true,
"created_at": "2026-08-24T04:12:41.220Z",
"sequence": 91884,
"account": { "merchant_id": "mch_01K2G4H9RT5V", "white_label_id": "wl_01K1QZ2MPD7S" },
"data": {
"object": {
"id": "btxn_01K3TQ8W1M4NP0DZ2XKQ7RV9B",
"object": "balance_transaction",
"type": "clearing_released",
"direction": "credit",
"amount_minor": 500000,
"currency": "THB",
"bucket_from": "pending_clearing",
"bucket_to": "available",
"cleared_from_balance_transaction_id": "btxn_01K3M9ZZ4P7QW2NHT0RXKD5V8C",
"balance_after": {
"available_minor": 1784300,
"pending_clearing_minor": 0,
"reserved_minor": 200000
},
"source": { "type": "deposit", "id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B" },
"created_at": "2026-08-24T04:12:41.108Z"
}
}
}ทำไมต้องรอ 72 ชั่วโมง — และทำไมมันดีกับคุณ
เงินไม่ได้ผ่านบัญชีกลางของเรา ผู้ใช้ปลายทางโอนเข้าบัญชีของ ผู้ถอนอีกราย โดยตรง ผู้ถอนคนนั้นกด "ยังไม่ได้รับเงิน" ได้ภายใน 72 ชั่วโมง
ถ้าเกิดการกลับรายการภายในหน้าต่างนี้ เราตัดจาก pending_clearing ไม่ใช่จาก available — ดูฟิลด์ bucket_from ของ balance.debited
{
"type": "reversal",
"direction": "debit",
"amount_minor": 492500,
"bucket_from": "pending_clearing",
"bucket_to": null,
"reason_code": "SLIP_INVALIDATED_AFTER_SETTLEMENT"
}⇒ กระเป๋าคุณ ไม่ติดลบ และคุณไม่ต้องไปตามเก็บเงินคืนจากผู้ใช้ที่เบิกของไปแล้ว นั่นคือเหตุผลทั้งหมดของหน้าต่าง 72 ชั่วโมง
ต่างจากตลาดตรงไหน
เกตเวย์ส่วนใหญ่เข้ากระเป๋าแล้วถอนได้เลยเพราะเงินอยู่ในบัญชีกลางของเขา ของเราไม่มีบัญชีกลางในเส้นทางปกติ
สิ่งที่คุณต้องทำเพิ่มก่อนขึ้น production
- บอกฝ่ายการเงินเรื่อง cash-flow ก่อน go-live ไม่ใช่หลัง — เงินขายวันนี้ถอนได้วันที่ 4
- แยก 2 คอลัมน์ในบัญชีภายในของคุณ: ยอดที่แสดงให้ผู้ใช้เห็น (
available + pending_clearing) กับยอดที่ใช้ตัดสินใจถอน (availableเท่านั้น) - หน้าจอกระเป๋าในระบบหลังบ้านต้องแสดง 3 ช่องแยกกัน พร้อมเวลา
available_atของแต่ละก้อนที่ยังรอ
เมื่อ available_at ผ่านไปแล้วแต่ยอดยังไม่ขยับ
เกิดได้จริงเมื่อผู้ถอนฝั่งตรงข้ามเปิดข้อพิพาทก่อนครบ 72 ชั่วโมง — เงินก้อนนั้นถูกพักไว้จนกว่าเรื่องจะตัดสิน
- คุณจะได้
withdrawal.disputedเฉพาะเมื่อคุณเป็นฝั่งผู้ถอน ของใบนั้น ถ้าคุณเป็นฝั่งผู้ฝาก คุณจะไม่ได้ event บอกว่าก้อนนี้ถูกพัก - ⇒ 🔴 อย่าใช้
available_atเป็นตัวขับ logic ให้ใช้เป็นข้อความบอกผู้ใช้เท่านั้น - ตัวเลขที่ตัดสินใจได้จริงมีตัวเดียวคือ
available_minorจากGET /v1/pay/balanceณ วินาทีที่คุณจะสร้างรายการถอน
ตรวจก่อนสร้างรายการถอนเสมอ
ไม่ต้องเดาว่าเงินพร้อมหรือยัง — ยิงสร้างเลยแล้ว handle 402 ให้ถูก จะทั้งเร็วกว่าและถูกต้องกว่าการ poll ยอด
{ "error": {
"code": "insufficient_balance",
"type": "invalid_request_error",
"message": "Available balance is lower than amount plus fee.",
"message_th": "ยอดเงินที่ถอนได้ไม่พอสำหรับยอดถอนบวกค่าธรรมเนียม",
"retryable": false,
"retry_after_seconds": null,
"request_id": "req_01JQA5T8V0X2Z4B6D8F0H2J4L6",
"details": {
"available_minor": 1002000,
"required_minor": 1003000,
"pending_clearing_minor": 500000,
"pending_clearing_available_at": "2026-08-24T04:12:33.881Z"
} } }🔴 ได้ 402 แล้ว ไม่มีรายการถูกสร้าง ไม่มีการล็อกเงิน ⇒ ต้องปลดล็อกยอดที่คุณกันไว้ในระบบตัวเองคืนให้ผู้ใช้ทันที แล้วบอกเวลาด้วย pending_clearing_available_at
ช่อง "ล็อกไว้" ขยับเมื่อไร
reserved_minor ในกระเป๋าขยับตามวงจรของรายการถอนและ refund
| เหตุการณ์ | ผลต่อกระเป๋า | balance_transaction.type |
|---|---|---|
สร้างรายการถอน (POST /v1/pay/withdrawals) | available −(ยอด+ค่าธรรมเนียม) · reserved +ยอด | withdrawal_reserved (debit) |
มีคนโอนให้ 1 ก้อน (withdrawal.filled) | reserved −ก้อนนั้น | withdrawal_settled (debit) |
| ใบถูกปฏิเสธตอนรออนุมัติ | คืนเต็มเข้า available | withdrawal_released (credit) |
| ใบหมดอายุ/ยกเลิกโดยเหลือ remaining | คืนส่วนที่เหลือ + ค่าธรรมเนียมที่ยังไม่ใช้ | withdrawal_released (credit) |
ค่าธรรมเนียมฝั่งถอนเป็นการ บวกเพิ่ม ไม่ใช่หักจากยอด
ถอน 10,000.00 ค่าธรรมเนียม 30.00 ⇒ ต้องมี available_minor ≥ 1003000 ผู้รับได้ 10,000.00 เต็ม · กระเป๋าคุณลด 10,030.00 ตรวจด้วย GET /v1/pay/quote?channel=withdrawal&amount_minor=1000000 ก่อนแสดงยอดให้ผู้ใช้
ตรวจว่ายอดในระบบคุณตรงกับของเรา
ทุกการขยับของกระเป๋ามี balance_transaction 1 ใบเสมอ ⇒ กระทบยอดได้แบบบรรทัดต่อบรรทัด
Σ balance_transactions ต่อช่อง == ตัวเลขช่องนั้นใน GET /v1/pay/balancebalance_transaction เป็น immutable ⇒ ไม่มี version ไม่มี previous_attributes ⇒ dedupe ด้วย id (btxn_...) แล้ว สะสม ห้ามเอา balance_after ไปเขียนทับยอดในระบบคุณ (event สลับลำดับได้ balance_after ของ event เก่าจะทับของใหม่)
สคริปต์กระทบยอดเต็ม ๆ อยู่ที่ รายงานและการกระทบยอด
เช็กลิสต์หน้ากระเป๋าเงิน
- [ ] แสดง 3 ช่องแยกกัน ไม่ยุบเป็นยอดเดียว
- [ ] ปุ่ม "ถอน" คิดจาก
available_minorเท่านั้น - [ ] handle
balance.clearedเป็นการย้ายช่อง ไม่ใช่เงินเข้าใหม่ - [ ] handle
balance.debited(event นี้ ปิดไม่ได้ ต้องมี handler ตั้งแต่วันแรก) - [ ] dedupe
balance_transactionด้วยbtxn_idและสะสม ไม่ใช้balance_afterเขียนทับ - [ ] handle
402 insufficient_balanceโดยปลดล็อกยอดผู้ใช้คืนทันที - [ ] แสดง
available_atเป็นข้อความ ไม่เอาไปขับ logic
ขั้นต่อไป
- รายงานและการกระทบยอด — สคริปต์ที่พิสูจน์ว่า
Σ balance_transactionsตรงกับกระเป๋า - อนุมัติรายการถอน — เงินถูกล็อกตั้งแต่ตอนสร้าง ไม่ใช่ตอนอนุมัติ
- สร้างรายการถอน —
402และการปลดล็อกยอดคืน - รายการ event ทั้งหมด —
balance.credited/balance.cleared/balance.debited - เรื่องร้องเรียน (claims) — เมื่อเงินถูกพักเพราะข้อพิพาท
