A Splitwise-inspired expense splitting backend built with Node.js + Express + Prisma for the core API, and Django for admin panel and analytics. Both services share the same PostgreSQL (Neon) database.
React (frontend β coming soon)
|
β
Node.js (Express) ββββββββ PostgreSQL (Neon)
Core API, Auth, β
Business Logic, |
Expense Splitting (same DB, read-only)
|
Django (Admin + Analytics) βββββββββ
Admin Panel, Reports,
Analytics API
Why two services?
- Node.js handles high-performance transactional API work
- Django was chosen for its superior admin interface and Python's data ecosystem for analytics β both pointing to the same PostgreSQL instance
| Layer | Technology |
|---|---|
| Core API | Node.js, Express, TypeScript |
| ORM (Node) | Prisma |
| Admin + Analytics | Django, Django REST Framework |
| Database | PostgreSQL (Neon) |
| Auth | JWT (jsonwebtoken + bcrypt) |
| Deployment | Render (both services) |
/
βββ node-api/ # Node.js service
β βββ prisma/
β β βββ schema.prisma
β βββ src/
β β βββ controller/
β β β βββ auth.controller.ts
β β β βββ group.controller.ts
β β β βββ expense.controller.ts
β β β βββ settlement.controller.ts
β β β βββ balance.controller.ts
β β βββ routes/
β β β βββ auth.routes.ts
β β β βββ group.routes.ts
β β β βββ expense.routes.ts
β β β βββ settlement.routes.ts
β β β βββ balance.routes.ts
β β βββ middlewares/
β β β βββ auth.middleware.ts
β β βββ utils/
β β β βββ balance.ts
β β βββ lib/
β β β βββ prisma.ts
β β βββ app.ts
β β βββ index.ts
β βββ .env
β βββ package.json
β
βββ django-admin/ # Django service
βββ core/
β βββ settings.py
β βββ urls.py
βββ analytics/
β βββ models.py
β βββ views.py
β βββ urls.py
β βββ admin.py
βββ .env
βββ manage.py
- Node.js 18+
- Python 3.10+
- A Neon DB account (or any PostgreSQL instance)
cd node-api
npm installCreate .env:
DATABASE_URL=postgresql://user:password@ep-xxx.neon.tech/dbname?sslmode=require
JWT_SECRET=your_super_secret_key
PORT=3001
Run migrations and start:
npx prisma migrate dev --name init
npm run devAPI runs at http://localhost:3001
cd django-admin
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install django djangorestframework psycopg2-binary python-dotenv dj-database-urlCreate .env:
DJANGO_SECRET_KEY=your-django-secret-key
DEBUG=True
DATABASE_URL=postgresql://user:password@ep-xxx.neon.tech/dbname?sslmode=require
Run and create admin user:
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver 8001Admin panel at http://localhost:8001/admin
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /api/v1/auth/register |
No | Register new user |
| POST | /api/v1/auth/login |
No | Login, returns JWT |
| GET | /api/v1/auth/me |
Yes | Get current user |
Register
POST /api/v1/auth/register
{
"name": "Rahul",
"email": "rahul@example.com",
"password": "secret123",
"phone": "9999999999"
}Login
POST /api/v1/auth/login
{
"email": "rahul@example.com",
"password": "secret123"
}Returns: { user, token }
All protected routes require
Authorization: Bearer <token>header
All group routes require auth.
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/groups |
Create a group |
| GET | /api/v1/groups |
Get my groups |
| GET | /api/v1/groups/:groupId |
Get group details |
| POST | /api/v1/groups/:groupId/members |
Add member by email (admin only) |
| DELETE | /api/v1/groups/:groupId/members/:userId |
Remove member (admin only) |
| DELETE | /api/v1/groups/:groupId/leave |
Leave a group |
Create Group
POST /api/v1/groups
{
"name": "Goa Trip",
"description": "Trip expenses"
}Add Member
POST /api/v1/groups/1/members
{
"email": "priya@example.com"
}All expense routes require auth.
Note:
getGroupExpenses,getExpenseById, anddeleteExpenseare currently registered asPOSTin the router (seeexpense.routes.ts). The intended methods are shown below β fix the route definitions if needed.
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/expenses |
Add an expense |
| POST | /api/v1/expenses/group/:groupId |
Get group expenses (registered as POST β should be GET) |
| POST | /api/v1/expenses/:expenseId |
Get single expense (registered as POST β should be GET) |
| POST | /api/v1/expenses/:expenseID |
Delete expense (registered as POST β should be DELETE; note param typo expenseID) |
Add Expense β Equal Split
POST /api/v1/expenses
{
"description": "Hotel",
"amount": 900,
"groupId": 1,
"splitType": "EQUAL"
}Add Expense β Exact Split
POST /api/v1/expenses
{
"description": "Dinner",
"amount": 500,
"groupId": 1,
"splitType": "EXACT",
"customSplits": [
{ "userId": 1, "value": 300 },
{ "userId": 2, "value": 200 }
]
}Add Expense β Percentage Split
POST /api/v1/expenses
{
"description": "Cab",
"amount": 500,
"groupId": 1,
"splitType": "PERCENTAGE",
"customSplits": [
{ "userId": 1, "value": 60 },
{ "userId": 2, "value": 40 }
]
}All settlement routes require auth.
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/settlements |
Record a payment |
| GET | /api/v1/settlements/group/:groupId |
Get group settlements |
| PATCH | /api/v1/settlements/:settlementId/complete |
Receiver confirms payment |
Create Settlement
POST /api/v1/settlements
{
"toUserId": 1,
"groupId": 1,
"amount": 300,
"note": "Paid via UPI"
}Flow: Payer creates settlement (PENDING) β Receiver confirms (COMPLETED) β Balance updates automatically
All balance routes require auth.
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/balances/group/:groupId |
Group balances + suggested transactions |
| GET | /api/v1/balances/me |
My net balance across all groups |
Group Balance Response
{
"balances": [
{ "user": { "id": 1, "name": "Rahul" }, "amount": 600, "status": "owed" },
{ "user": { "id": 2, "name": "Priya" }, "amount": -300, "status": "owes" }
],
"transactions": [
{ "from": { "name": "Priya" }, "to": { "name": "Rahul" }, "amount": 300 }
]
}My Balances Response
{
"overall": -500,
"groups": [
{ "group": { "id": 1, "name": "Goa Trip" }, "balance": -300, "status": "owes" },
{ "group": { "id": 2, "name": "Flat" }, "balance": -200, "status": "owes" }
]
}| Method | Endpoint | Description |
|---|---|---|
| GET | /api/analytics/summary/ |
Platform overview |
| GET | /api/analytics/top-spenders/ |
Top paying users |
| GET | /api/analytics/group-activity/ |
Most active groups |
| GET | /api/analytics/unsettled-debts/ |
All pending settlements |
Optional query params: ?limit=10
- Push code to GitHub
- Go to render.com β New β Web Service
- Connect your repo, select the
node-apifolder - Set:
- Build Command:
npm install && npx prisma generate && npm run build - Start Command:
node dist/index.js
- Build Command:
- Add environment variables:
DATABASE_URL=... JWT_SECRET=... PORT=3001
- Go to Render β New β Web Service
- Connect your repo, select the
django-adminfolder - Set:
- Build Command:
pip install -r requirements.txt - Start Command:
gunicorn core.wsgi:application
- Build Command:
- Add environment variables:
DATABASE_URL=... DJANGO_SECRET_KEY=... DEBUG=False - Install gunicorn:
pip install gunicorn pip freeze > requirements.txt
After deploy, create superuser via Render shell:
python manage.py createsuperuserUser βββββββββββββββββββββββββββββββββββββββββββ
β β
ββββ GroupMember βββββ Group β
β β β
β ββββ Expense βββββββββ€
β β β β
β β ββββ ExpenseSplit
β β
β ββββ Settlement
β β
βββββββββββββββββββββββββββββββββββ
Balances are never stored β always calculated live from expenses and settlements. This keeps data consistent and avoids sync issues.
Django uses managed = False β Django reads the same Postgres tables Node.js created. It never runs migrations on those tables, just reads them for admin and analytics.
Settlement flow is two-step β payer records it as PENDING, receiver confirms as COMPLETED. This prevents fake settlements.
Split types β EQUAL auto-divides among all group members. EXACT and PERCENTAGE require explicit per-user values that must sum to total/100%.