From cb62cbb0a23cea5b781df86f536054ad5da87ae7 Mon Sep 17 00:00:00 2001 From: padreug Date: Tue, 11 Nov 2025 01:54:04 +0100 Subject: [PATCH] Update account sync to mark orphaned accounts as inactive - Added update_account_is_active() function in crud.py - Updated sync_accounts_from_beancount() to: * Mark accounts in Castle DB but not in Beancount as inactive * Reactivate accounts that return to Beancount * Track deactivated and reactivated counts in sync stats - Improved sync efficiency with lookup maps - Enhanced logging for deactivation/reactivation events This completes the soft delete implementation for orphaned accounts. When accounts are removed from the Beancount ledger, they are now automatically marked as inactive in Castle DB during the hourly sync. --- account_sync.py | 65 +++++++++++++++++++++++++++++++++++++++---------- crud.py | 21 ++++++++++++++++ 2 files changed, 73 insertions(+), 13 deletions(-) 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: