Compare commits

..

No commits in common. "main" and "v0.3.0" have entirely different histories.

14 changed files with 114 additions and 991 deletions

View file

@ -209,8 +209,7 @@ entry = format_transaction(
{"account": "Liabilities:Payable:User-abc123", "amount": "-50000 SATS"} {"account": "Liabilities:Payable:User-abc123", "amount": "-50000 SATS"}
], ],
tags=["groceries"], tags=["groceries"],
links=["exp-a1b2c3d4e5f60708"], # typed settlement link; identity goes in entry-id metadata links=["libra-entry-123"]
meta={"entry-id": "a1b2c3d4e5f60708"}
) )
# Submit to Fava # Submit to Fava
@ -218,8 +217,6 @@ client = get_fava_client()
result = await client.add_entry(entry) result = await client.add_entry(entry)
``` ```
Prefer the purpose-built formatters (`format_expense_entry`, `format_income_entry`, …) over raw `format_transaction` — they write the `entry-id` metadata and typed links for you (see Data Integrity → Entry Identity & Links).
**Querying Balances**: **Querying Balances**:
```python ```python
# Query user balance from Fava # Query user balance from Fava
@ -281,8 +278,7 @@ entry = format_transaction(
{"account": "Assets:Lightning:Balance", "amount": "-50000 SATS"} {"account": "Assets:Lightning:Balance", "amount": "-50000 SATS"}
], ],
tags=["utilities"], tags=["utilities"],
links=["exp-0123456789abcdef"], links=["libra-tx-123"]
meta={"entry-id": "0123456789abcdef"}
) )
client = get_fava_client() client = get_fava_client()
@ -310,13 +306,6 @@ result = await client.query(query)
3. User accounts use `user_id` (NOT `wallet_id`) for consistency 3. User accounts use `user_id` (NOT `wallet_id`) for consistency
4. All accounting calculations delegated to Beancount/Fava 4. All accounting calculations delegated to Beancount/Fava
**Entry Identity & Links** (the contract `_extract_entry_id()` in views_api.py relies on):
- The `entry-id` **transaction metadata** is the single canonical entry identifier. Every libra-authored entry formatter (`format_expense_entry`, `format_receivable_entry`, `format_income_entry`, `format_revenue_entry`) writes it. All id resolution (pending list, user journal, approve, reject) reads it — never parse links to recover an id.
- Typed links `exp-{id}` / `rcv-{id}` / `inc-{id}` exist for **settlement tracking** (BQL queries match settlements to source entries by these links). They duplicate the id but are not the identity source.
- A user-supplied `reference` (invoice number, receipt id) becomes **its own sanitized link**, verbatim — never fused with the entry id, never displacing a system link. Two entries sharing a reference share the link (desired Beancount semantics).
- `ln-{payment_hash[:16]}` links mark Lightning payments.
- Legacy ledger history (pre-dfdcc44) carries a single `libra-{id}` link and no `entry-id` metadata — `_extract_entry_id()` falls back to parsing it. Do not write `libra-` links in new code.
**Validation** is performed in `core/validation.py`: **Validation** is performed in `core/validation.py`:
- Pure validation functions for entry correctness before submitting to Fava - Pure validation functions for entry correctness before submitting to Fava

View file

