Files
finwise/doc/database.md
2026-02-13 12:05:11 +00:00

26 KiB

Database Schema

Overview

SQLite database with SeaORM ORM. Uses 8-decimal precision for all monetary values (stored as TEXT). All tables include sync-compatible fields (UUIDs, versions, timestamps).

SeaORM Entities

All database tables are defined as SeaORM entities with derive macros:

use sea_orm::entity::prelude::*;

#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "accounts")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    pub id: String,
    pub name: String,
    pub account_type: String,
    pub currency: String,
    pub initial_balance: String,
    pub current_balance: String,
    pub color: Option<String>,
    pub icon: Option<String>,
    pub sort_order: i32,
    pub is_active: bool,
    pub is_archived: bool,
    pub include_in_net_worth: bool,
    pub show_in_combined_view: bool,
    pub created_at: DateTimeUtc,
    pub updated_at: DateTimeUtc,
    pub version: i32,
    pub device_id: Option<String>,
    pub is_deleted: bool,
}

Data Types

  • TEXT: Strings, UUIDs, dates (ISO 8601), decimals (stored as strings for precision)
  • INTEGER: Booleans (0/1), counts, foreign keys
  • JSON: Arrays and objects (SQLite 3.38+)

Precision Handling

All monetary amounts stored as TEXT with 8 decimal places:

1234.56000000
0.00010000
9999999999.99999999

Complete Schema

1. accounts

Multi-account support for tracking different financial accounts.

CREATE TABLE accounts (
    id TEXT PRIMARY KEY,              -- UUID v4
    name TEXT NOT NULL,               -- Display name
    account_type TEXT NOT NULL CHECK(account_type IN (
        'checking',        -- Bank checking account
        'savings',         -- Bank savings account
        'credit_card',     -- Credit card (negative = owed)
        'cash',            -- Physical cash
        'digital_wallet',  -- PayMe, AlipayHK, Octopus, etc.
        'loan',            -- Loan/liability tracking
        'other'            -- Custom account type
    )),
    
    -- Currency & Balance (8 decimal precision)
    currency TEXT NOT NULL,           -- ISO 4217 code: HKD, USD, CNY, etc.
    initial_balance TEXT NOT NULL DEFAULT '0.00000000',
    current_balance TEXT NOT NULL DEFAULT '0.00000000',
    
    -- Visual Customization
    color TEXT DEFAULT '#3B82F6',     -- Hex color for UI
    icon TEXT DEFAULT 'wallet',       -- Lucide icon name
    sort_order INTEGER DEFAULT 0,     -- Display order
    
    -- Settings
    is_active BOOLEAN DEFAULT 1,      -- Archived accounts hidden by default
    is_archived BOOLEAN DEFAULT 0,
    include_in_net_worth BOOLEAN DEFAULT 1,  -- Exclude loans from net worth
    show_in_combined_view BOOLEAN DEFAULT 1, -- Show in dashboard
    
    -- Sync & Audit
    created_at TEXT NOT NULL,         -- ISO 8601 UTC
    updated_at TEXT NOT NULL,
    version INTEGER DEFAULT 1,        -- Increment on every change
    device_id TEXT,                   -- Device that last modified
    is_deleted BOOLEAN DEFAULT 0,     -- Soft delete for sync
    
    -- Constraints
    CHECK (initial_balance GLOB '-?[0-9]*.?[0-9]*'),
    CHECK (current_balance GLOB '-?[0-9]*.?[0-9]*')
);

Indexes:

CREATE INDEX idx_accounts_active ON accounts(is_active, is_archived);
CREATE INDEX idx_accounts_type ON accounts(account_type);
CREATE INDEX idx_accounts_currency ON accounts(currency);

2. tags

Flat tag-based categorization system.

CREATE TABLE tags (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL UNIQUE,        -- "Food", "Transport", "Salary"
    color TEXT NOT NULL,              -- Hex color (full color picker)
    icon TEXT,                        -- Lucide icon name
    
    -- Budgeting (optional)
    budget_amount TEXT,               -- NULL = no budget
    budget_period TEXT CHECK(budget_period IN ('daily', 'weekly', 'monthly', 'yearly')),
    
    -- Flags
    is_system BOOLEAN DEFAULT 0,      -- Built-in tags (protected)
    sort_order INTEGER DEFAULT 0,
    
    -- Sync & Audit
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL,
    version INTEGER DEFAULT 1,
    device_id TEXT,
    is_deleted BOOLEAN DEFAULT 0,
    
    -- Constraints
    CHECK (budget_amount GLOB '-?[0-9]*.?[0-9]*' OR budget_amount IS NULL)
);

Indexes:

CREATE INDEX idx_tags_active ON tags(is_deleted);
CREATE INDEX idx_tags_system ON tags(is_system);

3. transactions

Core transaction table for expenses, incomes, and transfers.

CREATE TABLE transactions (
    id TEXT PRIMARY KEY,
    account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
    
    -- Transaction Type
    transaction_type TEXT NOT NULL CHECK(transaction_type IN (
        'expense',      -- Money out
        'income',       -- Money in
        'transfer_out', -- Transfer from this account
        'transfer_in'   -- Transfer to this account
    )),
    
    -- Amounts (8 decimal precision)
    gross_amount TEXT NOT NULL,       -- Pre-tax amount
    tax_amount TEXT DEFAULT '0.00000000',
    net_amount TEXT NOT NULL,         -- Post-tax (actual paid)
    tax_rate TEXT,                    -- e.g., "0.00000000" for HK (no GST)
    
    -- Currency
    currency TEXT NOT NULL,
    
    -- Description & Details
    description TEXT NOT NULL,
    merchant TEXT,
    notes TEXT,
    
    -- Receipt
    receipt_paths JSON,               -- Array of file paths: ["receipts/xxx.jpg"]
    receipt_ocr_data JSON,            -- Parsed OCR data
    
    -- Transfer Linkage
    transfer_id TEXT,                 -- Links paired transfer records
    related_transaction_id TEXT REFERENCES transactions(id),
    
    -- Schedule
    schedule_id TEXT REFERENCES scheduled_transactions(id),
    is_scheduled_instance BOOLEAN DEFAULT 0,
    is_auto_inserted BOOLEAN DEFAULT 0,    -- Flag for auto-inserted scheduled transactions
    needs_review BOOLEAN DEFAULT 0,        -- Review reminder flag
    
    -- Dates (ISO 8601)
    transaction_date TEXT NOT NULL,   -- Date only: "2026-02-13"
    created_at TEXT NOT NULL,         -- Full datetime
    updated_at TEXT NOT NULL,
    
    -- Sync & Audit
    version INTEGER DEFAULT 1,
    device_id TEXT,
    is_deleted BOOLEAN DEFAULT 0,
    sync_status TEXT DEFAULT 'synced' CHECK(sync_status IN ('synced', 'pending', 'conflict', 'error')),
    
    -- Constraints
    CHECK (gross_amount GLOB '-?[0-9]*.?[0-9]*'),
    CHECK (tax_amount GLOB '-?[0-9]*.?[0-9]*'),
    CHECK (net_amount GLOB '-?[0-9]*.?[0-9]*'),
    CHECK (tax_rate GLOB '-?[0-9]*.?[0-9]*' OR tax_rate IS NULL),
    
    -- Enforce positive amounts for expenses
    CHECK (
        (transaction_type IN ('expense', 'transfer_out') AND CAST(gross_amount AS REAL) > 0)
        OR
        (transaction_type IN ('income', 'transfer_in') AND CAST(gross_amount AS REAL) > 0)
    )
);

Indexes:

CREATE INDEX idx_transactions_account ON transactions(account_id, is_deleted);
CREATE INDEX idx_transactions_date ON transactions(transaction_date DESC);
CREATE INDEX idx_transactions_type ON transactions(transaction_type);
CREATE INDEX idx_transactions_schedule ON transactions(schedule_id);
CREATE INDEX idx_transactions_transfer ON transactions(transfer_id);
CREATE INDEX idx_transactions_review ON transactions(needs_review) WHERE needs_review = 1;
CREATE INDEX idx_transactions_auto ON transactions(is_auto_inserted) WHERE is_auto_inserted = 1;

4. transaction_tags

Many-to-many relationship between transactions and tags.

CREATE TABLE transaction_tags (
    transaction_id TEXT NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
    tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
    
    PRIMARY KEY (transaction_id, tag_id)
);

Indexes:

CREATE INDEX idx_transaction_tags_tag ON transaction_tags(tag_id);

5. scheduled_transactions

Recurring transaction templates.

CREATE TABLE scheduled_transactions (
    id TEXT PRIMARY KEY,
    account_id TEXT NOT NULL REFERENCES accounts(id),
    
    -- Schedule Pattern
    schedule_type TEXT NOT NULL CHECK(schedule_type IN (
        'daily',
        'weekly',    -- Select day(s) of week
        'monthly',   -- Select day of month
        'yearly',    -- Select month and day
        'custom'     -- Custom interval
    )),
    frequency INTEGER DEFAULT 1,      -- Every N days/weeks/months
    
    -- For weekly: 0=Sunday, 1=Monday, etc. (JSON array for multiple)
    days_of_week JSON,
    
    -- For monthly: 1-31 (-1 = last day of month)
    day_of_month INTEGER,
    
    -- For yearly: month (1-12)
    month_of_year INTEGER,
    
    -- Execution Time
    execution_time TEXT DEFAULT '00:00',  -- HH:MM format
    timezone TEXT,                        -- User's timezone or explicit
    
    -- Start/End Conditions
    start_date TEXT NOT NULL,
    end_date TEXT,                    -- NULL = indefinite
    occurrence_count INTEGER,         -- NULL = indefinite
    current_occurrence INTEGER DEFAULT 0,
    
    -- Transaction Template
    transaction_type TEXT NOT NULL,
    gross_amount TEXT NOT NULL,
    tax_amount TEXT DEFAULT '0.00000000',
    net_amount TEXT NOT NULL,
    currency TEXT NOT NULL,
    description TEXT,
    merchant TEXT,
    notes TEXT,
    
    -- Tags (JSON array of tag IDs)
    tag_ids JSON,
    
    -- Status
    is_active BOOLEAN DEFAULT 1,
    last_generated_date TEXT,
    next_execution_datetime TEXT,     -- Full datetime in UTC
    
    -- Sync & Audit
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL,
    version INTEGER DEFAULT 1,
    device_id TEXT,
    is_deleted BOOLEAN DEFAULT 0,
    
    -- Constraints
    CHECK (CAST(gross_amount AS REAL) > 0),
    CHECK (frequency > 0)
);

Indexes:

CREATE INDEX idx_scheduled_next_exec ON scheduled_transactions(next_execution_datetime) 
    WHERE is_active = 1 AND is_deleted = 0;
CREATE INDEX idx_scheduled_account ON scheduled_transactions(account_id);

6. scheduled_instances

Track generated instances of scheduled transactions.

CREATE TABLE scheduled_instances (
    id TEXT PRIMARY KEY,
    schedule_id TEXT NOT NULL REFERENCES scheduled_transactions(id) ON DELETE CASCADE,
    transaction_id TEXT REFERENCES transactions(id),
    
    due_date TEXT NOT NULL,           -- Date this instance was due
    is_generated BOOLEAN DEFAULT 0,
    is_skipped BOOLEAN DEFAULT 0,
    generated_at TEXT,
    notified BOOLEAN DEFAULT 0,       -- Track if user was reminded
    
    created_at TEXT NOT NULL
);

Indexes:

CREATE INDEX idx_scheduled_instances_schedule ON scheduled_instances(schedule_id);
CREATE INDEX idx_scheduled_instances_due ON scheduled_instances(due_date) 
    WHERE is_generated = 0 AND is_skipped = 0;

7. goals

Financial goals with progress tracking.

CREATE TABLE goals (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    description TEXT,
    
    -- Target
    target_amount TEXT NOT NULL,
    current_amount TEXT NOT NULL DEFAULT '0.00000000',
    currency TEXT NOT NULL,
    
    -- Type
    goal_type TEXT NOT NULL CHECK(goal_type IN (
        'savings',        -- Save up to target
        'debt_payoff',    -- Pay off debt
        'spending_limit', -- Don't exceed limit
        'custom'
    )),
    
    -- Timeline
    target_date TEXT,                 -- Optional deadline
    is_recurring BOOLEAN DEFAULT 0,   -- Reset monthly/quarterly?
    recurrence_period TEXT CHECK(recurrence_period IN ('monthly', 'quarterly', 'yearly')),
    
    -- Link to account (optional)
    linked_account_id TEXT REFERENCES accounts(id),
    
    -- Visual
    color TEXT,
    icon TEXT,
    
    -- Status
    is_active BOOLEAN DEFAULT 1,
    is_achieved BOOLEAN DEFAULT 0,
    achieved_at TEXT,
    last_reset_date TEXT,             -- For recurring goals
    
    -- Sync & Audit
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL,
    version INTEGER DEFAULT 1,
    device_id TEXT,
    is_deleted BOOLEAN DEFAULT 0,
    
    -- Constraints
    CHECK (CAST(target_amount AS REAL) > 0),
    CHECK (CAST(current_amount AS REAL) >= 0)
);

Indexes:

CREATE INDEX idx_goals_active ON goals(is_active, is_deleted);
CREATE INDEX idx_goals_achieved ON goals(is_achieved) WHERE is_achieved = 0;
CREATE INDEX idx_goals_account ON goals(linked_account_id);

8. goal_rules

Auto-contribution rules for goals.

CREATE TABLE goal_rules (
    id TEXT PRIMARY KEY,
    goal_id TEXT NOT NULL REFERENCES goals(id) ON DELETE CASCADE,
    
    -- Match Conditions
    -- Match if transaction has ALL specified tags (AND logic)
    tag_ids JSON NOT NULL,            -- Array of tag IDs
    
    -- Contribution Calculation
    contribution_type TEXT NOT NULL CHECK(contribution_type IN ('percentage', 'fixed')),
    percentage TEXT,                  -- e.g., "10.00" = 10%
    fixed_amount TEXT,                -- Fixed amount per transaction
    
    -- Limits
    max_contribution_per_transaction TEXT,  -- Cap per transaction
    monthly_cap TEXT,                       -- Monthly contribution limit
    
    is_active BOOLEAN DEFAULT 1,
    
    -- Sync & Audit
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL,
    version INTEGER DEFAULT 1,
    device_id TEXT,
    is_deleted BOOLEAN DEFAULT 0,
    
    -- Constraints
    CHECK (
        (contribution_type = 'percentage' AND percentage IS NOT NULL)
        OR
        (contribution_type = 'fixed' AND fixed_amount IS NOT NULL)
    ),
    CHECK (CAST(percentage AS REAL) >= 0 AND CAST(percentage AS REAL) <= 100)
);

Indexes:

CREATE INDEX idx_goal_rules_goal ON goal_rules(goal_id);
CREATE INDEX idx_goal_rules_active ON goal_rules(is_active);

9. goal_progress

Track contributions to goals.

CREATE TABLE goal_progress (
    id TEXT PRIMARY KEY,
    goal_id TEXT NOT NULL REFERENCES goals(id) ON DELETE CASCADE,
    amount TEXT NOT NULL,             -- Amount added (can be negative for adjustments)
    transaction_id TEXT REFERENCES transactions(id),
    notes TEXT,
    recorded_at TEXT NOT NULL,
    
    -- Constraints
    CHECK (amount GLOB '-?[0-9]*.?[0-9]*')
);

Indexes:

CREATE INDEX idx_goal_progress_goal ON goal_progress(goal_id);
CREATE INDEX idx_goal_progress_date ON goal_progress(recorded_at);

10. transfers

Track account-to-account transfers with FX rates.

CREATE TABLE transfers (
    id TEXT PRIMARY KEY,
    from_account_id TEXT NOT NULL REFERENCES accounts(id),
    to_account_id TEXT NOT NULL REFERENCES accounts(id),
    
    from_transaction_id TEXT REFERENCES transactions(id),
    to_transaction_id TEXT REFERENCES transactions(id),
    
    -- Amounts
    from_amount TEXT NOT NULL,        -- In from_account currency
    to_amount TEXT NOT NULL,          -- In to_account currency
    exchange_rate TEXT,               -- Rate used (to_amount / from_amount)
    exchange_rate_source TEXT,        -- 'auto', 'manual', 'api'
    
    fees TEXT DEFAULT '0.00000000',
    
    description TEXT,
    transfer_date TEXT NOT NULL,
    
    created_at TEXT NOT NULL,
    
    -- Sync & Audit
    version INTEGER DEFAULT 1,
    device_id TEXT,
    is_deleted BOOLEAN DEFAULT 0,
    
    -- Constraints
    CHECK (from_account_id != to_account_id),
    CHECK (CAST(from_amount AS REAL) > 0),
    CHECK (CAST(to_amount AS REAL) > 0)
);

Indexes:

CREATE INDEX idx_transfers_from ON transfers(from_account_id);
CREATE INDEX idx_transfers_to ON transfers(to_account_id);
CREATE INDEX idx_transfers_date ON transfers(transfer_date);

11. exchange_rates

Historical exchange rates for accurate conversions.

CREATE TABLE exchange_rates (
    from_currency TEXT NOT NULL,
    to_currency TEXT NOT NULL,
    rate TEXT NOT NULL,               -- 8 decimal precision
    date TEXT NOT NULL,               -- Date only
    source TEXT,                      -- 'ECB', 'HKMA', 'manual', etc.
    fetched_at TEXT,                  -- When rate was obtained
    
    PRIMARY KEY (from_currency, to_currency, date),
    
    CHECK (from_currency != to_currency),
    CHECK (CAST(rate AS REAL) > 0)
);

Indexes:

CREATE INDEX idx_exchange_rates_date ON exchange_rates(date);
CREATE INDEX idx_exchange_rates_lookup ON exchange_rates(from_currency, to_currency, date DESC);

12. reconciliations

Simple account reconciliation records.

CREATE TABLE reconciliations (
    id TEXT PRIMARY KEY,
    account_id TEXT NOT NULL REFERENCES accounts(id),
    
    statement_date TEXT NOT NULL,     -- Statement period end
    statement_balance TEXT NOT NULL,  -- Balance per statement
    
    app_balance TEXT NOT NULL,        -- Calculated balance
    difference TEXT NOT NULL,         -- statement - app
    
    status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'balanced', 'adjusted')),
    notes TEXT,
    
    created_at TEXT NOT NULL,
    resolved_at TEXT,
    
    -- Sync & Audit
    version INTEGER DEFAULT 1,
    device_id TEXT,
    
    CHECK (CAST(statement_balance AS REAL) IS NOT NULL),
    CHECK (CAST(app_balance AS REAL) IS NOT NULL)
);

Indexes:

CREATE INDEX idx_reconciliations_account ON reconciliations(account_id);
CREATE INDEX idx_reconciliations_date ON reconciliations(statement_date DESC);

13. settings

Application settings key-value store.

CREATE TABLE settings (
    key TEXT PRIMARY KEY,
    value TEXT,
    updated_at TEXT NOT NULL
);

Default Settings:

INSERT INTO settings (key, value, updated_at) VALUES 
    ('language', 'en', datetime('now')),
    ('default_currency', 'HKD', datetime('now')),
    ('base_currency', 'HKD', datetime('now')),
    ('timezone', 'auto', datetime('now')),
    ('default_view', 'combined', datetime('now')),      -- 'combined' or 'single'
    ('display_mode', 'net', datetime('now')),           -- 'net' or 'gross'
    ('decimal_places', '2', datetime('now')),           -- Display precision
    ('date_format', 'YYYY-MM-DD', datetime('now')),
    ('time_format', '24h', datetime('now')),
    ('theme', 'system', datetime('now')),               -- 'light', 'dark', 'system'
    ('week_starts_on', '1', datetime('now')),           -- 0=Sun, 1=Mon
    ('scheduled_check_interval', '1', datetime('now')); -- Minutes

Database Initialization

-- Enable foreign keys
PRAGMA foreign_keys = ON;

-- Enable WAL mode for better concurrency
PRAGMA journal_mode = WAL;

-- Set synchronous mode for safety
PRAGMA synchronous = NORMAL;

