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,