diff --git a/account_sync.py b/account_sync.py index 9591929..af97525 100644 --- a/account_sync.py +++ b/account_sync.py @@ -199,7 +199,12 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: stats["errors"].append(error_msg) # Step 2: Mark orphaned accounts (in Castle DB but not in Beancount) as inactive + # SKIP virtual accounts (they're intentionally metadata-only) for castle_account in castle_accounts: + if castle_account.is_virtual: + # Virtual accounts are metadata-only, never deactivate them + continue + if castle_account.name not in beancount_account_names: # Account no longer exists in Beancount if castle_account.is_active: diff --git a/crud.py b/crud.py index 7423c3c..fbb9317 100644 --- a/crud.py +++ b/crud.py @@ -65,6 +65,7 @@ async def create_account(data: CreateAccount) -> Account: account_type=data.account_type, description=data.description, user_id=data.user_id, + is_virtual=data.is_virtual, created_at=datetime.now(), ) await db.insert("accounts", account) diff --git a/migrations.py b/migrations.py index c36a4aa..c5dd4f3 100644 --- a/migrations.py +++ b/migrations.py @@ -352,3 +352,58 @@ async def m002_add_account_is_active(db): CREATE INDEX idx_accounts_is_active ON accounts (is_active) """ ) + + +async def m003_add_account_is_virtual(db): + """ + Add is_virtual field to accounts table for virtual parent accounts. + + Virtual parent accounts: + - Exist only in Castle DB (metadata-only, not in Beancount) + - Used solely for permission inheritance + - Allow granting permissions on top-level accounts like "Expenses", "Assets" + - Are not synced to/from Beancount + - Cannot be deactivated by account sync (they're intentionally metadata-only) + + Use case: Grant permission on "Expenses" → user gets access to all Expenses:* children + + Default: All existing accounts are real (is_virtual = FALSE). + """ + await db.execute( + """ + ALTER TABLE accounts + ADD COLUMN is_virtual BOOLEAN NOT NULL DEFAULT FALSE + """ + ) + + # Create index for faster queries filtering by is_virtual + await db.execute( + """ + CREATE INDEX idx_accounts_is_virtual ON accounts (is_virtual) + """ + ) + + # Insert default virtual parent accounts for permission management + import uuid + + virtual_parents = [ + ("Assets", "asset", "All asset accounts"), + ("Liabilities", "liability", "All liability accounts"), + ("Equity", "equity", "All equity accounts"), + ("Income", "revenue", "All income accounts"), + ("Expenses", "expense", "All expense accounts"), + ] + + for name, account_type, description in virtual_parents: + await db.execute( + f""" + INSERT INTO accounts (id, name, account_type, description, is_active, is_virtual, created_at) + VALUES (:id, :name, :type, :description, TRUE, TRUE, {db.timestamp_now}) + """, + { + "id": str(uuid.uuid4()), + "name": name, + "type": account_type, + "description": description, + }, + ) diff --git a/models.py b/models.py index 3507988..83bad04 100644 --- a/models.py +++ b/models.py @@ -37,6 +37,7 @@ class Account(BaseModel): user_id: Optional[str] = None # For user-specific accounts created_at: datetime is_active: bool = True # Soft delete flag + is_virtual: bool = False # Virtual parent account (metadata-only, not in Beancount) class CreateAccount(BaseModel): @@ -44,6 +45,7 @@ class CreateAccount(BaseModel): account_type: AccountType description: Optional[str] = None user_id: Optional[str] = None + is_virtual: bool = False # Set to True to create virtual parent account class EntryLine(BaseModel): @@ -342,6 +344,7 @@ class AccountWithPermissions(BaseModel): user_id: Optional[str] = None created_at: datetime is_active: bool = True # Soft delete flag + is_virtual: bool = False # Virtual parent account (metadata-only) # Only included when filter_by_user=true user_permissions: Optional[list[PermissionType]] = None inherited_from: Optional[str] = None # Parent account ID if inherited