Skip to content

Latest commit

Β 

History

17 Commits

Folders and files

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

Repository files navigation

SplitUp πŸ’Έ

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.

React Vite Tailwind CSS Node Express Prisma PostgreSQL

πŸ”— Live demo: https://<your-vercel-url>.vercel.app Β· API: splitup-yxut.onrender.com/api/health


Table of contents


What it does

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.

Why it's interesting (technical highlights)

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 /shared package 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:test runner.

Tech stack

Frontend

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

Backend

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
Email Nodemailer (Gmail SMTP) for resets & invites
Middleware cors (credentialed allowlist), cookie-parser, dotenv

Tooling & infra

Monorepo run with concurrently Β· Prisma Migrate (14 versioned migrations) Β· deployed on Vercel (frontend) + Render (API) + Neon (database).


Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   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 money engine

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.


Data model

12 Prisma models backing a normalized PostgreSQL schema. Highlights:

  • User β€” supports both password and Google accounts (nullable passwordHash, unique googleId).
  • Group + GroupMember β€” many-to-many membership via a join table, each group with a unique join code.
  • Expense with ExpensePayment (multi-payer) and ExpenseShare (per-person owed amount). A nullable groupId models 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 with pending / accepted status.
  • 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.

Security

  • 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 is Secure + SameSite=None to 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.

API overview

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.


Local setup

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

In dev, Vite proxies /api/* to the backend, so there's no CORS configuration to worry about locally.


Testing

The money-critical logic is unit-tested with Node's built-in test runner:

npm test

Covers equal / exact / percentage splits, rounding and remainder distribution, pairwise friend balances, and the group-level sum-to-zero invariant.


Deployment

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

Project layout

.
β”œβ”€β”€ 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.

About

Expense-sharing app for groups & trips. React 19, Express 5, Prisma, Neon Postgres. Multi-payer splits, live balances, JWT + Google auth.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages