穩定幣支付對帳:如何把鏈上轉帳匹配到業務訂單
穩定幣支付對帳需要連線業務訂單、平台支付事件與鏈上轉帳。本文講解如何設計穩定外部索引鍵和可追溯狀態,發現遺漏或重複的 Webhook,核對金額、資產、網路與交易雜湊,並用安全指令碼執行每日異常檢查和月度財務對帳。
錢包餘額不等於已付款訂單總額,這是正常現象。錢包餘額是某一時點的存量,已經包含入帳轉帳、資金歸集、退款和無關轉帳的共同結果;訂單報表則是指定期間內的流量。再加上多條鏈、兩種穩定幣、地址複用、遲到付款和 Webhook 端點故障,直接比較這兩個數字就不再是一種有效的會計方法。
可靠的穩定幣支付對帳,不會強求某一個資料庫回答所有問題。它連線三類記錄:業務訂單說明客戶應付什麼,支付層說明訂單經歷了哪些狀態,規範鏈說明轉帳是否真實存在。穩定識別符號負責連線三者,週期性介面核對則負責發現即時 Webhook 路徑之外的缺口。
實際執行時應分成兩種節奏:
- 每天執行一次小型自動對帳,發現訂單缺失、狀態不一致和投遞失敗;
- 月末執行一次受控關帳,關閉異常、證明餘額,並保留完整審計材料。
為什麼錢包餘額與已付款訂單總額不一致
調查差額之前,先確認兩邊比較的是同一種數字。一個地址的期末餘額,並不是某個期間的結算總額。
| 原因 | 對比較結果的影響 |
|---|---|
| 錢包期初餘額 | 本期之前收到的資金仍留在期末餘額中 |
| 資金歸集或供應商付款 | 有效入帳後來又離開收款地址 |
| 退款 | 業務訂單可能仍為已支付,但另一筆出帳轉帳減少了錢包餘額 |
| 多鏈與多資產 | Base 上的 10 USDC 與 TRON 上的 10 USDT 是兩套帳,不能當成一個餘額 |
| 少付或多付 | 轉帳到達商戶地址,卻沒有讓精確金額訂單最終完成 |
| 過期後付款 | 訂單已經進入終態且地址已經釋放後,資金仍可能遲到 |
| 地址複用 | 同一個地址隨時間可以對應多筆訂單,因此地址本身不是訂單鍵 |
| 鏈重組或回執失敗 | 已檢測或已確認的轉帳,可能在最終確定前變成 reverted |
| Webhook 投遞失敗 | StableOps 已推進訂單,但商戶應用沒有收到通知,也沒有更新自己的帳本 |
應該核對資金變動,而不是隻比較餘額:
鏈上期初餘額
+ 符合條件的入帳轉帳
+ 未匹配的入帳轉帳
- 資金歸集
- 退款及其他出帳轉帳
= 鏈上期末餘額然後,把符合條件的入帳轉帳與付款訂單核對,再把付款訂單與業務債權核對。這樣可以避免把資金歸集誤判成銷售缺失,也可以避免把未匹配轉帳誤認成收入。
使用三類記錄,分別回答不同問題
一筆付款不存在適用於所有問題的“唯一事實來源”。哪個記錄具有權威性,取決於目前要回答什麼問題。
| 記錄 | 記錄內容 | 權威範圍 |
|---|---|---|
| 商戶訂單與帳本 | 客戶、發票、應付金額、計價單位、履約、退款、人工入帳、會計期間 | 商業債權及商戶如何進行會計處理 |
| StableOps 訂單與事件 | 可用鏈和資產、分配地址、精確金額、狀態變化、投遞歷史 | 轉帳是否匹配有效訂單並達到要求的最終性 |
| 規範區塊鏈記錄 | 代幣合約或鑄幣地址、收發地址、最小單位金額、交易雜湊、區塊、回執 | 轉帳是否存在於規範鏈上 |
區塊瀏覽器只是第三類記錄的一種檢視工具,並不是會計系統。它不知道 merchantOrderId: invoice_10482 對應哪張客戶發票,也不知道一筆多付款中有多少被人工入帳、多少已經退款。同樣,payment.finalized 事件證明 StableOps 生命週期達到了最終確定,但不能證明你的履約任務已經提交了自己的資料庫事務。
對帳連線關係應該如下:
商戶系統 StableOps 區塊鏈
invoice_10482 <-------------> merchant_order_id
業務訂單號 payment_order_id <------------> 歸一化事件
結算狀態 event_id tx_hash + log_index
退款或人工入帳 delivery_id 鏈 + 代幣 + 金額絕不要只用收款地址連線記錄。訂單最終完成、回滾、過期或取消後,地址可能再次分配。也不要只按金額連線:不同地址或不同鏈上可以同時存在金額相等的有效付款。
在接受第一筆付款前設計外部索引鍵
merchantOrderId 是從 StableOps 返回業務物件的主要橋梁。它是必填欄位,並且在組織與環境範圍內唯一。應使用業務系統生成且長期穩定的識別符號,不要使用能夠修改或複用的展示編號。
商戶側應儲存以下欄位:
| 欄位 | 用途 |
|---|---|
| 內部業務編號 | 冪等履約與會計入帳鍵 |
merchantOrderId | StableOps 與業務的連線鍵,通常由內部編號派生 |
| StableOps 付款訂單號 | 直接查詢訂單,並按訂單篩選投遞記錄 |
| 請求金額與實際應付金額 | 自動金額調整可能使返回給付款人的金額不同於業務金額,因此兩者都必須保留 |
| 結算資產 | 防止意外合併 USDC 與 USDT 總額 |
| 事件號 | Webhook 處理去重 |
| 投遞號 | 診斷單次投遞嘗試,不能作為事件去重鍵 |
| 交易引用 | 從檢測事件儲存的 chain、交易雜湊與日誌序號 |
| 對帳狀態 | open、matched、exception 或 closed,以及原因和複核人 |
metadata 適合儲存查詢與營運上下文,但不能成為關鍵外部索引鍵的唯一副本:
const order = await client.paymentOrders.create(
{
merchantOrderId: 'invoice_10482',
amount: '249.00',
acceptedAssets: [
{ chain: 'base', asset: 'USDC' },
{ chain: 'tron', asset: 'USDT' },
],
expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
metadata: {
customerId: 'cus_781',
invoiceNumber: 'INV-2026-10482',
ledgerAccount: 'sales-software',
},
},
{ idempotencyKey: 'invoice_10482:create-payment-order' },
)merchantOrderId 防止第二筆付款訂單佔用同一個業務引用。Idempotency-Key 則是獨立的請求重放鍵:使用相同鍵重試完全相同的建單請求,會返回原回應;使用新的冪等鍵重複提交同一個 merchantOrderId,則會收到衝突回應。
如果後設資料包含客戶或發票資訊,應根據接收端點的實際需要設定 Webhook 後設資料脫敏。不要為了方便對帳,就把金鑰、私鑰或受監管資料放入後設資料。
在執行副作用之前建立事件帳本
Webhook 處理程序應先使用原始請求體驗證簽名,再把 X-Event-Id 原子寫入事件收件表。完成這一步後,才由任務更新業務訂單。一條有效的收件記錄應包含:
- 帶唯一約束的事件號;
- 用於診斷單次嘗試的投遞號;
- 事件型別與付款訂單號;
merchant_order_id與原始載荷;- 接收時間、處理狀態與處理錯誤;
payment.detected中存在的交易雜湊與日誌序號。
必須保留兩層冪等邊界。事件號唯一約束使重試投遞或重放不會重複處理;業務訂單或履約操作上的第二個唯一約束,則防止兩個不同的有效事件重複發放同一權益。
對帳時,不要把“存在一條成功的 Webhook 投遞”理解為“商戶帳本已經更新”。端點可能在持久化收件記錄後返回 2xx,而非同步任務隨後失敗。日對帳必須比較 StableOps 訂單狀態與商戶帳本中真正提交的處理結果。
穩定幣支付 Webhook:如何防止重複履約給出了完整的處理模式。
用介面核對建立恢復路徑
Webhook 負責低延遲通知,列表介面則提供獨立的恢復與審計路徑:
- 列出付款訂單返回目前介面金鑰所屬組織與環境中的訂單,按建立時間倒序排列,並支援狀態篩選與分頁;
- 列出 Webhook 投遞可以按狀態、端點或付款訂單篩選,並提供嘗試次數、回應狀態、錯誤與死信狀態;
- 重放指定投遞根據舊記錄建立新投遞,不會刪除原始審計記錄;
- 重放死信投遞可以在端點修復後,重新排入限定數量的死信。
為了讓這條審計路徑保留轉帳證據,至少應讓一個端點訂閱 payment.detected 與 payment.finalized,並儲存經過驗籤的商戶事件收件表。投遞列表只能恢復已經訂閱的事件,不能代替商戶側長期儲存。
下面的 Node.js 指令碼對一個已經關閉的會計時間窗執行有邊界的核對。輸入檔案會區分業務金額與返回給付款人的金額,儲存 StableOps 付款訂單號,並可包含商戶已經儲存的交易引用:
{
"windowStart": "2026-07-27T00:00:00.000Z",
"windowEnd": "2026-07-28T00:00:00.000Z",
"orders": [
{
"merchantOrderId": "invoice_10482",
"paymentOrderId": "po_01...",
"requestedAmount": "249.00",
"payableAmount": "249.000001",
"settlementAsset": "USDC",
"paymentApplied": true,
"transfer": {
"chain": "base",
"asset": "USDC",
"chainAmount": "249000001",
"txHash": "0x...",
"logIndex": 7
}
}
]
}指令碼會執行雙向核對,從去重後的 payment.detected 事件生成轉帳證據,並判斷失敗或進入死信的事件後來是否在同一個端點投遞成功。目前列表介面使用偏移量分頁,因此只有連續兩次完整掃描結果一致時,指令碼才接受訂單與投遞組成的聯合資料集。併發變化會讓任務失敗並等待重試,而不是產生錯誤的“無差異”結論。
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
type TransferRef = {
chain: string
asset: string
chainAmount: string
txHash: string
logIndex: number
}
type LocalOrder = {
merchantOrderId: string
paymentOrderId: string
requestedAmount: string
payableAmount: string
settlementAsset: string | null
paymentApplied: boolean
transfer?: TransferRef
}
type ExportFile = {
windowStart: string
windowEnd: string
orders: LocalOrder[]
}
type PlatformOrder = {
id: string
merchant_order_id: string
amount: string
requested_amount: string
settlement_asset?: string
status: string
created_at: string
}
type Delivery = {
id: string
webhook_endpoint_id: string
event_id: string
payment_order_id: string | null
event_type: string
status: string
created_at: string
payload: Record<string, unknown>
}
type Page<T> = { items: T[]; has_more: boolean }
type TransferEvidence = TransferRef & {
eventId: string
paymentOrderId: string
normalizedEventId: string
}
const apiKey = process.env.STABLEOPS_API_KEY
const baseUrl = process.env.STABLEOPS_API_URL ?? 'https://api.stableops.dev'
if (!apiKey) throw new Error('STABLEOPS_API_KEY is required')
function canonical(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonical)
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>
return Object.fromEntries(
Object.keys(record)
.sort()
.map((key) => [key, canonical(record[key])]),
)
}
return value
}
function fingerprint<T extends { id: string }>(items: T[]) {
const sorted = [...items].sort((a, b) => a.id.localeCompare(b.id))
return createHash('sha256')
.update(JSON.stringify(canonical(sorted)))
.digest('hex')
}
async function listOnce<T extends { id: string }>(
path: string,
query: Record<string, string> = {},
) {
const items = new Map<string, T>()
for (let offset = 0; ; offset += 200) {
const search = new URLSearchParams({ ...query, limit: '200', offset: String(offset) })
const response = await fetch(`${baseUrl}${path}?${search}`, {
headers: { authorization: `Bearer ${apiKey}` },
})
if (!response.ok) throw new Error(`${path}: ${response.status} ${await response.text()}`)
const page = (await response.json()) as Page<T>
for (const item of page.items) {
if (items.has(item.id)) throw new Error(`${path}: pagination changed during the scan`)
items.set(item.id, item)
}
if (!page.has_more) return [...items.values()]
}
}
async function scanAuditData() {
const [platform, deliveries] = await Promise.all([
listOnce<PlatformOrder>('/v1/payment-orders'),
listOnce<Delivery>('/v1/webhook-deliveries'),
])
return { platform, deliveries }
}
async function stableAuditSnapshot() {
let previous = await scanAuditData()
for (let attempt = 0; attempt < 3; attempt += 1) {
const current = await scanAuditData()
if (
fingerprint(previous.platform) === fingerprint(current.platform) &&
fingerprint(previous.deliveries) === fingerprint(current.deliveries)
)
return current
previous = current
}
throw new Error('no stable audit snapshot; retry after the cutoff settles')
}
function decimalKey(value: string) {
if (!/^\d+(?:\.\d+)?$/u.test(value)) throw new Error(`invalid decimal amount: ${value}`)
const [whole, fraction = ''] = value.split('.')
return `${BigInt(whole)}.${fraction.replace(/0+$/u, '')}`
}
function inWindow(value: string, start: number, end: number) {
const time = Date.parse(value)
return Number.isFinite(time) && time >= start && time < end
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function detectedEvidence(delivery: Delivery): TransferEvidence | null {
if (delivery.event_type !== 'payment.detected' || !isRecord(delivery.payload.data)) return null
const data = delivery.payload.data
if (
!delivery.payment_order_id ||
typeof data.normalized_event_id !== 'string' ||
typeof data.chain !== 'string' ||
typeof data.asset !== 'string' ||
typeof data.chain_amount !== 'string' ||
typeof data.tx_hash !== 'string' ||
typeof data.log_index !== 'number'
)
return null
return {
eventId: delivery.event_id,
paymentOrderId: delivery.payment_order_id,
normalizedEventId: data.normalized_event_id,
chain: data.chain,
asset: data.asset,
chainAmount: data.chain_amount,
txHash: data.tx_hash,
logIndex: data.log_index,
}
}
function transferKey(value: TransferRef) {
return [value.chain, value.asset, value.chainAmount, value.txHash, value.logIndex].join('|')
}
const file = process.argv[2]
if (!file) throw new Error('usage: npx tsx reconcile.ts business-export.json')
const input = JSON.parse(await readFile(file, 'utf8')) as ExportFile
const start = Date.parse(input.windowStart)
const end = Date.parse(input.windowEnd)
if (!Number.isFinite(start) || !Number.isFinite(end) || start >= end)
throw new Error('invalid reconciliation window')
const { platform, deliveries } = await stableAuditSnapshot()
const findings: string[] = []
const localByMerchantId = new Map<string, LocalOrder>()
const localByPaymentId = new Map<string, LocalOrder>()
for (const order of input.orders) {
if (localByMerchantId.has(order.merchantOrderId))
throw new Error(`duplicate merchantOrderId: ${order.merchantOrderId}`)
if (localByPaymentId.has(order.paymentOrderId))
throw new Error(`duplicate paymentOrderId: ${order.paymentOrderId}`)
localByMerchantId.set(order.merchantOrderId, order)
localByPaymentId.set(order.paymentOrderId, order)
}
const platformById = new Map(platform.map((order) => [order.id, order]))
const paymentIdsChangedInWindow = new Set(
deliveries
.filter((delivery) => delivery.payment_order_id && inWindow(delivery.created_at, start, end))
.map((delivery) => delivery.payment_order_id as string),
)
const platformInWindow = platform.filter(
(order) => inWindow(order.created_at, start, end) || paymentIdsChangedInWindow.has(order.id),
)
const relevantPaymentIds = new Set([
...localByPaymentId.keys(),
...platformInWindow.map((order) => order.id),
])
for (const order of input.orders) {
const remote = platformById.get(order.paymentOrderId)
if (!remote) {
findings.push(`${order.merchantOrderId}: payment order ${order.paymentOrderId} is missing`)
continue
}
if (remote.merchant_order_id !== order.merchantOrderId)
findings.push(`${order.paymentOrderId}: merchantOrderId mapping differs`)
if (decimalKey(order.requestedAmount) !== decimalKey(remote.requested_amount))
findings.push(`${order.merchantOrderId}: requested amount differs`)
if (decimalKey(order.payableAmount) !== decimalKey(remote.amount))
findings.push(`${order.merchantOrderId}: payable amount differs`)
if (order.settlementAsset !== (remote.settlement_asset ?? null))
findings.push(`${order.merchantOrderId}: settlement asset differs`)
if (order.paymentApplied && remote.status !== 'finalized')
findings.push(
`${order.merchantOrderId}: merchant applied payment but StableOps is ${remote.status}`,
)
if (!order.paymentApplied && remote.status === 'finalized')
findings.push(`${order.merchantOrderId}: finalized but merchant is not settled`)
}
for (const remote of platformInWindow) {
if (!localByMerchantId.has(remote.merchant_order_id))
findings.push(`${remote.id}: StableOps order has no business order in this window`)
}
const deliveryGroups = new Map<string, Delivery[]>()
for (const delivery of deliveries) {
if (!delivery.payment_order_id || !relevantPaymentIds.has(delivery.payment_order_id)) continue
const key = `${delivery.webhook_endpoint_id}:${delivery.event_id}`
deliveryGroups.set(key, [...(deliveryGroups.get(key) ?? []), delivery])
}
for (const group of deliveryGroups.values()) {
if (group.some((delivery) => delivery.status === 'succeeded')) continue
const sample = group[0]
if (group.some((delivery) => delivery.status === 'dead_letter'))
findings.push(`${sample.payment_order_id}: ${sample.event_type} remains dead-lettered`)
else if (group.some((delivery) => delivery.status === 'failed'))
findings.push(`${sample.payment_order_id}: ${sample.event_type} delivery is still failing`)
}
const evidenceByEvent = new Map<string, TransferEvidence>()
for (const delivery of deliveries) {
if (!delivery.payment_order_id || !relevantPaymentIds.has(delivery.payment_order_id)) continue
const evidence = detectedEvidence(delivery)
if (!evidence) {
if (delivery.event_type === 'payment.detected')
findings.push(`${delivery.id}: malformed payment.detected evidence`)
continue
}
const existing = evidenceByEvent.get(evidence.eventId)
if (existing && transferKey(existing) !== transferKey(evidence))
findings.push(`${evidence.eventId}: inconsistent replay payload`)
else evidenceByEvent.set(evidence.eventId, evidence)
}
const evidenceByPaymentId = new Map<string, TransferEvidence[]>()
for (const evidence of evidenceByEvent.values()) {
evidenceByPaymentId.set(evidence.paymentOrderId, [
...(evidenceByPaymentId.get(evidence.paymentOrderId) ?? []),
evidence,
])
}
for (const order of input.orders) {
const evidence = evidenceByPaymentId.get(order.paymentOrderId) ?? []
const remote = platformById.get(order.paymentOrderId)
if (evidence.length > 1)
findings.push(`${order.merchantOrderId}: multiple detected transfers are associated`)
if (remote?.status === 'finalized' && evidence.length === 0)
findings.push(`${order.merchantOrderId}: finalized without retained transfer evidence`)
if (order.transfer && evidence[0] && transferKey(order.transfer) !== transferKey(evidence[0]))
findings.push(`${order.merchantOrderId}: merchant and StableOps transfer references differ`)
if (!order.transfer && evidence[0])
findings.push(`${order.merchantOrderId}: merchant ledger is missing the transfer reference`)
}
console.log(
JSON.stringify(
{
window: { start: input.windowStart, end: input.windowEnd },
checkedLocalOrders: input.orders.length,
checkedPlatformOrders: platformInWindow.length,
transfers: [...evidenceByEvent.values()],
findings,
},
null,
2,
),
)
process.exitCode = findings.length === 0 ? 0 : 1關閉時間窗時,應為最終性規則、非同步任務和最終一致的列表讀取留出足夠緩衝。第一次發現差異後,應再次執行核對,再決定是否升級處理。指令碼根據時間窗內建立的付款訂單,以及時間窗內投遞的支付事件發現反向孤立訂單,因此端點必須訂閱需要審計的支付事件。
對於高流量生產帳戶,應把經過驗籤的商戶收件表作為主要事件資料集,並根據本地儲存的付款訂單號逐筆查詢。帶保護的全量掃描只用於週期性發現孤立訂單。如果併發寫入導致無法取得兩次一致快照,任務必須保持失敗,不能降級成“第一頁沒有差異”。
生成的 transfers 陣列是鏈上證據的連線索引,並不是一次新的區塊鏈查詢。對於異常專案與月末抽樣,應透過規範區塊瀏覽器或鏈服務商再次核對鏈、代幣合約或鑄幣地址、目標地址、最小單位金額、交易雜湊與日誌序號、回執狀態和目前區塊雜湊,並把核對時間與區塊引用儲存在對帳結果中。
這個指令碼只發現結構性差異,不會自動結算或退款。每一項差異仍需要證據與經過審批的處理決定。
按原因分派每項差異
應把比較結果變成受控異常佇列,而不是不斷編輯記錄,直到總額碰巧相等。
| 差異 | 可能原因 | 安全處理方式 |
|---|---|---|
| 存在業務訂單,但付款訂單缺失 | 建單請求失敗、環境錯誤或對映從未儲存 | 檢查冪等鍵與介面日誌;安全重試,或按原規則建立訂單 |
| 存在付款訂單,但業務訂單缺失 | 業務資料被刪除或匯入錯誤,或提交了錯誤的 merchantOrderId | 隔離履約;恢復業務物件,或記錄孤立訂單 |
| StableOps 已最終完成,商戶仍未入帳 | 收件任務失敗、事件未處理或對映錯誤 | 檢查事件與投遞記錄,再重試商戶任務或以冪等方式重放傳輸 |
| 商戶已入帳,StableOps 尚未最終完成 | 樂觀履約、人工入帳或錯誤狀態變化 | 停止後續履約;核對規範鏈狀態,並記錄或撤銷人工決定 |
| 存在死信投遞 | 端點、鑑權、超時或下游故障 | 先修復接收端,再執行重放;不要持續向仍故障的端點重放 |
| 金額不一致 | 折扣漂移、自動金額調整、匯出錯誤或人工入帳 | 分別比較請求、返回、實收、入帳和退款金額,不覆蓋任何原值 |
| 鏈上存在轉帳,但沒有匹配訂單 | 金額、鏈、資產、地址有效期錯誤,或轉帳遲到 | 進入付款不匹配處理流程 |
每項異常都應儲存處理人、處理時間、原因碼、相關事件和交易引用、人工會計分錄以及退款交易。“調整至相符”不是可審計的原因。
穩定幣支付日對帳清單
可以把下面這份清單直接放入營運手冊:
- 固定核對時間窗,並記錄組織、沙盒或生產環境、時區及結算資產。
- 匯出時間窗內建立、變化、履約、退款或人工入帳的業務訂單。
- 拉取 StableOps 訂單與投遞組成的聯合快照;重複掃描不一致時讓任務失敗。
- 按
merchantOrderId連線,並確認本地已經儲存 StableOps 付款訂單號。 - 分別比較
requested_amount、返回的amount、結算資產與終態。 - 雙向檢查沒有付款訂單的業務訂單,以及沒有業務訂單的付款訂單。
- 確認每筆本地結算恰好對應一條冪等履約記錄。
- 把每筆最終完成訂單與儲存的鏈、資產、交易雜湊、日誌序號和最小單位金額對應起來。
- 找出 StableOps 已
finalized、但商戶帳本尚未入帳的訂單。 - 找出商戶已入帳、但 StableOps 狀態不是
finalized的訂單。 - 按端點與事件歸併投遞;只有同組重放已經成功時,才把歷史死信視為已解決。
- 檢查尚未解決的
failed與dead_letter投遞,以及收件任務自身的失敗記錄。 - 檢查已過期、已回滾、已取消和仍未關閉的訂單,不要把它們排除在報表之外。
- 把未匹配和遲到轉帳送入異常佇列,絕不按地址或金額強制匹配。
- 為每項差異指定負責人、原因碼、處理時限與不可變證據連結。
- 按鏈與資產儲存筆數和總額,並儲存任務時間窗與已完成頁數。
日對帳報表應足夠小,才能及時處理。如果其中持續出現數百項異常,應修復製造異常的接入或規則,而不是圍繞症狀擴大人工團隊。
月末對帳清單
月末流程在每日營運核對之外增加財務關帳:
- 重新執行所有日對帳時間窗,證明每次介面掃描都取得了兩次一致的完整快照。
- 按鏈和資產,從期初餘額經過入帳轉帳、資金歸集和退款,滾動核對至期末餘額。
- 分開統計精確匹配收款、未匹配收款、人工入帳、退款、網路手續費和資金調撥。
- 確認所有最終完成訂單隻入帳一次,並進入正確的會計期間與科目。
- 確認所有人工入帳和退款都具有審批記錄與交易引用。
- 關閉每項異常或記錄帳齡;未解決專案應明確結轉,不能隱藏在淨額調整中。
- 檢查全部死信、重放,以及已經被收件表接受但未被任務完成的事件。
- 把業務記錄、StableOps 訂單與投遞記錄、鏈上證據、異常決定和對帳彙總匯出為只讀審計包。
- 由編制人以外的人員複核並簽署本期結果。
在會計規則執行有記錄的估值之前,應始終按資產和鏈分別儲存總額。把不同網路上的 USDC 與 USDT 原始數字相加,可以用於部分營運觀察,但不能代替會計本位幣與估值時點。
重放修復投遞,但不會改寫歷史
StableOps 會保留 Webhook 投遞嘗試。按端點事件、指定投遞或死信執行重放時,系統會建立一條新的投遞記錄,並保留原記錄。這正是審計所需的行為。
這也意味著重放事件仍具有相同的事件身份。處理程序必須按 X-Event-Id 去重,不能按投遞號去重。如果事件已經持久化到收件表,只是商戶任務執行失敗,那麼簡單重放可能只會走到收件表的空操作分支;正確恢復方式通常是重試商戶側失敗的任務。如果事件從未到達收件表,重放才是正確的傳輸恢復方式。
按下重放之前,必須先判斷哪個邊界發生了故障:
- StableOps 無法投遞 HTTP 請求;
- 商戶端點在持久化接收前拒絕或超時;
- 端點已經接受事件,但非同步處理失敗;
- 處理已經成功,但後續會計匯出或連線錯誤。
只有前兩類故障能透過投遞重放修復。
常見問題
什麼是穩定幣支付對帳?
穩定幣支付對帳,是連線商戶業務債權、付款訂單與事件生命週期、規範鏈轉帳,並證明每筆最終完成收款只入帳一次、每項異常都有解釋的過程。把錢包餘額與銷售總額進行比較,只是餘額核對,並不是完整對帳。
能否只根據區塊鏈資料完成穩定幣支付對帳?
不能。區塊鏈資料可以證明轉帳、代幣身份、金額、地址和規範狀態,但其中沒有你的客戶、發票、履約、退款規則或會計期間。必須使用 merchantOrderId 這樣的穩定業務外部索引鍵,並用事件帳本保留付款訂單關係。
Webhook 與輪詢介面,哪個應該作為事實來源?
用 Webhook 及時處理狀態變化,用列表介面執行週期性恢復與審計。它們透過不同傳輸路徑呈現同一套支付生命週期。處理程序仍需按事件去重;使用偏移量分頁的審計掃描只有在連續兩次完整快照一致時才能成功。對帳還必須比較平台狀態與商戶帳本中真正提交的副作用。
把對帳納入支付設計
不要等到第一次月末關帳時,才發現交易雜湊、事件號和業務引用散落在保留週期各不相同的日誌中。先理解付款訂單模型,把付款訂單列表和Webhook 投遞列表實現為恢復路徑,並在接收生產資金前,在沙盒環境執行日對帳清單。只有既能解釋正常路徑,也能解釋每一項差異,支付接入才算完整。
相關文章
穩定幣退款必須作為新的鏈上轉帳單獨處理。本文講解 StableOps 如何校驗最終訂單和剩餘額度,由商戶從原收款地址簽署退款交易,再核對目標地址、資產、金額與最終深度,並透過 Webhook、失敗重試和對帳避免重複退款與帳務遺漏。
穩定幣少付、多付、發錯網路、發錯資產或過期後到帳,都不應讓訂單被悄悄完成。本文說明如何從鏈上事件識別各類支付不匹配,為人工處理保留證據,設計退款或補款流程,並透過精確付款指令和狀態約束降低再次發生的機率。
穩定幣轉帳並不等於支付完成。本文從付款訂單狀態機、鏈與資產精確匹配、確認和重組處理、介面冪等、Webhook 事件去重及可恢復履約出發,說明如何把鏈上轉帳轉化為可安全進入生產環境、能夠審計並支援異常恢復的支付事件。
本文講解交易平台如何用唯一地址、鏈上最終確定、Webhook 冪等處理與每日對帳建置加密貨幣充值監控,在支援客戶選擇多條網路的同時安全增加內部餘額,妥善處理過期、遲到與異常轉帳,並降低重複記帳、錯誤歸屬和鏈重組造成的真實資金損失。