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 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:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue