Skip to content

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

{ "id": 123, "category": "Food & Grocery" }

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:

  • receipts for metadata (merchant, date, total, category, payment_type)
  • receipt_items for line items (description, quantity, unit_price, total_price)
  • Constructs an S3 presigned URL if filename is 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.