diff --git a/fava_client.py b/fava_client.py index 8f60b23..277120e 100644 --- a/fava_client.py +++ b/fava_client.py @@ -18,7 +18,6 @@ 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 @@ -31,19 +30,6 @@ 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. @@ -1501,20 +1487,13 @@ 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 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 uses /api/source to directly edit the Beancount file. This method implements optimistic concurrency control with retry logic: - Acquires a global write lock before modifying the ledger @@ -1527,8 +1506,6 @@ 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: @@ -1538,18 +1515,17 @@ class FavaClient: ChecksumConflictError: If all retry attempts fail due to concurrent modifications Example: - # User-account names route to accounts/users.beancount automatically. + # Add a user's receivable account result = await fava.add_account( - account_name="Assets:Receivable:User-abc12345", + account_name="Assets:Receivable:User-abc123", currencies=["EUR", "SATS", "USD"], - metadata={"user_id": "abc12345", "description": "User receivables"} + metadata={"user_id": "abc123", "description": "User receivables"} ) - # Static / admin-added chart entries route to accounts/chart.beancount. + # Add a user's payable account result = await fava.add_account( - account_name="Expenses:NewCategory", - currencies=["EUR"], - target_file="accounts/chart.beancount", + account_name="Liabilities:Payable:User-abc123", + currencies=["EUR", "SATS"] ) """ from datetime import date as date_type @@ -1557,9 +1533,6 @@ 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): @@ -1567,10 +1540,18 @@ class FavaClient: async with self._write_lock: try: async with httpx.AsyncClient(timeout=self.timeout) as client: - # Step 1: Get current source file (fresh read on each attempt) + # 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) response = await client.get( f"{self.base_url}/source", - params={"filename": target_file} + params={"filename": file_path} ) response.raise_for_status() source_data = response.json()["data"] @@ -1578,12 +1559,12 @@ class FavaClient: sha256sum = source_data["sha256sum"] source = source_data["source"] - # Step 2: Check if account already exists (may have been created by concurrent request) + # Step 3: 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 {target_file}") + logger.info(f"Account {account_name} already exists in Beancount file") return {"data": sha256sum, "mtime": source_data.get("mtime", "")} - # Step 3: Find insertion point (after last Open directive AND its metadata) + # Step 4: Find insertion point (after last Open directive AND its metadata) lines = source.split('\n') insert_index = 0 for i, line in enumerate(lines): @@ -1594,7 +1575,7 @@ class FavaClient: while insert_index < len(lines) and lines[insert_index].startswith((' ', '\t')) and lines[insert_index].strip(): insert_index += 1 - # Step 4: Format Open directive as Beancount text + # Step 5: Format Open directive as Beancount text currencies_str = ", ".join(currencies) open_lines = [ "", @@ -1610,15 +1591,15 @@ class FavaClient: else: open_lines.append(f' {key}: {value}') - # Step 5: Insert into source + # Step 6: Insert into source for i, line in enumerate(open_lines): lines.insert(insert_index + i, line) new_source = '\n'.join(lines) - # Step 6: Update source file via PUT /api/source + # Step 7: Update source file via PUT /api/source update_payload = { - "file_path": target_file, + "file_path": file_path, "source": new_source, "sha256sum": sha256sum } @@ -1631,7 +1612,7 @@ class FavaClient: response.raise_for_status() result = response.json() - logger.info(f"Added account {account_name} to {target_file} with currencies {currencies}") + logger.info(f"Added account {account_name} to Beancount file with currencies {currencies}") return result except httpx.HTTPStatusError as e: diff --git a/models.py b/models.py index 70abca4..22d4503 100644 --- a/models.py +++ b/models.py @@ -48,13 +48,6 @@ class CreateAccount(BaseModel): is_virtual: bool = False # Set to True to create virtual parent account -class CreateChartAccount(BaseModel): - """Admin-created chart-of-accounts entry written to accounts/chart.beancount.""" - name: str # Full hierarchical account name, e.g. "Expenses:Services:Domain" - currencies: list[str] = ["EUR", "SATS", "USD"] - description: Optional[str] = None - - class EntryLine(BaseModel): id: str journal_entry_id: str diff --git a/views_api.py b/views_api.py index eab2617..d31f881 100644 --- a/views_api.py +++ b/views_api.py @@ -52,7 +52,6 @@ from .models import ( LibraSettings, CreateAccount, CreateAccountPermission, - CreateChartAccount, CreateBalanceAssertion, CreateEntryLine, CreateJournalEntry, @@ -3566,61 +3565,6 @@ async def api_get_account_hierarchy( # ===== ACCOUNT SYNC ENDPOINTS ===== -_VALID_ACCOUNT_PREFIXES = ("Assets:", "Liabilities:", "Equity:", "Income:", "Expenses:") - - -@libra_api_router.post("/api/v1/admin/accounts", status_code=HTTPStatus.CREATED) -async def api_admin_add_chart_account( - payload: CreateChartAccount, - auth: AuthContext = Depends(require_super_user), -) -> dict: - """ - Add a chart-of-accounts entry (super-user only). - - Writes an Open directive to accounts/chart.beancount via Fava's /api/source, - then syncs the account into Libra's DB so permissions can be granted on it. - Per-user accounts (matching :User-xxxxxxxx) take a different code path via - crud.get_or_create_user_account and are not created through this endpoint. - """ - from .fava_client import get_fava_client - - if not payload.name.startswith(_VALID_ACCOUNT_PREFIXES): - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=( - f"Account name must start with one of " - f"{', '.join(_VALID_ACCOUNT_PREFIXES)} (got {payload.name!r})" - ), - ) - - logger.info( - f"Admin {auth.user_id[:8]} adding chart account {payload.name} " - f"with currencies {payload.currencies}" - ) - - fava = get_fava_client() - metadata: dict = {"added_by": auth.user_id[:8], "source": "admin-ui"} - if payload.description: - metadata["description"] = payload.description - - await fava.add_account( - account_name=payload.name, - currencies=payload.currencies, - target_file="accounts/chart.beancount", - metadata=metadata, - ) - - # 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) - - return { - "success": True, - "account_name": payload.name, - "synced_to_libra_db": synced, - } - - @libra_api_router.post("/api/v1/admin/accounts/sync") async def api_sync_all_accounts( force_full_sync: bool = False,