-- Create all tables (in order)
-- ... (all CREATE TABLE statements above) ...

-- Insert default settings
-- ... (settings INSERT statements) ...

-- Insert default/system tags
INSERT INTO tags (id, name, color, icon, is_system, sort_order) VALUES
    ('tag-food', 'Food & Dining', '#EF4444', 'utensils', 1, 1),
    ('tag-transport', 'Transportation', '#3B82F6', 'car', 1, 2),
    ('tag-shopping', 'Shopping', '#8B5CF6', 'shopping-bag', 1, 3),
    ('tag-entertainment', 'Entertainment', '#F59E0B', 'film', 1, 4),
    ('tag-utilities', 'Utilities', '#10B981', 'zap', 1, 5),
    ('tag-health', 'Health & Medical', '#EC4899', 'heart-pulse', 1, 6),
    ('tag-education', 'Education', '#6366F1', 'graduation-cap', 1, 7),
    ('tag-salary', 'Salary', '#10B981', 'banknote', 1, 8),
    ('tag-investment', 'Investment', '#14B8A6', 'trending-up', 1, 9),
    ('tag-transfer', 'Transfer', '#6B7280', 'arrow-right-left', 1, 10);

Common Queries

Using SeaORM

Get Account Balance

use sea_orm::{entity::*, query::*};
use entities::account;

let balance: Option<String> = account::Entity::find_by_id(account_id)
    .select_only()
    .column(account::Column::CurrentBalance)
    .into_tuple()
    .one(&db)
    .await?;

Get Transactions with Tags

use entities::{transaction, tag, transaction_tag};

let transactions = transaction::Entity::find()
    .filter(transaction::Column::AccountId.eq(account_id))
    .filter(transaction::Column::IsDeleted.eq(false))
    .order_by_desc(transaction::Column::TransactionDate)
    .find_with_linked(transaction::TagLink)
    .all(&db)
    .await?;

Get Spending by Tag (Monthly)

use sea_orm::{ sea_query::*, * };

let spending = transaction::Entity::find()
    .select_only()
    .column_as(tag::Column::Name, "tag_name")
    .column_as(tag::Column::Color, "tag_color")
    .column_as(transaction::Column::NetAmount.sum(), "total")
    .inner_join(transaction_tag::Entity)
    .inner_join(tag::Entity)
    .filter(transaction::Column::TransactionType.eq("expense"))
    .filter(transaction::Column::TransactionDate.gte("2026-02-01"))
    .filter(transaction::Column::TransactionDate.lt("2026-03-01"))
    .filter(transaction::Column::IsDeleted.eq(false))
    .group_by(tag::Column::Id)
    .order_by_desc(transaction::Column::NetAmount.sum())
    .into_json()
    .all(&db)
    .await?;

Get Upcoming Scheduled Transactions

use entities::scheduled_transaction;
use chrono::Utc;

let now = Utc::now();

let upcoming = scheduled_transaction::Entity::find()
    .filter(scheduled_transaction::Column::IsActive.eq(true))
    .filter(scheduled_transaction::Column::IsDeleted.eq(false))
    .filter(scheduled_transaction::Column::NextExecutionDatetime.lte(now))
    .order_by_asc(scheduled_transaction::Column::NextExecutionDatetime)
    .all(&db)
    .await?;

Get Goal Progress

use entities::goal;

let goals = goal::Entity::find()
    .filter(goal::Column::IsActive.eq(true))
    .filter(goal::Column::IsDeleted.eq(false))
    .order_by_desc(goal::Column::CreatedAt)
    .all(&db)
    .await?;

// Calculate progress in Rust
for goal in goals {
    let current: f64 = goal.current_amount.parse()?;
    let target: f64 = goal.target_amount.parse()?;
    let progress_percent = (current / target) * 100.0;
}

Raw SQL (if needed)

For complex queries, SeaORM supports raw SQL:

use sea_orm::{ConnectionTrait, Statement};

let stmt = Statement::from_sql_and_values(
    db.get_database_backend(),
    r#"
    SELECT 
        tg.name,
        tg.color,
        SUM(CAST(t.net_amount AS REAL)) as total
    FROM transactions t
    JOIN transaction_tags tt ON t.id = tt.transaction_id
    JOIN tags tg ON tt.tag_id = tg.id
    WHERE t.transaction_type = 'expense'
        AND strftime('%Y-%m', t.transaction_date) = ?
        AND t.is_deleted = 0
    GROUP BY tg.id
    ORDER BY total DESC
    "#,
    ["2026-02".into()]
);

let results = db.query_all(stmt).await?;

Data Integrity with SeaORM

Auto-Updating Timestamps and Version

With SeaORM, we handle these in the ActiveModel before updating:

// In your service/update function
pub async fn update_account(
    db: &DatabaseConnection,
    id: &str,
    updates: AccountUpdate,
) -> Result<account::Model, DbErr> {
    let account = account::Entity::find_by_id(id)
        .one(db)
        .await?
        .ok_or(DbErr::Custom("Account not found".to_string()))?;
    
    let mut active_model: account::ActiveModel = account.into();
    
    // Update fields
    if let Some(name) = updates.name {
        active_model.name = Set(name);
    }
    
    // Auto-update audit fields
    active_model.updated_at = Set(Utc::now());
    active_model.version = Set(active_model.version.unwrap() + 1);
    
    active_model.update(db).await
}

Database Constraints

SeaORM supports check constraints in migrations:

// In migration file
.col(ColumnDef::new(Account::InitialBalance)
    .string()
    .not_null()
    .check(Expr::col(Account::InitialBalance).like("-?[0-9]*.?[0-9]*")))

Migration Strategy with SeaORM

SeaORM provides a built-in migration system:

Migration Structure

src-tauri/src/migrations/
├── mod.rs
├── m001_initial.rs
├── m002_add_user_preferences.rs
└── m003_add_indexes.rs

Migration Files

// src-tauri/src/migrations/mod.rs
pub use sea_orm_migration::prelude::*;

mod m001_initial;

pub struct Migrator;

#[async_trait::async_trait]
impl MigratorTrait for Migrator {
    fn migrations() -> Vec<Box<dyn MigrationTrait>> {
        vec![
            Box::new(m001_initial::Migration),
        ]
    }
}

Running Migrations

// In main.rs or lib.rs
use migrations::Migrator;
use sea_orm_migration::MigratorTrait;

pub async fn run_migrations(db: &DatabaseConnection) -> Result<(), DbErr> {
    Migrator::up(db, None).await
}

Migration Best Practices

  1. Each migration in separate file: m001_initial.rs, m002_...
  2. Both up() and down(): Always implement rollback
  3. Test migrations: Test both up and down on sample data
  4. Never modify existing migrations after deployment
  5. Always backup before migration
  6. Handle data migration: Use raw SQL for complex data transformations

Checking Migration Status

# Check status
sea-orm-cli migrate status

# Run pending migrations
sea-orm-cli migrate up

# Rollback last migration
sea-orm-cli migrate down

# Rollback all migrations
sea-orm-cli migrate down -n 999

Backup Format (JSON Export)

{
  "version": "1.0.0",
  "exported_at": "2026-02-13T10:30:00Z",
  "schema_version": 1,
  "data": {
    "accounts": [...],
    "tags": [...],
    "transactions": [...],
    "transaction_tags": [...],
    "scheduled_transactions": [...],
    "scheduled_instances": [...],
    "goals": [...],
    "goal_rules": [...],
    "goal_progress": [...],
    "transfers": [...],
    "exchange_rates": [...],
    "reconciliations": [...],
    "settings": [...]
  },
  "checksum": "sha256_hash"
}

Performance Considerations

  1. Indexes: All foreign keys and frequently queried columns indexed
  2. Pagination: Use LIMIT/OFFSET for transaction lists
  3. Lazy loading: Load receipts on demand
  4. Caching: Cache account balances, don't calculate on every read
  5. Archiving: Soft delete instead of hard delete for sync compatibility

Security

  1. Encryption: Optional SQLCipher for database encryption
  2. Key storage: Use OS keychain (iOS Keychain, Android Keystore)
  3. Receipts: Store locally, no cloud upload
  4. No network: Except OCR API calls (opt-in)