diff --git a/core/balance.py b/core/balance.py index 1c4a03c..d93c7c2 100644 --- a/core/balance.py +++ b/core/balance.py @@ -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 diff --git a/core/validation.py b/core/validation.py index 75cec02..d2372b8 100644 --- a/core/validation.py +++ b/core/validation.py @@ -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), } ) diff --git a/crud.py b/crud.py index 6837d5c..53d7aae 100644 --- a/crud.py +++ b/crud.py @@ -142,13 +142,13 @@ async def create_journal_entry( ) -> JournalEntry: entry_id = urlsafe_short_hash() - # Validate that debits equal credits - total_debits = sum(line.debit for line in data.lines) - total_credits = sum(line.credit for line in data.lines) + # Validate that entry balances (sum of all amounts = 0) + # Beancount-style: positive amounts cancel out negative amounts + total_amount = sum(line.amount for line in data.lines) - if total_debits != total_credits: + if total_amount != 0: raise ValueError( - f"Journal entry must balance: debits={total_debits}, credits={total_credits}" + f"Journal entry must balance (sum of amounts = 0): sum={total_amount}" ) entry_date = data.entry_date or datetime.now() @@ -191,23 +191,21 @@ async def create_journal_entry( id=line_id, journal_entry_id=entry_id, account_id=line_data.account_id, - debit=line_data.debit, - credit=line_data.credit, + amount=line_data.amount, description=line_data.description, metadata=line_data.metadata, ) # Insert with metadata as JSON string await db.execute( """ - INSERT INTO entry_lines (id, journal_entry_id, account_id, debit, credit, description, metadata) - VALUES (:id, :journal_entry_id, :account_id, :debit, :credit, :description, :metadata) + INSERT INTO entry_lines (id, journal_entry_id, account_id, amount, description, metadata) + VALUES (:id, :journal_entry_id, :account_id, :amount, :description, :metadata) """, { "id": line.id, "journal_entry_id": line.journal_entry_id, "account_id": line.account_id, - "debit": line.debit, - "credit": line.credit, + "amount": line.amount, "description": line.description, "metadata": json.dumps(line.metadata), }, @@ -259,8 +257,7 @@ async def get_entry_lines(journal_entry_id: str) -> list[EntryLine]: id=row.id, journal_entry_id=row.journal_entry_id, account_id=row.account_id, - debit=row.debit, - credit=row.credit, + amount=row.amount, description=row.description, metadata=metadata, ) @@ -364,13 +361,21 @@ async def get_journal_entries_by_user( async def get_account_balance(account_id: str) -> int: - """Calculate account balance (debits - credits for assets/expenses, credits - debits for liabilities/equity/revenue) - Only includes entries that are cleared (flag='*'), excludes pending/flagged/voided entries.""" + """ + Calculate account balance using single amount field (Beancount-style). + Only includes entries that are cleared (flag='*'), excludes pending/flagged/voided entries. + + For each account type: + - Assets/Expenses: balance = sum of amounts (positive amounts increase, negative decrease) + - Liabilities/Equity/Revenue: balance = -sum of amounts (negative amounts increase, positive decrease) + + This works because we store amounts consistently: + - Debit (asset/expense increase) = positive amount + - Credit (liability/equity/revenue increase) = negative amount + """ result = await db.fetchone( """ - SELECT - COALESCE(SUM(el.debit), 0) as total_debit, - COALESCE(SUM(el.credit), 0) as total_credit + SELECT COALESCE(SUM(el.amount), 0) as total_amount FROM entry_lines el JOIN journal_entries je ON el.journal_entry_id = je.id WHERE el.account_id = :id @@ -386,13 +391,12 @@ async def get_account_balance(account_id: str) -> int: if not account: return 0 - total_debit = result["total_debit"] - total_credit = result["total_credit"] + total_amount = result["total_amount"] # Use core BalanceCalculator for consistent logic core_account_type = CoreAccountType(account.account_type.value) - return BalanceCalculator.calculate_account_balance( - total_debit, total_credit, core_account_type + return BalanceCalculator.calculate_account_balance_from_amount( + total_amount, core_account_type ) @@ -500,8 +504,7 @@ async def get_account_transactions( id=row.id, journal_entry_id=row.journal_entry_id, account_id=row.account_id, - debit=row.debit, - credit=row.credit, + amount=row.amount, description=row.description, metadata=metadata, ) diff --git a/migrations.py b/migrations.py index 3527ac9..e3b6f4d 100644 --- a/migrations.py +++ b/migrations.py @@ -541,3 +541,90 @@ async def m014_remove_legacy_equity_accounts(db): "DELETE FROM accounts WHERE name = :name", {"name": "Equity:RetainedEarnings"} ) + + +async def m015_convert_to_single_amount_field(db): + """ + Convert entry_lines from separate debit/credit columns to single amount field. + + This aligns Castle with Beancount's elegant design: + - Positive amount = debit (increase assets/expenses, decrease liabilities/equity/revenue) + - Negative amount = credit (decrease assets/expenses, increase liabilities/equity/revenue) + + Benefits: + - Simpler model (one field instead of two) + - Direct compatibility with Beancount import/export + - Eliminates invalid states (both debit and credit non-zero) + - More intuitive for programmers (positive/negative instead of accounting conventions) + + Migration formula: amount = debit - credit + + Examples: + - Expense transaction: + * Expenses:Food:Groceries amount=+100 (debit) + * Liabilities:Payable:User amount=-100 (credit) + - Payment transaction: + * Liabilities:Payable:User amount=+100 (debit) + * Assets:Bitcoin:Lightning amount=-100 (credit) + """ + from sqlalchemy.exc import OperationalError + + # Step 1: Add new amount column (nullable for migration) + try: + await db.execute( + "ALTER TABLE entry_lines ADD COLUMN amount INTEGER" + ) + except OperationalError: + # Column might already exist if migration was partially run + pass + + # Step 2: Populate amount from existing debit/credit + # Formula: amount = debit - credit + await db.execute( + """ + UPDATE entry_lines + SET amount = debit - credit + WHERE amount IS NULL + """ + ) + + # Step 3: Create new table with amount field as NOT NULL + # SQLite doesn't support ALTER COLUMN, so we need to recreate the table + await db.execute( + """ + CREATE TABLE entry_lines_new ( + id TEXT PRIMARY KEY, + journal_entry_id TEXT NOT NULL, + account_id TEXT NOT NULL, + amount INTEGER NOT NULL, + description TEXT, + metadata TEXT DEFAULT '{}' + ) + """ + ) + + # Step 4: Copy data from old table to new + await db.execute( + """ + INSERT INTO entry_lines_new (id, journal_entry_id, account_id, amount, description, metadata) + SELECT id, journal_entry_id, account_id, amount, description, metadata + FROM entry_lines + """ + ) + + # Step 5: Drop old table and rename new one + await db.execute("DROP TABLE entry_lines") + await db.execute("ALTER TABLE entry_lines_new RENAME TO entry_lines") + + # Step 6: Recreate indexes + await db.execute( + """ + CREATE INDEX idx_entry_lines_journal_entry ON entry_lines (journal_entry_id) + """ + ) + + await db.execute( + """ + CREATE INDEX idx_entry_lines_account ON entry_lines (account_id) + """ + ) diff --git a/models.py b/models.py index 317c44d..4402656 100644 --- a/models.py +++ b/models.py @@ -42,16 +42,14 @@ class EntryLine(BaseModel): id: str journal_entry_id: str account_id: str - debit: int = 0 # in satoshis - credit: int = 0 # in satoshis + amount: int # in satoshis; positive = debit, negative = credit description: Optional[str] = None metadata: dict = {} # Stores currency info: fiat_currency, fiat_amount, fiat_rate, etc. class CreateEntryLine(BaseModel): account_id: str - debit: int = 0 - credit: int = 0 + amount: int # in satoshis; positive = debit, negative = credit description: Optional[str] = None metadata: dict = {} # Stores currency info diff --git a/tasks.py b/tasks.py index 32333e1..3dcb2ca 100644 --- a/tasks.py +++ b/tasks.py @@ -207,15 +207,13 @@ async def on_invoice_paid(payment: Payment) -> None: lines=[ CreateEntryLine( account_id=lightning_account.id, - debit=amount_sats, - credit=0, + amount=amount_sats, # Positive = debit (asset increase) description="Lightning payment received", metadata=line_metadata, ), CreateEntryLine( account_id=user_receivable.id, - debit=0, - credit=amount_sats, + amount=-amount_sats, # Negative = credit (asset decrease - receivable settled) description="Payment applied to balance", metadata=line_metadata, ), diff --git a/views_api.py b/views_api.py index 4ab8cff..a36d78f 100644 --- a/views_api.py +++ b/views_api.py @@ -439,15 +439,13 @@ async def api_create_expense_entry( lines=[ CreateEntryLine( account_id=expense_account.id, - debit=amount_sats, - credit=0, + amount=amount_sats, # Positive = debit (expense increase) description=f"Expense paid by user {wallet.wallet.user[:8]}", metadata=metadata, ), CreateEntryLine( account_id=user_account.id, - debit=0, - credit=amount_sats, + amount=-amount_sats, # Negative = credit (liability/equity increase) description=f"{'Equity contribution' if data.is_equity else 'Amount owed to user'}", metadata=metadata, ), @@ -525,15 +523,13 @@ async def api_create_receivable_entry( lines=[ CreateEntryLine( account_id=user_receivable.id, - debit=amount_sats, - credit=0, + amount=amount_sats, # Positive = debit (asset increase - user owes castle) description=f"Amount owed by user {data.user_id[:8]}", metadata=metadata, ), CreateEntryLine( account_id=revenue_account.id, - debit=0, - credit=amount_sats, + amount=-amount_sats, # Negative = credit (revenue increase) description="Revenue earned", metadata=metadata, ), @@ -580,14 +576,12 @@ async def api_create_revenue_entry( lines=[ CreateEntryLine( account_id=payment_account.id, - debit=data.amount, - credit=0, + amount=data.amount, # Positive = debit (asset increase) description="Payment received", ), CreateEntryLine( account_id=revenue_account.id, - debit=0, - credit=data.amount, + amount=-data.amount, # Negative = credit (revenue increase) description="Revenue earned", ), ], @@ -871,15 +865,13 @@ async def api_record_payment( lines=[ CreateEntryLine( account_id=lightning_account.id, - debit=amount_sats, - credit=0, + amount=amount_sats, # Positive = debit (asset increase) description="Lightning payment received", metadata=line_metadata, ), CreateEntryLine( account_id=user_receivable.id, - debit=0, - credit=amount_sats, + amount=-amount_sats, # Negative = credit (asset decrease - receivable settled) description="Payment applied to balance", metadata=line_metadata, ), @@ -927,14 +919,12 @@ async def api_pay_user( lines=[ CreateEntryLine( account_id=user_payable.id, - debit=amount, - credit=0, + amount=amount, # Positive = debit (liability decrease) description="Payment made to user", ), CreateEntryLine( account_id=lightning_account.id, - debit=0, - credit=amount, + amount=-amount, # Negative = credit (asset decrease) description="Lightning payment sent", ), ], @@ -1070,15 +1060,13 @@ async def api_settle_receivable( lines=[ CreateEntryLine( account_id=payment_account.id, - debit=amount_in_sats, - credit=0, + amount=amount_in_sats, # Positive = debit (asset increase) description=f"Payment received via {data.payment_method}", metadata=line_metadata, ), CreateEntryLine( account_id=user_receivable.id, - debit=0, - credit=amount_in_sats, + amount=-amount_in_sats, # Negative = credit (asset decrease - receivable settled) description="Receivable settled", metadata=line_metadata, ), @@ -1216,15 +1204,13 @@ async def api_pay_user( lines=[ CreateEntryLine( account_id=user_payable.id, - debit=amount_in_sats, - credit=0, + amount=amount_in_sats, # Positive = debit (liability decrease) description="Payable settled", metadata=line_metadata, ), CreateEntryLine( account_id=payment_account.id, - debit=0, - credit=amount_in_sats, + amount=-amount_in_sats, # Negative = credit (asset decrease) description=f"Payment sent via {data.payment_method}", metadata=line_metadata, ), @@ -1510,14 +1496,12 @@ async def api_approve_manual_payment_request( lines=[ CreateEntryLine( account_id=liability_account.id, - debit=request.amount, # Decrease liability (castle owes less) - credit=0, + amount=request.amount, # Positive = debit (liability decrease - castle owes less) description="Payment to user", ), CreateEntryLine( account_id=lightning_account.id, - debit=0, - credit=request.amount, # Decrease asset (lightning balance reduced) + amount=-request.amount, # Negative = credit (asset decrease) description="Payment from castle", ), ],