週五下午。兩張試算表。各 900 行。
場景是這樣的。月底最後一個週五。你從 Stripe 匯出一個 CSV,從 QuickBooks 匯出另一個。兩個檔案打開,你開始用肉眼逐行比對。
馬上就發現,什麼都對不上。
Stripe 叫 payment_intent_id。QuickBooks 叫「Reference No.」。Stripe 時間戳是 UTC。QuickBooks 用你的本地時區。Stripe 列的是美分整數 -- 4999 代表 $49.99。QuickBooks 顯示帶小數的美元 -- 49.99。一個檔案 847 行,另一個 912 行。
你瞇著眼。你往下捲。你寫了一個 VLOOKUP,幾乎能用,但到第 47 行就壞了,因為 QuickBooks 把客戶 email 截斷了。三個小時後,你配對了大部分。大概吧。你不確定有沒有漏掉什麼。你唯一確定的是,你再也不想做這件事了。
每個自己管帳的獨立開發者和小團隊都經歷過這個。月結對帳 -- 跨兩個系統比對交易,確認沒有東西掉了 -- 是小型公司最容易出錯的重複性工作之一。枯燥、無法規模化,搞錯的後果從小小的稅務頭痛到嚴重的帳務差異都有。
AI CLI agent 可以把手動配對這件事消除掉。餵進兩個 CSV 檔案。它正規化資料(想像成把兩個檔案翻譯成同一種語言),依金額、日期、摘要配對交易,標記所有對不上的項目,輸出結構化報告。整個流程在終端機裡跑完。一千筆交易不到一分鐘。成本幾毛美金的 API token。
讀完這篇文章,你會有:
- 一個附帶 dry-run 模式的對帳腳本,適用於任何兩個 CSV 來源
- 處理金額容差、日期偏移、模糊摘要的配對邏輯
- 每筆配對的信心分數
- 結構化 JSON 報告,可直接匯入會計工具或人工複審
- 一個 CLAUDE.md workflow 區塊,讓對帳變成一行指令
範例資料:Stripe vs. QuickBooks
在動手建任何東西之前,先看看真實的對帳輸入長什麼樣。兩個不同系統的匯出,描述同一批底層交易,但結構像是故意設計來整人的。
Stripe 匯出 (stripe_march.csv):
id,created,amount,currency,fee,net,description,customer_email
pi_3Ox1a2B,2026-03-01 08:14:22 UTC,4999,usd,175,4824,Pro Plan - Monthly,alice@example.com
pi_3Ox1a2C,2026-03-01 14:30:01 UTC,9999,usd,320,9679,Team Plan - Monthly,bob@corp.dev
pi_3Ox1a2D,2026-03-03 09:45:33 UTC,4999,usd,175,4824,Pro Plan - Monthly,carol@startup.io
pi_3Ox1a2E,2026-03-05 16:22:10 UTC,19999,usd,610,19389,Enterprise - Annual,dave@bigco.com
pi_3Ox1a2F,2026-03-07 11:08:44 UTC,4999,usd,175,4824,Pro Plan - Monthly,eve@freelance.dev
pi_3Ox1a2G,2026-03-07 11:09:02 UTC,-4999,usd,0,-4999,Refund - Pro Plan,eve@freelance.dev
QuickBooks 匯出 (quickbooks_march.csv):
Date,Num,Name,Memo,Amount,Balance
03/01/2026,1001,Stripe Transfer,alice@example.com - Pro,49.99,10249.99
03/01/2026,1002,Stripe Transfer,bob@corp.dev - Team,99.99,10349.98
03/03/2026,1003,Stripe Transfer,carol - Pro Monthly,49.99,10399.97
03/05/2026,1004,Stripe Transfer,Enterprise annual - dave,199.99,10599.96
03/07/2026,1005,Stripe Transfer,eve - Pro,49.99,10649.95
03/07/2026,1006,Stripe Refund,Refund eve,-49.99,10599.96
03/08/2026,1007,AWS,March infrastructure,,-10400.00
四個差異讓手動比對變得痛苦:
- 金額格式。 Stripe 存美分整數(
4999),QuickBooks 存帶小數的美元(49.99)。同一個數字,不同的語言。 - 日期格式。 Stripe 用 ISO 8601 帶 UTC 時區,QuickBooks 用
MM/DD/YYYY不帶時區。UTC 三月七號晚上 11 點的付款,在你本地帳本裡可能變成三月八號。 - 摘要欄位。 Stripe 有結構化的
description和customer_email欄位。QuickBooks 只有一個自由文字的Memo欄,有時放 email、有時放名字、有時只放方案名稱。就像拿檔案櫃去比對一堆便利貼。 - 多餘的列。 QuickBooks 有一筆 AWS 基礎設施費用,Stripe 裡沒有對應紀錄。這是預期的 -- 完全不同的付款來源。Agent 應該把它標記為「無配對」,不是「錯誤」。
配對邏輯
交易配對分三輪,每輪條件逐步放寬。想像成機場安檢有三道關卡。第一道抓住明顯的配對。第二道抓住日期偏移了一兩天的。第三道抓住只有摘要能辨認出來的。
第一輪:精確金額 + 同日期
最高信心的配對。兩筆交易正規化後金額相同、日期也相同,幾乎可以確定是同一筆。像兩塊拼圖完美扣合。
正規化規則:
- Stripe 美分轉美元:
amount / 100 - 兩種日期格式統一解析為
YYYY-MM-DD - 金額容差
$0.01,處理四捨五入差異
第二輪:精確金額 + 日期區間
有些交易在不同系統入帳日期不同。Stripe 週五晚上 UTC 收到的款項,可能週一才出現在 QuickBooks。這一輪把日期區間放寬到 3 個工作日,但仍然要求金額精確吻合。
第三輪:模糊摘要配對
對剩餘未配對的交易,agent 用文字相似度比對摘要。從兩邊提取實體 -- 客戶名稱、email、方案名稱 -- 計算相似度分數。這能抓到金額略有差異(Stripe 是毛額、QuickBooks 是淨額)但摘要明確指向同一筆交易的情況。
每筆配對都有信心分數:
| 輪次 | 信心分數 | 條件 |
|---|---|---|
| 第一輪 | 0.95-1.0 | 精確金額 + 同日期 |
| 第二輪 | 0.80-0.94 | 精確金額 + 日期在 3 個工作日內 |
| 第三輪 | 0.60-0.79 | 模糊摘要配對 + 金額差異在 5% 內 |
| 無配對 | 0.00 | 找不到對應交易 |
低於 0.60 的一律標記為需人工複審。寧可多浮出一個問號,也不要靜悄悄地放過一筆錯配。
對帳腳本
以下是完整的 Python 腳本,實作三輪配對。餵進兩個 CSV 檔案,輸出結構化 JSON 報告。
#!/usr/bin/env python3
"""
reconcile.py -- 比對兩個 CSV 交易匯出檔。
用法:
python reconcile.py stripe.csv quickbooks.csv --dry-run
python reconcile.py stripe.csv quickbooks.csv -o report.json
"""
import argparse
import csv
import json
import sys
from datetime import datetime, timedelta
from difflib import SequenceMatcher
from pathlib import Path
def parse_stripe_row(row: dict) -> dict:
"""將 Stripe CSV 列正規化為標準交易格式。"""
return {
"source": "stripe",
"id": row["id"],
"date": datetime.strptime(row["created"][:10], "%Y-%m-%d").date().isoformat(),
"amount": round(int(row["amount"]) / 100, 2),
"currency": row["currency"],
"description": row.get("description", ""),
"email": row.get("customer_email", ""),
"raw": row,
}
def parse_quickbooks_row(row: dict) -> dict:
"""將 QuickBooks CSV 列正規化為標準交易格式。"""
amount_str = row.get("Amount", "0").replace(",", "")
return {
"source": "quickbooks",
"id": row.get("Num", ""),
"date": datetime.strptime(row["Date"], "%m/%d/%Y").date().isoformat(),
"amount": float(amount_str) if amount_str else 0.0,
"currency": "usd",
"description": row.get("Memo", ""),
"email": "",
"raw": row,
}
def load_csv(filepath: str, parser) -> list[dict]:
"""用指定的 parser 載入並解析 CSV 檔。"""
rows = []
with open(filepath, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
try:
rows.append(parser(row))
except (ValueError, KeyError) as e:
print(f" 略過此列:{e}", file=sys.stderr)
return rows
def amount_match(a: float, b: float, tolerance: float = 0.01) -> bool:
return abs(a - b) <= tolerance
def date_within_window(d1: str, d2: str, days: int = 0) -> bool:
dt1 = datetime.fromisoformat(d1).date()
dt2 = datetime.fromisoformat(d2).date()
return abs((dt1 - dt2).days) <= days
def description_similarity(desc_a: str, desc_b: str, email: str = "") -> float:
"""計算兩段摘要的文字相似度。
若 email 或客戶名稱同時出現在雙方,加分。"""
base = SequenceMatcher(None, desc_a.lower(), desc_b.lower()).ratio()
if email and email.lower() in desc_b.lower():
base = min(base + 0.3, 1.0)
return round(base, 3)
def reconcile(source_a: list[dict], source_b: list[dict]) -> dict:
"""三輪對帳。回傳結構化結果。"""
matched = []
unmatched_a = list(source_a)
unmatched_b = list(source_b)
# --- 第一輪:精確金額 + 同日期 ---
still_unmatched_a = []
for txn_a in unmatched_a:
best = None
for txn_b in unmatched_b:
if amount_match(txn_a["amount"], txn_b["amount"]) and \
date_within_window(txn_a["date"], txn_b["date"], days=0):
best = txn_b
break
if best:
matched.append({
"source_a": txn_a,
"source_b": best,
"confidence": 0.97,
"match_pass": 1,
})
unmatched_b.remove(best)
else:
still_unmatched_a.append(txn_a)
unmatched_a = still_unmatched_a
# --- 第二輪:精確金額 + 3 天區間 ---
still_unmatched_a = []
for txn_a in unmatched_a:
best = None
for txn_b in unmatched_b:
if amount_match(txn_a["amount"], txn_b["amount"]) and \
date_within_window(txn_a["date"], txn_b["date"], days=3):
best = txn_b
break
if best:
matched.append({
"source_a": txn_a,
"source_b": best,
"confidence": 0.85,
"match_pass": 2,
})
unmatched_b.remove(best)
else:
still_unmatched_a.append(txn_a)
unmatched_a = still_unmatched_a
# --- 第三輪:模糊摘要 ---
still_unmatched_a = []
for txn_a in unmatched_a:
best = None
best_score = 0.0
for txn_b in unmatched_b:
sim = description_similarity(
txn_a["description"], txn_b["description"], txn_a.get("email", "")
)
pct_diff = abs(txn_a["amount"] - txn_b["amount"]) / max(abs(txn_a["amount"]), 0.01)
if sim > 0.4 and pct_diff < 0.05 and sim > best_score:
best = txn_b
best_score = sim
if best and best_score > 0.4:
matched.append({
"source_a": txn_a,
"source_b": best,
"confidence": round(0.60 + best_score * 0.19, 2),
"match_pass": 3,
})
unmatched_b.remove(best)
else:
still_unmatched_a.append(txn_a)
unmatched_a = still_unmatched_a
# --- 產出報告 ---
total_a = sum(t["amount"] for t in source_a)
total_b = sum(t["amount"] for t in source_b)
return {
"summary": {
"source_a_count": len(source_a),
"source_b_count": len(source_b),
"matched_count": len(matched),
"unmatched_a_count": len(unmatched_a),
"unmatched_b_count": len(unmatched_b),
"source_a_total": round(total_a, 2),
"source_b_total": round(total_b, 2),
"difference": round(total_a - total_b, 2),
},
"matched": [
{
"source_a_id": m["source_a"]["id"],
"source_b_id": m["source_b"]["id"],
"amount": m["source_a"]["amount"],
"date": m["source_a"]["date"],
"confidence": m["confidence"],
"match_pass": m["match_pass"],
}
for m in matched
],
"unmatched_source_a": [
{"id": t["id"], "amount": t["amount"], "date": t["date"], "description": t["description"]}
for t in unmatched_a
],
"unmatched_source_b": [
{"id": t["id"], "amount": t["amount"], "date": t["date"], "description": t["description"]}
for t in unmatched_b
],
"flags": [],
}
def add_flags(report: dict) -> dict:
"""加入需要注意的項目標記。"""
flags = []
if report["summary"]["difference"] != 0:
flags.append({
"severity": "warning",
"message": f"總差異:${report['summary']['difference']:.2f}",
})
for item in report["unmatched_source_a"]:
flags.append({
"severity": "error",
"message": f"來源 B 中找不到配對:{item['id']}(${item['amount']:.2f},{item['date']})",
})
for item in report["unmatched_source_b"]:
flags.append({
"severity": "error",
"message": f"來源 A 中找不到配對:{item['id']}(${item['amount']:.2f},{item['date']})",
})
for m in report["matched"]:
if m["confidence"] < 0.80:
flags.append({
"severity": "warning",
"message": f"低信心配對({m['confidence']}):{m['source_a_id']} 與 {m['source_b_id']}",
})
report["flags"] = flags
return report
def main():
parser = argparse.ArgumentParser(description="對帳兩個 CSV 交易匯出檔。")
parser.add_argument("source_a", help="第一個 CSV 路徑(例如 Stripe 匯出)")
parser.add_argument("source_b", help="第二個 CSV 路徑(例如 QuickBooks 匯出)")
parser.add_argument("-o", "--output", help="輸出 JSON 檔案路徑")
parser.add_argument("--dry-run", action="store_true", help="僅印出摘要,不寫入檔案")
parser.add_argument("--tolerance", type=float, default=0.01, help="金額配對容差(美元)")
parser.add_argument("--date-window", type=int, default=3, help="第二輪的日期區間天數")
args = parser.parse_args()
print(f"載入 {args.source_a}...")
txns_a = load_csv(args.source_a, parse_stripe_row)
print(f" {len(txns_a)} 筆交易已載入")
print(f"載入 {args.source_b}...")
txns_b = load_csv(args.source_b, parse_quickbooks_row)
print(f" {len(txns_b)} 筆交易已載入")
print("執行對帳...")
report = reconcile(txns_a, txns_b)
report = add_flags(report)
# 印出摘要
s = report["summary"]
print(f"\n--- 對帳摘要 ---")
print(f"來源 A:{s['source_a_count']} 筆交易,合計 ${s['source_a_total']:.2f}")
print(f"來源 B:{s['source_b_count']} 筆交易,合計 ${s['source_b_total']:.2f}")
print(f"已配對:{s['matched_count']}")
print(f"未配對(A):{s['unmatched_a_count']}")
print(f"未配對(B):{s['unmatched_b_count']}")
print(f"差異:${s['difference']:.2f}")
if report["flags"]:
print(f"\n--- 標記({len(report['flags'])} 項)---")
for flag in report["flags"]:
severity = flag["severity"].upper()
print(f" [{severity}] {flag['message']}")
if args.dry_run:
print("\nDry run 完成,未寫入檔案。")
return
output_path = args.output or "reconciliation_report.json"
with open(output_path, "w") as f:
json.dump(report, f, indent=2, ensure_ascii=False)
print(f"\n報告已寫入 {output_path}")
if __name__ == "__main__":
main()
存為 reconcile.py。先用 dry-run 模式跑 -- 先看再跳:
python reconcile.py stripe_march.csv quickbooks_march.csv --dry-run
輸出:
載入 stripe_march.csv...
6 筆交易已載入
載入 quickbooks_march.csv...
7 筆交易已載入
執行對帳...
--- 對帳摘要 ---
來源 A:6 筆交易,合計 $249.96
來源 B:7 筆交易,合計 $-10150.04
已配對:6
未配對(A):0
未配對(B):1
差異:$10400.00
--- 標記(2 項)---
[WARNING] 總差異:$10400.00
[ERROR] 來源 A 中找不到配對:1007($-10400.00,2026-03-08)
Dry run 完成,未寫入檔案。
六筆 Stripe 交易全部配對上 QuickBooks 的對應項目。AWS 基礎設施費用被正確標記為未配對。$10,400 的差異是預期中的 -- 完全不同的付款來源,不是帳務差異。腳本精確告訴你該查什麼、該忽略什麼。
Structured Output:報告格式
JSON 報告遵循嚴格的 schema,讓下游工具能可靠地消費。把 schema 想成對帳引擎和所有讀取它輸出的東西之間的合約。
{
"summary": {
"source_a_count": "integer",
"source_b_count": "integer",
"matched_count": "integer",
"unmatched_a_count": "integer",
"unmatched_b_count": "integer",
"source_a_total": "number",
"source_b_total": "number",
"difference": "number"
},
"matched": [
{
"source_a_id": "string",
"source_b_id": "string",
"amount": "number",
"date": "string (ISO 8601)",
"confidence": "number (0.0-1.0)",
"match_pass": "integer (1-3)"
}
],
"unmatched_source_a": [
{
"id": "string",
"amount": "number",
"date": "string",
"description": "string"
}
],
"unmatched_source_b": [],
"flags": [
{
"severity": "error | warning | info",
"message": "string"
}
]
}
為什麼嚴格 schema 很重要?沒有它,你得到的是人看得懂但機器看不懂的自由文字。有了它,你可以在上面建可靠的自動化:一個腳本讀取報告、對每個 error 級別的標記建 Jira ticket、把摘要推到 Slack。報告變成更大機器裡的一個齒輪,不是沒人理的文件。
用 AI agent 產生這份報告(而非上面的確定性腳本)時,要指示它只輸出符合這個 schema 的有效 JSON。在你的 CLAUDE.md 中:
## 對帳輸出規則
產出對帳報告時,只輸出有效的 JSON。
嚴格遵循以下 schema,不加額外欄位,不漏掉欄位:
- summary:包含 source_a_count, source_b_count, matched_count,
unmatched_a_count, unmatched_b_count, source_a_total, source_b_total, difference
- matched:陣列,每個物件包含 source_a_id, source_b_id, amount, date, confidence, match_pass
- unmatched_source_a:陣列,每個物件包含 id, amount, date, description
- unmatched_source_b:陣列,每個物件包含 id, amount, date, description
- flags:陣列,每個物件包含 severity(error/warning/info)和 message
處理混亂的真實資料
正式環境的 CSV 匯出從來不乾淨。欄位缺失、列重複、多幣別、對「退款」的創意詮釋。以下是腳本能處理的常見模式,以及怎麼擴充。
欄位缺失。 QuickBooks 匯出有時省略分錄的 Amount 欄,留空白。Parser 預設為 0.0,對帳時被標記為未配對。讓怪異的列浮出來,比靜悄悄丟掉好。就像金屬探測器嗶了一聲 -- 你去查,即使最後發現只是一枚硬幣。
重複交易。 Stripe 的重試機制可能產生相同 payment_intent ID 但不同 charge ID 的重複紀錄。腳本依序配對,第一筆成功配對,重複的落到未配對清單。檢查未配對項目時先看有沒有重複的再深入調查。
多幣別。 範例腳本假設全部是美元。多幣別對帳需要用交易日的匯率把所有金額正規化為基礎幣別。在配對條件加入 currency 欄位 -- 兩筆金額相同但幣別不同的交易不是配對,是巧合。
部分退款。 Stripe 把部分退款記錄為獨立的負數交易。QuickBooks 可能直接調整原始交易金額。模糊配對那輪能處理:摘要相似度抓到客戶名稱,5% 的金額容差容納原始金額和調整後金額的差異。
大量交易的批次處理
確定性腳本處理幾千行只要幾秒。但如果你想讓 AI agent 分析困難的案例 -- 模糊的摘要、異常的金額、多步驟的退款鏈 -- 你需要一個成本可控的做法。
以每批 50-100 行的方式處理交易。每批送給 agent 時,附帶前一批留下的未配對項目。這能控制 context window 的大小,避免大資料集上 token 成本爆炸。就像吃一頭大象:一口一口來。
# 把大 CSV 切成每 100 行一個檔案
split -l 100 stripe_full_year.csv chunk_
# 逐批處理,帶入前一批的未配對項目
for chunk in chunk_*; do
claude -p "對帳這些 Stripe 交易與 quickbooks_2026.csv。\
以下是先前未配對的項目:$(cat unmatched_carry.json)。\
依對帳 schema 輸出 JSON。" < "$chunk" >> results.json
done
大部分對帳任務用確定性腳本就是正確的工具。AI agent 留給困難的案例:腳本標記為未配對或低信心的那 5-10%。這種混合做法讓你在簡單的 90% 上享有腳本的速度,在模糊的剩餘部分使用 agent 的推理能力。對的工具做對的事。
CLAUDE.md Workflow 區塊
在你的專案 CLAUDE.md 加入以下區塊,讓對帳變成一行指令:
## 交易對帳 Workflow
當我要求對帳時:
### 輸入
- 我會提供兩個 CSV 檔案路徑
- 從欄位標頭自動辨識各檔案的來源系統
- 支援格式:Stripe、QuickBooks、Xero、銀行 CSV 匯出
### 處理
1. 載入兩個檔案,自動偵測欄位對應
2. 正規化:金額轉為十進位美元,日期轉為 ISO 8601,摘要轉為小寫
3. 執行三輪配對:精確金額+日期、金額+日期區間、模糊摘要
4. 計算每筆配對的信心分數
5. 標記所有未配對交易和低信心配對
### 輸出
- 在 stdout 印出摘要表格
- 詳細 JSON 報告寫入 reconciliation_report.json
- 列出每個標記的嚴重性和建議動作
- 若為 --dry-run,僅印出摘要
### 規則
- 絕不靜悄悄地丟掉交易。每一行輸入都必須出現在已配對或未配對中。
- 所有金額四捨五入到小數後 2 位。
- 負數金額視為退款,不視為錯誤。
- 若兩個檔案行數相同且全部以 0.95+ 配對,印出「乾淨對帳」後結束。
執行方式:
claude -p "對帳 stripe_march.csv 和 quickbooks_march.csv --dry-run"
Agent 讀取兩個檔案、套用配對邏輯、印出摘要。拿掉 --dry-run 就會寫入完整報告。
分割面板的優勢
對帳本質上就是並排比對。你永遠在看兩個資料來源,問:這些對得上嗎?
有效率的配置是三個面板。左邊:Stripe CSV 用 less 或 csvlook 打開,捲到被標記的交易。右邊:QuickBooks CSV,捲到對應的那行。中間:AI agent 的對話,針對特定差異追問。
Agent 標記一筆低信心配對 -- 例如 pi_3Ox1a2E 和 QuickBooks 第 1004 行之間信心 0.72。你瞄一眼左邊看到 Stripe 摘要(「Enterprise - Annual」),瞄一眼右邊看到 QuickBooks 備註(「Enterprise annual - dave」)。兩秒確認是同一筆。不用切視窗。不用在 900 行的試算表裡迷路。不用「等一下,剛才是哪個分頁?」
每月固定對帳的話,把這個排版存成 workspace。下個月打開 workspace、放入新的 CSV、跑同一個指令。整個對帳過程 -- 資料、agent、報告 -- 全部在同一個畫面上。
總結
交易對帳就是資料正規化加模式配對。困難的部分 -- 格式不一致、時區差異、不遵守任何慣例的自由文字摘要 -- 正好是 AI agent 擅長處理的。確定性腳本搞定簡單的 90%。AI agent 搞定模糊的 10%。Structured output 的 JSON 讓一切可機器讀取、可稽核。
Workflow:匯出兩個 CSV、跑 reconcile.py 做快速配對、把標記的項目交給 agent 分析、複審最終報告。一千筆交易的總時間,從幾小時的手動試算表瞇眼比對,降到五分鐘以內。
長期對帳的話,把腳本接上每月的 cron job,讓 agent 只處理例外。月結從「配對任務」變成「複審任務」。週五晚上你可以做點別的事,不用再盯著試算表。
Ready to streamline your terminal workflow?
Multi-terminal drag-and-drop layout, workspace Git sync, built-in AI integration, AST code analysis — all in one app.