castle/account_utils.py
padreug 33c294de7f Removes parent-only accounts
Removes parent accounts from the database to simplify account management.

Since the application exports to Beancount and doesn't directly interface with it, parent accounts for organizational hierarchy aren't necessary. The hierarchy is implicitly derived from the colon-separated account names.

This change cleans the database and prevents accidental postings to parent accounts. Specifically removes "Assets:Bitcoin" and "Equity" accounts.
2025-11-07 23:24:11 +01:00

239 lines
8.4 KiB
Python

"""
Account naming utilities for hierarchical account structure.
Implements Beancount-style account naming conventions.
"""
from typing import Optional
from .models import AccountType
# Mapping from internal account types to Beancount root names
ACCOUNT_TYPE_ROOTS = {
AccountType.ASSET: "Assets",
AccountType.LIABILITY: "Liabilities",
AccountType.EQUITY: "Equity",
AccountType.REVENUE: "Income", # Beancount uses "Income" not "Revenue"
AccountType.EXPENSE: "Expenses",
}
def format_hierarchical_account_name(
account_type: AccountType,
base_name: str,
user_id: Optional[str] = None
) -> str:
"""
Format account name in hierarchical Beancount-style.
Examples:
format_hierarchical_account_name(AccountType.ASSET, "Cash")
"Assets:Cash"
format_hierarchical_account_name(AccountType.ASSET, "Accounts Receivable", "af983632")
"Assets:Receivable:User-af983632"
format_hierarchical_account_name(AccountType.EXPENSE, "Food & Supplies")
"Expenses:Food:Supplies"
Args:
account_type: The type of account (asset, liability, etc.)
base_name: The base name like "Cash", "Accounts Receivable", "Food & Supplies"
user_id: Optional user ID for user-specific accounts
Returns:
Hierarchical account name string
"""
root = ACCOUNT_TYPE_ROOTS[account_type]
# Clean up the base name:
# 1. Remove "Accounts" prefix (e.g., "Accounts Receivable" → "Receivable")
# 2. Replace " & " with ":" for hierarchy (e.g., "Food & Supplies" → "Food:Supplies")
# 3. Remove extra spaces
clean_name = base_name.replace("Accounts ", "").replace(" & ", ":").strip()
# Build hierarchical path
if user_id:
# For user-specific accounts, add user suffix
# "Receivable" + "af983632" → "Receivable:User-af983632"
user_suffix = f"User-{user_id[:8]}"
return f"{root}:{clean_name}:{user_suffix}"
else:
# Regular account
return f"{root}:{clean_name}"
def parse_legacy_account_name(name: str) -> tuple[str, Optional[str]]:
"""
Parse legacy account names like "Accounts Receivable - af983632"
into (base_name, user_id).
Used only for migration from old format to hierarchical.
Args:
name: Legacy account name
Returns:
Tuple of (base_name, user_id or None)
Examples:
parse_legacy_account_name("Accounts Receivable - af983632")
→ ("Accounts Receivable", "af983632")
parse_legacy_account_name("Cash")
→ ("Cash", None)
"""
if " - " in name:
parts = name.split(" - ", 1)
base_name = parts[0].strip()
user_id = parts[1].strip()
return base_name, user_id
else:
return name, None
def format_account_display_name(hierarchical_name: str) -> str:
"""
Convert hierarchical name to human-readable display name.
Examples:
format_account_display_name("Assets:Receivable:User-af983632")
"Accounts Receivable - af983632"
format_account_display_name("Expenses:Food:Supplies")
"Food & Supplies"
Args:
hierarchical_name: Hierarchical account name
Returns:
Human-readable display name
"""
parts = hierarchical_name.split(":")
if len(parts) < 2:
return hierarchical_name
# Skip the root (Assets, Liabilities, etc.)
body_parts = parts[1:]
# Check for user suffix
if len(body_parts) > 1 and body_parts[-1].startswith("User-"):
user_suffix = body_parts[-1].replace("User-", "")
base_parts = body_parts[:-1]
# Reconstruct base name
if base_parts[0] in ["Receivable", "Payable"]:
base_name = f"Accounts {base_parts[0]}"
else:
base_name = " & ".join(base_parts)
return f"{base_name} - {user_suffix}"
else:
# No user suffix, just join with &
return " & ".join(body_parts)
def get_account_type_from_hierarchical(hierarchical_name: str) -> Optional[AccountType]:
"""
Extract account type from hierarchical name.
Examples:
get_account_type_from_hierarchical("Assets:Cash")
→ AccountType.ASSET
get_account_type_from_hierarchical("Income:Accommodation")
→ AccountType.REVENUE
Args:
hierarchical_name: Hierarchical account name
Returns:
AccountType or None if not found
"""
parts = hierarchical_name.split(":")
if not parts:
return None
root = parts[0]
# Reverse lookup in ACCOUNT_TYPE_ROOTS
for account_type, root_name in ACCOUNT_TYPE_ROOTS.items():
if root == root_name:
return account_type
return None
def migrate_account_name(old_name: str, account_type: AccountType) -> str:
"""
Migrate a legacy account name to hierarchical format.
Args:
old_name: Legacy account name like "Accounts Receivable - af983632"
account_type: The account type
Returns:
Hierarchical account name
Examples:
migrate_account_name("Accounts Receivable - af983632", AccountType.ASSET)
"Assets:Receivable:User-af983632"
migrate_account_name("Food & Supplies", AccountType.EXPENSE)
"Expenses:Food:Supplies"
"""
base_name, user_id = parse_legacy_account_name(old_name)
return format_hierarchical_account_name(account_type, base_name, user_id)
# Default chart of accounts with hierarchical names
DEFAULT_HIERARCHICAL_ACCOUNTS = [
# Assets
("Assets:Bank", AccountType.ASSET, "Bank account"),
("Assets:Bitcoin:Lightning", AccountType.ASSET, "Lightning Network balance"),
("Assets:Bitcoin:OnChain", AccountType.ASSET, "On-chain Bitcoin wallet"),
("Assets:Cash", AccountType.ASSET, "Cash on hand"),
("Assets:FixedAssets:Equipment", AccountType.ASSET, "Equipment and machinery"),
("Assets:FixedAssets:FarmEquipment", AccountType.ASSET, "Farm equipment"),
("Assets:FixedAssets:Network", AccountType.ASSET, "Network infrastructure"),
("Assets:FixedAssets:ProductionFacility", AccountType.ASSET, "Production facilities"),
("Assets:Inventory", AccountType.ASSET, "Inventory and stock"),
("Assets:Livestock", AccountType.ASSET, "Livestock and animals"),
("Assets:Receivable", AccountType.ASSET, "Money owed to the Castle"),
("Assets:Tools", AccountType.ASSET, "Tools and hand equipment"),
# Liabilities
("Liabilities:Payable", AccountType.LIABILITY, "Money owed by the Castle"),
# Equity - User equity accounts created dynamically as Equity:User-{user_id}
# No parent "Equity" account needed - hierarchy is implicit in the name
# Revenue (Income in Beancount terminology)
("Income:Accommodation:Guests", AccountType.REVENUE, "Revenue from guest accommodation"),
("Income:Service", AccountType.REVENUE, "Revenue from services"),
("Income:Other", AccountType.REVENUE, "Other revenue"),
# Expenses
("Expenses:Administrative", AccountType.EXPENSE, "Administrative expenses"),
("Expenses:Construction:Materials", AccountType.EXPENSE, "Construction materials"),
("Expenses:Furniture", AccountType.EXPENSE, "Furniture and furnishings"),
("Expenses:Garden", AccountType.EXPENSE, "Garden supplies and materials"),
("Expenses:Gas:Kitchen", AccountType.EXPENSE, "Kitchen gas"),
("Expenses:Gas:Vehicle", AccountType.EXPENSE, "Vehicle gas and fuel"),
("Expenses:Groceries", AccountType.EXPENSE, "Groceries and food"),
("Expenses:Hardware", AccountType.EXPENSE, "Hardware and tools"),
("Expenses:Housewares", AccountType.EXPENSE, "Housewares and household items"),
("Expenses:Insurance", AccountType.EXPENSE, "Insurance premiums"),
("Expenses:Kitchen", AccountType.EXPENSE, "Kitchen supplies and equipment"),
("Expenses:Maintenance:Car", AccountType.EXPENSE, "Car maintenance and repairs"),
("Expenses:Maintenance:Garden", AccountType.EXPENSE, "Garden maintenance"),
("Expenses:Maintenance:Property", AccountType.EXPENSE, "Property maintenance and repairs"),
("Expenses:Membership", AccountType.EXPENSE, "Membership fees"),
("Expenses:Supplies", AccountType.EXPENSE, "General supplies"),
("Expenses:Tools", AccountType.EXPENSE, "Tools and equipment"),
("Expenses:Utilities:Electric", AccountType.EXPENSE, "Electricity"),
("Expenses:Utilities:Internet", AccountType.EXPENSE, "Internet service"),
("Expenses:WebHosting:Domain", AccountType.EXPENSE, "Domain registration"),
("Expenses:WebHosting:Wix", AccountType.EXPENSE, "Wix hosting service"),
]