← Raghav Gupta
§ Case study 02 / 05Shipped · open source

Squared Up

UPI-native expense splitting

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.

Stack
Django · DRF · PostgreSQL · React PWA
Money
Integer paise · no floats
Proved with
Spec vectors + Hypothesis

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

  1. 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, no Decimal, no currency library. Rounding is not the database's problem or the serialiser's problem — it happens once, in split.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) = amount is asserted before anything is persisted.

  2. 02

    Balances are derived, never stored.

    There is no balance column 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.

  3. 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.

  4. 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 /users returns only people you already know. The whole matrix is pinned in tests/test_authz.py rather than living in a reviewer's memory.

  5. 05

    Idempotency keys on every mutation.

    Mobile networks retry, and a retried “add expense” must not create a second expense. An Idempotency-Key header 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.

  6. 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.

What shipped, what did not

Built
  • 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
Deferred
  • 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