Skip to content

กระเป๋าเงินและยอดคงเหลือ ​

กระเป๋าของ 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

อย่าเอาสองตัวนี้มาบวกกันหรือใช้แทนกัน

อ่านยอดคงเหลือ ​

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

js
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
<?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 เต็มใบ

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

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

json
{
  "type": "reversal",
  "direction": "debit",
  "amount_minor": 492500,
  "bucket_from": "pending_clearing",
  "bucket_to": null,
  "reason_code": "SLIP_INVALIDATED_AFTER_SETTLEMENT"
}

⇒ กระเป๋าคุณ ไม่ติดลบ และคุณไม่ต้องไปตามเก็บเงินคืนจากผู้ใช้ที่เบิกของไปแล้ว นั่นคือเหตุผลทั้งหมดของหน้าต่าง 72 ชั่วโมง

ต่างจากตลาดตรงไหน ​

เกตเวย์ส่วนใหญ่เข้ากระเป๋าแล้วถอนได้เลยเพราะเงินอยู่ในบัญชีกลางของเขา ของเราไม่มีบัญชีกลางในเส้นทางปกติ

สิ่งที่คุณต้องทำเพิ่มก่อนขึ้น production

  1. บอกฝ่ายการเงินเรื่อง cash-flow ก่อน go-live ไม่ใช่หลัง — เงินขายวันนี้ถอนได้วันที่ 4
  2. แยก 2 คอลัมน์ในบัญชีภายในของคุณ: ยอดที่แสดงให้ผู้ใช้เห็น (available + pending_clearing) กับยอดที่ใช้ตัดสินใจถอน (available เท่านั้น)
  3. หน้าจอกระเป๋าในระบบหลังบ้านต้องแสดง 3 ช่องแยกกัน พร้อมเวลา available_at ของแต่ละก้อนที่ยังรอ

เมื่อ available_at ผ่านไปแล้วแต่ยอดยังไม่ขยับ ​

เกิดได้จริงเมื่อผู้ถอนฝั่งตรงข้ามเปิดข้อพิพาทก่อนครบ 72 ชั่วโมง — เงินก้อนนั้นถูกพักไว้จนกว่าเรื่องจะตัดสิน

  • คุณจะได้ withdrawal.disputed เฉพาะเมื่อคุณเป็นฝั่งผู้ถอน ของใบนั้น ถ้าคุณเป็นฝั่งผู้ฝาก คุณจะไม่ได้ event บอกว่าก้อนนี้ถูกพัก
  • ⇒ 🔴 อย่าใช้ available_at เป็นตัวขับ logic ให้ใช้เป็นข้อความบอกผู้ใช้เท่านั้น
  • ตัวเลขที่ตัดสินใจได้จริงมีตัวเดียวคือ available_minor จาก GET /v1/pay/balance ณ วินาทีที่คุณจะสร้างรายการถอน

ตรวจก่อนสร้างรายการถอนเสมอ

ไม่ต้องเดาว่าเงินพร้อมหรือยัง — ยิงสร้างเลยแล้ว handle 402 ให้ถูก จะทั้งเร็วกว่าและถูกต้องกว่าการ poll ยอด

json
{ "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)
ใบถูกปฏิเสธตอนรออนุมัติคืนเต็มเข้า availablewithdrawal_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/balance

balance_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

ขั้นต่อไป ​

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