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:
commit
4b01428a17
10 changed files with 577 additions and 19 deletions
6
crud.py
6
crud.py
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
170
tests/test_admin_chart_accounts_api.py
Normal file
170
tests/test_admin_chart_accounts_api.py
Normal 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"
|
||||||
|
|
@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
75
views_api.py
75
views_api.py
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue