MODE: PLAN - This document specifies the database schema for the wallet PWA.
Data Plane Specification
- Architecture: Local-First (IndexDB/Dexie) + Firestore Sync
- Pattern: Denormalized Double-Entry Ledger
- Core Principle: The Ledger Entry is the atomic unit; no separate Transaction documents
The wallet uses a denormalized schema with UUID-based entities for offline-first operation. Data is stored locally in IndexDB and optionally synced to user's Firestore instance.
type TransactionStatus = 'projected' | 'confirmed';
type AccountType = 'asset' | 'liability' | 'income' | 'expense' | 'equity';
type CurrencyCode = string; // e.g., "USD", "PEN", "EUR"
type UUID = string; // UUID v4
type ISODate = string; // "2026-02-01T14:30:00.000Z"Purpose: The fundamental building block. Individual splits that share a transaction_id form a complete transaction. No separate Transaction document exists.
interface LedgerEntry {
// === Identity & Sequencing ===
id: UUID; // Primary Key
transaction_id: UUID; // Foreign Key: Links splits together
idx: number; // 0, 1, 2... Preserves visual order of splits
// === Header Data (Denormalized - repeated on every split) ===
date: ISODate; // User-defined date
description: string; // e.g., "Grocery Run"
status: TransactionStatus; // 'projected' | 'confirmed'
recurring_rule_id?: UUID | null; // Linked if auto-generated
search_tags?: string[]; // For fuzzy search optimization
// === The Financial "Triple Truth" ===
// 1. DISPLAY (The Receipt) - Used for Zero-Sum Validation
currency_display: CurrencyCode;
amount_display: number; // Signed (+/-). Sum of group must be 0.
// 2. ACCOUNT (The Bank) - Used for Account Balances
account_id: UUID;
amount_account: number; // Converted to Account's currency
rate_display_to_account: number; // Frozen exchange rate used
// 3. BUDGET (The Plan) - Used for Budget Availability
budget_id?: UUID | null; // Optional "Cost Center"
amount_budget: number | null; // Converted to Budget's currency
rate_display_to_budget: number | null; // Frozen exchange rate used
// === Metadata (Sync logic) ===
created_at: ISODate; // Immutable system time
updated_at: ISODate; // Used for LWW (Last Write Wins) sync
}Key Properties:
id: Globally unique identifier (UUID v4)transaction_id: Groups multiple splits into a logical transactionidx: Order index for displaying splits in UI (0, 1, 2...)status: Distinguishes between projected (forecasted) and confirmed (actual) transactionsamount_display: Signed amount (+/-) where sum of all splits in a transaction must equal 0rate_*: Frozen exchange rates at transaction time (immutable)updated_at: Timestamp for Last-Write-Wins conflict resolution
Validation Rules:
- All entries with same
transaction_idmust sum to zero:SUM(amount_display) = 0 - Amounts can be positive or negative (signed values)
- Exchange rates must be positive numbers
account_idmust reference existing accountbudget_idis optional (can be null)idxmust be sequential within transaction group (0, 1, 2...)
Purpose: Represents financial accounts (bank accounts, credit cards, etc.).
interface Account {
id: UUID;
name: string; // e.g., "Chase Sapphire"
type: AccountType; // 'asset' | 'liability' | 'income' | 'expense' | 'equity'
currency: CurrencyCode; // Immutable after creation
// Logic Flags
include_in_net_worth: boolean; // False for "Budget Exchange" accounts
is_system_default: boolean; // True for "Ready to Assign"
is_archived: boolean;
updated_at: ISODate;
}Account Types:
asset: Things owned (cash, bank accounts, investments)liability: Things owed (credit cards, loans)equity: Net worthincome: Money earnedexpense: Money spent
Logic Flags:
include_in_net_worth: When false, account is excluded from net worth calculations (useful for virtual/system accounts)is_system_default: Marks special system accounts like "Ready to Assign"is_archived: Soft delete flag; archived accounts hidden from UI but data preserved
Validation Rules:
- Account names should be unique (recommended, not enforced)
currencyis immutable after creation (prevents data corruption)- Cannot delete accounts with existing ledger entries (use
is_archived) typemust be one of the five valid AccountType values
Purpose: Represents budget categories or cost centers for tracking spending against targets.
interface Budget {
id: UUID;
name: string; // e.g., "Groceries"
currency: CurrencyCode; // Immutable
is_archived: boolean;
updated_at: ISODate;
}Key Properties:
- Budget tracking is done by querying ledger entries with matching
budget_id - Budget totals are tracked through transactions and recurring transactions to increase budgets
- No predefined period or target amounts - flexible budget management
Validation Rules:
currencyis immutable after creation- Cannot delete budgets with existing ledger entries (use
is_archived)
Purpose: Historical exchange rate tracking with date-based lookup.
interface ExchangeRate {
id: string; // Composite: "USD_PEN_2026-02-01"
from: CurrencyCode;
to: CurrencyCode;
rate: number;
date: string; // YYYY-MM-DD
source: 'manual' | 'api';
updated_at: ISODate;
}Key Properties:
id: Composite key format ensures uniqueness per currency pair per dayrate: Conversion rate (1 from = rate to)source: Tracks whether rate was user-entered or API-fetcheddate: YYYY-MM-DD format for efficient date-based queries
Validation Rules:
ratemust be positive number- Currency codes must be valid ISO 4217 (recommended)
fromandtomust be different currencies- Composite ID format:
{from}_{to}_{date}
Rate Lookup Logic:
- Query rates where from/to match and date <= transaction_date
- Order by date DESC
- Take most recent rate
Purpose: Template for automatic transaction generation based on recurring schedules.
interface RecurringRule {
id: UUID;
title: string; // e.g., "Monthly Rent"
rrule: string; // iCal RRULE string
template_entries: Partial<LedgerEntry>[]; // JSON template for generation
generated_up_to: ISODate; // Checkpoint for projection engine
updated_at: ISODate;
}Key Properties:
rrule: Standard iCalendar RRULE format for recurrence patternstemplate_entries: Array of partial ledger entry templates (without id, date, created_at)generated_up_to: Tracks which dates have been projected to avoid duplicates
Usage:
- Projection engine reads rules and generates
status: 'projected'entries - When user confirms, status changes to
'confirmed' generated_up_toupdates as projections are created
// Defines IndexDB structure and indices for performance
db.version(1).stores({
ledger_entries: `
id,
transaction_id,
date,
status,
updated_at,
account_id,
budget_id,
[account_id+date], // Index for Account Activity Screens
[budget_id+date], // Index for Budget Reports
[transaction_id+idx] // Index for Transaction Reconstruction
`,
accounts: 'id, updated_at',
budgets: 'id, updated_at',
rates: 'id, date, updated_at',
rules: 'id, updated_at'
});Database Name: wallet_db
Stores:
ledger_entries: Main ledger entries (denormalized for performance)accounts: Chart of accountsbudgets: Budget categories and cost centersrates: Historical exchange rate snapshotsrules: Recurring transaction templates
ledger_entries store:
- Primary key:
id(UUID) - Index:
transaction_id- Groups splits together - Index:
date- Date-based queries and reports - Index:
status- Filter by projected/confirmed - Index:
updated_at- LWW sync logic - Index:
account_id- Account-specific queries - Index:
budget_id- Budget-specific queries - Compound index:
[account_id+date]- Optimized for Account Activity Screens - Compound index:
[budget_id+date]- Optimized for Budget Reports - Compound index:
[transaction_id+idx]- Transaction reconstruction with ordering
accounts store:
- Primary key:
id(UUID) - Index:
updated_at- Sync logic
budgets store:
- Primary key:
id(UUID) - Index:
updated_at- Sync logic
rates store:
- Primary key:
id(composite: "USD_PEN_2026-02-01") - Index:
date- Date-based lookups - Index:
updated_at- Sync logic
rules store:
- Primary key:
id(UUID) - Index:
updated_at- Sync logic
ledger_entries/{entryId}
accounts/{accountId}
budgets/{budgetId}
rates/{rateId}
rules/{ruleId}
All documents use the same structure as IndexDB, enabling direct sync.
Note: Users bring their own backend (BYOB). No per-user grouping necessary - each user configures their own Firestore instance. LWW conflict resolution uses the updated_at field on each document.
For each transaction (group of entries with same transaction_id):
SUM(amount_display WHERE transaction_id=X) = 0
Tolerance: ±0.01 (to handle rounding in multi-currency scenarios)
For each ledger entry:
amount_displayis the source of truth for zero-sum validationamount_account = amount_display * rate_display_to_accountamount_budget = amount_display * rate_display_to_budget(ifbudget_idandrate_display_to_budgetare present)
- All
ledger_entries.account_idmust reference existing account - All
ledger_entries.budget_idmust reference existing budget or be null - All
ledger_entries.recurring_rule_idmust reference existing rule or be null
account.currencycannot be changed after creationbudget.currencycannot be changed after creation- Exchange rates (
rate_*fields) in ledger entries cannot be changed after creation - Ledger entries can be edited; corrections can be made directly or via reversing entries
1. Get Transaction (reconstruct from splits):
const entries = await db.ledger_entries
.where('[transaction_id+idx]')
.between([txId, Dexie.minKey], [txId, Dexie.maxKey])
.toArray();2. Get Account Activity:
const activity = await db.ledger_entries
.where('[account_id+date]')
.between([accountId, startDate], [accountId, endDate])
.toArray();3. Get Budget Spending:
const spending = await db.ledger_entries
.where('[budget_id+date]')
.between([budgetId, periodStart], [budgetId, periodEnd])
.toArray();
const total = spending.reduce((sum, e) => sum + e.amount_budget, 0);4. Calculate Account Balance:
const entries = await db.ledger_entries
.where('account_id')
.equals(accountId)
.toArray();
const balance = entries.reduce((sum, e) => sum + e.amount_account, 0);5. Filter Projected vs Confirmed:
const confirmed = await db.ledger_entries
.where('status')
.equals('confirmed')
.toArray();Benefits:
- Fast queries without joins
- Offline-first queries don't need complex logic
- Each ledger entry is self-contained
- Header data (date, description) instantly available without reconstruction
Costs:
- Larger storage footprint (header repeated on every split)
- Updating transaction description requires updating all splits
- Search tags need to be maintained across all splits
Account Balances:
- Cache in memory with Map<account_id, balance>
- Invalidate on any ledger entry change for that account
- Lazy recalculation on next read
Budget Availability:
- Cache per budget per period
- Invalidate when period changes or entries added/modified
- Recalculate on demand
Compound Indexes:
[account_id+date]enables efficient date-range queries per account[budget_id+date]enables efficient period queries per budget[transaction_id+idx]ensures split order is maintained efficiently
Query Optimization:
- Use compound indexes for common filter patterns
- Avoid full table scans by always querying with indexed fields
- Use Dexie's
.where()with compound keys for best performance
Metadata Used:
updated_at: ISO8601 timestamp of last modification
Resolution Logic:
- Compare
updated_atof local vs remote - More recent timestamp wins
- On exact tie: Accept remote (rare edge case)
Sync Flow:
- Query Firestore for documents where
updated_at > last_sync_time - For each document:
- If doesn't exist locally: Insert
- If exists and remote is newer: Update local
- If exists and local is newer: Re-upload to Firestore
- Query local documents where
updated_at > last_sync_time - Upload to Firestore
- Update
last_sync_timecheckpoint
Transaction Atomicity:
When syncing ledger entries, entries with the same transaction_id should be synced together as a logical group to maintain zero-sum invariant.
When schema changes are needed:
- Version the schema: Use IndexDB version number
- Provide migration functions: Transform old data to new structure
- Maintain backwards compatibility: Support reading old formats during transition
- Sync compatibility: Firestore schema must match IndexDB
Example migration:
// Version 1 -> Version 2: Add status field
db.version(2).stores({
ledger_entries: `id, transaction_id, date, status, updated_at, account_id, budget_id, [account_id+date], [budget_id+date], [transaction_id+idx]`,
accounts: 'id, updated_at',
budgets: 'id, updated_at',
rates: 'id, date, updated_at',
rules: 'id, updated_at'
}).upgrade(trans => {
return trans.ledger_entries.toCollection().modify(entry => {
if (!entry.status) {
entry.status = 'confirmed'; // Default old entries to confirmed
}
});
});- IndexDB is domain-scoped (protected by same-origin policy)
- Consider optional encryption for sensitive fields
- Clear data on logout if multi-user device
- User-configured security rules
- Enforce user can only access own data
- Validate data structure server-side
Example Firestore rules:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// BYOB: Users configure their own Firestore instance and rules
// Example: Allow authenticated user to access all collections
match /{collection}/{document} {
allow read, write: if request.auth != null;
// Optional: Additional validation
allow write: if request.auth != null
&& request.resource.data.updated_at is timestamp
&& request.resource.data.id is string;
}
}
}The database schema implements:
- Denormalized Ledger Entries as atomic units (no separate Transaction documents)
- Signed Amounts (+/-) with zero-sum validation per transaction group
- Triple Truth currency representation (display, account, budget)
- Frozen Exchange Rates for historical accuracy
- Status-based Workflow (projected vs confirmed transactions)
- Recurring Rules for automatic transaction generation
- Budget Tracking as first-class entities
- Compound Indexes for optimized queries
- LWW Sync Strategy using
updated_attimestamps
This schema supports the core requirements of offline-first operation, double-entry accounting (via zero-sum validation), multi-currency handling with historical accuracy, and budget tracking.