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

@ -2455,9 +2455,17 @@ async def api_create_balance_assertion(
) -> 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,