Compare commits
No commits in common. "main" and "v0.3.0" have entirely different histories.
14 changed files with 114 additions and 991 deletions
15
CLAUDE.md
15
CLAUDE.md
|
|
@ -209,8 +209,7 @@ entry = format_transaction(
|
|||
{"account": "Liabilities:Payable:User-abc123", "amount": "-50000 SATS"}
|
||||
],
|
||||
tags=["groceries"],
|
||||
links=["exp-a1b2c3d4e5f60708"], # typed settlement link; identity goes in entry-id metadata
|
||||
meta={"entry-id": "a1b2c3d4e5f60708"}
|
||||
links=["libra-entry-123"]
|
||||
)
|
||||
|
||||
# Submit to Fava
|
||||
|
|
@ -218,8 +217,6 @@ client = get_fava_client()
|
|||
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**:
|
||||
```python
|
||||
# Query user balance from Fava
|
||||
|
|
@ -281,8 +278,7 @@ entry = format_transaction(
|
|||
{"account": "Assets:Lightning:Balance", "amount": "-50000 SATS"}
|
||||
],
|
||||
tags=["utilities"],
|
||||
links=["exp-0123456789abcdef"],
|
||||
meta={"entry-id": "0123456789abcdef"}
|
||||
links=["libra-tx-123"]
|
||||
)
|
||||
|
||||
client = get_fava_client()
|
||||
|
|
@ -310,13 +306,6 @@ result = await client.query(query)
|
|||
3. User accounts use `user_id` (NOT `wallet_id`) for consistency
|
||||
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`:
|
||||
- Pure validation functions for entry correctness before submitting to Fava
|
||||
|
||||
|
|
|
|||
|
|
@ -945,8 +945,7 @@ def format_revenue_entry(
|
|||
entry_date: date,
|
||||
fiat_currency: Optional[str] = None,
|
||||
fiat_amount: Optional[Decimal] = None,
|
||||
reference: Optional[str] = None,
|
||||
entry_id: Optional[str] = None
|
||||
reference: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Format a revenue entry (libra receives payment directly).
|
||||
|
|
@ -963,8 +962,7 @@ def format_revenue_entry(
|
|||
entry_date: Date of payment
|
||||
fiat_currency: Optional fiat currency
|
||||
fiat_amount: Optional fiat amount (unsigned)
|
||||
reference: Optional reference (invoice ID, etc.) — stored as its own link
|
||||
entry_id: Optional unique entry ID (generated if not provided)
|
||||
reference: Optional reference
|
||||
|
||||
Returns:
|
||||
Fava API entry dict
|
||||
|
|
@ -980,9 +978,6 @@ def format_revenue_entry(
|
|||
fiat_amount=Decimal("50.00")
|
||||
)
|
||||
"""
|
||||
if not entry_id:
|
||||
entry_id = generate_entry_id()
|
||||
|
||||
amount_sats_abs = abs(amount_sats)
|
||||
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
|
||||
entry_meta = {
|
||||
"source": "libra-api",
|
||||
"entry-id": entry_id
|
||||
"source": "libra-api"
|
||||
}
|
||||
|
||||
links = []
|
||||
if reference:
|
||||
links.append(sanitize_link(reference))
|
||||
links.append(reference)
|
||||
|
||||
return format_transaction(
|
||||
date_val=entry_date,
|
||||
|
|
|
|||
6
crud.py
6
crud.py
|
|
@ -250,13 +250,9 @@ async def get_or_create_user_account(
|
|||
if not fava_account_exists:
|
||||
# Create account in Fava/Beancount via Open directive
|
||||
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(
|
||||
account_name=account_name,
|
||||
currencies=None,
|
||||
currencies=["EUR", "SATS", "USD"], # Support common currencies
|
||||
metadata={
|
||||
"user_id": user_id,
|
||||
"description": f"User-specific {account_type.value} account"
|
||||
|
|
|
|||
|
|
@ -44,55 +44,6 @@ 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")
|
||||
)
|
||||
|
||||
|
||||
# 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:
|
||||
"""
|
||||
Async client for Fava REST API.
|
||||
|
|
@ -1593,7 +1544,7 @@ class FavaClient:
|
|||
async def add_account(
|
||||
self,
|
||||
account_name: str,
|
||||
currencies: Optional[list[str]] = None,
|
||||
currencies: list[str],
|
||||
opening_date: Optional[date] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
target_file: Optional[str] = None,
|
||||
|
|
@ -1676,16 +1627,10 @@ class FavaClient:
|
|||
sha256sum = source_data["sha256sum"]
|
||||
source = source_data["source"]
|
||||
|
||||
# Step 2: Check if account already exists (may have been
|
||||
# created by a concurrent request). See
|
||||
# _open_directive_exists for the anchoring rationale.
|
||||
if _open_directive_exists(source, account_name):
|
||||
# Step 2: Check if account already exists (may have been created by concurrent request)
|
||||
if f"open {account_name}" in source:
|
||||
logger.info(f"Account {account_name} already exists in {target_file}")
|
||||
return {
|
||||
"data": sha256sum,
|
||||
"mtime": source_data.get("mtime", ""),
|
||||
"already_existed": True,
|
||||
}
|
||||
return {"data": sha256sum, "mtime": source_data.get("mtime", "")}
|
||||
|
||||
# Step 3: Always append at end of file.
|
||||
# Post-split layout, each include file has one mutation
|
||||
|
|
@ -1697,23 +1642,19 @@ class FavaClient:
|
|||
lines = source.split('\n')
|
||||
insert_index = len(lines)
|
||||
|
||||
# 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]
|
||||
# Step 4: Format Open directive as Beancount text
|
||||
currencies_str = ", ".join(currencies)
|
||||
open_lines = [
|
||||
"",
|
||||
f"{opening_date.isoformat()} open {account_name} {currencies_str}"
|
||||
]
|
||||
|
||||
# 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}: "{_escape_beancount_string(value)}"'
|
||||
)
|
||||
open_lines.append(f' {key}: "{value}"')
|
||||
else:
|
||||
open_lines.append(f' {key}: {value}')
|
||||
|
||||
|
|
@ -1739,7 +1680,7 @@ class FavaClient:
|
|||
result = response.json()
|
||||
|
||||
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:
|
||||
# Check for checksum conflict (HTTP 412 Precondition Failed or similar)
|
||||
|
|
|
|||
|
|
@ -51,11 +51,7 @@ class CreateAccount(BaseModel):
|
|||
class CreateChartAccount(BaseModel):
|
||||
"""Admin-created chart-of-accounts entry written to accounts/chart.beancount."""
|
||||
name: str # Full hierarchical account name, e.g. "Expenses:Services:Domain"
|
||||
# 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
|
||||
currencies: list[str] = ["EUR", "SATS", "USD"]
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -69,13 +69,6 @@ window.app = Vue.createApp({
|
|||
userWalletId: '',
|
||||
loading: false
|
||||
},
|
||||
addAccountDialog: {
|
||||
show: false,
|
||||
rootType: 'Expenses',
|
||||
subPath: '',
|
||||
description: '',
|
||||
loading: false
|
||||
},
|
||||
receivableDialog: {
|
||||
show: false,
|
||||
selectedUser: '',
|
||||
|
|
@ -293,16 +286,6 @@ window.app = Vue.createApp({
|
|||
})
|
||||
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() {
|
||||
const options = []
|
||||
this.users.forEach(user => {
|
||||
|
|
@ -583,55 +566,6 @@ window.app = Vue.createApp({
|
|||
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() {
|
||||
this.settingsDialog.libraWalletId = this.settings?.libra_wallet_id || ''
|
||||
this.settingsDialog.favaUrl = this.settings?.fava_url || 'http://localhost:3333'
|
||||
|
|
|
|||
|
|
@ -857,20 +857,7 @@
|
|||
<!-- Chart of Accounts -->
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<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>
|
||||
<h6 class="q-my-none q-mb-md">Chart of Accounts</h6>
|
||||
<q-list dense v-if="accounts.length > 0">
|
||||
<q-item v-for="account in accounts" :key="account.id">
|
||||
<q-item-section>
|
||||
|
|
@ -1245,63 +1232,6 @@
|
|||
</q-card>
|
||||
</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 -->
|
||||
<q-dialog v-model="receivableDialog.show" position="top">
|
||||
<q-card v-if="receivableDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
|
|
|
|||
|
|
@ -22,56 +22,22 @@ Inside the regtest container `fava` is already provisioned.
|
|||
|
||||
## Running
|
||||
|
||||
The suite targets the **`lnbits/dev` worktree** (`~/dev/lnbits/dev`) — it
|
||||
relies on dev-branch modules (`lnbits.core.signers`, the bunker work) that
|
||||
`main` doesn't carry. A known-good invocation from scratch:
|
||||
From the LNbits source root (with the libra extension reachable via `LNBITS_EXTENSIONS_PATH` or symlinked into `lnbits/extensions/`):
|
||||
|
||||
```bash
|
||||
# One-time: build a venv with lnbits (dev) + test deps + fava
|
||||
nix-shell -p uv --run "uv venv /tmp/libra-test-venv --python 3.12 && \
|
||||
uv pip install --python /tmp/libra-test-venv/bin/python \
|
||||
-e ~/dev/lnbits/dev pytest asgi-lifespan fava"
|
||||
# Whole suite
|
||||
pytest path/to/libra/tests
|
||||
|
||||
# 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)
|
||||
... pytest path/to/libra/tests/test_smoke.py
|
||||
pytest path/to/libra/tests/test_smoke.py
|
||||
|
||||
# One area
|
||||
... pytest path/to/libra/tests/test_balances_api.py
|
||||
pytest path/to/libra/tests/test_balances_api.py
|
||||
|
||||
# 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.
|
||||
|
||||
## Conventions
|
||||
|
|
|
|||
|
|
@ -108,9 +108,6 @@ def _settings_cleanup(settings: Settings) -> None:
|
|||
settings.lnbits_user_activation_by_invitation_code = False
|
||||
settings.lnbits_register_reusable_activation_code = ""
|
||||
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")
|
||||
|
|
@ -136,12 +133,6 @@ def settings() -> Iterator[Settings]:
|
|||
lnbits_settings.lnbits_admin_ui = True
|
||||
lnbits_settings.lnbits_extensions_default_install = []
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -179,32 +170,13 @@ option "render_commas" "TRUE"
|
|||
2020-01-01 open Equity:Opening-Balances EUR,SATS
|
||||
2020-01-01 open Income: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")
|
||||
def fava_ledger_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""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)."""
|
||||
"""Session-scoped .beancount file Fava reads from."""
|
||||
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.write_text(MINIMAL_LEDGER)
|
||||
return ledger
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ separate ISO code field — this matches `models.ExpenseEntry` / `ReceivableEntr
|
|||
from decimal import Decimal
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from httpx import AsyncClient, Response
|
||||
from httpx import AsyncClient
|
||||
|
||||
Amount = Union[Decimal, int, float, str]
|
||||
|
||||
|
|
@ -106,26 +106,6 @@ async def grant_permission(
|
|||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -141,7 +121,6 @@ async def post_expense(
|
|||
expense_account: str,
|
||||
currency: Optional[str] = "EUR",
|
||||
is_equity: bool = False,
|
||||
reference: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""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,
|
||||
"currency": currency,
|
||||
"is_equity": is_equity,
|
||||
"reference": reference,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201, f"post_expense failed: {r.status_code} {r.text}"
|
||||
|
|
@ -172,7 +150,6 @@ async def post_income(
|
|||
description: str,
|
||||
revenue_account: str,
|
||||
currency: str = "EUR",
|
||||
reference: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""User submits income on libra's behalf — creates Receivable (user owes libra)."""
|
||||
r = await client.post(
|
||||
|
|
@ -183,14 +160,13 @@ async def post_income(
|
|||
"amount": _amount(amount),
|
||||
"revenue_account": revenue_account,
|
||||
"currency": currency,
|
||||
"reference": reference,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201, f"post_income failed: {r.status_code} {r.text}"
|
||||
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(
|
||||
"/libra/api/v1/entries/user",
|
||||
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()
|
||||
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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"]
|
||||
|
|
@ -31,75 +31,9 @@ bf = _module("beancount_format")
|
|||
au = _module("account_utils")
|
||||
val = _module("core.validation")
|
||||
mdl = _module("models")
|
||||
fc = _module("fava_client")
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
233
views_api.py
233
views_api.py
|
|
@ -434,39 +434,6 @@ async def api_get_journal_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")
|
||||
async def api_get_user_entries(
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
|
|
@ -557,9 +524,18 @@ async def api_get_user_entries(
|
|||
continue
|
||||
|
||||
# Extract data for frontend
|
||||
# Resolve canonical entry ID (metadata first, link fallback)
|
||||
entry_id = _extract_entry_id(e)
|
||||
# Extract entry ID from links
|
||||
entry_id = None
|
||||
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
|
||||
amount_sats = 0
|
||||
|
|
@ -616,15 +592,13 @@ async def api_get_user_entries(
|
|||
fiat_amount = float(cost_match.group(1))
|
||||
fiat_currency = cost_match.group(2)
|
||||
|
||||
# Extract reference from links (first link that isn't a
|
||||
# libra-system link: typed entry/settlement links, lightning
|
||||
# payment links, or the legacy libra-{id} identity link)
|
||||
# Extract reference from links (first non-libra link)
|
||||
reference = None
|
||||
if isinstance(links, (list, set)):
|
||||
for link in links:
|
||||
if isinstance(link, str):
|
||||
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
|
||||
break
|
||||
|
||||
|
|
@ -804,9 +778,19 @@ async def api_get_pending_entries(
|
|||
for e in all_entries:
|
||||
# Only include pending transactions that are NOT voided
|
||||
if e.get("t") == "Transaction" and e.get("flag") == "!" and "voided" not in e.get("tags", []):
|
||||
# Resolve canonical entry ID (metadata first, link fallback)
|
||||
entry_id = _extract_entry_id(e)
|
||||
# Extract entry ID from links field
|
||||
entry_id = None
|
||||
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
|
||||
user_id = None
|
||||
|
|
@ -922,11 +906,7 @@ async def api_create_journal_entry(
|
|||
Submits entry to Fava/Beancount.
|
||||
"""
|
||||
from .fava_client import get_fava_client
|
||||
from .beancount_format import (
|
||||
format_transaction,
|
||||
format_posting_with_cost,
|
||||
sanitize_link,
|
||||
)
|
||||
from .beancount_format import format_transaction, format_posting_with_cost
|
||||
|
||||
# Validate that entry balances to zero
|
||||
total = sum(line.amount for line in data.lines)
|
||||
|
|
@ -995,7 +975,7 @@ async def api_create_journal_entry(
|
|||
tags = data.meta.get("tags", [])
|
||||
links = data.meta.get("links", [])
|
||||
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_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
|
||||
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()
|
||||
|
||||
|
|
@ -1160,8 +1140,12 @@ async def api_create_expense_entry(
|
|||
import uuid
|
||||
entry_id = str(uuid.uuid4()).replace("-", "")[:16]
|
||||
|
||||
# Format Beancount entry. Identity travels as entry-id metadata +
|
||||
# exp-{entry_id} link; the user reference becomes its own link.
|
||||
# Add libra ID as reference/link (sanitized for Beancount)
|
||||
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(
|
||||
user_id=wallet.wallet.user,
|
||||
expense_account=expense_account.name,
|
||||
|
|
@ -1172,8 +1156,8 @@ async def api_create_expense_entry(
|
|||
is_equity=data.is_equity,
|
||||
fiat_currency=fiat_currency,
|
||||
fiat_amount=fiat_amount,
|
||||
reference=data.reference,
|
||||
entry_id=entry_id
|
||||
reference=libra_reference,
|
||||
entry_id=entry_id # Pass entry_id so all links match
|
||||
)
|
||||
|
||||
# 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(),
|
||||
created_by=wallet.wallet.user, # Use user_id, not wallet_id
|
||||
created_at=datetime.now(),
|
||||
reference=data.reference,
|
||||
reference=libra_reference,
|
||||
flag=JournalEntryFlag.PENDING,
|
||||
meta=entry_meta,
|
||||
lines=[
|
||||
|
|
@ -1278,15 +1262,17 @@ async def api_create_income_entry(
|
|||
|
||||
# Submit to Fava
|
||||
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()
|
||||
|
||||
import uuid
|
||||
entry_id = str(uuid.uuid4()).replace("-", "")[:16]
|
||||
|
||||
# Identity travels as entry-id metadata + inc-{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}"
|
||||
|
||||
entry = format_income_entry(
|
||||
user_id=wallet.wallet.user,
|
||||
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(),
|
||||
fiat_currency=fiat_currency,
|
||||
fiat_amount=data.amount,
|
||||
reference=data.reference,
|
||||
reference=libra_reference,
|
||||
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(),
|
||||
created_by=wallet.wallet.user,
|
||||
created_at=datetime.now(),
|
||||
reference=data.reference,
|
||||
reference=libra_reference,
|
||||
flag=JournalEntryFlag.PENDING,
|
||||
meta=entry_meta,
|
||||
lines=[
|
||||
|
|
@ -1403,7 +1389,7 @@ async def api_create_receivable_entry(
|
|||
|
||||
# Format as Beancount entry and submit to Fava
|
||||
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()
|
||||
|
||||
|
|
@ -1415,8 +1401,12 @@ async def api_create_receivable_entry(
|
|||
import uuid
|
||||
entry_id = str(uuid.uuid4()).replace("-", "")[:16]
|
||||
|
||||
# Format Beancount entry. Identity travels as entry-id metadata +
|
||||
# rcv-{entry_id} link; the user reference becomes its own link.
|
||||
# Add libra ID as reference/link (sanitized for Beancount)
|
||||
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(
|
||||
user_id=data.user_id,
|
||||
revenue_account=revenue_account.name,
|
||||
|
|
@ -1426,8 +1416,8 @@ async def api_create_receivable_entry(
|
|||
entry_date=datetime.now().date(),
|
||||
fiat_currency=fiat_currency,
|
||||
fiat_amount=fiat_amount,
|
||||
reference=data.reference,
|
||||
entry_id=entry_id
|
||||
reference=libra_reference,
|
||||
entry_id=entry_id # Pass entry_id so all links match
|
||||
)
|
||||
|
||||
# Submit to Fava
|
||||
|
|
@ -1441,7 +1431,7 @@ async def api_create_receivable_entry(
|
|||
entry_date=datetime.now(),
|
||||
created_by=auth.user_id,
|
||||
created_at=datetime.now(),
|
||||
reference=data.reference,
|
||||
reference=libra_reference, # Use libra reference with unique ID
|
||||
flag=JournalEntryFlag.PENDING,
|
||||
meta=entry_meta,
|
||||
lines=[
|
||||
|
|
@ -1477,7 +1467,7 @@ async def api_create_revenue_entry(
|
|||
Submits entry to Fava/Beancount.
|
||||
"""
|
||||
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
|
||||
revenue_account = await get_account_by_name(data.revenue_account)
|
||||
|
|
@ -1527,8 +1517,11 @@ async def api_create_revenue_entry(
|
|||
import uuid
|
||||
entry_id = str(uuid.uuid4()).replace("-", "")[:16]
|
||||
|
||||
# Identity travels as entry-id metadata; the user reference becomes
|
||||
# its own link.
|
||||
# Add libra ID as reference/link (sanitized for Beancount)
|
||||
libra_reference = f"libra-{entry_id}"
|
||||
if data.reference:
|
||||
libra_reference = f"{sanitize_link(data.reference)}-{entry_id}"
|
||||
|
||||
entry = format_revenue_entry(
|
||||
payment_account=payment_account.name,
|
||||
revenue_account=revenue_account.name,
|
||||
|
|
@ -1537,8 +1530,7 @@ async def api_create_revenue_entry(
|
|||
entry_date=datetime.now().date(),
|
||||
fiat_currency=fiat_currency,
|
||||
fiat_amount=fiat_amount,
|
||||
reference=data.reference,
|
||||
entry_id=entry_id,
|
||||
reference=libra_reference # Use libra reference with unique ID
|
||||
)
|
||||
|
||||
# Submit to Fava
|
||||
|
|
@ -1553,7 +1545,7 @@ async def api_create_revenue_entry(
|
|||
entry_date=datetime.now(),
|
||||
created_by=auth.user_id,
|
||||
created_at=datetime.now(),
|
||||
reference=data.reference,
|
||||
reference=libra_reference,
|
||||
flag=JournalEntryFlag.CLEARED,
|
||||
lines=[], # Empty - entry is stored in Fava, not Libra DB
|
||||
meta={"source": "fava", "fava_response": result.get('data', 'Unknown')}
|
||||
|
|
@ -2865,14 +2857,21 @@ async def api_approve_expense_entry(
|
|||
# 1. Get all journal entries from Fava
|
||||
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
|
||||
|
||||
for entry in all_entries:
|
||||
# Only look at transactions with pending flag
|
||||
if entry.get("t") == "Transaction" and entry.get("flag") == "!":
|
||||
if _extract_entry_id(entry) == entry_id:
|
||||
target_entry = entry
|
||||
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
|
||||
break
|
||||
if target_entry:
|
||||
break
|
||||
|
||||
if not target_entry:
|
||||
|
|
@ -2974,14 +2973,21 @@ async def api_reject_expense_entry(
|
|||
# 1. Get all journal entries from Fava
|
||||
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
|
||||
|
||||
for entry in all_entries:
|
||||
# Only look at transactions with pending flag
|
||||
if entry.get("t") == "Transaction" and entry.get("flag") == "!":
|
||||
if _extract_entry_id(entry) == entry_id:
|
||||
target_entry = entry
|
||||
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
|
||||
break
|
||||
if target_entry:
|
||||
break
|
||||
|
||||
if not target_entry:
|
||||
|
|
@ -3661,52 +3667,6 @@ async def api_get_account_hierarchy(
|
|||
_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)
|
||||
async def api_admin_add_chart_account(
|
||||
payload: CreateChartAccount,
|
||||
|
|
@ -3731,8 +3691,6 @@ async def api_admin_add_chart_account(
|
|||
),
|
||||
)
|
||||
|
||||
_validate_account_name(payload.name)
|
||||
|
||||
logger.info(
|
||||
f"Admin {auth.user_id[:8]} adding chart account {payload.name} "
|
||||
f"with currencies {payload.currencies}"
|
||||
|
|
@ -3743,38 +3701,15 @@ async def api_admin_add_chart_account(
|
|||
if payload.description:
|
||||
metadata["description"] = payload.description
|
||||
|
||||
result = await fava.add_account(
|
||||
await fava.add_account(
|
||||
account_name=payload.name,
|
||||
currencies=payload.currencies,
|
||||
target_file="accounts/chart.beancount",
|
||||
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.
|
||||
from .account_sync import sync_single_account_from_beancount
|
||||
synced = await sync_single_account_from_beancount(payload.name)
|
||||
|
||||
return {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue