信用卡付款接近即時,而且由 event 驅動。B2B 銀行匯款讓我學到另一種 payment engineering:銀行端的帳務可以完全正確,但在 statement file 抵達前,我們的應用程式什麼都不知道。
我參與的系統替企業提供金融解決方案。資金實際走銀行帳戶,我們的應用程式則要辨認是哪兩家公司、對應哪一筆內部交易,最後更新正確的 balance 或 open item。原本的作業方式是人工下載 CODA 檔案,查看 credit、debit、金額與公司名稱,再手動輸入系統。
把它自動化不是「逐行讀檔,然後改餘額」,而是建立一條能保留證據、可以重跑、也能解釋每一次決策的 reconciliation pipeline。
以下結合公開的 CODA 格式與通用設計,整理我現在會採用的處理方式;不是特定銀行的內部 SOP,也不公開客戶帳戶、交易樣本或連線設定。
CODA 是什麼,又不是什麼
CODA 是 Febelfin 制定的比利時跨銀行 coded electronic account statement 標準,用來回報已經入帳的帳戶異動。它不是 payment initiation API,也不負責發起匯款。
檔案怎麼來取決於公司與銀行的契約。有些企業透過 Isabel desktop application 手動下載,有些使用 secure EDI/FTP,也可能走銀行自己的 channel。只要銀行工作日有 movement,就可能提供檔案;「一個月不定期收到幾次」是特定公司的操作方式,不是 CODA 格式規定只能這樣出檔。
CODA 是 fixed-width、record-based 格式。Header、帳號、old/new balance、movement、additional information 與 trailer 分屬不同 record type,而且一筆 logical movement 可能跨 continuation record。若把每一行都當成一筆獨立 transaction,遲早會把資料解析錯。
Credit/debit 必須從 statement account 的角度看
credit 與 debit 很容易被直接翻成充值與扣款,但它們描述的是 CODA 裡那個銀行帳戶的變化:
- Credit:增加該銀行帳戶的金額。
- Debit:減少該銀行帳戶的金額。
它們並不直接代表「替客戶 A 充值」或「向公司 B 扣款」。這個 business meaning 還必須綜合 statement account、counterparty、structured communication、amount、currency、日期與內部 open item 才能判斷。
Counterparty company name 可以當線索,但不能當安全的 unique key。名稱可能被截斷、改名、標點不同,甚至顯示的是付款代理人而不是實際客戶。Structured reference 與已知銀行帳號通常比較強,但一樣需要處理衝突。
我現在會怎麼設計這條 pipeline
1. 在受控範圍內保存原始證據
不論是人工下載,還是排程透過安全通道接收,都應先確認接收與保存權限。對需要保留作為對帳證據的檔案,保存未修改的 raw bytes,並記錄來源、接收時間、帳戶識別、檔名、checksum 與 parser version,才能重現後續解析與配對。
原檔可能含帳號、對手方名稱、金額與匯款附言;它是受保護的業務資料,不是一般除錯附件。這類 archive 應加密、限權、記錄查閱,並依適用法規、契約與業務目的設定保存期限及刪除流程;有依法或依約必須保存的證據,也不能任意提前刪除。「保持原檔不被改寫」不等於「永久保存」。
Archive 與 log 要分開。一般 log 只記錄匯入 ID、處理結果與錯誤分類,必要時由有權限的人回查原檔。測試與公開教學則應另用虛構資料,不把銀行原檔放進公開儲存庫、issue 或未核准的 AI 工具。這些是這條 pipeline 應具備的資料保護條件,不是對過往系統合規狀態的認證。OWASP:Logging Cheat Sheet
File-level idempotency 很重要。使用者可能下載同一份 statement 兩次;銀行可能重新投遞;SFTP poller 也可能再次看到同一個檔案。可靠的 identity 應綜合 account、statement sequence、銀行提供的 identifier,再用 file hash 當額外保護,不能只靠 filename。
2. 先驗證 statement,再讀 movement
遇到未知帳號、statement sequence 中斷、前後 balance 接不起來、不支援的 format version,或 mandatory trailer check 失敗時,先 reject 或 quarantine。即使是沒有 movement 的 empty statement,也可能對 sequence continuity 有意義。
Old balance、所有 movement 與 new balance 應依格式規則核對。通過這項檢查,只代表 statement 結構與金額一致,不代表每一筆 movement 都已經配對到正確客戶。
3. 先組裝 record,再正規化 movement
Parser 必須先把屬於同一筆 logical movement 的 record 與 continuation 組合起來,最後才建立 provider-neutral object:
BankMovement
source_account
statement_sequence
movement_sequence
booking_date / value_date
direction / amount / currency
counterparty_name / counterparty_iban
structured_reference / free_text
transaction_family / bank_reference
raw_record_ids
這個 boundary 很重要。CODA 的 fixed position 與 code 留在 parser 裡;matching 與 ledger 只處理 normalized movement。
Febelfin 的規格也特別提醒,有些 bank reference 會依銀行而異,甚至可能在沒有通知的情況下改變。欄位存在,不代表它就適合當長期不變的 business key。
4. 根據證據配對,不要樂觀猜測
我會大致這樣排序 matching evidence:
| 證據 | 強度 | 注意事項 |
|---|---|---|
| 唯一 structured payment communication,且能對到 open item | 高 | amount/currency 仍要一致,而且 reference 不能已經被使用 |
| 已知 counterparty IBAN + 精確金額 + 合理日期/open item | 中高 | 企業可能共用 treasury account,也可能用 batch payment |
| Free text 裡的 end-to-end 或內部 reference | 中 | normalization 與 truncation 可能改變內容 |
| Company name + amount | 低 | 名稱不穩定,也不唯一 |
| 只有 amount | 不足 | 撞金額非常常見 |
Matching result 不應只有 matched/unmatched,而應保留 candidate ID、命中的 rule、confidence、被排除的其他候選與 reason code。
只有唯一而且 high-confidence 的結果可以自動入帳。未知 counterparty、一筆匯款支付多張 invoice、split payment、銀行先扣手續費、匯差、reversal,或多個相同金額候選,都應進 manual review queue。
5. 用冪等 journal entry 入帳
Parser 不應直接執行 customer.balance += amount。它應該請 ledger 根據 bank movement 的 stable source key 建立 journal entry。Ledger 保證同一個 source 不會重複入帳,並把 journal 連回 CODA movement、原始檔與 matching decision。
人工 correction 不直接覆寫歷史資料,而是 reverse 原 journal,再建立 replacement entry。Finance、support 與 audit 因此能沿著完整證據還原每一次調整。
Manual review 本來就是系統的一部分
自動化不是完全沒有人,而是只讓人處理真正需要判斷的例外。
一個好用的 review 畫面應並排顯示 raw bank description 與 normalized movement、系統建議的 company/open item、其他候選、同一 IBAN 的歷史付款,以及每一條 scoring rule。每一次 approve/reject 都要留下使用者、時間與原因。
人工判斷可以用來改進後續 deterministic rule,但金融入帳不應因為某個 fuzzy model 突然更有信心,就在沒有人知道的情況下改變結果。
值得喚回記憶的 edge case
- 同一個 file 或 movement 收到兩次。
- Statement sequence 中斷,或 empty statement 補上 sequence。
- 一筆 movement 跨多個 continuation record。
- Booking date 與 value date 不同。
- 一筆 transfer 支付多張 invoice,或多筆 transfer 合併支付一張 invoice。
- 銀行先扣 fee,實際收到金額與 invoice 不同。
- Structured communication 缺少或格式錯誤。
- Counterparty name 被截斷、轉寫,或顯示中間機構。
- 後續 statement 出現 reversal 或 correction。
- 外幣與 FX 造成金額差異。
- 銀行把 movement aggregate,只在另一種 report 提供明細。
- Parser 重新部署後不小心重跑歷史檔案。
- Manual correction 必須連回可稽核的 reversal 與 replacement entry。
監控指標也不能只有「處理幾個檔案」。還要看 sequence gap、duplicate file、parser reject、unmatched rate、ambiguous rate、auto-match precision、manual review age、從 bank booking 到 ledger posting 的時間,以及 balance reconciliation difference。
CODA 也提醒我:payment 和 settlement 是不同系統
即時的 PSP 流程與批次 CODA 流程,最後會在 reconciliation 相遇。PSP 可以告訴我們信用卡已 capture,但 settlement report 才能說明最後實際 payout 多少;B2B transfer 也可能已在銀行入帳,但應用程式尚未辨認它的 business meaning。
可靠的架構會分開三層:
- External evidence:PSP event、status API、bank statement 與 raw file。
- Normalized financial event:不綁 provider 的 payment 與 bank movement。
- Internal ledger decision:哪個 account 或 open item 要改變,並保留 audit trail。
有這層分離,新 PSP、新銀行格式、延遲檔案與人工修正才不會迫使系統改寫金融歷史。