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:
parent
f0899bf788
commit
894de72953
1 changed files with 44 additions and 25 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue