castle/account_utils.py
padreug 1a28ec59eb Completes Phase 1: Beancount patterns adoption
Implements core improvements from Phase 1 of the Beancount patterns adoption:

- Uses Decimal for fiat amounts to prevent floating point errors
- Adds a meta field to journal entries for a full audit trail
- Adds a flag field to journal entries for transaction status
- Migrates existing account names to a hierarchical format

This commit introduces a database migration to add the `flag` and `meta` columns to the `journal_entries` table. It also includes updates to the models, CRUD operations, and API endpoints to handle the new fields.
2025-10-23 00:17:04 +02:00

215 lines
6.5 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:Cash", AccountType.ASSET, "Cash on hand"),
("Assets:Bank", AccountType.ASSET, "Bank account"),
("Assets:Lightning:Balance", AccountType.ASSET, "Lightning Network balance"),
("Assets:Receivable", AccountType.ASSET, "Money owed to the Castle"),
# Liabilities
("Liabilities:Payable", AccountType.LIABILITY, "Money owed by the Castle"),
# Equity
("Equity:MemberEquity", AccountType.EQUITY, "Member contributions"),
("Equity:RetainedEarnings", AccountType.EQUITY, "Accumulated profits"),
# Revenue (Income in Beancount terminology)
("Income:Accommodation", AccountType.REVENUE, "Revenue from stays"),
("Income:Service", AccountType.REVENUE, "Revenue from services"),
("Income:Other", AccountType.REVENUE, "Other revenue"),
# Expenses
("Expenses:Utilities", AccountType.EXPENSE, "Electricity, water, internet"),
("Expenses:Food:Supplies", AccountType.EXPENSE, "Food and supplies"),
("Expenses:Maintenance", AccountType.EXPENSE, "Repairs and maintenance"),
("Expenses:Other", AccountType.EXPENSE, "Miscellaneous expenses"),
]