A full-stack, Splitwise-style expense-sharing app for splitting trip and group costs with friends β with multi-payer expenses, three split algorithms, real-time balance netting, Google sign-in, and email invites.
π Live demo: https://<your-vercel-url>.vercel.app Β· API: splitup-yxut.onrender.com/api/health
- What it does
- Why it's interesting (technical highlights)
- Tech stack
- Architecture
- The money engine
- Data model
- Security
- API overview
- Local setup
- Testing
- Deployment
- Project layout
SplitUp lets a group of people track shared spending and figure out who owes whom, without spreadsheets or manual arithmetic.
- Groups & friends β create a group with a shareable join code / QR, or track one-on-one expenses directly with a friend (no group required).
- Add an expense with a description, amount, date, one or multiple payers, and a split across any subset of members.
- Three split modes β split equally, by exact amounts, or by percentage / shares.
- Live balances β the app nets every expense and payment into a single "you are owed / you owe" figure per person, per group and overall.
- Settle up β record a payment between two members; balances move toward zero and both parties get a notification.
- Activity feed β a per-user notification stream (dismissible independently by each side).
- Auth β email/password and Google Sign-In, with password reset and email-based group/friend invitations.
A few things that go beyond a standard CRUD app:
- Shared money-math module used by both client and server. The split/balance algorithms live in a framework-agnostic
/sharedpackage imported by the React frontend (for instant live previews) and the Express backend (as the authoritative recompute). The preview a user sees is guaranteed to match what gets stored, because it's literally the same code. - All money is integer paise β never floats. Every amount is stored and computed as integer hundredths of a rupee, eliminating an entire class of floating-point rounding bugs. Conversions round explicitly rather than relying on
parseFloat(x) * 100. - Exact splits via the largest-remainder (Hamilton) method. Percentage and share splits distribute leftover paise to the largest fractional remainders, so per-person amounts always sum exactly to the total β no lost or phantom paise.
- Multi-payer expenses. An expense can be fronted by several people at once; balances are computed from the actual money each person paid vs. their resolved share.
- A defended zero-sum invariant. The group balance engine asserts that the sum of every member's balance is exactly
0β every paise owed by someone is owed to someone. A broken invariant throws instead of silently corrupting balances. - Unit-tested core. The split, pairwise, and balance logic is covered by ~50 tests using Node's built-in
node:testrunner.
| Concern | Choice |
|---|---|
| UI library | React 19 |
| Build tool / dev server | Vite 8 (dev proxy to the API, no CORS setup needed locally) |
| Styling | Tailwind CSS 4 (via @tailwindcss/vite), Inter font, Material Symbols |
| Server state / caching | TanStack React Query |
| Routing | React Router 7 (route guards for protected / public-only routes) |
| Local/UI state | React Context (auth, toasts, expense-form wizard) |
| HTTP | Axios (single configured instance, cookies sent with every request) |
| Extras | QR code generation for group invites |
| Linting | oxlint |
| Concern | Choice |
|---|---|
| Runtime / framework | Node.js (ESM) + Express 5 |
| ORM | Prisma 7 with the Neon serverless adapter |
| Database | PostgreSQL (Neon, serverless) |
| Auth | JWT in an httpOnly cookie (Bearer-header fallback for tooling) |
| Passwords | bcryptjs hashing |
| OAuth | Google Sign-In via google-auth-library |
| Nodemailer (Gmail SMTP) for resets & invites | |
| Middleware | cors (credentialed allowlist), cookie-parser, dotenv |
Monorepo run with concurrently Β· Prisma Migrate (14 versioned migrations) Β· deployed on Vercel (frontend) + Render (API) + Neon (database).
ββββββββββββββββββββββββββββ βββββββββββββββββββββββββββββ
β Client (Vercel) β β API (Render) β
β React 19 + Vite + β HTTPS β Express 5 (ESM) β
β Tailwind + React Query β ββββββΊ β REST /api/* β
β β cookie β JWT (httpOnly) auth β
β axios (withCredentials)β ββββββ β Prisma 7 β
βββββββββββββ¬βββββββββββββββ ββββββββββββββ¬βββββββββββββββ
β β
β imports the same β Neon serverless adapter
βΌ /shared money module βΌ
βββββββββββββββββββββββββββ ββββββββββββββββββββ
β shared/ (pure JS) β β PostgreSQL β
β splitMath Β· balances Β· β β (Neon) β
β pairwise (+ tests) β ββββββββββββββββββββ
βββββββββββββββββββββββββββ
The backend follows a clean routes β controllers β utils layering, with a single requireAuth middleware gatekeeping protected routes. Prisma is the single source of truth for the schema; migrations are versioned and committed.
The /shared module is the core of the app and is deliberately UI- and DB-agnostic.
splitMath.js β resolves any expense into canonical per-person shares that sum exactly to the total:
splitEquallyβ divides evenly and hands leftover paise to the first N participants deterministically (10000 / 3 β [3334, 3333, 3333]).splitExactβ passes through caller-supplied amounts (validated to sum correctly).splitByWeightsβ percentage/shares split using the largest-remainder method so paise always reconcile.validateSplitβ rejects negative shares and reports human-readable over/short amounts.rupeesToPaise/paiseToRupeesβ safe, rounding-correct conversions.
balances.js β nets an entire group into one signed balance per member (credit each payer what they fronted, debit each participant their share, then apply settlements), and asserts the sum-to-zero invariant.
pairwise.js β computes the one-on-one "friend view": how much one specific person owes another from a single expense, correctly attributing multi-payer contributions.
12 Prisma models backing a normalized PostgreSQL schema. Highlights:
Userβ supports both password and Google accounts (nullablepasswordHash, uniquegoogleId).Group+GroupMemberβ many-to-many membership via a join table, each group with a unique join code.ExpensewithExpensePayment(multi-payer) andExpenseShare(per-person owed amount). A nullablegroupIdmodels Splitwise-style direct expenses between two friends.Settlementβ a payment from one member to another that moves both balances toward zero.Friendshipβ a single directed row per pair withpending/acceptedstatus.Activityβ a denormalized, per-user notification feed; one payment writes two rows so each side can dismiss independently without affecting balances.PasswordResetToken&Invitationβ store only the SHA-256 hash of the emailed token, with expiry and single-use semantics.
- Passwords hashed with bcrypt; never stored or logged in plaintext.
- JWTs live in httpOnly cookies (not
localStorage), reducing XSS token theft. In production the cookie isSecure+SameSite=Noneto work across the Vercel/Render origins. - Reset & invite tokens are emailed as raw secrets but persisted only as SHA-256 hashes, with short expiry, single-use enforcement, and email-locking β a database leak can't forge a working link.
- CORS uses an explicit credentialed origin allowlist (no wildcard, which is invalid with credentials anyway).
- Google tokens are verified server-side against the app's own client ID.
A REST API of ~40 endpoints under /api, grouped by feature:
| Router | Responsibilities |
|---|---|
/api/auth |
register, login, Google sign-in, logout, forgot/reset password, get/update profile |
/api/groups |
create/list/get/update/delete, join, leave, invite (code + email), members, expenses, balances, settlements |
/api/expenses |
get / edit / delete a single expense |
/api/friends |
list, request/accept/decline/remove, email invite, per-friend expenses & settlements |
/api/settlements |
delete a settlement |
/api/invitations |
fetch & accept an emailed invite by token |
/api/activity |
fetch & dismiss notifications |
A /api/health endpoint reports liveness.
Prerequisites: Node.js (with npm) and a free Neon Postgres project.
# 1. Install root, server, and client dependencies
npm run install:all
# 2. Configure the backend
cp server/.env.example server/.env
# β set DATABASE_URL & DIRECT_URL (Neon), JWT_SECRET, GOOGLE_CLIENT_ID,
# Gmail SMTP creds, and CLIENT_URL
# 3. (Optional) configure the frontend
cp client/.env.example client/.env.local # leave VITE_API_URL unset for local dev
# 4. Create the database tables
cd server && npx prisma migrate dev && cd ..
# 5. Run backend + frontend together
npm run dev- Frontend β http://localhost:5173
- Backend β http://localhost:5000
In dev, Vite proxies /api/* to the backend, so there's no CORS configuration to worry about locally.
The money-critical logic is unit-tested with Node's built-in test runner:
npm testCovers equal / exact / percentage splits, rounding and remainder distribution, pairwise friend balances, and the group-level sum-to-zero invariant.
| Layer | Host | Notes |
|---|---|---|
| Frontend | Vercel | SPA rewrite in vercel.json; VITE_API_URL points the build at the Render API |
| Backend | Render | Express server; COOKIE_SECURE=true for cross-origin auth cookies |
| Database | Neon | Serverless Postgres; migrations applied via Prisma |
.
βββ client/ # React 19 + Vite + Tailwind frontend
β βββ src/
β βββ api/ # axios instance + typed API wrappers
β βββ components/ # auth, expense, group, layout, ui
β βββ context/ # Auth / Toast / ExpenseForm providers
β βββ lib/ # formatting, datetime, balance helpers
β βββ pages/ # route-level screens
βββ server/ # Express 5 API
β βββ prisma/ # schema + 14 versioned migrations
β βββ src/
β βββ controllers/ # auth, group, expense, settlement, friend, invitation
β βββ routes/ # feature routers
β βββ middleware/ # requireAuth (JWT gatekeeper)
β βββ utils/ # jwt, password, email, tokens, join codes
β βββ index.js # app entry
βββ shared/ # framework-agnostic money engine (+ tests)
β βββ splitMath.js
β βββ balances.js
β βββ pairwise.js
βββ package.json # monorepo scripts (runs both apps together)
Built with React, Express, Prisma, and PostgreSQL. Money handled in integer paise, because floats and money don't mix.