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:
parent
28832d6bfe
commit
a3c3e44e5f
3 changed files with 69 additions and 6 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -520,7 +520,7 @@
|
|||
icon="add"
|
||||
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>
|
||||
</div>
|
||||
|
||||
|
|
@ -629,7 +629,7 @@
|
|||
|
||||
<!-- No assertions message -->
|
||||
<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>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
|
@ -1185,7 +1185,10 @@
|
|||
<div class="text-h6 q-mb-md">Create Balance Assertion</div>
|
||||
|
||||
<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>
|
||||
|
||||
<q-select
|
||||
|
|
|
|||
36
views_api.py
36
views_api.py
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue