Add bank reconciliation workflow and templates

New capability for importing bank transactions (Schwab JSON), matching
against existing ledger entries, creating missing transactions, and
reconciling balances. Includes two new Grist tables (Reconciliations,
BankRules), two bank-import templates, and reference documentation.
This commit is contained in:
2026-02-19 20:26:48 -05:00
parent 357d1aadc8
commit fdb813d647
8 changed files with 630 additions and 4 deletions

View File

@@ -12,6 +12,7 @@ Detailed code examples for common accounting operations.
- [Pay Bill via Owner](#pay-bill-via-owner-reimbursement)
- [Reimburse Owner](#reimburse-owner)
- [Batch Operations](#batch-operations)
- [Bank Reconciliation](#bank-reconciliation)
## Create a Vendor
@@ -298,5 +299,124 @@ For bank fees, minor expenses without vendor invoices:
| 23 | 3001 | Owner's Investment |
| 24 | 3002 | Owner's Draws |
| 25 | 4001 | Service Revenue |
| 26 | 4010 | Interest Income |
| 30 | 5020 | Bank & Merchant Fees |
| 36 | 5080 | Software & Subscriptions |
## Bank Reconciliation
Import bank transactions, match against ledger, create missing entries, and reconcile.
For full reference: see [reconciliation.md](reconciliation.md)
### Phase 1: Import Bank File (Schwab JSON)
```python
# Read and parse the bank export file
import json
with open("path/to/schwab_export.json") as f:
data = json.load(f)
# Parse each PostedTransaction
for txn in data["PostedTransactions"]:
# Date: "MM/DD/YYYY" -> Unix timestamp
# Amount: parse "$X,XXX.XX" strings; Deposit = positive, Withdrawal = negative
# Empty string = no amount (skip)
pass
```
### Phase 2: Match Against Ledger
```python
# Fetch existing TransactionLines for Checking (id=14)
# MUST use get_records, not sql_query (Transaction is reserved word)
get_records("TransactionLines", filter={"Account": [14]})
# Get transaction details for date matching
sql_query("SELECT id, Date, Description, Status FROM Transactions WHERE id IN (...)")
# Match criteria: exact amount AND date ±3 days AND not already matched
# Deposits match Debit > 0 (money into checking = debit to asset)
# Withdrawals match Credit > 0 (money out of checking = credit to asset)
```
### Phase 3: Create Missing Transactions
```python
# For unmatched deposits (e.g., interest income):
add_records("Transactions", [{
"Date": 1738195200,
"Description": "Bank interest income",
"Reference": "Interest Paid",
"Status": "Cleared",
"Memo": "Auto-imported from bank statement"
}])
# Returns: {"inserted_ids": [txn_id]}
# Dr Checking, Cr Interest Income
add_records("TransactionLines", [
{"Transaction": txn_id, "Account": 14, "Debit": 0.01, "Credit": 0, "Memo": "Bank interest"},
{"Transaction": txn_id, "Account": 26, "Debit": 0, "Credit": 0.01, "Memo": "Bank interest"}
])
# For unmatched withdrawals (e.g., ATM owner draw):
add_records("Transactions", [{
"Date": 1739750400,
"Description": "ATM withdrawal - owner draw",
"Reference": "P421164 88 ESSEX STREET NEW YORK",
"Status": "Cleared",
"Memo": "Auto-imported from bank statement"
}])
# Dr Owner's Draws, Cr Checking
add_records("TransactionLines", [
{"Transaction": txn_id, "Account": 24, "Debit": 102.00, "Credit": 0, "Memo": "ATM withdrawal"},
{"Transaction": txn_id, "Account": 14, "Debit": 0, "Credit": 102.00, "Memo": "ATM withdrawal"}
])
# Optionally save a BankRule for auto-categorization:
add_records("BankRules", [{
"Account": 14,
"Pattern": "Interest Paid",
"MatchType": "Contains",
"OffsetAccount": 26,
"TransactionDescription": "Bank interest income",
"IsActive": true
}])
```
### Phase 4: Reconcile
```python
# Update matched existing transactions to Cleared status
update_records("Transactions", [
{"id": existing_txn_id, "fields": {"Status": "Cleared"}}
])
# Calculate cleared balance
lines = get_records("TransactionLines", filter={"Account": [14]})
txn_ids = list(set(l["fields"]["Transaction"] for l in lines))
cleared = sql_query(f"SELECT id FROM Transactions WHERE Status = 'Cleared' AND id IN (...)")
cleared_ids = set(r["id"] for r in cleared)
cleared_balance = sum(
l["fields"]["Debit"] - l["fields"]["Credit"]
for l in lines
if l["fields"]["Transaction"] in cleared_ids
)
# Create Reconciliation record
add_records("Reconciliations", [{
"Account": 14,
"StatementDate": 1739750400, # date of last bank entry
"StatementBalance": 1398.01,
"ClearedBalance": cleared_balance,
"Difference": 1398.01 - cleared_balance,
"Status": "Completed", # if Difference == 0
"StartedAt": 1739923200, # today
"CompletedAt": 1739923200,
"Notes": "Reconciled against Schwab export"
}])
# Verify
sql_query("SELECT * FROM Transactions WHERE IsBalanced = false")
```