diff --git a/account_sync.py b/account_sync.py index af97525..b5d277f 100644 --- a/account_sync.py +++ b/account_sync.py @@ -94,10 +94,17 @@ 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): + New behavior (soft delete + virtual parents): - 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 + - Missing intermediate parents: Auto-created as virtual accounts + + Virtual parent auto-generation example: + Beancount has: "Expenses:Supplies:Food" + Missing parent: "Expenses:Supplies" (doesn't exist in Beancount) + → Auto-create "Expenses:Supplies" as virtual account + → Enables granting permission on "Expenses:Supplies" to cover all Supplies:* children Args: force_full_sync: If True, re-check all accounts. If False, only add new ones. @@ -112,6 +119,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: "accounts_skipped": 148, "accounts_deactivated": 5, "accounts_reactivated": 1, + "virtual_parents_created": 3, "errors": [] } """ @@ -150,6 +158,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: "accounts_skipped": 0, "accounts_deactivated": 0, "accounts_reactivated": 0, + "virtual_parents_created": 0, "errors": [], } @@ -221,11 +230,50 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: logger.error(error_msg) stats["errors"].append(error_msg) + # Step 3: Auto-generate virtual intermediate parent accounts + # For each account in Beancount, check if all parent levels exist + # If not, create them as virtual accounts + all_account_names = set(castle_accounts_by_name.keys()) + + for bc_account in beancount_accounts: + account_name = bc_account["account"] + parts = account_name.split(":") + + # Check each parent level (e.g., for "Expenses:Supplies:Food", check "Expenses:Supplies") + for i in range(1, len(parts)): + parent_name = ":".join(parts[:i]) + + # Skip if parent already exists + if parent_name in all_account_names: + continue + + # Create virtual parent account + try: + parent_type = infer_account_type_from_name(parent_name) + await create_account( + CreateAccount( + name=parent_name, + account_type=parent_type, + description=f"Auto-generated virtual parent for {parent_name}:* accounts", + is_virtual=True, + ) + ) + + stats["virtual_parents_created"] += 1 + all_account_names.add(parent_name) # Track so we don't create duplicates + logger.info(f"Created virtual parent account: {parent_name}") + + except Exception as e: + error_msg = f"Failed to create virtual parent {parent_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['virtual_parents_created']} virtual parents created, " f"{stats['accounts_skipped']} skipped, " f"{len(stats['errors'])} errors" )