Auto-generate virtual intermediate parent accounts during sync

Automatically creates missing intermediate parent accounts as virtual accounts.

Problem:
- Beancount has: Expenses:Supplies:Food, Expenses:Supplies:Office
- Beancount does NOT have: Expenses:Supplies (intermediate parent)
- Admin wants to grant permission on "Expenses:Supplies" to cover all Supplies:* accounts
- But Expenses:Supplies doesn't exist in Castle DB

Solution:
During account sync, for each Beancount account, check if all parent levels exist.
If any parent is missing, auto-create it as a virtual account.

Example:
  Beancount accounts:
  - Expenses:Supplies:Food
  - Expenses:Supplies:Office
  - Expenses:Gas:Kitchen

  Auto-generated virtual parents:
  - Expenses:Supplies (virtual)
  - Expenses:Gas (virtual)
  - (Expenses already exists from migration)

Benefits:
- No manual creation needed
- Always stays in sync with Beancount structure
- Enables hierarchical permission grants at any level
- Admin can now grant on "Expenses:Supplies" → user gets all Supplies:* children

Changes:
- Add Step 3 to sync: Auto-generate virtual intermediate parents
- Track stats['virtual_parents_created']
- Skip parents that already exist (check all_account_names set)
- Infer account type from parent name (e.g., Expenses:* → EXPENSE)
- Mark auto-generated accounts with descriptive description

🤖 Generated with Claude Code (https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
padreug 2025-11-11 02:48:06 +01:00
parent 2ebc9af798
commit fa92295513

View file

@ -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 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): New behavior (soft delete + virtual parents):
- Accounts in Beancount but not in Castle DB: Added as active - Accounts in Beancount but not in Castle DB: Added as active
- Accounts in Castle DB but not in Beancount: Marked as inactive (soft delete) - Accounts in Castle DB but not in Beancount: Marked as inactive (soft delete)
- Inactive accounts that return to Beancount: Reactivated - 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: 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.
@ -112,6 +119,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
"accounts_skipped": 148, "accounts_skipped": 148,
"accounts_deactivated": 5, "accounts_deactivated": 5,
"accounts_reactivated": 1, "accounts_reactivated": 1,
"virtual_parents_created": 3,
"errors": [] "errors": []
} }
""" """
@ -150,6 +158,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
"accounts_skipped": 0, "accounts_skipped": 0,
"accounts_deactivated": 0, "accounts_deactivated": 0,
"accounts_reactivated": 0, "accounts_reactivated": 0,
"virtual_parents_created": 0,
"errors": [], "errors": [],
} }
@ -221,11 +230,50 @@ 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 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( 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_reactivated']} reactivated, "
f"{stats['accounts_deactivated']} deactivated, " f"{stats['accounts_deactivated']} deactivated, "
f"{stats['virtual_parents_created']} virtual parents created, "
f"{stats['accounts_skipped']} skipped, " f"{stats['accounts_skipped']} skipped, "
f"{len(stats['errors'])} errors" f"{len(stats['errors'])} errors"
) )