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 asyncio
import re
import httpx import httpx
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from decimal import Decimal from decimal import Decimal
@ -30,6 +31,19 @@ class ChecksumConflictError(Exception):
pass 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: class FavaClient:
""" """
Async client for Fava REST API. Async client for Fava REST API.
@ -1487,13 +1501,20 @@ class FavaClient:
currencies: list[str], currencies: list[str],
opening_date: Optional[date] = None, opening_date: Optional[date] = None,
metadata: Optional[Dict[str, Any]] = None, metadata: Optional[Dict[str, Any]] = None,
target_file: Optional[str] = None,
max_retries: int = 3 max_retries: int = 3
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Add an account to the Beancount ledger via an Open directive. Add an account to the Beancount ledger via an Open directive.
NOTE: Fava's /api/add_entries endpoint does NOT support Open directives. 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: This method implements optimistic concurrency control with retry logic:
- Acquires a global write lock before modifying the ledger - 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"]) currencies: List of currencies for this account (e.g., ["EUR", "SATS"])
opening_date: Date to open the account (defaults to today) opening_date: Date to open the account (defaults to today)
metadata: Optional metadata for the account 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) max_retries: Maximum number of retry attempts on checksum conflict (default: 3)
Returns: Returns:
@ -1515,17 +1538,18 @@ class FavaClient:
ChecksumConflictError: If all retry attempts fail due to concurrent modifications ChecksumConflictError: If all retry attempts fail due to concurrent modifications
Example: Example:
# Add a user's receivable account # User-account names route to accounts/users.beancount automatically.
result = await fava.add_account( result = await fava.add_account(
account_name="Assets:Receivable:User-abc123", account_name="Assets:Receivable:User-abc12345",
currencies=["EUR", "SATS", "USD"], 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( result = await fava.add_account(
account_name="Liabilities:Payable:User-abc123", account_name="Expenses:NewCategory",
currencies=["EUR", "SATS"] currencies=["EUR"],
target_file="accounts/chart.beancount",
) )
""" """
from datetime import date as date_type from datetime import date as date_type
@ -1533,6 +1557,9 @@ class FavaClient:
if opening_date is None: if opening_date is None:
opening_date = date_type.today() opening_date = date_type.today()
if target_file is None:
target_file = _infer_target_file(account_name)
last_error = None last_error = None
for attempt in range(max_retries): for attempt in range(max_retries):
@ -1540,18 +1567,10 @@ class FavaClient:
async with self._write_lock: async with self._write_lock:
try: try:
async with httpx.AsyncClient(timeout=self.timeout) as client: async with httpx.AsyncClient(timeout=self.timeout) as client:
# Step 1: Get the main Beancount file path from Fava # Step 1: Get current source file (fresh read on each attempt)
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)
response = await client.get( response = await client.get(
f"{self.base_url}/source", f"{self.base_url}/source",
params={"filename": file_path} params={"filename": target_file}
) )
response.raise_for_status() response.raise_for_status()
source_data = response.json()["data"] source_data = response.json()["data"]
@ -1559,12 +1578,12 @@ class FavaClient:
sha256sum = source_data["sha256sum"] sha256sum = source_data["sha256sum"]
source = source_data["source"] 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: 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", "")} 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') lines = source.split('\n')
insert_index = 0 insert_index = 0
for i, line in enumerate(lines): 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(): while insert_index < len(lines) and lines[insert_index].startswith((' ', '\t')) and lines[insert_index].strip():
insert_index += 1 insert_index += 1
# Step 5: Format Open directive as Beancount text # Step 4: Format Open directive as Beancount text
currencies_str = ", ".join(currencies) currencies_str = ", ".join(currencies)
open_lines = [ open_lines = [
"", "",
@ -1591,15 +1610,15 @@ class FavaClient:
else: else:
open_lines.append(f' {key}: {value}') open_lines.append(f' {key}: {value}')
# Step 6: Insert into source # Step 5: Insert into source
for i, line in enumerate(open_lines): for i, line in enumerate(open_lines):
lines.insert(insert_index + i, line) lines.insert(insert_index + i, line)
new_source = '\n'.join(lines) 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 = { update_payload = {
"file_path": file_path, "file_path": target_file,
"source": new_source, "source": new_source,
"sha256sum": sha256sum "sha256sum": sha256sum
} }
@ -1612,7 +1631,7 @@ class FavaClient:
response.raise_for_status() response.raise_for_status()
result = response.json() 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 return result
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e: