From c0c8acbe30ec167ee078f415e263d01a2cb26ac0 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 12 Jul 2026 15:42:44 +0200 Subject: [PATCH] fix(assertions): send Balance directives in Fava's JSON shape (libra-#39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit format_balance returned a Beancount source string, but fava.add_entry feeds PUT /add_entries whose deserialiser expects {"t": "Balance", "amount": {"number", "currency"}, ...} — every assertion create 500'd. Returns the dict shape now. The seven strict-xfail reconciliation tests tracking this flip to regular passing tests. Co-Authored-By: Claude Fable 5 --- beancount_format.py | 25 +++++++++++++++---------- tests/test_reconciliation_api.py | 20 -------------------- 2 files changed, 15 insertions(+), 30 deletions(-) diff --git a/beancount_format.py b/beancount_format.py index 4cbc5c7..fbdfba9 100644 --- a/beancount_format.py +++ b/beancount_format.py @@ -115,13 +115,18 @@ def format_balance( account: str, amount: int, currency: str = "SATS" -) -> str: +) -> Dict[str, Any]: """ - Format a balance assertion directive for Beancount. + Format a balance assertion directive for Fava's JSON API. 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. + Fava's `deserialise` (fava/serialisation.py) expects + `{"t": "Balance", "amount": {"number", "currency"}, ...}` — the + previous source-string return 500'd on every assertion create + (libra-#39). + Args: date_val: Date of the balance assertion account: Account name (e.g., "Assets:Bitcoin:Lightning") @@ -129,15 +134,15 @@ def format_balance( 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' + Fava API Balance entry dict ready for `fava.add_entry`. """ - date_str = date_val.strftime('%Y-%m-%d') - # Two spaces between account and amount (Beancount convention) - return f"{date_str} balance {account} {amount} {currency}" + return { + "t": "Balance", + "date": date_val.strftime('%Y-%m-%d'), + "account": account, + "amount": {"number": str(amount), "currency": currency}, + "meta": {}, + } def format_posting_with_cost( diff --git a/tests/test_reconciliation_api.py b/tests/test_reconciliation_api.py index 66757be..a168ff8 100644 --- a/tests/test_reconciliation_api.py +++ b/tests/test_reconciliation_api.py @@ -18,19 +18,6 @@ from uuid import uuid4 import pytest -# Tests that try to actually create + check an assertion all hit issue #39: -# `format_balance` returns a Beancount source string but `fava.add_entry` -# expects a dict, so Fava 500s on every assertion-create call. The contract -# violation is on libra's side; mark these strict-xfail so they go green -# automatically once #39 lands and the format_balance return shape is fixed. -ASSERTION_CREATE_BROKEN = pytest.mark.xfail( - reason="libra/issues/39 — POST /assertions submits a Beancount source string " - "to Fava's JSON API and 500s. Drop this marker when the format_balance " - "return type is changed to a dict.", - strict=True, -) - - # --------------------------------------------------------------------------- # helpers (local — assertion endpoints don't have wrapper helpers yet) # --------------------------------------------------------------------------- @@ -58,7 +45,6 @@ async def _create_assertion( # --------------------------------------------------------------------------- -@ASSERTION_CREATE_BROKEN @pytest.mark.anyio async def test_assertion_against_empty_account_passes( client, super_user_headers, standard_accounts, @@ -79,7 +65,6 @@ async def test_assertion_against_empty_account_passes( assert body.get("difference_sats", 0) == 0 -@ASSERTION_CREATE_BROKEN @pytest.mark.anyio async def test_assertion_with_wrong_balance_returns_409( client, super_user_headers, standard_accounts, @@ -102,7 +87,6 @@ async def test_assertion_with_wrong_balance_returns_409( assert detail.get("difference_sats") == 999_999 or detail.get("difference_sats") == -999_999 -@ASSERTION_CREATE_BROKEN @pytest.mark.anyio async def test_assertion_with_tolerance_accepts_small_diff( client, super_user_headers, standard_accounts, @@ -119,7 +103,6 @@ async def test_assertion_with_tolerance_accepts_small_diff( assert r.json().get("status") == "passed" -@ASSERTION_CREATE_BROKEN @pytest.mark.anyio async def test_list_assertions_returns_created( client, super_user_headers, standard_accounts, @@ -145,7 +128,6 @@ async def test_list_assertions_returns_created( assert assertion_id in ids, f"created assertion {assertion_id} missing from list {ids}" -@ASSERTION_CREATE_BROKEN @pytest.mark.anyio async def test_get_assertion_by_id( client, super_user_headers, standard_accounts, @@ -167,7 +149,6 @@ async def test_get_assertion_by_id( assert r.json().get("id") == assertion_id -@ASSERTION_CREATE_BROKEN @pytest.mark.anyio async def test_recheck_assertion_via_check_endpoint( client, super_user_headers, standard_accounts, @@ -190,7 +171,6 @@ async def test_recheck_assertion_via_check_endpoint( assert r.json().get("status") == "passed" -@ASSERTION_CREATE_BROKEN @pytest.mark.anyio async def test_delete_assertion_removes_it( client, super_user_headers, standard_accounts,