Squared Up
Splitwise got worse and it never understood UPI. Squared Up is the version I wanted. The interesting part is not the app — it is that the money math is a framework-free Python package, proved out before Django ever sees it.
The problem
Group expenses are a rounding problem wearing a social costume. ₹1,000 split three ways is not ₹333.33 each — it is 33,334 paise and 33,333 and 33,333, and whoever gets the spare paise has to be decided by a rule that gives the same answer every time or the balances quietly drift apart. Splitwise handles this and hides it.
What it does not handle is the settlement. In India the money moves over UPI, and every existing app stops at “you owe Aarav ₹412” and leaves the chase to WhatsApp. So the product bet is small — carry the split all the way to a payable intent — and the engineering bet is that the money core has to be provably right, because a payments app that is 99% right is a payments app people stop trusting.
How it works
backend/domain/ is a plain Python package with no Django import anywhere in it. It takes numbers and returns numbers. Models, serialisers and views call into it and never re-derive a split themselves, so there is exactly one place in the system where money math happens — and exactly one place to test it.
Everything above that is an ordinary modular monolith: phone-OTP to JWT, group membership, expenses, balances, settlements. The frontend is a separate Vite app that talks to /api/v1 and installs as a PWA. docker compose up --build brings up Postgres, Django behind gunicorn, and an nginx-served frontend in one command.
frontend-monzo/ React · Vite · Tailwind · installable PWA
│
│ fetch /api/v1 (JWT bearer, Idempotency-Key)
▼
backend/ Django + DRF — modular monolith
core/ auth · directory · groups · expenses
balances · turn · settlements · AI entry
│
│ calls into, never re-implements
▼
backend/domain/ pure Python — zero Django imports
money.py integer paise, no floats anywhere
split.py largest-remainder allocation
balance.py balances derived from the ledger
simplify.py debt minimisation
turn.py "Turn to Pay" fairness rotation
upi.py upi://pay intent builder
▲
└── tests/test_vectors.py every worked spec example
tests/test_property.py Hypothesis invariants
(both run with no database)Decisions worth defending
- 01
Money is an integer, or it is a bug.
Every amount is paise, as an
int, end to end: request body, domain, ORM column, response. No floats, noDecimal, no currency library. Rounding is not the database's problem or the serialiser's problem — it happens once, insplit.py, by largest remainder: leftover paise go to the largest fractional remainders, ties broken(frac DESC, user_id ASC)so identical input always produces an identical allocation.SUM(paid) = SUM(owed) = amountis asserted before anything is persisted. - 02
Balances are derived, never stored.
There is no
balancecolumn to drift out of sync. Balances are computed from the expense ledger on read, and debt simplification runs on top of that. It costs a query and it removes a whole class of bug — the one where a half-failed write leaves two people disagreeing about what they owe and no way to tell which of them is right. - 03
The server never touches the money.
Settlement builds a
upi://pay?…intent and hands it to the phone. The user pays in their own UPI app, comes back, and confirms; the counterparty confirms or disputes. Squared Up is a ledger with a deep link, not a payment processor — which is the difference between shipping this and needing a licence to ship it. Manual settlement is the fallback when someone has no VPA on file. - 04
Outsiders get 404, not 403.
Every group-scoped read and write requires active membership, and a non-member gets 404 rather than 403 so group ids cannot be found by enumeration. Expenses are visible to their group; personal ones only to the creator and participants. A settlement can only be confirmed or disputed by the two parties in it.
GET /usersreturns only people you already know. The whole matrix is pinned intests/test_authz.pyrather than living in a reviewer's memory. - 05
Idempotency keys on every mutation.
Mobile networks retry, and a retried “add expense” must not create a second expense. An
Idempotency-Keyheader on mutating requests means a replay returns the stored response and creates nothing new. That is invariant I9, and it is a test rather than an assumption. - 06
Invited placeholders and real accounts are the same user.
Invites are by phone number, normalised to E.164 and deduped, so the placeholder you invited last month and the account that later signs in with that number resolve to one identity. Get this wrong and balances fork silently — the worst failure mode a splitting app has, because nobody notices until settlement.
Proving it works
This is the part I would want to read first if I were reviewing someone else's payments code.
- Spec vectors. The Core Domain Specification carries a §14 table of worked examples. Every one is a passing test in
tests/test_vectors.py, so the spec and the code cannot disagree quietly. - Hypothesis property tests.
tests/test_property.pygenerates arbitrary amounts and participant sets and asserts what must hold for all of them: the allocation sums to the total, nobody is allocated negative paise, and re-running an allocation is stable. - Invariants I1–I9 are enforced in
core/services.pyand asserted before persist, not audited afterwards. - The authorization matrix is a test file —
tests/test_authz.py— not a convention that erodes. - The domain tests need no database.
pytest tests/test_vectors.py tests/test_property.pyruns in about a second against pure functions, which is why nobody is ever tempted to skip them. - The API tests do use Postgres, and they cover authz, membership, itemisation, categories, export, insights and hardening as separate suites rather than one god-file.
What shipped, what did not
- Phone-OTP → JWT auth, registration and onboarding
- All five split types, multiple payers, expense create / edit / delete
- Balances, debt simplification and “Turn to Pay” fairness rotation
- UPI settle with confirm-after-pay and manual fallback
- Friends, group membership, activity feed, natural-language entry and auto-categorise
- Offline-ready PWA across 10+ screens, Dockerised deployment
- Real SMS and Google/Apple OAuth — the seams exist (
core/sms.py, stub buttons); a Twilio provider is already wired behind an env var - Splitwise import, FX conversion, realtime WebSocket sync
- Hindi i18n strings, receipt OCR, bank linking, native apps