REFACTOR Migrates to single 'amount' field for transactions

Refactors the data model to use a single 'amount' field for journal entry lines, aligning with the Beancount approach.
This simplifies the model, enhances compatibility, and eliminates invalid states.

Includes a database migration to convert existing debit/credit columns to the new 'amount' field.

Updates balance calculation logic to utilize the new amount field for improved accuracy and efficiency.
This commit is contained in:
padreug 2025-11-08 10:26:14 +01:00
parent 0b50ba0f82
commit 5cc2630777
7 changed files with 196 additions and 144 deletions

View file

@ -55,18 +55,51 @@ class BalanceCalculator:
else:
return total_credit - total_debit
@staticmethod
def calculate_account_balance_from_amount(
total_amount: int,
account_type: AccountType
) -> int:
"""
Calculate account balance from total amount (Beancount-style single amount field).
This method uses Beancount's elegant single amount field approach:
- Positive amounts represent debits (increase assets/expenses)
- Negative amounts represent credits (increase liabilities/equity/revenue)
Args:
total_amount: Sum of all amounts for this account (positive/negative)
account_type: Type of account
Returns:
Balance in satoshis
Examples:
# Asset account with +100 (debit):
calculate_account_balance_from_amount(100, AccountType.ASSET) 100
# Liability account with -100 (credit = liability increase):
calculate_account_balance_from_amount(-100, AccountType.LIABILITY) 100
"""
if account_type in [AccountType.ASSET, AccountType.EXPENSE]:
# For assets and expenses, positive amounts increase balance
return total_amount
else:
# For liabilities, equity, and revenue, negative amounts increase balance
# So we invert the sign for display
return -total_amount
@staticmethod
def build_inventory_from_entry_lines(
entry_lines: List[Dict[str, Any]],
account_type: AccountType
) -> CastleInventory:
"""
Build a CastleInventory from journal entry lines.
Build a CastleInventory from journal entry lines (Beancount-style with single amount field).
Args:
entry_lines: List of entry line dictionaries with keys:
- debit: int (satoshis)
- credit: int (satoshis)
- amount: int (satoshis; positive = debit, negative = credit)
- metadata: str (JSON string with optional fiat_currency, fiat_amount)
account_type: Type of account (affects sign of amounts)
@ -86,33 +119,17 @@ class BalanceCalculator:
# Convert fiat amount to Decimal
fiat_amount = Decimal(str(fiat_amount_raw)) if fiat_amount_raw else None
# Calculate amount based on debit/credit and account type
debit = line.get("debit", 0)
credit = line.get("credit", 0)
# Get amount (Beancount-style: positive = debit, negative = credit)
amount = line.get("amount", 0)
if debit > 0:
sats_amount = Decimal(debit)
# For liability accounts: debit decreases balance (negative)
# For asset accounts: debit increases balance (positive)
if account_type == AccountType.LIABILITY:
sats_amount = -sats_amount
fiat_amount = -fiat_amount if fiat_amount else None
if amount != 0:
sats_amount = Decimal(amount)
inventory.add_position(
CastlePosition(
currency="SATS",
amount=sats_amount,
cost_currency=fiat_currency,
cost_amount=fiat_amount,
metadata=metadata,
)
)
if credit > 0:
sats_amount = Decimal(credit)
# For liability accounts: credit increases balance (positive)
# For asset accounts: credit decreases balance (negative)
if account_type == AccountType.ASSET:
# Apply account-specific sign adjustment
# For liability/equity/revenue: negative amounts increase balance
# For assets/expenses: positive amounts increase balance
if account_type in [AccountType.LIABILITY, AccountType.EQUITY, AccountType.REVENUE]:
# Invert sign for liability-type accounts
sats_amount = -sats_amount
fiat_amount = -fiat_amount if fiat_amount else None

View file

@ -23,13 +23,13 @@ def validate_journal_entry(
entry_lines: List[Dict[str, Any]]
) -> None:
"""
Validate a journal entry and its lines.
Validate a journal entry and its lines (Beancount-style with single amount field).
Checks:
1. Entry must have at least 2 lines (double-entry requirement)
2. Entry must be balanced (sum of debits = sum of credits)
3. All lines must have valid amounts (non-negative)
4. All lines must have account_id
2. Entry must be balanced (sum of amounts = 0)
3. All lines must have account_id
4. No line should have amount = 0 (would serve no purpose)
Args:
entry: Journal entry dict with keys:
@ -38,8 +38,7 @@ def validate_journal_entry(
- entry_date: datetime
entry_lines: List of entry line dicts with keys:
- account_id: str
- debit: int
- credit: int
- amount: int (positive = debit, negative = credit)
Raises:
ValidationError: If validation fails
@ -66,64 +65,30 @@ def validate_journal_entry(
}
)
# Check amounts are non-negative
debit = line.get("debit", 0)
credit = line.get("credit", 0)
# Get amount (Beancount-style: positive = debit, negative = credit)
amount = line.get("amount", 0)
if debit < 0:
# Check that amount is non-zero (zero amounts serve no purpose)
if amount == 0:
raise ValidationError(
f"Entry line {i + 1} has negative debit: {debit}",
{
"entry_id": entry.get("id"),
"line_index": i,
"debit": debit,
}
)
if credit < 0:
raise ValidationError(
f"Entry line {i + 1} has negative credit: {credit}",
{
"entry_id": entry.get("id"),
"line_index": i,
"credit": credit,
}
)
# Check that a line doesn't have both debit and credit
if debit > 0 and credit > 0:
raise ValidationError(
f"Entry line {i + 1} has both debit and credit",
{
"entry_id": entry.get("id"),
"line_index": i,
"debit": debit,
"credit": credit,
}
)
# Check that a line has at least one non-zero amount
if debit == 0 and credit == 0:
raise ValidationError(
f"Entry line {i + 1} has both debit and credit as zero",
f"Entry line {i + 1} has amount = 0 (serves no purpose)",
{
"entry_id": entry.get("id"),
"line_index": i,
}
)
# Check entry is balanced
total_debits = sum(line.get("debit", 0) for line in entry_lines)
total_credits = sum(line.get("credit", 0) for line in entry_lines)
# Check entry is balanced (sum of amounts must equal 0)
# Beancount-style: positive amounts cancel out negative amounts
total_amount = sum(line.get("amount", 0) for line in entry_lines)
if total_debits != total_credits:
if total_amount != 0:
raise ValidationError(
"Journal entry is not balanced",
"Journal entry is not balanced (sum of amounts must equal 0)",
{
"entry_id": entry.get("id"),
"total_debits": total_debits,
"total_credits": total_credits,
"difference": total_debits - total_credits,
"total_amount": total_amount,
"line_count": len(entry_lines),
}
)