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
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"
)