Skip to content

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

{ "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.


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:

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