Merge pull request 'Wire admin add-account endpoint into the UI' (#46) from feat/add-account-ui into main

Reviewed-on: #46
This commit is contained in:
padreug 2026-06-18 10:03:10 +00:00
commit 6acb23b913
10 changed files with 577 additions and 19 deletions

View file

@ -250,9 +250,13 @@ async def get_or_create_user_account(
if not fava_account_exists: if not fava_account_exists:
# Create account in Fava/Beancount via Open directive # Create account in Fava/Beancount via Open directive
logger.info(f"[FAVA CREATE] Creating account in Fava: {account_name}") logger.info(f"[FAVA CREATE] Creating account in Fava: {account_name}")
# Unconstrained Open: a per-user receivable/payable legitimately
# holds arbitrary fiat (CAD/GBP/JPY/…). Constraining it to
# EUR/SATS/USD made any posting in another currency fail
# bean-check (the errors this account path originally exhibited).
await fava.add_account( await fava.add_account(
account_name=account_name, account_name=account_name,
currencies=["EUR", "SATS", "USD"], # Support common currencies currencies=None,
metadata={ metadata={
"user_id": user_id, "user_id": user_id,
"description": f"User-specific {account_type.value} account" "description": f"User-specific {account_type.value} account"

View file

@ -44,6 +44,55 @@ def _infer_target_file(account_name: str) -> str:
return "accounts/chart.beancount" return "accounts/chart.beancount"
def _escape_beancount_string(value: str) -> str:
"""Escape a value for safe inclusion in a Beancount string literal.
Beancount's tokenizer unescapes \\", \\n, \\t, \\r, \\\\ etc. (tokens.c
cunescape). Unescaped quotes or newlines in free-text metadata written
straight into the ledger source would corrupt the file, so escape the
backslash first (to keep it round-tripping) then quotes and newlines.
"""
return (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
)
# Beancount's DATE token (parser/lexer.l): (17|18|19|20)[0-9]{2}[-/][0-9]+[-/][0-9]+
# — '-' OR '/' separators, 1+ digit month/day. Inter-token whitespace is any
# run of [ \t\r] (ignored by the lexer). The duplicate-detection regex must
# mirror this, or a validly-formatted existing Open (e.g. '2024/3/5 open X' or
# '2020-01-01 open X') escapes detection and a duplicate Open is appended,
# which bean-check then rejects — breaking every later write.
_OPEN_DATE = r"(?:17|18|19|20)\d\d[-/]\d+[-/]\d+"
def _open_directive_exists(source: str, account_name: str) -> bool:
"""Return True if `source` already contains an Open directive for exactly
`account_name`.
Anchored to a real `<date> open <account>` directive line (re.MULTILINE),
with `<date>` and the inter-token whitespace matching Beancount's grammar,
so the account name can't match text inside another account's description
metadata or a comment (false positive spurious 409). The trailing
negative-lookahead `(?![\\w:-])` requires the next char not to be an
account-continuation char, so:
- a prefix (Expenses:Gas) does not match a longer sibling
(Expenses:GasStation / Expenses:Gas:Vehicle), and
- a real directive with an inline comment and no space
(`open Expenses:Gas;legacy`) is still detected (`;` ends the name).
"""
return bool(
re.search(
rf"^{_OPEN_DATE}[ \t]+open[ \t]+{re.escape(account_name)}(?![\w:-])",
source,
re.MULTILINE,
)
)
class FavaClient: class FavaClient:
""" """
Async client for Fava REST API. Async client for Fava REST API.
@ -1544,7 +1593,7 @@ class FavaClient:
async def add_account( async def add_account(
self, self,
account_name: str, account_name: str,
currencies: list[str], currencies: Optional[list[str]] = None,
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, target_file: Optional[str] = None,
@ -1627,10 +1676,16 @@ 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 2: Check if account already exists (may have been
if f"open {account_name}" in source: # created by a concurrent request). See
# _open_directive_exists for the anchoring rationale.
if _open_directive_exists(source, account_name):
logger.info(f"Account {account_name} already exists in {target_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", ""),
"already_existed": True,
}
# Step 3: Always append at end of file. # Step 3: Always append at end of file.
# Post-split layout, each include file has one mutation # Post-split layout, each include file has one mutation
@ -1642,19 +1697,23 @@ class FavaClient:
lines = source.split('\n') lines = source.split('\n')
insert_index = len(lines) insert_index = len(lines)
# Step 4: Format Open directive as Beancount text # Step 4: Format Open directive as Beancount text.
currencies_str = ", ".join(currencies) # Currencies are an optional constraint on an Open
open_lines = [ # directive; when none are given the account accepts
"", # any commodity.
f"{opening_date.isoformat()} open {account_name} {currencies_str}" open_directive = f"{opening_date.isoformat()} open {account_name}"
] if currencies:
open_directive += f" {', '.join(currencies)}"
open_lines = ["", open_directive]
# Add metadata if provided # Add metadata if provided
if metadata: if metadata:
for key, value in metadata.items(): for key, value in metadata.items():
# Format metadata with proper indentation # Format metadata with proper indentation
if isinstance(value, str): if isinstance(value, str):
open_lines.append(f' {key}: "{value}"') open_lines.append(
f' {key}: "{_escape_beancount_string(value)}"'
)
else: else:
open_lines.append(f' {key}: {value}') open_lines.append(f' {key}: {value}')
@ -1680,7 +1739,7 @@ class FavaClient:
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 {target_file} with currencies {currencies}")
return result return {**result, "already_existed": False}
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
# Check for checksum conflict (HTTP 412 Precondition Failed or similar) # Check for checksum conflict (HTTP 412 Precondition Failed or similar)

View file

@ -51,7 +51,11 @@ class CreateAccount(BaseModel):
class CreateChartAccount(BaseModel): class CreateChartAccount(BaseModel):
"""Admin-created chart-of-accounts entry written to accounts/chart.beancount.""" """Admin-created chart-of-accounts entry written to accounts/chart.beancount."""
name: str # Full hierarchical account name, e.g. "Expenses:Services:Domain" name: str # Full hierarchical account name, e.g. "Expenses:Services:Domain"
currencies: list[str] = ["EUR", "SATS", "USD"] # Optional currency constraint. Omitted by the UI: an Open directive needs
# no currency list, and constraining it would reject postings in other
# currencies (the CAD/GBP/JPY bean-check errors we saw on user accounts).
# None → unconstrained Open; a list → explicit constraint for API callers.
currencies: Optional[list[str]] = None
description: Optional[str] = None description: Optional[str] = None

View file

@ -69,6 +69,13 @@ window.app = Vue.createApp({
userWalletId: '', userWalletId: '',
loading: false loading: false
}, },
addAccountDialog: {
show: false,
rootType: 'Expenses',
subPath: '',
description: '',
loading: false
},
receivableDialog: { receivableDialog: {
show: false, show: false,
selectedUser: '', selectedUser: '',
@ -286,6 +293,16 @@ window.app = Vue.createApp({
}) })
return options return options
}, },
accountRootTypes() {
// The five Beancount root account types — the only valid parents.
// Mirrors the server's _VALID_ACCOUNT_PREFIXES.
return ['Assets', 'Liabilities', 'Equity', 'Income', 'Expenses']
},
addAccountFullName() {
const sub = (this.addAccountDialog.subPath || '').trim().replace(/^:+|:+$/g, '')
if (!this.addAccountDialog.rootType || !sub) return ''
return `${this.addAccountDialog.rootType}:${sub}`
},
userOptions() { userOptions() {
const options = [] const options = []
this.users.forEach(user => { this.users.forEach(user => {
@ -566,6 +583,55 @@ window.app = Vue.createApp({
this.syncingAccounts = false this.syncingAccounts = false
} }
}, },
showAddAccountDialog() {
this.addAccountDialog.rootType = 'Expenses'
this.addAccountDialog.subPath = ''
this.addAccountDialog.description = ''
this.addAccountDialog.show = true
},
async submitAddAccount() {
const name = this.addAccountFullName
if (!name) {
this.$q.notify({type: 'warning', message: 'Enter a sub-account name'})
return
}
// Each segment under the root must be a valid Beancount account
// component (core/account.py ACC_COMP_NAME_RE): starts with an uppercase
// letter or digit, then letters/digits/hyphens (Unicode letters allowed).
const badSegment = name.split(':').slice(1).find(
seg => !/^[\p{Lu}\p{Nd}][\p{L}\p{Nd}-]*$/u.test(seg)
)
if (badSegment !== undefined) {
this.$q.notify({
type: 'warning',
message: `Invalid segment "${badSegment}" — letters, digits and hyphens only, starting with a capital letter or digit`
})
return
}
this.addAccountDialog.loading = true
try {
const {data} = await LNbits.api.request(
'POST',
'/libra/api/v1/admin/accounts',
this.g.user.wallets[0].adminkey,
{
name,
description: this.addAccountDialog.description || null
}
)
this.$q.notify({
type: 'positive',
message: `Account ${data.account_name} created` +
(data.synced_to_libra_db ? '' : ' (sync pending)')
})
this.addAccountDialog.show = false
await this.loadAccounts()
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.addAccountDialog.loading = false
}
},
showSettingsDialog() { showSettingsDialog() {
this.settingsDialog.libraWalletId = this.settings?.libra_wallet_id || '' this.settingsDialog.libraWalletId = this.settings?.libra_wallet_id || ''
this.settingsDialog.favaUrl = this.settings?.fava_url || 'http://localhost:3333' this.settingsDialog.favaUrl = this.settings?.fava_url || 'http://localhost:3333'

View file

@ -857,7 +857,20 @@
<!-- Chart of Accounts --> <!-- Chart of Accounts -->
<q-card> <q-card>
<q-card-section> <q-card-section>
<h6 class="q-my-none q-mb-md">Chart of Accounts</h6> <div class="row items-center q-mb-md">
<h6 class="q-my-none">Chart of Accounts</h6>
<q-space></q-space>
<q-btn
v-if="isSuperUser"
unelevated
dense
size="sm"
color="primary"
icon="add"
label="Add Account"
@click="showAddAccountDialog"
></q-btn>
</div>
<q-list dense v-if="accounts.length > 0"> <q-list dense v-if="accounts.length > 0">
<q-item v-for="account in accounts" :key="account.id"> <q-item v-for="account in accounts" :key="account.id">
<q-item-section> <q-item-section>
@ -1232,6 +1245,63 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
<!-- Add Account Dialog -->
<q-dialog v-model="addAccountDialog.show" position="top">
<q-card v-if="addAccountDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="submitAddAccount" class="q-gutter-md">
<div class="text-h6 q-mb-md">Add Account</div>
<q-select
filled
dense
v-model="addAccountDialog.rootType"
:options="accountRootTypes"
label="Account Type *"
hint="Top-level category — the only valid parents"
></q-select>
<q-input
filled
dense
v-model.trim="addAccountDialog.subPath"
label="Sub-account *"
placeholder="e.g., Vehicle:Gas"
hint="Path under the type. Use ':' to nest; capitalize each part."
></q-input>
<div v-if="addAccountFullName" class="text-caption text-grey">
Will create: <span class="text-weight-medium">{% raw %}{{ addAccountFullName }}{% endraw %}</span>
</div>
<q-input
filled
dense
v-model.trim="addAccountDialog.description"
label="Description"
placeholder="Optional notes about this account"
></q-input>
<div class="text-caption text-grey">
Creates an Open directive in the Beancount ledger and syncs it into Libra
so permissions can be granted. Per-user accounts are managed automatically.
</div>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
type="submit"
:loading="addAccountDialog.loading"
:disable="!addAccountFullName"
>
Create Account
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<!-- Receivable Dialog --> <!-- Receivable Dialog -->
<q-dialog v-model="receivableDialog.show" position="top"> <q-dialog v-model="receivableDialog.show" position="top">
<q-card v-if="receivableDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card"> <q-card v-if="receivableDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card">

View file

@ -108,6 +108,9 @@ def _settings_cleanup(settings: Settings) -> None:
settings.lnbits_user_activation_by_invitation_code = False settings.lnbits_user_activation_by_invitation_code = False
settings.lnbits_register_reusable_activation_code = "" settings.lnbits_register_reusable_activation_code = ""
settings.lnbits_register_one_time_activation_codes = [] settings.lnbits_register_one_time_activation_codes = []
# Keep the rate limiter disabled across per-test settings resets (the
# limiter itself is fixed at app-creation time, but keep the value coherent).
settings.lnbits_rate_limit_no = 1_000_000
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
@ -133,6 +136,12 @@ def settings() -> Iterator[Settings]:
lnbits_settings.lnbits_admin_ui = True lnbits_settings.lnbits_admin_ui = True
lnbits_settings.lnbits_extensions_default_install = [] lnbits_settings.lnbits_extensions_default_install = []
lnbits_settings.lnbits_extensions_deactivate_all = False lnbits_settings.lnbits_extensions_deactivate_all = False
# The full suite fires >200 requests/minute; the default rate limit (200/min)
# otherwise 429s fixture setup intermittently. The limiter is built once at
# app creation from this value (lnbits/app.py register_new_ratelimiter), and
# this fixture runs before the `app` fixture, so raising it here disables it
# for the session.
lnbits_settings.lnbits_rate_limit_no = 1_000_000
yield lnbits_settings yield lnbits_settings
@ -170,13 +179,32 @@ option "render_commas" "TRUE"
2020-01-01 open Equity:Opening-Balances EUR,SATS 2020-01-01 open Equity:Opening-Balances EUR,SATS
2020-01-01 open Income:Generic EUR,SATS 2020-01-01 open Income:Generic EUR,SATS
2020-01-01 open Expenses:Generic EUR,SATS 2020-01-01 open Expenses:Generic EUR,SATS
include "accounts/chart.beancount"
include "accounts/users.beancount"
""" """
# Split-layout include targets, mirroring the production fava layout
# (aiolabs/server-deploy#4). libra's fava_client routes Open directives by
# account name (fava_client._infer_target_file): per-user accounts
# (:User-xxxxxxxx) to accounts/users.beancount, everything else to
# accounts/chart.beancount. Both must exist as Fava *source* files (i.e. be
# included) or /api/source writes 500 with "non-source file". The title stays
# in the root ledger above so Fava's slug still matches LEDGER_SLUG (scalar
# options don't propagate from includes — see aiolabs/server-deploy#9).
CHART_SEED = "; Admin-mutable chart of accounts (libra appends Open directives).\n"
USERS_SEED = "; Per-user account opens (libra appends at signup).\n"
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def fava_ledger_path(tmp_path_factory: pytest.TempPathFactory) -> Path: def fava_ledger_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""Session-scoped .beancount file Fava reads from.""" """Session-scoped split ledger Fava reads from: a root file that includes
accounts/chart.beancount (admin add-account target) and
accounts/users.beancount (per-user opens target)."""
ledger_dir = tmp_path_factory.mktemp("libra-ledger") ledger_dir = tmp_path_factory.mktemp("libra-ledger")
(ledger_dir / "accounts").mkdir()
(ledger_dir / "accounts" / "chart.beancount").write_text(CHART_SEED)
(ledger_dir / "accounts" / "users.beancount").write_text(USERS_SEED)
ledger = ledger_dir / f"{LEDGER_SLUG}.beancount" ledger = ledger_dir / f"{LEDGER_SLUG}.beancount"
ledger.write_text(MINIMAL_LEDGER) ledger.write_text(MINIMAL_LEDGER)
return ledger return ledger

View file

@ -13,7 +13,7 @@ separate ISO code field — this matches `models.ExpenseEntry` / `ReceivableEntr
from decimal import Decimal from decimal import Decimal
from typing import Any, Optional, Union from typing import Any, Optional, Union
from httpx import AsyncClient from httpx import AsyncClient, Response
Amount = Union[Decimal, int, float, str] Amount = Union[Decimal, int, float, str]
@ -106,6 +106,26 @@ async def grant_permission(
return r.json() return r.json()
async def add_chart_account(
client: AsyncClient,
*,
super_user_headers: dict,
name: str,
description: Optional[str] = None,
) -> Response:
"""Super user adds a chart-of-accounts entry via the admin endpoint
(POST /api/v1/admin/accounts). Returns the raw Response so callers can
assert on status codes (201 / 400 / 409 / 403)."""
body: dict[str, Any] = {"name": name}
if description is not None:
body["description"] = description
return await client.post(
"/libra/api/v1/admin/accounts",
headers=super_user_headers,
json=body,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Entries — user side # Entries — user side
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -0,0 +1,170 @@
"""Admin chart-of-accounts endpoint — POST /api/v1/admin/accounts.
Covers the endpoint wired into the UI's "Add Account" dialog:
- Writes an Open directive to accounts/chart.beancount via Fava /api/source,
*unconstrained* by currency (the directive needs no currency list), with
provenance + description metadata (escaped for Beancount).
- Mirrors the account into libra's DB (synced_to_libra_db).
- Rejects duplicates with 409, malformed names with 400, and non-super-users
with 403.
The harness ledger is the split layout (root includes accounts/chart.beancount)
so the endpoint's hardcoded target_file resolves — see conftest.CHART_SEED.
"""
import re
from pathlib import Path
from uuid import uuid4
import pytest
from .helpers import add_chart_account
def _chart_text(fava_ledger_path: Path) -> str:
return (fava_ledger_path.parent / "accounts" / "chart.beancount").read_text()
def _unique(prefix: str = "Expenses:Test") -> str:
# Capitalized leaf (valid Beancount component) unique per call so the
# session-scoped ledger doesn't collide across tests.
return f"{prefix}:T{uuid4().hex[:8].upper()}"
@pytest.mark.anyio
async def test_add_chart_account_writes_unconstrained_open_with_escaped_meta(
client, super_user_headers, fava_ledger_path,
):
"""Happy path: 201, the Open directive carries no currency constraint, the
description metadata is escaped, and the account is synced into libra's DB."""
name = _unique()
r = await add_chart_account(
client,
super_user_headers=super_user_headers,
name=name,
description='has a "quote" and ok',
)
assert r.status_code == 201, f"expected 201, got {r.status_code}: {r.text}"
body = r.json()
assert body["account_name"] == name
assert body["synced_to_libra_db"] is True
chart = _chart_text(fava_ledger_path)
# Open present and UNCONSTRAINED: the account name is followed directly by
# end-of-line, not " EUR, SATS, USD".
assert re.search(rf"^\d{{4}}-\d{{2}}-\d{{2}} open {re.escape(name)}$", chart, re.MULTILINE), (
f"expected an unconstrained Open for {name}, chart was:\n{chart}"
)
# Description metadata is escaped so the quote can't break the ledger.
assert r'description: "has a \"quote\" and ok"' in chart
assert 'source: "admin-ui"' in chart
@pytest.mark.anyio
async def test_add_chart_account_with_explicit_currencies_constrains_open(
client, super_user_headers, fava_ledger_path,
):
"""API callers may still pass an explicit currency constraint (the UI never
does). When provided, it lands on the Open directive."""
name = _unique()
r = await client.post(
"/libra/api/v1/admin/accounts",
headers=super_user_headers,
json={"name": name, "currencies": ["EUR", "SATS"]},
)
assert r.status_code == 201, f"expected 201, got {r.status_code}: {r.text}"
chart = _chart_text(fava_ledger_path)
assert re.search(rf"open {re.escape(name)} EUR, SATS$", chart, re.MULTILINE), (
f"expected a currency-constrained Open for {name}, chart was:\n{chart}"
)
@pytest.mark.anyio
async def test_add_chart_account_duplicate_returns_409(
client, super_user_headers,
):
"""Adding the same account twice: first 201, second 409 (not a false success)."""
name = _unique()
first = await add_chart_account(client, super_user_headers=super_user_headers, name=name)
assert first.status_code == 201, f"first add: {first.status_code} {first.text}"
second = await add_chart_account(client, super_user_headers=super_user_headers, name=name)
assert second.status_code == 409, f"expected 409, got {second.status_code}: {second.text}"
assert "already exists" in second.json().get("detail", "").lower()
@pytest.mark.anyio
async def test_add_chart_account_recovers_ledger_only_account(
client, super_user_headers,
):
"""An account present in the ledger but absent from libra's DB (prior sync
failure / out-of-band edit) is recovered (synced), not 409'd — otherwise it
would be permanently un-grantable with no path back.
Reproduce the ledger-only state by creating normally (so Fava parses the
Open) then deleting only the libra-DB row appending to the ledger file
directly would race Fava's parse cache."""
from ..crud import db # the same singleton the app uses
name = _unique("Expenses:Recover")
first = await add_chart_account(client, super_user_headers=super_user_headers, name=name)
assert first.status_code == 201, f"setup create failed: {first.status_code} {first.text}"
await db.execute("DELETE FROM accounts WHERE name = :name", {"name": name})
r = await add_chart_account(client, super_user_headers=super_user_headers, name=name)
assert r.status_code == 201, f"expected 201 recovery, got {r.status_code}: {r.text}"
body = r.json()
assert body.get("already_existed") is True, body
assert body["synced_to_libra_db"] is True, body
@pytest.mark.anyio
async def test_add_chart_account_invalid_prefix_returns_400(
client, super_user_headers, fava_ledger_path,
):
"""A root outside the five valid types is rejected and never written."""
before = _chart_text(fava_ledger_path)
r = await add_chart_account(client, super_user_headers=super_user_headers, name="Foo:Bar")
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert _chart_text(fava_ledger_path) == before, "rejected account must not be written"
@pytest.mark.anyio
@pytest.mark.parametrize(
"bad_name",
[
"Expenses:Foo Bar", # space
"Expenses:foo", # lowercase sub-component start
"Expenses:Foo!", # punctuation
"Expenses:", # no sub-account
"Expenses:Foo::Bar", # empty component
],
)
async def test_add_chart_account_invalid_characters_returns_400(
client, super_user_headers, fava_ledger_path, bad_name,
):
"""Malformed account names are rejected server-side (the UI guard can be
bypassed via the API) and never reach the ledger."""
before = _chart_text(fava_ledger_path)
r = await add_chart_account(client, super_user_headers=super_user_headers, name=bad_name)
assert r.status_code == 400, f"expected 400 for {bad_name!r}, got {r.status_code}: {r.text}"
assert _chart_text(fava_ledger_path) == before, "rejected account must not be written"
@pytest.mark.anyio
async def test_add_chart_account_requires_super_user(
client, configured_user, fava_ledger_path,
):
"""A regular user's wallet admin-key passes require_admin_key but fails the
super-user identity check 403, nothing written."""
_user, wallet = configured_user
name = _unique()
before = _chart_text(fava_ledger_path)
r = await client.post(
"/libra/api/v1/admin/accounts",
headers={"X-Api-Key": wallet.adminkey, "Content-type": "application/json"},
json={"name": name},
)
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
assert _chart_text(fava_ledger_path) == before, "unauthorized add must not be written"

View file

@ -31,9 +31,75 @@ bf = _module("beancount_format")
au = _module("account_utils") au = _module("account_utils")
val = _module("core.validation") val = _module("core.validation")
mdl = _module("models") mdl = _module("models")
fc = _module("fava_client")
AccountType = mdl.AccountType AccountType = mdl.AccountType
# ---------------------------------------------------------------------------
# fava_client._open_directive_exists — duplicate-account detection
# ---------------------------------------------------------------------------
def test_open_directive_exists_matches_real_directive():
src = "2020-01-01 open Expenses:Vehicle:Gas\n"
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is True
def test_open_directive_exists_matches_currency_constrained_and_metadata():
src = (
"2020-01-01 open Expenses:Vehicle:Gas EUR, SATS\n"
' added_by: "abc"\n'
)
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is True
def test_open_directive_exists_matches_inline_comment_without_space():
# Valid Beancount: the account token ends at ';'. The old (?:\\s|$) boundary
# missed this → duplicate Open written → bean-check breaks.
src = "2020-01-01 open Expenses:Vehicle:Gas;legacy-import\n"
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is True
def test_open_directive_exists_ignores_name_inside_description():
# The name appears only inside another account's description metadata.
src = (
"2020-01-01 open Expenses:Notes\n"
' description: "remember to open Expenses:Vehicle:Gas next month"\n'
)
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False
def test_open_directive_exists_ignores_comment_line():
src = "; TODO: open Expenses:Vehicle:Gas eventually\n"
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False
def test_open_directive_exists_does_not_match_longer_sibling():
src = "2020-01-01 open Expenses:Vehicle:GasStation\n"
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False
def test_open_directive_exists_does_not_match_deeper_child():
src = "2020-01-01 open Expenses:Vehicle:Gas:Premium\n"
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False
@pytest.mark.parametrize(
"line",
[
"2024/3/5 open Expenses:Vehicle:Gas", # slash date, single-digit M/D
"2020-1-1 open Expenses:Vehicle:Gas", # dash date, single-digit M/D
"2020-01-01 open Expenses:Vehicle:Gas", # multiple spaces
"2020-01-01\topen\tExpenses:Vehicle:Gas", # tab separators
"1970-01-01 open Expenses:Vehicle:Gas EUR", # currency-constrained
],
)
def test_open_directive_exists_matches_beancount_date_and_whitespace_variants(line):
# All of these are valid Beancount Open directives per lexer.l's DATE token
# and ignored inter-token whitespace; each must be detected as existing.
assert fc._open_directive_exists(line + "\n", "Expenses:Vehicle:Gas") is True
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# beancount_format.sanitize_link # beancount_format.sanitize_link
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -3661,6 +3661,52 @@ async def api_get_account_hierarchy(
_VALID_ACCOUNT_PREFIXES = ("Assets:", "Liabilities:", "Equity:", "Income:", "Expenses:") _VALID_ACCOUNT_PREFIXES = ("Assets:", "Liabilities:", "Equity:", "Income:", "Expenses:")
def _is_valid_account_component(component: str, *, is_root: bool) -> bool:
"""Validate one ':'-separated account component against Beancount's grammar.
Mirrors core/account.py: a root component matches ``[\\p{Lu}][\\p{L}\\p{Nd}-]*``
(must start with an uppercase letter); a sub component matches
``[\\p{Lu}\\p{Nd}][\\p{L}\\p{Nd}-]*`` (may also start with a digit). Body
chars are letters, decimal digits, or hyphen. Implemented with Unicode-aware
str methods (libra's runtime has no beancount — Fava is a separate service),
so non-ASCII letters are accepted exactly as Beancount accepts them.
"""
if not component:
return False
first, rest = component[0], component[1:]
first_ok = (first.isalpha() and first.isupper()) or (
not is_root and first.isdecimal()
)
if not first_ok:
return False
return all(ch == "-" or ch.isalpha() or ch.isdecimal() for ch in rest)
def _validate_account_name(name: str) -> None:
"""Raise HTTP 400 if ``name`` is not a syntactically valid Beancount account.
The UI guards this client-side, but the endpoint is reachable directly via
API, so this is the load-bearing check before the name is written into the
ledger source. Requires a root plus at least one sub-component.
"""
parts = name.split(":")
valid = (
len(parts) >= 2
and _is_valid_account_component(parts[0], is_root=True)
and all(_is_valid_account_component(p, is_root=False) for p in parts[1:])
)
if not valid:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
f"Invalid account name {name!r}: each ':'-separated part must be "
"letters/digits/hyphens, the root starting with an uppercase "
"letter (sub-accounts may start with a digit), with at least one "
"sub-account (e.g. Expenses:Food)."
),
)
@libra_api_router.post("/api/v1/admin/accounts", status_code=HTTPStatus.CREATED) @libra_api_router.post("/api/v1/admin/accounts", status_code=HTTPStatus.CREATED)
async def api_admin_add_chart_account( async def api_admin_add_chart_account(
payload: CreateChartAccount, payload: CreateChartAccount,
@ -3685,6 +3731,8 @@ async def api_admin_add_chart_account(
), ),
) )
_validate_account_name(payload.name)
logger.info( logger.info(
f"Admin {auth.user_id[:8]} adding chart account {payload.name} " f"Admin {auth.user_id[:8]} adding chart account {payload.name} "
f"with currencies {payload.currencies}" f"with currencies {payload.currencies}"
@ -3695,15 +3743,38 @@ async def api_admin_add_chart_account(
if payload.description: if payload.description:
metadata["description"] = payload.description metadata["description"] = payload.description
await fava.add_account( result = await fava.add_account(
account_name=payload.name, account_name=payload.name,
currencies=payload.currencies, currencies=payload.currencies,
target_file="accounts/chart.beancount", target_file="accounts/chart.beancount",
metadata=metadata, metadata=metadata,
) )
# Mirror into libra DB so permissions / metadata layer sees it.
from .account_sync import sync_single_account_from_beancount from .account_sync import sync_single_account_from_beancount
if result.get("already_existed"):
# The Open directive is already in the ledger. If it's also already
# mirrored into libra's DB, it's a true duplicate → 409. If not (a prior
# sync failed — there's no cross-DB atomicity — or it was opened out of
# band), mirror it now so it becomes grantable instead of being stranded
# with no recovery path.
from .crud import get_account_by_name
if await get_account_by_name(payload.name) is not None:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail=f"Account {payload.name} already exists",
)
synced = await sync_single_account_from_beancount(payload.name)
return {
"success": True,
"account_name": payload.name,
"synced_to_libra_db": synced,
"already_existed": True,
}
# Mirror into libra DB so permissions / metadata layer sees it.
synced = await sync_single_account_from_beancount(payload.name) synced = await sync_single_account_from_beancount(payload.name)
return { return {