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 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:
| Account | Type | Debit | Credit |
|---|---|---|---|
| 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.
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.
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 —
dedup_key derived from its date, amount, and description. A row whose key already exists is recognized as the same line — even if it arrives via a different statement (e.g. an overlapping month).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.
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.
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.
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.
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.
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.
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.
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.
/api.