Compare commits

..

No commits in common. "34ecb3f2492be5903b109730845a9c3de50edb4e" and "f0899bf7889fbd0e463844ed23e9444550b42cdb" have entirely different histories.

3 changed files with 25 additions and 107 deletions

View file

@ -18,7 +18,6 @@ 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
@ -31,19 +30,6 @@ 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.
@ -1501,20 +1487,13 @@ 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 a Beancount file. This method uses /api/source to directly edit the 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
@ -1527,8 +1506,6 @@ 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:
@ -1538,18 +1515,17 @@ class FavaClient:
ChecksumConflictError: If all retry attempts fail due to concurrent modifications ChecksumConflictError: If all retry attempts fail due to concurrent modifications
Example: Example:
# User-account names route to accounts/users.beancount automatically. # Add a user's receivable account
result = await fava.add_account( result = await fava.add_account(
account_name="Assets:Receivable:User-abc12345", account_name="Assets:Receivable:User-abc123",
currencies=["EUR", "SATS", "USD"], 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( result = await fava.add_account(
account_name="Expenses:NewCategory", account_name="Liabilities:Payable:User-abc123",
currencies=["EUR"], currencies=["EUR", "SATS"]
target_file="accounts/chart.beancount",
) )
""" """
from datetime import date as date_type from datetime import date as date_type
@ -1557,9 +1533,6 @@ 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):
@ -1567,10 +1540,18 @@ 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 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( response = await client.get(
f"{self.base_url}/source", f"{self.base_url}/source",
params={"filename": target_file} params={"filename": file_path}
) )
response.raise_for_status() response.raise_for_status()
source_data = response.json()["data"] source_data = response.json()["data"]
@ -1578,12 +1559,12 @@ class FavaClient:
sha256sum = source_data["sha256sum"] sha256sum = source_data["sha256sum"]
source = source_data["source"] 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: 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", "")} 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') lines = source.split('\n')
insert_index = 0 insert_index = 0
for i, line in enumerate(lines): 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(): while insert_index < len(lines) and lines[insert_index].startswith((' ', '\t')) and lines[insert_index].strip():
insert_index += 1 insert_index += 1
# Step 4: Format Open directive as Beancount text # Step 5: Format Open directive as Beancount text
currencies_str = ", ".join(currencies) currencies_str = ", ".join(currencies)
open_lines = [ open_lines = [
"", "",
@ -1610,15 +1591,15 @@ class FavaClient:
else: else:
open_lines.append(f' {key}: {value}') open_lines.append(f' {key}: {value}')
# Step 5: Insert into source # Step 6: 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 6: Update source file via PUT /api/source # Step 7: Update source file via PUT /api/source
update_payload = { update_payload = {
"file_path": target_file, "file_path": file_path,
"source": new_source, "source": new_source,
"sha256sum": sha256sum "sha256sum": sha256sum
} }
@ -1631,7 +1612,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 {target_file} with currencies {currencies}") logger.info(f"Added account {account_name} to Beancount file with currencies {currencies}")
return result return result
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:

View file

@ -48,13 +48,6 @@ class CreateAccount(BaseModel):
is_virtual: bool = False # Set to True to create virtual parent account 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): class EntryLine(BaseModel):
id: str id: str
journal_entry_id: str journal_entry_id: str

View file

@ -52,7 +52,6 @@ from .models import (
LibraSettings, LibraSettings,
CreateAccount, CreateAccount,
CreateAccountPermission, CreateAccountPermission,
CreateChartAccount,
CreateBalanceAssertion, CreateBalanceAssertion,
CreateEntryLine, CreateEntryLine,
CreateJournalEntry, CreateJournalEntry,
@ -3566,61 +3565,6 @@ async def api_get_account_hierarchy(
# ===== ACCOUNT SYNC ENDPOINTS ===== # ===== 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") @libra_api_router.post("/api/v1/admin/accounts/sync")
async def api_sync_all_accounts( async def api_sync_all_accounts(
force_full_sync: bool = False, force_full_sync: bool = False,