Skip to content

Latest commit

Β 

History

8 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

SettleUp

Shared-expense settlement for flatmates and trips, built around one guarantee: concurrent writes can never make a group's money wrong.

Everyone has been in the WhatsApp group where six people paid for different things, nobody knows who owes whom, and the UPI screenshots are lost somewhere above the memes. SettleUp works out the net position of each person and reduces the whole tangle to the smallest possible set of payments.

The interesting part is not the CRUD. It is what happens when three flatmates add expenses at the same time while a fourth is halfway through settling up.


Contents


What makes this non-trivial

Problem Approach
Splitting β‚Ή100 three ways loses a paisa Integer paise everywhere + largest-remainder distribution
Concurrent writes losing each other's updates SELECT … FOR UPDATE on the group row, serialising balance writes
Acting on balances that changed while you were looking Version-stamped settlement plans, rejected with 409 when stale
Retrying a request on a flaky mobile network Idempotency-Key with a Redis fast path and a unique index as the real guarantee
A monthly rent job that runs twice Generation keyed by (template, period) with a unique constraint
Proving any of the above actually works 62 tests, including 200 concurrent writes asserting the ledger still sums to zero

Running it

No infrastructure required β€” just a JDK 17:

mvn spring-boot:run "-Dspring-boot.run.profiles=local"

(The quotes matter in PowerShell, which otherwise mangles the -D argument.)

This runs on an in-memory database with in-memory stand-ins for Redis. The API comes up on http://localhost:8080, Swagger UI on http://localhost:8080/swagger-ui.html.

The real stack (PostgreSQL + Redis):

docker compose up --build

Frontend:

cd frontend
npm install
npm run dev          # http://localhost:5173, proxies /api to :8080

The marketing site is at /; the working app lives behind sign-in at /app.

Tests:

mvn test             # 62 tests, no Docker needed

Architecture

One Spring Boot service. Not microservices β€” there is no independent scaling or deployment story here that would justify the operational cost, and splitting a ledger across service boundaries would turn a database transaction into a distributed one for no benefit.

                     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   React + TS  ──────▢  REST API (Spring Boot 3.4)  β”‚
                     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                     β”‚
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β–Ό                      β–Ό                      β–Ό
      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
      β”‚ ExpenseServiceβ”‚     β”‚SettlementServ. β”‚     β”‚ RecurringJob   β”‚
      β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚                      β”‚                      β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                         β–Ό
                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                β”‚ GroupLockService β”‚  ← every balance write passes through here
                β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                         β”‚ SELECT … FOR UPDATE
                         β–Ό
                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                β”‚  LedgerService   │───────▢│ balance_entry        β”‚
                β”‚  (only writer)   β”‚        β”‚ (append-only)        β”‚
                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

   Redis ──▢ idempotency keys Β· version-keyed balance cache Β· rate limiting

Package layout

com.settleup
β”œβ”€β”€ common/       Money (paise arithmetic), errors, health
β”œβ”€β”€ auth/         JWT issue/verify, registration, login
β”œβ”€β”€ group/        Groups, membership, GroupLockService  ← the locking protocol
β”œβ”€β”€ expense/      Expenses, splits, SplitType
β”œβ”€β”€ ledger/       BalanceEntry + LedgerService          ← the only ledger writer
β”œβ”€β”€ settlement/   DebtSimplifier + settlement recording
β”œβ”€β”€ recurring/    Monthly template generation
β”œβ”€β”€ infra/        Redis adapters, each with an in-memory twin
β”œβ”€β”€ notification/ Async fan-out on a bounded pool
└── export/       CSV export

The four design decisions that matter

1. Money is integer paise, never a float

0.1 + 0.2 != 0.3 in binary floating point. In a ledger, a fraction of a paisa of drift per expense compounds into balances that visibly don't add up.

Everything is a long count of paise. The API accepts decimal rupees (what a person types) and converts once, at the edge.

Splitting is the subtle part. β‚Ή100 across three people is β‚Ή33.333…, and naive rounding gives β‚Ή33.33 each β€” which is β‚Ή99.99. The missing paisa has to go somewhere, so the largest-remainder method gives it to the first participant by id:

10000 paise / 3  β†’  3334, 3333, 3333   (sums to exactly 10000)

Deterministic, and it sums back to the total for any amount and any group size. There is a parameterised test asserting exactly that across a range of awkward values.

2. Balances are derived from an append-only ledger, not stored

