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:
padreug 2025-10-23 00:03:32 +02:00
parent 35d2057694
commit 1a28ec59eb
7 changed files with 616 additions and 31 deletions

62
crud.py
View file

@ -49,6 +49,7 @@ async def get_account(account_id: str) -> Optional[Account]:
async def get_account_by_name(name: str) -> Optional[Account]:
"""Get account by name (hierarchical format)"""
return await db.fetchone(
"SELECT * FROM accounts WHERE name = :name",
{"name": name},
@ -74,9 +75,22 @@ async def get_accounts_by_type(account_type: AccountType) -> list[Account]:
async def get_or_create_user_account(
user_id: str, account_type: AccountType, base_name: str
) -> Account:
"""Get or create a user-specific account (e.g., 'Accounts Payable - User123')"""
account_name = f"{base_name} - {user_id[:8]}"
"""
Get or create a user-specific account with hierarchical naming.
Examples:
get_or_create_user_account("af983632", AccountType.ASSET, "Accounts Receivable")
"Assets:Receivable:User-af983632"
get_or_create_user_account("af983632", AccountType.LIABILITY, "Accounts Payable")
"Liabilities:Payable:User-af983632"
"""
from .account_utils import format_hierarchical_account_name
# Generate hierarchical account name
account_name = format_hierarchical_account_name(account_type, base_name, user_id)
# Try to find existing account with this hierarchical name
account = await db.fetchone(
"""
SELECT * FROM accounts
@ -87,6 +101,7 @@ async def get_or_create_user_account(
)
if not account:
# Create new account with hierarchical name
account = await create_account(
CreateAccount(
name=account_name,
@ -126,13 +141,15 @@ async def create_journal_entry(
created_at=datetime.now(),
reference=data.reference,
lines=[],
flag=data.flag,
meta=data.meta,
)
# Insert journal entry without the lines field (lines are stored in entry_lines table)
await db.execute(
"""
INSERT INTO journal_entries (id, description, entry_date, created_by, created_at, reference)
VALUES (:id, :description, :entry_date, :created_by, :created_at, :reference)
INSERT INTO journal_entries (id, description, entry_date, created_by, created_at, reference, flag, meta)
VALUES (:id, :description, :entry_date, :created_by, :created_at, :reference, :flag, :meta)
""",
{
"id": journal_entry.id,
@ -141,6 +158,8 @@ async def create_journal_entry(
"created_by": journal_entry.created_by,
"created_at": journal_entry.created_at,
"reference": journal_entry.reference,
"flag": journal_entry.flag.value,
"meta": json.dumps(journal_entry.meta),
},
)
@ -268,6 +287,11 @@ async def get_journal_entries_by_user(
entries = []
for entry_data in entries_data:
# Parse flag and meta from database
from .models import JournalEntryFlag
flag = JournalEntryFlag(entry_data.get("flag", "*"))
meta = json.loads(entry_data.get("meta", "{}")) if entry_data.get("meta") else {}
entry = JournalEntry(
id=entry_data["id"],
description=entry_data["description"],
@ -275,6 +299,8 @@ async def get_journal_entries_by_user(
created_by=entry_data["created_by"],
created_at=entry_data["created_at"],
reference=entry_data["reference"],
flag=flag,
meta=meta,
lines=[],
)
entry.lines = await get_entry_lines(entry.id)
@ -346,23 +372,27 @@ async def get_user_balance(user_id: str) -> UserBalance:
fiat_amount = metadata.get("fiat_amount")
if fiat_currency and fiat_amount:
from decimal import Decimal
# Initialize currency if not exists
if fiat_currency not in fiat_balances:
fiat_balances[fiat_currency] = 0.0
fiat_balances[fiat_currency] = Decimal("0")
# Convert fiat_amount to Decimal
fiat_decimal = Decimal(str(fiat_amount))
# Calculate fiat balance based on account type
if account.account_type == AccountType.LIABILITY:
# Liability: credit increases (castle owes more), debit decreases
if line["credit"] > 0:
fiat_balances[fiat_currency] += fiat_amount
fiat_balances[fiat_currency] += fiat_decimal
elif line["debit"] > 0:
fiat_balances[fiat_currency] -= fiat_amount
fiat_balances[fiat_currency] -= fiat_decimal
elif account.account_type == AccountType.ASSET:
# Asset (receivable): debit increases (user owes more), credit decreases
if line["debit"] > 0:
fiat_balances[fiat_currency] -= fiat_amount
fiat_balances[fiat_currency] -= fiat_decimal
elif line["credit"] > 0:
fiat_balances[fiat_currency] += fiat_amount
fiat_balances[fiat_currency] += fiat_decimal
# Calculate satoshi balance
# If it's a liability account (castle owes user), it's positive
@ -419,23 +449,27 @@ async def get_all_user_balances() -> list[UserBalance]:
fiat_amount = metadata.get("fiat_amount")
if fiat_currency and fiat_amount:
from decimal import Decimal
# Initialize currency if not exists
if fiat_currency not in fiat_balances:
fiat_balances[fiat_currency] = 0.0
fiat_balances[fiat_currency] = Decimal("0")
# Convert fiat_amount to Decimal
fiat_decimal = Decimal(str(fiat_amount))
# Calculate fiat balance based on account type
if account.account_type == AccountType.LIABILITY:
# Liability: credit increases (castle owes more), debit decreases
if line["credit"] > 0:
fiat_balances[fiat_currency] += fiat_amount
fiat_balances[fiat_currency] += fiat_decimal
elif line["debit"] > 0:
fiat_balances[fiat_currency] -= fiat_amount
fiat_balances[fiat_currency] -= fiat_decimal
elif account.account_type == AccountType.ASSET:
# Asset (receivable): debit increases (user owes more), credit decreases
if line["debit"] > 0:
fiat_balances[fiat_currency] -= fiat_amount
fiat_balances[fiat_currency] -= fiat_decimal
elif line["credit"] > 0:
fiat_balances[fiat_currency] += fiat_amount
fiat_balances[fiat_currency] += fiat_decimal
# Calculate satoshi balance
if account.account_type == AccountType.LIABILITY: