- Added documentation for architecture and code plan - Implemented database schema for users, expenses, settlements, and transactions - Set up double-entry bookkeeping system with multi-currency support - Configured Hono web framework with middleware - Integrated Drizzle ORM for database operations
16 KiB
Splitwise Architecture
This document outlines the detailed architecture for the Splitwise-style application, focusing on the database design and backend endpoints. The application implements a double-entry bookkeeping system and supports multiple currencies.
Database Architecture
Core Entities and Relationships
[Users] 1--N [Auth]
1
|
N
[Contacts]
|
N
[Users] 1--N [Accounts] N--1 [Currencies]
1 1 1 1
| | | |
| | | N
N | N |
[Expenses] | [Transactions]-+
1 | N N
| | | |
N | | |
[ExpenseParticipants] |
|
[Settlements]
|
N
[Notifications]
Entity Relationship Legend:
1andNindicate one-to-many relationships- Lines connect related entities
- Entities in square brackets
[]
Schema Details
Users
CREATE TABLE users (
id UUID PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
display_name VARCHAR(255) NOT NULL,
profile_photo_url TEXT,
default_currency_code CHAR(3) NOT NULL DEFAULT 'USD',
locale VARCHAR(10) NOT NULL DEFAULT 'en-US',
timezone VARCHAR(50) NOT NULL DEFAULT 'UTC',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
Auth (managed by better-auth)
CREATE TABLE auth (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider VARCHAR(50) NOT NULL,
provider_user_id VARCHAR(255),
email VARCHAR(255) NOT NULL,
password_hash TEXT,
refresh_token TEXT,
last_login TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
UNIQUE(provider, provider_user_id)
);
Contacts
CREATE TABLE contacts (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
contact_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL CHECK (status IN ('pending', 'accepted', 'declined')),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
UNIQUE(user_id, contact_id)
);
Currencies
CREATE TABLE currencies (
code CHAR(3) PRIMARY KEY,
name VARCHAR(100) NOT NULL,
symbol VARCHAR(10) NOT NULL,
decimal_digits INT NOT NULL DEFAULT 2,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
Exchange Rates
CREATE TABLE exchange_rates (
id UUID PRIMARY KEY,
from_currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
to_currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
rate DECIMAL(24, 12) NOT NULL,
effective_date DATE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
UNIQUE(from_currency_code, to_currency_code, effective_date)
);
Accounts (for double-entry bookkeeping)
CREATE TABLE accounts (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(20) NOT NULL CHECK (type IN ('asset', 'liability')),
currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
UNIQUE(user_id, currency_code)
);
Expenses
CREATE TABLE expenses (
id UUID PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
amount DECIMAL(20, 2) NOT NULL CHECK (amount > 0),
currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
date TIMESTAMP WITH TIME ZONE NOT NULL,
payer_id UUID NOT NULL REFERENCES users(id),
receipt_image_url TEXT,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
Expense Participants
CREATE TABLE expense_participants (
id UUID PRIMARY KEY,
expense_id UUID NOT NULL REFERENCES expenses(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id),
share_amount DECIMAL(20, 2) NOT NULL CHECK (share_amount >= 0),
share_percentage DECIMAL(5, 2) GENERATED ALWAYS AS (
CASE
WHEN (SELECT amount FROM expenses WHERE id = expense_id) > 0
THEN (share_amount / (SELECT amount FROM expenses WHERE id = expense_id)) * 100
ELSE 0
END
) STORED,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
UNIQUE(expense_id, user_id)
);
Transactions (double-entry bookkeeping)
CREATE TABLE transactions (
id UUID PRIMARY KEY,
expense_id UUID REFERENCES expenses(id) ON DELETE SET NULL,
settlement_id UUID REFERENCES settlements(id) ON DELETE SET NULL,
from_account_id UUID NOT NULL REFERENCES accounts(id),
to_account_id UUID NOT NULL REFERENCES accounts(id),
amount DECIMAL(20, 2) NOT NULL,
currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
exchange_rate DECIMAL(24, 12) NOT NULL DEFAULT 1.0,
native_amount DECIMAL(20, 2) GENERATED ALWAYS AS (amount * exchange_rate) STORED,
date TIMESTAMP WITH TIME ZONE NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('expense', 'settlement')),
memo TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CHECK (expense_id IS NOT NULL OR settlement_id IS NOT NULL),
CHECK (from_account_id != to_account_id)
);
Settlements
CREATE TABLE settlements (
id UUID PRIMARY KEY,
payer_id UUID NOT NULL REFERENCES users(id),
receiver_id UUID NOT NULL REFERENCES users(id),
amount DECIMAL(20, 2) NOT NULL CHECK (amount > 0),
currency_code CHAR(3) NOT NULL REFERENCES currencies(code),
date TIMESTAMP WITH TIME ZONE NOT NULL,
memo TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CHECK (payer_id != receiver_id)
);
Balances (materialized view or calculated on-the-fly)
CREATE VIEW user_balances AS
SELECT
u1.id AS user_id,
u2.id AS other_user_id,
t.currency_code,
SUM(
CASE
WHEN t.from_account_id = a1.id THEN -t.amount
WHEN t.to_account_id = a1.id THEN t.amount
ELSE 0
END
) AS balance
FROM
users u1
CROSS JOIN users u2
JOIN accounts a1 ON u1.id = a1.user_id
JOIN accounts a2 ON u2.id = a2.user_id
JOIN transactions t ON
(t.from_account_id = a1.id AND t.to_account_id = a2.id) OR
(t.from_account_id = a2.id AND t.to_account_id = a1.id)
WHERE
u1.id != u2.id
AND a1.currency_code = a2.currency_code
AND a1.currency_code = t.currency_code
GROUP BY
u1.id, u2.id, t.currency_code;
Notifications
CREATE TABLE notifications (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL CHECK (type IN ('expense_added', 'expense_edited', 'expense_deleted', 'settlement_received')),
content JSONB NOT NULL,
is_read BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
Double-Entry Bookkeeping Implementation
Every financial event in the system (expenses and settlements) is recorded as a transaction with two sides:
- A debit to one account (increasing an asset or decreasing a liability)
- A credit to another account (decreasing an asset or increasing a liability)
Example: Creating an Expense
When User A records a $30 expense that they paid for, which is split equally between User A, User B, and User C:
-
User A paid $30 (creates a transaction)
- Credit User A's account (they're owed money) +$20
- Debit User B's account (they owe money) -$10
- Debit User C's account (they owe money) -$10
-
Corresponding transactions are created:
- Transaction 1: from User B's account to User A's account for $10
- Transaction 2: from User C's account to User A's account for $10
Example: Settling a Debt
When User B settles their $10 debt to User A:
- A settlement record is created
- A transaction is recorded:
- Debit User A's account -$10 (they received money)
- Credit User B's account +$10 (they paid money)
Multi-Currency Support
The system supports multiple currencies through:
- A currencies table with ISO currency codes
- Exchange rates tracking between currency pairs
- Storing all transactions in their original currency
- Recording exchange rates used at the time of transaction
- Converting balances to user's preferred currency on-the-fly
When an expense is created in a different currency:
- The expense is recorded in its original currency
- Each participant's share is calculated in the original currency
- Transactions are created in the original currency
- Exchange rates are applied when viewing balances in a different currency
Backend API Endpoints
Authentication (managed by better-auth)
POST /api/auth/register
POST /api/auth/login
POST /api/auth/social/:provider
POST /api/auth/social/:provider/callback
POST /api/auth/logout
GET /api/auth/me
POST /api/auth/refresh-token
POST /api/auth/forgot-password
POST /api/auth/reset-password
Users and Profiles
GET /api/users/profile
PUT /api/users/profile
PUT /api/users/settings
GET /api/users/search?query=:query
Contacts
GET /api/contacts
POST /api/contacts
GET /api/contacts/:id
PUT /api/contacts/:id
DELETE /api/contacts/:id
Currencies and Exchange Rates
GET /api/currencies
GET /api/currencies/:code
GET /api/exchange-rates?from=:code&to=:code&date=:date
Expenses
GET /api/expenses?page=:page&limit=:limit
POST /api/expenses
GET /api/expenses/:id
PUT /api/expenses/:id
DELETE /api/expenses/:id
POST /api/expenses/:id/receipt
Expense Participants
GET /api/expenses/:expenseId/participants
POST /api/expenses/:expenseId/participants
PUT /api/expenses/:expenseId/participants/:id
DELETE /api/expenses/:expenseId/participants/:id
Balances
GET /api/balances?currency=:code
GET /api/balances/:userId?currency=:code
GET /api/balances/summary?currency=:code
Settlements
POST /api/settlements
GET /api/settlements?page=:page&limit=:limit
GET /api/settlements/:id
Transactions (mostly internal)
GET /api/transactions?page=:page&limit=:limit
GET /api/transactions/:id
Notifications
GET /api/notifications
PUT /api/notifications/:id/read
PUT /api/notifications/read-all
API Request/Response Examples
Creating an Expense
Request:
POST /api/expenses
{
"title": "Dinner at Restaurant",
"description": "Birthday celebration",
"amount": 150.00,
"currency_code": "USD",
"date": "2023-05-15T19:30:00Z",
"payer_id": "550e8400-e29b-41d4-a716-446655440000",
"participants": [
{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"share_amount": 50.00
},
{
"user_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"share_amount": 50.00
},
{
"user_id": "6a2f41a3-c54c-4b01-90e6-d701748f0851",
"share_amount": 50.00
}
]
}
Response:
{
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"title": "Dinner at Restaurant",
"description": "Birthday celebration",
"amount": 150.0,
"currency_code": "USD",
"date": "2023-05-15T19:30:00Z",
"payer_id": "550e8400-e29b-41d4-a716-446655440000",
"participants": [
{
"id": "d9d9e7b6-a5c5-4988-810f-f81b137e31b3",
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"share_amount": 50.0,
"share_percentage": 33.33
},
{
"id": "8a8a8a8a-7425-40de-944b-e07fc1f90ae7",
"user_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"share_amount": 50.0,
"share_percentage": 33.33
},
{
"id": "9b9b9b9b-c54c-4b01-90e6-d701748f0851",
"user_id": "6a2f41a3-c54c-4b01-90e6-d701748f0851",
"share_amount": 50.0,
"share_percentage": 33.33
}
],
"created_at": "2023-05-15T19:35:00Z",
"updated_at": "2023-05-15T19:35:00Z"
}
Viewing Balances
Request:
GET /api/balances?currency=USD
Response:
{
"balances": [
{
"user_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"display_name": "John Doe",
"profile_photo_url": "https://example.com/photos/john.jpg",
"currency_code": "USD",
"amount": -50.0,
"last_activity": "2023-05-15T19:35:00Z"
},
{
"user_id": "6a2f41a3-c54c-4b01-90e6-d701748f0851",
"display_name": "Jane Smith",
"profile_photo_url": "https://example.com/photos/jane.jpg",
"currency_code": "USD",
"amount": -50.0,
"last_activity": "2023-05-15T19:35:00Z"
}
],
"total_owed_to_you": 100.0,
"total_you_owe": 0.0,
"net_balance": 100.0
}
Creating a Settlement
Request:
POST /api/settlements
{
"payer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"receiver_id": "550e8400-e29b-41d4-a716-446655440000",
"amount": 50.00,
"currency_code": "USD",
"date": "2023-05-16T14:00:00Z",
"memo": "Paying back for dinner"
}
Response:
{
"id": "3a3a3a3a-58cc-4372-a567-0e02b2c3d479",
"payer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"payer_name": "John Doe",
"receiver_id": "550e8400-e29b-41d4-a716-446655440000",
"receiver_name": "Alice Johnson",
"amount": 50.0,
"currency_code": "USD",
"date": "2023-05-16T14:00:00Z",
"memo": "Paying back for dinner",
"created_at": "2023-05-16T14:02:00Z",
"updated_at": "2023-05-16T14:02:00Z"
}
Implementation Considerations
Double-Entry Bookkeeping Logic
-
Transaction Creation:
- Every expense and settlement must create balanced transactions
- System must ensure debits equal credits for all transactions
-
Balance Calculation:
- Balances are derived from the transaction ledger
- Each user's balance with another user is calculated by summing all transactions between them
- Negative balance means the user owes money; positive means they are owed money
Multi-Currency Implementation
-
Currency Conversion:
- Use exchange rates from reliable sources (or integrate with exchange rate APIs)
- Store historical exchange rates for accurate reporting
- Implement conversion functions at the service layer
-
User Preferences:
- Allow users to set default currency
- Provide options to view balances in different currencies
-
Settlement Handling:
- Support settlements in different currencies
- Record the exchange rate used at settlement time
- Handle currency conversion fees if applicable
Security and Data Integrity
-
Transaction Atomicity:
- Use database transactions to ensure all related changes succeed or fail together
- Implement validation to ensure expense shares sum to the total amount
-
Audit Trail:
- Log all financial operations
- Prevent modification of completed transactions
- Allow corrections through compensating transactions
-
Data Validation:
- Validate all currency amounts and exchange rates
- Ensure proper decimal precision handling
- Implement comprehensive input validation
Conclusion
This architecture leverages double-entry bookkeeping principles to provide a robust foundation for tracking expenses and debts between users. The multi-currency support ensures the application can be used globally, with accurate conversion and balance tracking across different currencies.
The system design prioritizes data integrity, ensuring that all financial records are accurate, balanced, and traceable. By implementing this architecture, the Splitwise application will provide users with a reliable platform for managing shared expenses across multiple currencies.