@ -945,8 +945,7 @@ def format_revenue_entry(
entry_date: date, entry_date: date,
fiat_currency: Optional[str] = None, fiat_currency: Optional[str] = None,
fiat_amount: Optional[Decimal] = None, fiat_amount: Optional[Decimal] = None,
reference: Optional[str] = None, reference: Optional[str] = None
entry_id: Optional[str] = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Format a revenue entry (libra receives payment directly). Format a revenue entry (libra receives payment directly).
@ -963,8 +962,7 @@ def format_revenue_entry(
entry_date: Date of payment entry_date: Date of payment
fiat_currency: Optional fiat currency fiat_currency: Optional fiat currency
fiat_amount: Optional fiat amount (unsigned) fiat_amount: Optional fiat amount (unsigned)
reference: Optional reference (invoice ID, etc.) stored as its own link reference: Optional reference
entry_id: Optional unique entry ID (generated if not provided)
Returns: Returns:
Fava API entry dict Fava API entry dict
@ -980,9 +978,6 @@ def format_revenue_entry(
fiat_amount=Decimal("50.00") fiat_amount=Decimal("50.00")
) )
""" """
if not entry_id:
entry_id = generate_entry_id()
amount_sats_abs = abs(amount_sats) amount_sats_abs = abs(amount_sats)
fiat_amount_abs = abs(fiat_amount) if fiat_amount else None fiat_amount_abs = abs(fiat_amount) if fiat_amount else None
@ -1007,13 +1002,12 @@ def format_revenue_entry(
# Note: created-via is redundant with #revenue-entry tag # Note: created-via is redundant with #revenue-entry tag
entry_meta = { entry_meta = {
"source": "libra-api", "source": "libra-api"
"entry-id": entry_id
} }
links = [] links = []
if reference: if reference:
links.append(sanitize_link(reference)) links.append(reference)
return format_transaction( return format_transaction(
date_val=entry_date, date_val=entry_date,

View file

@ -250,13 +250,9 @@ 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=None, currencies=["EUR", "SATS", "USD"], # Support common currencies
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,55 +44,6 @@ 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.
@ -1593,7 +1544,7 @@ class FavaClient:
async def add_account( async def add_account(
self, self,
account_name: str, account_name: str,
currencies: Optional[list[str]] = None, 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, target_file: Optional[str] = None,
@ -1676,16 +1627,10 @@ 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 # Step 2: Check if account already exists (may have been created by concurrent request)
# created by a concurrent request). See if f"open {account_name}" in source:
# _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 { return {"data": sha256sum, "mtime": source_data.get("mtime", "")}
"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
@ -1697,23 +1642,19 @@ 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 are an optional constraint on an Open currencies_str = ", ".join(currencies)
# directive; when none are given the account accepts open_lines = [
# any commodity. "",
open_directive = f"{opening_date.isoformat()} open {account_name}" f"{opening_date.isoformat()} open {account_name} {currencies_str}"
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( open_lines.append(f' {key}: "{value}"')
f' {key}: "{_escape_beancount_string(value)}"'
)
else: else:
open_lines.append(f' {key}: {value}') open_lines.append(f' {key}: {value}')
@ -1739,7 +1680,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, "already_existed": False} return result
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,11 +51,7 @@ 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"
# Optional currency constraint. Omitted by the UI: an Open directive needs currencies: list[str] = ["EUR", "SATS", "USD"]
# 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,13 +69,6 @@ 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: '',
@ -293,16 +286,6 @@ 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 => {
@ -583,55 +566,6 @@ 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,20 +857,7 @@
<!-- Chart of Accounts --> <!-- Chart of Accounts -->
<q-card> <q-card>
<q-card-section> <q-card-section>
<div class="row items-center q-mb-md"> <h6 class="q-my-none q-mb-md">Chart of Accounts</h6>
<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>
@ -1245,63 +1232,6 @@
</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

@ -22,56 +22,22 @@ Inside the regtest container `fava` is already provisioned.
## Running ## Running
The suite targets the **`lnbits/dev` worktree** (`~/dev/lnbits/dev`) — it From the LNbits source root (with the libra extension reachable via `LNBITS_EXTENSIONS_PATH` or symlinked into `lnbits/extensions/`):
relies on dev-branch modules (`lnbits.core.signers`, the bunker work) that
`main` doesn't carry. A known-good invocation from scratch:
```bash ```bash
# One-time: build a venv with lnbits (dev) + test deps + fava # Whole suite
nix-shell -p uv --run "uv venv /tmp/libra-test-venv --python 3.12 && \ pytest path/to/libra/tests
uv pip install --python /tmp/libra-test-venv/bin/python \
-e ~/dev/lnbits/dev pytest asgi-lifespan fava"
# Run (each invocation gets a fresh data folder — REQUIRED, see gotchas)
cd ~/dev/lnbits/dev && \
env LNBITS_KEY_MASTER=$(openssl rand -hex 32) \
LNBITS_DATA_FOLDER=$(mktemp -d -t libra-test-data-XXXX) \
LNBITS_EXTENSIONS_PATH=$HOME/dev/shared \
PYTHONPATH=$HOME/dev/shared/extensions:. \
PATH=/tmp/libra-test-venv/bin:$PATH \
/tmp/libra-test-venv/bin/pytest ~/dev/shared/extensions/libra/tests -q
```
```bash
# Smoke test only (validate the harness before running everything) # Smoke test only (validate the harness before running everything)
... pytest path/to/libra/tests/test_smoke.py pytest path/to/libra/tests/test_smoke.py
# One area # One area
... pytest path/to/libra/tests/test_balances_api.py pytest path/to/libra/tests/test_balances_api.py
# Single test, verbose # Single test, verbose
... pytest path/to/libra/tests/test_balances_api.py::test_mixed_income_expense_nets_correctly -v pytest path/to/libra/tests/test_balances_api.py::test_mixed_income_expense_nets_correctly -v
``` ```
### Environment gotchas (each cost a failed run on 2026-06-12)
- **`LNBITS_EXTENSIONS_PATH` is the *parent* of an `extensions/` dir** —
lnbits scans `{path}/extensions/` (`lnbits/app.py`,
`build_all_installed_extensions_list`). For extensions at
`~/dev/shared/extensions/libra`, pass `~/dev/shared`. Pointing it at
`~/dev/shared/extensions` makes libra invisible: zero extensions install,
migrations never run, and every test errors with
`no such table: extension_settings`.
- **Set `LNBITS_DATA_FOLDER` to a fresh temp dir explicitly.** The
conftest's `os.environ.setdefault` redirect is not always effective;
reusing a previous run's database fails `first_install` with
"Username already exists" during app-fixture setup.
- **`LNBITS_KEY_MASTER` (32-byte hex) is mandatory on lnbits dev** — the
signer migration aborts startup without it (issue lnbits#9
encrypt-at-rest). Any random value is fine for tests.
- **lnbits `main` does not work**: extensions importing
`lnbits.core.signers` fail to load, and libra's app fixture errors.
The Fava subprocess starts once per session (~1-2s) and is shared across tests; each test creates its own LNbits user so the shared ledger doesn't cause inter-test interference. The Fava subprocess starts once per session (~1-2s) and is shared across tests; each test creates its own LNbits user so the shared ledger doesn't cause inter-test interference.
## Conventions ## Conventions

View file

@ -108,9 +108,6 @@ 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")
@ -136,12 +133,6 @@ 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
@ -179,32 +170,13 @@ 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 split ledger Fava reads from: a root file that includes """Session-scoped .beancount file Fava reads from."""
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, Response from httpx import AsyncClient
Amount = Union[Decimal, int, float, str] Amount = Union[Decimal, int, float, str]
@ -106,26 +106,6 @@ 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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -141,7 +121,6 @@ async def post_expense(
expense_account: str, expense_account: str,
currency: Optional[str] = "EUR", currency: Optional[str] = "EUR",
is_equity: bool = False, is_equity: bool = False,
reference: Optional[str] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""User submits an expense — creates Liability (libra owes user) or Equity contribution. """User submits an expense — creates Liability (libra owes user) or Equity contribution.
@ -157,7 +136,6 @@ async def post_expense(
"user_wallet": user_wallet_id, "user_wallet": user_wallet_id,
"currency": currency, "currency": currency,
"is_equity": is_equity, "is_equity": is_equity,
"reference": reference,
}, },
) )
assert r.status_code == 201, f"post_expense failed: {r.status_code} {r.text}" assert r.status_code == 201, f"post_expense failed: {r.status_code} {r.text}"
@ -172,7 +150,6 @@ async def post_income(
description: str, description: str,
revenue_account: str, revenue_account: str,
currency: str = "EUR", currency: str = "EUR",
reference: Optional[str] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""User submits income on libra's behalf — creates Receivable (user owes libra).""" """User submits income on libra's behalf — creates Receivable (user owes libra)."""
r = await client.post( r = await client.post(
@ -183,14 +160,13 @@ async def post_income(
"amount": _amount(amount), "amount": _amount(amount),
"revenue_account": revenue_account, "revenue_account": revenue_account,
"currency": currency, "currency": currency,
"reference": reference,
}, },
) )
assert r.status_code == 201, f"post_income failed: {r.status_code} {r.text}" assert r.status_code == 201, f"post_income failed: {r.status_code} {r.text}"
return r.json() return r.json()
async def list_user_entries(client: AsyncClient, *, wallet_inkey: str) -> dict[str, Any]: async def list_user_entries(client: AsyncClient, *, wallet_inkey: str) -> list[dict]:
r = await client.get( r = await client.get(
"/libra/api/v1/entries/user", "/libra/api/v1/entries/user",
headers={"X-Api-Key": wallet_inkey}, headers={"X-Api-Key": wallet_inkey},
@ -199,18 +175,6 @@ async def list_user_entries(client: AsyncClient, *, wallet_inkey: str) -> dict[s
return r.json() return r.json()
async def list_pending_entries(
client: AsyncClient, *, super_user_headers: dict,
) -> list[dict]:
"""Admin lists pending (`!`) entries awaiting approval."""
r = await client.get(
"/libra/api/v1/entries/pending",
headers=super_user_headers,
)
assert r.status_code == 200, f"list_pending_entries failed: {r.status_code} {r.text}"
return r.json()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Entries — admin side # Entries — admin side
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -1,170 +0,0 @@
"""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

@ -1,168 +0,0 @@
"""Entry identity resolution — the canonical id must survive a user reference.
Regression coverage for the production bug where a pending income entry
created with a `reference` (e.g. an invoice number like "42-144") could
not be approved: the admin UI's pending list resolved the entry id by
parsing links for a `libra-` prefix, but reference-bearing entries carry
typed links (`inc-/exp-/rcv-{id}`) plus the reference as its own link
no `libra-` link. The id surfaced as the literal string "unknown" and
`POST /entries/unknown/approve` 404'd.
The fix makes the `entry-id` transaction metadata the single source of
truth (list, approve, and reject endpoints), with link parsing kept only
for pre-metadata ledger history. These tests pin that contract:
- pending list returns the real id for reference-bearing entries
- approve/reject resolve that id end-to-end
- the user reference round-trips as `reference`, never as a system link
"""
from uuid import uuid4
import pytest
from .helpers import (
approve_entry,
list_pending_entries,
list_user_entries,
post_expense,
post_income,
reject_entry,
)
@pytest.mark.anyio
async def test_pending_income_with_reference_resolves_real_id(
client, super_user_headers, configured_user, standard_accounts,
):
"""The production repro: income + reference must list with its real
id (not 'unknown') and approve successfully."""
_, wallet = configured_user
marker = f"Membership dues {uuid4().hex[:6]}"
posted = await post_income(
client,
wallet_inkey=wallet.inkey,
amount="700.00", currency="EUR",
description=marker,
revenue_account=standard_accounts["revenue_rent"]["name"],
reference="42-144",
)
pending = await list_pending_entries(
client, super_user_headers=super_user_headers,
)
entry = next(
(e for e in pending if marker in (e.get("description") or "")), None,
)
assert entry is not None, f"income entry not in pending list: {pending}"
assert entry["id"] == posted["id"], (
f"pending list must surface the canonical entry id, "
f"got {entry['id']!r} (expected {posted['id']!r})"
)
assert entry["id"] != "unknown"
# The id from the listing must drive approval end-to-end.
result = await approve_entry(
client, super_user_headers=super_user_headers, entry_id=entry["id"],
)
assert result.get("entry_id") == posted["id"]
@pytest.mark.anyio
async def test_pending_expense_with_reference_resolves_real_id_and_rejects(
client, super_user_headers, configured_user, standard_accounts,
):
"""Same contract on the expense path, exercised through reject."""
_, wallet = configured_user
marker = f"Receipted groceries {uuid4().hex[:6]}"
posted = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="36.93", currency="EUR",
description=marker,
expense_account=standard_accounts["expense_food"]["name"],
reference="RECEIPT/2026-06-12",
)
pending = await list_pending_entries(
client, super_user_headers=super_user_headers,
)
entry = next(
(e for e in pending if marker in (e.get("description") or "")), None,
)
assert entry is not None, f"expense entry not in pending list: {pending}"
assert entry["id"] == posted["id"]
result = await reject_entry(
client, super_user_headers=super_user_headers, entry_id=entry["id"],
)
assert result.get("entry_id") == posted["id"]
@pytest.mark.anyio
async def test_reference_round_trips_in_user_journal(
client, configured_user, standard_accounts,
):
"""The user journal must report the user's reference, not a system
link (typed inc-/exp- links used to leak into the reference field)."""
_, wallet = configured_user
marker = f"Referenced expense {uuid4().hex[:6]}"
posted = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="12.00", currency="EUR",
description=marker,
expense_account=standard_accounts["expense_food"]["name"],
reference="INV-7731",
)
assert posted.get("reference") == "INV-7731"
listing = await list_user_entries(client, wallet_inkey=wallet.inkey)
entry = next(
(
e for e in listing.get("entries", [])
if marker in (e.get("description") or "")
),
None,
)
assert entry is not None
assert entry["id"] == posted["id"]
assert entry.get("reference") == "INV-7731", (
f"reference field must carry the user's reference, "
f"got {entry.get('reference')!r}"
)
@pytest.mark.anyio
async def test_entry_without_reference_still_resolves(
client, super_user_headers, configured_user, standard_accounts,
):
"""No-reference entries keep working (the case that always worked)."""
_, wallet = configured_user
marker = f"Plain income {uuid4().hex[:6]}"
posted = await post_income(
client,
wallet_inkey=wallet.inkey,
amount="55.00", currency="EUR",
description=marker,
revenue_account=standard_accounts["revenue_rent"]["name"],
)
pending = await list_pending_entries(
client, super_user_headers=super_user_headers,
)
entry = next(
(e for e in pending if marker in (e.get("description") or "")), None,
)
assert entry is not None
assert entry["id"] == posted["id"]
result = await approve_entry(
client, super_user_headers=super_user_headers, entry_id=entry["id"],
)
assert result.get("entry_id") == posted["id"]

View file

@ -31,75 +31,9 @@ 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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -201,96 +135,6 @@ def test_generate_entry_ids_are_unique():
assert len(ids) == 100 # 16 hex chars = 64 bits; collisions in 100 are negligible assert len(ids) == 100 # 16 hex chars = 64 bits; collisions in 100 are negligible
# ---------------------------------------------------------------------------
# Entry identity contract — every libra-authored entry formatter must write
# `entry-id` metadata (the canonical id) and keep the user reference as its
# own sanitized link, never fused with the id.
# ---------------------------------------------------------------------------
def test_format_expense_entry_identity_contract():
entry = bf.format_expense_entry(
user_id="abc12345",
expense_account="Expenses:Food",
user_account="Liabilities:Payable:User-abc12345",
amount_sats=50000,
description="Groceries",
entry_date=date(2026, 6, 12),
fiat_currency="EUR",
fiat_amount=Decimal("46.50"),
reference="Invoice #123",
entry_id="deadbeef00000001",
)
assert entry["meta"]["entry-id"] == "deadbeef00000001"
assert "exp-deadbeef00000001" in entry["links"]
assert "Invoice-123" in entry["links"] # sanitized, standalone
def test_format_receivable_entry_identity_contract():
entry = bf.format_receivable_entry(
user_id="abc12345",
revenue_account="Income:Accommodation",
receivable_account="Assets:Receivable:User-abc12345",
amount_sats=100000,
description="2-night stay",
entry_date=date(2026, 6, 12),
fiat_currency="EUR",
fiat_amount=Decimal("93.00"),
reference="BOOKING/42",
entry_id="deadbeef00000002",
)
assert entry["meta"]["entry-id"] == "deadbeef00000002"
assert "rcv-deadbeef00000002" in entry["links"]
assert "BOOKING/42" in entry["links"]
def test_format_income_entry_identity_contract():
"""The production-bug shape: income + reference like '42-144'."""
entry = bf.format_income_entry(
user_id="abc12345",
user_account="Assets:Receivable:User-abc12345",
revenue_account="Income:MemberDuesContributions",
amount_sats=1112490,
description="2 Memberships",
entry_date=date(2026, 6, 12),
fiat_currency="USD",
fiat_amount=Decimal("700.00"),
reference="42-144",
entry_id="deadbeef00000003",
)
assert entry["meta"]["entry-id"] == "deadbeef00000003"
assert "inc-deadbeef00000003" in entry["links"]
assert "42-144" in entry["links"]
def test_format_revenue_entry_identity_contract():
entry = bf.format_revenue_entry(
payment_account="Assets:Cash",
revenue_account="Income:Sales",
amount_sats=100000,
description="Product sale",
entry_date=date(2026, 6, 12),
fiat_currency="EUR",
fiat_amount=Decimal("50.00"),
reference="Till receipt 9",
entry_id="deadbeef00000004",
)
assert entry["meta"]["entry-id"] == "deadbeef00000004"
assert "Till-receipt-9" in entry["links"] # sanitized
def test_format_revenue_entry_generates_entry_id_when_absent():
entry = bf.format_revenue_entry(
payment_account="Assets:Cash",
revenue_account="Income:Sales",
amount_sats=100000,
description="Product sale",
entry_date=date(2026, 6, 12),
)
eid = entry["meta"]["entry-id"]
assert len(eid) == 16 and all(c in "0123456789abcdef" for c in eid)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# account_utils.format_hierarchical_account_name # account_utils.format_hierarchical_account_name
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -434,39 +434,6 @@ async def api_get_journal_entries(
return enriched_entries return enriched_entries
# Link prefixes written by libra itself (vs user-supplied references):
# exp-/rcv-/inc- typed entry links, ln- lightning payment links, and the
# legacy libra-{id} identity link.
_SYSTEM_LINK_PREFIXES = ("exp-", "rcv-", "inc-", "ln-", "libra-")
def _extract_entry_id(entry: dict) -> Optional[str]:
"""Resolve the canonical libra entry id for a Fava transaction.
The ``entry-id`` transaction metadata is the single source of truth
written by every libra entry formatter since dfdcc44. Ledger history
predating it carries only a ``libra-{id}`` link; parse that as a
fallback so old entries still resolve.
Returns None when no id can be determined (e.g. settlement/payment
transactions, which are not approvable).
"""
meta = entry.get("meta", {})
entry_id = meta.get("entry-id")
if entry_id:
return str(entry_id)
# Legacy fallback: pre-entry-id ledger history (single libra-{id} link)
links = entry.get("links", [])
if isinstance(links, (list, set)):
for link in links:
if isinstance(link, str):
link_clean = link.lstrip('^')
if link_clean.startswith("libra-"):
return link_clean[len("libra-"):]
return None
@libra_api_router.get("/api/v1/entries/user") @libra_api_router.get("/api/v1/entries/user")
async def api_get_user_entries( async def api_get_user_entries(
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
@ -557,9 +524,18 @@ async def api_get_user_entries(
continue continue
# Extract data for frontend # Extract data for frontend
# Resolve canonical entry ID (metadata first, link fallback) # Extract entry ID from links
entry_id = _extract_entry_id(e) entry_id = None
links = e.get("links", []) links = e.get("links", [])
if isinstance(links, (list, set)):
for link in links:
if isinstance(link, str):
link_clean = link.lstrip('^')
if "libra-" in link_clean:
parts = link_clean.split("libra-")
if len(parts) > 1:
entry_id = parts[-1]
break
# Extract amount from postings # Extract amount from postings
amount_sats = 0 amount_sats = 0
@ -616,15 +592,13 @@ async def api_get_user_entries(
fiat_amount = float(cost_match.group(1)) fiat_amount = float(cost_match.group(1))
fiat_currency = cost_match.group(2) fiat_currency = cost_match.group(2)
# Extract reference from links (first link that isn't a # Extract reference from links (first non-libra link)
# libra-system link: typed entry/settlement links, lightning
# payment links, or the legacy libra-{id} identity link)
reference = None reference = None
if isinstance(links, (list, set)): if isinstance(links, (list, set)):
for link in links: for link in links:
if isinstance(link, str): if isinstance(link, str):
link_clean = link.lstrip('^') link_clean = link.lstrip('^')
if not link_clean.startswith(_SYSTEM_LINK_PREFIXES): if not link_clean.startswith("libra-") and not link_clean.startswith("ln-"):
reference = link_clean reference = link_clean
break break
@ -804,9 +778,19 @@ async def api_get_pending_entries(
for e in all_entries: for e in all_entries:
# Only include pending transactions that are NOT voided # Only include pending transactions that are NOT voided
if e.get("t") == "Transaction" and e.get("flag") == "!" and "voided" not in e.get("tags", []): if e.get("t") == "Transaction" and e.get("flag") == "!" and "voided" not in e.get("tags", []):
# Resolve canonical entry ID (metadata first, link fallback) # Extract entry ID from links field
entry_id = _extract_entry_id(e) entry_id = None
links = e.get("links", []) links = e.get("links", [])
if isinstance(links, (list, set)):
for link in links:
if isinstance(link, str):
# Strip ^ prefix if present (Beancount link syntax)
link_clean = link.lstrip('^')
if "libra-" in link_clean:
parts = link_clean.split("libra-")
if len(parts) > 1:
entry_id = parts[-1]
break
# Extract user ID from metadata or account names # Extract user ID from metadata or account names
user_id = None user_id = None
@ -922,11 +906,7 @@ async def api_create_journal_entry(
Submits entry to Fava/Beancount. Submits entry to Fava/Beancount.
""" """
from .fava_client import get_fava_client from .fava_client import get_fava_client
from .beancount_format import ( from .beancount_format import format_transaction, format_posting_with_cost
format_transaction,
format_posting_with_cost,
sanitize_link,
)
# Validate that entry balances to zero # Validate that entry balances to zero
total = sum(line.amount for line in data.lines) total = sum(line.amount for line in data.lines)
@ -995,7 +975,7 @@ async def api_create_journal_entry(
tags = data.meta.get("tags", []) tags = data.meta.get("tags", [])
links = data.meta.get("links", []) links = data.meta.get("links", [])
if data.reference: if data.reference:
links.append(sanitize_link(data.reference)) links.append(data.reference)
# Entry metadata (excluding tags and links which go at transaction level) # Entry metadata (excluding tags and links which go at transaction level)
entry_meta = {k: v for k, v in data.meta.items() if k not in ["tags", "links"]} entry_meta = {k: v for k, v in data.meta.items() if k not in ["tags", "links"]}
@ -1148,7 +1128,7 @@ async def api_create_expense_entry(
# Format as Beancount entry and submit to Fava # Format as Beancount entry and submit to Fava
from .fava_client import get_fava_client from .fava_client import get_fava_client
from .beancount_format import format_expense_entry from .beancount_format import format_expense_entry, sanitize_link
fava = get_fava_client() fava = get_fava_client()
@ -1160,8 +1140,12 @@ async def api_create_expense_entry(
import uuid import uuid
entry_id = str(uuid.uuid4()).replace("-", "")[:16] entry_id = str(uuid.uuid4()).replace("-", "")[:16]
# Format Beancount entry. Identity travels as entry-id metadata + # Add libra ID as reference/link (sanitized for Beancount)
# exp-{entry_id} link; the user reference becomes its own link. libra_reference = f"libra-{entry_id}"
if data.reference:
libra_reference = f"{sanitize_link(data.reference)}-{entry_id}"
# Format Beancount entry
entry = format_expense_entry( entry = format_expense_entry(
user_id=wallet.wallet.user, user_id=wallet.wallet.user,
expense_account=expense_account.name, expense_account=expense_account.name,
@ -1172,8 +1156,8 @@ async def api_create_expense_entry(
is_equity=data.is_equity, is_equity=data.is_equity,
fiat_currency=fiat_currency, fiat_currency=fiat_currency,
fiat_amount=fiat_amount, fiat_amount=fiat_amount,
reference=data.reference, reference=libra_reference,
entry_id=entry_id entry_id=entry_id # Pass entry_id so all links match
) )
# Submit to Fava # Submit to Fava
@ -1187,7 +1171,7 @@ async def api_create_expense_entry(
entry_date=data.entry_date if data.entry_date else datetime.now(), entry_date=data.entry_date if data.entry_date else datetime.now(),
created_by=wallet.wallet.user, # Use user_id, not wallet_id created_by=wallet.wallet.user, # Use user_id, not wallet_id
created_at=datetime.now(), created_at=datetime.now(),
reference=data.reference, reference=libra_reference,
flag=JournalEntryFlag.PENDING, flag=JournalEntryFlag.PENDING,
meta=entry_meta, meta=entry_meta,
lines=[ lines=[
@ -1278,15 +1262,17 @@ async def api_create_income_entry(
# Submit to Fava # Submit to Fava
from .fava_client import get_fava_client from .fava_client import get_fava_client
from .beancount_format import format_income_entry from .beancount_format import format_income_entry, sanitize_link
fava = get_fava_client() fava = get_fava_client()
import uuid import uuid
entry_id = str(uuid.uuid4()).replace("-", "")[:16] entry_id = str(uuid.uuid4()).replace("-", "")[:16]
# Identity travels as entry-id metadata + inc-{entry_id} link; the libra_reference = f"libra-{entry_id}"
# user reference becomes its own link. if data.reference:
libra_reference = f"{sanitize_link(data.reference)}-{entry_id}"
entry = format_income_entry( entry = format_income_entry(
user_id=wallet.wallet.user, user_id=wallet.wallet.user,
user_account=user_account.name, user_account=user_account.name,
@ -1296,7 +1282,7 @@ async def api_create_income_entry(
entry_date=data.entry_date.date() if data.entry_date else datetime.now().date(), entry_date=data.entry_date.date() if data.entry_date else datetime.now().date(),
fiat_currency=fiat_currency, fiat_currency=fiat_currency,
fiat_amount=data.amount, fiat_amount=data.amount,
reference=data.reference, reference=libra_reference,
entry_id=entry_id, entry_id=entry_id,
) )
@ -1317,7 +1303,7 @@ async def api_create_income_entry(
entry_date=data.entry_date if data.entry_date else datetime.now(), entry_date=data.entry_date if data.entry_date else datetime.now(),
created_by=wallet.wallet.user, created_by=wallet.wallet.user,
created_at=datetime.now(), created_at=datetime.now(),
reference=data.reference, reference=libra_reference,
flag=JournalEntryFlag.PENDING, flag=JournalEntryFlag.PENDING,
meta=entry_meta, meta=entry_meta,
lines=[ lines=[
@ -1403,7 +1389,7 @@ async def api_create_receivable_entry(
# Format as Beancount entry and submit to Fava # Format as Beancount entry and submit to Fava
from .fava_client import get_fava_client from .fava_client import get_fava_client
from .beancount_format import format_receivable_entry from .beancount_format import format_receivable_entry, sanitize_link
fava = get_fava_client() fava = get_fava_client()
@ -1415,8 +1401,12 @@ async def api_create_receivable_entry(
import uuid import uuid
entry_id = str(uuid.uuid4()).replace("-", "")[:16] entry_id = str(uuid.uuid4()).replace("-", "")[:16]
# Format Beancount entry. Identity travels as entry-id metadata + # Add libra ID as reference/link (sanitized for Beancount)
# rcv-{entry_id} link; the user reference becomes its own link. libra_reference = f"libra-{entry_id}"
if data.reference:
libra_reference = f"{sanitize_link(data.reference)}-{entry_id}"
# Format Beancount entry
entry = format_receivable_entry( entry = format_receivable_entry(
user_id=data.user_id, user_id=data.user_id,
revenue_account=revenue_account.name, revenue_account=revenue_account.name,
@ -1426,8 +1416,8 @@ async def api_create_receivable_entry(
entry_date=datetime.now().date(), entry_date=datetime.now().date(),
fiat_currency=fiat_currency, fiat_currency=fiat_currency,
fiat_amount=fiat_amount, fiat_amount=fiat_amount,
reference=data.reference, reference=libra_reference,
entry_id=entry_id entry_id=entry_id # Pass entry_id so all links match
) )
# Submit to Fava # Submit to Fava
@ -1441,7 +1431,7 @@ async def api_create_receivable_entry(
entry_date=datetime.now(), entry_date=datetime.now(),
created_by=auth.user_id, created_by=auth.user_id,
created_at=datetime.now(), created_at=datetime.now(),
reference=data.reference, reference=libra_reference, # Use libra reference with unique ID
flag=JournalEntryFlag.PENDING, flag=JournalEntryFlag.PENDING,
meta=entry_meta, meta=entry_meta,
lines=[ lines=[
@ -1477,7 +1467,7 @@ async def api_create_revenue_entry(
Submits entry to Fava/Beancount. Submits entry to Fava/Beancount.
""" """
from .fava_client import get_fava_client from .fava_client import get_fava_client
from .beancount_format import format_revenue_entry from .beancount_format import format_revenue_entry, sanitize_link
# Get revenue account # Get revenue account
revenue_account = await get_account_by_name(data.revenue_account) revenue_account = await get_account_by_name(data.revenue_account)
@ -1527,8 +1517,11 @@ async def api_create_revenue_entry(
import uuid import uuid
entry_id = str(uuid.uuid4()).replace("-", "")[:16] entry_id = str(uuid.uuid4()).replace("-", "")[:16]
# Identity travels as entry-id metadata; the user reference becomes # Add libra ID as reference/link (sanitized for Beancount)
# its own link. libra_reference = f"libra-{entry_id}"
if data.reference:
libra_reference = f"{sanitize_link(data.reference)}-{entry_id}"
entry = format_revenue_entry( entry = format_revenue_entry(
payment_account=payment_account.name, payment_account=payment_account.name,
revenue_account=revenue_account.name, revenue_account=revenue_account.name,
@ -1537,8 +1530,7 @@ async def api_create_revenue_entry(
entry_date=datetime.now().date(), entry_date=datetime.now().date(),
fiat_currency=fiat_currency, fiat_currency=fiat_currency,
fiat_amount=fiat_amount, fiat_amount=fiat_amount,
reference=data.reference, reference=libra_reference # Use libra reference with unique ID
entry_id=entry_id,
) )
# Submit to Fava # Submit to Fava
@ -1553,7 +1545,7 @@ async def api_create_revenue_entry(
entry_date=datetime.now(), entry_date=datetime.now(),
created_by=auth.user_id, created_by=auth.user_id,
created_at=datetime.now(), created_at=datetime.now(),
reference=data.reference, reference=libra_reference,
flag=JournalEntryFlag.CLEARED, flag=JournalEntryFlag.CLEARED,
lines=[], # Empty - entry is stored in Fava, not Libra DB lines=[], # Empty - entry is stored in Fava, not Libra DB
meta={"source": "fava", "fava_response": result.get('data', 'Unknown')} meta={"source": "fava", "fava_response": result.get('data', 'Unknown')}
@ -2865,15 +2857,22 @@ async def api_approve_expense_entry(
# 1. Get all journal entries from Fava # 1. Get all journal entries from Fava
all_entries = await fava.get_journal_entries() all_entries = await fava.get_journal_entries()
# 2. Find the pending transaction with matching canonical entry id # 2. Find the entry with matching libra ID in links
target_entry = None target_entry = None
for entry in all_entries: for entry in all_entries:
# Only look at transactions with pending flag # Only look at transactions with pending flag
if entry.get("t") == "Transaction" and entry.get("flag") == "!": if entry.get("t") == "Transaction" and entry.get("flag") == "!":
if _extract_entry_id(entry) == entry_id: links = entry.get("links", [])
for link in links:
# Strip ^ prefix if present (Beancount link syntax)
link_clean = link.lstrip('^')
# Check if this entry has our libra ID
if link_clean == f"libra-{entry_id}" or link_clean.endswith(f"-{entry_id}"):
target_entry = entry target_entry = entry
break break
if target_entry:
break
if not target_entry: if not target_entry:
raise HTTPException( raise HTTPException(
@ -2974,15 +2973,22 @@ async def api_reject_expense_entry(
# 1. Get all journal entries from Fava # 1. Get all journal entries from Fava
all_entries = await fava.get_journal_entries() all_entries = await fava.get_journal_entries()
# 2. Find the pending transaction with matching canonical entry id # 2. Find the entry with matching libra ID in links
target_entry = None target_entry = None
for entry in all_entries: for entry in all_entries:
# Only look at transactions with pending flag # Only look at transactions with pending flag
if entry.get("t") == "Transaction" and entry.get("flag") == "!": if entry.get("t") == "Transaction" and entry.get("flag") == "!":
if _extract_entry_id(entry) == entry_id: links = entry.get("links", [])
for link in links:
# Strip ^ prefix if present (Beancount link syntax)
link_clean = link.lstrip('^')
# Check if this entry has our libra ID
if link_clean == f"libra-{entry_id}" or link_clean.endswith(f"-{entry_id}"):
target_entry = entry target_entry = entry
break break
if target_entry:
break
if not target_entry: if not target_entry:
raise HTTPException( raise HTTPException(
@ -3661,52 +3667,6 @@ 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,
@ -3731,8 +3691,6 @@ 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}"
@ -3743,38 +3701,15 @@ async def api_admin_add_chart_account(
if payload.description: if payload.description:
metadata["description"] = payload.description metadata["description"] = payload.description
result = await fava.add_account( 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,
) )
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. # 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) synced = await sync_single_account_from_beancount(payload.name)
return { return {