Compare commits
No commits in common. "34ecb3f2492be5903b109730845a9c3de50edb4e" and "f0899bf7889fbd0e463844ed23e9444550b42cdb" have entirely different histories.
34ecb3f249
...
f0899bf788
3 changed files with 25 additions and 107 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
56
views_api.py
56
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue