Skip to content

รายงานและการกระทบยอด ​

หน้านี้มี 2 เรื่องที่คนละวัตถุประสงค์กัน อย่าสลับกัน

ต้องการอะไรใช้ชุดไหน
ดึงรายงานไปทำหน้าจอ / ส่งบัญชี / ตอบ supportGET /v1/bo/reports/*
กระทบยอด ว่าตัวเลขในระบบคุณตรงกับของเราหรือไม่GET /v1/pay/* เท่านั้น

กระทบยอดต้องใช้ /v1/pay/* ไม่ใช่ /v1/bo/*

endpoint สำหรับกระทบยอดมีชุดเดียวคือ 4 ตัวใน ชุดกระทบยอด ด้านล่าง /v1/bo/* เป็นของระบบหลังบ้าน ไม่ใช่ของงาน integration และ ห้ามใช้เป็นแหล่งความจริงของเงิน

รายงานที่มี ​

สรุปการเงินตาม scope สำหรับหน้า Back Office ​

หน้า Reports ของ Owner, White Label, Agent และ Merchant ใช้ endpoint เดียวกัน:

http
GET /v1/bo/reports/financial?scope_id={scope}&period=last_7_days&view=subtree&drilldown=direct_children&livemode=true&limit=50

endpoint นี้เป็น contract แบบปิด scope_financial_report_v1 และต้องมีทั้ง API contract reports กับ capability reports:read_summary จาก bootstrap; reports:read แบบกว้างไม่ให้สิทธิ์แทนกันได้. Brand UI เรียกผ่าน opaque same-origin session cookie พร้อม X-PG-BO-Client: 1; access token ไม่ถูกส่งเข้า browser.

ระดับดูตัวเองดูผลรวมทั้งสายแตกชั้นถัดไป
Owner✅✅White Label
White Label✅✅Agent ของตัวเอง
Agent✅✅Merchant ของตัวเอง
Merchant✅——

scope นอก membership, ข้าม White Label/Agent, environment ไม่ตรง หรือไม่มี capability/contract จะได้ 404 resource_not_found เหมือนกันทั้งหมด. Server ตรวจ hierarchy ซ้ำในฐานข้อมูล ไม่ใช้ role label หรือเมนูที่ browser แสดงเป็นหลักฐานอนุญาต.

period รับ today, last_7_days, last_30_days หรือ custom; ช่วงสำเร็จรูปยึดวันตาม Asia/Bangkok ส่วน custom ต้องส่ง from/to, ปลายช่วงเป็น exclusive และช่วงรวมไม่เกิน 366 วัน. view=self ต้องใช้ drilldown=none; cursor ใช้ได้เฉพาะ drilldown=direct_children และผูกกับ scope/mode/filter เดิมเพื่อกันนำไปใช้ข้ามสาย.

ตัวเลขบนหน้ารายงานมีความหมายตายตัว:

  • ยอดฝากที่รับจริง = A=min(V,X) — โอนไม่ถึงรับตามยอดจริง และไม่รวม overpay ส่วนเกิน
  • ยอดฝากเครดิต = ยอดรับจริงหลังหัก fee snapshot ของรายการนั้น
  • ยอดถอนจ่ายจริง = เงินต้นที่จ่ายสำเร็จ ไม่รวม fee
  • ยอดฝาก/ถอนอ่านจาก immutable deposit.settled / withdrawal.partial_fill projection ที่ตรวจ digest และผูกกับ posted journal จึงไม่หายเมื่อสถานะ order ปัจจุบันถูก reverse; สถานะการส่ง webhook ไม่เกี่ยวกับการนับยอด
  • รายได้ที่ลงบัญชีให้ scope นี้, ค่าที่หักจาก scope นี้ และ reversal อ่านจาก posted ledger เท่านั้น
  • net_earned_minor = earned_minor - deducted_minor - reversals_minor
  • configured_revenue_share_bps คือเรตที่ตั้งไว้กับ downline และแสดงแยกจาก effective_rate_bps ซึ่งคำนวณจากผลที่ลงบัญชีจริง; การเปลี่ยนเรตวันนี้ไม่เขียนประวัติย้อนหลัง

จำนวนเงิน aggregate ทุกค่าเป็น decimal string หน่วยสตางค์เพื่อไม่เสีย precision. series เป็น daily projection จริงจาก replica; UI ห้ามแต่ง trend จาก summary. เมื่อ view=subtree ยอดฝาก/ถอนรวม descendant แต่ earned/deducted/reversal/net เป็นบัญชีของ scope ที่ระบุเท่านั้น. เช่นเดียวกัน แถว direct child แสดง volume รวมใต้ child แต่ economics เป็นของ child เอง ไม่ใช่ “กำไรของ parent”. การแยกนี้ป้องกันรายได้ถูกนับซ้ำ และ response สำหรับ White Label/Agent/Merchant ไม่มีต้นทุน provider หรือ margin ภายในของ Owner.

GET /v1/bo/reports/transactions      รายการเดินบัญชีของกระเป๋า (balance_transaction)
GET /v1/bo/reports/deposits          รายการฝาก
GET /v1/bo/reports/withdrawals       รายการถอน (รวม refund ซึ่งเป็นรายการถอนชนิดหนึ่ง)
GET /v1/bo/reports/credit            ยอดคงเหลือและการเปลี่ยนแปลงต่อช่วงเวลา
GET /v1/bo/reports/tickets           เรื่องที่เข้าคิวตรวจสอบและเรื่องร้องเรียน
GET /v1/bo/reports/action-logs       ใครกดอะไรในระบบหลังบ้าน

ทุกตัวใช้ query แบบเดียวกัน

queryค่าเปลี่ยนกลางคันได้ไหม
cursoropaque string ที่ได้จาก next_cursor ของหน้าก่อน—
limit1..200✅
from / toRFC3339 UTC ลงท้าย Z❌
type / statusตามชนิดรายงาน❌
orderลำดับการเรียง❌
bash
curl -sS "https://api.example-wl.com/v1/bo/reports/deposits?from=2026-08-20T00:00:00.000Z&limit=200&status=settled&to=2026-08-21T00:00:00.000Z" \
  -H "Authorization: Bearer $BO_OIDC_ACCESS_TOKEN"

รายงาน /v1/bo/* ใช้ OIDC access token ของเจ้าหน้าที่ ไม่ใช้ sk_live_* หรือ PG HMAC ส่วนชุดกระทบยอด /v1/pay/* ด้านล่างยังใช้ merchant API key + HMAC ตามปกติ

json
{
  "data": [
    {
      "id": "dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B",
      "object": "deposit_order",
      "version": 9,
      "status": "settled",
      "is_terminal": false,
      "merchant_reference": "ORD-9981",
      "amount_minor": 500000,
      "ordered_amount_minor": 500000,
      "transferred_amount_minor": 500000,
      "amount_received_minor": 500000,
      "fee_total_minor": 7500,
      "credited_amount_minor": 492500,
      "currency": "THB",
      "partial": false,
      "settled_via": "auto",
      "liquidity_source": "internal",
      "balance_transaction_id": "btxn_01K3M9ZZ4P7QW2NHT0RXKD5V8C",
      "clearing_available_at": "2026-08-24T04:12:33.881Z",
      "created_at": "2026-08-21T04:10:47.902Z",
      "settled_at": "2026-08-21T04:12:33.881Z",
      "sender": {
        "bank_code": "002",
        "account_number_masked": "xxx-x-x5567",
        "account_name_th_masked": "ป*** ส***"
      },
      "metadata": { "user_id": "u_88213", "order_id": "ORD-9981" }
    }
  ],
  "has_more": true,
  "next_cursor": "cur_01K3PA2M8Q4RW7YT0NBVXH5DKC"
}

โควตาของรายงานต่ำกว่าที่คุณคิด

bo_reports = 60 request / 60 วินาที (เทียบกับ bo_read 300 และ pay_read 1,200) ดึงรายงานทั้งวันด้วย limit=200 แบบไม่หน่วง จะชน 429 กลางทาง อ่าน RateLimit-Remaining ที่ติดมากับ ทุก response แล้วหน่วงเอง อย่ารอให้ 429 มาสอน

Cursor pagination ที่ไม่เพี้ยนเมื่อมีข้อมูลใหม่แทรก ​

รูปแบบ response เหมือนกันทุก endpoint ที่ใช้ cursor

json
{ "data": [], "has_more": true, "next_cursor": "cur_01K3PA2M8Q4RW7YT0NBVXH5DKC" }

กฎ 4 ข้อ

  1. cursor เป็น opaque — ห้าม parse ห้ามสร้างเอง ห้ามเก็บข้ามวัน
  2. เปลี่ยน limit กลางคันได้ (ไม่ได้อยู่ใน filter hash) — ใช้ตอนโดน 429 แล้วอยากลดขนาดหน้า
  3. เปลี่ยน from / to / type / status / order กลางคัน ไม่ได้ → 400 cursor_filter_mismatch ⇒ ต้องเริ่มลูปใหม่ตั้งแต่หน้าแรก
  4. เลิกวนเมื่อ has_more === false ไม่ใช่เมื่อ data.length < limit (หน้าสุดท้ายเต็มพอดีก็เกิดได้)

เราไม่มี page และ offset เลย

เอกสารเจ้าอื่นหลายเจ้าให้ page=1,2,3 ⇒ มีรายการใหม่แทรกระหว่างที่คุณวนอยู่ = ทุกหน้าเลื่อน = แถวเดิมโผล่ซ้ำหรือหายไปเงียบ ๆ ของเราไม่มีให้ ไม่ใช่เพราะยังไม่ได้ทำ แต่เพราะมันกระทบยอดไม่ได้ — และการกระทบยอดเป็นเงื่อนไข go-live

ทำไม cursor ถึงเสถียรเมื่อมีข้อมูลใหม่ ​

cursor คือ "ตำแหน่งที่หยุดไว้" ไม่ใช่ "หน้าที่เท่าไร" ⇒ รายการที่ถูก สร้างใหม่ ระหว่างที่คุณวนอยู่ ไม่ทำให้แถวที่คุณยังไม่อ่านเลื่อนตำแหน่ง

เหลือกรณีเดียวที่ต้องเผื่อไว้: รายการที่ถูก แก้ไข ระหว่างที่คุณวนอยู่ เช่นรายการฝากที่เดินจาก under_review ไป settled ตอนตีสาม ระหว่างที่ลูปของคุณอยู่หน้าที่ 7

รูปแบบที่รอดทุกกรณี — upsert ด้วย id ไม่ใช่ append

sql
INSERT INTO merchant_deposits (deposit_id, version, status)
VALUES ($1, $2, $3)
ON CONFLICT (deposit_id) DO UPDATE
   SET version = EXCLUDED.version, status = EXCLUDED.status
 WHERE merchant_deposits.version < EXCLUDED.version;

ทำแบบนี้แล้วแถวเดิมโผล่ซ้ำก็ไม่เจ็บ และเป็น version guard ตัวเดียวกับที่คุณใช้กับ webhook อยู่แล้ว balance_transaction ไม่มี version (immutable) ⇒ ใช้ ON CONFLICT (btxn_id) DO NOTHING

ตัวอย่างการวนที่ถูกต้อง ​

js
// วนทุกหน้าของ endpoint ที่ใช้ cursor · หยุดที่ has_more === false เท่านั้น
const BO_TOKEN = process.env.BO_OIDC_ACCESS_TOKEN

async function* pages(path, params) {
  let cursor = null
  for (;;) {
    const query = buildQuery({ ...params, limit: 200, ...(cursor ? { cursor } : {}) })

    const res = await fetch(`${BASE}${path}?${query}`, {
      headers: { Authorization: `Bearer ${BO_TOKEN}` }
    })

    if (res.status === 429) {
      const wait = Number(res.headers.get('Retry-After') ?? 5)
      await sleep(wait * 1000 * (0.8 + Math.random() * 0.4))   // jitter ±20%
      continue                                                  // 🔴 cursor เดิม ไม่ใช่เริ่มใหม่
    }
    if (!res.ok) throw new Error(`${path} → ${res.status} ${await res.text()}`)

    const body = await res.json()
    yield body.data
    if (!body.has_more) return                                  // 🔴 ไม่ใช่ data.length < limit
    cursor = body.next_cursor
  }
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms))

🔴 สตริง query ที่ใส่ใน canonical string ต้องเป็นสตริงเดียวกับที่ต่อท้าย URL จริง (percent-encode ก่อน แล้วเรียงตาม byte ของ key ที่ encode แล้ว) ไม่งั้นได้ 401 signature_invalid — ดู การยืนยันตัวตนของ request

ชุดกระทบยอด (4 endpoint) ​

http
GET /v1/pay/events?since_sequence=88214&limit=100
GET /v1/pay/deposits?status=settled&updated_after=2026-08-20T00:00:00.000Z&cursor=
GET /v1/pay/withdrawals?updated_after=2026-08-20T00:00:00.000Z&cursor=
GET /v1/pay/balance/transactions?since_sequence=41902

/v1/pay/events ไม่ใช้ cursor

เป็นตัวเดียวในระบบที่ใช้ since_sequence — เก็บ sequence สูงสุดที่ประมวลผลสำเร็จไว้ในตารางของคุณ แล้วยิงต่อจากค่านั้น

  • sequence เพิ่มขึ้นตาม เวลาที่สร้าง event ไม่ใช่เวลาที่ส่งถึงคุณ
  • ช่องว่างของ sequence เป็นเรื่องปกติชั่วคราว — event ที่ endpoint คุณตอบไม่ได้ยังอยู่ในรอบ retry (ยาวสุด ~92 ชั่วโมง)
  • ช่องว่างที่ค้างเกิน 4 วัน = หายจริง ⇒ ตรงนี้ต้อง alert แล้วไล่หาด้วย หน้า logs

/v1/pay/balance/transactions ก็ใช้ since_sequence เช่นกัน (คนละชุดตัวเลขกับของ /events)

webhook ไม่ใช่แหล่งความจริง

ledger คือความจริง webhook คือการแจ้งเตือน ⇒ reconcile job รายวันเป็นเงื่อนไข go-live ไม่ใช่ของแถม ย้อนหลังได้ 30 วัน ทั้ง /v1/pay/events และ /v1/pay/balance/transactions

กฎการกระทบยอด 2 ข้อ ​

ข้อ 1 — ทุกรายการฝากที่ settled ต้องบวกลบลงตัว ​

amount_received_minor − fee_total_minor = credited_amount_minor

รับประกันทุกใบ ไม่มีข้อยกเว้น · เจอใบที่ไม่ตรงคือบั๊กฝั่งเรา ให้ส่ง deposit_id มาให้ทีมงานทันที

ตัวเลขใช้ทำอะไร
ordered_amount_minor (X)ยอดที่สร้างรายการ
transferred_amount_minorยอดที่ธนาคารยืนยันว่าโอนจริง; ใช้กระทบยอด evidence/overpayment
amount_received_minorเครดิตให้ ผู้ใช้ปลายทาง — ยอดที่ระบบรับเข้า order
overpayment.amount_minor (E)ส่วนเกินใน claim; เป็น 0/ไม่มีเมื่อไม่ overpay
fee_total_minorค่าธรรมเนียมที่คุณจ่าย · มี fee_breakdown[] แยกตาม component มาด้วย
credited_amount_minorกระเป๋าคุณ ขยับด้วยตัวนี้ตัวเดียว

สลับสองตัวนี้แล้วไม่มีใครรู้จนสิ้นเดือน

เครดิตผู้ใช้ด้วย credited_amount_minor ⇒ ผู้ใช้ได้เงินขาดเท่าค่าธรรมเนียม ทุกออร์เดอร์ กระทบยอดกระเป๋าด้วย amount_received_minor ⇒ ยอดในระบบคุณสูงกว่าจริงเท่าค่าธรรมเนียมรวมทั้งเดือน ทั้งสองแบบไม่ทำให้ระบบพัง แค่ตัวเลขผิดเงียบ ๆ — จึงต้อง assert ทุกใบด้วยเครื่อง ไม่ใช่ด้วยสายตา

เฉพาะ overpayment.cause='same_transfer_excess' ต้องเพิ่ม rule transferred_amount_minor = amount_received_minor + overpayment.amount_minor สำหรับ settlement evidence · ถ้าเป็น additional_transfer, transferred_amount_minor คงค่า evidence เดิมและยอดเพิ่มอยู่ใน Claim แยกใบ; ส่วนเกินไม่รวม fee/merchant wallet

ยอด overpayment รายวันต้อง aggregate จาก Claim แบบ append-only โดย dedupe ด้วย claim_id และเลือกวันจาก Claim.created_at — ห้าม group ให้เหลือหนึ่งแถวต่อ deposit_id และห้าม sum แค่ Deposit.overpayment ซึ่งเป็น latest-flag projection เพราะ deposit เดียวอาจมี additional_transfer หลายครั้งและมีหลาย claim ได้

เก็บ fee_breakdown[] แบบดิบไว้ด้วย ตอนเถียงเรื่องค่าธรรมเนียมจะได้มีของ · อ่านด้วย component เท่านั้น และต้องมี default: เพราะเป็น open enum

ข้อ 2 — ผลรวมของ balance_transactions ต้องตรงกับกระเป๋า ​

ผลรวมของ balance_transactions ของช่องนั้น  ==  ตัวเลขช่องนั้นใน GET /v1/pay/balance

ทุกใบมี direction ∈ credit | debit และ bucket_from / bucket_to ⇒ คำนวณยอดแต่ละช่องได้จากรายการเดินบัญชีล้วน ๆ

typeผลต่อช่อง
deposit_settledpending_clearing +
clearing_releasedpending_clearing − · available +
withdrawal_reservedavailable − · reserved +
withdrawal_settledreserved −
withdrawal_releasedreserved − · available +
feeตาม bucket_from / bucket_to ของใบนั้น
reversalตาม bucket_from (ภายใน 72 ชม. = pending_clearing)
adjustmentตาม bucket_from / bucket_to

balance_transaction.type เป็น open enum

เราเพิ่มค่าใหม่ได้โดยไม่นับเป็น breaking change ⇒ switch ของคุณ ต้องมี default: ที่บันทึกไว้แล้ว alert ไม่ใช่ throw และไม่ใช่เงียบ วิธีที่ทนที่สุด: อย่าคิดผลจาก type ให้คิดจาก direction + bucket_from / bucket_to ซึ่งมีครบทุกใบ แล้วใช้ type แค่ตอนแสดงผล

สคริปต์กระทบยอดรายวัน ​

รันวันละครั้งหลังเที่ยงคืน UTC · ให้มัน ออก exit code ไม่ใช่แค่ log จะได้ผูกกับ monitoring ได้

js
// reconcile.mjs — กระทบยอดรายวัน · รันด้วย: node reconcile.mjs 2026-08-20
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'
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))

// 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 canonical = `v1\n${ts}\n${nonce}\n${method}\n${path}\n${query}\n` +
    `${createHash('sha256').update(body, 'utf8').digest('hex')}\n${idempotencyKey}\n${PG_VERSION}\n`
  return {
    'Authorization': `Bearer ${KEY}`,
    'PG-Timestamp': ts,
    'PG-Nonce': nonce,
    'PG-Version': PG_VERSION,
    'PG-Signature': `v1=${createHmac('sha256', KEY).update(canonical, 'utf8').digest('hex')}`
  }
}

function buildQuery(params) {
  return Object.entries(params)
    .map(([k, v]) => [encodeURIComponent(k), encodeURIComponent(String(v))])
    .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
    .map(([k, v]) => `${k}=${v}`)
    .join('&')
}

async function call(path, params = {}) {
  for (let attempt = 0; ; attempt++) {
    const query = buildQuery(params)
    const url = query ? `${BASE}${path}?${query}` : `${BASE}${path}`
    const res = await fetch(url, { headers: sign({ method: 'GET', path, query }) })
    if (res.status === 429 || res.status === 503) {
      if (attempt >= 6) throw new Error(`${path} ยอมแพ้หลังลอง ${attempt} ครั้ง`)
      const wait = Number(res.headers.get('Retry-After') ?? 5)
      await sleep(wait * 1000 * (0.8 + Math.random() * 0.4))
      continue
    }
    if (!res.ok) throw new Error(`${path} → ${res.status} ${await res.text()}`)
    return res.json()
  }
}

// 1) เดินรายการเดินบัญชีทั้งหมดตั้งแต่จุดที่หยุดไว้ครั้งก่อน
async function walkBalanceTransactions(sinceSequence) {
  const rows = []
  let since = sinceSequence
  for (;;) {
    const body = await call('/v1/pay/balance/transactions', { limit: 200, since_sequence: since })
    if (body.data.length === 0) break
    rows.push(...body.data)
    since = Math.max(...body.data.map((r) => r.sequence))
    if (!body.has_more) break
  }
  return rows
}

// 2) คำนวณยอดแต่ละช่องจากรายการเดินบัญชีล้วน ๆ
function foldBuckets(rows, opening = { available: 0, pending_clearing: 0, reserved: 0 }) {
  const b = { ...opening }
  const seen = new Set()
  for (const r of rows) {
    if (seen.has(r.id)) continue                 // 🔴 immutable ⇒ dedupe ด้วย id เท่านั้น
    seen.add(r.id)
    if (r.bucket_from) b[r.bucket_from] -= r.amount_minor
    if (r.bucket_to) b[r.bucket_to] += r.amount_minor
  }
  return b
}

// 3) ตรวจรายการฝากที่ settle ในวันนั้นทีละใบ
async function checkSettledDeposits(fromIso, toIso) {
  const bad = []
  let cursor = null
  for (;;) {
    const params = { limit: 200, status: 'settled', updated_after: fromIso }
    if (cursor) params.cursor = cursor
    const body = await call('/v1/pay/deposits', params)
    for (const d of body.data) {
      if (d.settled_at >= toIso) continue
      const amounts = [d.ordered_amount_minor, d.transferred_amount_minor,
        d.amount_received_minor, d.fee_total_minor, d.credited_amount_minor]
      if (!amounts.every(Number.isSafeInteger) || d.ordered_amount_minor <= 0 ||
          d.transferred_amount_minor <= 0 || d.amount_received_minor <= 0) {
        bad.push({ deposit_id: d.id, kind: 'X/V/A/fee/credited ต้องเป็น integer บวกตามสัญญา' })
        continue
      }
      if (d.amount_received_minor - d.fee_total_minor !== d.credited_amount_minor) {
        bad.push({ deposit_id: d.id, kind: 'ยอดรับเข้า ลบค่าธรรมเนียม ไม่เท่ากับยอดเข้ากระเป๋า' })
      }
      if (d.amount_received_minor !== Math.min(d.transferred_amount_minor, d.ordered_amount_minor)) {
        bad.push({ deposit_id: d.id, kind: 'ยอดรับเข้าต้องเท่ากับ min(ยอดโอนจริง, ยอดที่สร้าง)' })
      }
      if (d.fee_total_minor < 0 || d.fee_total_minor > d.amount_received_minor) {
        bad.push({ deposit_id: d.id, kind: 'ค่าธรรมเนียมต้องอยู่ระหว่าง 0 และยอดรับเข้า' })
      }
      if (d.overpayment?.cause === 'same_transfer_excess' &&
          d.transferred_amount_minor !== d.amount_received_minor + d.overpayment.amount_minor) {
        bad.push({ deposit_id: d.id, kind: 'same-slip overpay ไม่เป็น V = A + E' })
      }
    }
    if (!body.has_more) break
    cursor = body.next_cursor
  }
  return bad
}

// 4) ตรวจช่องว่างของ sequence ในสาย event
async function checkEventGaps(sinceSequence) {
  const seen = []
  let since = sinceSequence
  for (;;) {
    const body = await call('/v1/pay/events', { limit: 100, since_sequence: since })
    if (body.data.length === 0) break
    seen.push(...body.data.map((e) => e.sequence))
    since = Math.max(...body.data.map((e) => e.sequence))
    if (body.data.length < 100) break
  }
  seen.sort((a, b) => a - b)
  const gaps = []
  for (let i = 1; i < seen.length; i++) {
    for (let s = seen[i - 1] + 1; s < seen[i]; s++) gaps.push(s)
  }
  return gaps        // ช่องว่างสด ๆ = ปกติ · ค้างเกิน 4 วัน = หายจริง
}

// ── main ────────────────────────────────────────────────────────────────────
const day = process.argv[2]                                    // 2026-08-20
const from = `${day}T00:00:00.000Z`
const to = new Date(Date.parse(from) + 86_400_000).toISOString().replace(/\.\d{3}Z$/, '.000Z')

// โหลดจากตาราง state ของคุณเอง
const state = { lastBtxnSequence: 0, lastEventSequence: 0, opening: undefined }

const live = await call('/v1/pay/balance')                     // 🔴 อ่านยอดก่อน แล้วค่อยเดินรายการ
const rows = await walkBalanceTransactions(state.lastBtxnSequence)
const upTo = rows.filter((r) => r.created_at <= live.as_of)    // ตัดรายการที่เกิดหลัง as_of ทิ้ง
const computed = foldBuckets(upTo, state.opening)

const mismatch = [
  ['available', computed.available, live.available_minor],
  ['pending_clearing', computed.pending_clearing, live.pending_clearing_minor],
  ['reserved', computed.reserved, live.reserved_minor]
].filter(([, a, b]) => a !== b)

const badDeposits = await checkSettledDeposits(from, to)
const gaps = await checkEventGaps(state.lastEventSequence)

console.log(JSON.stringify({
  day,
  as_of: live.as_of,
  balance_mismatch: mismatch.map(([bucket, computed_minor, reported_minor]) => ({
    bucket, computed_minor, reported_minor, delta_minor: computed_minor - reported_minor
  })),
  deposits_failing_identity: badDeposits,
  event_sequence_gaps: gaps.slice(0, 50)
}, null, 2))

process.exit(mismatch.length === 0 && badDeposits.length === 0 ? 0 : 1)

จับเวลาให้ถูกก่อนจะตกใจ

GET /v1/pay/balance คืนยอด ณ วินาทีที่คุณถาม (as_of) ส่วนรายการเดินบัญชีที่เดินจบคือ ณ วินาทีอื่น ถ้ามีรายการใหม่เข้าพอดีระหว่างนั้น ตัวเลขจะต่างกันหนึ่งรายการ — ไม่ใช่บั๊ก สคริปต์ข้างบนแก้ด้วยการอ่านยอดก่อน แล้วตัดรายการที่ created_at เกิน as_of ทิ้ง

ตรวจฝั่งฐานข้อมูลของคุณเอง ​

รันคู่กับสคริปต์ข้างบน เพื่อจับกรณีที่ เราถูกแต่คุณเขียนผิด

sql
-- 1) สมการยอดรับเข้า/ค่าธรรมเนียม/กระเป๋าต้องลงตัวทุกใบที่ settled
SELECT deposit_id, amount_received_minor, fee_total_minor, credited_amount_minor
FROM   merchant_deposits
WHERE  status = 'settled'
  AND  amount_received_minor - fee_total_minor <> credited_amount_minor;

-- 2) เครดิตผู้ใช้ต้องเกิดครั้งเดียวต่อ 1 รายการฝาก และเท่ากับยอดที่ระบบรับเข้า order
SELECT d.deposit_id
FROM   merchant_deposits d
JOIN   user_ledger u ON u.deposit_id = d.deposit_id
WHERE  d.status = 'settled'
GROUP  BY d.deposit_id, d.amount_received_minor
HAVING COUNT(*) <> 1
    OR SUM(u.amount_minor) <> d.amount_received_minor;

-- 3) event ที่รับมาต้องไม่ซ้ำ (ซ้ำ = UNIQUE(event_id) ไม่ทำงาน)
SELECT event_id, COUNT(*)
FROM   webhook_events_received
GROUP  BY event_id
HAVING COUNT(*) > 1;

รายงานที่ควรมีในระบบหลังบ้านของคุณ ​

รายงานตัวเลขที่ต้องมีที่มา
ยอดฝากรายวันจำนวนใบ · ผลรวม transferred_amount_minor (settlement evidence) · amount_received_minor · fee_total_minor · credited_amount_minor จาก deposit; ผลรวมยอดเกินจาก Claim append-only ที่ dedupe claim_id และอยู่ในช่วง created_at ของวันนั้น/v1/pay/deposits?status=settled + claim queue
อัตราเข้าคิวตรวจสอบใบที่เคยผ่าน under_review หารด้วยใบทั้งหมดสาย event ที่คุณเก็บไว้เอง
ยอดถอนรายวันผลรวมของ amount_total_minor · filled_minor · refunded_minor · fee_recognized_minor/v1/pay/withdrawals
ใบถอนที่ไม่เต็มใบที่ expired/canceled โดย filled_minor > 0/v1/pay/withdrawals
เดินบัญชีกระเป๋าทุก balance_transaction เรียงตาม sequence/v1/pay/balance/transactions
การกลับรายการทุกใบที่ type เป็น reversal พร้อม reason_code/v1/pay/balance/transactions

ใบถอนที่ expired ไม่ได้แปลว่าไม่มีการจ่ายเลย

ถอน 10,000.00 ได้จริง 4,000.00 แล้วหมดอายุ = expired ที่ filled_minor เท่ากับ 400000 รายงานที่นับ expired เป็น "ไม่สำเร็จทั้งใบ" จะทำให้ตัวเลขจ่ายออกขาดไป ⇒ ทุกใบต้องแยก amount_total_minor / filled_minor / refunded_minor / fee_recognized_minor เสมอ

เช็กลิสต์ก่อนถือว่ากระทบยอดผ่าน ​

  • [ ] job รันอัตโนมัติทุกวัน มี alert เมื่อ exit code ไม่ใช่ 0
  • [ ] เก็บ sequence สูงสุดที่ประมวลผลสำเร็จของทั้ง /events และ /balance/transactions
  • [ ] upsert ด้วย id + version guard ไม่ใช่ append
  • [ ] balance_transaction dedupe ด้วย btxn_id แล้ว สะสม ห้ามใช้ balance_after เขียนทับ
  • [ ] มี default: ในทุก switch ของ balance_transaction.type และ fee_breakdown[].component
  • [ ] ตรงกันติดกัน 3 วัน ก่อนเพิ่มสัดส่วน traffic

ขั้นต่อไป ​

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