上篇說明了對帳系統的概念模型:三本帳的關係、以結算日為基準的決策、日終對帳的五個步驟、軋差計算與差異單的分類處理,這些概念是設計實作的地基,這篇則著重於如何把地基變成可以執行的系統;本篇的重心有以下四個:對帳系統需要的核心資料表、確保批次作業可以安全重跑的冪等性設計、完整的比對與軋差驗算邏輯,以及多閘道報表匯入的介面抽象。


核心資料表設計

ordersrefunds 表負責提供系統帳的資料來源,對帳系統在此之上還需要建立四張專屬資料表。

gateway_transactions(閘道交易記錄)

這張表儲存從閘道報表匯入的原始交易資料,是串聯系統帳和銀行帳的橋樑:

 1CREATE TABLE gateway_transactions (
 2    id               UUID PRIMARY KEY DEFAULT gen_random_uuid(),
 3    gateway_name     VARCHAR(50)  NOT NULL,   -- 'stripe', 'tappay', 'jkopay'
 4    gateway_txn_id   VARCHAR(100) NOT NULL,
 5    order_id         UUID REFERENCES orders(id),
 6    amount           DECIMAL(12,2) NOT NULL,
 7    fee              DECIMAL(12,2) NOT NULL DEFAULT 0,
 8    net_amount       DECIMAL(12,2) NOT NULL,  -- amount - fee
 9    txn_type         VARCHAR(20)  NOT NULL,   -- 'payment', 'refund', 'chargeback'
10    settlement_date  DATE NOT NULL,
11    imported_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12    UNIQUE (gateway_name, gateway_txn_id)     -- 防止重複匯入
13);

settlement_date 是這張表最重要的欄位,比對邏輯和軋差計算都以它為基準,對應上篇說明的「以結算日為基準」決策;UNIQUE (gateway_name, gateway_txn_id) 則確保同一筆閘道交易不會被重複匯入,給匯入流程天然的冪等性保障。

bank_statements(銀行入帳記錄)

 1CREATE TABLE bank_statements (
 2    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
 3    bank_name       VARCHAR(50)  NOT NULL,
 4    reference_no    VARCHAR(100) NOT NULL,
 5    amount          DECIMAL(12,2) NOT NULL,
 6    txn_type        VARCHAR(20)  NOT NULL,    -- 'credit', 'debit'
 7    value_date      DATE NOT NULL,
 8    description     TEXT,
 9    imported_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
10    UNIQUE (bank_name, reference_no)
11);

reconciliation_runs(對帳執行記錄)

每次執行對帳都建立一筆記錄,這是對帳系統冪等性的核心:

1CREATE TABLE reconciliation_runs (
2    id               UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3    settlement_date  DATE NOT NULL UNIQUE,    -- 同一天只允許一筆 completed 記錄
4    status           VARCHAR(20) NOT NULL,    -- 'running', 'completed', 'failed'
5    started_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
6    completed_at     TIMESTAMPTZ,
7    total_matched    INT DEFAULT 0,
8    total_exceptions INT DEFAULT 0
9);

settlement_date UNIQUE 用以約束確保同一個結算日不會產生兩次完整的對帳結果,若需要重跑,則要先將記錄狀態改為 failed、刪除對應的差異單,再重新執行,而不是直接新增。

reconciliation_exceptions(差異單)

 1CREATE TABLE reconciliation_exceptions (
 2    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
 3    run_id          UUID NOT NULL REFERENCES reconciliation_runs(id),
 4    exception_type  VARCHAR(50) NOT NULL,
 5    severity        VARCHAR(10) NOT NULL
 6                      CHECK (severity IN ('critical', 'warning', 'info')),
 7    order_id        UUID,
 8    gateway_txn_id  VARCHAR(100),
 9    system_amount   DECIMAL(12,2),
10    gateway_amount  DECIMAL(12,2),
11    description     TEXT NOT NULL,
12    status          VARCHAR(20) NOT NULL DEFAULT 'open'
13                      CHECK (status IN ('open', 'in_review', 'resolved')),
14    resolved_by     VARCHAR(100),
15    resolved_at     TIMESTAMPTZ,
16    resolution_note TEXT,
17    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
18);

resolution_note 欄位記錄人工處理的說明,是稽核軌跡的重要組成,財務人員解決差異單時,必須在這個欄位填寫原因,以備後續審計。


對帳作業的冪等性設計