There is no balance column anywhere. Every expense and every settlement appends signed deltas to balance_entry, and a user's balance is SUM(delta_paise).

Aditi pays β‚Ή900 for dinner, split 3 ways:

  balance_entry:  (Aditi, +90000)   ← she is out of pocket
                  (Aditi, -30000)   ← her own share
                  (Rohan, -30000)
                  (Sana,  -30000)
                  ─────────────────
                  sum       0       ← always

Three things fall out of this for almost no added complexity:

  • No lost updates. Inserts never read-modify-write a running total, so nothing can be clobbered.
  • A free audit trail. Rows are only ever inserted. Every paisa traces to the expense or settlement that caused it.
  • A testable invariant. A group's entries must sum to exactly zero. One assertion catches virtually every money bug, and it is asserted after every concurrency test.

LedgerService is the only component that may write entries, and it refuses to persist a batch that doesn't net to zero β€” so a bad split fails at the moment it would corrupt the ledger, not weeks later.

3. One lock per group, taken the same way every time

Every balance-changing write begins by taking PESSIMISTIC_WRITE on the group row:

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select g from ExpenseGroup g where g.id = :id")
Optional<ExpenseGroup> findByIdForUpdate(@Param("id") Long id);

Group-level granularity is a deliberate trade. Groups are small and low-traffic, so serialising writes within one group costs nothing in practice, and in exchange every balance mutation is trivially serialised. Because the lock is always taken on a single, always-present row, lock ordering is consistent by construction and the protocol cannot deadlock against itself.

All of it lives in one class, GroupLockService, so "how do we stay consistent?" has exactly one answer to audit.

4. Settlement plans carry a version

A settlement plan is a snapshot. If Rohan opens the settle-up screen and Aditi adds an expense before he taps pay, the amounts on his screen are fiction.

Every balance write bumps the group's version. The plan reports the version it was computed from, the client echoes it back, and a mismatch is rejected:

POST /api/groups/1/settlements
{ "fromUserId": 2, "toUserId": 1, "amount": "500.00", "expectedVersion": 1 }

HTTP 409 Conflict
{
  "message": "settlement plan was computed at version 1 but the group is now at version 2;
              re-fetch the plan and try again",
  "meta": { "expectedVersion": 1, "currentVersion": 2 }
}

The frontend handles this by refreshing and telling the user what happened, rather than failing opaquely.

That same version keys the balance cache β€” entries are stored under settleup:balances:{groupId}:v{version}. Because a write produces a new version, it also produces a new cache key, so the stale entry becomes unreachable without an explicit delete. That sidesteps the classic invalidation race where a writer deletes a key a moment before a slow reader writes a stale value back.


Debt simplification

Raw expense history produces a mess of pairwise IOUs. Only each person's net position matters, so the individual debts are discarded and a minimal payment set is re-derived from the net balances using two heaps:

  1. Put creditors in one max-heap, debtors in another.
  2. Match the largest creditor with the largest debtor, transfer min(credit, debt).
  3. Push back whatever remains and repeat.

Each transfer zeroes out at least one person, so n non-zero participants need at most n - 1 payments, in O(n log n).

Before                          After
──────                          ─────
Aditi  is owed  β‚Ή300            Rohan pays Aditi β‚Ή100
Rohan  owes     β‚Ή100            Sana  pays Aditi β‚Ή200
Sana   owes     β‚Ή200

This is a greedy heuristic, not a proven optimum β€” and that is a deliberate choice. Finding the true minimum number of transfers is NP-hard: it generalises subset-sum, because you would have to find every subset of participants whose balances cancel to zero and settle each independently. Exponential work to maybe save one payment in a five-person flat is not a good trade. The n - 1 bound is already a large reduction and it runs instantly.

A randomised test runs 500 trials of up to 13 people, asserting the plan both respects the n - 1 bound and drives every balance to exactly zero.


Testing

62 tests, no Docker required

MoneyTest ......................... 21   splitting, rounding, rupee/paise conversion
DebtSimplifierTest .................. 8   including 500 randomised trials
LedgerIntegrationTest ............... 7   ledger correctness across split types
SettlementServiceIntegrationTest .... 7   plans, stale rejection, idempotency
RecurringExpenseJobTest ............. 6   re-run safety, month clamping
ApiFlowTest ......................... 9   end-to-end HTTP, auth, status codes
ConcurrentBalanceIntegrityTest ...... 4   the ones that matter

The suite runs against an in-memory database with in-memory adapters standing in for Redis, so mvn test works on any machine with a JDK and CI needs no service containers. The concurrency tests still exercise genuine row-level locking.

The concurrency tests

All four hammer a single group from 50 threads, released simultaneously by a CountDownLatch so they genuinely collide rather than trickling in and accidentally serialising themselves.

200 concurrent expenses on one group. Uses β‚Ή99.99 split 5 ways β€” deliberately indivisible, so any sloppy rounding is amplified 200Γ—. Asserts every expense persisted exactly once, that balances land on their precise expected values, and that the ledger sums to zero.

Expenses and settlements interleaved. 200 mixed operations racing on the same group; the ledger must stay balanced throughout.

Version integrity. 60 concurrent writes must advance the group version by exactly 60 β€” no skipped increments, no clobbered ones. This is the test that caught the bug described below.

Idempotency under contention. 40 identical requests sharing one Idempotency-Key must produce exactly one expense.


API

All endpoints except /api/auth/* and /api/health require Authorization: Bearer <jwt>. Write endpoints accept an optional Idempotency-Key header.

Method Path Purpose
POST /api/auth/register Create an account
POST /api/auth/login Obtain a token
GET /api/groups Groups you belong to
POST /api/groups Create a group
POST /api/groups/{id}/members Add someone by email (rate limited)
GET /api/groups/{id}/balances Net position per member
POST /api/groups/{id}/expenses Record an expense
GET /api/groups/{id}/expenses Expense history
GET /api/groups/{id}/settlement-plan Minimal payment set + version
POST /api/groups/{id}/settlements Record a payment
GET /api/groups/{id}/ledger-check Live sum-to-zero check
GET /api/groups/{id}/export/expenses.csv CSV export
POST /api/groups/{id}/recurring Standing monthly expense
GET /api/users/me/summary Position across all groups

GET /api/groups/{id}/ledger-check exposes the core invariant at runtime:

{ "groupId": 1, "imbalancePaise": 0, "balanced": true }

Point a load generator at a group, poll this, and watch it stay at zero.


Deployment

The whole stack is defined in render.yaml as a Render Blueprint β€” API, Postgres, Redis and the frontend, provisioned together.

Deploy it

  1. Push this repo to GitHub.
  2. In Render: New β†’ Blueprint, select the repo, click Apply.
  3. Wait for the first build (~5 min; the Docker build compiles the jar).
  4. Lock down CORS once the frontend URL exists β€” on settleup-api β†’ Environment, set CORS_ALLOWED_ORIGINS to the exact static-site origin, e.g. https://settleup-web.onrender.com.

That last step is worth doing. The blueprint ships a https://*.onrender.com default because the frontend's hostname isn't known until it deploys, and the two services can't reference each other without creating a dependency cycle the Blueprint can't resolve.

What gets created

Resource Type Why
settleup-api Docker web service The Spring Boot API
settleup-db Postgres The ledger
settleup-cache Key Value Idempotency keys, balance cache, rate limits
settleup-web Static site Marketing page + app, on the CDN

The frontend is a static site, not served by the API. Static sites are always-on and edge-cached, so the landing page loads instantly even while a free-tier API instance is still waking from cold start. Only the app behind sign-in waits.

Configuration notes

  • JWT_SECRET is generated by Render, never committed. Rotating it just invalidates existing tokens.
  • Postgres credentials are injected as parts, not as a single URL. Render emits a libpq-style postgres:// connection string, which the JDBC driver won't accept.
  • The API binds $PORT. Render assigns it at runtime; a hard-coded 8080 would be unreachable.
  • healthCheckPath is /api/health, which actually queries the database β€” an instance that can't reach Postgres never receives traffic.
  • The frontend's API host is baked in at build time via VITE_API_HOST. Blueprints expose hostnames without a scheme and don't support interpolation, so the client prepends https://.
  • Free Postgres expires after 30 days. Fine for a demo; upgrade or re-provision after that.

Validating the blueprint before pushing

cd frontend
node scripts/validate-render.mjs

Checks render.yaml against Render's published JSON schema, so a bad enum or misplaced field fails locally instead of halfway through a Blueprint sync.


A bug the test suite caught

Worth writing down, because it is the kind of bug that ships silently.

The original version bumped the group version the idiomatic JPA way β€” take the row lock, then ask Hibernate to force-increment the @Version field:

entityManager.lock(group, LockModeType.OPTIMISTIC_FORCE_INCREMENT);   // does nothing here

This compiles, throws nothing, logs nothing β€” and never increments the version. Hibernate treats lock modes as a hierarchy and refuses to downgrade. The entity was already held at PESSIMISTIC_WRITE, which outranks OPTIMISTIC_FORCE_INCREMENT, so the request was silently discarded.

Nothing would have looked broken. Balances would have stayed correct. But the version would have been frozen at 0 forever, which means the balance cache would have served stale data indefinitely and stale-plan detection would never have fired β€” the exact feature it existed to provide, quietly disabled.

The versionAdvancesExactlyOncePerWrite test failed with expected: 60 but was: 0.

The fix is an explicit, unambiguous increment under the lock:

@Modifying(flushAutomatically = true)
@Query("update ExpenseGroup g set g.version = g.version + 1 where g.id = :id")
int incrementVersion(@Param("id") Long id);

Less magical, and it actually happens. The obvious-looking alternative β€” dirty-checking a lastUpdated timestamp β€” has the same failure mode: two commits in the same clock tick write an identical value, Hibernate detects no change, and the version silently stalls again.


Frontend

A marketing site at / and the working app behind sign-in at /app, sharing one design system.

Stack: React 18 + TypeScript + Vite, Tailwind CSS, Framer Motion, React Router.

Vite rather than Next.js: this is a single-page app talking to a Spring Boot API, so there is no server-rendering or routing work for Next to do. Adding it would mean a second runtime to deploy for no benefit the API does not already provide.

Design system. Semantic CSS custom properties (--surface, --border, --text) flip between light and dark, so components never branch on theme β€” bg-[rgb(var(--surface))] is correct in both. Theme is resolved from localStorage, falling back to the OS preference, and applied by an inline script before first paint so a light-mode visitor never sees a dark flash.

Motion. One shared easing curve across the whole site; mixing eases is the fastest way to look unconsidered. Scroll reveals fire once and slightly early, so content reads as already in motion rather than popping in. The ambient gradients are pure CSS on the compositor, costing no main-thread time. Everything collapses under prefers-reduced-motion β€” drifting gradients are genuinely unpleasant for people with vestibular disorders, not merely distracting.

The demo section is real. src/lib/settle.ts is the same greedy two-heap simplification the backend runs, ported to the browser. Add an expense on the landing page and the balances and payment plan actually recompute β€” including the live ledger check, which must read β‚Ή0.00 no matter what you type. The page cannot claim a result the algorithm does not produce.

Performance. The dashboard and auth screens are lazy-loaded, so a first-time visitor on the marketing page does not download the app shell before seeing the hero.

dist/index.html           1.65 kB β”‚ gzip   0.78 kB
dist/assets/index.css    37.69 kB β”‚ gzip   7.21 kB
dist/assets/Login.js      3.18 kB β”‚ gzip   1.32 kB
dist/assets/Dashboard.js 14.95 kB β”‚ gzip   4.29 kB
dist/assets/index.js    375.57 kB β”‚ gzip 119.54 kB

Verification. npm run visual-check drives a real browser against the production build: screenshots in both themes and at mobile width, asserts every section renders, fails on any console error, and exercises the demo and FAQ accordion to prove they are not just decorative. It needs a local Chrome or Edge:

cd frontend
npm run build
npx vite preview --port 4173     # in one terminal
npm run visual-check             # in another

What I would do next

Things deliberately left out, and why:

  • Multi-currency trips. Needs historical FX rates stored per expense, since converting at read time makes past balances shift around. Real work, not a config flag.
  • Partial settlements against a specific plan transfer. The data model supports it; the UX question of what happens to the rest of the plan is the hard part.
  • Group-level sharding of the lock. Only worth it if a single group ever became hot enough to matter, which for a flat-share app it will not.
  • Push notifications. The async fan-out, threading and failure isolation are built and tested; only the delivery provider is stubbed.

Stack

Backend β€” Java 17 Β· Spring Boot 3.4 Β· Spring Data JPA / Hibernate Β· PostgreSQL Β· Redis Β· Spring Security (JWT) Β· JUnit 5

Frontend β€” React 18 Β· TypeScript Β· Vite Β· Tailwind CSS Β· Framer Motion

Ops β€” Docker Β· GitHub Actions Β· Render

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages