An open-source expense splitting application that helps groups fairly divide shared costs. It supports custom split ratios, tracks individual payments, and provides a clear overview of outstanding balances so everyone knows who owes whom.
- No Registration Required: Access groups via capability links (tokens) - no user accounts needed
- Multiple Payers: Track expenses where multiple people contributed to payment
- Flexible Splitting:
- Equal splits among members
- Fixed amount splits
- Percentage-based splits
- Custom ratio-based splits (e.g., 2:1 for pro-rata share)
- Smart Settlements: Greedy algorithm minimizes the number of transactions needed to settle all debts
- Cross-Platform: Web (React + Vite), iOS, and Android (React Native + Expo) with shared TypeScript business logic
- Lightweight Backend: Uses Supabase free tier with Row Level Security for token-based access control
- Real-Time Balance Tracking: See who owes whom and outstanding balances at a glance
- Secure Sharing: Separate read-only and full-access tokens for group sharing
Shared Library (@teilfair/shared):
- TypeScript
- Vitest (testing framework)
- Focuses on calculation logic and data validation
Web Application (@teilfair/web):
- React 19
- Vite 7 (build tool)
- React Router 7 (routing)
- Zustand (state management)
- Supabase JS client
- FontAwesome (icons)
- Vercel Analytics & Speed Insights
Mobile Application (@teilfair/mobile):
- React Native 0.83
- Expo 54
- Zustand (state management)
- React Navigation (native stack navigation)
- Async Storage (local persistence)
- Secure Store (sensitive data)
- Supabase JS client
Backend:
- Supabase (PostgreSQL database with RLS)
- Row Level Security policies (token-based access)
- UUID generation for secure IDs
teilfair/
├── packages/
│ ├── shared/ # Shared TypeScript library
│ │ ├── src/
│ │ │ ├── calculations.ts # Core calculation logic
│ │ │ ├── types.ts # Shared types
│ │ │ └── index.ts
│ │ ├── __tests__/
│ │ └── package.json
│ ├── web/ # React web application
│ │ ├── src/
│ │ │ ├── components/ # React components
│ │ │ ├── pages/ # Page components
│ │ │ ├── stores/ # Zustand stores
│ │ │ ├── services/ # API/Supabase services
│ │ │ └── App.tsx
│ │ ├── index.html
│ │ └── package.json
│ └── mobile/ # React Native application
│ ├── src/
│ ├── app.json # Expo config
│ └── package.json
├── supabase/
│ ├── migrations/ # Database schema and RLS policies
│ │ ├── 000_reset.sql
│ │ ├── 001_initial_schema.sql
│ │ └── 002_change_expense_date_to_timestamptz.sql
│ └── config.toml
├── pnpm-workspace.yaml # Monorepo workspace config
└── package.json
TeilFair uses capability-based security instead of user accounts:
- Each group has two tokens:
readTokenandwriteToken - Share the read link for view-only access
- Share the write link for full edit access
- Tokens are validated via Supabase Row Level Security
- Groups cannot be guessed (cryptographically random IDs and tokens)
- Node.js 24 or higher
- pnpm 10.4+
- A Supabase project (free tier works)
- For mobile development: Expo CLI
- For iOS development: Xcode
- For Android development: Android Studio
git clone https://github.com/your-username/teilfair.git
cd teilfair
pnpm install- Create a new project at supabase.com (free tier is fine)
- Navigate to the SQL Editor
- Create a new query and copy the contents of
supabase/migrations/001_initial_schema.sql - Paste and execute the SQL
- In Settings > API, copy:
- Project URL:
https://[project-id].supabase.co - Anon Public Key: The public API key
- Service Role Key: Keep this safe, only for backend use
- Project URL:
For Web (packages/web/.env):
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key
Create the file if it doesn't exist:
cd packages/web
cat > .env << EOF
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key
EOFFor Mobile (packages/mobile/.env):
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
Web (recommended to start here):
pnpm web
# Opens at http://localhost:5173Mobile (requires Expo Go app on your phone):
pnpm mobile
# Scan the QR code with Expo Go appRun Tests:
pnpm testBuild All Packages:
pnpm buildpnpm install fails or missing dependencies
- Delete
pnpm-lock.yamlandnode_modules/ - Clear pnpm store:
pnpm store prune - Run
pnpm installagain
Web dev server won't start
- Ensure
.envfile exists inpackages/web/with valid Supabase credentials - Check that port 5173 is not in use
- Try:
pnpm web -- --port 3000to use a different port
Mobile app won't connect to backend
- Verify
.envinpackages/mobile/has correct Supabase URL and key - Ensure your phone and dev machine are on the same network
- Try restarting the Expo dev server
RLS policy errors on Supabase
- Confirm all migrations from
supabase/migrations/were run in order - Check that RLS is enabled on all tables
- Verify the
x-group-tokenheader is being sent with requests
"Cannot find module" errors
- Run
pnpm installfrom the root directory - Clear TypeScript cache:
rm -rf packages/*/dist - Restart your IDE
TeilFair uses a PostgreSQL database with the following core tables:
Represents a group of people sharing expenses.
| Column | Type | Description |
|---|---|---|
id |
UUID | Primary key, auto-generated |
name |
TEXT | Group name (e.g., "Roommates", "Trip to Greece") |
currency |
TEXT | ISO currency code (EUR, USD, GBP, etc.) |
read_token |
TEXT | Token for read-only access (32+ chars, unique) |
write_token |
TEXT | Token for full edit access (32+ chars, unique) |
created_at |
TIMESTAMPTZ | When the group was created |
Relationships: One-to-many with Members, Expenses
People who are part of a group and can participate in expenses.
| Column | Type | Description |
|---|---|---|
id |
UUID | Primary key, auto-generated |
group_id |
UUID | Foreign key to groups |
name |
TEXT | Member name (e.g., "Alice", "Bob") |
created_at |
TIMESTAMPTZ | When the member was added |
Relationships: Many-to-one with Groups; One-to-many with ExpensePayers and ExpenseSplits
Tracks shared expenses and how they were paid/split.
| Column | Type | Description |
|---|---|---|
id |
UUID | Primary key, auto-generated |
group_id |
UUID | Foreign key to groups |
description |
TEXT | What the expense was for (e.g., "Groceries") |
total_amount |
DECIMAL(12,2) | Total cost (must be > 0) |
expense_date |
DATE | When the expense occurred |
created_at |
TIMESTAMPTZ | When created |
Relationships: Many-to-one with Groups; One-to-many with ExpensePayers and ExpenseSplits
Records who paid for an expense and how much they paid.
| Column | Type | Description |
|---|---|---|
id |
UUID | Primary key, auto-generated |
expense_id |
UUID | Foreign key to expenses |
member_id |
UUID | Foreign key to members |
amount |
DECIMAL(12,2) | Amount this person paid (must be > 0) |
Constraints:
- One person can only be listed once per expense
- Must sum to total expense amount
Relationships: Many-to-one with Expenses and Members
Records how an expense should be split among members (who owes what).
| Column | Type | Description |
|---|---|---|
id |
UUID | Primary key, auto-generated |
expense_id |
UUID | Foreign key to expenses |
member_id |
UUID | Foreign key to members |
share |
DECIMAL(12,4) | The share value (meaning depends on share_type) |
share_type |
TEXT | One of: 'ratio', 'fixed', 'percentage' |
Share Types:
ratio: Proportional share (e.g., Alice:1, Bob:2 means Alice pays 1/3, Bob pays 2/3)fixed: Fixed amount (e.g., Alice:10.50 means Alice owes exactly €10.50)percentage: Percentage of total (e.g., Alice:50 means Alice owes 50% of total)
Relationships: Many-to-one with Expenses and Members
erDiagram
GROUP ||--o{ MEMBER : contains
GROUP ||--o{ EXPENSE : contains
GROUP {
string read_token PK
string write_token PK
}
MEMBER ||--o{ EXPENSE_PAYER : participates_as
MEMBER ||--o{ EXPENSE_SPLIT : participates_as
EXPENSE ||--o{ EXPENSE_PAYER : has
EXPENSE ||--o{ EXPENSE_SPLIT : has
All tables have RLS enabled. Access is controlled via the x-group-token HTTP header:
- Read Token: Can view group data
- Write Token: Can view and modify group data
The check_group_access() function in Supabase validates tokens before allowing any database operation.
The calculation engine (in packages/shared/src/calculations.ts) handles:
-
Split Types:
ratio: Divide proportionally (e.g., 1:1:1 = equal, 2:1 = 2/3 and 1/3)fixed: Exact amountspercentage: Percentage of total
-
Settlement Optimization: Uses a greedy algorithm to minimize the number of transactions needed to settle all debts.
- Fork the repository
- Create a feature branch
- Make your changes
- Run tests:
npm test - Submit a pull request
MIT