libra/CLAUDE.md
Padreug 15d9910073 Resolve entry identity via entry-id metadata; unfuse user references (libra-#42)
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>
2026-06-12 20:39:06 +02:00

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:Balance
  • Assets:Receivable:User-af983632
  • Liabilities:Payable:User-af983632
  • Expenses: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 structures
  • crud.py - Database operations (create/read/update accounts, journal entries)
  • views_api.py - FastAPI endpoints for all operations
  • views.py - Web interface routing
  • services.py - Settings management layer
  • migrations.py - Database schema migrations
  • tasks.py - Background tasks (invoice payment monitoring)
  • account_utils.py - Hierarchical account naming utilities
  • fava_client.py - HTTP client for Fava REST API (add_entry, query, balance_sheet)
  • beancount_format.py - Converts Libra entries to Beancount transaction format
  • core/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 operations
  • fava_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 accounts
  • POST /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 entries
  • POST /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 Libra
  • POST /api/v1/record-payment - Record Lightning payment from user to Libra
  • POST /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 payment
  • GET /api/v1/manual-payment-requests - User's requests
  • GET /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 assertion
  • GET /api/v1/assertions/balance - List balance assertions
  • POST /api/v1/assertions/balance/{id}/check - Check assertion
  • POST /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 settings
  • PUT /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/expense for user expenses (handles account creation automatically)
  • POST /api/v1/entries/receivable for what users owe
  • POST /api/v1/entries/revenue for 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_ext router 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:

  1. Every transaction submitted to Fava MUST have balanced debits and credits (Beancount enforces this)
  2. Fiat amounts tracked via cost basis notation: "AMOUNT SATS {COST FIAT}"
  3. User accounts use user_id (NOT wallet_id) for consistency
  4. All accounting calculations delegated to Beancount/Fava

Entry Identity & Links (the contract _extract_entry_id() in views_api.py relies on):

  • The entry-id transaction metadata is the single canonical entry identifier. Every libra-authored entry formatter (format_expense_entry, format_receivable_entry, format_income_entry, format_revenue_entry) writes it. All id resolution (pending list, user journal, approve, reject) reads it — never parse links to recover an id.
  • Typed links exp-{id} / rcv-{id} / inc-{id} exist for settlement tracking (BQL queries match settlements to source entries by these links). They duplicate the id but are not the identity source.
  • A user-supplied reference (invoice number, receipt id) becomes its own sanitized link, verbatim — never fused with the entry id, never displacing a system link. Two entries sharing a reference share the link (desired Beancount semantics).
  • ln-{payment_hash[:16]} links mark Lightning payments.
  • Legacy ledger history (pre-dfdcc44) carries a single libra-{id} link and no entry-id metadata — _extract_entry_id() falls back to parsing it. Do not write libra- links in new code.

Validation is performed in core/validation.py:

  • Pure validation functions for entry correctness before submitting to Fava

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 accounts and entry_lines tables (Fava is source of truth)
  • Added fava_client.py and beancount_format.py modules
  • 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

  1. LNbits: This extension must be installed in the lnbits/extensions/ directory
  2. 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
    
  3. Configure Libra Settings: Set fava_url and fava_ledger_slug via 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:

  1. Modify code in lnbits/extensions/libra/
  2. Restart LNbits
  3. 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.

  • docs/README.md - User-facing overview
  • docs/DOCUMENTATION.md - Comprehensive technical documentation
  • docs/BEANCOUNT_PATTERNS.md - Beancount-inspired design patterns
  • docs/PHASE1_COMPLETE.md, PHASE2_COMPLETE.md, PHASE3_COMPLETE.md - Development milestones
  • docs/EXPENSE_APPROVAL.md - Manual payment request workflow
  • docs/DAILY_RECONCILIATION.md - Automated reconciliation system