Route fava_client.add_account writes per account type

The Fava-backed ledger is being split into purpose-specific files (see
aiolabs/server-deploy#4): accounts/chart.beancount for static + admin-managed
opens, accounts/users.beancount for libra-appended per-user opens.

Add a `target_file` parameter to `add_account` that defaults to inference
from the account name (`:User-[0-9a-f]{8}$` -> users.beancount, otherwise
chart.beancount). Drop the now-redundant `GET /api/options` call that was
only used to discover the root file path. Callers that need explicit
control (e.g. the upcoming admin chart-edit endpoint) can pass
`target_file=` directly.

The retry loop, write lock, and insertion-point search are unchanged --
each included file is a self-contained source the existing logic operates
on cleanly.

Refs: aiolabs/libra#28
This commit is contained in:
Padreug 2026-06-06 15:27:28 +02:00
commit 894de72953

View file

@ -18,6 +18,7 @@ See: https://github.com/beancount/fava/blob/main/src/fava/json_api.py
"""
import asyncio
import re
import httpx
from typing import Any, Dict, List, Optional
from decimal import Decimal
@ -30,6 +31,19 @@ class ChecksumConflictError(Exception):
pass
# Per-user account names end with :User-{user_id[:8]} (8 hex chars). Anything
# matching is routed to accounts/users.beancount; anything else goes to
# accounts/chart.beancount. See `_infer_target_file` and `add_account`.
_USER_ACCT_RE = re.compile(r":User-[0-9a-f]{8}$")
def _infer_target_file(account_name: str) -> str:
"""Pick the Beancount include file for an Open directive based on account name."""
if _USER_ACCT_RE.search(account_name):
return "accounts/users.beancount"
return "accounts/chart.beancount"
class FavaClient:
"""
Async client for Fava REST API.
@ -1487,13 +1501,20 @@ class FavaClient:
currencies: list[str],
opening_date: Optional[date] = None,
metadata: Optional[Dict[str, Any]] = None,
target_file: Optional[str] = None,
max_retries: int = 3
) -> Dict[str, Any]:
"""
Add an account to the Beancount ledger via an Open directive.
NOTE: Fava's /api/add_entries endpoint does NOT support Open directives.
This method uses /api/source to directly edit the Beancount file.
This method uses /api/source to directly edit a Beancount file.
The ledger is split across multiple include files
(see modules/services/fava-seeds.nix in server-deploy). Per-user
opens go to accounts/users.beancount; admin/static chart opens go to
accounts/chart.beancount. If `target_file` is not passed, it is
inferred from the account name via `_infer_target_file`.
This method implements optimistic concurrency control with retry logic:
- Acquires a global write lock before modifying the ledger
@ -1506,6 +1527,8 @@ class FavaClient:
currencies: List of currencies for this account (e.g., ["EUR", "SATS"])
opening_date: Date to open the account (defaults to today)
metadata: Optional metadata for the account
target_file: Beancount file path (relative to ledger root) to append
the Open directive to. Defaults to inference from `account_name`.
max_retries: Maximum number of retry attempts on checksum conflict (default: 3)
Returns:
@ -1515,17 +1538,18 @@ class FavaClient:
ChecksumConflictError: If all retry attempts fail due to concurrent modifications
Example:
# Add a user's receivable account
# User-account names route to accounts/users.beancount automatically.
result = await fava.add_account(
account_name="Assets:Receivable:User-abc123",
account_name="Assets:Receivable:User-abc12345",
currencies=["EUR", "SATS", "USD"],
metadata={"user_id": "abc123", "description": "User receivables"}
metadata={"user_id": "abc12345", "description": "User receivables"}
)
# Add a user's payable account
# Static / admin-added chart entries route to accounts/chart.beancount.
result = await fava.add_account(
account_name="Liabilities:Payable:User-abc123",
currencies=["EUR", "SATS"]
account_name="Expenses:NewCategory",
currencies=["EUR"],
target_file="accounts/chart.beancount",
)
"""
from datetime import date as date_type
@ -1533,6 +1557,9 @@ class FavaClient:
if opening_date is None:
opening_date = date_type.today()
if target_file is None:
target_file = _infer_target_file(account_name)
last_error = None
for attempt in range(max_retries):
@ -1540,18 +1567,10 @@ class FavaClient:
async with self._write_lock:
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
# Step 1: Get the main Beancount file path from Fava
options_response = await client.get(f"{self.base_url}/options")
options_response.raise_for_status()
options_data = options_response.json()["data"]
file_path = options_data["beancount_options"]["filename"]
logger.debug(f"Fava main file: {file_path}")
# Step 2: Get current source file (fresh read on each attempt)
# Step 1: Get current source file (fresh read on each attempt)
response = await client.get(
f"{self.base_url}/source",
params={"filename": file_path}
params={"filename": target_file}
)
response.raise_for_status()
source_data = response.json()["data"]
@ -1559,12 +1578,12 @@ class FavaClient:
sha256sum = source_data["sha256sum"]
source = source_data["source"]
# Step 3: Check if account already exists (may have been created by concurrent request)
# Step 2: Check if account already exists (may have been created by concurrent request)
if f"open {account_name}" in source:
logger.info(f"Account {account_name} already exists in Beancount file")
logger.info(f"Account {account_name} already exists in {target_file}")
return {"data": sha256sum, "mtime": source_data.get("mtime", "")}
# Step 4: Find insertion point (after last Open directive AND its metadata)
# Step 3: Find insertion point (after last Open directive AND its metadata)
lines = source.split('\n')
insert_index = 0
for i, line in enumerate(lines):
@ -1575,7 +1594,7 @@ class FavaClient:
while insert_index < len(lines) and lines[insert_index].startswith((' ', '\t')) and lines[insert_index].strip():
insert_index += 1
# Step 5: Format Open directive as Beancount text
# Step 4: Format Open directive as Beancount text
currencies_str = ", ".join(currencies)
open_lines = [
"",
@ -1591,15 +1610,15 @@ class FavaClient:
else:
open_lines.append(f' {key}: {value}')
# Step 6: Insert into source
# Step 5: Insert into source
for i, line in enumerate(open_lines):
lines.insert(insert_index + i, line)
new_source = '\n'.join(lines)
# Step 7: Update source file via PUT /api/source
# Step 6: Update source file via PUT /api/source
update_payload = {
"file_path": file_path,
"file_path": target_file,
"source": new_source,
"sha256sum": sha256sum
}
@ -1612,7 +1631,7 @@ class FavaClient:
response.raise_for_status()
result = response.json()
logger.info(f"Added account {account_name} to Beancount file with currencies {currencies}")
logger.info(f"Added account {account_name} to {target_file} with currencies {currencies}")
return result
except httpx.HTTPStatusError as e: