diff --git a/tests/test_admin_chart_accounts_api.py b/tests/test_admin_chart_accounts_api.py index 1f574f9..023835f 100644 --- a/tests/test_admin_chart_accounts_api.py +++ b/tests/test_admin_chart_accounts_api.py @@ -93,6 +93,32 @@ async def test_add_chart_account_duplicate_returns_409( assert "already exists" in second.json().get("detail", "").lower() +@pytest.mark.anyio +async def test_add_chart_account_recovers_ledger_only_account( + client, super_user_headers, +): + """An account present in the ledger but absent from libra's DB (prior sync + failure / out-of-band edit) is recovered (synced), not 409'd — otherwise it + would be permanently un-grantable with no path back. + + Reproduce the ledger-only state by creating normally (so Fava parses the + Open) then deleting only the libra-DB row — appending to the ledger file + directly would race Fava's parse cache.""" + from ..crud import db # the same singleton the app uses + + name = _unique("Expenses:Recover") + first = await add_chart_account(client, super_user_headers=super_user_headers, name=name) + assert first.status_code == 201, f"setup create failed: {first.status_code} {first.text}" + + await db.execute("DELETE FROM accounts WHERE name = :name", {"name": name}) + + r = await add_chart_account(client, super_user_headers=super_user_headers, name=name) + assert r.status_code == 201, f"expected 201 recovery, got {r.status_code}: {r.text}" + body = r.json() + assert body.get("already_existed") is True, body + assert body["synced_to_libra_db"] is True, body + + @pytest.mark.anyio async def test_add_chart_account_invalid_prefix_returns_400( client, super_user_headers, fava_ledger_path, diff --git a/views_api.py b/views_api.py index 3e8647a..1b3149a 100644 --- a/views_api.py +++ b/views_api.py @@ -3750,14 +3750,31 @@ async def api_admin_add_chart_account( metadata=metadata, ) + from .account_sync import sync_single_account_from_beancount + if result.get("already_existed"): - raise HTTPException( - status_code=HTTPStatus.CONFLICT, - detail=f"Account {payload.name} already exists", - ) + # The Open directive is already in the ledger. If it's also already + # mirrored into libra's DB, it's a true duplicate → 409. If not (a prior + # sync failed — there's no cross-DB atomicity — or it was opened out of + # band), mirror it now so it becomes grantable instead of being stranded + # with no recovery path. + from .crud import get_account_by_name + + if await get_account_by_name(payload.name) is not None: + raise HTTPException( + status_code=HTTPStatus.CONFLICT, + detail=f"Account {payload.name} already exists", + ) + + synced = await sync_single_account_from_beancount(payload.name) + return { + "success": True, + "account_name": payload.name, + "synced_to_libra_db": synced, + "already_existed": True, + } # Mirror into libra DB so permissions / metadata layer sees it. - from .account_sync import sync_single_account_from_beancount synced = await sync_single_account_from_beancount(payload.name) return {