diff --git a/beancount_format.py b/beancount_format.py index 2819e67..2ffb01b 100644 --- a/beancount_format.py +++ b/beancount_format.py @@ -104,6 +104,36 @@ def format_transaction( } +def format_balance( + date_val: date, + account: str, + amount: int, + currency: str = "SATS" +) -> str: + """ + Format a balance assertion directive for Beancount. + + Balance assertions verify that an account has an expected balance on a specific date. + They are checked automatically by Beancount when the file is loaded. + + Args: + date_val: Date of the balance assertion + account: Account name (e.g., "Assets:Bitcoin:Lightning") + amount: Expected balance amount + currency: Currency code (default: "SATS") + + Returns: + Beancount balance directive as a string + + Example: + >>> format_balance(date(2025, 11, 10), "Assets:Bitcoin:Lightning", 1500000, "SATS") + '2025-11-10 balance Assets:Bitcoin:Lightning 1500000 SATS' + """ + date_str = date_val.strftime('%Y-%m-%d') + # Two spaces between account and amount (Beancount convention) + return f"{date_str} balance {account} {amount} {currency}" + + def format_posting_with_cost( account: str, amount_sats: int, diff --git a/templates/castle/index.html b/templates/castle/index.html index 367f54e..8d2268d 100644 --- a/templates/castle/index.html +++ b/templates/castle/index.html @@ -520,7 +520,7 @@ icon="add" label="Create Assertion" > - Create a new balance assertion for reconciliation + Write a balance assertion to Beancount ledger for automatic validation @@ -629,7 +629,7 @@
- No balance assertions yet. Create one to verify your accounting accuracy. + No balance assertions yet. Create one to add checkpoints to your Beancount ledger and verify accounting accuracy.
@@ -1185,7 +1185,10 @@
Create Balance Assertion
- Balance assertions help you verify accounting accuracy by checking if an account's actual balance matches your expected balance. If the assertion fails, you'll be alerted to investigate the discrepancy. + Balance assertions are written to your Beancount ledger and validated automatically by Beancount. + This verifies that an account's actual balance matches your expected balance at a specific date. + If the assertion fails, Beancount will alert you to investigate the discrepancy. Castle stores + metadata (tolerance, notes) for your convenience.
BalanceAssertion: """ Create a balance assertion for reconciliation (admin only). + + Uses hybrid approach: + 1. Writes balance assertion to Beancount (via Fava) - source of truth + 2. Stores metadata in Castle DB for UI convenience (created_by, notes, tolerance) + 3. Lets Beancount validate the assertion automatically + The assertion will be checked immediately upon creation. """ from lnbits.settings import settings as lnbits_settings + from .fava_client import get_fava_client + from .beancount_format import format_balance if wallet.wallet.user != lnbits_settings.super_user: raise HTTPException( @@ -2473,10 +2481,32 @@ async def api_create_balance_assertion( detail=f"Account {data.account_id} not found", ) - # Create the assertion + assertion_date = data.date or datetime.now() + + # HYBRID APPROACH: Write to Beancount first (source of truth) + balance_directive = format_balance( + date_val=assertion_date.date() if isinstance(assertion_date, datetime) else assertion_date, + account=account.name, + amount=data.expected_balance_sats, + currency="SATS" + ) + + # Submit to Fava/Beancount + try: + fava = get_fava_client() + result = await fava.add_entry(balance_directive) + logger.info(f"Balance assertion submitted to Fava: {result}") + except Exception as e: + logger.error(f"Failed to write balance assertion to Fava: {e}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Failed to write balance assertion to Beancount: {str(e)}" + ) + + # Store metadata in Castle DB for UI convenience assertion = await create_balance_assertion(data, wallet.wallet.user) - # Check it immediately + # Check it immediately (queries Fava for actual balance) try: assertion = await check_balance_assertion(assertion.id) except ValueError as e: @@ -2490,7 +2520,7 @@ async def api_create_balance_assertion( raise HTTPException( status_code=HTTPStatus.CONFLICT, detail={ - "message": "Balance assertion failed", + "message": "Balance assertion failed (validated by Beancount)", "expected_sats": assertion.expected_balance_sats, "actual_sats": assertion.checked_balance_sats, "difference_sats": assertion.difference_sats,