對帳作業本身必須保持冪等性,批次作業可能因為各種原因中途失敗,需要能夠安全地重新執行,不能重跑一次就多出一批重複的差異單;冪等性的保障方式在 reconciliation_runs 表以 settlement_date 做唯一約束,對帳開始前先查詢當天是否已有 completed 的記錄,若有則直接跳過:

 1public async Task RunAsync(DateOnly settlementDate)
 2{
 3    // 冪等性檢查
 4    var existing = await _db.ReconciliationRuns
 5        .FirstOrDefaultAsync(r => r.SettlementDate == settlementDate);
 6
 7    if (existing?.Status == "completed")
 8    {
 9        _logger.LogInformation(
10            "Reconciliation for {Date} already completed, skipping",
11            settlementDate);
12        return;
13    }
14
15    // 建立或重用執行記錄
16    var run = existing ?? new ReconciliationRun
17    {
18        SettlementDate = settlementDate,
19        Status = "running"
20    };
21    run.Status = "running";
22    run.StartedAt = DateTime.UtcNow;
23
24    if (existing == null) _db.ReconciliationRuns.Add(run);
25    await _db.SaveChangesAsync();
26
27    try
28    {
29        await ExecuteReconciliationAsync(run, settlementDate);
30        run.Status = "completed";
31        run.CompletedAt = DateTime.UtcNow;
32    }
33    catch (Exception ex)
34    {
35        run.Status = "failed";
36        _logger.LogError(ex, "Reconciliation failed for {Date}", settlementDate);
37        throw;
38    }
39    finally
40    {
41        await _db.SaveChangesAsync();
42    }
43}

若需要重跑(例如閘道報表資料有誤,需要重新匯入),則要先將 reconciliation_runs 的記錄狀態改為 failed,刪除對應的 reconciliation_exceptions,再重新執行;每次重跑都從乾淨的狀態開始,不會累加錯誤數據。


核心對帳邏輯的完整實作

  1private async Task ExecuteReconciliationAsync(
  2    ReconciliationRun run, DateOnly settlementDate)
  3{
  4    var exceptions = new List<ReconciliationException>();
  5
  6    // Step 1:讀取系統帳(當日已結算訂單)
  7    var systemOrders = await _db.Orders
  8        .Where(o => o.SettledDate == settlementDate
  9                 && o.Status == OrderStatus.Settled)
 10        .ToListAsync();
 11
 12    // Step 2:讀取閘道報表(已匯入)
 13    var gatewayTxns = await _db.GatewayTransactions
 14        .Where(g => g.SettlementDate == settlementDate
 15                 && g.TxnType == "payment")
 16        .ToListAsync();
 17
 18    // Step 3:交易比對(系統帳 → 閘道報表)
 19    foreach (var order in systemOrders)
 20    {
 21        var gw = gatewayTxns.FirstOrDefault(g => g.OrderId == order.Id);
 22
 23        if (gw == null)
 24        {
 25            exceptions.Add(new ReconciliationException
 26            {
 27                RunId         = run.Id,
 28                ExceptionType = "one_sided",
 29                Severity      = "warning",
 30                OrderId       = order.Id,
 31                SystemAmount  = order.CapturedAmount,
 32                Description   = $"系統帳有訂單 {order.Id},閘道報表無對應記錄"
 33            });
 34        }
 35        else if (Math.Abs(order.CapturedAmount - gw.NetAmount) > 0.01m)
 36        {
 37            exceptions.Add(new ReconciliationException
 38            {
 39                RunId          = run.Id,
 40                ExceptionType  = "amount_mismatch",
 41                Severity       = "critical",
 42                OrderId        = order.Id,
 43                GatewayTxnId   = gw.GatewayTxnId,
 44                SystemAmount   = order.CapturedAmount,
 45                GatewayAmount  = gw.NetAmount,
 46                Description    = $"金額差異 {order.CapturedAmount - gw.NetAmount:N2} TWD"
 47            });
 48        }
 49    }
 50
 51    // Step 3(反向):閘道報表有、系統帳沒有的交易
 52    var matchedOrderIds = systemOrders.Select(o => o.Id).ToHashSet();
 53    foreach (var gw in gatewayTxns.Where(g => !matchedOrderIds.Contains(g.OrderId!.Value)))
 54    {
 55        exceptions.Add(new ReconciliationException
 56        {
 57            RunId         = run.Id,
 58            ExceptionType = "one_sided",
 59            Severity      = "warning",
 60            GatewayTxnId  = gw.GatewayTxnId,
 61            GatewayAmount = gw.NetAmount,
 62            Description   = $"閘道報表有交易 {gw.GatewayTxnId},系統帳無對應訂單"
 63        });
 64    }
 65
 66    // Step 4:軋差驗算
 67    var totalSystemAmount = systemOrders.Sum(o => o.CapturedAmount);
 68    var totalRefunds      = await _db.Refunds
 69        .Where(r => r.SettledDate == settlementDate && r.Status == "succeeded")
 70        .SumAsync(r => r.Amount);
 71    var totalFees         = gatewayTxns.Sum(g => g.Fee);
 72    var expectedNet       = totalSystemAmount - totalRefunds - totalFees;
 73
 74    var bankDeposit = await _db.BankStatements
 75        .Where(b => b.ValueDate == settlementDate && b.TxnType == "credit")
 76        .SumAsync(b => b.Amount);
 77
 78    if (Math.Abs(expectedNet - bankDeposit) > 1m)
 79    {
 80        exceptions.Add(new ReconciliationException
 81        {
 82            RunId         = run.Id,
 83            ExceptionType = "netting_mismatch",
 84            Severity      = "critical",
 85            SystemAmount  = expectedNet,
 86            GatewayAmount = bankDeposit,
 87            Description   = $"軋差不符:預期淨額 {expectedNet:N2}," +
 88                            $"銀行實際入帳 {bankDeposit:N2}," +
 89                            $"差距 {expectedNet - bankDeposit:N2} TWD"
 90        });
 91    }
 92
 93    // Step 5:儲存結果並通知
 94    _db.ReconciliationExceptions.AddRange(exceptions);
 95    run.TotalMatched    = systemOrders.Count - exceptions.Count(e => e.OrderId != null);
 96    run.TotalExceptions = exceptions.Count;
 97    await _db.SaveChangesAsync();
 98
 99    var criticalItems = exceptions.Where(e => e.Severity == "critical").ToList();
100    if (criticalItems.Any())
101        await _notifier.SendAlertAsync(settlementDate, criticalItems);
102}

