fix(accounts): recover ledger-only account instead of blanket 409 (libra-#50)

When add_account reported the Open already existed, the endpoint raised
409 before the DB-mirror step — so an account present in the ledger but
missing from libra's DB (a prior sync failure with no cross-DB atomicity,
or an out-of-band open) was stranded: invisible to permissions with no
recovery path. Now 409 only when the account is already in the DB too;
otherwise sync it and return success. Adds a recovery test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-17 10:08:47 +02:00
commit 39440b75a7
2 changed files with 48 additions and 5 deletions

View file

@ -93,6 +93,32 @@ async def test_add_chart_account_duplicate_returns_409(
assert "already exists" in second.json().get("detail", "").lower() 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 @pytest.mark.anyio
async def test_add_chart_account_invalid_prefix_returns_400( async def test_add_chart_account_invalid_prefix_returns_400(
client, super_user_headers, fava_ledger_path, client, super_user_headers, fava_ledger_path,

View file

@ -3750,14 +3750,31 @@ async def api_admin_add_chart_account(
metadata=metadata, metadata=metadata,
) )
from .account_sync import sync_single_account_from_beancount
if result.get("already_existed"): if result.get("already_existed"):
raise HTTPException( # The Open directive is already in the ledger. If it's also already
status_code=HTTPStatus.CONFLICT, # mirrored into libra's DB, it's a true duplicate → 409. If not (a prior
detail=f"Account {payload.name} already exists", # 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. # 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) synced = await sync_single_account_from_beancount(payload.name)
return { return {