A full-stack web application for splitting expenses among friends and groups. Built with React, TypeScript, Tailwind CSS, and Supabase.
SplitMate allows users to:
┌─────────────────────────────────────────────────────────┐
│ CLIENTS │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ React │ │ Android/iOS │ │ Shared View │ │
│ │ Web App │ │ Native Apps │ │ (Public Page) │ │
│ └────┬─────┘ └──────┬───────┘ └───────┬────────┘ │
│ │ │ │ │
└───────┼────────────────┼────────────────────┼───────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────┐
│ SUPABASE BACKEND │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Edge Functions (REST API) │ │
│ │ │ │
│ │ api-groups api-expenses api-members │ │
│ │ api-settlements api-activity api-share-settings│ │
│ │ api-shared-view (PUBLIC) │ │
│ └────────────────────┬─────────────────────────────┘ │
│ │ │
│ ┌────────────────────▼─────────────────────────────┐ │
│ │ PostgreSQL Database │ │
│ │ │ │
│ │ expense_groups │ group_members │ expenses │ │
│ │ activity_log │ profiles │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Supabase Auth │ │
│ │ Email/Password signup & login │ │
│ │ JWT token management │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
| Layer | Technology |
|---|---|
| Frontend | React 18, TypeScript, Vite |
| Styling | Tailwind CSS, shadcn/ui component library |
| State Management | React hooks, TanStack React Query |
| Routing | React Router v6 |
| Backend | Supabase (PostgreSQL + Auth + Edge Functions) |
| PDF Export | jsPDF + jspdf-autotable |
| Edge Functions | Deno (TypeScript), deployed on Supabase |
┌──────────────────┐ ┌──────────────────────────┐
│ profiles │ │ expense_groups │
├──────────────────┤ ├──────────────────────────┤
│ id (PK, uuid) │ │ id (PK, uuid) │
│ display_name │ │ name │
│ created_at │ │ code (4-digit unique) │
└──────────────────┘ │ created_by (user uuid) │
│ │ share_password (nullable)│
│ │ created_at │
│ └──────────┬───────────────┘
│ │
│ │ 1:N
│ ▼
│ ┌──────────────────────┐
│ │ group_members │
│ ├──────────────────────┤
│ │ id (PK, uuid) │
└──────────────────►│ user_id (nullable) │
│ group_id (FK) │
│ name │
│ created_at │
└──────────┬───────────┘
│
┌───────────┴───────────┐
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ expenses │ │ activity_log │
├──────────────────┤ ├──────────────────┤
│ id (PK, uuid) │ │ id (PK, uuid) │
│ group_id (FK) │ │ group_id (FK) │
│ paid_by (FK → │ │ member_id (FK) │
│ group_members) │ │ action │
│ amount (numeric) │ │ details │
│ description │ │ created_at │
│ split_among[] │ └──────────────────┘
│ category │
│ created_at │
│ updated_at │
└──────────────────┘
profilesAuto-created on user signup via a database trigger (handle_new_user). Stores the user’s display name.
| Column | Type | Description |
|---|---|---|
id |
uuid (PK) | References auth.users.id |
display_name |
text | User’s chosen display name |
created_at |
timestamptz | Auto-set |
expense_groupsEach group has a unique 4-digit code for easy sharing.
| Column | Type | Description |
|---|---|---|
id |
uuid (PK) | Auto-generated |
name |
text | Group name (e.g., “Weekend Trip”) |
code |
text | 4-digit invite code |
created_by |
uuid | The user who created the group |
share_password |
text (nullable) | If set, enables public read-only sharing |
created_at |
timestamptz | Auto-set |
group_membersLinks users to groups. A user can be in multiple groups.
| Column | Type | Description |
|---|---|---|
id |
uuid (PK) | Auto-generated |
group_id |
uuid (FK) | References expense_groups.id |
user_id |
uuid (nullable) | References auth.users.id |
name |
text | Display name in this group |
created_at |
timestamptz | Auto-set |
expensesEach expense records who paid, how much, and who it’s split among.
| Column | Type | Description |
|---|---|---|
id |
uuid (PK) | Auto-generated |
group_id |
uuid (FK) | References expense_groups.id |
paid_by |
uuid (FK) | References group_members.id |
amount |
numeric | Expense amount |
description |
text | What the expense was for |
split_among |
uuid[] | Array of member IDs to split among (empty = all) |
category |
text (nullable) | Category tag (food, transport, etc.) |
created_at |
timestamptz | Auto-set |
updated_at |
timestamptz | Auto-updated via trigger |
activity_logImmutable audit trail for all group actions.
| Column | Type | Description |
|---|---|---|
id |
uuid (PK) | Auto-generated |
group_id |
uuid (FK) | References expense_groups.id |
member_id |
uuid (FK) | References group_members.id |
action |
text | Action type enum (see below) |
details |
text (nullable) | Human-readable description |
created_at |
timestamptz | Auto-set |
Action Types:
GROUP_CREATED — Group was createdMEMBER_JOINED — A new member joinedEXPENSE_ADDED / EXPENSE_CREATED — Expense was addedEXPENSE_UPDATED — Expense was editedEXPENSE_DELETED — Expense was removed┌─────────────┐
│ App Load │
└──────┬──────┘
│
▼
┌──────────────┐ No ┌──────────────┐
│ Auth Session ├────────────►│ Auth Page │
│ exists? │ │ Login/Signup │
└──────┬───────┘ └──────┬───────┘
│ Yes │ Success
▼ ▼
┌──────────────────────────────────────────┐
│ JoinGroup Screen │
│ │
│ ┌────────────┐ ┌────────────────────┐ │
│ │ My Groups │ │ Create / Join Tab │ │
│ │ (list) │ │ │ │
│ └─────┬──────┘ └────────┬───────────┘ │
│ │ │ │
│ Tap group Create group (→ code) │
│ │ Join via 4-digit code │
│ │ │ │
└────────┼──────────────────┼──────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────┐
│ GroupDashboard │
│ │
│ Header: Group name, code, user info │
│ Stats: Total | Expenses | Members │
│ │
│ ┌──────────┬─────────┬────────────┐ │
│ │ Expenses │ Split │ History │ │
│ │ Tab │ Tab │ Tab │ │
│ ├──────────┼─────────┼────────────┤ │
│ │ List of │ Balance │ Activity │ │
│ │ expenses │ per │ log with │ │
│ │ with │ member │ timestamps │ │
│ │ edit/ │ │ │ │
│ │ delete │ Who │ │ │
│ │ │ pays │ │ │
│ │ Add new │ whom │ │ │
│ │ expense │ (min │ │ │
│ │ button │ txns) │ │ │
│ └──────────┴─────────┴────────────┘ │
│ │
│ Actions: Add Expense | Export PDF │
│ Share Settings | Manage Members│
│ Close Group (creator only) │
└──────────────────────────────────────────┘
┌───────────────┐
│ /view page │
└───────┬───────┘
│
▼
┌───────────────────┐
│ Enter 4-digit │
│ group code + │
│ share password │
└───────┬───────────┘
│
▼
┌───────────────────┐ ┌────────────────────┐
│ Verify against │ No │ Error: │
│ expense_groups ├────►│ "Group not found" │
│ table │ │ or "Wrong password │
└───────┬───────────┘ └────────────────────┘
│ Yes
▼
┌───────────────────────────────────┐
│ Read-only dashboard │
│ - Expense list │
│ - Split/settlement view │
│ - PDF export │
│ (No edit/delete/add capabilities) │
└───────────────────────────────────┘
┌──────────────────┐
│ Click "Add │
│ Expense" button │
└───────┬──────────┘
│
▼
┌──────────────────────────────────────┐
│ AddExpenseDialog │
│ │
│ Description: [optional text] │
│ Amount (₹): [required number] │
│ Category: [dropdown, optional] │
│ Paid by: [dropdown - members] │
│ Split among: [checkbox list] │
│ │
│ Preview: "Each person pays: ₹X.XX" │
│ │
│ [Add Expense] button │
└───────┬──────────────────────────────┘
│
▼
┌──────────────────┐
│ Insert into │
│ expenses table │
│ + activity_log │
└───────┬──────────┘
│
▼
┌──────────────────┐
│ Refresh data & │
│ recalculate │
│ balances │
└──────────────────┘
| Route | Component | Auth Required | Description |
|---|---|---|---|
/ |
Index → Auth or JoinGroup or GroupDashboard |
Yes | Main app flow |
/view |
SharedView |
No | Public read-only expense viewer |
* |
NotFound |
No | 404 page |
App
├── Index (/)
│ ├── Auth (when not logged in)
│ │ ├── Login Tab
│ │ └── Signup Tab
│ │
│ ├── JoinGroup (when logged in, no active group)
│ │ ├── My Groups List
│ │ ├── Create Group Tab
│ │ └── Join Group Tab
│ │
│ └── GroupDashboard (when group is active)
│ ├── Header (group name, code, actions)
│ ├── Stats Cards (total, count, members)
│ ├── Tabs
│ │ ├── ExpenseList
│ │ ├── SettlementView
│ │ └── ActivityHistory
│ ├── AddExpenseDialog
│ ├── ShareSettings (dialog)
│ └── MemberManagement (dialog, creator only)
│
└── SharedView (/view)
├── Code + Password Input
└── Read-only Dashboard
├── ExpenseList (readOnly mode)
└── SettlementView
| Hook | File | Purpose |
|---|---|---|
useAuth |
src/hooks/useAuth.ts |
Manages auth state, session restoration, display name |
useGroupSession |
src/hooks/useGroupSession.ts |
Persists active group to localStorage |
| File | Purpose |
|---|---|
src/lib/splitCalculator.ts |
Balance calculation & greedy settlement algorithm |
src/lib/pdfExport.ts |
Generates PDF reports with expenses, balances, settlements |
src/lib/categories.ts |
Expense category definitions with emoji icons |
All business logic is exposed via 7 Supabase Edge Functions deployed as REST APIs. These are designed to be consumed by both the web app and native mobile apps (Android/iOS).
| Function | Auth | Method(s) | Purpose |
|---|---|---|---|
api-groups |
JWT Required | GET, POST, DELETE | Group CRUD + join |
api-expenses |
JWT Required | GET, POST, PUT, DELETE | Expense CRUD |
api-members |
JWT Required | GET, PUT, DELETE | Member management |
api-settlements |
JWT Required | GET | Balance & settlement calculation |
api-activity |
JWT Required | GET | Activity log retrieval |
api-share-settings |
JWT Required | GET, PUT | Sharing toggle & password |
api-shared-view |
Public | POST | Read-only view with code+password |
https://tzvgmjszxxmtewmwacpv.supabase.co/functions/v1
SplitMate uses email/password authentication via Supabase Auth.
User fills: name, email, password
│
▼
supabase.auth.signUp({
email, password,
options: { data: { display_name } }
})
│
▼
Database trigger: handle_new_user()
→ Inserts row into profiles table
→ display_name from user metadata
│
▼
User is logged in with JWT token
useAuth calls supabase.auth.getSession() to restore session from localStorageonAuthStateChange listener handles subsequent login/logout eventsloading state prevents flash of auth screen during session restorationAuthorization: Bearer <access_token>
apikey: <SUPABASE_ANON_KEY>
Content-Type: application/json
/viewLocated in src/lib/splitCalculator.ts.
For each expense:
1. Full amount credited to the payer (totalPaid += amount)
2. Equal share (amount / split_count) debited from each member in split_among
3. If split_among is empty, all group members are included
Balance = totalPaid - totalOwed
Positive → others owe you money (creditor)
Negative → you owe money (debtor)
Zero → settled
1. Separate members into debtors (balance < 0) and creditors (balance > 0)
2. Sort debtors ascending (largest debt first)
3. Sort creditors descending (largest credit first)
4. Use two-pointer approach:
- Match largest debtor with largest creditor
- Transfer amount = min(|debtor.balance|, creditor.balance)
- Adjust both balances
- Move pointer when balance reaches zero
5. Continue until all settled
This produces minimal number of transactions.
Example:
Members: Alice (+$30), Bob (-$20), Charlie (-$10)
Settlement:
Bob → Alice: $20
Charlie → Alice: $10
POST /auth/v1/signup
Host: tzvgmjszxxmtewmwacpv.supabase.co
apikey: <SUPABASE_ANON_KEY>
Content-Type: application/json
{
"email": "user@example.com",
"password": "securepassword",
"data": { "display_name": "John Doe" }
}
→ 200: { "access_token": "...", "refresh_token": "...", "user": {...} }
POST /auth/v1/token?grant_type=password
Host: tzvgmjszxxmtewmwacpv.supabase.co
apikey: <SUPABASE_ANON_KEY>
Content-Type: application/json
{
"email": "user@example.com",
"password": "securepassword"
}
→ 200: { "access_token": "...", "refresh_token": "...", "user": {...} }
POST /auth/v1/token?grant_type=refresh_token
Host: tzvgmjszxxmtewmwacpv.supabase.co
apikey: <SUPABASE_ANON_KEY>
Content-Type: application/json
{ "refresh_token": "xxx" }
POST|GET|DELETE /api-groupsAll endpoints require Authorization: Bearer <token> header.
GET /functions/v1/api-groups
→ 200:
{
"groups": [{
"group_id": "uuid",
"member_id": "uuid",
"group_name": "Weekend Trip",
"group_code": "1234",
"is_creator": true,
"member_count": 4,
"created_at": "2026-03-07T10:00:00Z"
}]
}
POST /functions/v1/api-groups
{ "name": "Weekend Trip" }
→ 201:
{
"group_id": "uuid",
"member_id": "uuid",
"group_name": "Weekend Trip",
"group_code": "1234",
"member_name": "John Doe"
}
POST /functions/v1/api-groups/join
{ "code": "1234" }
→ 201:
{
"group_id": "uuid",
"member_id": "uuid",
"group_name": "Weekend Trip",
"group_code": "1234",
"member_name": "John Doe",
"already_member": false
}
DELETE /functions/v1/api-groups/<group_id>
→ 200: { "message": "Group and all associated data deleted" }
GET|POST|PUT|DELETE /api-expensesGET /functions/v1/api-expenses?group_id=<uuid>
→ 200:
{
"expenses": [{
"id": "uuid",
"group_id": "uuid",
"paid_by": "uuid",
"description": "Dinner",
"amount": 1500.00,
"split_among": ["member_id_1", "member_id_2"],
"category": "food",
"created_at": "2026-03-07T10:00:00Z",
"updated_at": "2026-03-07T10:00:00Z"
}]
}
POST /functions/v1/api-expenses
{
"group_id": "uuid",
"paid_by": "uuid (member_id)",
"amount": 1500.00,
"description": "Dinner",
"split_among": ["member_id_1", "member_id_2"],
"category": "food"
}
→ 201: { "expense": {...} }
PUT /functions/v1/api-expenses/<expense_id>
{
"amount": 2000.00,
"description": "Updated dinner",
"paid_by": "uuid",
"split_among": ["member_id_1", "member_id_2"],
"category": "food",
"member_id": "uuid" // for activity logging
}
→ 200: { "expense": {...} }
DELETE /functions/v1/api-expenses/<expense_id>?member_id=<uuid>
→ 200: { "message": "Expense deleted" }
GET|PUT|DELETE /api-membersGET /functions/v1/api-members?group_id=<uuid>
→ 200:
{
"members": [{
"id": "uuid",
"name": "John Doe",
"user_id": "uuid or null",
"created_at": "2026-03-07T10:00:00Z"
}]
}
PUT /functions/v1/api-members/<member_id>
{ "name": "New Name" }
→ 200: { "member": {...} }
DELETE /functions/v1/api-members/<member_id>
→ 200: { "message": "Member \"John\" removed" }
GET /api-settlementsGET /functions/v1/api-settlements?group_id=<uuid>
→ 200:
{
"balances": [{
"memberId": "uuid",
"memberName": "John",
"totalPaid": 3000.00,
"totalOwed": 1500.00,
"balance": 1500.00
}],
"settlements": [{
"from": "uuid",
"from_name": "Alice",
"to": "uuid",
"to_name": "John",
"amount": 750.00
}]
}
GET /api-activityGET /functions/v1/api-activity?group_id=<uuid>&limit=50
→ 200:
{
"activities": [{
"id": "uuid",
"action": "EXPENSE_ADDED",
"details": "John added \"Dinner\" (₹1500)",
"member_id": "uuid",
"created_at": "2026-03-07T10:00:00Z"
}]
}
GET|PUT /api-share-settingsGET /functions/v1/api-share-settings?group_id=<uuid>
→ 200: { "sharing_enabled": true, "is_creator": true }
PUT /functions/v1/api-share-settings?group_id=<uuid>
{ "share_password": "mypassword" } // null or empty to disable
→ 200: { "message": "Sharing enabled", "sharing_enabled": true }
POST /api-shared-view (PUBLIC)No authentication required. Only requires apikey header.
POST /functions/v1/api-shared-view
apikey: <SUPABASE_ANON_KEY>
Content-Type: application/json
{
"code": "1234",
"password": "sharepassword"
}
→ 200:
{
"group_name": "Weekend Trip",
"group_code": "1234",
"members": [{ "id": "uuid", "name": "John" }],
"expenses": [{ "id": "uuid", "paid_by": "uuid", "description": "Dinner", "amount": 1500, ... }],
"balances": [{ "memberId": "uuid", "memberName": "John", "totalPaid": 3000, "totalOwed": 1500, "balance": 1500 }],
"total_expenses": 5000.00
}
All errors follow a consistent structure:
{ "error": "Description of the error" }
| Status Code | Meaning |
|---|---|
400 |
Bad request / validation error |
401 |
Unauthorized / incorrect password |
403 |
Forbidden (not creator / sharing not enabled) |
404 |
Resource not found |
405 |
Method not allowed |
500 |
Internal server error |
| Value | Label | Icon |
|---|---|---|
food |
Food | 🍕 |
transport |
Transport | 🚗 |
accommodation |
Accommodation | 🏨 |
entertainment |
Entertainment | 🎬 |
shopping |
Shopping | 🛍️ |
utilities |
Utilities | 💡 |
health |
Health | 🏥 |
other |
Other | 📦 |
All tables have Row-Level Security (RLS) enabled with the following policies:
| Table | Action | Policy |
|---|---|---|
profiles |
SELECT | Own profile only (auth.uid() = id) |
profiles |
INSERT | Own profile only |
profiles |
UPDATE | Own profile only |
expense_groups |
SELECT | All authenticated users |
expense_groups |
INSERT | All authenticated users |
expense_groups |
UPDATE | All authenticated users |
expense_groups |
DELETE | Creator only (created_by = auth.uid()) |
group_members |
SELECT | All authenticated users |
group_members |
INSERT | All authenticated users |
group_members |
UPDATE/DELETE | Group creator only (via FK check) |
expenses |
SELECT/INSERT/UPDATE/DELETE | All authenticated users |
activity_log |
SELECT/INSERT | All authenticated users |
activity_log |
UPDATE/DELETE | Not allowed |
| Trigger | Table | Function | Purpose |
|---|---|---|---|
handle_new_user |
auth.users (on INSERT) |
handle_new_user() |
Auto-creates profile row |
update_updated_at |
expenses (on UPDATE) |
update_updated_at_column() |
Auto-updates updated_at |
Edge functions are automatically deployed when code is pushed. No manual deployment needed.
For building Android/iOS apps against these APIs, use the native Supabase SDKs:
| Platform | SDK | Auth | API Calls |
|---|---|---|---|
| Android (Kotlin) | io.github.jan-tennert.supabase:gotrue-kt |
Built-in | supabase.functions.invoke("api-groups") |
| iOS (Swift) | supabase-swift (SPM) |
Built-in | supabase.functions.invoke("api-groups") |
| Flutter | supabase_flutter |
Built-in | Supabase.instance.client.functions.invoke("api-groups") |
| React Native | @supabase/supabase-js |
Built-in | Same as web |
For real-time updates, connect to:
wss://tzvgmjszxxmtewmwacpv.supabase.co/realtime/v1/websocket?apikey=<SUPABASE_ANON_KEY>
Subscribe to expenses and group_members tables filtered by group_id.
├── src/
│ ├── App.tsx # Root component with routing
│ ├── main.tsx # Entry point
│ ├── index.css # Global styles & Tailwind config
│ ├── pages/
│ │ ├── Index.tsx # Main page (auth → groups → dashboard)
│ │ ├── Auth.tsx # Login/signup page
│ │ ├── SharedView.tsx # Public read-only expense viewer
│ │ └── NotFound.tsx # 404 page
│ ├── components/
│ │ ├── JoinGroup.tsx # Group listing, creation, joining
│ │ ├── GroupDashboard.tsx # Main dashboard with tabs
│ │ ├── ExpenseList.tsx # Expense cards with edit/delete
│ │ ├── AddExpenseDialog.tsx # Create/edit expense modal
│ │ ├── SettlementView.tsx # Balances & settlement display
│ │ ├── ActivityHistory.tsx # Activity log timeline
│ │ ├── ShareSettings.tsx # Sharing toggle & password
│ │ ├── MemberManagement.tsx # Rename/remove members
│ │ └── ui/ # shadcn/ui components
│ ├── hooks/
│ │ ├── useAuth.ts # Authentication state management
│ │ └── useGroupSession.ts # Group session persistence
│ ├── lib/
│ │ ├── splitCalculator.ts # Balance & settlement algorithms
│ │ ├── pdfExport.ts # PDF report generation
│ │ ├── categories.ts # Expense category definitions
│ │ └── utils.ts # Tailwind utility helpers
│ └── integrations/supabase/
│ ├── client.ts # Supabase client (auto-generated)
│ └── types.ts # Database types (auto-generated)
├── supabase/
│ ├── config.toml # Supabase project configuration
│ └── functions/
│ ├── api-groups/index.ts
│ ├── api-expenses/index.ts
│ ├── api-members/index.ts
│ ├── api-settlements/index.ts
│ ├── api-activity/index.ts
│ ├── api-share-settings/index.ts
│ └── api-shared-view/index.ts
└── API_DOCUMENTATION.md # Standalone API reference