diff --git a/account_sync.py b/account_sync.py index 699a875..990a099 100644 --- a/account_sync.py +++ b/account_sync.py @@ -15,7 +15,12 @@ from datetime import datetime from typing import Optional from loguru import logger -from .crud import create_account, get_account_by_name, get_all_accounts +from .crud import ( + create_account, + get_account_by_name, + get_all_accounts, + update_account_is_active, +) from .fava_client import get_fava_client from .models import AccountType, CreateAccount @@ -89,6 +94,11 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: This ensures Castle DB has metadata entries for all accounts that exist in Beancount, enabling permissions and user associations to work properly. + New behavior (soft delete): + - Accounts in Beancount but not in Castle DB: Added as active + - Accounts in Castle DB but not in Beancount: Marked as inactive (soft delete) + - Inactive accounts that return to Beancount: Reactivated + Args: force_full_sync: If True, re-check all accounts. If False, only add new ones. @@ -100,6 +110,8 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: "accounts_added": 2, "accounts_updated": 0, "accounts_skipped": 148, + "accounts_deactivated": 5, + "accounts_reactivated": 1, "errors": [] } """ @@ -118,12 +130,17 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: "accounts_added": 0, "accounts_updated": 0, "accounts_skipped": 0, + "accounts_deactivated": 0, + "accounts_reactivated": 0, "errors": [str(e)], } # Get all accounts from Castle DB castle_accounts = await get_all_accounts() - castle_account_names = {acc.name for acc in castle_accounts} + + # Build lookup maps + beancount_account_names = {acc["account"] for acc in beancount_accounts} + castle_accounts_by_name = {acc.name: acc for acc in castle_accounts} stats = { "total_beancount_accounts": len(beancount_accounts), @@ -131,26 +148,28 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: "accounts_added": 0, "accounts_updated": 0, "accounts_skipped": 0, + "accounts_deactivated": 0, + "accounts_reactivated": 0, "errors": [], } - # Sync each Beancount account to Castle DB + # Step 1: Sync accounts from Beancount to Castle DB for bc_account in beancount_accounts: account_name = bc_account["account"] - # Skip if already in Castle DB (unless force_full_sync) - if account_name in castle_account_names and not force_full_sync: - stats["accounts_skipped"] += 1 - continue - try: - # Check if account exists (for force_full_sync) - existing = await get_account_by_name(account_name) + existing = castle_accounts_by_name.get(account_name) if existing: - # Account exists - could update metadata here if needed - stats["accounts_skipped"] += 1 - logger.debug(f"Account already exists: {account_name}") + # Account exists in Castle DB + # Check if it needs to be reactivated + if not existing.is_active: + await update_account_is_active(existing.id, True) + stats["accounts_reactivated"] += 1 + logger.info(f"Reactivated account: {account_name}") + else: + stats["accounts_skipped"] += 1 + logger.debug(f"Account already active: {account_name}") continue # Create new account in Castle DB @@ -179,9 +198,29 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: logger.error(error_msg) stats["errors"].append(error_msg) + # Step 2: Mark orphaned accounts (in Castle DB but not in Beancount) as inactive + for castle_account in castle_accounts: + if castle_account.name not in beancount_account_names: + # Account no longer exists in Beancount + if castle_account.is_active: + try: + await update_account_is_active(castle_account.id, False) + stats["accounts_deactivated"] += 1 + logger.info( + f"Deactivated orphaned account: {castle_account.name}" + ) + except Exception as e: + error_msg = ( + f"Failed to deactivate account {castle_account.name}: {e}" + ) + logger.error(error_msg) + stats["errors"].append(error_msg) + logger.info( f"Account sync complete: " f"{stats['accounts_added']} added, " + f"{stats['accounts_reactivated']} reactivated, " + f"{stats['accounts_deactivated']} deactivated, " f"{stats['accounts_skipped']} skipped, " f"{len(stats['errors'])} errors" ) diff --git a/crud.py b/crud.py index c4b418f..11cfeb8 100644 --- a/crud.py +++ b/crud.py @@ -135,6 +135,27 @@ async def get_accounts_by_type(account_type: AccountType) -> list[Account]: ) +async def update_account_is_active(account_id: str, is_active: bool) -> None: + """ + Update the is_active status of an account (soft delete/reactivate). + + Args: + account_id: Account ID to update + is_active: True to activate, False to deactivate + """ + await db.execute( + """ + UPDATE accounts + SET is_active = :is_active + WHERE id = :account_id + """, + {"account_id": account_id, "is_active": is_active}, + ) + + # Invalidate cache + account_cache._values.pop(f"account:id:{account_id}", None) + + async def get_or_create_user_account( user_id: str, account_type: AccountType, base_name: str ) -> Account: