Approving a pending entry created with a reference (e.g. invoice
"42-144") 404'd with "Pending entry unknown not found": the list
endpoints recovered the entry id by parsing links for a libra- prefix,
but reference-bearing entries displace that link with the fused
"{reference}-{entry_id}" form, so the id surfaced as the literal
"unknown" and the approve call round-tripped it.
Make the entry-id transaction metadata the single canonical identity:
- _extract_entry_id() resolves metadata-first (libra- link parsing kept
only for pre-dfdcc44 ledger history); used by /entries/user,
/entries/pending, approve, and reject.
- Creation endpoints no longer fuse the reference with the entry id —
the user reference becomes its own sanitized link and round-trips
verbatim in API responses. Typed exp-/rcv-/inc- links stay as the
settlement-tracking handles.
- format_revenue_entry now writes entry-id metadata like its siblings
and sanitizes its reference link (was appended raw); generic
POST /entries sanitizes its reference link too.
- User-journal reference extraction skips all system link prefixes
(typed links used to leak into the reference field).
Contract documented in CLAUDE.md (Data Integrity → Entry Identity &
Links), pinned by tests/test_entry_identity_api.py and formatter
contract tests in test_unit.py.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
17 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
Libra is a double-entry bookkeeping extension for LNbits that enables collectives (co-living spaces, makerspaces, community projects) to track finances with proper accounting principles. It integrates Lightning Network payments with traditional accounting, supporting both cryptocurrency and fiat currency tracking.
Architecture
Core Design Principles
Double-Entry Accounting: Every transaction affects at least two accounts. Debits must equal credits. Five account types: Assets, Liabilities, Equity, Revenue (Income), Expenses.
Fava/Beancount Backend: Libra now uses Fava as the primary accounting engine. Fava is a web interface for Beancount that provides a REST API for ledger operations. All accounting calculations (balance sheets, trial balances, account reports) are delegated to Fava/Beancount. Libra formats transactions as Beancount entries and submits them via Fava's API.
Required External Dependency: Fava must be running as a separate service. Configure fava_url and fava_ledger_slug in Libra settings (default: http://localhost:3333 with slug libra-accounting). Libra will not function without Fava.
Pure Functional Core: The core/ directory contains pure accounting logic independent of the database layer:
core/validation.py- Entry validation rules
Account Hierarchy: Beancount-style hierarchical naming with : separators:
Assets:Lightning:BalanceAssets:Receivable:User-af983632Liabilities:Payable:User-af983632Expenses:Food:Supplies
Amount Format: Recent architecture change uses string-based amounts with currency codes:
- SATS amounts:
"200000 SATS" - Fiat amounts:
"100.00 EUR"or"250.00 USD" - Cost basis notation:
"200000 SATS {100.00 EUR}"(200k sats acquired at 100 EUR) - Parsing handles both formats via
parse_amount_string()in views_api.py
Metadata System: Beancount metadata format stores original fiat amounts and exchange rates as key-value pairs. Critical: fiat balances are calculated by summing fiat amounts from journal entries, NOT by converting current satoshi balances. This prevents exchange rate fluctuations from affecting historical records.
Key Files
models.py- Pydantic models for API I/O and data structurescrud.py- Database operations (create/read/update accounts, journal entries)views_api.py- FastAPI endpoints for all operationsviews.py- Web interface routingservices.py- Settings management layermigrations.py- Database schema migrationstasks.py- Background tasks (invoice payment monitoring)account_utils.py- Hierarchical account naming utilitiesfava_client.py- HTTP client for Fava REST API (add_entry, query, balance_sheet)beancount_format.py- Converts Libra entries to Beancount transaction formatcore/validation.py- Pure validation functions for accounting rules
Database Schema
Fava is the sole source of truth for journal entries. Libra does NOT maintain a local mirror of transactions — the previous journal_entries and entry_lines tables were removed during the Fava migration. All transaction reads (history, balances, summaries) go through Fava's HTTP API; writes go through PUT /api/add_entries and are serialized via FavaClient._write_lock. When retrieving journal entries from Fava for UI display, results are enriched with a username field from LNbits user data.
The SQLite tables below hold operational state that Fava doesn't (and shouldn't) own — workflow, RBAC, settings, reconciliation assertions. None of it is derivable from the Beancount file; they are independent stores, not caches.
extension_settings: Libra wallet configuration (admin-only)
libra_wallet_id- The LNbits wallet used for Libra operationsfava_url- Fava service URL (default: http://localhost:3333)fava_ledger_slug- Ledger identifier in Fava (default: libra-accounting)fava_timeout- API request timeout in seconds
user_wallet_settings: Per-user wallet configuration
manual_payment_requests: User requests for cash/manual payments
Transaction Flows
User Adds Expense (Liability)
User pays cash for groceries, Libra owes them:
DR Expenses:Food 39,669 sats
CR Liabilities:Payable:User-af983632 39,669 sats
Metadata preserves: {"fiat_currency": "EUR", "fiat_amount": "36.93", "fiat_rate": "1074.192"}
Libra Adds Receivable
User owes Libra for accommodation:
DR Assets:Receivable:User-af983632 268,548 sats
CR Income:Accommodation 268,548 sats
User Pays with Lightning
Invoice generated on Libra's wallet (not user's). After payment:
DR Assets:Lightning:Balance 268,548 sats
CR Assets:Receivable:User-af983632 268,548 sats
Manual Payment Approval
User requests cash payment → Admin approves → Journal entry created:
DR Liabilities:Payable:User-af983632 39,669 sats
CR Assets:Lightning:Balance 39,669 sats
Balance Calculation Logic
User Balance (calculated by Beancount via Fava):
- Positive = Libra owes user (LIABILITY accounts have credit balance)
- Negative = User owes Libra (ASSET accounts have debit balance)
- Calculated by querying Fava for sum of all postings across user's accounts
- Fiat balances calculated by Beancount from cost basis annotations, NOT converted from current sats
Perspective-Based UI:
- User View: Green = Libra owes them, Red = They owe Libra
- Libra Admin View: Green = User owes Libra, Red = Libra owes user
Balance Retrieval: Use GET /api/v1/balance which queries Fava's balance sheet or account reports for accurate, Beancount-calculated balances.
API Endpoints
Accounts
GET /api/v1/accounts- List all accountsPOST /api/v1/accounts- Create account (admin)GET /api/v1/accounts/{id}/balance- Get account balance
Journal Entries
POST /api/v1/entries/expense- User adds expense (creates liability or equity)POST /api/v1/entries/receivable- Admin records what user owes (admin only)POST /api/v1/entries/revenue- Admin records direct revenue (admin only)GET /api/v1/entries/user- Get user's journal entriesPOST /api/v1/entries- Create raw journal entry (admin only)
Payments & Balances
GET /api/v1/balance- Get user balance (or Libra total if super user)GET /api/v1/balances/all- Get all user balances (admin, enriched with usernames)POST /api/v1/generate-payment-invoice- Generate invoice for user to pay LibraPOST /api/v1/record-payment- Record Lightning payment from user to LibraPOST /api/v1/settle-receivable- Manually settle receivable (cash/bank)POST /api/v1/pay-user- Libra pays user (cash/bank/lightning)
Manual Payment Requests
POST /api/v1/manual-payment-requests- User requests paymentGET /api/v1/manual-payment-requests- User's requestsGET /api/v1/manual-payment-requests/all- All requests (admin)POST /api/v1/manual-payment-requests/{id}/approve- Approve (admin)POST /api/v1/manual-payment-requests/{id}/reject- Reject (admin)
Reconciliation
POST /api/v1/assertions/balance- Create balance assertionGET /api/v1/assertions/balance- List balance assertionsPOST /api/v1/assertions/balance/{id}/check- Check assertionPOST /api/v1/tasks/daily-reconciliation- Run daily reconciliation (admin)
Settings
GET /api/v1/settings- Get Libra settings (super user)PUT /api/v1/settings- Update Libra settings (super user)GET /api/v1/user/wallet- Get user wallet settingsPUT /api/v1/user/wallet- Update user wallet settings
Development Notes
Testing Entry Creation
When creating journal entries programmatically, use the helper endpoints:
POST /api/v1/entries/expensefor user expenses (handles account creation automatically)POST /api/v1/entries/receivablefor what users owePOST /api/v1/entries/revenuefor direct revenue
For custom entries, use POST /api/v1/entries with properly balanced lines.
User Account Management
User-specific accounts are created automatically with format:
- Assets:
Assets:Receivable:User-{user_id[:8]} - Liabilities:
Liabilities:Payable:User-{user_id[:8]} - Equity:
Equity:MemberEquity:User-{user_id[:8]}
Use get_or_create_user_account() in crud.py to ensure consistency.
Currency Handling
CRITICAL: Use Decimal for all fiat amounts, never float.
New Amount String Format (recent architecture change):
- Input format:
"100.00 EUR"or"200000 SATS" - Cost basis format:
"200000 SATS {100.00 EUR}"(for recording acquisition cost) - Parse using
parse_amount_string(amount_str)in views_api.py - Returns tuple:
(amount: Decimal, currency: str, cost_basis: Optional[tuple])
Beancount Metadata Format:
# Metadata attached to individual postings (legs of a transaction)
metadata = {
"fiat_currency": "EUR",
"fiat_amount": "250.00", # String for precision
"fiat_rate": "1074.192", # Sats per fiat unit
}
Important: When creating entries to submit to Fava, use beancount_format.format_transaction() to ensure proper Beancount syntax.
Fava Integration Patterns
Adding a Transaction:
from .fava_client import get_fava_client
from .beancount_format import format_transaction
from datetime import date
# Format as Beancount transaction
entry = format_transaction(
date_val=date.today(),
flag="*",
narration="Groceries purchase",
postings=[
{"account": "Expenses:Food", "amount": "50000 SATS {46.50 EUR}"},
{"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"}
)
# Submit to Fava
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:
# Query user balance from Fava
balance_result = await client.query(
f"SELECT sum(position) WHERE account ~ 'User-{user_id_short}'"
)
Important: Always use sanitize_link() from beancount_format.py when creating links to ensure Beancount compatibility (only A-Z, a-z, 0-9, -, _, /, . allowed).
Permission Model
- Super User: Full access (check via
wallet.wallet.user == lnbits_settings.super_user) - Admin Key: Required for creating receivables, approving payments, viewing all balances
- Invoice Key: Read access to user's own data
- Users: Can only see/manage their own accounts and transactions
Extension as LNbits Module
This extension follows LNbits extension structure:
- Registered via
libra_extrouter in__init__.py - Static files served from
static/directory - Templates in
templates/libra/ - Database accessed via
db = Database("ext_libra")
Startup Requirements:
libra_start()initializes Fava client on extension load- Background task
wait_for_paid_invoices()monitors Lightning invoice payments - Fava service MUST be running before starting LNbits with Libra extension
Common Tasks
Add New Account in Fava
from .fava_client import get_fava_client
from datetime import date
# Create Open directive for new account
client = get_fava_client()
entry = {
"t": "Open",
"date": str(date.today()),
"account": "Expenses:Internet",
"currencies": ["SATS", "EUR"]
}
await client.add_entry(entry)
Record Transaction to Fava
from .beancount_format import format_transaction
entry = format_transaction(
date_val=date.today(),
flag="*",
narration="Internet bill payment",
postings=[
{"account": "Expenses:Internet", "amount": "50000 SATS {46.50 EUR}"},
{"account": "Assets:Lightning:Balance", "amount": "-50000 SATS"}
],
tags=["utilities"],
links=["exp-0123456789abcdef"],
meta={"entry-id": "0123456789abcdef"}
)
client = get_fava_client()
await client.add_entry(entry)
Query User Balance from Fava
client = get_fava_client()
# Query all accounts for a user
user_short = user_id[:8]
query = f"SELECT account, sum(position) WHERE account ~ 'User-{user_short}' GROUP BY account"
result = await client.query(query)
# Parse result to calculate net balance
# (sum of all user accounts across Assets, Liabilities, Equity)
Data Integrity
Critical Invariants:
- Every transaction submitted to Fava MUST have balanced debits and credits (Beancount enforces this)
- Fiat amounts tracked via cost basis notation:
"AMOUNT SATS {COST FIAT}" - User accounts use
user_id(NOTwallet_id) for consistency - All accounting calculations delegated to Beancount/Fava
Entry Identity & Links (the contract _extract_entry_id() in views_api.py relies on):
- The
entry-idtransaction 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 noentry-idmetadata —_extract_entry_id()falls back to parsing it. Do not writelibra-links in new code.
Validation is performed in core/validation.py:
- Pure validation functions for entry correctness before submitting to Fava
Beancount String Sanitization:
- Links must match pattern:
[A-Za-z0-9\-_/.] - Use
sanitize_link()from beancount_format.py for all links and tags
Recent Architecture Changes
Migration to Fava/Beancount (2025):
- Removed local balance calculation logic (now handled by Beancount)
- Removed local
accountsandentry_linestables (Fava is source of truth) - Added
fava_client.pyandbeancount_format.pymodules - Changed amount format to string-based with currency codes
- Username enrichment added to journal entries for UI display
Key Breaking Changes:
- All balance queries now go through Fava API
- Account creation must use Fava's Open directive
- Transaction format must follow Beancount syntax
- Cost basis notation required for multi-currency tracking
Development Setup
Prerequisites
- LNbits: This extension must be installed in the
lnbits/extensions/directory - Fava Service: Must be running before starting LNbits with Libra enabled
# Install Fava pip install fava # Create a basic Beancount file touch libra-ledger.beancount # Start Fava (default: http://localhost:3333) fava libra-ledger.beancount - Configure Libra Settings: Set
fava_urlandfava_ledger_slugvia settings API or UI
Running Libra Extension
Libra is loaded as part of LNbits. No separate build or test commands are needed for the extension itself. Development workflow:
- Modify code in
lnbits/extensions/libra/ - Restart LNbits
- Extension hot-reloads are supported by LNbits in development mode
Testing Transactions
Use the web UI or API endpoints to create test transactions. For API testing:
# Create expense (user owes Libra)
curl -X POST http://localhost:5000/libra/api/v1/entries/expense \
-H "X-Api-Key: YOUR_INVOICE_KEY" \
-d '{"description": "Test expense", "amount": "100.00 EUR", "account_name": "Expenses:Test"}'
# Check user balance
curl http://localhost:5000/libra/api/v1/balance \
-H "X-Api-Key: YOUR_INVOICE_KEY"
Debugging Fava Connection: Check logs for "Fava client initialized" message on startup. If missing, verify Fava is running and settings are correct.
Related Documentation
docs/README.md- User-facing overviewdocs/DOCUMENTATION.md- Comprehensive technical documentationdocs/BEANCOUNT_PATTERNS.md- Beancount-inspired design patternsdocs/PHASE1_COMPLETE.md,PHASE2_COMPLETE.md,PHASE3_COMPLETE.md- Development milestonesdocs/EXPENSE_APPROVAL.md- Manual payment request workflowdocs/DAILY_RECONCILIATION.md- Automated reconciliation system