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.
215 lines
6.5 KiB
Python
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"),
|
|
]
|