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.
This commit is contained in:
parent
35d2057694
commit
1a28ec59eb
7 changed files with 616 additions and 31 deletions
215
account_utils.py
Normal file
215
account_utils.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
"""
|
||||
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"),
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue