Build B-05

Aashan

Two of your accounts saw the same lunch. Every expense app in India adds them up and tells you that you spent twice as much as you did.

Status
BUILDING
Year
2026
Domain
Fintech / Privacy / Backend
Stack
Python / FastAPI / PostgreSQL / SQLite / Supabase / scikit-learn

Artifact

  SMS      PDF / CSV      AA feed      cash
   |           |             |           |
   '-----------+------+------+-----------'
                      |
        SOURCE OBSERVATION        append-only
        what one source claims it saw
        never edited · never deleted
                      |
        MATCH   amount · account · date window
                ±1 day UPI · ±3 days card
                      |
           hit  ->  attach as another witness
          miss  ->  open a new transaction
                      |
        CANONICAL TRANSACTION LEDGER
        Decimal money · one timezone
        every read is served from here

An observation is what one source claims it saw. The ledger is what the observations agree on.

Signal chain

  1. 01 · InputFour sourcesSMS, PDF and CSV statements, an Account Aggregator feed, and cash typed in by hand.
  2. 02 · ProcessingSource observations, append-onlyWhat one source claims it saw. Never edited, never deleted. The raw bytes are destroyed after extraction.
  3. 03 · ProcessingMatch and reconcileExact amount, same account, a date window (one day for UPI, three for card), then merchant similarity.
  4. 04 · OutputCanonical ledgerOne transaction, however many sources witnessed it. Decimal money, one timezone, every total traceable to its evidence.

System architecture

Not documented yet.

Implementation

I kept a spreadsheet of my own spending. It decayed after thirteen days. So I did what everyone does and installed an expense tracker, and it told me I had spent ₹696 on a lunch that cost ₹348, because the bank SMS and the monthly statement had both seen it, and the app had no way to know they were the same meal.

That is not a bug in one app. It is the default outcome of the schema every one of them uses.

The line of DDL that causes it

The obvious way to stop duplicates is a uniqueness constraint:

UNIQUE (user_id, source, external_id)

The source is part of the key. So an SMS saying ₹348 and a statement row saying ₹348 can never collide. Not because the matching is weak, but because the schema has made agreement structurally impossible. Two rows. ₹696. I wrote this constraint myself before I understood what it did.

A transaction is not a row you insert

It is a conclusion you reach.

So the primitive is not the transaction, it is the observation: what one source claims it saw, written to an append-only log that is never edited and never deleted. A new observation is matched against the ledger on amount, account and a date window. A hit attaches it to the existing transaction as another witness. A miss creates a new one.

Deduplication stops being drop the row and becomes add a witness, and one mechanism now handles overlapping statements, cross-source confirmation, provenance and conflict, instead of four special cases.

It is event sourcing, without needing Kafka to say so. Money events arrive at human speed, and the projection lives in the same Postgres transaction as the event, so merges are atomic instead of eventually consistent.

The payoff is that why does this say ₹348 has an answer. Two observations, matched on a one-day UPI window, statement won on amount, SMS kept its timestamp. Nothing about the number is unexplainable.

Every number ships with its denominator

An average is a fraction, and a fraction with an unstated denominator is a guess wearing a decimal point. If the app has SMS coverage for nine days of a thirty-day month, the honest output is ₹412 a day over nine days, not a monthly total that silently treats twenty-one missing days as zero spending.

So the system tracks coverage windows per account per source, and there is a screen whose entire job is to show what the app cannot see. It is the least marketable feature in the product and the reason the rest of it can be trusted.

Where the model is, and where it is not

Deterministic, always: every total, average and percentage. Duplicate matching. Transfer detection. Recurring detection. Balance validation.

Model-assisted and reversible: turning BHARATPE9847 into the tea shop. First-pass category for a description no rule has seen. Phrasing facts that have already been computed.

Never: computing an amount, deciding what is a duplicate, writing to the ledger.

Categorisation runs on a pinned local embedding model rather than an API, because a classifier you bill per transaction cannot be unit-tested, cannot be asserted on in CI, and ships every merchant name you have ever paid to someone else’s server. A cosine score can be pinned to a number in a test. A vendor’s new model version cannot silently re-categorise last month.

The parts that are hostile

A bank statement is attacker-controllable input the moment you accept uploads: decompression bombs, malformed xref tables, nested streams. That parser runs in its own process: non-root, no network, hard memory and CPU and wall-clock caps, read-only filesystem, tmpfs scratch, allowed to die. It is the only process boundary in the system that earns its keep. The password for an encrypted statement lives in memory and never touches disk.

What I got wrong

I audited the whole thing before a user could, and wrote the results down unedited. Totals were computed per import, so importing ₹500 and then ₹300 displayed ₹300. The review queue existed, worked, and was unreachable from any real code path. Row-level security was enabled but not FORCEd while the application owned the tables, which means it was decorative. The webhook had no authentication of any kind. Money was NUMERIC(18,2) in Postgres, float in Python and REAL in SQLite, so development and production could disagree on a rounded total.

All of those are fixed now, each in its own migration and its own commit. None of them were hard. They were the difference between a demo and something that handles other people’s money.

Measurements

Python, tracked
8,576 lines
Test files
21
Schema migrations
13
Users
1 (me)

Failures and iterations

No iterations logged yet.

Result

No result stated yet.

Current status

BUILDING

Next step

Not decided yet.

Credits

Hari
Design, schema, backend
Claude Opus 5
Co-author on commits

Links

Source is private while the product is unreleased. Everything above comes from the architecture and the self-audit, not from a repository you cannot read.

guest@hari: ~esc to close