比對邏輯有兩個方向:系統帳掃閘道報表(找出系統有但閘道沒有的)、閘道報表掃系統帳(找出閘道有但系統沒有的),兩個方向都要跑,才能抓到所有的單邊差異;軋差允許 NT$1 以內的差距,超過才產生差異單,這個閾值應和財務部門共同定義。


閘道報表的匯入設計

閘道報表的匯入是對帳系統的資料入口,需要能處理不同閘道的格式差異,同時保證冪等性,因此建議抽象出 IGatewayReportImporter 介面,每個閘道各自實作:

 1public interface IGatewayReportImporter
 2{
 3    string GatewayName { get; }
 4    Task<IEnumerable<GatewayTransaction>> FetchAsync(DateOnly settlementDate);
 5}
 6
 7public class StripeReportImporter : IGatewayReportImporter
 8{
 9    public string GatewayName => "stripe";
10
11    public async Task<IEnumerable<GatewayTransaction>> FetchAsync(DateOnly settlementDate)
12    {
13        // 呼叫 Stripe Balance Transactions API
14        // 篩選 created 在 settlementDate 範圍內的記錄
15        // 轉換格式並回傳
16    }
17}
18
19public class TapPayReportImporter : IGatewayReportImporter
20{
21    public string GatewayName => "tappay";
22
23    public async Task<IEnumerable<GatewayTransaction>> FetchAsync(DateOnly settlementDate)
24    {
25        // 從 SFTP 下載 CSV
26        // 解析 CSV 格式
27        // 轉換格式並回傳
28    }
29}

這個設計和支付生命週期篇的 IPaymentGateway 介面是同一套思路:把各閘道的格式差異封裝在實作類別裡,核心對帳邏輯不需要知道底層是 Stripe 的 API 還是 TapPay 的 CSV,新增一個閘道只需要新增一個實作類別;匯入時利用 UNIQUE (gateway_name, gateway_txn_id) 約束做去重,使用 INSERT ... ON CONFLICT DO NOTHING 讓重複匯入安全地跳過:

1INSERT INTO gateway_transactions
2    (id, gateway_name, gateway_txn_id, order_id, amount, fee, net_amount,
3     txn_type, settlement_date, imported_at)
4VALUES
5    (@id, @gateway_name, @gateway_txn_id, @order_id, @amount, @fee, @net_amount,
6     @txn_type, @settlement_date, NOW())
7ON CONFLICT (gateway_name, gateway_txn_id) DO NOTHING;

從資料表到程式碼,對帳系統的工程設計有幾個一致貫穿的原則:

  • 四張表各有職責gateway_transactions 是系統帳和銀行帳之間的橋樑,bank_statements 是最終的資金驗證來源,reconciliation_runs 是對帳作業的冪等性錨點,reconciliation_exceptions 是財務人員的工作佇列,也是稽核軌跡的記錄。
  • 對帳作業本身必須冪等reconciliation_runs 表的唯一約束和狀態設計,確保重跑不會產生重複的差異單或累加的錯誤數據。
  • 比對要跑兩個方向:系統帳掃閘道報表、閘道報表掃系統帳,兩個方向都跑才能抓到所有的單邊差異,少一個方向就會有漏網之魚。
  • 閘道報表匯入要冪等UNIQUE constraintON CONFLICT DO NOTHING 是最簡單可靠的去重機制,讓重複執行匯入腳本不會造成問題。
  • 介面抽象讓多閘道可維護IGatewayReportImporter 把各閘道的格式差異封裝起來,核心對帳邏輯保持整潔,新增閘道不動核心程式碼。
  • 稽核軌跡不可缺:每一筆差異單的處理過程都要記錄在 resolution_note,讓未來的審計有跡可循。

對帳系統設計好之後,下一個值得深入的主題是 Outbox Pattern,確保訂單狀態更新和通知送出在同一個原子操作內完成,解決支付系統中「資料庫寫入成功,但 Webhook 沒送出去」這個棘手的可靠性問題。


本文為個人學習筆記,持續更新中。