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(
|
def format_posting_with_cost(
|
||||||
account: str,
|
account: str,
|
||||||
amount_sats: int,
|
amount_sats: int,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
36
views_api.py
36
views_api.py
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue