From 788a9998f609bff212b95d8a876fb545865456b8 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 15 Jun 2026 20:31:27 +0200 Subject: [PATCH 1/2] fix(fava): escape string metadata + make Open currencies optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add_account wrote free-text metadata values straight into the ledger source via /api/source with no escaping — an unescaped quote or newline in an admin-supplied description would corrupt the Beancount file (or forge extra metadata lines). Escape backslash/quote/newline per the tokenizer's cunescape rules (verified round-trip through beancount's parser). Also make the currency constraint list optional so an Open directive can be written unconstrained (currencies are an optional part of the directive, not required). Co-Authored-By: Claude Opus 4.8 (1M context) --- fava_client.py | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/fava_client.py b/fava_client.py index c61e1d9..afc964f 100644 --- a/fava_client.py +++ b/fava_client.py @@ -44,6 +44,22 @@ def _infer_target_file(account_name: str) -> str: 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") + ) + + class FavaClient: """ Async client for Fava REST API. @@ -1544,7 +1560,7 @@ class FavaClient: async def add_account( self, account_name: str, - currencies: list[str], + currencies: Optional[list[str]] = None, opening_date: Optional[date] = None, metadata: Optional[Dict[str, Any]] = None, target_file: Optional[str] = None, @@ -1642,19 +1658,23 @@ class FavaClient: lines = source.split('\n') insert_index = len(lines) - # Step 4: Format Open directive as Beancount text - currencies_str = ", ".join(currencies) - open_lines = [ - "", - f"{opening_date.isoformat()} open {account_name} {currencies_str}" - ] + # Step 4: Format Open directive as Beancount text. + # Currencies are an optional constraint on an Open + # directive; when none are given the account accepts + # any commodity. + open_directive = f"{opening_date.isoformat()} open {account_name}" + if currencies: + open_directive += f" {', '.join(currencies)}" + open_lines = ["", open_directive] # Add metadata if provided if metadata: for key, value in metadata.items(): # Format metadata with proper indentation if isinstance(value, str): - open_lines.append(f' {key}: "{value}"') + open_lines.append( + f' {key}: "{_escape_beancount_string(value)}"' + ) else: open_lines.append(f' {key}: {value}') From 9dd46e818cfeee1cf16b7892e08dec456c5eed7f Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 15 Jun 2026 20:31:42 +0200 Subject: [PATCH 2/2] feat(ui): wire admin add-account endpoint into Chart of Accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the existing POST /api/v1/admin/accounts endpoint in the UI: a super-user-only 'Add Account' button on the Chart of Accounts card opens a dialog for the hierarchical account name + optional description, posts with the wallet admin key (require_super_user), then reloads accounts. Client-side prefix validation mirrors the server's _VALID_ACCOUNT_PREFIXES. No currency input — an Open directive does not require currency constraints. Co-Authored-By: Claude Opus 4.8 (1M context) --- static/js/index.js | 45 +++++++++++++++++++++++++++++ templates/libra/index.html | 59 +++++++++++++++++++++++++++++++++++++- 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/static/js/index.js b/static/js/index.js index 418ec41..a10d50c 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -69,6 +69,12 @@ window.app = Vue.createApp({ userWalletId: '', loading: false }, + addAccountDialog: { + show: false, + name: '', + description: '', + loading: false + }, receivableDialog: { show: false, selectedUser: '', @@ -566,6 +572,45 @@ window.app = Vue.createApp({ this.syncingAccounts = false } }, + showAddAccountDialog() { + this.addAccountDialog.name = '' + this.addAccountDialog.description = '' + this.addAccountDialog.show = true + }, + async submitAddAccount() { + const name = (this.addAccountDialog.name || '').trim() + const validPrefixes = ['Assets:', 'Liabilities:', 'Equity:', 'Income:', 'Expenses:'] + if (!validPrefixes.some(p => name.startsWith(p))) { + this.$q.notify({ + type: 'warning', + message: `Account name must start with one of: ${validPrefixes.join(', ')}` + }) + 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() { this.settingsDialog.libraWalletId = this.settings?.libra_wallet_id || '' this.settingsDialog.favaUrl = this.settings?.fava_url || 'http://localhost:3333' diff --git a/templates/libra/index.html b/templates/libra/index.html index 0de0e71..f36c466 100644 --- a/templates/libra/index.html +++ b/templates/libra/index.html @@ -857,7 +857,20 @@ -
Chart of Accounts
+
+
Chart of Accounts
+ + +
@@ -1232,6 +1245,50 @@
+ + + + +
Add Account
+ + + + + +
+ Creates an Open directive in the Beancount ledger and syncs it into Libra + so permissions can be granted. Per-user accounts are managed automatically. +
+ +
+ + Create Account + + Cancel +
+
+
+
+