Files
grist-accounting/references/reconciliation.md
Bill Ballou fdb813d647 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.
2026-02-19 20:26:48 -05:00

7.5 KiB

Bank Reconciliation

Import bank transactions, match against ledger entries, create missing transactions, and reconcile balances.

Contents

Bank File Parsing

Schwab JSON Format

Schwab exports a JSON file with this structure:

{
  "FromDate": "MM/DD/YYYY",
  "ToDate": "MM/DD/YYYY",
  "PendingTransactions": [],
  "PostedTransactions": [
    {
      "CheckNumber": null,
      "Description": "Interest Paid",
      "Date": "MM/DD/YYYY",
      "RunningBalance": "$1,500.01",
      "Withdrawal": "",
      "Deposit": "$0.01",
      "Type": "INTADJUST"
    }
  ]
}

Parsing Rules

  1. Date: Convert MM/DD/YYYY → Unix timestamp. Example: 01/14/20261736812800
  2. Amount: Parse $ strings, remove commas. Deposit = positive, Withdrawal = negative. Empty string = no amount.
  3. Sort: By date ascending after parsing
  4. Zero-amount entries: Flag for user review (e.g., INTADJUST with no Deposit/Withdrawal)
  5. Statement ending balance: Extract from last entry's RunningBalance (chronologically latest)

Date Conversion Reference

Use Jan 1, 2026 = 1767312000 as base, add days * 86400.

Date String Unix Timestamp Calculation
12/31/2025 1767225600 Jan 1 - 1 day
01/14/2026 1768435200 Jan 1 + 13 days
01/30/2026 1769817600 Jan 1 + 29 days
02/17/2026 1771372800 Jan 1 + 47 days

Type Field Mapping

Bank Type Suggested Offset Account Notes
INTADJUST Interest Income (4010, id=26) Bank interest payments
TRANSFER Owner's Investment (3001, id=23) Incoming transfers — confirm with user
ATM Owner's Draws (3002, id=24) ATM withdrawals
CHECK Prompt user Check payments
ACH Prompt user ACH debits
DEBIT Prompt user Debit card transactions

Transaction Matching

Algorithm

For each bank transaction, find a matching ledger entry:

  1. Fetch all TransactionLines for the bank account using get_records (not sql_queryTransaction is a reserved word)
  2. For each bank transaction with an amount:
    • Find ledger entries where: exact amount match AND date within ±3 days AND not already matched
    • Deposits match TransactionLines where Debit > 0 (money into checking = debit to asset)
    • Withdrawals match TransactionLines where Credit > 0 (money out of checking = credit to asset)
  3. Mark each bank entry: Matched, Unmatched, or Skipped (no amount)
  4. Mark each ledger entry: Matched or Outstanding

Matching with get_records

# Fetch all TransactionLines for Checking Account (id=14)
get_records("TransactionLines", filter={"Account": [14]})

# Then fetch transaction details for date comparison
# Get unique transaction IDs from the lines, then:
sql_query("SELECT id, Date, Description, Status FROM Transactions WHERE id IN (id1, id2, ...)")

Match Classification

Bank Entry Ledger Entry Classification
Has match Has match Matched — both confirmed
No match Unmatched (bank only) — needs new ledger entry
No amount Skipped — review manually
No match Outstanding — in ledger but not cleared by bank

Creating Missing Transactions

For each unmatched bank transaction:

1. Check BankRules

get_records("BankRules", filter={"Account": [14], "IsActive": [true]})

For each rule, check if the bank description matches the pattern:

  • Contains: pattern is a substring of bank description
  • Starts With: bank description starts with pattern
  • Exact: bank description equals pattern

If a rule matches, auto-fill the offset account and description.

2. Determine Offset Account

Use the Type field mapping above as a suggestion. Always confirm with user for ambiguous types.

3. Create Transaction

Use the bank-import templates:

  • Depositsbank-import-deposit.json (Dr Checking, Cr Offset)
  • Withdrawalsbank-import-expense.json (Dr Offset, Cr Checking)

All imported transactions use Status = "Cleared" since the bank confirms them.

4. Optionally Save BankRule

If user agrees, create a BankRule for future auto-categorization:

add_records("BankRules", [{
    "Account": 14,
    "Pattern": "Interest Paid",
    "MatchType": "Contains",
    "OffsetAccount": 26,
    "TransactionDescription": "Bank interest income",
    "IsActive": true
}])

Balance Reconciliation

Calculate Cleared Balance

After matching and creating missing transactions:

# Get all TransactionLines for Checking Account
lines = get_records("TransactionLines", filter={"Account": [14]})

# Get transaction IDs and filter for Cleared status
txn_ids = [unique transaction IDs from lines]
cleared_txns = sql_query("SELECT id FROM Transactions WHERE Status = 'Cleared' AND id IN (...)")

# Sum: Debit - Credit for cleared lines only
cleared_balance = sum(line.Debit - line.Credit for line in lines if line.Transaction in cleared_txn_ids)

Reconciliation Record

# Create reconciliation record
add_records("Reconciliations", [{
    "Account": 14,
    "StatementDate": statement_date_timestamp,
    "StatementBalance": statement_balance,
    "ClearedBalance": cleared_balance,
    "Difference": statement_balance - cleared_balance,
    "Status": "In Progress",
    "StartedAt": today_timestamp,
    "Notes": "Reconciling against Schwab export"
}])

Finalization

  • Difference = $0: Update Status to "Completed", set CompletedAt
  • Difference ≠ $0: Report discrepancy, list outstanding items, offer options:
    1. Review unmatched items
    2. Create adjusting entry
    3. Save progress and return later

Queries

Cleared Balance for Account

# Use get_records to avoid Transaction reserved word issue
lines = get_records("TransactionLines", filter={"Account": [14]})
# Then filter by cleared transactions and sum Debit - Credit

Outstanding Items (in ledger, not cleared)

SELECT t.id, t.Date, t.Description, t.Status,
       tl.Debit, tl.Credit
FROM TransactionLines tl
JOIN Transactions t ON tl.Transaction = t.id
WHERE tl.Account = 14
  AND t.Status != 'Cleared'
ORDER BY t.Date

Reconciliation History

SELECT r.id, r.StatementDate, r.StatementBalance,
       r.ClearedBalance, r.Difference, r.Status
FROM Reconciliations r
WHERE r.Account = 14
ORDER BY r.StatementDate DESC

Unmatched Bank Rules

get_records("BankRules", filter={"Account": [14], "IsActive": [true]})

Edge Cases

Zero-Amount Entries

Some bank entries (e.g., INTADJUST with empty Deposit and Withdrawal) have no monetary value. Skip these during matching and creation but report them to the user.

Duplicate Amounts

When multiple bank transactions have the same amount, use date proximity as the tiebreaker. If still ambiguous, present options to the user.

Re-imports

If the same bank file is imported again, the matching phase will find existing ledger entries for previously imported transactions. Only truly new entries will be unmatched.

Partial Reconciliation

If the user can't resolve all differences in one session, save the Reconciliation with Status="In Progress". Resume later by loading the record and continuing from Phase 2.