Reconcile Page¶
Route: /reconcile
Last Updated: 2026-08-19
Template: dockerfiles/heezy-finance/templates/reconcile.html
Backend: dockerfiles/heezy-finance/app.py
What It Does¶
Shows all credit card debit transactions for the selected period 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 |
GET |
/api/payment-methods |
Accounts a receipt can be paid from, closed ones included |
POST |
/api/receipts/<id>/payment-method |
Set receipts.account_id / receipts.payment_type |
The page also POSTs directly to https://receipts.heezy.info/upload to attach a photo to a
transaction, bypassing the finance app entirely.
Routed but uncalled¶
/api/reconcile/summary, /unmatched-bank, /unmatched-receipts, /auto-match, /link,
/unreconciled-summary, and /unreconciled-by-category are routed in app.py, and no page in the
app calls any of them. They are reachable only by hand.
/api/reconcile/link is worth keeping for that reason: it is the only way to attach a receipt to a
specific transaction, or to detach one, by posting bank_id: null.
receipts.bank_transaction_id is not dependent on those routes. /api/reconcile/page-data
writes it: loading a period persists every match it worked out, filling NULLs only and never taking
a receipt from a transaction that already claims one. That is what turns item splits on for a
transaction, so a category reading as empty in Spending usually means the period has not been opened
on this page yet.
/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.
Period Selection¶
The same year + month control the Spending page uses, one period rather than two. p1Year /
p1Month are populated from GET /api/available-periods, and picking either reloads the page
immediately — there is no Apply button.
Both pages persist to localStorage.heezyP1, so the month picked on one is the month the other
opens on. A first visit with nothing stored lands on the newest month that has credit card data.
Changing the year resets the month to Full Year, because the new year may hold no data for the
month that was selected.
The shared implementation is static/js/period-picker.js. This page previously had From/To date
inputs defaulting to the last 60 days.
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.
Payment Method¶
Every row carries a 💳 chip under the merchant name. What it means depends on which table it is in, and only one of the two is editable.
| Table | Chip shows | Editable |
|---|---|---|
| Transactions | the card from bank_statements, resolved to an accounts row by institution + type + last4 |
no |
| Receipts Without a Matching CC Charge | receipts.account_id, falling back to the coarse receipts.payment_type |
yes |
A transaction's card is the bank's own record of where the charge landed, so there is nothing to
choose. A receipt's is a stored field nobody had a way to set: 103 of them said credit with no
account at all.
Clicking an editable chip swaps it for a <select> in place, the same shape as the category editor
beside it. The list is every credit card, checking, savings and money-market account, then the coarse
types (Cash, Check, Other, and "which one unknown" for card and bank). Choosing one POSTs to
/api/receipts/<id>/payment-method and the page reloads.
The same picker is the Payment field in the receipt modal, which is how a receipt already matched to a transaction gets its card set — its own chip is not on screen in that case.
This changes what the receipt is allowed to match
find_receipt_match() will not link a receipt that names a card to a transaction on a different
one. Setting the right card sharpens the match; setting the wrong one hides a real charge, which
is worse than leaving it unset. A receipt with no account stays eligible for anything.
Because of that, saving triggers a full load() rather than repainting the chip — both tables
can move.
Setting an account also rewrites payment_type to credit or debit to match the account's type,
so the chip's fallback label can never contradict the card that is set. Choosing a coarse type
clears the account.
Closed cards still name themselves¶
The accounts lookup for a transaction orders by is_active DESC rather than filtering on it.
AND a.is_active left the 399 charges on Chase ••4279 with no account row at all — drawn as
"card not set" on a card bank_statements names perfectly well, and unable to confirm a receipt
against it. Closed cards appear in the picker too, marked (closed).
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, account_id)receipt_itemsfor line items (description, quantity, unit_price, total_price)- Constructs an S3 presigned URL if
filenameis set
The Payment field is the picker described in Payment Method, not a label.
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.