Club Finance is a full-stack expense-splitting platform built for student organizations, robotics teams, clubs, and volunteer groups. Members can create groups, log shared expenses, split costs equally or by custom amounts, and see exactly who owes whom — with automatic debt simplification to minimize the number of payments needed to settle up.
- Authentication — register, log in, update your profile; sessions secured with JWT
- Groups — create a group, share its ID for others to join, or invite members by email; three roles: Owner, Treasurer, Member
- Expenses — add expenses with a description, amount, and category (Food, Travel, Equipment, Events, Other)
- Splitting — choose Equal, Custom dollar amounts, or Percentage splits per expense
- Balance engine — calculates each member's net balance in real time
- Debt simplification — converts complex multi-person debts into the minimum set of payments
- Settlements — record payments and mark debts as paid
| Layer | Technology |
|---|---|
| Backend | Node.js, Express, TypeScript |
| ORM | Prisma |
| Database | PostgreSQL |
| Validation | Zod |
| Auth | JWT + bcryptjs |
| Frontend | React, TypeScript, Vite |
| Styling | Tailwind CSS |
| State | Zustand |
| Routing | React Router v6 |
| HTTP client | Axios |
Make sure you have the following installed before starting:
git clone https://github.com/your-username/club-finance.git
cd club-financeThe project ships with a docker-compose.yml that starts a local Postgres instance. From the project root:
docker compose up -dThis starts Postgres on port 5432 with:
- Database:
club_finance - Username:
clubfinance - Password:
clubfinance
To verify it's running:
docker compose psTo stop it later:
docker compose downcd backend
cp .env.example .envOpen .env and fill in the values:
DATABASE_URL="postgresql://clubfinance:clubfinance@localhost:5432/club_finance?schema=public"
JWT_SECRET="replace-this-with-a-long-random-string"
PORT=4000Tip: generate a strong
JWT_SECRETwithopenssl rand -base64 48
npm install
npx prisma migrate dev --name initprisma migrate dev does three things automatically:
- Reads
prisma/schema.prismaand creates all database tables - Runs
prisma generateto produce the typed Prisma client - Seeds the migration history
You should see output like:
Your database is now in sync with your schema.
✔ Generated Prisma Client
npm run devThe API will be running at http://localhost:4000. You can verify it with:
curl http://localhost:4000/health
# {"status":"ok"}Open a new terminal, then:
cd ../frontend
npm install
npm run devThe app will open at http://localhost:5173.
By default, the frontend expects the API at
http://localhost:4000/api. To use a different URL, create a.envfile in thefrontend/folder:VITE_API_URL=https://your-api-domain.com/api
club-finance/
├── docker-compose.yml # Local Postgres
├── README.md
│
├── backend/
│ ├── prisma/
│ │ └── schema.prisma # Database schema (User, Group, Expense, Settlement…)
│ └── src/
│ ├── index.ts # Express app entry point
│ ├── prisma.ts # Prisma client singleton
│ ├── middleware/
│ │ ├── auth.ts # JWT verification
│ │ └── group.ts # Group membership + role checks
│ ├── routes/
│ │ ├── auth.ts # /api/auth/*
│ │ ├── groups.ts # /api/groups/*
│ │ ├── expenses.ts # /api/groups/:id/expenses/*
│ │ └── balances.ts # /api/groups/:id/balances + settlements
│ └── utils/
│ ├── split.ts # Equal / custom / percentage split logic
│ └── balance.ts # Balance engine + debt simplification
│
└── frontend/
└── src/
├── App.tsx # Routes
├── api/client.ts # Axios instance with auth header
├── store/auth.ts # Zustand auth store (persisted to localStorage)
├── components/
│ ├── Navbar.tsx
│ └── ProtectedRoute.tsx
└── pages/
├── LoginPage.tsx
├── RegisterPage.tsx
├── ProfilePage.tsx
├── GroupsPage.tsx # Create / join / list groups
└── GroupDetailPage.tsx # Expenses, splits, balances, settlements
All endpoints except POST /api/auth/register and POST /api/auth/login require:
Authorization: Bearer <token>
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/auth/register |
Create a new account |
| POST | /api/auth/login |
Log in and receive a token |
| GET | /api/auth/me |
Get the current user |
| PATCH | /api/auth/me |
Update name or password |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/groups |
Create a group (you become OWNER) |
| GET | /api/groups |
List groups you belong to |
| GET | /api/groups/:groupId |
Group details with member list |
| POST | /api/groups/:groupId/join |
Join a group by ID |
| POST | /api/groups/:groupId/invite |
Add a user by email (Treasurer+) |
| PATCH | /api/groups/:groupId/members/:memberId |
Change a member's role (Treasurer+) |
| DELETE | /api/groups/:groupId/members/:memberId |
Remove a member (Treasurer+) |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/groups/:groupId/expenses |
Add an expense |
| GET | /api/groups/:groupId/expenses |
List all expenses |
| GET | /api/groups/:groupId/expenses/:id |
Get a single expense |
| PUT | /api/groups/:groupId/expenses/:id |
Edit an expense (recomputes shares) |
| DELETE | /api/groups/:groupId/expenses/:id |
Delete an expense |
Example request body for a new expense:
{
"description": "Pizza Night",
"amount": 60,
"category": "Food",
"paidById": "<user-uuid>",
"splitType": "EQUAL",
"participants": ["<user-a-uuid>", "<user-b-uuid>", "<user-c-uuid>"]
}For CUSTOM split, add "customAmounts": { "<uuid>": 30, "<uuid>": 20, "<uuid>": 10 }.
For PERCENTAGE split, add "percentages": { "<uuid>": 50, "<uuid>": 25, "<uuid>": 25 }.
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/groups/:groupId/balances |
Net balance per member |
| GET | /api/groups/:groupId/settlements/suggested |
Minimum payments to settle all debts |
| GET | /api/groups/:groupId/settlements |
Settlement history |
| POST | /api/groups/:groupId/settlements |
Record a payment |
| PATCH | /api/groups/:groupId/settlements/:id |
Mark a settlement as paid |
Given this example:
- Aryaa paid $60 for pizza, split equally between Aryaa, Anastasiia, and Seb ($20 each)
The engine calculates:
Aryaa: +60 (paid) − 20 (her share) = +40 ← is owed
Sebastian: −20 ← owes
Anastasiia: −20 ← owes
Debt simplification then produces:
Anastasiia → Aryaa $20
Sebastian → Aryaa $20
With multiple expenses and people, the algorithm collapses all debts into the fewest possible payments using a greedy max-creditor / max-debtor approach.
The project is ready to deploy on the following recommended services:
| Part | Service | Notes |
|---|---|---|
| Frontend | Vercel | Connect the frontend/ folder; set VITE_API_URL in environment variables |
| Backend | Railway | Connect the backend/ folder; set DATABASE_URL, JWT_SECRET, PORT |
| Database | Neon | Copy the connection string into DATABASE_URL; run npx prisma migrate deploy |
prisma generate fails with a network error
Prisma needs to download engine binaries from binaries.prisma.sh. If you're in a restricted network environment, ensure that domain is reachable, or use the PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING=1 env variable as a last resort.
Port already in use
Change PORT in backend/.env or kill the conflicting process with lsof -ti:4000 | xargs kill.
CORS errors in the browser
The backend allows all origins in development. If you deploy and see CORS errors, set the CORS_ORIGIN environment variable to your frontend URL and update src/index.ts accordingly.
Database connection refused
Make sure Docker is running and the Postgres container is healthy: docker compose ps. If the port is already taken by a local Postgres install, change the host port in docker-compose.yml (e.g. "5433:5432") and update DATABASE_URL to match.
Future features planned for subsequent versions:
- Receipt upload and OCR auto-parsing (Cloudinary + Tesseract)
- Email notifications (invitations, debt reminders, monthly reports)
- Real-time updates via WebSockets
- Analytics dashboard with charts (Recharts)
- Budget tracking and projected spend
- Expense approval workflow for treasurers
- Activity feed
- CSV / Excel / PDF export
- Full-text search across expenses and members
- Organization layer (e.g. a university containing multiple clubs)
- Progressive Web App (installable on mobile)
- AI assistant ("how much did we spend on robot parts?")
- Admin dashboard