Implement hybrid approach for balance assertions

Balance assertions now use a hybrid architecture where Beancount is the source
of truth for validation, while Castle stores metadata for UI convenience.

Backend changes:
- Add format_balance() function to beancount_format.py for formatting balance directives
- Update POST /api/v1/assertions to write balance directive to Beancount first (via Fava)
- Store metadata in Castle DB (created_by, tolerance, notes) for UI features
- Validate assertions immediately by querying Fava for actual balance

Frontend changes:
- Update dialog description to explain Beancount validation
- Update button tooltip to clarify balance assertions are written to Beancount
- Update empty state message to mention Beancount checkpoints

Benefits:
- Single source of truth (Beancount ledger file)
- Automatic validation by Beancount
- Best of both worlds: robust validation + friendly UI

See misc-docs/BALANCE-ASSERTIONS-HYBRID-APPROACH.md for full documentation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
padreug 2025-11-10 20:46:12 +01:00
parent 28832d6bfe
commit a3c3e44e5f
3 changed files with 69 additions and 6 deletions

View file

@ -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( def format_posting_with_cost(
account: str, account: str,
amount_sats: int, amount_sats: int,

View file

@ -520,7 +520,7 @@
icon="add" icon="add"
label="Create Assertion" label="Create Assertion"
> >
<q-tooltip>Create a new balance assertion for reconciliation</q-tooltip> <q-tooltip>Write a balance assertion to Beancount ledger for automatic validation</q-tooltip>
</q-btn> </q-btn>
</div> </div>
@ -629,7 +629,7 @@
<!-- No assertions message --> <!-- No assertions message -->
<div v-if="balanceAssertions.length === 0" class="text-center text-grey q-pa-md"> <div v-if="balanceAssertions.length === 0" class="text-center text-grey q-pa-md">
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.
</div> </div>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -1185,7 +1185,10 @@
<div class="text-h6 q-mb-md">Create Balance Assertion</div> <div class="text-h6 q-mb-md">Create Balance Assertion</div>
<div class="text-caption text-grey q-mb-md"> <div class="text-caption text-grey q-mb-md">
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.
</div> </div>
<q-select <q-select

View file

@ -2455,9 +2455,17 @@ async def api_create_balance_assertion(
) -> BalanceAssertion: ) -> BalanceAssertion:
""" """
Create a balance assertion for reconciliation (admin only). 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. The assertion will be checked immediately upon creation.
""" """
from lnbits.settings import settings as lnbits_settings 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: if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException( raise HTTPException(
@ -2473,10 +2481,32 @@ async def api_create_balance_assertion(
detail=f"Account {data.account_id} not found", 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) assertion = await create_balance_assertion(data, wallet.wallet.user)
# Check it immediately # Check it immediately (queries Fava for actual balance)
try: try:
assertion = await check_balance_assertion(assertion.id) assertion = await check_balance_assertion(assertion.id)
except ValueError as e: except ValueError as e:
@ -2490,7 +2520,7 @@ async def api_create_balance_assertion(
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.CONFLICT, status_code=HTTPStatus.CONFLICT,
detail={ detail={
"message": "Balance assertion failed", "message": "Balance assertion failed (validated by Beancount)",
"expected_sats": assertion.expected_balance_sats, "expected_sats": assertion.expected_balance_sats,
"actual_sats": assertion.checked_balance_sats, "actual_sats": assertion.checked_balance_sats,
"difference_sats": assertion.difference_sats, "difference_sats": assertion.difference_sats,