This document provides a professional accounting assessment of
-Libra’s net settlement entry pattern used for recording Lightning
-Network payments that settle fiat-denominated receivables. The analysis
-identifies areas where the implementation deviates from traditional
-accounting best practices and provides specific recommendations for
-improvement.
Libra operates as a Lightning Network-integrated accounting system
-for collectives (co-living spaces, makerspaces). It faces a unique
-accounting challenge:
-
Scenario: User creates a receivable in EUR (e.g.,
-€200 for room rent), then pays via Lightning Network in satoshis
-(225,033 sats).
-
Challenge: Record the payment while: 1. Clearing the
-exact EUR receivable amount 2. Recording the exact satoshi amount
-received 3. Handling cases where users have both receivables (owe
-Libra) and payables (Libra owes them) 4. Maintaining Beancount
-double-entry balance
Three-Posting Structure: 1. Lightning
-Account: Records SATS received with @@ total price
-notation 2. Receivable Account: Clears EUR receivable
-with sats-equivalent metadata 3. Payable Account:
-Clears any outstanding EUR payables (often 0.00)
-
-
Accounting Issues Identified
-
Issue 1: Zero-Amount Postings
-
Problem: The third posting often records
-0.00 EUR when no payable exists.
-
Liabilities:Payable:User-375ec158 0.00 EUR
-
Why This Is Wrong: - Zero-amount postings have no
-economic substance - Clutters the journal with non-events - Violates the
-principle of materiality (GAAP Concept Statement 2) - Makes auditing
-more difficult (reviewers must verify why zero amounts exist)
-
Accounting Principle Violated: > “Transactions
-should only include postings that represent actual economic events or
-changes in account balances.”
-
Impact: Low severity, but unprofessional
-presentation
Why This Is Problematic: - The @@
-notation already records the exact satoshi amount - Beancount’s price
-database stores this relationship - Metadata becomes redundant for this
-specific posting - Increases storage and potential for inconsistency
-
Technical Detail:
-
The @@ notation means “total price” and Beancount
-converts it to per-unit price:
Don’t: Mix both in the same transaction (current
-implementation)
-
-
Issue 3: No Exchange
-Gain/Loss Recognition
-
Problem: When receivables are denominated in one
-currency (EUR) and paid in another (SATS), exchange rate fluctuations
-create gains or losses that should be recognized.
This hides the exchange variance by treating the
-payment as if it was worth exactly the receivable amount.
-
GAAP/IFRS Requirement:
-
Under both US GAAP (ASC 830) and IFRS (IAS 21), exchange gains and
-losses on monetary items (like receivables) should be recognized in the
-period they occur.
Why This Matters: - Tax reporting may require
-exchange gain/loss recognition - Financial statements misstate true
-economic results - Auditors would flag this as a compliance issue -
-Cannot accurately calculate ROI or performance metrics
-
-
Issue 4: Semantic
-Misuse of Price Notation
-
Problem: The @ notation in Beancount
-represents acquisition cost, not settlement
-value.
What this notation means in accounting: “We
-purchased 225,033 satoshis at a cost of 0.000888 EUR
-per satoshi”
-
What actually happened: “We
-received 225,033 satoshis as payment for a debt”
-
Economic Difference: - Purchase:
-You exchange cash for an asset (buying Bitcoin) - Payment
-Receipt: You receive an asset in settlement of a receivable
-
Accounting Substance vs. Form: -
-Form: The transaction looks like a Bitcoin purchase -
-Substance: The transaction is actually a receivable
-collection
-
GAAP Principle (ASC 105-10-05): > “Accounting
-should reflect the economic substance of transactions, not merely their
-legal form.”
-
Why This Creates Issues:
-
-
Cost Basis Tracking: For tax purposes, the “cost”
-of Bitcoin received as payment should be its fair market value at
-receipt, not the receivable amount
-
Price Database Pollution: Beancount’s price
-database now contains “prices” that aren’t real market prices
-
Auditor Confusion: An auditor reviewing this would
-question why purchase prices don’t match market rates
-
-
Proper Accounting Approach:
-
; Approach 1: Record at fair market value
-Assets:Bitcoin:Lightning 225033 SATS @ 0.000886... EUR
- ; Using actual market price at time of receipt
- acquisition-type: "payment-received"
-Revenue:Exchange-Gain 0.50 EUR
-Assets:Receivable:User-375ec158 -200.00 EUR
-
-; Approach 2: Don't use @ notation at all
-Assets:Bitcoin:Lightning 200.00 EUR
- sats-received: "225033"
- fmv-at-receipt: "199.50 EUR"
-Assets:Receivable:User-375ec158 -200.00 EUR
-
-
Issue 5: Misnamed
-Function and Incorrect Usage
-
Problem: Function is called
-format_net_settlement_entry, but it’s used for simple
-payments that aren’t true net settlements.
-
Example from User’s Transaction: - Receivable:
-200.00 EUR - Payable: 0.00 EUR - Net: 200.00 EUR (this is just a
-payment, not a settlement)
-
Accounting Terminology:
-
-
Payment: Settling a single obligation (receivable
-OR payable)
-
Net Settlement: Offsetting multiple obligations
-(receivable AND payable)
Best Practice: Use the simplest journal entry
-structure that accurately represents the transaction.
-
Recommendation: 1. Rename function to
-format_payment_entry or
-format_receivable_payment_entry 2. Create separate
-format_net_settlement_entry for true netting scenarios 3.
-Use conditional logic to choose 2-posting vs 3-posting based on whether
-both receivables AND payables exist
-
-
Traditional Accounting
-Approaches
-
Approach
-1: Record Bitcoin at Fair Market Value (Tax Compliant)
Current Issue: Mixed approach (EUR positions with
-SATS metadata, but also SATS positions with @ notation)
-
Decision Required: Choose ONE of the following
-architectures:
-
Architecture A - EUR Primary, SATS Secondary
-(recommended):
-
; All positions in EUR, SATS in metadata
-2025-11-12 * "Payment"
- Assets:Bitcoin:Lightning 200.00 EUR
- sats-received: "225033"
- Assets:Receivable:User -200.00 EUR
- sats-cleared: "225033"
-
Architecture B - SATS Primary, EUR Secondary:
-
; All positions in SATS, EUR in metadata
-2025-11-12 * "Payment"
- Assets:Bitcoin:Lightning 225033 SATS
- eur-value: "200.00"
- Assets:Receivable:User -225033 SATS
- eur-cleared: "200.00"
-
Recommendation: Architecture A (EUR primary)
-because: 1. Most receivables created in EUR 2. Financial reporting
-requirements typically in fiat 3. Tax obligations calculated in fiat 4.
-Aligns with current Libra metadata approach
-
-
3.2
-Consider Separate Ledger for Cryptocurrency Holdings
-
Advanced Approach: Separate cryptocurrency movements
-from fiat accounting
-
Main Ledger (EUR-denominated):
-
2025-11-12 * "Payment received from user"
- Assets:Bitcoin-Custody:User-375ec158 200.00 EUR
- Assets:Receivable:User-375ec158 -200.00 EUR
Verify: - ✅ Bitcoin recorded at fair market value -
-✅ Exchange loss recognized - ✅ Receivable cleared at book value - ✅
-Entry balances
-
-
Conclusion
-
Summary of Issues
-
-
-
-
-
-
-
-
-
-
Issue
-
Severity
-
Accounting Impact
-
Recommended Action
-
-
-
-
-
Zero-amount postings
-
Low
-
Presentation only
-
Remove immediately
-
-
-
Redundant SATS tracking
-
Low
-
Storage/efficiency
-
Choose one method
-
-
-
No exchange gain/loss
-
High
-
Financial accuracy
-
Implement for compliance
-
-
-
Semantic misuse of @
-
Medium
-
Audit clarity
-
Consider EUR-only positions
-
-
-
Misnamed function
-
Low
-
Code clarity
-
Rename function
-
-
-
-
Professional Assessment
-
Is this “best practice” accounting?
-No, this implementation deviates from traditional
-accounting standards in several ways.
-
Is it acceptable for Libra’s use case?Yes,
-with modifications, it’s a reasonable pragmatic solution for a
-novel problem (cryptocurrency payments of fiat debts).
-
Critical improvements needed: 1. ✅ Remove
-zero-amount postings (easy fix, professional presentation) 2. ✅
-Implement exchange gain/loss tracking (required for compliance) 3. ✅
-Separate payment vs. settlement logic (accuracy and clarity)
-
The fundamental challenge: Traditional accounting
-wasn’t designed for this scenario. There is no established “standard”
-for recording cryptocurrency payments of fiat-denominated receivables.
-Libra’s approach is functional, but should be refined to align better
-with accounting principles where possible.
Document Version: 1.0 Last Updated:
-2025-01-12 Next Review: After Priority 1 fixes
-implemented
-
-
This analysis was prepared for internal review and development
-planning. It represents a professional accounting assessment of the
-current implementation and should be used to guide improvements to
-Libra’s payment recording system.
-
-
diff --git a/docs/CODE-REVIEW-2026-06.md b/docs/CODE-REVIEW-2026-06.md
new file mode 100644
index 0000000..a200c5c
--- /dev/null
+++ b/docs/CODE-REVIEW-2026-06.md
@@ -0,0 +1,260 @@
+# Code review — 2026-06-05
+
+Findings from a deep review of the Libra LNbits extension (12k LOC,
+14 files). Each finding has `file:line` references, a one-line fix
+proposal, and a status tag:
+
+- ✅ **fixed** — merged in commit listed
+- ⏳ **outstanding** — still needs work
+- 🚫 **downgraded** — initially flagged, verified not a bug on closer read
+
+Triage order at the bottom prioritises blast radius over file location.
+
+> **2026-07-12 refactor series:** findings #2–#19 fixed across PRs
+> #55–#59 + the chore/hygiene branch (stacked; merge in order). LOW
+> items fixed in chore/hygiene except `parse_legacy_account_name`
+> fragility (documented assumption, internal input only) and the
+> `is_active`/`is_virtual` filter inconsistency (still open).
+
+
+---
+
+## CRITICAL
+
+### ✅ #1 — Mass `require_admin_key` mis-use → cross-user privilege escalation
+**Status:** fixed in `1201557` (`aiolabs/libra` main, 2026-06-05) +
+`4c704e5` (`aiolabs/webapp` dev).
+
+27 endpoints documented "(admin only)" used `require_admin_key`, which
+only checks the caller owns *some* wallet with its admin key — i.e.
+any authenticated user. Cluster included `receivable`/`revenue`
+creation, equity-eligibility grant/revoke, account-permission CRUD
+(grant yourself MANAGE on any account → ledger god mode), role and
+user-role CRUD, account-sync admin, cross-user reports.
+
+Also deleted the duplicate `api_pay_user` at `views_api.py:1937` (the
+correctly-gated `/api/v1/payables/pay` at L2144 replaces it).
+
+Webapp side: deleted orphaned `PermissionManager.vue` +
+`GrantPermissionDialog.vue` admin components that were never imported
+or routed and whose backing API methods pointed at non-existent paths.
+
+### ✅ #2 — `format_net_settlement_entry` ships unbalanced postings on partial payments
+`beancount_format.py:761-777` emits three postings whose weights sum to
+`net_fiat − total_receivable + total_payable`. The docstring example
+assumes `net_fiat == total_receivable − total_payable`. But
+`tasks.py:251-258` sets `total_receivable = total_prior_balance` and
+`net_fiat = invoice_fiat_amount` — only equal when the user paid the
+full balance. Any partial payment ships unbalanced postings; Beancount
+will reject or apply tolerance silently.
+
+**Fix:** add `assert abs(net_fiat − (receivable − payable)) <= 0.005`
+inside the formatter and raise on violation. Then fix the caller in
+`tasks.py:218-322` to settle only what the payment covers (likely a
+two-posting `DR Lightning / CR Receivable`-for-payment-amount, not
+net-settlement).
+
+### ✅ #3 — Migrations not idempotent (violates fork-migrations contract)
+`migrations.py:347, 377` (`ALTER TABLE accounts ADD COLUMN
+is_active/is_virtual`) and `migrations.py:441, 472, 510` (`CREATE TABLE
+roles/role_permissions/user_roles`) lack idempotency guards. Seed
+`INSERT`s at `migrations.py:319-330, 400-412, 587-597` have no `ON
+CONFLICT DO NOTHING`. Per CLAUDE.md, the cross-DB write between
+`ext_libra` and core `dbversions` is non-atomic — a failed version-bump
+leaves the migration to re-run on boot and crash with `duplicate
+column` / `table exists` / UNIQUE violation. Bricks the extension until
+manual `dbversions` surgery.
+
+**Fix:** wrap ALTERs with `_alter_add_column_safe`, switch CREATEs to
+`CREATE TABLE IF NOT EXISTS`, gate seed INSERTs with `INSERT ... ON
+CONFLICT DO NOTHING`.
+
+### ✅ #4 — Lightning payment recording has no local idempotency gate
+`tasks.py:218-322` relies entirely on `fava.add_entry_idempotent` for
+dedup, which itself does a read-then-write race on the Fava ledger. On
+lnbits restart with a persisted invoice queue, the same `payment_hash`
+can re-fire; the per-user lock is in-process only and doesn't survive
+restart. Webhook + poller hitting concurrently both pass the
+"not present" check and both insert.
+
+**Fix:** add a `processed_payments(payment_hash TEXT PRIMARY KEY)`
+table; `INSERT OR IGNORE` at the top of `on_invoice_paid`; only
+proceed if `rowcount == 1`.
+
+---
+
+## HIGH
+
+### ✅ #5 — Auth prefix-match in `can_access_user_data`
+`auth.py:248-251` uses `caller.user_id[:8] == target.user_id[:8]`.
+Eight hex chars = 32 bits; birthday collision at ~65k users.
+
+**Fix:** require full UUID equality; never resolve by prefix in an
+authorisation decision.
+
+### ✅ #6 — `can_access_account` substring match
+`auth.py:178-180` does `f"User-{short}" in account.name` — matches
+`Expenses:Misc-User-deadbeef` too.
+
+**Fix:** split on `:`, require segment equality.
+
+### ✅ #7 — `ChecksumConflictError` never raised by update/delete
+`fava_client.py:1392-1482`: Fava 409/412 propagates as raw
+`HTTPStatusError`. The `ChecksumConflictError` type exists but isn't
+raised by these methods; callers see stack-trace 500s instead of a
+clean retry path.
+
+**Fix:** `if status in (409, 412): raise ChecksumConflictError(...)`.
+
+### ✅ #8 — `float()` arithmetic in fiat-rate metadata
+`views_api.py:1061-1062, 1263-1264, 1364-1365, 1759-1760` compute
+`fiat_rate` / `btc_rate` via `float()`, then persist into Beancount
+metadata as the cost basis for that entry. Float drift cascades
+through reporting.
+
+**Fix:** keep `Decimal` end-to-end; only stringify at JSON-serialise
+time.
+
+### ✅ #9 — Background loop swallows `raise` in `on_invoice_paid`
+`tasks.py:320-322` does `logger.error(...); raise`.
+`wait_for_paid_invoices` at `tasks.py:178-180` has no surrounding
+try/except, so one unhandled exception kills the listener for the
+rest of the process lifetime — no further Lightning payments get
+recorded, no alarm.
+
+**Fix:** wrap the iteration body in
+`try/except Exception: logger.exception(...)`; never `raise` from
+`on_invoice_paid`.
+
+### ✅ #10 — `record-payment` dedup is non-atomic AND exception-swallowing
+`views_api.py:1841-1924` (per subagent report — needs verification)
+catches all exceptions in the dedup window with a 5-second timeout
+and treats Fava errors as "not duplicate", producing double-entries
+on transient Fava blips.
+
+**Fix:** fail closed on transport error; narrow the exception type
+catch to `httpx.HTTPError` only.
+
+### ✅ #11 — `validate_journal_entry` is stale
+`core/validation.py:21-93` validates the pre-string-amount model —
+sums one bag of integers, doesn't balance per currency. Doesn't match
+production data shape post-Fava migration.
+
+**Fix:** rewrite to parse `"X CCY"` strings and balance per currency,
+or delete if Beancount-side validation is now considered sufficient.
+
+---
+
+## MEDIUM
+
+### ✅ #12 — `m001_initial` seed `INSERT` into `accounts` non-idempotent
+`migrations.py:319-330` — same shape as #3.
+
+### ✅ #13 — `format_posting_at_average_cost` emits `{}` when `cost_currency=None`
+`beancount_format.py:256` — ` SATS {}` isn't valid Beancount
+syntax; drop the braces when cost is unset.
+
+### ✅ #14 — Per-call `httpx.AsyncClient` instantiation in fava_client
+~30 sites build a new `httpx.AsyncClient` per call. TCP handshake every
+time.
+
+**Fix:** construct once on `FavaClient.__init__`, expose `aclose()`.
+
+### ✅ #15 — BQL string interpolation without quoting
+`fava_client.py:250, 621, 770-775, 853-858` interpolate
+`account_name` / user-id-prefix raw into BQL `WHERE account = '{...}'`.
+The 8-char hex prefix is safe in practice; arbitrary `account_name`
+input is not.
+
+**Fix:** validate against `^[A-Za-z0-9:_-]+$` before interpolation.
+
+### ✅ #16 — `approve_manual_payment_request` not status-guarded
+`crud.py:559-579` overwrites `status='approved'` regardless of current
+state. Two concurrent admins → two journal entries.
+
+**Fix:** `UPDATE ... WHERE id=:id AND status='pending'`, check
+`rowcount == 1`.
+
+### ✅ #17 — Account name not validated on receivable/revenue/expense
+`views_api.py:1066-1074, 1224-1236, 1369-1376, 1477-1494` accept
+free-string `data.expense_account` (etc.) with no Beancount-syntax
+check before lookup.
+
+**Fix:** enforce `^[A-Z][A-Za-z0-9:-]*$`.
+
+### ✅ #18 — `_get_username_from_user_id` creates a fresh LNbits DB per call
+`views_api.py:697-708` opens an LNbits DB inside a per-row hot path.
+
+**Fix:** cache the Database instance at module load; batch-load
+usernames once per request via single `IN (…)` query.
+
+### ✅ #19 — `get_user_balance` regex rejects decimal SATS
+`fava_client.py:346, 503` patterns require `(-?\d+)` SATS. Fava's
+`@@→@` normalisation can emit decimal SATS.
+
+**Fix:** `(-?[\d.]+)`.
+
+### ⏳ #20 — `fava_url` default `http://localhost:3333` and sandboxed lnbits
+Loopback breaks if the lnbits service unit gains `PrivateNetwork=true`.
+Not a code bug — worth documenting in deploy assumptions.
+
+---
+
+## LOW
+
+- ⏳ `tasks.py` mixes `print()` with `logger.*` (`:61, 65-69, 81, 89,
+ 94, 162`).
+- ⏳ Dead model imports in `crud.py:21-27` (`JournalEntry`,
+ `EntryLine`) after `entry_lines` table dropped.
+- ⏳ `auto_assign_default_role` check-then-act race
+ (`crud.py:1609-1641`) — add UNIQUE constraint on
+ `user_roles(user_id, role_id)`.
+- ⏳ Pydantic v1 `.dict()` calls (`crud.py:381, 400, 411, 450`) if
+ upstream is on v2.
+- ⏳ `account_utils.parse_legacy_account_name` splits on ` - ` —
+ fragile if ever called on user input.
+- ⏳ `Account.is_active` vs `is_virtual` default-filter inconsistency
+ hides virtual parents in permission-grant UI (`crud.py:146-167`).
+
+---
+
+## 🚫 Downgraded (initially flagged, verified not a bug)
+
+### Subagent A — "inverted balance sign in tasks.py:251-258"
+`fava_client.py:303` docstring says positive = user owes libra
+(bookkeeper perspective). `tasks.py:249-250` matches. CLAUDE.md
+describes the *user's* perspective (positive = libra owes user), which
+is consistent at the UI layer. Both representations are internally
+coherent — no bug, just a doc-vs-code perspective collision.
+
+### Subagent A — "fava_ledger_slug default doesn't match deploy"
+Verified empirically by user: Fava in single-ledger mode appears to
+accept arbitrary slugs against the JSON API, and the deploy's seed
+title `"Libra Ledger"` → slugify → `libra-ledger` matches the
+extension default in `models.py:159` anyway. False alarm.
+
+---
+
+## Triage order (when picking the next item)
+
+1. **#2 (unbalanced net settlement) + #4 (idempotency)** — silently
+ corrupts the ledger on every partial Lightning payment + every
+ restart with a persisted invoice queue. Real-money blast radius.
+2. **#3 (migrations) + #12** — guaranteed boot crash on the documented
+ failure mode; bricks the extension.
+3. **#8 (float in fiat metadata)** — every entry written today carries
+ float-drift cost basis into Beancount.
+4. **#9 (silent listener death)** — operational; the kind of bug
+ discovered when nobody can pay for a week.
+5. **#5, #6 (auth narrowing)** — residual privilege risk; smaller blast
+ than #1 (already fixed) but worth closing.
+6. Everything else, in arbitrary order; mostly hygiene.
+
+---
+
+## Commits applied
+
+| Commit | Repo / branch | What |
+|---|---|---|
+| `1201557` | `aiolabs/libra` `main` | Gate cross-user admin endpoints behind `require_super_user`; delete duplicate `api_pay_user` |
+| `4c704e5` | `aiolabs/webapp` `dev` | Delete orphaned `PermissionManager.vue` + `GrantPermissionDialog.vue` + 3 API methods + 4 dead types |
diff --git a/docs/PHASE1_COMPLETE.md b/docs/PHASE1_COMPLETE.md
deleted file mode 100644
index c152ef1..0000000
--- a/docs/PHASE1_COMPLETE.md
+++ /dev/null
@@ -1,200 +0,0 @@
-# Phase 1 Implementation - Complete ✅
-
-## Summary
-
-We've successfully implemented the core improvements from Phase 1 of the Beancount patterns adoption:
-
-## ✅ Completed
-
-### 1. **Decimal Instead of Float for Fiat Amounts**
-- **Files Changed:**
- - `models.py`: Changed all fiat amount fields from `float` to `Decimal`
- - `ExpenseEntry.amount`
- - `ReceivableEntry.amount`
- - `RevenueEntry.amount`
- - `UserBalance.fiat_balances` dictionary values
- - `crud.py`: Updated fiat balance calculations to use `Decimal`
- - `views_api.py`: Store fiat amounts as strings with `str(amount.quantize(Decimal("0.001")))`
-
-- **Benefits:**
- - Prevents floating point rounding errors
- - Exact decimal arithmetic
- - Financial-grade precision
-
-### 2. **Meta Field for Journal Entries**
-- **Database Migration:** `m005_add_flag_and_meta`
- - Added `meta TEXT DEFAULT '{}'` column to `journal_entries` table
-
-- **Model Changes:**
- - Added `meta: dict = {}` to `JournalEntry` and `CreateJournalEntry`
- - Meta stores: source, created_via, user_id, payment_hash, etc.
-
-- **CRUD Updates:**
- - `create_journal_entry()` now stores meta as JSON
- - `get_journal_entries_by_user()` parses meta from JSON
-
-- **API Integration:**
- - Expense entries: `{"source": "api", "created_via": "expense_entry", "user_id": "...", "is_equity": false}`
- - Receivable entries: `{"source": "api", "created_via": "receivable_entry", "debtor_user_id": "..."}`
- - Payment entries: `{"source": "lightning_payment", "created_via": "record_payment", "payment_hash": "...", "payer_user_id": "..."}`
-
-- **Benefits:**
- - Full audit trail for every transaction
- - Source tracking (where did this entry come from?)
- - Can add tags, links, notes in future
- - Essential for compliance and debugging
-
-### 3. **Flag Field for Transaction Status**
-- **Database Migration:** `m005_add_flag_and_meta`
- - Added `flag TEXT DEFAULT '*'` column to `journal_entries` table
-
-- **Model Changes:**
- - Created `JournalEntryFlag` enum:
- - `*` = CLEARED (confirmed/reconciled)
- - `!` = PENDING (awaiting confirmation)
- - `#` = FLAGGED (needs review)
- - `x` = VOID (cancelled)
- - Added `flag: JournalEntryFlag` to `JournalEntry` and `CreateJournalEntry`
-
-- **CRUD Updates:**
- - `create_journal_entry()` stores flag as string value
- - `get_journal_entries_by_user()` converts string to enum
-
-- **API Logic:**
- - Expense entries: Default to CLEARED (immediately confirmed)
- - Receivable entries: Start as PENDING (unpaid debt)
- - Payment entries: Mark as CLEARED (payment received)
-
-- **Benefits:**
- - Visual indication of transaction status in UI
- - Filter transactions by status
- - Supports reconciliation workflows
- - Standard accounting practice (Beancount-style)
-
-## 📊 Migration Details
-
-**Migration `m005_add_flag_and_meta`:**
-```sql
-ALTER TABLE journal_entries ADD COLUMN flag TEXT DEFAULT '*';
-ALTER TABLE journal_entries ADD COLUMN meta TEXT DEFAULT '{}';
-```
-
-**To Apply:**
-1. Stop LNbits server (if running)
-2. Restart LNbits - migration runs automatically
-3. Check logs for "m005_add_flag_and_meta" success message
-
-## 🔧 Technical Implementation Details
-
-### Decimal Handling
-```python
-# Store as string for precision
-metadata = {
- "fiat_amount": str(data.amount.quantize(Decimal("0.001"))),
-}
-
-# Parse back to Decimal
-fiat_decimal = Decimal(str(fiat_amount))
-```
-
-### Flag Handling
-```python
-# Set flag on creation
-entry_data = CreateJournalEntry(
- flag=JournalEntryFlag.PENDING, # or CLEARED
- # ...
-)
-
-# Parse from database
-flag = JournalEntryFlag(entry_data.get("flag", "*"))
-```
-
-### Meta Handling
-```python
-# Create with meta
-entry_meta = {
- "source": "api",
- "created_via": "expense_entry",
- "user_id": wallet.wallet.user,
-}
-
-entry_data = CreateJournalEntry(
- meta=entry_meta,
- # ...
-)
-
-# Parse from database
-meta = json.loads(entry_data.get("meta", "{}")) if entry_data.get("meta") else {}
-```
-
-## 🎯 What's Next (Remaining Phase 1 Items)
-
-### Hierarchical Account Naming (In Progress)
-Implement Beancount-style account hierarchy:
-- Current: `"Accounts Receivable - af983632"`
-- Better: `"Assets:Receivable:User-af983632"`
-
-### UI Updates for Flags
-Display flag icons in transaction list:
-- ✅ `*` = Green checkmark (cleared)
-- ⚠️ `!` = Yellow/Orange badge (pending)
-- 🚩 `#` = Red flag (needs review)
-- ❌ `x` = Strikethrough (voided)
-
-## 🧪 Testing Recommendations
-
-1. **Test Decimal Precision:**
- ```python
- # Create expense with fiat amount
- POST /api/v1/entries/expense
- {"amount": "36.93", "currency": "EUR", ...}
-
- # Verify stored as exact string
- SELECT metadata FROM entry_lines WHERE ...
- # Should see: {"fiat_amount": "36.930", ...}
- ```
-
-2. **Test Flag Workflow:**
- ```python
- # Create receivable (should be PENDING)
- POST /api/v1/entries/receivable
- # Check: flag = '!'
-
- # Pay receivable (creates CLEARED entry)
- POST /api/v1/record-payment
- # Check: payment entry flag = '*'
- ```
-
-3. **Test Meta Audit Trail:**
- ```python
- # Create any entry
- # Check database:
- SELECT meta FROM journal_entries WHERE ...
- # Should see: {"source": "api", "created_via": "...", ...}
- ```
-
-## 🎉 Success Metrics
-
-- ✅ No more floating point errors in fiat calculations
-- ✅ Every transaction has source tracking
-- ✅ Transaction status is visible (pending vs cleared)
-- ✅ Database migration successful
-- ✅ All API endpoints updated
-- ✅ CRUD operations handle new fields
-
-## 📝 Notes
-
-- **Backward Compatibility:** Old entries will have default values (`flag='*'`, `meta='{}'`)
-- **Performance:** No impact - added columns have defaults and indexes not needed yet
-- **Storage:** Minimal increase (meta typically < 200 bytes per entry)
-
-## ✅ Phase 1 Complete!
-
-All Phase 1 tasks have been completed:
-1. ✅ Decimal instead of float for fiat amounts
-2. ✅ Meta field for journal entries (audit trail)
-3. ✅ Flag field for transaction status
-4. ✅ Hierarchical account naming (Beancount-style)
-5. ✅ UI updated to display flags and metadata
-
-**Next:** Move to Phase 2 (Core logic refactoring) when ready.
diff --git a/docs/PHASE2_COMPLETE.md b/docs/PHASE2_COMPLETE.md
deleted file mode 100644
index cc45614..0000000
--- a/docs/PHASE2_COMPLETE.md
+++ /dev/null
@@ -1,273 +0,0 @@
-# Phase 2: Reconciliation - COMPLETE ✅
-
-## Summary
-
-Phase 2 of the Beancount-inspired refactor focused on **reconciliation and automated balance checking**. This phase builds on Phase 1's foundation to provide robust reconciliation tools that ensure accounting accuracy and catch discrepancies early.
-
-## Completed Features
-
-### 1. Balance Assertions ✅
-
-**Purpose**: Verify account balances match expected values at specific points in time (like Beancount's `balance` directive)
-
-**Implementation**:
-- **Models** (`models.py:184-219`):
- - `AssertionStatus` enum (pending, passed, failed)
- - `BalanceAssertion` model with sats and optional fiat checks
- - `CreateBalanceAssertion` request model
-
-- **Database** (`migrations.py:275-320`):
- - `balance_assertions` table with expected/actual balance tracking
- - Tolerance levels for flexible matching
- - Status tracking and timestamps
- - Indexes for performance
-
-- **CRUD** (`crud.py:773-981`):
- - `create_balance_assertion()` - Create and store assertion
- - `get_balance_assertion()` - Fetch single assertion
- - `get_balance_assertions()` - List with filters
- - `check_balance_assertion()` - Compare expected vs actual
- - `delete_balance_assertion()` - Remove assertion
-
-- **API Endpoints** (`views_api.py:1067-1230`):
- - `POST /api/v1/assertions` - Create and check assertion
- - `GET /api/v1/assertions` - List assertions with filters
- - `GET /api/v1/assertions/{id}` - Get specific assertion
- - `POST /api/v1/assertions/{id}/check` - Re-check assertion
- - `DELETE /api/v1/assertions/{id}` - Delete assertion
-
-- **UI** (`templates/libra/index.html:254-378`):
- - Balance Assertions card (super user only)
- - Failed assertions prominently displayed with red banner
- - Passed assertions in collapsible panel
- - Create assertion dialog with validation
- - Re-check and delete buttons
-
-- **Frontend** (`static/js/index.js:70-79, 602-726`):
- - Data properties and computed values
- - CRUD methods for assertions
- - Automatic loading on page load
-
-### 2. Reconciliation API Endpoints ✅
-
-**Purpose**: Provide comprehensive reconciliation tools and reporting
-
-**Implementation**:
-- **Summary Endpoint** (`views_api.py:1236-1287`):
- - `GET /api/v1/reconciliation/summary`
- - Returns counts of assertions by status
- - Returns counts of journal entries by flag
- - Total accounts count
- - Last checked timestamp
-
-- **Check All Endpoint** (`views_api.py:1290-1325`):
- - `POST /api/v1/reconciliation/check-all`
- - Re-checks all balance assertions
- - Returns summary of results (passed/failed/errors)
- - Useful for manual reconciliation runs
-
-- **Discrepancies Endpoint** (`views_api.py:1328-1357`):
- - `GET /api/v1/reconciliation/discrepancies`
- - Returns all failed assertions
- - Returns all flagged journal entries
- - Returns all pending entries
- - Total discrepancy count
-
-### 3. Reconciliation UI Dashboard ✅
-
-**Purpose**: Visual dashboard for reconciliation status and quick access to reconciliation tools
-
-**Implementation** (`templates/libra/index.html:380-499`):
-- **Summary Cards**:
- - Balance Assertions stats (total, passed, failed, pending)
- - Journal Entries stats (total, cleared, pending, flagged)
- - Total Accounts count with last checked timestamp
-
-- **Discrepancies Alert**:
- - Warning banner when discrepancies found
- - Shows count of failed assertions and flagged entries
- - "View Details" button to expand discrepancy list
-
-- **Discrepancy Details**:
- - Failed assertions list with expected vs actual balances
- - Flagged entries list
- - Quick access to problematic transactions
-
-- **Actions**:
- - "Check All" button to run full reconciliation
- - Loading states during checks
- - Success message when all accounts reconciled
-
-**Frontend** (`static/js/index.js:80-85, 727-779, 933-934`):
-- Reconciliation data properties
-- Methods to load summary and discrepancies
-- `runFullReconciliation()` method with notifications
-- Automatic loading on page load for super users
-
-### 4. Automated Daily Balance Checks ✅
-
-**Purpose**: Run balance checks automatically on a schedule to catch discrepancies early
-
-**Implementation**:
-
-- **Tasks Module** (`tasks.py`):
- - `check_all_balance_assertions()` - Core checking logic
- - `scheduled_daily_reconciliation()` - Scheduled wrapper
- - Results logging and reporting
- - Error handling
-
-- **API Endpoint** (`views_api.py:1363-1390`):
- - `POST /api/v1/tasks/daily-reconciliation`
- - Can be triggered manually or via cron
- - Returns detailed results
- - Super user only
-
-- **Documentation** (`DAILY_RECONCILIATION.md`):
- - Comprehensive setup guide
- - Multiple scheduling options (cron, systemd, k8s)
- - Monitoring and troubleshooting
- - Best practices
- - Example scripts
-
-## Benefits
-
-### Accounting Accuracy
-- ✅ Catch data entry errors early
-- ✅ Verify balances at critical checkpoints
-- ✅ Build confidence in accounting accuracy
-- ✅ Required for external audits
-
-### Operational Excellence
-- ✅ Automated daily checks reduce manual work
-- ✅ Dashboard provides at-a-glance reconciliation status
-- ✅ Discrepancies are immediately visible
-- ✅ Historical tracking of assertions
-
-### Developer Experience
-- ✅ Clean API for programmatic reconciliation
-- ✅ Well-documented scheduling options
-- ✅ Flexible tolerance levels
-- ✅ Comprehensive error reporting
-
-## File Changes
-
-### New Files Created
-1. `tasks.py` - Background tasks for automated reconciliation
-2. `DAILY_RECONCILIATION.md` - Setup and scheduling documentation
-3. `PHASE2_COMPLETE.md` - This file
-
-### Modified Files
-1. `models.py` - Added `BalanceAssertion`, `CreateBalanceAssertion`, `AssertionStatus`
-2. `migrations.py` - Added `m007_balance_assertions` migration
-3. `crud.py` - Added balance assertion CRUD operations
-4. `views_api.py` - Added assertion, reconciliation, and task endpoints
-5. `templates/libra/index.html` - Added assertions and reconciliation UI
-6. `static/js/index.js` - Added assertion and reconciliation functionality
-7. `BEANCOUNT_PATTERNS.md` - Updated roadmap to mark Phase 2 complete
-
-## API Endpoints Summary
-
-### Balance Assertions
-- `POST /api/v1/assertions` - Create assertion
-- `GET /api/v1/assertions` - List assertions
-- `GET /api/v1/assertions/{id}` - Get assertion
-- `POST /api/v1/assertions/{id}/check` - Re-check assertion
-- `DELETE /api/v1/assertions/{id}` - Delete assertion
-
-### Reconciliation
-- `GET /api/v1/reconciliation/summary` - Get reconciliation summary
-- `POST /api/v1/reconciliation/check-all` - Check all assertions
-- `GET /api/v1/reconciliation/discrepancies` - Get discrepancies
-
-### Automated Tasks
-- `POST /api/v1/tasks/daily-reconciliation` - Run daily reconciliation check
-
-## Usage Examples
-
-### Create a Balance Assertion
-```bash
-curl -X POST http://localhost:5000/libra/api/v1/assertions \
- -H "X-Api-Key: ADMIN_KEY" \
- -H "Content-Type: application/json" \
- -d '{
- "account_id": "lightning",
- "expected_balance_sats": 268548,
- "tolerance_sats": 100
- }'
-```
-
-### Get Reconciliation Summary
-```bash
-curl http://localhost:5000/libra/api/v1/reconciliation/summary \
- -H "X-Api-Key: ADMIN_KEY"
-```
-
-### Run Full Reconciliation
-```bash
-curl -X POST http://localhost:5000/libra/api/v1/reconciliation/check-all \
- -H "X-Api-Key: ADMIN_KEY"
-```
-
-### Schedule Daily Reconciliation (Cron)
-```bash
-# Add to crontab
-0 2 * * * curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: ADMIN_KEY"
-```
-
-## Testing Checklist
-
-- [x] Create balance assertion (UI)
-- [x] Create balance assertion (API)
-- [x] Assertion passes when balance matches
-- [x] Assertion fails when balance doesn't match
-- [x] Tolerance levels work correctly
-- [x] Fiat balance assertions work
-- [x] Re-check assertion updates status
-- [x] Delete assertion removes it
-- [x] Reconciliation summary shows correct stats
-- [x] Check all assertions endpoint works
-- [x] Discrepancies endpoint returns correct data
-- [x] Dashboard displays summary correctly
-- [x] Discrepancy alert shows when issues exist
-- [x] "Check All" button triggers reconciliation
-- [x] Daily reconciliation task executes successfully
-- [x] Failed assertions are logged
-- [x] All endpoints require super user access
-
-## Next Steps
-
-**Phase 3: Core Logic Refactoring (Medium Priority)**
-- Create `core/` module with pure accounting logic
-- Implement `LibraInventory` for position tracking
-- Move balance calculation to `core/balance.py`
-- Add comprehensive validation in `core/validation.py`
-
-**Phase 4: Validation Plugins (Medium Priority)**
-- Create plugin system architecture
-- Implement `check_balanced` plugin
-- Implement `check_receivables` plugin
-- Add plugin configuration UI
-
-**Phase 5: Advanced Features (Low Priority)**
-- Add tags and links to entries
-- Implement query language
-- Add lot tracking to inventory
-- Support multi-currency in single entry
-
-## Conclusion
-
-Phase 2 successfully implements Beancount's reconciliation philosophy in the Libra extension. With balance assertions, comprehensive reconciliation APIs, a visual dashboard, and automated daily checks, users can:
-
-- **Trust their data** with automated verification
-- **Catch errors early** through regular reconciliation
-- **Save time** with automated daily checks
-- **Gain confidence** in their accounting accuracy
-
-The implementation follows Beancount's best practices while adapting to LNbits' architecture and use case. All reconciliation features are admin-only, ensuring proper access control for sensitive accounting operations.
-
-**Phase 2 Status**: ✅ COMPLETE
-
----
-
-*Generated: 2025-10-23*
-*Next: Phase 3 - Core Logic Refactoring*
diff --git a/docs/PHASE3_COMPLETE.md b/docs/PHASE3_COMPLETE.md
deleted file mode 100644
index b53625a..0000000
--- a/docs/PHASE3_COMPLETE.md
+++ /dev/null
@@ -1,365 +0,0 @@
-# Phase 3: Core Logic Refactoring - COMPLETE ✅
-
-## Summary
-
-Phase 3 of the Beancount-inspired refactor focused on **separating business logic from database operations** and creating a clean, testable core module. This phase improves code quality, maintainability, and follows best practices from Beancount's architecture.
-
-## Completed Features
-
-### 1. Core Module Structure ✅
-
-**Purpose**: Separate pure accounting logic from database and API concerns
-
-**Implementation** (`core/__init__.py`):
-- Created `core/` module package
-- Exports main classes and functions
-- Clean separation of concerns
-
-**Benefits**:
-- Testable without database
-- Reusable across different storage backends
-- Easier to audit and verify
-- Clear architecture
-
-### 2. LibraInventory for Position Tracking ✅
-
-**Purpose**: Track balances across multiple currencies with cost basis information (following Beancount's Inventory pattern)
-
-**Implementation** (`core/inventory.py`):
-
-**LibraPosition** (Lines 11-84):
-- Immutable dataclass representing a single position
-- Tracks currency, amount, cost basis, and metadata
-- Supports addition and negation operations
-- Automatic Decimal conversion in `__post_init__`
-
-```python
-@dataclass(frozen=True)
-class LibraPosition:
- currency: str # "SATS", "EUR", "USD"
- amount: Decimal
- cost_currency: Optional[str] = None
- cost_amount: Optional[Decimal] = None
- date: Optional[datetime] = None
- metadata: Dict[str, Any] = field(default_factory=dict)
-```
-
-**LibraInventory** (Lines 87-201):
-- Container for multiple positions
-- Positions keyed by `(currency, cost_currency)` tuple
-- Methods for querying balances:
- - `get_balance_sats()` - Total satoshis
- - `get_balance_fiat(currency)` - Fiat balance for specific currency
- - `get_all_fiat_balances()` - All fiat balances
-- Utility methods:
- - `is_empty()` - Check if no positions
- - `is_zero()` - Check if all positions sum to zero
- - `to_dict()` - Export to dictionary
-
-### 3. BalanceCalculator ✅
-
-**Purpose**: Pure logic for calculating balances from journal entries
-
-**Implementation** (`core/balance.py`):
-
-**AccountType Enum** (Lines 13-19):
-```python
-class AccountType(str, Enum):
- ASSET = "asset"
- LIABILITY = "liability"
- EQUITY = "equity"
- REVENUE = "revenue"
- EXPENSE = "expense"
-```
-
-**BalanceCalculator Class** (Lines 22-217):
-
-**Static Methods**:
-
-1. **`calculate_account_balance()`** (Lines 29-54):
- - Calculate balance based on account type
- - Normal balances:
- - Assets/Expenses: Debit balance (debit - credit)
- - Liabilities/Equity/Revenue: Credit balance (credit - debit)
-
-2. **`build_inventory_from_entry_lines()`** (Lines 56-117):
- - Build LibraInventory from journal entry lines
- - Handles both sats and fiat currency tracking
- - Accounts for account type when determining sign
-
-3. **`calculate_user_balance()`** (Lines 119-168):
- - Calculate user's total balance across all accounts
- - Returns both sats balance and fiat balances by currency
- - Properly handles asset (receivable) vs liability (payable) accounts
-
-4. **`check_balance_matches()`** (Lines 170-187):
- - Verify balance assertion for sats
-
-5. **`check_fiat_balance_matches()`** (Lines 189-202):
- - Verify balance assertion for fiat currency
-
-### 4. Comprehensive Validation ✅
-
-**Purpose**: Validation rules for accounting operations
-
-**Implementation** (`core/validation.py`):
-
-**ValidationError Exception** (Lines 10-18):
-- Custom exception for validation failures
-- Includes detailed error information
-
-**Validation Functions**:
-
-1. **`validate_journal_entry()`** (Lines 21-124):
- - Checks:
- - At least 2 lines (double-entry requirement)
- - Entry is balanced (debits = credits)
- - Valid amounts (non-negative)
- - No line has both debit and credit
- - All lines have account_id
-
-2. **`validate_balance()`** (Lines 127-177):
- - Validates balance assertions
- - Checks both sats and fiat within tolerance
-
-3. **`validate_receivable_entry()`** (Lines 180-199):
- - Validates receivable (user owes libra) entries
- - Ensures positive amount
- - Ensures revenue account type
-
-4. **`validate_expense_entry()`** (Lines 202-227):
- - Validates expense entries
- - Ensures positive amount
- - Checks account type (expense or equity)
-
-5. **`validate_payment_entry()`** (Lines 230-245):
- - Validates payment entries
- - Ensures positive amount
-
-6. **`validate_metadata()`** (Lines 248-284):
- - Validates entry line metadata
- - Checks for required keys
- - Validates fiat currency/amount consistency
- - Validates Decimal conversion
-
-### 5. Refactored CRUD Operations ✅
-
-**Purpose**: Use core logic in database operations
-
-**Modified Files**: `crud.py`
-
-**Changes**:
-
-1. **Imports** (Lines 26-36):
- - Import core accounting logic
- - Import validation functions
-
-2. **`get_account_balance()`** (Lines 347-377):
- - Refactored to use `BalanceCalculator.calculate_account_balance()`
- - Removed duplicate logic
-
-3. **`get_user_balance()`** (Lines 380-435):
- - Completely refactored to use:
- - `BalanceCalculator.build_inventory_from_entry_lines()`
- - `BalanceCalculator.calculate_user_balance()`
- - Cleaner separation of database queries vs business logic
-
-4. **`get_all_user_balances()`** (Lines 438-459):
- - Simplified to call `get_user_balance()` for each user
- - Eliminates code duplication
-
-## Architecture
-
-### Before Phase 3
-
-```
-views_api.py → crud.py (mixed DB + logic)
- ↓
- database
-```
-
-All accounting logic was embedded in crud.py alongside database operations.
-
-### After Phase 3
-
-```
-views_api.py → crud.py → core/
- ↓ ↓
- database Pure Logic
- (testable)
-```
-
-**Separation of Concerns**:
-- `core/` - Pure accounting logic (no DB dependencies)
-- `crud.py` - Database operations + orchestration
-- `views_api.py` - HTTP API layer
-
-## Benefits
-
-### Code Quality
-- ✅ **Testability**: Core logic can be tested without database
-- ✅ **Maintainability**: Clear separation makes code easier to understand
-- ✅ **Reusability**: Core logic can be used in different contexts
-- ✅ **Consistency**: Centralized accounting rules
-
-### Developer Experience
-- ✅ **Type Safety**: Immutable dataclasses with proper types
-- ✅ **Documentation**: Well-documented core functions
-- ✅ **Debugging**: Easier to trace accounting logic
-- ✅ **Refactoring**: Safer to make changes
-
-### Reliability
-- ✅ **Validation**: Comprehensive validation rules
-- ✅ **Correctness**: Pure functions easier to verify
-- ✅ **Auditability**: Clear accounting rules
-
-## File Structure
-
-```
-lnbits/extensions/libra/
-├── core/
-│ ├── __init__.py # Module exports
-│ ├── inventory.py # LibraInventory, LibraPosition
-│ ├── balance.py # BalanceCalculator
-│ └── validation.py # Validation functions
-├── crud.py # DB operations (refactored to use core/)
-├── models.py # Pydantic models
-├── views_api.py # API endpoints
-└── PHASE3_COMPLETE.md # This file
-```
-
-## Usage Examples
-
-### Using LibraInventory
-
-```python
-from decimal import Decimal
-from libra.core.inventory import LibraInventory, LibraPosition
-
-# Create inventory
-inv = LibraInventory()
-
-# Add positions
-inv.add_position(LibraPosition(
- currency="SATS",
- amount=Decimal("100000")
-))
-
-inv.add_position(LibraPosition(
- currency="SATS",
- amount=Decimal("50000"),
- cost_currency="EUR",
- cost_amount=Decimal("25.00")
-))
-
-# Query balances
-total_sats = inv.get_balance_sats() # Decimal("150000")
-eur_balance = inv.get_balance_fiat("EUR") # Decimal("25.00")
-
-# Export
-data = inv.to_dict()
-# {"sats": 150000, "fiat": {"EUR": 25.00}}
-```
-
-### Using BalanceCalculator
-
-```python
-from libra.core.balance import BalanceCalculator, AccountType
-
-# Calculate account balance
-balance = BalanceCalculator.calculate_account_balance(
- total_debit=100000,
- total_credit=50000,
- account_type=AccountType.ASSET
-)
-# Returns: 50000 (debit balance for asset)
-
-# Build inventory from entry lines
-entry_lines = [
- {"amount": 100000, "metadata": '{"fiat_currency": "EUR", "fiat_amount": "50.00"}'}, # Positive = debit
- {"amount": -50000, "metadata": "{}"} # Negative = credit
-]
-
-inventory = BalanceCalculator.build_inventory_from_entry_lines(
- entry_lines,
- AccountType.ASSET
-)
-
-# Check balance matches
-is_valid = BalanceCalculator.check_balance_matches(
- actual_balance_sats=100000,
- expected_balance_sats=99900,
- tolerance_sats=100
-)
-# Returns: True (within tolerance)
-```
-
-### Using Validation
-
-```python
-from libra.core.validation import validate_journal_entry, ValidationError
-
-entry = {
- "id": "abc123",
- "description": "Test entry",
- "entry_date": datetime.now()
-}
-
-entry_lines = [
- {"account_id": "acc1", "amount": 100000}, # Positive = debit
- {"account_id": "acc2", "amount": -100000} # Negative = credit
-]
-
-try:
- validate_journal_entry(entry, entry_lines)
- print("Valid!")
-except ValidationError as e:
- print(f"Invalid: {e.message}")
- print(f"Details: {e.details}")
-```
-
-## Testing Checklist
-
-- [x] LibraInventory created and tested
-- [x] LibraPosition addition works
-- [x] Inventory balance calculations work
-- [x] BalanceCalculator account balance calculation works
-- [x] BalanceCalculator inventory building works
-- [x] BalanceCalculator user balance calculation works
-- [x] Validation functions work
-- [x] crud.py refactored to use core logic
-- [x] Existing balance calculations still work
-- [ ] Unit tests for core module (future work)
-
-## Next Steps
-
-**Phase 4: Validation Plugins** (Medium Priority)
-- Create plugin system architecture
-- Implement `check_balanced` plugin
-- Implement `check_receivables` plugin
-- Add plugin configuration UI
-
-**Future Enhancements**:
-- Add unit tests for core/ module
-- Add integration tests
-- Add lot tracking to inventory
-- Support multi-currency in single entry
-- Add more validation plugins
-
-## Conclusion
-
-Phase 3 successfully refactors Libra's accounting logic into a clean, testable core module. By following Beancount's architecture patterns, we've created:
-
-- **Pure accounting logic** separated from database concerns
-- **LibraInventory** for position tracking across currencies
-- **BalanceCalculator** for consistent balance calculations
-- **Comprehensive validation** for data integrity
-
-The refactoring improves code quality, maintainability, and sets the foundation for Phase 4's plugin system.
-
-**Phase 3 Status**: ✅ COMPLETE
-
----
-
-*Generated: 2025-10-23*
-*Next: Phase 4 - Validation Plugins*
diff --git a/fava_client.py b/fava_client.py
index 057562a..1cca139 100644
--- a/fava_client.py
+++ b/fava_client.py
@@ -1409,9 +1409,12 @@ class FavaClient:
logger.warning(f"Failed to fetch {endpoint}: {e}")
# Filter out synthetic entries like "Net Profit"
+ from .account_utils import ACCOUNT_TYPE_ROOTS
+
+ valid_roots = set(ACCOUNT_TYPE_ROOTS.values())
account_names = {
name for name in account_names
- if ":" in name or name in ("Assets", "Liabilities", "Equity", "Income", "Expenses")
+ if ":" in name or name in valid_roots
}
if account_names:
diff --git a/migrations.py b/migrations.py
index d8a4bba..1e661c5 100644
--- a/migrations.py
+++ b/migrations.py
@@ -651,3 +651,30 @@ async def m005_add_processed_payments(db):
);
"""
)
+
+
+async def m006_unique_user_roles(db):
+ """
+ Enforce one assignment per (user, role).
+
+ auto_assign_default_role's check-then-act let two concurrent logins
+ both pass the "no roles yet" check and insert twice. The unique
+ index makes the insert itself the arbiter (assign_user_role now
+ uses ON CONFLICT DO NOTHING against it).
+ """
+ # Remove duplicate assignments before creating the index (keep one
+ # deterministic row per pair).
+ await db.execute(
+ """
+ DELETE FROM user_roles
+ WHERE id NOT IN (
+ SELECT min(id) FROM user_roles GROUP BY user_id, role_id
+ )
+ """
+ )
+ await db.execute(
+ """
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_user_roles_unique
+ ON user_roles (user_id, role_id)
+ """
+ )
diff --git a/migrations_old.py.bak b/migrations_old.py.bak
deleted file mode 100644
index a412e3e..0000000
--- a/migrations_old.py.bak
+++ /dev/null
@@ -1,651 +0,0 @@
-async def m001_initial(db):
- """
- Initial migration for Castle accounting extension.
- Creates tables for double-entry bookkeeping system.
- """
- await db.execute(
- f"""
- CREATE TABLE accounts (
- id TEXT PRIMARY KEY,
- name TEXT NOT NULL,
- account_type TEXT NOT NULL,
- description TEXT,
- user_id TEXT,
- created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
- );
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_accounts_user_id ON accounts (user_id);
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_accounts_type ON accounts (account_type);
- """
- )
-
- await db.execute(
- f"""
- CREATE TABLE journal_entries (
- id TEXT PRIMARY KEY,
- description TEXT NOT NULL,
- entry_date TIMESTAMP NOT NULL,
- created_by TEXT NOT NULL,
- created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
- reference TEXT
- );
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_journal_entries_created_by ON journal_entries (created_by);
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_journal_entries_date ON journal_entries (entry_date);
- """
- )
-
- await db.execute(
- f"""
- CREATE TABLE entry_lines (
- id TEXT PRIMARY KEY,
- journal_entry_id TEXT NOT NULL,
- account_id TEXT NOT NULL,
- debit INTEGER NOT NULL DEFAULT 0,
- credit INTEGER NOT NULL DEFAULT 0,
- description TEXT,
- metadata TEXT DEFAULT '{{}}'
- );
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_entry_lines_journal_entry ON entry_lines (journal_entry_id);
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_entry_lines_account ON entry_lines (account_id);
- """
- )
-
- # Insert default chart of accounts
- default_accounts = [
- # Assets
- ("cash", "Cash", "asset", "Cash on hand"),
- ("bank", "Bank Account", "asset", "Bank account"),
- ("lightning", "Lightning Balance", "asset", "Lightning Network balance"),
- ("accounts_receivable", "Accounts Receivable", "asset", "Money owed to the Castle"),
-
- # Liabilities
- ("accounts_payable", "Accounts Payable", "liability", "Money owed by the Castle"),
-
- # Equity
- ("member_equity", "Member Equity", "equity", "Member contributions"),
- ("retained_earnings", "Retained Earnings", "equity", "Accumulated profits"),
-
- # Revenue
- ("accommodation_revenue", "Accommodation Revenue", "revenue", "Revenue from stays"),
- ("service_revenue", "Service Revenue", "revenue", "Revenue from services"),
- ("other_revenue", "Other Revenue", "revenue", "Other revenue"),
-
- # Expenses
- ("utilities", "Utilities", "expense", "Electricity, water, internet"),
- ("food", "Food & Supplies", "expense", "Food and supplies"),
- ("maintenance", "Maintenance", "expense", "Repairs and maintenance"),
- ("other_expense", "Other Expenses", "expense", "Miscellaneous expenses"),
- ]
-
- for acc_id, name, acc_type, desc in default_accounts:
- await db.execute(
- """
- INSERT INTO accounts (id, name, account_type, description)
- VALUES (:id, :name, :type, :description)
- """,
- {"id": acc_id, "name": name, "type": acc_type, "description": desc}
- )
-
-
-async def m002_extension_settings(db):
- """
- Create extension_settings table for Castle configuration.
- """
- await db.execute(
- f"""
- CREATE TABLE extension_settings (
- id TEXT NOT NULL PRIMARY KEY,
- castle_wallet_id TEXT,
- updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
- );
- """
- )
-
-
-async def m003_user_wallet_settings(db):
- """
- Create user_wallet_settings table for per-user wallet configuration.
- """
- await db.execute(
- f"""
- CREATE TABLE user_wallet_settings (
- id TEXT NOT NULL PRIMARY KEY,
- user_wallet_id TEXT,
- updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
- );
- """
- )
-
-
-async def m004_manual_payment_requests(db):
- """
- Create manual_payment_requests table for user payment requests to Castle.
- """
- await db.execute(
- f"""
- CREATE TABLE manual_payment_requests (
- id TEXT PRIMARY KEY,
- user_id TEXT NOT NULL,
- amount INTEGER NOT NULL,
- description TEXT NOT NULL,
- status TEXT NOT NULL DEFAULT 'pending',
- created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
- reviewed_at TIMESTAMP,
- reviewed_by TEXT,
- journal_entry_id TEXT
- );
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_manual_payment_requests_user_id ON manual_payment_requests (user_id);
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_manual_payment_requests_status ON manual_payment_requests (status);
- """
- )
-
-
-async def m005_add_flag_and_meta(db):
- """
- Add flag and meta columns to journal_entries table.
- - flag: Transaction status (* = cleared, ! = pending, # = flagged, x = void)
- - meta: JSON metadata for audit trail (source, tags, links, notes)
- """
- await db.execute(
- """
- ALTER TABLE journal_entries ADD COLUMN flag TEXT DEFAULT '*';
- """
- )
-
- await db.execute(
- """
- ALTER TABLE journal_entries ADD COLUMN meta TEXT DEFAULT '{}';
- """
- )
-
-
-async def m006_hierarchical_account_names(db):
- """
- Migrate account names to hierarchical Beancount-style format.
- - "Cash" → "Assets:Cash"
- - "Accounts Receivable" → "Assets:Receivable"
- - "Food & Supplies" → "Expenses:Food:Supplies"
- - "Accounts Receivable - af983632" → "Assets:Receivable:User-af983632"
- """
- from .account_utils import migrate_account_name
- from .models import AccountType
-
- # Get all existing accounts
- accounts = await db.fetchall("SELECT * FROM accounts")
-
- # Mapping of old names to new names
- name_mappings = {
- # Assets
- "cash": "Assets:Cash",
- "bank": "Assets:Bank",
- "lightning": "Assets:Bitcoin:Lightning",
- "accounts_receivable": "Assets:Receivable",
-
- # Liabilities
- "accounts_payable": "Liabilities:Payable",
-
- # Equity
- "member_equity": "Equity:MemberEquity",
- "retained_earnings": "Equity:RetainedEarnings",
-
- # Revenue → Income
- "accommodation_revenue": "Income:Accommodation",
- "service_revenue": "Income:Service",
- "other_revenue": "Income:Other",
-
- # Expenses
- "utilities": "Expenses:Utilities",
- "food": "Expenses:Food:Supplies",
- "maintenance": "Expenses:Maintenance",
- "other_expense": "Expenses:Other",
- }
-
- # Update default accounts using ID-based mapping
- for old_id, new_name in name_mappings.items():
- await db.execute(
- """
- UPDATE accounts
- SET name = :new_name
- WHERE id = :old_id
- """,
- {"new_name": new_name, "old_id": old_id}
- )
-
- # Update user-specific accounts (those with user_id set)
- user_accounts = await db.fetchall(
- "SELECT * FROM accounts WHERE user_id IS NOT NULL"
- )
-
- for account in user_accounts:
- # Parse account type
- account_type = AccountType(account["account_type"])
-
- # Migrate name
- new_name = migrate_account_name(account["name"], account_type)
-
- await db.execute(
- """
- UPDATE accounts
- SET name = :new_name
- WHERE id = :id
- """,
- {"new_name": new_name, "id": account["id"]}
- )
-
-
-async def m007_balance_assertions(db):
- """
- Create balance_assertions table for reconciliation.
- Allows admins to assert expected balances at specific dates.
- """
- await db.execute(
- f"""
- CREATE TABLE balance_assertions (
- id TEXT PRIMARY KEY,
- date TIMESTAMP NOT NULL,
- account_id TEXT NOT NULL,
- expected_balance_sats INTEGER NOT NULL,
- expected_balance_fiat TEXT,
- fiat_currency TEXT,
- tolerance_sats INTEGER DEFAULT 0,
- tolerance_fiat TEXT DEFAULT '0',
- checked_balance_sats INTEGER,
- checked_balance_fiat TEXT,
- difference_sats INTEGER,
- difference_fiat TEXT,
- status TEXT NOT NULL DEFAULT 'pending',
- created_by TEXT NOT NULL,
- created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
- checked_at TIMESTAMP,
- FOREIGN KEY (account_id) REFERENCES accounts (id)
- );
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_balance_assertions_account_id ON balance_assertions (account_id);
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_balance_assertions_status ON balance_assertions (status);
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_balance_assertions_date ON balance_assertions (date);
- """
- )
-
-
-async def m008_rename_lightning_account(db):
- """
- Rename Lightning account from Assets:Lightning:Balance to Assets:Bitcoin:Lightning
- for better naming consistency.
- """
- await db.execute(
- """
- UPDATE accounts
- SET name = 'Assets:Bitcoin:Lightning'
- WHERE name = 'Assets:Lightning:Balance'
- """
- )
-
-
-async def m009_add_onchain_bitcoin_account(db):
- """
- Add Assets:Bitcoin:OnChain account for on-chain Bitcoin transactions.
- This allows tracking on-chain Bitcoin separately from Lightning Network payments.
- """
- import uuid
-
- # Check if the account already exists
- existing = await db.fetchone(
- """
- SELECT id FROM accounts
- WHERE name = 'Assets:Bitcoin:OnChain'
- """
- )
-
- if not existing:
- # Create the on-chain Bitcoin asset account
- await db.execute(
- f"""
- INSERT INTO accounts (id, name, account_type, description, created_at)
- VALUES (:id, :name, :type, :description, {db.timestamp_now})
- """,
- {
- "id": str(uuid.uuid4()),
- "name": "Assets:Bitcoin:OnChain",
- "type": "asset",
- "description": "On-chain Bitcoin wallet"
- }
- )
-
-
-async def m010_user_equity_status(db):
- """
- Create user_equity_status table for managing equity contribution eligibility.
- Only equity-eligible users can convert their expenses to equity contributions.
- """
- await db.execute(
- f"""
- CREATE TABLE user_equity_status (
- user_id TEXT PRIMARY KEY,
- is_equity_eligible BOOLEAN NOT NULL DEFAULT FALSE,
- equity_account_name TEXT,
- notes TEXT,
- granted_by TEXT NOT NULL,
- granted_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
- revoked_at TIMESTAMP
- );
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_user_equity_status_eligible
- ON user_equity_status (is_equity_eligible)
- WHERE is_equity_eligible = TRUE;
- """
- )
-
-
-async def m011_account_permissions(db):
- """
- Create account_permissions table for granular account access control.
- Allows admins to grant specific permissions (read, submit_expense, manage) to users for specific accounts.
- Supports hierarchical permission inheritance (permissions on parent accounts cascade to children).
- """
- await db.execute(
- f"""
- CREATE TABLE account_permissions (
- id TEXT PRIMARY KEY,
- user_id TEXT NOT NULL,
- account_id TEXT NOT NULL,
- permission_type TEXT NOT NULL,
- granted_by TEXT NOT NULL,
- granted_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
- expires_at TIMESTAMP,
- notes TEXT,
- FOREIGN KEY (account_id) REFERENCES accounts (id)
- );
- """
- )
-
- # Index for looking up permissions by user
- await db.execute(
- """
- CREATE INDEX idx_account_permissions_user_id ON account_permissions (user_id);
- """
- )
-
- # Index for looking up permissions by account
- await db.execute(
- """
- CREATE INDEX idx_account_permissions_account_id ON account_permissions (account_id);
- """
- )
-
- # Composite index for checking specific user+account permissions
- await db.execute(
- """
- CREATE INDEX idx_account_permissions_user_account
- ON account_permissions (user_id, account_id);
- """
- )
-
- # Index for finding permissions by type
- await db.execute(
- """
- CREATE INDEX idx_account_permissions_type ON account_permissions (permission_type);
- """
- )
-
- # Index for finding expired permissions
- await db.execute(
- """
- CREATE INDEX idx_account_permissions_expires
- ON account_permissions (expires_at)
- WHERE expires_at IS NOT NULL;
- """
- )
-
-
-async def m012_update_default_accounts(db):
- """
- Update default chart of accounts to include more detailed hierarchical structure.
- Adds new accounts for fixed assets, livestock, equity contributions, and detailed expenses.
- Only adds accounts that don't already exist.
- """
- import uuid
- from .account_utils import DEFAULT_HIERARCHICAL_ACCOUNTS
-
- for name, account_type, description in DEFAULT_HIERARCHICAL_ACCOUNTS:
- # Check if account already exists
- existing = await db.fetchone(
- """
- SELECT id FROM accounts WHERE name = :name
- """,
- {"name": name}
- )
-
- if not existing:
- # Create new account
- await db.execute(
- f"""
- INSERT INTO accounts (id, name, account_type, description, created_at)
- VALUES (:id, :name, :type, :description, {db.timestamp_now})
- """,
- {
- "id": str(uuid.uuid4()),
- "name": name,
- "type": account_type.value,
- "description": description
- }
- )
-
-
-async def m013_remove_parent_only_accounts(db):
- """
- Remove parent-only accounts from the database.
-
- Since Castle doesn't interface directly with Beancount (only exports to it),
- we don't need parent accounts that exist only for organizational hierarchy.
- The hierarchy is implicit in the colon-separated account names.
-
- When exporting to Beancount, the parent accounts will be inferred from the
- hierarchical naming (e.g., "Assets:Bitcoin:Lightning" implies "Assets:Bitcoin" exists).
-
- This keeps our database clean and prevents accidentally posting to parent accounts.
-
- Removes:
- - Assets:Bitcoin (parent of Lightning and OnChain)
- - Equity (parent of user equity accounts like Equity:User-xxx)
- """
- # Remove Assets:Bitcoin (parent account)
- await db.execute(
- "DELETE FROM accounts WHERE name = :name",
- {"name": "Assets:Bitcoin"}
- )
-
- # Remove Equity (parent account)
- await db.execute(
- "DELETE FROM accounts WHERE name = :name",
- {"name": "Equity"}
- )
-
-
-async def m014_remove_legacy_equity_accounts(db):
- """
- Remove legacy generic equity accounts that don't fit the user-specific equity model.
-
- The castle extension uses dynamic user-specific equity accounts (Equity:User-{user_id})
- created automatically when granting equity eligibility. Generic equity accounts like
- MemberEquity and RetainedEarnings are not needed.
-
- Removes:
- - Equity:MemberEquity
- - Equity:RetainedEarnings
- """
- # Remove Equity:MemberEquity
- await db.execute(
- "DELETE FROM accounts WHERE name = :name",
- {"name": "Equity:MemberEquity"}
- )
-
- # Remove Equity:RetainedEarnings
- await db.execute(
- "DELETE FROM accounts WHERE name = :name",
- {"name": "Equity:RetainedEarnings"}
- )
-
-
-async def m015_convert_to_single_amount_field(db):
- """
- Convert entry_lines from separate debit/credit columns to single amount field.
-
- This aligns Castle with Beancount's elegant design:
- - Positive amount = debit (increase assets/expenses, decrease liabilities/equity/revenue)
- - Negative amount = credit (decrease assets/expenses, increase liabilities/equity/revenue)
-
- Benefits:
- - Simpler model (one field instead of two)
- - Direct compatibility with Beancount import/export
- - Eliminates invalid states (both debit and credit non-zero)
- - More intuitive for programmers (positive/negative instead of accounting conventions)
-
- Migration formula: amount = debit - credit
-
- Examples:
- - Expense transaction:
- * Expenses:Food:Groceries amount=+100 (debit)
- * Liabilities:Payable:User amount=-100 (credit)
- - Payment transaction:
- * Liabilities:Payable:User amount=+100 (debit)
- * Assets:Bitcoin:Lightning amount=-100 (credit)
- """
- from sqlalchemy.exc import OperationalError
-
- # Step 1: Add new amount column (nullable for migration)
- try:
- await db.execute(
- "ALTER TABLE entry_lines ADD COLUMN amount INTEGER"
- )
- except OperationalError:
- # Column might already exist if migration was partially run
- pass
-
- # Step 2: Populate amount from existing debit/credit
- # Formula: amount = debit - credit
- await db.execute(
- """
- UPDATE entry_lines
- SET amount = debit - credit
- WHERE amount IS NULL
- """
- )
-
- # Step 3: Create new table with amount field as NOT NULL
- # SQLite doesn't support ALTER COLUMN, so we need to recreate the table
- await db.execute(
- """
- CREATE TABLE entry_lines_new (
- id TEXT PRIMARY KEY,
- journal_entry_id TEXT NOT NULL,
- account_id TEXT NOT NULL,
- amount INTEGER NOT NULL,
- description TEXT,
- metadata TEXT DEFAULT '{}'
- )
- """
- )
-
- # Step 4: Copy data from old table to new
- await db.execute(
- """
- INSERT INTO entry_lines_new (id, journal_entry_id, account_id, amount, description, metadata)
- SELECT id, journal_entry_id, account_id, amount, description, metadata
- FROM entry_lines
- """
- )
-
- # Step 5: Drop old table and rename new one
- await db.execute("DROP TABLE entry_lines")
- await db.execute("ALTER TABLE entry_lines_new RENAME TO entry_lines")
-
- # Step 6: Recreate indexes
- await db.execute(
- """
- CREATE INDEX idx_entry_lines_journal_entry ON entry_lines (journal_entry_id)
- """
- )
-
- await db.execute(
- """
- CREATE INDEX idx_entry_lines_account ON entry_lines (account_id)
- """
- )
-
-
-async def m016_drop_obsolete_journal_tables(db):
- """
- Drop journal_entries and entry_lines tables.
-
- Castle now uses Fava/Beancount as the single source of truth for accounting data.
- These tables are no longer written to or read from.
-
- All journal entry operations now:
- - Write: Submit to Fava via FavaClient.add_entry()
- - Read: Query Fava via FavaClient.get_entries()
-
- Migration completed as part of Castle extension cleanup (Nov 2025).
- No backwards compatibility concerns - user explicitly approved.
- """
- # Drop entry_lines first (has foreign key to journal_entries)
- await db.execute("DROP TABLE IF EXISTS entry_lines")
-
- # Drop journal_entries
- await db.execute("DROP TABLE IF EXISTS journal_entries")
diff --git a/tasks.py b/tasks.py
index 23dd0b5..0482708 100644
--- a/tasks.py
+++ b/tasks.py
@@ -58,15 +58,15 @@ async def check_all_balance_assertions() -> dict:
})
except Exception as e:
results["errors"] += 1
- print(f"Error checking assertion {assertion.id}: {e}")
+ logger.error(f"Error checking assertion {assertion.id}: {e}")
# Log results
if results["failed"] > 0:
- print(f"[LIBRA] Daily reconciliation check: {results['failed']} FAILED assertions!")
+ logger.warning(f"[LIBRA] Daily reconciliation check: {results['failed']} FAILED assertions!")
for failed in results["failed_assertions"]:
- print(f" - Account {failed['account_id']}: expected {failed['expected_sats']}, got {failed['actual_sats']}")
+ logger.warning(f" - Account {failed['account_id']}: expected {failed['expected_sats']}, got {failed['actual_sats']}")
else:
- print(f"[LIBRA] Daily reconciliation check: All {results['passed']} assertions passed ✓")
+ logger.info(f"[LIBRA] Daily reconciliation check: All {results['passed']} assertions passed ✓")
return results
@@ -78,7 +78,7 @@ async def scheduled_daily_reconciliation():
This function is meant to be called by a scheduler (cron, systemd timer, etc.)
or by LNbits background task system.
"""
- print(f"[LIBRA] Running scheduled daily reconciliation check at {datetime.now()}")
+ logger.info(f"[LIBRA] Running scheduled daily reconciliation check at {datetime.now()}")
try:
results = await check_all_balance_assertions()
@@ -86,12 +86,12 @@ async def scheduled_daily_reconciliation():
# TODO: Send notifications if there are failures
# This could send email, webhook, or in-app notification
if results["failed"] > 0:
- print(f"[LIBRA] WARNING: {results['failed']} balance assertions failed!")
+ logger.warning(f"[LIBRA] {results['failed']} balance assertions failed!")
# Future: Send alert notification
return results
except Exception as e:
- print(f"[LIBRA] Error in scheduled reconciliation: {e}")
+ logger.error(f"[LIBRA] Error in scheduled reconciliation: {e}")
raise
@@ -166,7 +166,7 @@ def start_daily_reconciliation_task():
# Run daily at 2 AM
0 2 * * * curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY"
"""
- print("[LIBRA] Daily reconciliation task registered")
+ logger.info("[LIBRA] Daily reconciliation task registered")
# In a production system, you would register this with LNbits task scheduler
# For now, it can be triggered manually via API endpoint
diff --git a/tests/conftest.py b/tests/conftest.py
index 44b5c26..7b4bfe7 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -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")
diff --git a/tests/test_unit.py b/tests/test_unit.py
index 11779e9..a25b436 100644
--- a/tests/test_unit.py
+++ b/tests/test_unit.py
@@ -394,66 +394,6 @@ def test_migrate_account_name_expense_with_ampersand():
)
-# ---------------------------------------------------------------------------
-# core.validation — validate_journal_entry
-# ---------------------------------------------------------------------------
-
-
-def test_validate_journal_entry_balanced_passes():
- val.validate_journal_entry(
- {"id": "x"},
- [
- {"account_id": "a", "amount": 100},
- {"account_id": "b", "amount": -100},
- ],
- )
-
-
-def test_validate_journal_entry_unbalanced_raises():
- with pytest.raises(val.ValidationError) as exc:
- val.validate_journal_entry(
- {"id": "x"},
- [
- {"account_id": "a", "amount": 100},
- {"account_id": "b", "amount": -50},
- ],
- )
- assert "not balanced" in str(exc.value)
-
-
-def test_validate_journal_entry_single_line_raises():
- with pytest.raises(val.ValidationError) as exc:
- val.validate_journal_entry(
- {"id": "x"},
- [{"account_id": "a", "amount": 100}],
- )
- assert "at least 2 lines" in str(exc.value)
-
-
-def test_validate_journal_entry_zero_amount_raises():
- with pytest.raises(val.ValidationError) as exc:
- val.validate_journal_entry(
- {"id": "x"},
- [
- {"account_id": "a", "amount": 0},
- {"account_id": "b", "amount": 0},
- ],
- )
- assert "amount = 0" in str(exc.value)
-
-
-def test_validate_journal_entry_missing_account_id_raises():
- with pytest.raises(val.ValidationError) as exc:
- val.validate_journal_entry(
- {"id": "x"},
- [
- {"amount": 100},
- {"account_id": "b", "amount": -100},
- ],
- )
- assert "missing account_id" in str(exc.value)
-
-
# ---------------------------------------------------------------------------
# core.validation — validate_balance
# ---------------------------------------------------------------------------
diff --git a/user_lookup.py b/user_lookup.py
new file mode 100644
index 0000000..040c09b
--- /dev/null
+++ b/user_lookup.py
@@ -0,0 +1,126 @@
+"""Username resolution for UI display.
+
+Extracted from views_api (CODE-REVIEW-2026-06 #18): the old helper
+constructed a fresh LNbits `Database` per call inside per-row hot paths
+(entry listings, all-user balances). This module keeps one shared core-DB
+handle and a short TTL cache, so listing N rows for the same few users
+costs one lookup per unique user per TTL window instead of one per row.
+
+Accepted id shapes (they all occur in ledger data):
+- Full UUID with dashes (36 chars): "375ec158-686c-4a21-b44d-a51cc90ef07d"
+- Dashless UUID (32 chars): "375ec158686c4a21b44da51cc90ef07d"
+- Partial id from account names (8 chars): "375ec158"
+"""
+
+from typing import Dict, Iterable, Optional
+
+from lnbits.core.crud.users import get_user
+from lnbits.db import Database
+from lnbits.utils.cache import Cache
+from loguru import logger
+
+# One shared handle to the LNbits core DB (username lives on core
+# `accounts`, not in libra's extension DB).
+_core_db = Database("database")
+
+_username_cache = Cache()
+_USERNAME_CACHE_TTL = 60 # seconds — usernames change rarely
+
+
+def _dashed(user_id: str) -> str:
+ return (
+ f"{user_id[0:8]}-{user_id[8:12]}-{user_id[12:16]}"
+ f"-{user_id[16:20]}-{user_id[20:32]}"
+ )
+
+
+async def _resolve(user_id: str) -> str:
+ from .crud import get_all_user_wallet_settings
+
+ # Case 1: full UUID with dashes
+ if len(user_id) == 36 and user_id.count('-') == 4:
+ user = await get_user(user_id)
+ return user.username if user and user.username else f"User-{user_id[:8]}"
+
+ # Case 2: dashless 32-char UUID — libra user settings first, then
+ # the LNbits core DB directly
+ if len(user_id) == 32 and '-' not in user_id:
+ try:
+ user_id_with_dashes = _dashed(user_id)
+
+ user_settings = await get_all_user_wallet_settings()
+ for setting in user_settings:
+ if setting.id == user_id_with_dashes:
+ user = await get_user(setting.id)
+ return (
+ user.username
+ if user and user.username
+ else f"User-{user_id[:8]}"
+ )
+
+ async with _core_db.connect() as conn:
+ row = await conn.fetchone(
+ "SELECT id, username FROM accounts WHERE id = :user_id LIMIT 1",
+ {"user_id": user_id_with_dashes},
+ )
+ if row and row["username"]:
+ return row["username"]
+
+ return f"User-{user_id[:8]}"
+ except Exception as e:
+ logger.error(f"Error looking up user by dashless UUID {user_id}: {e}")
+ return f"User-{user_id[:8]}"
+
+ # Case 3: 8-char partial id from an account name — resolve to a full
+ # id via libra user settings
+ if len(user_id) == 8:
+ try:
+ user_settings = await get_all_user_wallet_settings()
+ for setting in user_settings:
+ if setting.id.startswith(user_id):
+ user = await get_user(setting.id)
+ return (
+ user.username
+ if user and user.username
+ else f"User-{user_id}"
+ )
+ return f"User-{user_id}"
+ except Exception as e:
+ logger.error(f"Error looking up user by partial ID {user_id}: {e}")
+ return f"User-{user_id}"
+
+ # Case 4: unknown shape — try as-is, fall back
+ try:
+ user = await get_user(user_id)
+ return user.username if user and user.username else f"User-{user_id[:8]}"
+ except Exception:
+ return f"User-{user_id[:8]}"
+
+
+async def get_username(user_id: str) -> Optional[str]:
+ """Resolve a user id (any accepted shape) to a display username.
+
+ Returns a "User-{short}" fallback when no username exists, or None
+ for falsy input.
+ """
+ if not user_id:
+ return None
+
+ cache_key = f"username:{user_id}"
+ cached = _username_cache.get(cache_key)
+ if cached is not None:
+ return cached
+
+ result = await _resolve(user_id)
+ _username_cache.set(cache_key, result, _USERNAME_CACHE_TTL)
+ return result
+
+
+async def get_usernames(user_ids: Iterable[str]) -> Dict[str, str]:
+ """Resolve many user ids at once, deduplicated and cache-backed."""
+ result: Dict[str, str] = {}
+ for user_id in {u for u in user_ids if u}:
+ username = await get_username(user_id)
+ if username is not None:
+ result[user_id] = username
+ return result
diff --git a/views_api.py b/views_api.py
index b41e6b6..65185e8 100644
--- a/views_api.py
+++ b/views_api.py
@@ -15,6 +15,7 @@ from lnbits.utils.exchange_rates import allowed_currencies, fiat_amount_as_satos
from .account_utils import VALID_ACCOUNT_PREFIXES, validate_account_name
from .beancount_format import fiat_rate_metadata
+from .user_lookup import get_username
from .crud import (
approve_manual_payment_request,
check_balance_assertion,
@@ -643,7 +644,7 @@ async def api_get_user_entries(
break
# Look up actual username using helper function
- username = await _get_username_from_user_id(user_id_match) if user_id_match else None
+ username = await get_username(user_id_match) if user_id_match else None
entry_data = {
"id": entry_id or e.get("entry_hash", "unknown"),
@@ -684,119 +685,6 @@ async def api_get_user_entries(
}
-async def _get_username_from_user_id(user_id: str) -> str:
- """
- Helper function to get username from user_id, handling various formats.
-
- Supports:
- - Full UUID with dashes (36 chars): "375ec158-686c-4a21-b44d-a51cc90ef07d"
- - Dashless UUID (32 chars): "375ec158686c4a21b44da51cc90ef07d"
- - Partial ID (8 chars from account names): "375ec158"
-
- Returns username or formatted fallback.
- """
- from lnbits.core.crud.users import get_user
-
- logger.debug(f"[USERNAME] Called with: '{user_id}' (len={len(user_id) if user_id else 0})")
-
- if not user_id:
- return None
-
- # Case 1: Already in standard UUID format (36 chars with dashes)
- if len(user_id) == 36 and user_id.count('-') == 4:
- logger.debug(f"[USERNAME] Case 1: Full UUID format")
- user = await get_user(user_id)
- result = user.username if user and user.username else f"User-{user_id[:8]}"
- logger.debug(f"[USERNAME] Case 1 result: '{result}'")
- return result
-
- # Case 2: Dashless 32-char UUID - lookup via Libra user settings, fallback to LNbits
- elif len(user_id) == 32 and '-' not in user_id:
- logger.debug(f"[USERNAME] Case 2: Dashless UUID format - looking up in Libra user settings")
- try:
- # Convert dashless to dashed format
- user_id_with_dashes = f"{user_id[0:8]}-{user_id[8:12]}-{user_id[12:16]}-{user_id[16:20]}-{user_id[20:32]}"
- logger.debug(f"[USERNAME] Converted to dashed format: {user_id_with_dashes}")
-
- # Try Libra settings first
- user_settings = await get_all_user_wallet_settings()
- for setting in user_settings:
- if setting.id == user_id_with_dashes:
- logger.debug(f"[USERNAME] Found matching user in Libra settings")
- user = await get_user(setting.id)
- result = user.username if user and user.username else f"User-{user_id[:8]}"
- logger.debug(f"[USERNAME] Case 2 result (from Libra): '{result}'")
- return result
-
- # Not in Libra settings - try LNbits database directly
- logger.debug(f"[USERNAME] Not in Libra settings, querying LNbits database directly")
- from lnbits.db import Database
- db = Database("database")
- async with db.connect() as conn:
- row = await conn.fetchone(
- "SELECT id, username FROM accounts WHERE id = :user_id LIMIT 1",
- {"user_id": user_id_with_dashes}
- )
- logger.debug(f"[USERNAME] Database query result: {row}")
- if row and row["username"]:
- result = row["username"]
- logger.debug(f"[USERNAME] Case 2 result (from LNbits DB): '{result}'")
- return result
-
- # User doesn't exist anywhere
- logger.debug(f"[USERNAME] User not found in LNbits database either")
- result = f"User-{user_id[:8]}"
- logger.debug(f"[USERNAME] Case 2 result (not found): '{result}'")
- return result
-
- except Exception as e:
- logger.error(f"Error looking up user by dashless UUID {user_id}: {e}")
- result = f"User-{user_id[:8]}"
- return result
-
- # Case 3: Partial ID (8 chars from account name) - lookup via Libra user settings
- elif len(user_id) == 8:
- logger.debug(f"[USERNAME] Case 3: Partial ID format - looking up in Libra user settings")
- try:
- # Get all Libra users (which have full user_ids)
- user_settings = await get_all_user_wallet_settings()
-
- # Find matching user by first 8 chars
- for setting in user_settings:
- if setting.id.startswith(user_id):
- logger.debug(f"[USERNAME] Found full user_id: {setting.id}")
- # Now get username from LNbits with full ID
- user = await get_user(setting.id)
- result = user.username if user and user.username else f"User-{user_id}"
- logger.debug(f"[USERNAME] Case 3 result (found): '{result}'")
- return result
-
- # No matching user found in Libra settings
- logger.debug(f"[USERNAME] No matching user found in Libra settings")
- result = f"User-{user_id}"
- logger.debug(f"[USERNAME] Case 3 result (not found): '{result}'")
- return result
-
- except Exception as e:
- logger.error(f"Error looking up user by partial ID {user_id}: {e}")
- result = f"User-{user_id}"
- return result
-
- # Case 4: Unknown format - try as-is and fall back
- else:
- logger.debug(f"[USERNAME] Case 4: Unknown format - trying as-is")
- try:
- user = await get_user(user_id)
- result = user.username if user and user.username else f"User-{user_id[:8]}"
- logger.debug(f"[USERNAME] Case 4 result: '{result}'")
- return result
- except Exception as e:
- logger.debug(f"[USERNAME] Case 4 exception: {e}")
- result = f"User-{user_id[:8]}"
- logger.debug(f"[USERNAME] Case 4 fallback result: '{result}'")
- return result
-
-
@libra_api_router.get("/api/v1/entries/pending")
async def api_get_pending_entries(
auth: AuthContext = Depends(require_super_user),
@@ -844,7 +732,7 @@ async def api_get_pending_entries(
break
# Look up username using helper function
- username = await _get_username_from_user_id(user_id) if user_id else None
+ username = await get_username(user_id) if user_id else None
# Extract amount from postings (sum of absolute values / 2)
amount_sats = 0
@@ -1453,7 +1341,9 @@ async def api_create_receivable_entry(
created_by=auth.user_id,
created_at=datetime.now(),
reference=data.reference,
- flag=JournalEntryFlag.PENDING,
+ # Receivables are written cleared (format_receivable_entry uses
+ # flag="*") — reporting PENDING here misled the UI (libra-#35).
+ flag=JournalEntryFlag.CLEARED,
meta=entry_meta,
lines=[
EntryLine(
@@ -1682,7 +1572,7 @@ async def api_get_all_balances(
# Enrich with username information using helper function
result = []
for balance in balances:
- username = await _get_username_from_user_id(balance["user_id"])
+ username = await get_username(balance["user_id"])
result.append({
"user_id": balance["user_id"],