What's actually going on in here.

FINC isn't a budgeting app that stores a list of transactions. It's a proper double-entry accounting system that happens to be aimed at one person's money. This page walks through how a bank-statement PDF becomes a reconciled net-worth figure — every stage, in plain language.

The data model Storing money Ingest & dedup Categorization Matching & refunds Reconciliation Investments & FX Scheduling & jobs Backups Auth & the demo The stack

The foundation

Everything is double-entry postings

The whole system rests on four tables. Accounts form a chart (assets, liabilities, income, expenses, equity) in the usual 1xxx–5xxx numbering. A transaction is an event with a date and description. Each transaction owns two or more postings — signed amounts against accounts that must sum to exactly zero. Instruments and trades extend this for stocks and funds.

That "sum to zero" rule is the heart of it. Money is never created or destroyed on a screen — it only ever moves from one account to another. When you spend ฿420 on groceries from your bank:

AccountTypeDebitCredit
Groceries (5xxx)Expense฿420.00
Bank — checking (1xxx)Asset฿420.00
Balanced฿420.00฿420.00

The expense account grows, the asset account shrinks, and the two legs cancel. Every figure on every page — net worth, cashflow, expenses by category — is just a different way of summing these postings.

Why bother with double-entry? Because it makes whole classes of bug impossible. A balance can't drift without a matching entry somewhere, transfers between your own accounts net to zero instead of looking like spending, and the books are self-checking: if postings don't sum to zero, something is wrong and the app says so.

Precision

Money is stored as integer satang, never floats

Floating-point numbers can't represent 0.1 exactly, which is a quiet disaster for money — rounding errors accumulate until balances drift by a few satang and reconciliation fails for no visible reason. So FINC stores every amount as an integer number of satang (1 baht = 100 satang). ฿420.00 is 42000. Arithmetic stays exact; formatting to "฿420.00" happens only at the very edge, for display.

Getting data in

Parsers turn PDFs into postings — safely re-runnable
Cloud Drive Parser (per bank) dedup_key check Postings

Statements live in a cloud Drive folder. A scheduled job pulls new files and routes each to a parser keyed to that institution's format — bank PDFs, credit-card statements, brokerage trade confirmations, email receipts. Each parser extracts rows and emits balanced transactions.

The critical property is idempotency: importing the same file twice must change nothing. FINC enforces this at two levels —

This is why a crashed or half-finished import is always safe to re-run, and why two statements covering overlapping periods don't double-count the transactions they share.

Making sense of it

A self-learning categorization engine

A raw statement line like SQ *MERCHANT 4815 BKK means nothing to a report. A rules engine maps descriptions to categories: each rule is a pattern (contains / regex), a target category, and a priority. The highest-priority matching rule wins.

Crucially it learns. When you re-categorize a transaction by hand, the app can persist that as a new rule, so the next import of the same merchant is categorized automatically. Over time the ledger gets tidier with no extra effort — and because rules are data, not hard-coded if statements, you can inspect and edit them on the Rules page.

Connecting the dots

Transfer matching and refunds

Move ฿10,000 from your savings to your checking and it shows up on both statements — once as money leaving, once as money arriving. Naïvely that looks like ฿10,000 of spending plus ฿10,000 of income. The matcher finds these two halves (same amount, opposite sign, near dates) and links them into a single transfer, so internal movement nets to zero and never pollutes expense or income totals.

Refunds work the same way in reverse. When money comes back — a returned purchase, your share of a split bill, a reimbursed trip cost — it's booked against the original expense category rather than as income, so the category nets out to what you actually spent. There's a dedicated view to confirm each refund is matched to the right original charge.

Manual overrides stick. Once you've matched, ignored, or categorized something by hand, the automatic passes never overwrite your decision. Automation proposes; you dispose.

Trust, but verify

Reconciliation replays the ledger against statements

A ledger can be internally balanced and still be wrong — a missing transaction, a bad opening balance, a fat-fingered import. The only real check is against the bank's own number. So for each account, FINC sums all postings up to a statement's end date and compares that computed balance to the closing balance the bank printed on that statement.

Match to the satang → ✓. Any drift → ✗ with the exact delta, so a problem surfaces instead of hiding behind a plausible-looking total. This runs continuously, not just at import time. Opening balances are themselves a posting (against an Equity "Opening Balances" account), so even an account that pre-dates your data reconciles cleanly.

Holdings

FIFO cost basis, realized P&L, and a USD wallet

Trades are parsed from brokerage confirmations into instruments (a stock/fund) and trade rows (buys and sells). Valuation uses FIFO — first lot in is the first lot out — to compute cost basis, so a partial sale relieves the oldest shares first and the remaining position keeps the correct average cost.

The FX subtlety. Foreign holdings are bought in USD but the ledger's truth is THB. Realized gain must compare THB proceeds to THB cost — comparing a USD sale price against a THB cost basis is a unit mismatch that can make a profitable sale look like a loss. FINC converts at the transaction's own FX rate, and foreign cash flows through a dedicated USD wallet account so currency movement is explicit rather than smeared across baht balances.

Keeping itself current

Scheduled jobs, live activity, and lock-safety

A scheduler runs a chain a couple of times a day: pull new statements → refresh prices → back up → reconcile. The UI shows a live activity feed — a running-job indicator in the top bar and a streaming log so you can watch a run as it happens, not just see its aftermath.

Not losing it

Continuous backup with Litestream

The database is a single SQLite file, which sounds fragile until you put Litestream in front of it: it streams every change to object storage, giving continuous backup and point-in-time restore. Combined with the scheduled .backup snapshots and a "back up before any bulk change" discipline, the data is recoverable even though it lives in one file on one small container.

Who gets to see what

One token locks the real data; the demo is mock-only

The boundary is simple and strict: everything under /api requires a token (sent as a cookie or bearer header) — no exceptions, no demo bypass. The owner visits once with the token in the URL, which sets a long-lived secure cookie and then drops the token from the address bar.

The public pages — this page, the overview, and the demo — carry no real data, so they're served without auth. The demo you can open runs the exact same frontend, but a mock layer intercepts every API call and answers from a bundled fictional dataset. It never touches the real backend, which is why a demo visitor sees a complete, working app and zero real numbers.

The stack, end to end

What runs where
Frontend
React + TypeScript + Vite, Chart.js. A multi-tab dashboard (Overview, Investments, Ledger, Accounts, Insights, Settings) with a mobile tab bar.
Backend
Python + FastAPI. Statement parsers, the rules/categorization engine, the matcher, FIFO valuation, reconciliation, and reporting — exposed as a token-locked /api.
Database
SQLite in WAL mode. A real double-entry schema: accounts, transactions, postings, instruments, trades — amounts as integer satang.
Hosting
A single container on Google Cloud Run. Scales to zero; the whole app is one image.
Durability
Litestream streams the SQLite file to object storage for continuous backup and point-in-time restore.
Automation
A scheduled chain pulls statements, refreshes prices, backs up and reconciles; a live feed surfaces runs and logs.
Demo
A Mock Service Worker layer answers API calls from bundled fictional fixtures — the real app shell, none of the real data.