Reconcile Page¶
Route: /reconcile
Last Updated: 2026-08-12
Template: dockerfiles/heezy-finance/templates/reconcile.html
Backend: dockerfiles/heezy-finance/app.py
What It Does¶
Shows all credit card debit transactions for a date range alongside their receipt match status. Lets you filter by status, view receipt details, and reassign categories inline. Category changes feed directly into spending graphs and budget analytics on other pages.
Data Flow¶
bank_statements (account_type='credit_card')
└── bank_transactions (type='debit', category NOT IN ('Transfer','Income'))
└── matched against receipts by (date, amount) exact key
The match is done in Python at request time — no join column. A (date, amount) tuple is built from both tables and compared. If a receipt key matches a transaction key, status = matched. If the transaction has no category (or Other), status = uncategorized. Otherwise unmatched.
Receipts in the date range with no matching transaction are returned as orphaned_receipts.
DB Tables¶
| Table | Relevant Columns |
|---|---|
bank_transactions |
id, date, description, merchant_normalized, amount, category, type, statement_id, reconcile_acknowledged |
bank_statements |
statement_id, account_type |
receipts |
id, date_ts, merchant, merchant_normalized, total, category, payment_type, bank_transaction_id, filename |
API Endpoints¶
| Method | Endpoint | Purpose |
|---|---|---|
GET |
/api/reconcile/page-data?start=&end= |
Main data load — transactions + match status + orphaned receipts + stats |
POST |
/api/reconcile/update-category |
Update bank_transactions.category for a transaction |
POST |
/api/reconcile/acknowledge |
Toggle bank_transactions.reconcile_acknowledged |
GET |
/api/receipt-detail/<id> |
Receipt metadata + line items + image URL for the modal |
POST |
/api/receipts/<id>/items |
Add a line item |
PUT / DELETE |
/api/receipt-items/<id> |
Edit or remove a line item |
POST |
/api/bank-transactions/<id>/stub-receipt |
Create an empty receipt attached to a transaction, for hand-entering items when no photo exists |
DELETE |
/api/receipt/<id> |
Delete an orphaned receipt outright, image included. Proxies to the receipts service |
GET |
/api/categories |
Full category list |
The page also POSTs directly to https://receipts.heezy.info/upload to attach a photo to a
transaction, bypassing the finance app entirely.
Endpoints this page does not use¶
/api/reconcile/summary, /unmatched-bank, /unmatched-receipts, /auto-match, /link,
/unreconciled-summary, and /unreconciled-by-category are all still routed in app.py but nothing
on this page calls them. They served the older reconcile panel on the Spending page.
/api/reconcile/auto-match and /link are the only path that writes receipts.bank_transaction_id,
so with the panel gone, that column is effectively unpopulated.
/api/reconcile/page-data response shape¶
{
"period": { "start": "YYYY-MM-DD", "end": "YYYY-MM-DD" },
"stats": { "total": 0, "matched": 0, "unmatched": 0, "uncategorized": 0, "orphaned_receipts": 0 },
"transactions": [
{
"id": 123,
"date": "YYYY-MM-DD",
"description": "...",
"merchant_normalized": "...",
"amount": 42.00,
"category": "Restaurant",
"reconcile_acknowledged": false,
"status": "matched|unmatched|uncategorized",
"receipt_id": "b3f1c8e2-...",
"receipt_has_image": true
}
],
"orphaned_receipts": []
}
/api/reconcile/update-category request¶
Category must be a value from CATEGORIES or the endpoint returns 400. CATEGORIES is defined in
categories.py and re-exported by app.py. See Categories.
Stat Cards / Filters¶
The four stat cards (Total, Matched, Unmatched, Uncategorized) are clickable filters. Filtering is client-side — currentData is held in memory and renderTxns() re-filters on each click without a new API call.
Acknowledged rows are hidden by default. "Show acknowledged" toggle re-renders from the same in-memory data.
Inline Category Editing¶
Each category cell renders a <select> populated at page load from GET /api/categories, which
returns the keys of CATEGORY_ICONS in categories.py. On change, updateCategory() fires
POST /api/reconcile/update-category. On success it patches currentData.transactions in place and
recalculates the uncategorized stat count — no page reload.
Deleting an Orphaned Receipt¶
Orphaned receipts (in the date range, no matching transaction) carry a 🗑 button. The confirm dialog names the merchant, amount and date, then the row is removed and the page reloads.
It calls DELETE /api/receipt/<id>, which proxies to the receipts service at
RECEIPTS_URL (default http://receipts:8080) rather than deleting from the shared receipts
table directly.
Never delete a receipt row from heezy-finance directly
The receipts service owns the NFS volume; heezy-finance does not mount it. A local DELETE
drops the row and strands the image, and an image with no row is exactly what
reconcile_orphans.py mistakes for a lost receipt and re-imports. That is how 36 duplicate
receipts appeared on 2026-08-12. See
Receipts / Recovering orphaned images.
An unreachable receipts service returns 502 with Nothing was deleted. rather than falling back
to a local delete — half a delete is worse than none. If the receipts service removed the row but
could not remove a file, the response carries orphaned_files and the UI surfaces it.
Duplicate receipts are most easily spotted here: two rows, same date and amount, different categories. Reconcile shows them even when they are easy to miss in the receipts list, because they sit apart there when their upload dates differ.
Receipt Modal¶
Clicking the receipt icon on a matched row calls GET /api/receipt-detail/<id>, which queries:
receiptsfor metadata (merchant, date, total, category, payment_type)receipt_itemsfor line items (description, quantity, unit_price, total_price)- Constructs an S3 presigned URL if
filenameis set
Matching Logic¶
Match key: (date::date, ROUND(amount, 2)) — exact match only, no fuzzy logic. If two receipts share the same date+amount, the first one wins. Transactions with category IN ('Transfer', 'Income') are excluded from the query entirely.