รายงานและการกระทบยอด
หน้านี้มี 2 เรื่องที่คนละวัตถุประสงค์กัน อย่าสลับกัน
| ต้องการอะไร | ใช้ชุดไหน |
|---|---|
| ดึงรายงานไปทำหน้าจอ / ส่งบัญชี / ตอบ support | GET /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 เดียวกัน:
GET /v1/bo/reports/financial?scope_id={scope}&period=last_7_days&view=subtree&drilldown=direct_children&livemode=true&limit=50endpoint นี้เป็น 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_fillprojection ที่ตรวจ digest และผูกกับ posted journal จึงไม่หายเมื่อสถานะ order ปัจจุบันถูก reverse; สถานะการส่ง webhook ไม่เกี่ยวกับการนับยอด - รายได้ที่ลงบัญชีให้ scope นี้, ค่าที่หักจาก scope นี้ และ reversal อ่านจาก posted ledger เท่านั้น
net_earned_minor = earned_minor - deducted_minor - reversals_minorconfigured_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 | ค่า | เปลี่ยนกลางคันได้ไหม |
|---|---|---|
cursor | opaque string ที่ได้จาก next_cursor ของหน้าก่อน | — |
limit | 1..200 | ✅ |
from / to | RFC3339 UTC ลงท้าย Z | ❌ |
type / status | ตามชนิดรายงาน | ❌ |
order | ลำดับการเรียง | ❌ |
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 ตามปกติ
{
"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
{ "data": [], "has_more": true, "next_cursor": "cur_01K3PA2M8Q4RW7YT0NBVXH5DKC" }กฎ 4 ข้อ
cursorเป็น opaque — ห้าม parse ห้ามสร้างเอง ห้ามเก็บข้ามวัน- เปลี่ยน
limitกลางคันได้ (ไม่ได้อยู่ใน filter hash) — ใช้ตอนโดน429แล้วอยากลดขนาดหน้า - เปลี่ยน
from/to/type/status/orderกลางคัน ไม่ได้ →400 cursor_filter_mismatch⇒ ต้องเริ่มลูปใหม่ตั้งแต่หน้าแรก - เลิกวนเมื่อ
has_more === falseไม่ใช่เมื่อdata.length < limit(หน้าสุดท้ายเต็มพอดีก็เกิดได้)
เราไม่มี page และ offset เลย
เอกสารเจ้าอื่นหลายเจ้าให้ page=1,2,3 ⇒ มีรายการใหม่แทรกระหว่างที่คุณวนอยู่ = ทุกหน้าเลื่อน = แถวเดิมโผล่ซ้ำหรือหายไปเงียบ ๆ ของเราไม่มีให้ ไม่ใช่เพราะยังไม่ได้ทำ แต่เพราะมันกระทบยอดไม่ได้ — และการกระทบยอดเป็นเงื่อนไข go-live
ทำไม cursor ถึงเสถียรเมื่อมีข้อมูลใหม่
cursor คือ "ตำแหน่งที่หยุดไว้" ไม่ใช่ "หน้าที่เท่าไร" ⇒ รายการที่ถูก สร้างใหม่ ระหว่างที่คุณวนอยู่ ไม่ทำให้แถวที่คุณยังไม่อ่านเลื่อนตำแหน่ง
เหลือกรณีเดียวที่ต้องเผื่อไว้: รายการที่ถูก แก้ไข ระหว่างที่คุณวนอยู่ เช่นรายการฝากที่เดินจาก under_review ไป settled ตอนตีสาม ระหว่างที่ลูปของคุณอยู่หน้าที่ 7
รูปแบบที่รอดทุกกรณี — upsert ด้วย id ไม่ใช่ append
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
ตัวอย่างการวนที่ถูกต้อง
// วนทุกหน้าของ 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)
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_settled | pending_clearing + |
clearing_released | pending_clearing − · available + |
withdrawal_reserved | available − · reserved + |
withdrawal_settled | reserved − |
withdrawal_released | reserved − · 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 ได้
// 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 ทิ้ง
ตรวจฝั่งฐานข้อมูลของคุณเอง
รันคู่กับสคริปต์ข้างบน เพื่อจับกรณีที่ เราถูกแต่คุณเขียนผิด
-- 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_transactiondedupe ด้วยbtxn_idแล้ว สะสม ห้ามใช้balance_afterเขียนทับ - [ ] มี
default:ในทุกswitchของbalance_transaction.typeและfee_breakdown[].component - [ ] ตรงกันติดกัน 3 วัน ก่อนเพิ่มสัดส่วน traffic
ขั้นต่อไป
- กระเป๋าเงินและยอดคงเหลือ — 3 ช่องและความหมายของ
available_at - บันทึกและ audit log — ตามหา event ที่หายจากช่องว่างของ
sequence - ค้นหารายการ — เจาะรายใบเมื่อกระทบยอดไม่ตรง
- แดชบอร์ด — ตัวเลขรวมที่คำนวณจากชุดเดียวกันนี้
- เช็กลิสต์ก่อน go-live
