Files
splitwise/docs/architecture.md
Yadunand Prem 74b7ddf3d6 feat: initial backend setup with Bun, Hono, and Drizzle ORM
- 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
2025-05-27 17:38:50 -04:00

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:

  • 1 and N indicate 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:

  1. A debit to one account (increasing an asset or decreasing a liability)
  2. 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:

  1. 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
  2. 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:

  1. A settlement record is created
  2. 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:

  1. A currencies table with ISO currency codes
  2. Exchange rates tracking between currency pairs
  3. Storing all transactions in their original currency
  4. Recording exchange rates used at the time of transaction
  5. Converting balances to user's preferred currency on-the-fly

When an expense is created in a different currency:

  1. The expense is recorded in its original currency
  2. Each participant's share is calculated in the original currency
  3. Transactions are created in the original currency
  4. 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

  1. Transaction Creation:

    • Every expense and settlement must create balanced transactions
    • System must ensure debits equal credits for all transactions
  2. 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

  1. 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
  2. User Preferences:

    • Allow users to set default currency
    • Provide options to view balances in different currencies
  3. 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

  1. 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
  2. Audit Trail:

    • Log all financial operations
    • Prevent modification of completed transactions
    • Allow corrections through compensating transactions
  3. 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.