A simple, mobile-first web app for two friends to track shared trip expenses, adjust split percentages, and settle up — with multi-currency support and real-time exchange rates.
- Trip Management — Create trips with two travelers and a base currency
- Expense Tracking — Log expenses with descriptions, amounts, and who paid
- Flexible Splits — Adjust the percentage split per expense (50/50, 70/30, etc.)
- 29 Currencies — Select a different currency per expense (EUR, USD, GBP, MXN, JPY, and 24 more)
- Real-Time Exchange Rates — Automatic currency conversion via Frankfurter API with 1-hour caching
- Settlement Calculator — See who owes whom, displayed in both the trip currency and your native currency
- PIN Protection — Optional 4-digit PIN per trip with SHA-256 hashing and rate limiting (5 failed attempts → 15-minute lockout)
- Share Trips — Share a trip link via Web Share API or clipboard
- Dark Mode — Light and dark themes with localStorage persistence
- Mobile-First — Designed for phones, works everywhere
| Layer | Technology |
|---|---|
| Frontend | React 18, Vite, TypeScript |
| Routing | wouter |
| Data Fetching | TanStack Query v5 |
| UI Components | shadcn/ui, Tailwind CSS |
| Backend | Express.js 5 |
| Database | PostgreSQL |
| ORM | Drizzle ORM |
| Validation | Zod + drizzle-zod |
- Node.js 20+
- PostgreSQL database
-
Clone the repository
git clone https://github.com/your-username/splittrip.git cd splittrip -
Install dependencies
npm install
-
Set up environment variables
Copy the example file and fill in your values:
cp .env.example .env
Then edit
.envwith your database credentials and a random session secret. -
Push the database schema
npm run db:push
-
Start the development server
npm run dev
The app will be available at
http://localhost:5000.
npm run build
npm start| Column | Type | Description |
|---|---|---|
| id | UUID | Primary key (auto-generated) |
| name | text | Trip name |
| person1 | text | First traveler's name |
| person2 | text | Second traveler's name |
| currency | text | Base currency code (default: EUR) |
| pinHash | text | SHA-256 hash of 4-digit PIN (nullable) |
| Column | Type | Description |
|---|---|---|
| id | UUID | Primary key (auto-generated) |
| tripId | UUID | Foreign key → trips (cascade delete) |
| description | text | What the expense was for |
| amount | real | Amount in the expense's currency |
| paidBy | text | Name of person who paid |
| person1Share | integer | Person 1's share as percentage 0–100 (default: 50) |
| currency | text | Currency code for this expense (default: EUR) |
| createdAt | timestamp | When the expense was created |
All endpoints return JSON. Protected endpoints require the x-trip-pin header if the trip has a PIN set.
| Method | Endpoint | Description | Auth |
|---|---|---|---|
GET |
/api/trips |
List all trips | — |
GET |
/api/trips/:id |
Get a single trip | — |
POST |
/api/trips |
Create a new trip | — |
DELETE |
/api/trips/:id |
Delete a trip | PIN |
POST |
/api/trips/:id/verify-pin |
Verify a trip's PIN | — |
Create Trip — POST /api/trips
{
"name": "Barcelona Weekend",
"person1": "Alice",
"person2": "Bob",
"currency": "EUR",
"pin": "1234"
}The pin field is optional. If provided, it must be exactly 4 digits. The PIN is hashed server-side and never stored in plain text. The response never includes the hash — instead, a hasPin boolean is returned.
| Method | Endpoint | Description | Auth |
|---|---|---|---|
GET |
/api/trips/:tripId/expenses |
List expenses for a trip | PIN |
POST |
/api/expenses |
Create an expense | PIN |
DELETE |
/api/expenses/:id |
Delete an expense | PIN |
Create Expense — POST /api/expenses
{
"tripId": "uuid-here",
"description": "Dinner",
"amount": 45.50,
"paidBy": "Alice",
"person1Share": 60,
"currency": "EUR"
}| Method | Endpoint | Description |
|---|---|---|
GET |
/api/exchange-rates/:base |
Get rates for a base currency (1-hour cache) |
- PIN Hashing — PINs are hashed with SHA-256 on the server. The hash is never exposed to clients.
- Rate Limiting — 5 failed PIN attempts trigger a 15-minute lockout per trip (in-memory, resets on server restart).
- PIN Header — Protected endpoints require the correct PIN via the
x-trip-pinrequest header. - No User Accounts — The app is intentionally account-free for simplicity. Anyone with the link (and PIN, if set) can access a trip.
├── client/ # Frontend (React + Vite)
│ ├── src/
│ │ ├── pages/ # Route pages (home, trip-detail)
│ │ ├── components/ # Dialogs, theme provider, shadcn/ui
│ │ ├── hooks/ # Custom hooks (toast, currency, mobile)
│ │ └── lib/ # Query client, utilities
│ └── index.html
├── server/ # Backend (Express)
│ ├── routes.ts # API route handlers
│ ├── storage.ts # Database access layer
│ ├── pin.ts # PIN hashing utilities
│ ├── rate-limit.ts # Rate limiting for PIN attempts
│ ├── exchange-rates.ts # Frankfurter API client with caching
│ └── db.ts # Database connection
├── shared/
│ └── schema.ts # Database schema, types, validation
└── package.json
MIT