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.
This commit is contained in:
padreug 2025-11-11 01:54:04 +01:00
parent 3af9b44e39
commit cb62cbb0a2
2 changed files with 73 additions and 13 deletions

View file

@ -15,7 +15,12 @@ from datetime import datetime
from typing import Optional from typing import Optional
from loguru import logger 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 .fava_client import get_fava_client
from .models import AccountType, CreateAccount 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 This ensures Castle DB has metadata entries for all accounts that exist
in Beancount, enabling permissions and user associations to work properly. 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: Args:
force_full_sync: If True, re-check all accounts. If False, only add new ones. 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_added": 2,
"accounts_updated": 0, "accounts_updated": 0,
"accounts_skipped": 148, "accounts_skipped": 148,
"accounts_deactivated": 5,
"accounts_reactivated": 1,
"errors": [] "errors": []
} }
""" """
@ -118,12 +130,17 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
"accounts_added": 0, "accounts_added": 0,
"accounts_updated": 0, "accounts_updated": 0,
"accounts_skipped": 0, "accounts_skipped": 0,
"accounts_deactivated": 0,
"accounts_reactivated": 0,
"errors": [str(e)], "errors": [str(e)],
} }
# Get all accounts from Castle DB # Get all accounts from Castle DB
castle_accounts = await get_all_accounts() 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 = { stats = {
"total_beancount_accounts": len(beancount_accounts), "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_added": 0,
"accounts_updated": 0, "accounts_updated": 0,
"accounts_skipped": 0, "accounts_skipped": 0,
"accounts_deactivated": 0,
"accounts_reactivated": 0,
"errors": [], "errors": [],
} }
# Sync each Beancount account to Castle DB # Step 1: Sync accounts from Beancount to Castle DB
for bc_account in beancount_accounts: for bc_account in beancount_accounts:
account_name = bc_account["account"] 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: try:
# Check if account exists (for force_full_sync) existing = castle_accounts_by_name.get(account_name)
existing = await get_account_by_name(account_name)
if existing: if existing:
# Account exists - could update metadata here if needed # Account exists in Castle DB
stats["accounts_skipped"] += 1 # Check if it needs to be reactivated
logger.debug(f"Account already exists: {account_name}") 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 continue
# Create new account in Castle DB # 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) logger.error(error_msg)
stats["errors"].append(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( logger.info(
f"Account sync complete: " f"Account sync complete: "
f"{stats['accounts_added']} added, " f"{stats['accounts_added']} added, "
f"{stats['accounts_reactivated']} reactivated, "
f"{stats['accounts_deactivated']} deactivated, "
f"{stats['accounts_skipped']} skipped, " f"{stats['accounts_skipped']} skipped, "
f"{len(stats['errors'])} errors" f"{len(stats['errors'])} errors"
) )

21
crud.py
View file

@ -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( async def get_or_create_user_account(
user_id: str, account_type: AccountType, base_name: str user_id: str, account_type: AccountType, base_name: str
) -> Account: ) -> Account: