From 9a1893c54695a098e19aeb65a6e94f560b8c3410 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 25 Apr 2026 19:08:13 +0200 Subject: [PATCH 01/40] Fix startup: load Fava settings from DB instead of hardcoded defaults castle_start() was using CastleSettings() defaults (slug=castle-ledger) instead of reading the saved settings from the database. This caused all Fava queries to 404 on instances where the ledger slug differs from the default (e.g. demo-ledger). Now loads settings from extension_settings table at startup, falling back to defaults only if no saved settings exist. Co-Authored-By: Claude Opus 4.6 (1M context) --- __init__.py | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/__init__.py b/__init__.py index 6209e9d..438d0f1 100644 --- a/__init__.py +++ b/__init__.py @@ -38,16 +38,35 @@ def castle_start(): from .models import CastleSettings from .tasks import wait_for_account_sync - # Initialize Fava client with default settings - # (Will be re-initialized if admin updates settings) - defaults = CastleSettings() - try: + async def _init_fava(): + """Load saved settings from DB, fall back to defaults.""" + from .crud import db as castle_db + + settings = None + try: + row = await castle_db.fetchone( + "SELECT * FROM extension_settings LIMIT 1", + model=CastleSettings, + ) + if row: + settings = row + logger.info(f"Loaded Castle settings from DB: {settings.fava_url}/{settings.fava_ledger_slug}") + except Exception as e: + logger.warning(f"Could not load settings from DB: {e}") + + if not settings: + settings = CastleSettings() + logger.info(f"Using default Castle settings: {settings.fava_url}/{settings.fava_ledger_slug}") + init_fava_client( - fava_url=defaults.fava_url, - ledger_slug=defaults.fava_ledger_slug, - timeout=defaults.fava_timeout + fava_url=settings.fava_url, + ledger_slug=settings.fava_ledger_slug, + timeout=settings.fava_timeout ) - logger.info(f"Fava client initialized: {defaults.fava_url}/{defaults.fava_ledger_slug}") + logger.info(f"Fava client initialized: {settings.fava_url}/{settings.fava_ledger_slug}") + + try: + asyncio.get_event_loop().create_task(_init_fava()) except Exception as e: logger.error(f"Failed to initialize Fava client: {e}") logger.warning("Castle will not function without Fava. Please configure Fava settings.") From 9c577c740c872dc7f60fac1d050bba1652b6493d Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 5 May 2026 08:51:16 +0200 Subject: [PATCH 02/40] Add account sync button to super user toolbar Wires the existing POST /api/v1/admin/accounts/sync endpoint into the Castle index toolbar (sync icon between permissions and settings). Surfaces sync stats (added/reactivated/deactivated/virtual_parents/errors) via a Quasar notification and refreshes the accounts list on success. Co-Authored-By: Claude Opus 4.7 (1M context) --- static/js/index.js | 27 +++++++++++++++++++++++++++ templates/castle/index.html | 3 +++ 2 files changed, 30 insertions(+) diff --git a/static/js/index.js b/static/js/index.js index bd52e39..4cc5c06 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -34,6 +34,7 @@ window.app = Vue.createApp({ settingsLoaded: false, // Flag to prevent race conditions on toolbar buttons castleWalletConfigured: false, userWalletConfigured: false, + syncingAccounts: false, currentExchangeRate: null, // BTC/EUR rate (sats per EUR) expenseDialog: { show: false, @@ -539,6 +540,32 @@ window.app = Vue.createApp({ this.userWalletConfigured = false } }, + async syncAccounts() { + this.syncingAccounts = true + try { + const {data} = await LNbits.api.request( + 'POST', + '/castle/api/v1/admin/accounts/sync', + this.g.user.wallets[0].adminkey + ) + const errors = (data?.errors || []).length + const message = `Synced: ${data?.accounts_added ?? 0} added, ` + + `${data?.accounts_reactivated ?? 0} reactivated, ` + + `${data?.accounts_deactivated ?? 0} deactivated, ` + + `${data?.virtual_parents_created ?? 0} virtual parents` + + (errors ? `, ${errors} errors` : '') + this.$q.notify({ + type: errors ? 'warning' : 'positive', + message, + timeout: errors ? 8000 : 4000 + }) + await this.loadAccounts() + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.syncingAccounts = false + } + }, showSettingsDialog() { this.settingsDialog.castleWalletId = this.settings?.castle_wallet_id || '' this.settingsDialog.favaUrl = this.settings?.fava_url || 'http://localhost:3333' diff --git a/templates/castle/index.html b/templates/castle/index.html index 2a1e665..98b1625 100644 --- a/templates/castle/index.html +++ b/templates/castle/index.html @@ -24,6 +24,9 @@ Manage Permissions (Admin) + + Sync Accounts from Beancount + Castle Settings (Super User Only) From c174cda48d9db0ace9a26353d71e6a1aab91f3ba Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 5 May 2026 10:24:46 +0200 Subject: [PATCH 03/40] Rename Castle Accounting extension to Libra MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full identifier rename: module path lnbits.extensions.castle → lnbits.extensions.libra, DB ext_castle → ext_libra, URL prefix /castle/ → /libra/, manifest id castle → libra, fava ledger slug default castle-ledger → libra-ledger, Beancount source metadata castle-api → libra-api and link prefixes castle-{entry,tx}- → libra-{entry,tx}-, column castle_wallet_id → libra_wallet_id, all Python/JS/HTML identifiers (castle_ext, CastleSettings, castle_reference, castleWalletConfigured, etc.). Display name "Castle Accounting" → "Libra" (the scales/balance metaphor — fits double-entry bookkeeping). No backward compat: production hosts will be force-updated. Old castle-prefixed Beancount metadata in existing Fava ledgers is historical; new entries use libra-* prefixes going forward. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 78 ++-- MIGRATION_SQUASH_SUMMARY.md | 46 +- README.md | 20 +- __init__.py | 46 +- account_sync.py | 70 ++-- account_utils.py | 4 +- auth.py | 8 +- beancount_format.py | 38 +- config.json | 8 +- core/__init__.py | 2 +- core/validation.py | 4 +- crud.py | 64 +-- description.md | 18 +- ...CCOUNT-SYNC-AND-PERMISSION-IMPROVEMENTS.md | 72 ++-- docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.html | 40 +- docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.md | 40 +- docs/BEANCOUNT_PATTERNS.md | 74 ++-- docs/BQL-BALANCE-QUERIES.md | 8 +- docs/BQL-PRICE-NOTATION-SOLUTION.md | 6 +- docs/DAILY_RECONCILIATION.md | 42 +- docs/DOCUMENTATION.md | 156 +++---- docs/EXPENSE_APPROVAL.md | 10 +- docs/PERMISSIONS-SYSTEM.md | 8 +- docs/PHASE2_COMPLETE.md | 18 +- docs/PHASE3_COMPLETE.md | 38 +- docs/SATS-EQUIVALENT-METADATA.md | 24 +- docs/UI-IMPROVEMENTS-PLAN.md | 12 +- fava_client.py | 30 +- helper/README.md | 26 +- helper/import_beancount.py | 82 ++-- manifest.json | 4 +- migrations.py | 24 +- models.py | 24 +- package.json | 2 +- permission_management.py | 4 +- services.py | 22 +- static/image/{castle.png => libra.png} | Bin static/js/index.js | 144 +++---- static/js/permissions.js | 38 +- tasks.py | 64 +-- templates/{castle => libra}/index.html | 74 ++-- templates/{castle => libra}/permissions.html | 2 +- views.py | 16 +- views_api.py | 396 +++++++++--------- 44 files changed, 953 insertions(+), 953 deletions(-) rename static/image/{castle.png => libra.png} (100%) rename templates/{castle => libra}/index.html (95%) rename templates/{castle => libra}/permissions.html (99%) diff --git a/CLAUDE.md b/CLAUDE.md index 3086441..b58591c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Castle Accounting 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. +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 @@ -12,9 +12,9 @@ Castle Accounting is a double-entry bookkeeping extension for LNbits that enable **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**: Castle now uses [Fava](https://github.com/beancount/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. Castle formats transactions as Beancount entries and submits them via Fava's API. +**Fava/Beancount Backend**: Libra now uses [Fava](https://github.com/beancount/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 Castle settings (default: `http://localhost:3333` with slug `castle-accounting`). Castle will not function without Fava. +**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 @@ -44,12 +44,12 @@ Castle Accounting is a double-entry bookkeeping extension for LNbits that enable - `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 Castle entries to Beancount transaction format +- `beancount_format.py` - Converts Libra entries to Beancount transaction format - `core/validation.py` - Pure validation functions for accounting rules ### Database Schema -**Note**: With Fava integration, Castle maintains a local cache of some data but delegates authoritative balance calculations to Beancount/Fava. +**Note**: With Fava integration, Libra maintains a local cache of some data but delegates authoritative balance calculations to Beancount/Fava. **journal_entries**: Transaction headers stored locally and synced to Fava - `flag` field: `*` (cleared), `!` (pending), `#` (flagged), `x` (void) @@ -57,10 +57,10 @@ Castle Accounting is a double-entry bookkeeping extension for LNbits that enable - `reference` field: Links to payment_hash, invoice numbers, etc. - Enriched with `username` field when retrieved via API (added from LNbits user data) -**extension_settings**: Castle wallet configuration (admin-only) -- `castle_wallet_id` - The LNbits wallet used for Castle operations +**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: castle-accounting) +- `fava_ledger_slug` - Ledger identifier in Fava (default: libra-accounting) - `fava_timeout` - API request timeout in seconds **user_wallet_settings**: Per-user wallet configuration @@ -70,22 +70,22 @@ Castle Accounting is a double-entry bookkeeping extension for LNbits that enable ## Transaction Flows ### User Adds Expense (Liability) -User pays cash for groceries, Castle owes them: +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"}` -### Castle Adds Receivable -User owes Castle for accommodation: +### 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 **Castle's wallet** (not user's). After payment: +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 @@ -101,14 +101,14 @@ DR Liabilities:Payable:User-af983632 39,669 sats ## Balance Calculation Logic **User Balance** (calculated by Beancount via Fava): -- Positive = Castle owes user (LIABILITY accounts have credit balance) -- Negative = User owes Castle (ASSET accounts have debit balance) +- 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 = Castle owes them, Red = They owe Castle -- **Castle Admin View**: Green = User owes Castle, Red = Castle owes user +- **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. @@ -127,12 +127,12 @@ DR Liabilities:Payable:User-af983632 39,669 sats - `POST /api/v1/entries` - Create raw journal entry (admin only) ### Payments & Balances -- `GET /api/v1/balance` - Get user balance (or Castle total if super user) +- `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 Castle -- `POST /api/v1/record-payment` - Record Lightning payment from user to Castle +- `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` - Castle pays user (cash/bank/lightning) +- `POST /api/v1/pay-user` - Libra pays user (cash/bank/lightning) ### Manual Payment Requests - `POST /api/v1/manual-payment-requests` - User requests payment @@ -148,8 +148,8 @@ DR Liabilities:Payable:User-af983632 39,669 sats - `POST /api/v1/tasks/daily-reconciliation` - Run daily reconciliation (admin) ### Settings -- `GET /api/v1/settings` - Get Castle settings (super user) -- `PUT /api/v1/settings` - Update Castle settings (super user) +- `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 @@ -213,7 +213,7 @@ entry = format_transaction( {"account": "Liabilities:Payable:User-abc123", "amount": "-50000 SATS"} ], tags=["groceries"], - links=["castle-entry-123"] + links=["libra-entry-123"] ) # Submit to Fava @@ -241,15 +241,15 @@ balance_result = await client.query( ### Extension as LNbits Module This extension follows LNbits extension structure: -- Registered via `castle_ext` router in `__init__.py` +- Registered via `libra_ext` router in `__init__.py` - Static files served from `static/` directory -- Templates in `templates/castle/` -- Database accessed via `db = Database("ext_castle")` +- Templates in `templates/libra/` +- Database accessed via `db = Database("ext_libra")` **Startup Requirements**: -- `castle_start()` initializes Fava client on extension load +- `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 Castle extension +- Fava service MUST be running before starting LNbits with Libra extension ## Common Tasks @@ -282,7 +282,7 @@ entry = format_transaction( {"account": "Assets:Lightning:Balance", "amount": "-50000 SATS"} ], tags=["utilities"], - links=["castle-tx-123"] + links=["libra-tx-123"] ) client = get_fava_client() @@ -337,24 +337,24 @@ result = await client.query(query) ### Prerequisites 1. **LNbits**: This extension must be installed in the `lnbits/extensions/` directory -2. **Fava Service**: Must be running before starting LNbits with Castle enabled +2. **Fava Service**: Must be running before starting LNbits with Libra enabled ```bash # Install Fava pip install fava # Create a basic Beancount file - touch castle-ledger.beancount + touch libra-ledger.beancount # Start Fava (default: http://localhost:3333) - fava castle-ledger.beancount + fava libra-ledger.beancount ``` -3. **Configure Castle Settings**: Set `fava_url` and `fava_ledger_slug` via settings API or UI +3. **Configure Libra Settings**: Set `fava_url` and `fava_ledger_slug` via settings API or UI -### Running Castle Extension +### Running Libra Extension -Castle is loaded as part of LNbits. No separate build or test commands are needed for the extension itself. Development workflow: +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/castle/` +1. Modify code in `lnbits/extensions/libra/` 2. Restart LNbits 3. Extension hot-reloads are supported by LNbits in development mode @@ -363,13 +363,13 @@ Castle is loaded as part of LNbits. No separate build or test commands are neede Use the web UI or API endpoints to create test transactions. For API testing: ```bash -# Create expense (user owes Castle) -curl -X POST http://localhost:5000/castle/api/v1/entries/expense \ +# 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/castle/api/v1/balance \ +curl http://localhost:5000/libra/api/v1/balance \ -H "X-Api-Key: YOUR_INVOICE_KEY" ``` diff --git a/MIGRATION_SQUASH_SUMMARY.md b/MIGRATION_SQUASH_SUMMARY.md index 8d86b9b..4b03ed0 100644 --- a/MIGRATION_SQUASH_SUMMARY.md +++ b/MIGRATION_SQUASH_SUMMARY.md @@ -1,11 +1,11 @@ -# Castle Migration Squash Summary +# Libra Migration Squash Summary **Date:** November 10, 2025 **Action:** Squashed 16 incremental migrations into a single clean initial migration ## Overview -The Castle extension had accumulated 16 migrations (m001-m016) during development. Since the software has not been released yet, we safely squashed all migrations into a single clean `m001_initial` migration. +The Libra extension had accumulated 16 migrations (m001-m016) during development. Since the software has not been released yet, we safely squashed all migrations into a single clean `m001_initial` migration. ## Files Changed @@ -16,37 +16,37 @@ The Castle extension had accumulated 16 migrations (m001-m016) during developmen The squashed migration creates **7 tables**: -### 1. castle_accounts +### 1. libra_accounts - Core chart of accounts with hierarchical Beancount-style names - Examples: "Assets:Bitcoin:Lightning", "Expenses:Food:Groceries" - User-specific accounts: "Assets:Receivable:User-af983632" - Includes comprehensive default account set (40+ accounts) -### 2. castle_extension_settings -- Castle-wide configuration -- Stores castle_wallet_id for Lightning payments +### 2. libra_extension_settings +- Libra-wide configuration +- Stores libra_wallet_id for Lightning payments -### 3. castle_user_wallet_settings +### 3. libra_user_wallet_settings - Per-user wallet configuration - Allows users to have separate wallet preferences -### 4. castle_manual_payment_requests -- User-submitted payment requests to Castle +### 4. libra_manual_payment_requests +- User-submitted payment requests to Libra - Reviewed by admins before processing - Includes notes field for additional context -### 5. castle_balance_assertions +### 5. libra_balance_assertions - Reconciliation and balance checking at specific dates - Multi-currency support (satoshis + fiat) - Tolerance checking for small discrepancies - Includes notes field for reconciliation comments -### 6. castle_user_equity_status +### 6. libra_user_equity_status - Manages equity contribution eligibility - Equity-eligible users can convert expenses to equity - Creates dynamic user-specific equity accounts: Equity:User-{user_id} -### 7. castle_account_permissions +### 7. libra_account_permissions - Granular access control for accounts - Permission types: read, submit_expense, manage - Supports hierarchical inheritance (parent permissions cascade) @@ -56,10 +56,10 @@ The squashed migration creates **7 tables**: The following tables were **intentionally NOT included** in the final schema (they were dropped in m016): -- **castle_journal_entries** - Journal entries now managed by Fava/Beancount (external source of truth) -- **castle_entry_lines** - Entry lines now managed by Fava/Beancount +- **libra_journal_entries** - Journal entries now managed by Fava/Beancount (external source of truth) +- **libra_entry_lines** - Entry lines now managed by Fava/Beancount -Castle now uses Fava as the single source of truth for accounting data. Journal operations: +Libra now uses Fava as the single source of truth for accounting data. Journal operations: - **Write:** Submit to Fava via FavaClient.add_entry() - **Read:** Query Fava via FavaClient.get_entries() @@ -106,7 +106,7 @@ For reference, the original migration sequence (preserved in migrations_old.py.b For new installations: ```bash -# Castle's migration system will run m001_initial automatically +# Libra's migration system will run m001_initial automatically # No manual intervention needed ``` @@ -174,20 +174,20 @@ After squashing, verify the migration works: ```bash # 1. Backup existing database (if any) -cp castle.sqlite3 castle.sqlite3.backup +cp libra.sqlite3 libra.sqlite3.backup # 2. Drop and recreate database to test fresh install -rm castle.sqlite3 +rm libra.sqlite3 # 3. Start LNbits - migration should run automatically poetry run lnbits # 4. Verify tables created -sqlite3 castle.sqlite3 ".tables" -# Should show: castle_accounts, castle_extension_settings, etc. +sqlite3 libra.sqlite3 ".tables" +# Should show: libra_accounts, libra_extension_settings, etc. # 5. Verify default accounts -sqlite3 castle.sqlite3 "SELECT COUNT(*) FROM castle_accounts;" +sqlite3 libra.sqlite3 "SELECT COUNT(*) FROM libra_accounts;" # Should show: 40 (default accounts) ``` @@ -200,12 +200,12 @@ If issues are discovered: cp migrations_old.py.bak migrations.py # Restore database -cp castle.sqlite3.backup castle.sqlite3 +cp libra.sqlite3.backup libra.sqlite3 ``` ## Notes -- This squash is safe because Castle has not been released yet +- This squash is safe because Libra has not been released yet - No existing production databases need migration - Historical migrations preserved in migrations_old.py.bak - All functionality preserved in final schema diff --git a/README.md b/README.md index 6174bfb..f67e15c 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# Castle Accounting Extension for LNbits +# Libra Extension for LNbits A full-featured double-entry accounting system for collective projects, integrated with LNbits Lightning payments. ## Overview -Castle Accounting enables collectives like co-living spaces, makerspaces, and community projects to: +Libra enables collectives like co-living spaces, makerspaces, and community projects to: - Track expenses and revenue with proper accounting - Manage individual member balances - Record contributions as equity or reimbursable expenses @@ -17,7 +17,7 @@ This extension is designed to be installed in the `lnbits/extensions/` directory ```bash cd lnbits/extensions/ -# Copy or clone the castle directory here +# Copy or clone the libra directory here ``` Enable the extension through the LNbits admin interface or by adding it to your configuration. @@ -30,7 +30,7 @@ Enable the extension through the LNbits admin interface or by adding it to your - Choose "Liability" if you want reimbursement - Choose "Equity" if it's a contribution -2. **View Your Balance**: See if the Castle owes you money or vice versa +2. **View Your Balance**: See if the Libra owes you money or vice versa 3. **Pay Outstanding Balance**: Generate a Lightning invoice to settle what you owe @@ -54,8 +54,8 @@ Enable the extension through the LNbits admin interface or by adding it to your ### Account Types -- **Assets**: Things the Castle owns (Cash, Bank, Accounts Receivable) -- **Liabilities**: What the Castle owes (Accounts Payable to members) +- **Assets**: Things the Libra owns (Cash, Bank, Accounts Receivable) +- **Liabilities**: What the Libra owes (Accounts Payable to members) - **Equity**: Member contributions and retained earnings - **Revenue**: Income streams - **Expenses**: Operating costs @@ -63,9 +63,9 @@ Enable the extension through the LNbits admin interface or by adding it to your ### Database Schema The extension creates three tables: -- `castle.accounts` - Chart of accounts -- `castle.journal_entries` - Transaction headers -- `castle.entry_lines` - Debit/credit lines +- `libra.accounts` - Chart of accounts +- `libra.journal_entries` - Transaction headers +- `libra.entry_lines` - Debit/credit lines ## API Reference @@ -79,7 +79,7 @@ To modify this extension: 2. Add database migrations in `migrations.py` 3. Implement business logic in `crud.py` 4. Create API endpoints in `views_api.py` -5. Update UI in `templates/castle/index.html` +5. Update UI in `templates/libra/index.html` ## Contributing diff --git a/__init__.py b/__init__.py index 438d0f1..614a8ba 100644 --- a/__init__.py +++ b/__init__.py @@ -5,24 +5,24 @@ from loguru import logger from .crud import db from .tasks import wait_for_paid_invoices -from .views import castle_generic_router -from .views_api import castle_api_router +from .views import libra_generic_router +from .views_api import libra_api_router -castle_ext: APIRouter = APIRouter(prefix="/castle", tags=["Castle"]) -castle_ext.include_router(castle_generic_router) -castle_ext.include_router(castle_api_router) +libra_ext: APIRouter = APIRouter(prefix="/libra", tags=["Libra"]) +libra_ext.include_router(libra_generic_router) +libra_ext.include_router(libra_api_router) -castle_static_files = [ +libra_static_files = [ { - "path": "/castle/static", - "name": "castle_static", + "path": "/libra/static", + "name": "libra_static", } ] scheduled_tasks: list[asyncio.Task] = [] -def castle_stop(): +def libra_stop(): """Clean up background tasks on extension shutdown""" for task in scheduled_tasks: try: @@ -31,32 +31,32 @@ def castle_stop(): logger.warning(ex) -def castle_start(): - """Initialize Castle extension background tasks""" +def libra_start(): + """Initialize Libra extension background tasks""" from lnbits.tasks import create_permanent_unique_task from .fava_client import init_fava_client - from .models import CastleSettings + from .models import LibraSettings from .tasks import wait_for_account_sync async def _init_fava(): """Load saved settings from DB, fall back to defaults.""" - from .crud import db as castle_db + from .crud import db as libra_db settings = None try: - row = await castle_db.fetchone( + row = await libra_db.fetchone( "SELECT * FROM extension_settings LIMIT 1", - model=CastleSettings, + model=LibraSettings, ) if row: settings = row - logger.info(f"Loaded Castle settings from DB: {settings.fava_url}/{settings.fava_ledger_slug}") + logger.info(f"Loaded Libra settings from DB: {settings.fava_url}/{settings.fava_ledger_slug}") except Exception as e: logger.warning(f"Could not load settings from DB: {e}") if not settings: - settings = CastleSettings() - logger.info(f"Using default Castle settings: {settings.fava_url}/{settings.fava_ledger_slug}") + settings = LibraSettings() + logger.info(f"Using default Libra settings: {settings.fava_url}/{settings.fava_ledger_slug}") init_fava_client( fava_url=settings.fava_url, @@ -69,16 +69,16 @@ def castle_start(): asyncio.get_event_loop().create_task(_init_fava()) except Exception as e: logger.error(f"Failed to initialize Fava client: {e}") - logger.warning("Castle will not function without Fava. Please configure Fava settings.") + logger.warning("Libra will not function without Fava. Please configure Fava settings.") # Start background tasks - task = create_permanent_unique_task("ext_castle", wait_for_paid_invoices) + task = create_permanent_unique_task("ext_libra", wait_for_paid_invoices) scheduled_tasks.append(task) # Start account sync task (runs hourly) - sync_task = create_permanent_unique_task("ext_castle_account_sync", wait_for_account_sync) + sync_task = create_permanent_unique_task("ext_libra_account_sync", wait_for_account_sync) scheduled_tasks.append(sync_task) - logger.info("Castle account sync task started (runs hourly)") + logger.info("Libra account sync task started (runs hourly)") -__all__ = ["castle_ext", "castle_static_files", "db", "castle_start", "castle_stop"] +__all__ = ["libra_ext", "libra_static_files", "db", "libra_start", "libra_stop"] diff --git a/account_sync.py b/account_sync.py index 95fe41c..7e875f8 100644 --- a/account_sync.py +++ b/account_sync.py @@ -1,11 +1,11 @@ """ Account Synchronization Module -Syncs accounts from Beancount (source of truth) to Castle DB (metadata store). +Syncs accounts from Beancount (source of truth) to Libra DB (metadata store). This implements the hybrid approach: - Beancount owns account existence (Open directives) -- Castle DB stores permissions and user associations +- Libra DB stores permissions and user associations - Background sync keeps them in sync Related: ACCOUNTS-TABLE-REMOVAL-FEASIBILITY.md - Phase 2 implementation @@ -89,14 +89,14 @@ def extract_user_id_from_account_name(account_name: str) -> Optional[str]: async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: """ - Sync accounts from Beancount to Castle DB. + Sync accounts from Beancount to Libra DB. - This ensures Castle DB has metadata entries for all accounts that exist + This ensures Libra DB has metadata entries for all accounts that exist in Beancount, enabling permissions and user associations to work properly. New behavior (soft delete + virtual parents): - - Accounts in Beancount but not in Castle DB: Added as active - - Accounts in Castle DB but not in Beancount: Marked as inactive (soft delete) + - Accounts in Beancount but not in Libra DB: Added as active + - Accounts in Libra DB but not in Beancount: Marked as inactive (soft delete) - Inactive accounts that return to Beancount: Reactivated - Missing intermediate parents: Auto-created as virtual accounts @@ -113,7 +113,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: dict with sync statistics: { "total_beancount_accounts": 150, - "total_castle_accounts": 148, + "total_libra_accounts": 148, "accounts_added": 2, "accounts_updated": 0, "accounts_skipped": 148, @@ -123,7 +123,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: "errors": [] } """ - logger.info("Starting account sync from Beancount to Castle DB") + logger.info("Starting account sync from Beancount to Libra DB") fava = get_fava_client() @@ -134,7 +134,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: logger.error(f"Failed to fetch accounts from Beancount: {e}") return { "total_beancount_accounts": 0, - "total_castle_accounts": 0, + "total_libra_accounts": 0, "accounts_added": 0, "accounts_updated": 0, "accounts_skipped": 0, @@ -143,16 +143,16 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: "errors": [str(e)], } - # Get all accounts from Castle DB (including inactive ones for sync) - castle_accounts = await get_all_accounts(include_inactive=True) + # Get all accounts from Libra DB (including inactive ones for sync) + libra_accounts = await get_all_accounts(include_inactive=True) # Build lookup maps beancount_account_names = {acc["account"] for acc in beancount_accounts} - castle_accounts_by_name = {acc.name: acc for acc in castle_accounts} + libra_accounts_by_name = {acc.name: acc for acc in libra_accounts} stats = { "total_beancount_accounts": len(beancount_accounts), - "total_castle_accounts": len(castle_accounts), + "total_libra_accounts": len(libra_accounts), "accounts_added": 0, "accounts_updated": 0, "accounts_skipped": 0, @@ -162,15 +162,15 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: "errors": [], } - # Step 1: Sync accounts from Beancount to Castle DB + # Step 1: Sync accounts from Beancount to Libra DB for bc_account in beancount_accounts: account_name = bc_account["account"] try: - existing = castle_accounts_by_name.get(account_name) + existing = libra_accounts_by_name.get(account_name) if existing: - # Account exists in Castle DB + # Account exists in Libra DB # Check if it needs to be reactivated if not existing.is_active: await update_account_is_active(existing.id, True) @@ -181,7 +181,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: logger.debug(f"Account already active: {account_name}") continue - # Create new account in Castle DB + # Create new account in Libra DB account_type = infer_account_type_from_name(account_name) user_id = extract_user_id_from_account_name(account_name) @@ -207,25 +207,25 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: logger.error(error_msg) stats["errors"].append(error_msg) - # Step 2: Mark orphaned accounts (in Castle DB but not in Beancount) as inactive + # Step 2: Mark orphaned accounts (in Libra DB but not in Beancount) as inactive # SKIP virtual accounts (they're intentionally metadata-only) - for castle_account in castle_accounts: - if castle_account.is_virtual: + for libra_account in libra_accounts: + if libra_account.is_virtual: # Virtual accounts are metadata-only, never deactivate them continue - if castle_account.name not in beancount_account_names: + if libra_account.name not in beancount_account_names: # Account no longer exists in Beancount - if castle_account.is_active: + if libra_account.is_active: try: - await update_account_is_active(castle_account.id, False) + await update_account_is_active(libra_account.id, False) stats["accounts_deactivated"] += 1 logger.info( - f"Deactivated orphaned account: {castle_account.name}" + f"Deactivated orphaned account: {libra_account.name}" ) except Exception as e: error_msg = ( - f"Failed to deactivate account {castle_account.name}: {e}" + f"Failed to deactivate account {libra_account.name}: {e}" ) logger.error(error_msg) stats["errors"].append(error_msg) @@ -236,8 +236,8 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: # IMPORTANT: Re-fetch accounts from DB after Step 1 added new accounts # Otherwise we'll be checking against stale data and miss newly synced children - current_castle_accounts = await get_all_accounts(include_inactive=True) - all_account_names = {acc.name for acc in current_castle_accounts} + current_libra_accounts = await get_all_accounts(include_inactive=True) + all_account_names = {acc.name for acc in current_libra_accounts} for bc_account in beancount_accounts: account_name = bc_account["account"] @@ -287,9 +287,9 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: async def sync_single_account_from_beancount(account_name: str) -> bool: """ - Sync a single account from Beancount to Castle DB. + Sync a single account from Beancount to Libra DB. - Useful for ensuring a specific account exists in Castle DB before + Useful for ensuring a specific account exists in Libra DB before granting permissions on it. Args: @@ -318,7 +318,7 @@ async def sync_single_account_from_beancount(account_name: str) -> bool: logger.error(f"Account not found in Beancount: {account_name}") return False - # Create in Castle DB + # Create in Libra DB account_type = infer_account_type_from_name(account_name) user_id = extract_user_id_from_account_name(account_name) @@ -343,9 +343,9 @@ async def sync_single_account_from_beancount(account_name: str) -> bool: return False -async def ensure_account_exists_in_castle(account_name: str) -> bool: +async def ensure_account_exists_in_libra(account_name: str) -> bool: """ - Ensure account exists in Castle DB, creating from Beancount if needed. + Ensure account exists in Libra DB, creating from Beancount if needed. This is the recommended function to call before granting permissions. @@ -355,7 +355,7 @@ async def ensure_account_exists_in_castle(account_name: str) -> bool: Returns: True if account exists (or was created), False if failed """ - # Check Castle DB first + # Check Libra DB first existing = await get_account_by_name(account_name) if existing: return True @@ -367,9 +367,9 @@ async def ensure_account_exists_in_castle(account_name: str) -> bool: # Background sync task (can be scheduled with cron or async scheduler) async def scheduled_account_sync(): """ - Scheduled task to sync accounts from Beancount to Castle DB. + Scheduled task to sync accounts from Beancount to Libra DB. - Run this periodically (e.g., every hour) to keep Castle DB in sync with Beancount. + Run this periodically (e.g., every hour) to keep Libra DB in sync with Beancount. Example with APScheduler: from apscheduler.schedulers.asyncio import AsyncIOScheduler diff --git a/account_utils.py b/account_utils.py index 46db327..7e71630 100644 --- a/account_utils.py +++ b/account_utils.py @@ -200,11 +200,11 @@ DEFAULT_HIERARCHICAL_ACCOUNTS = [ ("Assets:FixedAssets:ProductionFacility", AccountType.ASSET, "Production facilities"), ("Assets:Inventory", AccountType.ASSET, "Inventory and stock"), ("Assets:Livestock", AccountType.ASSET, "Livestock and animals"), - ("Assets:Receivable", AccountType.ASSET, "Money owed to the Castle"), + ("Assets:Receivable", AccountType.ASSET, "Money owed to the Libra"), ("Assets:Tools", AccountType.ASSET, "Tools and hand equipment"), # Liabilities - ("Liabilities:Payable", AccountType.LIABILITY, "Money owed by the Castle"), + ("Liabilities:Payable", AccountType.LIABILITY, "Money owed by the Libra"), # Equity - User equity accounts created dynamically as Equity:User-{user_id} # No parent "Equity" account needed - hierarchy is implicit in the name diff --git a/auth.py b/auth.py index b729347..8cc9c17 100644 --- a/auth.py +++ b/auth.py @@ -1,5 +1,5 @@ """ -Centralized Authorization Module for Castle Extension. +Centralized Authorization Module for Libra Extension. Provides consistent, secure authorization patterns across all endpoints. @@ -55,9 +55,9 @@ class AuthContext: @property def is_admin(self) -> bool: """ - Check if user is a Castle admin (super user). + Check if user is a Libra admin (super user). - Note: In Castle, admin = super_user. There's no separate admin concept. + Note: In Libra, admin = super_user. There's no separate admin concept. """ return self.is_super_user @@ -130,7 +130,7 @@ async def require_super_user( Require super user access. Raises HTTPException 403 if not super user. - Use for Castle admin operations. + Use for Libra admin operations. """ auth = _build_auth_context(wallet) if not auth.is_super_user: diff --git a/beancount_format.py b/beancount_format.py index 74ba53c..956a4ee 100644 --- a/beancount_format.py +++ b/beancount_format.py @@ -1,8 +1,8 @@ """ -Format Castle entries as Beancount transactions for Fava API. +Format Libra entries as Beancount transactions for Fava API. All entries submitted to Fava must follow Beancount syntax. -This module converts Castle data models to Fava API format. +This module converts Libra data models to Fava API format. Key concepts: - Amounts are strings: "200000 SATS" or "100.00 EUR" @@ -35,8 +35,8 @@ def sanitize_link(text: str) -> str: 'Test-pending' >>> sanitize_link("Invoice #123") 'Invoice-123' - >>> sanitize_link("castle-abc123") - 'castle-abc123' + >>> sanitize_link("libra-abc123") + 'libra-abc123' """ # Replace any character that's not alphanumeric, dash, underscore, slash, or period with a hyphen sanitized = re.sub(r'[^A-Za-z0-9\-_/.]', '-', text) @@ -67,7 +67,7 @@ def format_transaction( postings: List of posting dicts (formatted by format_posting) payee: Optional payee tags: Optional tags (e.g., ["expense-entry", "approved"]) - links: Optional links (e.g., ["castle-abc123", "^invoice-xyz"]) + links: Optional links (e.g., ["libra-abc123", "^invoice-xyz"]) meta: Optional transaction metadata Returns: @@ -93,8 +93,8 @@ def format_transaction( ) ], tags=["expense-entry"], - links=["castle-abc123"], - meta={"user-id": "abc123", "source": "castle-expense-entry"} + links=["libra-abc123"], + meta={"user-id": "abc123", "source": "libra-expense-entry"} ) """ return { @@ -150,7 +150,7 @@ def format_posting_with_cost( """ Format a posting with cost basis for Fava API. - This is the RECOMMENDED format for all Castle transactions. + This is the RECOMMENDED format for all Libra transactions. Uses Beancount's cost basis syntax to preserve exchange rates. IMPORTANT: Beancount cost syntax uses PER-UNIT cost, not total cost. @@ -381,7 +381,7 @@ def format_expense_entry( # Build entry metadata entry_meta = { "user-id": user_id, - "source": "castle-api", + "source": "libra-api", "entry-id": entry_id } @@ -419,7 +419,7 @@ def format_receivable_entry( entry_id: Optional[str] = None ) -> Dict[str, Any]: """ - Format a receivable entry (user owes castle). + Format a receivable entry (user owes libra). Uses price notation (@@ SATS) for BQL-queryable SATS tracking. Generates unique receivable link (^rcv-{entry_id}) for settlement tracking. @@ -466,7 +466,7 @@ def format_receivable_entry( entry_meta = { "user-id": user_id, - "source": "castle-api", + "source": "libra-api", "entry-id": entry_id } @@ -512,7 +512,7 @@ def format_payment_entry( amount_sats: Amount in satoshis (unsigned) description: Payment description entry_date: Date of payment - is_payable: True if castle paying user (payable), False if user paying castle (receivable) + is_payable: True if libra paying user (payable), False if user paying libra (receivable) fiat_currency: Optional fiat currency fiat_amount: Optional fiat amount (unsigned) payment_hash: Lightning payment hash @@ -531,7 +531,7 @@ def format_payment_entry( # Example: 908.44 EUR / 996896 SATS = 0.000911268 EUR/SAT (matches original receivable rate) if fiat_currency and fiat_amount_abs and amount_sats_abs > 0: if is_payable: - # Castle paying user: DR Payable, CR Lightning + # Libra paying user: DR Payable, CR Lightning postings = [ format_posting_with_cost( account=payable_or_receivable_account, @@ -546,7 +546,7 @@ def format_payment_entry( ) ] else: - # User paying castle: DR Lightning, CR Receivable + # User paying libra: DR Lightning, CR Receivable postings = [ format_posting_simple( account=payment_account, @@ -633,7 +633,7 @@ def format_fiat_settlement_entry( amount_sats: Equivalent amount in satoshis description: Payment description entry_date: Date of settlement - is_payable: True if castle paying user (payable), False if user paying castle (receivable) + is_payable: True if libra paying user (payable), False if user paying libra (receivable) payment_method: Payment method (cash, bank_transfer, check, etc.) reference: Optional reference settled_entry_links: List of expense/receivable links being settled (e.g., ["exp-abc123", "exp-def456"]) @@ -646,7 +646,7 @@ def format_fiat_settlement_entry( # Build postings using price notation (@@ SATS) for BQL queryability if is_payable: - # Castle paying user: DR Payable, CR Cash/Bank + # Libra paying user: DR Payable, CR Cash/Bank postings = [ { "account": payable_or_receivable_account, @@ -658,7 +658,7 @@ def format_fiat_settlement_entry( } ] else: - # User paying castle: DR Cash/Bank, CR Receivable + # User paying libra: DR Cash/Bank, CR Receivable postings = [ { "account": payment_account, @@ -815,7 +815,7 @@ def format_revenue_entry( reference: Optional[str] = None ) -> Dict[str, Any]: """ - Format a revenue entry (castle receives payment directly). + Format a revenue entry (libra receives payment directly). Creates a cleared transaction (flag="*") since payment was received. @@ -869,7 +869,7 @@ def format_revenue_entry( # Note: created-via is redundant with #revenue-entry tag entry_meta = { - "source": "castle-api" + "source": "libra-api" } links = [] diff --git a/config.json b/config.json index b83f290..00c8c50 100644 --- a/config.json +++ b/config.json @@ -1,11 +1,11 @@ { - "name": "Castle Accounting", + "name": "Libra", "short_description": "Double-entry accounting system for collective projects", - "tile": "/castle/static/image/castle.png", + "tile": "/libra/static/image/libra.png", "contributors": [ "Your Name" ], "hidden": false, - "migration_module": "lnbits.extensions.castle.migrations", - "db_name": "ext_castle" + "migration_module": "lnbits.extensions.libra.migrations", + "db_name": "ext_libra" } diff --git a/core/__init__.py b/core/__init__.py index 662bb20..10c362c 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,5 +1,5 @@ """ -Castle Core Module - Pure accounting logic separated from database operations. +Libra Core Module - Pure accounting logic separated from database operations. This module contains the core business logic for double-entry accounting, following Beancount patterns for clean architecture: diff --git a/core/validation.py b/core/validation.py index d2372b8..c29f069 100644 --- a/core/validation.py +++ b/core/validation.py @@ -1,5 +1,5 @@ """ -Validation rules for Castle accounting. +Validation rules for Libra accounting. Comprehensive validation following Beancount's plugin system approach, but implemented as simple functions that can be called directly. @@ -159,7 +159,7 @@ def validate_receivable_entry( revenue_account_type: str ) -> None: """ - Validate a receivable entry (user owes castle). + Validate a receivable entry (user owes libra). Args: user_id: User ID diff --git a/crud.py b/crud.py index cd610b1..b2b43dd 100644 --- a/crud.py +++ b/crud.py @@ -14,7 +14,7 @@ from .models import ( AssertionStatus, AssignUserRole, BalanceAssertion, - CastleSettings, + LibraSettings, CreateAccount, CreateAccountPermission, CreateBalanceAssertion, @@ -32,7 +32,7 @@ from .models import ( StoredUserWalletSettings, UpdateRole, UserBalance, - UserCastleSettings, + UserLibraSettings, UserEquityStatus, UserRole, UserWalletSettings, @@ -49,7 +49,7 @@ from .core.validation import ( validate_payment_entry, ) -db = Database("ext_castle") +db = Database("ext_libra") # ===== CACHING ===== # Cache for account and permission lookups to reduce DB queries @@ -197,7 +197,7 @@ async def get_or_create_user_account( Get or create a user-specific account with hierarchical naming. This function checks if the account exists in Fava/Beancount and creates it - if it doesn't exist. The account is also registered in Castle's database for + if it doesn't exist. The account is also registered in Libra's database for metadata tracking (permissions, descriptions, etc.). Examples: @@ -214,7 +214,7 @@ async def get_or_create_user_account( # Generate hierarchical account name account_name = format_hierarchical_account_name(account_type, base_name, user_id) - # Try to find existing account with this hierarchical name in Castle DB + # Try to find existing account with this hierarchical name in Libra DB account = await db.fetchone( """ SELECT * FROM accounts @@ -224,9 +224,9 @@ async def get_or_create_user_account( Account, ) - logger.info(f"[ACCOUNT CHECK] User {user_id[:8]}, Account: {account_name}, In Castle DB: {account is not None}") + logger.info(f"[ACCOUNT CHECK] User {user_id[:8]}, Account: {account_name}, In Libra DB: {account is not None}") - # Always check/create in Fava, even if account exists in Castle DB + # Always check/create in Fava, even if account exists in Libra DB # This ensures Beancount has the Open directive fava_account_exists = False if True: # Always check Fava @@ -262,23 +262,23 @@ async def get_or_create_user_account( except Exception as e: logger.error(f"[FAVA ERROR] Could not check/create account in Fava: {e}", exc_info=True) - # Continue anyway - account creation in Castle DB is still useful for metadata + # Continue anyway - account creation in Libra DB is still useful for metadata - # Ensure account exists in Castle DB (sync from Beancount if needed) + # Ensure account exists in Libra DB (sync from Beancount if needed) # This uses the account sync module for consistency if not account: - logger.info(f"[CASTLE DB] Syncing account from Beancount to Castle DB: {account_name}") + logger.info(f"[LIBRA DB] Syncing account from Beancount to Libra DB: {account_name}") from .account_sync import sync_single_account_from_beancount - # Sync from Beancount to Castle DB + # Sync from Beancount to Libra DB created = await sync_single_account_from_beancount(account_name) if created: - logger.info(f"[CASTLE DB] Account synced from Beancount: {account_name}") + logger.info(f"[LIBRA DB] Account synced from Beancount: {account_name}") else: - logger.warning(f"[CASTLE DB] Failed to sync account from Beancount: {account_name}") + logger.warning(f"[LIBRA DB] Failed to sync account from Beancount: {account_name}") - # Fetch the account from Castle DB + # Fetch the account from Libra DB account = await db.fetchone( """ SELECT * FROM accounts @@ -289,9 +289,9 @@ async def get_or_create_user_account( ) if not account: - logger.error(f"[CASTLE DB] Account still not found after sync: {account_name}") - # Fallback: create directly in Castle DB if sync failed - logger.info(f"[CASTLE DB] Creating account directly in Castle DB: {account_name}") + logger.error(f"[LIBRA DB] Account still not found after sync: {account_name}") + # Fallback: create directly in Libra DB if sync failed + logger.info(f"[LIBRA DB] Creating account directly in Libra DB: {account_name}") try: account = await create_account( CreateAccount( @@ -304,7 +304,7 @@ async def get_or_create_user_account( except Exception as e: # Handle UNIQUE constraint error - account already exists if "UNIQUE constraint failed" in str(e) and "accounts.name" in str(e): - logger.warning(f"[CASTLE DB] Account already exists (UNIQUE constraint), fetching by name: {account_name}") + logger.warning(f"[LIBRA DB] Account already exists (UNIQUE constraint), fetching by name: {account_name}") # Fetch existing account by name only (ignore user_id in query) account = await db.fetchone( """ @@ -315,10 +315,10 @@ async def get_or_create_user_account( Account, ) if account: - logger.info(f"[CASTLE DB] Found existing account: {account_name} (user_id: {account.user_id})") + logger.info(f"[LIBRA DB] Found existing account: {account_name} (user_id: {account.user_id})") # Update user_id if it's NULL or different if account.user_id != user_id: - logger.info(f"[CASTLE DB] Updating account user_id from {account.user_id} to {user_id}") + logger.info(f"[LIBRA DB] Updating account user_id from {account.user_id} to {user_id}") await db.execute( """ UPDATE accounts @@ -340,7 +340,7 @@ async def get_or_create_user_account( # Re-raise if it's a different error raise else: - logger.info(f"[CASTLE DB] Account already exists in Castle DB: {account_name}") + logger.info(f"[LIBRA DB] Account already exists in Libra DB: {account_name}") return account @@ -351,7 +351,7 @@ async def get_or_create_user_account( # ===== JOURNAL ENTRY OPERATIONS (REMOVED) ===== # # All journal entry operations have been moved to Fava/Beancount. -# Castle no longer maintains its own journal_entries and entry_lines tables. +# Libra no longer maintains its own journal_entries and entry_lines tables. # # For journal entry operations, see: # - views_api.py: api_create_journal_entry() - writes to Fava via FavaClient @@ -375,29 +375,29 @@ async def get_or_create_user_account( # ===== SETTINGS ===== -async def create_castle_settings( - user_id: str, data: CastleSettings -) -> CastleSettings: - settings = UserCastleSettings(**data.dict(), id=user_id) +async def create_libra_settings( + user_id: str, data: LibraSettings +) -> LibraSettings: + settings = UserLibraSettings(**data.dict(), id=user_id) await db.insert("extension_settings", settings) return settings -async def get_castle_settings(user_id: str) -> Optional[CastleSettings]: +async def get_libra_settings(user_id: str) -> Optional[LibraSettings]: return await db.fetchone( """ SELECT * FROM extension_settings WHERE id = :user_id """, {"user_id": user_id}, - CastleSettings, + LibraSettings, ) -async def update_castle_settings( - user_id: str, data: CastleSettings -) -> CastleSettings: - settings = UserCastleSettings(**data.dict(), id=user_id) +async def update_libra_settings( + user_id: str, data: LibraSettings +) -> LibraSettings: + settings = UserLibraSettings(**data.dict(), id=user_id) await db.update("extension_settings", settings) return settings diff --git a/description.md b/description.md index b3628a2..294a3b3 100644 --- a/description.md +++ b/description.md @@ -1,4 +1,4 @@ -# Castle Accounting +# Libra A comprehensive double-entry accounting system for collective projects, designed specifically for LNbits. @@ -7,29 +7,29 @@ A comprehensive double-entry accounting system for collective projects, designed - **Double-Entry Bookkeeping**: Full accounting system with debits and credits - **Chart of Accounts**: Pre-configured accounts for Assets, Liabilities, Equity, Revenue, and Expenses - **User Expense Tracking**: Members can record out-of-pocket expenses as either: - - **Liabilities**: Castle owes them money (reimbursable) + - **Liabilities**: Libra owes them money (reimbursable) - **Equity**: Their contribution to the collective -- **Accounts Receivable**: Track what users owe the Castle (e.g., accommodation fees) +- **Accounts Receivable**: Track what users owe the Libra (e.g., accommodation fees) - **Revenue Tracking**: Record revenue received by the collective -- **User Balance Dashboard**: Each user sees their balance with the Castle +- **User Balance Dashboard**: Each user sees their balance with the Libra - **Lightning Integration**: Generate invoices for outstanding balances - **Transaction History**: View all accounting entries and transactions ## Use Cases ### 1. User Pays Expense Out of Pocket -When a member buys supplies for the Castle: +When a member buys supplies for the Libra: - They can choose to be reimbursed (Liability) - Or contribute it as equity (Equity) ### 2. Accounts Receivable -When someone stays at the Castle and owes money: +When someone stays at the Libra and owes money: - Admin creates an AR entry (e.g., "5 nights @ 10€/night = 50€") - User sees they owe 50€ in their dashboard - They can generate an invoice to pay it off ### 3. Revenue Recording -When the Castle receives revenue: +When the Libra receives revenue: - Record revenue with the payment method (Cash, Lightning, Bank) - Properly categorized in the accounting system @@ -58,8 +58,8 @@ When the Castle receives revenue: ## Getting Started -1. Enable the Castle extension in LNbits -2. Visit the Castle page to see your dashboard +1. Enable the Libra extension in LNbits +2. Visit the Libra page to see your dashboard 3. Start tracking expenses and balances! The extension automatically creates a default chart of accounts on first run. diff --git a/docs/ACCOUNT-SYNC-AND-PERMISSION-IMPROVEMENTS.md b/docs/ACCOUNT-SYNC-AND-PERMISSION-IMPROVEMENTS.md index f5528f5..ea37aaa 100644 --- a/docs/ACCOUNT-SYNC-AND-PERMISSION-IMPROVEMENTS.md +++ b/docs/ACCOUNT-SYNC-AND-PERMISSION-IMPROVEMENTS.md @@ -8,9 +8,9 @@ ## Summary -Implemented two major improvements for Castle administration: +Implemented two major improvements for Libra administration: -1. **Account Synchronization** - Automatically sync accounts from Beancount → Castle DB +1. **Account Synchronization** - Automatically sync accounts from Beancount → Libra DB 2. **Bulk Permission Management** - Tools for managing permissions at scale **Total Implementation Time**: ~4 hours @@ -23,24 +23,24 @@ Implemented two major improvements for Castle administration: ### Problem Solved -**Before**: Accounts existed in both Beancount and Castle DB, with manual sync required. -**After**: Automatic sync keeps Castle DB in sync with Beancount (source of truth). +**Before**: Accounts existed in both Beancount and Libra DB, with manual sync required. +**After**: Automatic sync keeps Libra DB in sync with Beancount (source of truth). ### Implementation -**New Module**: `castle/account_sync.py` +**New Module**: `libra/account_sync.py` **Core Functions**: ```python -# 1. Full sync from Beancount to Castle +# 1. Full sync from Beancount to Libra stats = await sync_accounts_from_beancount(force_full_sync=False) # 2. Sync single account success = await sync_single_account_from_beancount("Expenses:Food") # 3. Ensure account exists (recommended before granting permissions) -exists = await ensure_account_exists_in_castle("Expenses:Marketing") +exists = await ensure_account_exists_in_libra("Expenses:Marketing") # 4. Scheduled background sync (run hourly) stats = await scheduled_account_sync() @@ -77,7 +77,7 @@ stats = await scheduled_account_sync() ```python # Sync all accounts from Beancount -from castle.account_sync import sync_accounts_from_beancount +from libra.account_sync import sync_accounts_from_beancount stats = await sync_accounts_from_beancount() @@ -96,11 +96,11 @@ Errors: 0 #### Before Granting Permission (Best Practice) ```python -from castle.account_sync import ensure_account_exists_in_castle -from castle.crud import create_account_permission +from libra.account_sync import ensure_account_exists_in_libra +from libra.crud import create_account_permission -# Ensure account exists in Castle DB first -account_exists = await ensure_account_exists_in_castle("Expenses:Marketing") +# Ensure account exists in Libra DB first +account_exists = await ensure_account_exists_in_libra("Expenses:Marketing") if account_exists: # Now safe to grant permission @@ -116,9 +116,9 @@ if account_exists: ```python # Add to your scheduler (cron, APScheduler, etc.) -from castle.account_sync import scheduled_account_sync +from libra.account_sync import scheduled_account_sync -# Run every hour to keep Castle DB in sync +# Run every hour to keep Libra DB in sync scheduler.add_job( scheduled_account_sync, 'interval', @@ -142,7 +142,7 @@ Authorization: Bearer {admin_key} ```json { "total_beancount_accounts": 150, - "total_castle_accounts": 150, + "total_libra_accounts": 150, "accounts_added": 2, "accounts_updated": 0, "accounts_skipped": 148, @@ -152,8 +152,8 @@ Authorization: Bearer {admin_key} ### Benefits -1. **Beancount as Source of Truth**: Castle DB automatically reflects Beancount state -2. **Reduced Manual Work**: No more manual account creation in Castle +1. **Beancount as Source of Truth**: Libra DB automatically reflects Beancount state +2. **Reduced Manual Work**: No more manual account creation in Libra 3. **Prevents Permission Errors**: Cannot grant permission on non-existent account 4. **Audit Trail**: Tracks which accounts were synced and when 5. **Safe Operations**: Continues on errors, never deletes accounts @@ -169,7 +169,7 @@ Authorization: Bearer {admin_key} ### Implementation -**New Module**: `castle/permission_management.py` +**New Module**: `libra/permission_management.py` **Core Functions**: @@ -471,19 +471,19 @@ print(f"Permission types removed: {result['permission_types_removed']}") # OLD: Manual permission creation (risky) await create_account_permission( user_id="alice", - account_id="acc123", # What if account doesn't exist in Castle DB? + account_id="acc123", # What if account doesn't exist in Libra DB? permission_type=PermissionType.SUBMIT_EXPENSE, granted_by="admin" ) # NEW: Safe permission creation with account sync -from castle.account_sync import ensure_account_exists_in_castle +from libra.account_sync import ensure_account_exists_in_libra # Ensure account exists first -account_exists = await ensure_account_exists_in_castle("Expenses:Marketing") +account_exists = await ensure_account_exists_in_libra("Expenses:Marketing") if account_exists: - # Now safe - account guaranteed to be in Castle DB + # Now safe - account guaranteed to be in Libra DB await create_account_permission( user_id="alice", account_id=account_id, @@ -497,10 +497,10 @@ else: ### Scheduler Integration ```python -# Add to your Castle extension startup +# Add to your Libra extension startup from apscheduler.schedulers.asyncio import AsyncIOScheduler -from castle.account_sync import scheduled_account_sync -from castle.permission_management import cleanup_expired_permissions +from libra.account_sync import scheduled_account_sync +from libra.permission_management import cleanup_expired_permissions scheduler = AsyncIOScheduler() @@ -610,7 +610,7 @@ async def test_copy_permissions(): async def test_onboarding_workflow(): """Test complete onboarding workflow""" # 1. Sync account - await ensure_account_exists_in_castle("Expenses:Food") + await ensure_account_exists_in_libra("Expenses:Food") # 2. Copy permissions from template user result = await copy_permissions( @@ -745,19 +745,19 @@ logger.error(f"Account sync error: {error}") ## Migration Guide -### For Existing Castle Installations +### For Existing Libra Installations **Step 1: Deploy New Modules** ```bash -# Copy new files to Castle extension -cp account_sync.py /path/to/castle/ -cp permission_management.py /path/to/castle/ +# Copy new files to Libra extension +cp account_sync.py /path/to/libra/ +cp permission_management.py /path/to/libra/ ``` **Step 2: Initial Account Sync** ```python # Run once to sync existing accounts -from castle.account_sync import sync_accounts_from_beancount +from libra.account_sync import sync_accounts_from_beancount stats = await sync_accounts_from_beancount(force_full_sync=True) print(f"Synced {stats['accounts_added']} accounts") @@ -784,14 +784,14 @@ await bulk_grant_permission(...) ## Documentation Updates **New files created**: -- ✅ `castle/account_sync.py` (230 lines) -- ✅ `castle/permission_management.py` (400 lines) +- ✅ `libra/account_sync.py` (230 lines) +- ✅ `libra/permission_management.py` (400 lines) - ✅ `docs/PERMISSIONS-SYSTEM.md` (full permission system docs) - ✅ `docs/ACCOUNT-SYNC-AND-PERMISSION-IMPROVEMENTS.md` (this file) **Files to update**: -- `castle/views_api.py` - Add new admin endpoints -- `castle/README.md` - Document new features +- `libra/views_api.py` - Add new admin endpoints +- `libra/README.md` - Document new features - `tests/` - Add comprehensive tests --- @@ -801,7 +801,7 @@ await bulk_grant_permission(...) ### What Was Built 1. **Account Sync Module** (230 lines) - - Automatic sync from Beancount → Castle DB + - Automatic sync from Beancount → Libra DB - Type inference and user ID extraction - Background scheduling support diff --git a/docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.html b/docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.html index d0e9bfe..6271865 100644 --- a/docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.html +++ b/docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.html @@ -195,13 +195,13 @@ id="toc-professional-assessment">Professional Assessment

Accounting Analysis: Net Settlement Entry Pattern

Date: 2025-01-12 Prepared By: -Senior Accounting Review Subject: Castle Extension - +Senior Accounting Review Subject: Libra Extension - Lightning Payment Settlement Entries Status: Technical Review


Executive Summary

This document provides a professional accounting assessment of -Castle’s net settlement entry pattern used for recording Lightning +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 @@ -214,7 +214,7 @@ hierarchy


Background: The Technical Challenge

-

Castle operates as a Lightning Network-integrated accounting system +

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., @@ -223,7 +223,7 @@ accounting challenge:

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 -Castle) and payables (Castle owes them) 4. Maintaining Beancount +Libra) and payables (Libra owes them) 4. Maintaining Beancount double-entry balance


Current Implementation

@@ -231,7 +231,7 @@ double-entry balance

; Step 1: Receivable Created
 2025-11-12 * "room (200.00 EUR)" #receivable-entry
   user-id: "375ec158"
-  source: "castle-api"
+  source: "libra-api"
   sats-amount: "225033"
   Assets:Receivable:User-375ec158     200.00 EUR
     sats-equivalent: "225033"
@@ -344,7 +344,7 @@ class="sourceCode sql">Assets:Bitcoin:Lightning           200.00 EUR
   sats-received: "225033"
@@ -452,8 +452,8 @@ OR payable)
 (receivable AND payable)
 
 

When Net Settlement is Appropriate:

-
User owes Castle:    555.00 EUR (receivable)
-Castle owes User:     38.00 EUR (payable)
+
User owes Libra:    555.00 EUR (receivable)
+Libra owes User:     38.00 EUR (payable)
 Net amount due:      517.00 EUR (true settlement)

Proper three-posting entry:

Assets:Bitcoin:Lightning           565251 SATS @@ 517.00 EUR
@@ -461,8 +461,8 @@ Assets:Receivable:User            -555.00 EUR
 Liabilities:Payable:User            38.00 EUR
 ; Net: 517.00 = -555.00 + 38.00 ✓

When Two Postings Suffice:

-
User owes Castle:    200.00 EUR (receivable)
-Castle owes User:      0.00 EUR (no payable)
+
User owes Libra:    200.00 EUR (receivable)
+Libra owes User:      0.00 EUR (no payable)
 Amount due:          200.00 EUR (simple payment)

Simpler two-posting entry:

Assets:Bitcoin:Lightning           225033 SATS @@ 200.00 EUR
@@ -515,7 +515,7 @@ positions - ❌ Requires metadata parsing for SATS balances

id="approach-3-true-net-settlement-when-both-obligations-exist">Approach 3: True Net Settlement (When Both Obligations Exist)
2025-11-12 * "Net settlement via Lightning"
-  ; User owes 555 EUR, Castle owes 38 EUR, net: 517 EUR
+  ; User owes 555 EUR, Libra owes 38 EUR, net: 517 EUR
   Assets:Bitcoin:Lightning           517.00 EUR
     sats-received: "565251"
   Assets:Receivable:User-375ec158   -555.00 EUR
@@ -570,7 +570,7 @@ Method
 

Decision Required: Select either position-based OR metadata-based satoshi tracking.

Option A - Keep Metadata Approach (recommended for -Castle):

+Libra):

# In format_net_settlement_entry()
 postings = [
@@ -604,7 +604,7 @@ class="sourceCode python">    }
 ]

Recommendation: Choose Option A (metadata) for -consistency with Castle’s architecture.

+consistency with Libra’s architecture.


1.3 Rename Function for Clarity

@@ -713,7 +713,7 @@ class="sourceCode python"> payment_hash=payment_hash ) else: - # PAYABLE PAYMENT: Castle paying user (different flow) + # PAYABLE PAYMENT: Libra paying user (different flow) return await format_payable_payment_entry(...)

Priority 3: @@ -742,7 +742,7 @@ architectures:

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 Castle metadata approach

+Aligns with current Libra metadata approach


3.2 Consider Separate Ledger for Cryptocurrency Holdings

@@ -754,7 +754,7 @@ from fiat accounting

Assets:Receivable:User-375ec158 -200.00 EUR

Cryptocurrency Sub-Ledger (SATS-denominated):

2025-11-12 * "Lightning payment received"
-  Assets:Bitcoin:Lightning:Castle    225033 SATS
+  Assets:Bitcoin:Lightning:Libra    225033 SATS
   Assets:Bitcoin:Custody:User-375ec  225033 SATS

Benefits: - ✅ Clean separation of concerns - ✅ Cryptocurrency movements tracked independently - ✅ Fiat accounting @@ -902,7 +902,7 @@ Entry balances

Is this “best practice” accounting? No, this implementation deviates from traditional accounting standards in several ways.

-

Is it acceptable for Castle’s use case? Yes, +

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 @@ -912,7 +912,7 @@ 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. -Castle’s approach is functional, but should be refined to align better +Libra’s approach is functional, but should be refined to align better with accounting principles where possible.

Next Steps

    @@ -935,7 +935,7 @@ Characteristics of Accounting Information
  1. ASC 105-10-05: Substance Over Form
  2. Beancount Documentation: http://furius.ca/beancount/doc/index
  3. -
  4. Castle Extension: +
  5. Libra Extension: docs/SATS-EQUIVALENT-METADATA.md
  6. BQL Analysis: docs/BQL-BALANCE-QUERIES.md
  7. @@ -948,6 +948,6 @@ 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 -Castle’s payment recording system.

    +Libra’s payment recording system.

    diff --git a/docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.md b/docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.md index b145128..8725145 100644 --- a/docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.md +++ b/docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.md @@ -2,14 +2,14 @@ **Date**: 2025-01-12 **Prepared By**: Senior Accounting Review -**Subject**: Castle Extension - Lightning Payment Settlement Entries +**Subject**: Libra Extension - Lightning Payment Settlement Entries **Status**: Technical Review --- ## Executive Summary -This document provides a professional accounting assessment of Castle'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. +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. **Key Findings**: - ✅ Double-entry integrity maintained @@ -23,14 +23,14 @@ This document provides a professional accounting assessment of Castle's net sett ## Background: The Technical Challenge -Castle operates as a Lightning Network-integrated accounting system for collectives (co-living spaces, makerspaces). It faces a unique accounting challenge: +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 Castle) and payables (Castle owes them) +3. Handling cases where users have both receivables (owe Libra) and payables (Libra owes them) 4. Maintaining Beancount double-entry balance --- @@ -43,7 +43,7 @@ Castle operates as a Lightning Network-integrated accounting system for collecti ; Step 1: Receivable Created 2025-11-12 * "room (200.00 EUR)" #receivable-entry user-id: "375ec158" - source: "castle-api" + source: "libra-api" sats-amount: "225033" Assets:Receivable:User-375ec158 200.00 EUR sats-equivalent: "225033" @@ -187,7 +187,7 @@ Assets:Receivable:User-375ec158 -200.00 EUR ; No sats-equivalent needed here ``` -**Option B - Use EUR positions with metadata** (Castle's current approach): +**Option B - Use EUR positions with metadata** (Libra's current approach): ```beancount Assets:Bitcoin:Lightning 200.00 EUR sats-received: "225033" @@ -314,8 +314,8 @@ Assets:Receivable:User-375ec158 -200.00 EUR **When Net Settlement is Appropriate**: ``` -User owes Castle: 555.00 EUR (receivable) -Castle owes User: 38.00 EUR (payable) +User owes Libra: 555.00 EUR (receivable) +Libra owes User: 38.00 EUR (payable) Net amount due: 517.00 EUR (true settlement) ``` @@ -330,8 +330,8 @@ Liabilities:Payable:User 38.00 EUR **When Two Postings Suffice**: ``` -User owes Castle: 200.00 EUR (receivable) -Castle owes User: 0.00 EUR (no payable) +User owes Libra: 200.00 EUR (receivable) +Libra owes User: 0.00 EUR (no payable) Amount due: 200.00 EUR (simple payment) ``` @@ -405,7 +405,7 @@ Assets:Receivable:User -200.00 EUR ```beancount 2025-11-12 * "Net settlement via Lightning" - ; User owes 555 EUR, Castle owes 38 EUR, net: 517 EUR + ; User owes 555 EUR, Libra owes 38 EUR, net: 517 EUR Assets:Bitcoin:Lightning 517.00 EUR sats-received: "565251" Assets:Receivable:User-375ec158 -555.00 EUR @@ -469,7 +469,7 @@ if total_payable_fiat > 0: **Decision Required**: Select either position-based OR metadata-based satoshi tracking. -**Option A - Keep Metadata Approach** (recommended for Castle): +**Option A - Keep Metadata Approach** (recommended for Libra): ```python # In format_net_settlement_entry() postings = [ @@ -506,7 +506,7 @@ postings = [ ] ``` -**Recommendation**: Choose Option A (metadata) for consistency with Castle's architecture. +**Recommendation**: Choose Option A (metadata) for consistency with Libra's architecture. --- @@ -625,7 +625,7 @@ async def create_payment_entry( payment_hash=payment_hash ) else: - # PAYABLE PAYMENT: Castle paying user (different flow) + # PAYABLE PAYMENT: Libra paying user (different flow) return await format_payable_payment_entry(...) ``` @@ -663,7 +663,7 @@ async def create_payment_entry( 1. Most receivables created in EUR 2. Financial reporting requirements typically in fiat 3. Tax obligations calculated in fiat -4. Aligns with current Castle metadata approach +4. Aligns with current Libra metadata approach --- @@ -681,7 +681,7 @@ async def create_payment_entry( **Cryptocurrency Sub-Ledger** (SATS-denominated): ```beancount 2025-11-12 * "Lightning payment received" - Assets:Bitcoin:Lightning:Castle 225033 SATS + Assets:Bitcoin:Lightning:Libra 225033 SATS Assets:Bitcoin:Custody:User-375ec 225033 SATS ``` @@ -821,7 +821,7 @@ async def create_payment_entry( **Is this "best practice" accounting?** **No**, this implementation deviates from traditional accounting standards in several ways. -**Is it acceptable for Castle's use case?** +**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**: @@ -829,7 +829,7 @@ async def create_payment_entry( 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. Castle's approach is functional, but should be refined to align better with accounting principles where possible. +**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. ### Next Steps @@ -847,7 +847,7 @@ async def create_payment_entry( - **FASB Concept Statement No. 2**: Qualitative Characteristics of Accounting Information - **ASC 105-10-05**: Substance Over Form - **Beancount Documentation**: http://furius.ca/beancount/doc/index -- **Castle Extension**: `docs/SATS-EQUIVALENT-METADATA.md` +- **Libra Extension**: `docs/SATS-EQUIVALENT-METADATA.md` - **BQL Analysis**: `docs/BQL-BALANCE-QUERIES.md` --- @@ -858,4 +858,4 @@ async def create_payment_entry( --- -*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 Castle's payment recording system.* +*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/BEANCOUNT_PATTERNS.md b/docs/BEANCOUNT_PATTERNS.md index 2124c92..29b65c9 100644 --- a/docs/BEANCOUNT_PATTERNS.md +++ b/docs/BEANCOUNT_PATTERNS.md @@ -1,8 +1,8 @@ -# Beancount Patterns Analysis for Castle Extension +# Beancount Patterns Analysis for Libra Extension ## Overview -After analyzing the [Beancount repository](https://github.com/beancount/beancount), I've identified several excellent design patterns and architectural decisions that we should adopt or consider for the Castle Accounting extension. +After analyzing the [Beancount repository](https://github.com/beancount/beancount), I've identified several excellent design patterns and architectural decisions that we should adopt or consider for the Libra extension. ## Key Patterns to Adopt @@ -38,7 +38,7 @@ class Posting(NamedTuple): - More memory efficient than regular classes - Thread-safe by design -**Castle Application:** +**Libra Application:** ```python # In models.py from typing import NamedTuple, Optional @@ -109,15 +109,15 @@ def validate_commodity_directives(entries, options_map, config): return entries, errors ``` -**Castle Application:** +**Libra Application:** ```python # Create plugins/ directory -# lnbits/extensions/castle/plugins/__init__.py +# lnbits/extensions/libra/plugins/__init__.py from typing import Protocol, Tuple, List, Any -class CastlePlugin(Protocol): - """Protocol for Castle plugins""" +class LibraPlugin(Protocol): + """Protocol for Libra plugins""" def __call__( self, @@ -130,7 +130,7 @@ class CastlePlugin(Protocol): Args: entries: Journal entries to process - settings: Castle settings + settings: Libra settings config: Plugin-specific configuration Returns: @@ -212,7 +212,7 @@ class PluginManager: if plugin_file.name.startswith('_'): continue - module_name = f"castle.plugins.{plugin_file.stem}" + module_name = f"libra.plugins.{plugin_file.stem}" module = importlib.import_module(module_name) if hasattr(module, '__plugins__'): @@ -276,7 +276,7 @@ class Inventory(dict[tuple[str, Optional[Cost]], Position]): ) ``` -**Castle Application:** +**Libra Application:** ```python # core/inventory.py from decimal import Decimal @@ -284,8 +284,8 @@ from typing import Optional, Dict, Tuple from dataclasses import dataclass @dataclass(frozen=True) -class CastlePosition: - """A position in the Castle inventory""" +class LibraPosition: + """A position in the Libra inventory""" currency: str # "SATS", "EUR", "USD" amount: Decimal cost_currency: Optional[str] = None # Original currency if converted @@ -293,22 +293,22 @@ class CastlePosition: date: Optional[datetime] = None metadata: Dict[str, Any] = None -class CastleInventory: +class LibraInventory: """ Track user balances across multiple currencies with conversion tracking. - Similar to Beancount's Inventory but optimized for Castle's use case. + Similar to Beancount's Inventory but optimized for Libra's use case. """ def __init__(self): - self.positions: Dict[Tuple[str, Optional[str]], CastlePosition] = {} + self.positions: Dict[Tuple[str, Optional[str]], LibraPosition] = {} - def add_position(self, position: CastlePosition): + def add_position(self, position: LibraPosition): """Add or merge a position""" key = (position.currency, position.cost_currency) if key in self.positions: existing = self.positions[key] - self.positions[key] = CastlePosition( + self.positions[key] = LibraPosition( currency=position.currency, amount=existing.amount + position.amount, cost_currency=position.cost_currency, @@ -353,9 +353,9 @@ class CastleInventory: } # Usage in balance calculation: -async def get_user_inventory(user_id: str) -> CastleInventory: +async def get_user_inventory(user_id: str) -> LibraInventory: """Calculate user's inventory from journal entries""" - inventory = CastleInventory() + inventory = LibraInventory() user_accounts = await get_user_accounts(user_id) for account in user_accounts: @@ -369,7 +369,7 @@ async def get_user_inventory(user_id: str) -> CastleInventory: # Beancount-style: positive = debit, negative = credit # Adjust sign for cost amount based on amount direction cost_sign = 1 if line.amount > 0 else -1 - inventory.add_position(CastlePosition( + inventory.add_position(LibraPosition( currency="SATS", amount=Decimal(line.amount), cost_currency=metadata.get("fiat_currency"), @@ -397,7 +397,7 @@ Every directive has a `meta: dict[str, Any]` attribute that stores: - `lineno`: Line number - Custom metadata like tags, links, notes -**Castle Application:** +**Libra Application:** ```python class JournalEntryMeta(BaseModel): """Metadata for journal entries""" @@ -447,7 +447,7 @@ entry = await create_journal_entry( This asserts that the account balance should be exactly 268,548 sats on that date. If it's not, Beancount throws an error. -**Castle Application:** +**Libra Application:** ```python # models.py class BalanceAssertion(BaseModel): @@ -464,7 +464,7 @@ class BalanceAssertion(BaseModel): created_at: datetime # API endpoint -@castle_api_router.post("/api/v1/assertions/balance") +@libra_api_router.post("/api/v1/assertions/balance") async def create_balance_assertion( data: CreateBalanceAssertion, wallet: WalletTypeInfo = Depends(require_admin_key), @@ -554,7 +554,7 @@ Liabilities:US:CreditCard:Amex Accounts are organized hierarchically with `:` separator. -**Castle Application:** +**Libra Application:** ```python # Currently: "Accounts Receivable - af983632" # Better: "Assets:Receivable:User-af983632" @@ -617,7 +617,7 @@ def format_account_name( Flags: `*` = cleared, `!` = pending, `#` = flagged for review -**Castle Application:** +**Libra Application:** ```python # Add flag field to journal_entries class JournalEntryFlag(str, Enum): @@ -661,7 +661,7 @@ from decimal import Decimal amount = Decimal("19.99") ``` -**Castle Current Issue:** +**Libra Current Issue:** We're using `int` for satoshis (good!) but `float` for fiat amounts (bad!). **Fix:** @@ -709,10 +709,10 @@ WHERE account = 'Assets:Checking' AND date >= 2025-01-01; ``` -**Castle Application (Future):** +**Libra Application (Future):** ```python # Add query endpoint -@castle_api_router.post("/api/v1/query") +@libra_api_router.post("/api/v1/query") async def execute_query( query: str, wallet: WalletTypeInfo = Depends(require_invoice_key), @@ -756,12 +756,12 @@ beancount/ tools/ # Reporting and analysis ``` -**Castle Should Adopt:** +**Libra Should Adopt:** ``` -castle/ +libra/ core/ # NEW: Pure accounting logic __init__.py - inventory.py # CastleInventory for position tracking + inventory.py # LibraInventory for position tracking balance.py # Balance calculation logic validation.py # Entry validation (debits=credits, etc) account.py # Account hierarchy and naming @@ -805,11 +805,11 @@ def validate_entries(entries): return errors ``` -**Castle Application:** +**Libra Application:** ```python from typing import NamedTuple, Optional -class CastleError(NamedTuple): +class LibraError(NamedTuple): """Base error type""" source: dict # {'endpoint': '...', 'user_id': '...'} message: str @@ -828,7 +828,7 @@ class UnbalancedEntryError(NamedTuple): difference: int # Return errors from validation -async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError]: +async def validate_journal_entry(entry: CreateJournalEntry) -> list[LibraError]: errors = [] # Beancount-style: sum of amounts must equal 0 @@ -870,7 +870,7 @@ async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError] ### Phase 3: Core Logic Refactoring (Medium Priority) ✅ COMPLETE 9. ✅ Create `core/` module with pure accounting logic -10. ✅ Implement `CastleInventory` for position tracking +10. ✅ Implement `LibraInventory` for position tracking 11. ✅ Move balance calculation to `core/balance.py` 12. ✅ Add comprehensive validation in `core/validation.py` @@ -900,7 +900,7 @@ async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError] 7. ✅ Separation of core logic from I/O 8. ✅ Comprehensive validation -**What Castle Should Adopt First:** +**What Libra Should Adopt First:** 1. **Decimal for fiat amounts** (prevent rounding errors) 2. **Meta field** (audit trail, source tracking) 3. **Flag field** (transaction status) @@ -916,7 +916,7 @@ async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError] ## Conclusion -Beancount's architecture is battle-tested for serious accounting work. By adopting these patterns, Castle can: +Beancount's architecture is battle-tested for serious accounting work. By adopting these patterns, Libra can: - Prevent financial calculation errors (Decimal) - Support complex workflows (plugins) - Build user trust (balance assertions, audit trail) diff --git a/docs/BQL-BALANCE-QUERIES.md b/docs/BQL-BALANCE-QUERIES.md index d4997ab..45cd738 100644 --- a/docs/BQL-BALANCE-QUERIES.md +++ b/docs/BQL-BALANCE-QUERIES.md @@ -496,7 +496,7 @@ Improvement: 5-10x faster ## Test Results and Findings **Date**: November 10, 2025 -**Status**: ⚠️ **NOT FEASIBLE for Castle's Current Data Structure** +**Status**: ⚠️ **NOT FEASIBLE for Libra's Current Data Structure** ### Implementation Completed @@ -523,7 +523,7 @@ Improvement: 5-10x faster ### Root Cause: Architecture Limitation -**Current Castle Ledger Structure:** +**Current Libra Ledger Structure:** ``` Posting format: Amount: -360.00 EUR ← Position (BQL can query this) @@ -549,7 +549,7 @@ SELECT account, sum(meta["sats-equivalent"]) WHERE account ~ 'User-' ### Why Manual Aggregation is Necessary -1. **SATS are Castle's primary currency** for balance tracking +1. **SATS are Libra's primary currency** for balance tracking 2. **SATS values are in metadata**, not positions 3. **BQL has no metadata query capability** 4. **Must iterate through postings** to read `meta["sats-equivalent"]` @@ -590,7 +590,7 @@ SELECT account, sum(meta["sats-equivalent"]) WHERE account ~ 'User-' ## Future Consideration: Ledger Format Change -**If** Castle's ledger format changes to use SATS as position amounts: +**If** Libra's ledger format changes to use SATS as position amounts: ```beancount ; Current format (EUR position, SATS in metadata): diff --git a/docs/BQL-PRICE-NOTATION-SOLUTION.md b/docs/BQL-PRICE-NOTATION-SOLUTION.md index 24cd073..5a8df9b 100644 --- a/docs/BQL-PRICE-NOTATION-SOLUTION.md +++ b/docs/BQL-PRICE-NOTATION-SOLUTION.md @@ -63,7 +63,7 @@ Expenses:Food -360.00 EUR @@ 337096 SATS **Total calculation**: Exact 337,096 SATS (no rounding) **Precision**: Preserves exact SATS amount from original calculation -**Why `@@` is better for Castle:** +**Why `@@` is better for Libra:** - ✅ Preserves exact SATS amount (no rounding errors) - ✅ Matches current metadata storage exactly - ✅ Clearer intent: "this transaction equals X SATS total" @@ -124,7 +124,7 @@ GROUP BY account; ### Step 1: Run Metadata Test ```bash -cd /home/padreug/projects/castle-beancounter +cd /home/padreug/projects/libra-beancounter ./test_metadata_simple.sh ``` @@ -166,7 +166,7 @@ Add one test entry to your ledger: Then query: ```bash -curl -s "http://localhost:3333/castle-ledger/api/query" \ +curl -s "http://localhost:3333/libra-ledger/api/query" \ -G \ --data-urlencode "query_string=SELECT account, position, price_number, price_currency WHERE account ~ 'TEST'" \ | jq '.' diff --git a/docs/DAILY_RECONCILIATION.md b/docs/DAILY_RECONCILIATION.md index af38a8d..e284441 100644 --- a/docs/DAILY_RECONCILIATION.md +++ b/docs/DAILY_RECONCILIATION.md @@ -1,6 +1,6 @@ # Automated Daily Reconciliation -The Castle extension includes automated daily balance checking to ensure accounting accuracy. +The Libra extension includes automated daily balance checking to ensure accounting accuracy. ## Overview @@ -16,7 +16,7 @@ You can manually trigger the reconciliation check from the UI or via API: ### Via API ```bash -curl -X POST https://your-lnbits-instance.com/castle/api/v1/tasks/daily-reconciliation \ +curl -X POST https://your-lnbits-instance.com/libra/api/v1/tasks/daily-reconciliation \ -H "X-Api-Key: YOUR_ADMIN_KEY" ``` @@ -28,7 +28,7 @@ Add to your crontab: ```bash # Run daily at 2 AM -0 2 * * * curl -X POST http://localhost:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY" >> /var/log/castle-reconciliation.log 2>&1 +0 2 * * * curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY" >> /var/log/libra-reconciliation.log 2>&1 ``` To edit crontab: @@ -38,22 +38,22 @@ crontab -e ### Option 2: Systemd Timer -Create `/etc/systemd/system/castle-reconciliation.service`: +Create `/etc/systemd/system/libra-reconciliation.service`: ```ini [Unit] -Description=Castle Daily Reconciliation Check +Description=Libra Daily Reconciliation Check After=network.target [Service] Type=oneshot User=lnbits -ExecStart=/usr/bin/curl -X POST http://localhost:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY" +ExecStart=/usr/bin/curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY" ``` -Create `/etc/systemd/system/castle-reconciliation.timer`: +Create `/etc/systemd/system/libra-reconciliation.timer`: ```ini [Unit] -Description=Run Castle reconciliation daily +Description=Run Libra reconciliation daily [Timer] OnCalendar=daily @@ -66,8 +66,8 @@ WantedBy=timers.target Enable and start: ```bash -sudo systemctl enable castle-reconciliation.timer -sudo systemctl start castle-reconciliation.timer +sudo systemctl enable libra-reconciliation.timer +sudo systemctl start libra-reconciliation.timer ``` ### Option 3: Docker/Kubernetes CronJob @@ -78,7 +78,7 @@ For containerized deployments: apiVersion: batch/v1 kind: CronJob metadata: - name: castle-reconciliation + name: libra-reconciliation spec: schedule: "0 2 * * *" # Daily at 2 AM jobTemplate: @@ -91,7 +91,7 @@ spec: args: - /bin/sh - -c - - curl -X POST http://lnbits:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: ${ADMIN_KEY}" + - curl -X POST http://lnbits:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: ${ADMIN_KEY}" restartPolicy: OnFailure ``` @@ -129,7 +129,7 @@ The endpoint returns: grep CRON /var/log/syslog # View custom log (if using cron with redirect) -tail -f /var/log/castle-reconciliation.log +tail -f /var/log/libra-reconciliation.log ``` ### Success Criteria @@ -142,7 +142,7 @@ tail -f /var/log/castle-reconciliation.log If `failed > 0`: 1. Check the `failed_assertions` array for details -2. Investigate discrepancies in the Castle UI +2. Investigate discrepancies in the Libra UI 3. Review recent transactions 4. Check for data entry errors 5. Verify exchange rate conversions (for fiat) @@ -172,7 +172,7 @@ Planned features: 3. **Check network connectivity**: ```bash - curl http://localhost:5000/castle/api/v1/reconciliation/summary -H "X-Api-Key: YOUR_KEY" + curl http://localhost:5000/libra/api/v1/reconciliation/summary -H "X-Api-Key: YOUR_KEY" ``` ### Permission Denied @@ -202,31 +202,31 @@ Planned features: ```bash #!/bin/bash -# setup-castle-reconciliation.sh +# setup-libra-reconciliation.sh # Configuration LNBITS_URL="http://localhost:5000" ADMIN_KEY="your_admin_key_here" -LOG_FILE="/var/log/castle-reconciliation.log" +LOG_FILE="/var/log/libra-reconciliation.log" # Create log file touch "$LOG_FILE" chmod 644 "$LOG_FILE" # Add cron job -(crontab -l 2>/dev/null; echo "0 2 * * * curl -X POST $LNBITS_URL/castle/api/v1/tasks/daily-reconciliation -H 'X-Api-Key: $ADMIN_KEY' >> $LOG_FILE 2>&1") | crontab - +(crontab -l 2>/dev/null; echo "0 2 * * * curl -X POST $LNBITS_URL/libra/api/v1/tasks/daily-reconciliation -H 'X-Api-Key: $ADMIN_KEY' >> $LOG_FILE 2>&1") | crontab - echo "Daily reconciliation scheduled for 2 AM" echo "Logs will be written to: $LOG_FILE" # Test the endpoint echo "Running test reconciliation..." -curl -X POST "$LNBITS_URL/castle/api/v1/tasks/daily-reconciliation" \ +curl -X POST "$LNBITS_URL/libra/api/v1/tasks/daily-reconciliation" \ -H "X-Api-Key: $ADMIN_KEY" ``` Make executable and run: ```bash -chmod +x setup-castle-reconciliation.sh -./setup-castle-reconciliation.sh +chmod +x setup-libra-reconciliation.sh +./setup-libra-reconciliation.sh ``` diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index ac79f03..12b239b 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -1,8 +1,8 @@ -# Castle Accounting Extension - Comprehensive Documentation +# Libra Extension - Comprehensive Documentation ## Overview -The Castle Accounting extension for LNbits implements a double-entry bookkeeping system designed for cooperative/communal living spaces (like "castles"). It tracks financial relationships between a central entity (the Castle) and multiple users, handling both Lightning Network payments and manual/cash transactions. +The Libra extension for LNbits implements a double-entry bookkeeping system designed for cooperative/communal living spaces (like cooperatives). It tracks financial relationships between a central entity (the Libra) and multiple users, handling both Lightning Network payments and manual/cash transactions. ## Architecture @@ -19,23 +19,23 @@ The system implements traditional **double-entry bookkeeping** principles: | Account Type | Normal Balance | Increases With | Decreases With | Purpose | |--------------|----------------|----------------|----------------|---------| -| Asset | Debit | Debit | Credit | What Castle owns or is owed | -| Liability | Credit | Credit | Debit | What Castle owes to others | +| Asset | Debit | Debit | Credit | What Libra owns or is owed | +| Liability | Credit | Credit | Debit | What Libra owes to others | | Equity | Credit | Credit | Debit | Member contributions, retained earnings | -| Revenue | Credit | Credit | Debit | Income earned by Castle | -| Expense | Debit | Debit | Credit | Costs incurred by Castle | +| Revenue | Credit | Credit | Debit | Income earned by Libra | +| Expense | Debit | Debit | Credit | Costs incurred by Libra | ### User-Specific Accounts The system creates **per-user accounts** for tracking individual balances: -- `Accounts Receivable - {user_id[:8]}` (Asset) - User owes Castle -- `Accounts Payable - {user_id[:8]}` (Liability) - Castle owes User +- `Accounts Receivable - {user_id[:8]}` (Asset) - User owes Libra +- `Accounts Payable - {user_id[:8]}` (Liability) - Libra owes User - `Member Equity - {user_id[:8]}` (Equity) - User's equity contributions **Balance Interpretation:** -- `balance > 0` and account is Liability → Castle owes user (user is creditor) -- `balance < 0` (or positive Asset balance) → User owes Castle (user is debtor) +- `balance > 0` and account is Liability → Libra owes user (user is creditor) +- `balance < 0` (or positive Asset balance) → User owes Libra (user is debtor) ### Database Schema @@ -81,7 +81,7 @@ CREATE TABLE entry_lines ( ```sql CREATE TABLE extension_settings ( id TEXT NOT NULL PRIMARY KEY, -- Always "admin" - castle_wallet_id TEXT, -- LNbits wallet ID for Castle operations + libra_wallet_id TEXT, -- LNbits wallet ID for Libra operations updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); ``` @@ -129,11 +129,11 @@ Each `entry_line` can store metadata as JSON to preserve original fiat amounts: ### 1. User Adds Expense (Liability Model) -**Use Case:** User pays for groceries with cash, Castle reimburses them +**Use Case:** User pays for groceries with cash, Libra reimburses them **User Action:** Add expense via UI ```javascript -POST /castle/api/v1/entries/expense +POST /libra/api/v1/entries/expense { "description": "Biocoop groceries", "amount": 36.93, @@ -162,15 +162,15 @@ Metadata on both lines: } ``` -**Effect:** Castle owes user €36.93 (39,669 sats) +**Effect:** Libra owes user €36.93 (39,669 sats) -### 2. Castle Adds Receivable +### 2. Libra Adds Receivable -**Use Case:** User stays in a room, owes Castle for accommodation +**Use Case:** User stays in a room, owes Libra for accommodation -**Castle Admin Action:** Add receivable via UI +**Libra Admin Action:** Add receivable via UI ```javascript -POST /castle/api/v1/entries/receivable +POST /libra/api/v1/entries/receivable { "description": "room 5 days", "amount": 250.0, @@ -198,7 +198,7 @@ Metadata: } ``` -**Effect:** User owes Castle €250.00 (268,548 sats) +**Effect:** User owes Libra €250.00 (268,548 sats) ### 3. User Pays with Lightning @@ -206,7 +206,7 @@ Metadata: **Step A: Generate Invoice** ```javascript -POST /castle/api/v1/generate-payment-invoice +POST /libra/api/v1/generate-payment-invoice { "amount": 268548 } @@ -218,19 +218,19 @@ Returns: "payment_hash": "...", "payment_request": "lnbc...", "amount": 268548, - "memo": "Payment from user af983632 to Castle", - "check_wallet_key": "castle_wallet_inkey" + "memo": "Payment from user af983632 to Libra", + "check_wallet_key": "libra_wallet_inkey" } ``` -**Note:** Invoice is generated on **Castle's wallet**, not user's wallet. User polls using `check_wallet_key`. +**Note:** Invoice is generated on **Libra's wallet**, not user's wallet. User polls using `check_wallet_key`. **Step B: User Pays Invoice** (External Lightning wallet or LNbits wallet) **Step C: Record Payment** ```javascript -POST /castle/api/v1/record-payment +POST /libra/api/v1/record-payment { "payment_hash": "..." } @@ -250,11 +250,11 @@ DR Lightning Balance 268,548 sats ### 4. Manual Payment Request Flow -**Use Case:** User wants Castle to pay them in cash instead of Lightning +**Use Case:** User wants Libra to pay them in cash instead of Lightning **Step A: User Requests Payment** ```javascript -POST /castle/api/v1/manual-payment-requests +POST /libra/api/v1/manual-payment-requests { "amount": 39669, "description": "Please pay me in cash for groceries" @@ -263,16 +263,16 @@ POST /castle/api/v1/manual-payment-requests Creates `manual_payment_request` with status='pending' -**Step B: Castle Admin Reviews** +**Step B: Libra Admin Reviews** Admin sees pending request in UI: - User: af983632 - Amount: 39,669 sats (€36.93) - Description: "Please pay me in cash for groceries" -**Step C: Castle Admin Approves** +**Step C: Libra Admin Approves** ```javascript -POST /castle/api/v1/manual-payment-requests/{id}/approve +POST /libra/api/v1/manual-payment-requests/{id}/approve ``` **Journal Entry Created:** @@ -285,11 +285,11 @@ DR Accounts Payable - af983632 39,669 sats CR Lightning Balance 39,669 sats ``` -**Effect:** Castle's liability to user reduced by 39,669 sats +**Effect:** Libra's liability to user reduced by 39,669 sats -**Alternative: Castle Admin Rejects** +**Alternative: Libra Admin Rejects** ```javascript -POST /castle/api/v1/manual-payment-requests/{id}/reject +POST /libra/api/v1/manual-payment-requests/{id}/reject ``` No journal entry created, request marked as 'rejected'. @@ -308,20 +308,20 @@ for account in user_accounts: # Calculate satoshi balance if account.account_type == AccountType.LIABILITY: - total_balance += account_balance # Positive = Castle owes user + total_balance += account_balance # Positive = Libra owes user elif account.account_type == AccountType.ASSET: - total_balance -= account_balance # Positive asset = User owes Castle, so negative balance + total_balance -= account_balance # Positive asset = User owes Libra, so negative balance # Calculate fiat balance from metadata # Beancount-style: positive amount = debit, negative amount = credit for line in account_entry_lines: if line.metadata.fiat_currency and line.metadata.fiat_amount: if account.account_type == AccountType.LIABILITY: - # For liabilities, negative amounts (credits) increase what castle owes + # For liabilities, negative amounts (credits) increase what libra owes if line.amount < 0: - fiat_balances[currency] += fiat_amount # Castle owes more + fiat_balances[currency] += fiat_amount # Libra owes more else: - fiat_balances[currency] -= fiat_amount # Castle owes less + fiat_balances[currency] -= fiat_amount # Libra owes less elif account.account_type == AccountType.ASSET: # For assets, positive amounts (debits) increase what user owes if line.amount > 0: @@ -331,19 +331,19 @@ for account in user_accounts: ``` **Result:** -- `balance > 0`: Castle owes user (LIABILITY side dominates) -- `balance < 0`: User owes Castle (ASSET side dominates) +- `balance > 0`: Libra owes user (LIABILITY side dominates) +- `balance < 0`: User owes Libra (ASSET side dominates) - `fiat_balances`: Net fiat position per currency -### Castle Balance Calculation +### Libra Balance Calculation From `views_api.py:api_get_my_balance()` (super user): ```python all_balances = get_all_user_balances() -total_liabilities = sum(b.balance for b in all_balances if b.balance > 0) # What Castle owes -total_receivables = sum(abs(b.balance) for b in all_balances if b.balance < 0) # What is owed to Castle +total_liabilities = sum(b.balance for b in all_balances if b.balance > 0) # What Libra owes +total_receivables = sum(abs(b.balance) for b in all_balances if b.balance < 0) # What is owed to Libra net_balance = total_liabilities - total_receivables # Aggregate all fiat balances @@ -354,34 +354,34 @@ for user_balance in all_balances: ``` **Result:** -- `net_balance > 0`: Castle owes users (net liability) -- `net_balance < 0`: Users owe Castle (net receivable) +- `net_balance > 0`: Libra owes users (net liability) +- `net_balance < 0`: Users owe Libra (net receivable) ## UI/UX Design ### Perspective-Based Display -The UI adapts based on whether the viewer is a regular user or Castle admin (super user): +The UI adapts based on whether the viewer is a regular user or Libra admin (super user): #### User View **Balance Display:** -- Green text: Castle owes them (positive balance, incoming money) -- Red text: They owe Castle (negative balance, outgoing money) +- Green text: Libra owes them (positive balance, incoming money) +- Red text: They owe Libra (negative balance, outgoing money) **Transaction Badges:** -- Green "Receivable": Castle owes them (Accounts Payable entry) -- Red "Payable": They owe Castle (Accounts Receivable entry) +- Green "Receivable": Libra owes them (Accounts Payable entry) +- Red "Payable": They owe Libra (Accounts Receivable entry) -#### Castle Admin View (Super User) +#### Libra Admin View (Super User) **Balance Display:** -- Red text: Castle owes users (positive balance, outgoing money) -- Green text: Users owe Castle (negative balance, incoming money) +- Red text: Libra owes users (positive balance, outgoing money) +- Green text: Users owe Libra (negative balance, incoming money) **Transaction Badges:** -- Green "Receivable": User owes Castle (Accounts Receivable entry) -- Red "Payable": Castle owes user (Accounts Payable entry) +- Green "Receivable": User owes Libra (Accounts Receivable entry) +- Red "Payable": Libra owes user (Accounts Payable entry) **Outstanding Balances Table:** Shows all users with non-zero balances: @@ -411,10 +411,10 @@ Created by `m001_initial` migration: - `cash` - Cash on hand - `bank` - Bank Account - `lightning` - Lightning Balance -- `accounts_receivable` - Money owed to the Castle +- `accounts_receivable` - Money owed to the Libra ### Liabilities -- `accounts_payable` - Money owed by the Castle +- `accounts_payable` - Money owed by the Libra ### Equity - `member_equity` - Member contributions @@ -449,11 +449,11 @@ Created by `m001_initial` migration: - `POST /api/v1/entries/revenue` - Create direct revenue entry (admin only) ### Balance & Payments -- `GET /api/v1/balance` - Get current user's balance (or Castle total if super user) +- `GET /api/v1/balance` - Get current user's balance (or Libra total if super user) - `GET /api/v1/balance/{user_id}` - Get specific user's balance - `GET /api/v1/balances/all` - Get all user balances (admin only, enriched with usernames) -- `POST /api/v1/generate-payment-invoice` - Generate invoice for user to pay Castle -- `POST /api/v1/record-payment` - Record Lightning payment to Castle +- `POST /api/v1/generate-payment-invoice` - Generate invoice for user to pay Libra +- `POST /api/v1/record-payment` - Record Lightning payment to Libra ### Manual Payments - `POST /api/v1/manual-payment-requests` - User creates manual payment request @@ -463,8 +463,8 @@ Created by `m001_initial` migration: - `POST /api/v1/manual-payment-requests/{id}/reject` - Admin rejects request ### Settings -- `GET /api/v1/settings` - Get Castle settings (super user only) -- `PUT /api/v1/settings` - Update Castle settings (super user only) +- `GET /api/v1/settings` - Get Libra settings (super user only) +- `PUT /api/v1/settings` - Update Libra settings (super user only) - `GET /api/v1/user/wallet` - Get user's wallet settings - `PUT /api/v1/user/wallet` - Update user's wallet settings - `GET /api/v1/users` - Get all users with configured wallets (admin only) @@ -712,7 +712,7 @@ GET /api/v1/entries/user?start_date=2025-01-01&end_date=2025-03-31 **Add Endpoint:** ```python -@castle_api_router.get("/api/v1/export/beancount") +@libra_api_router.get("/api/v1/export/beancount") async def export_beancount( start_date: Optional[str] = None, end_date: Optional[str] = None, @@ -812,7 +812,7 @@ async def export_beancount( **UI Addition:** -Add export button to Castle admin UI: +Add export button to Libra admin UI: ```html Export to Beancount @@ -825,7 +825,7 @@ async exportBeancount() { try { const response = await LNbits.api.request( 'GET', - '/castle/api/v1/export/beancount', + '/libra/api/v1/export/beancount', this.g.user.wallets[0].adminkey ) @@ -834,7 +834,7 @@ async exportBeancount() { const url = window.URL.createObjectURL(blob) const link = document.createElement('a') link.href = url - link.download = `castle-accounting-${new Date().toISOString().split('T')[0]}.beancount` + link.download = `libra-accounting-${new Date().toISOString().split('T')[0]}.beancount` link.click() window.URL.revokeObjectURL(url) @@ -854,12 +854,12 @@ After export, users can verify with Beancount: ```bash # Check file is valid -bean-check castle-accounting-2025-10-22.beancount +bean-check libra-accounting-2025-10-22.beancount # Generate reports -bean-report castle-accounting-2025-10-22.beancount balances -bean-report castle-accounting-2025-10-22.beancount income -bean-web castle-accounting-2025-10-22.beancount +bean-report libra-accounting-2025-10-22.beancount balances +bean-report libra-accounting-2025-10-22.beancount income +bean-web libra-accounting-2025-10-22.beancount ``` ## Testing Strategy @@ -891,7 +891,7 @@ bean-web castle-accounting-2025-10-22.beancount 1. **End-to-End User Flow** - User adds expense - - Castle adds receivable + - Libra adds receivable - User pays via Lightning - Verify balances at each step @@ -904,7 +904,7 @@ bean-web castle-accounting-2025-10-22.beancount 3. **Multi-User Scenarios** - Multiple users with positive balances - Multiple users with negative balances - - Verify Castle net balance calculation + - Verify Libra net balance calculation ## Security Considerations @@ -916,12 +916,12 @@ bean-web castle-accounting-2025-10-22.beancount 2. **User Isolation** - Users can only see their own balances and transactions - - Users cannot create receivables (only Castle admin can) + - Users cannot create receivables (only Libra admin can) - Users cannot approve their own manual payment requests 3. **Wallet Key Requirements** - `require_invoice_key`: Read access to user's data - - `require_admin_key`: Write access, Castle admin operations + - `require_admin_key`: Write access, Libra admin operations ### Potential Vulnerabilities @@ -959,7 +959,7 @@ bean-web castle-accounting-2025-10-22.beancount limiter = Limiter(key_func=get_remote_address) @limiter.limit("10/minute") - @castle_api_router.post("/api/v1/entries/expense") + @libra_api_router.post("/api/v1/entries/expense") async def api_create_expense_entry(...): ... ``` @@ -1020,7 +1020,7 @@ bean-web castle-accounting-2025-10-22.beancount 2. **Add Pagination** ```python - @castle_api_router.get("/api/v1/entries/user") + @libra_api_router.get("/api/v1/entries/user") async def api_get_user_entries( wallet: WalletTypeInfo = Depends(require_invoice_key), limit: int = 100, @@ -1092,7 +1092,7 @@ bean-web castle-accounting-2025-10-22.beancount ## Migration Path for Existing Data -If Castle is already in production with the old code: +If Libra is already in production with the old code: ### Migration Script: `m005_fix_user_accounts.py` @@ -1185,7 +1185,7 @@ async def m005_fix_user_accounts(db): ## Conclusion -The Castle Accounting extension provides a solid foundation for double-entry bookkeeping in LNbits. The core accounting logic is sound, with proper debit/credit handling and user-specific account isolation. +The Libra extension provides a solid foundation for double-entry bookkeeping in LNbits. The core accounting logic is sound, with proper debit/credit handling and user-specific account isolation. ### Strengths ✅ Correct double-entry bookkeeping implementation @@ -1193,7 +1193,7 @@ The Castle Accounting extension provides a solid foundation for double-entry boo ✅ Metadata preservation for fiat amounts ✅ Lightning payment integration ✅ Manual payment workflow -✅ Perspective-based UI (user vs Castle view) +✅ Perspective-based UI (user vs Libra view) ### Immediate Action Items 1. ✅ Fix user account creation bug (COMPLETED) diff --git a/docs/EXPENSE_APPROVAL.md b/docs/EXPENSE_APPROVAL.md index 3123b32..78dfbe5 100644 --- a/docs/EXPENSE_APPROVAL.md +++ b/docs/EXPENSE_APPROVAL.md @@ -2,7 +2,7 @@ ## Overview -The Castle extension now requires admin approval for all user-submitted expenses. This prevents invalid or incorrect expenses from affecting balances until they are verified by the Castle admin. +The Libra extension now requires admin approval for all user-submitted expenses. This prevents invalid or incorrect expenses from affecting balances until they are verified by the Libra admin. ## How It Works @@ -61,7 +61,7 @@ AND je.flag = '*' -- Only cleared entries ### Get Pending Entries (Admin Only) ``` -GET /castle/api/v1/entries/pending +GET /libra/api/v1/entries/pending Authorization: Admin Key Returns: list[JournalEntry] @@ -69,7 +69,7 @@ Returns: list[JournalEntry] ### Approve Expense (Admin Only) ``` -POST /castle/api/v1/entries/{entry_id}/approve +POST /libra/api/v1/entries/{entry_id}/approve Authorization: Admin Key Returns: JournalEntry (with flag='*') @@ -77,7 +77,7 @@ Returns: JournalEntry (with flag='*') ### Reject Expense (Admin Only) ``` -POST /castle/api/v1/entries/{entry_id}/reject +POST /libra/api/v1/entries/{entry_id}/reject Authorization: Admin Key Returns: JournalEntry (with flag='x') @@ -133,7 +133,7 @@ Returns: JournalEntry (with flag='x') 1. **Submit test expense as regular user** ``` - POST /castle/api/v1/entries/expense + POST /libra/api/v1/entries/expense { "description": "Test groceries", "amount": 50.00, diff --git a/docs/PERMISSIONS-SYSTEM.md b/docs/PERMISSIONS-SYSTEM.md index c3c88b7..b3f86ea 100644 --- a/docs/PERMISSIONS-SYSTEM.md +++ b/docs/PERMISSIONS-SYSTEM.md @@ -1,4 +1,4 @@ -# Castle Permissions System - Overview & Administration Guide +# Libra Permissions System - Overview & Administration Guide **Date**: November 10, 2025 **Status**: 📚 **Documentation** + 🔧 **Improvement Recommendations** @@ -7,7 +7,7 @@ ## Executive Summary -Castle implements a **granular, hierarchical permission system** that controls who can access which accounts and perform what actions. The system supports permission inheritance, making it easy to grant access to entire account hierarchies with a single permission. +Libra implements a **granular, hierarchical permission system** that controls who can access which accounts and perform what actions. The system supports permission inheritance, making it easy to grant access to entire account hierarchies with a single permission. **Key Features:** - ✅ **Three permission levels**: READ, SUBMIT_EXPENSE, MANAGE @@ -680,7 +680,7 @@ CREATE TABLE account_permissions ( expires_at TIMESTAMP, notes TEXT, - FOREIGN KEY (account_id) REFERENCES castle_accounts (id) + FOREIGN KEY (account_id) REFERENCES libra_accounts (id) ); CREATE INDEX idx_account_permissions_user_id ON account_permissions (user_id); @@ -840,7 +840,7 @@ async def test_expense_submission_without_permission(): ## Summary -The Castle permissions system is **well-designed** with strong features: +The Libra permissions system is **well-designed** with strong features: - Hierarchical inheritance reduces admin burden - Caching provides good performance - Expiration and audit trail support compliance diff --git a/docs/PHASE2_COMPLETE.md b/docs/PHASE2_COMPLETE.md index a9574a0..cc45614 100644 --- a/docs/PHASE2_COMPLETE.md +++ b/docs/PHASE2_COMPLETE.md @@ -36,7 +36,7 @@ Phase 2 of the Beancount-inspired refactor focused on **reconciliation and autom - `POST /api/v1/assertions/{id}/check` - Re-check assertion - `DELETE /api/v1/assertions/{id}` - Delete assertion -- **UI** (`templates/castle/index.html:254-378`): +- **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 @@ -77,7 +77,7 @@ Phase 2 of the Beancount-inspired refactor focused on **reconciliation and autom **Purpose**: Visual dashboard for reconciliation status and quick access to reconciliation tools -**Implementation** (`templates/castle/index.html:380-499`): +**Implementation** (`templates/libra/index.html:380-499`): - **Summary Cards**: - Balance Assertions stats (total, passed, failed, pending) - Journal Entries stats (total, cleared, pending, flagged) @@ -161,7 +161,7 @@ Phase 2 of the Beancount-inspired refactor focused on **reconciliation and autom 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/castle/index.html` - Added assertions and reconciliation UI +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 @@ -186,7 +186,7 @@ Phase 2 of the Beancount-inspired refactor focused on **reconciliation and autom ### Create a Balance Assertion ```bash -curl -X POST http://localhost:5000/castle/api/v1/assertions \ +curl -X POST http://localhost:5000/libra/api/v1/assertions \ -H "X-Api-Key: ADMIN_KEY" \ -H "Content-Type: application/json" \ -d '{ @@ -198,20 +198,20 @@ curl -X POST http://localhost:5000/castle/api/v1/assertions \ ### Get Reconciliation Summary ```bash -curl http://localhost:5000/castle/api/v1/reconciliation/summary \ +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/castle/api/v1/reconciliation/check-all \ +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/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: ADMIN_KEY" +0 2 * * * curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: ADMIN_KEY" ``` ## Testing Checklist @@ -238,7 +238,7 @@ curl -X POST http://localhost:5000/castle/api/v1/reconciliation/check-all \ **Phase 3: Core Logic Refactoring (Medium Priority)** - Create `core/` module with pure accounting logic -- Implement `CastleInventory` for position tracking +- Implement `LibraInventory` for position tracking - Move balance calculation to `core/balance.py` - Add comprehensive validation in `core/validation.py` @@ -256,7 +256,7 @@ curl -X POST http://localhost:5000/castle/api/v1/reconciliation/check-all \ ## Conclusion -Phase 2 successfully implements Beancount's reconciliation philosophy in the Castle extension. With balance assertions, comprehensive reconciliation APIs, a visual dashboard, and automated daily checks, users can: +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 diff --git a/docs/PHASE3_COMPLETE.md b/docs/PHASE3_COMPLETE.md index 1a3dbb6..b53625a 100644 --- a/docs/PHASE3_COMPLETE.md +++ b/docs/PHASE3_COMPLETE.md @@ -21,13 +21,13 @@ Phase 3 of the Beancount-inspired refactor focused on **separating business logi - Easier to audit and verify - Clear architecture -### 2. CastleInventory for Position Tracking ✅ +### 2. LibraInventory for Position Tracking ✅ **Purpose**: Track balances across multiple currencies with cost basis information (following Beancount's Inventory pattern) **Implementation** (`core/inventory.py`): -**CastlePosition** (Lines 11-84): +**LibraPosition** (Lines 11-84): - Immutable dataclass representing a single position - Tracks currency, amount, cost basis, and metadata - Supports addition and negation operations @@ -35,7 +35,7 @@ Phase 3 of the Beancount-inspired refactor focused on **separating business logi ```python @dataclass(frozen=True) -class CastlePosition: +class LibraPosition: currency: str # "SATS", "EUR", "USD" amount: Decimal cost_currency: Optional[str] = None @@ -44,7 +44,7 @@ class CastlePosition: metadata: Dict[str, Any] = field(default_factory=dict) ``` -**CastleInventory** (Lines 87-201): +**LibraInventory** (Lines 87-201): - Container for multiple positions - Positions keyed by `(currency, cost_currency)` tuple - Methods for querying balances: @@ -83,7 +83,7 @@ class AccountType(str, Enum): - Liabilities/Equity/Revenue: Credit balance (credit - debit) 2. **`build_inventory_from_entry_lines()`** (Lines 56-117): - - Build CastleInventory from journal entry lines + - Build LibraInventory from journal entry lines - Handles both sats and fiat currency tracking - Accounts for account type when determining sign @@ -123,7 +123,7 @@ class AccountType(str, Enum): - Checks both sats and fiat within tolerance 3. **`validate_receivable_entry()`** (Lines 180-199): - - Validates receivable (user owes castle) entries + - Validates receivable (user owes libra) entries - Ensures positive amount - Ensures revenue account type @@ -216,10 +216,10 @@ views_api.py → crud.py → core/ ## File Structure ``` -lnbits/extensions/castle/ +lnbits/extensions/libra/ ├── core/ │ ├── __init__.py # Module exports -│ ├── inventory.py # CastleInventory, CastlePosition +│ ├── inventory.py # LibraInventory, LibraPosition │ ├── balance.py # BalanceCalculator │ └── validation.py # Validation functions ├── crud.py # DB operations (refactored to use core/) @@ -230,22 +230,22 @@ lnbits/extensions/castle/ ## Usage Examples -### Using CastleInventory +### Using LibraInventory ```python from decimal import Decimal -from castle.core.inventory import CastleInventory, CastlePosition +from libra.core.inventory import LibraInventory, LibraPosition # Create inventory -inv = CastleInventory() +inv = LibraInventory() # Add positions -inv.add_position(CastlePosition( +inv.add_position(LibraPosition( currency="SATS", amount=Decimal("100000") )) -inv.add_position(CastlePosition( +inv.add_position(LibraPosition( currency="SATS", amount=Decimal("50000"), cost_currency="EUR", @@ -264,7 +264,7 @@ data = inv.to_dict() ### Using BalanceCalculator ```python -from castle.core.balance import BalanceCalculator, AccountType +from libra.core.balance import BalanceCalculator, AccountType # Calculate account balance balance = BalanceCalculator.calculate_account_balance( @@ -297,7 +297,7 @@ is_valid = BalanceCalculator.check_balance_matches( ### Using Validation ```python -from castle.core.validation import validate_journal_entry, ValidationError +from libra.core.validation import validate_journal_entry, ValidationError entry = { "id": "abc123", @@ -320,8 +320,8 @@ except ValidationError as e: ## Testing Checklist -- [x] CastleInventory created and tested -- [x] CastlePosition addition works +- [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 @@ -348,10 +348,10 @@ except ValidationError as e: ## Conclusion -Phase 3 successfully refactors Castle's accounting logic into a clean, testable core module. By following Beancount's architecture patterns, we've created: +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 -- **CastleInventory** for position tracking across currencies +- **LibraInventory** for position tracking across currencies - **BalanceCalculator** for consistent balance calculations - **Comprehensive validation** for data integrity diff --git a/docs/SATS-EQUIVALENT-METADATA.md b/docs/SATS-EQUIVALENT-METADATA.md index 48ab36c..6a4e61f 100644 --- a/docs/SATS-EQUIVALENT-METADATA.md +++ b/docs/SATS-EQUIVALENT-METADATA.md @@ -8,21 +8,21 @@ ## Overview -The `sats-equivalent` metadata field is Castle's solution for **dual-currency tracking** in a fiat-denominated ledger. It preserves Bitcoin (satoshi) amounts alongside fiat currency amounts without violating accounting principles or creating multi-currency complexity in position balances. +The `sats-equivalent` metadata field is Libra's solution for **dual-currency tracking** in a fiat-denominated ledger. It preserves Bitcoin (satoshi) amounts alongside fiat currency amounts without violating accounting principles or creating multi-currency complexity in position balances. ### Quick Summary - **Purpose**: Track Bitcoin/Lightning amounts in a EUR-denominated ledger - **Location**: Beancount posting metadata (not position amounts) - **Format**: String containing absolute satoshi amount (e.g., `"337096"`) -- **Primary Use**: Calculate user balances in satoshis (Castle's primary currency) +- **Primary Use**: Calculate user balances in satoshis (Libra's primary currency) - **Key Principle**: Satoshis are for reference; EUR is the actual transaction currency --- ## The Problem: Dual-Currency Tracking -Castle needs to track both: +Libra needs to track both: 1. **Fiat amounts** (EUR, USD) - The actual transaction currency 2. **Bitcoin amounts** (satoshis) - The Lightning Network settlement currency @@ -34,7 +34,7 @@ Castle needs to track both: - ❌ Complicate traditional accounting reconciliation - ❌ Make fiat-based reporting difficult -**Castle's Philosophy**: Record transactions in their **actual currency**, with Bitcoin as supplementary data. +**Libra's Philosophy**: Record transactions in their **actual currency**, with Bitcoin as supplementary data. --- @@ -88,7 +88,7 @@ if fiat_currency and fiat_amount: ### Primary Use Case: User Balances -Castle's core function is tracking **who owes whom** in satoshis. The `sats-equivalent` metadata enables this. +Libra's core function is tracking **who owes whom** in satoshis. The `sats-equivalent` metadata enables this. **Flow** (`fava_client.py:220-248`): @@ -147,7 +147,7 @@ SELECT account, sum(meta["sats-equivalent"]) WHERE account ~ 'User-5987ae95' -- Error: BQL cannot access metadata ``` -### Why Castle Accepts This Trade-off +### Why Libra Accepts This Trade-off **Performance Analysis** (see `docs/BQL-BALANCE-QUERIES.md`): 1. **Caching solves the bottleneck**: 60-80% performance improvement from caching account/permission lookups @@ -196,9 +196,9 @@ See `docs/BQL-PRICE-NOTATION-SOLUTION.md` for full analysis. **User Action**: "I paid €36.93 cash for groceries" -**Castle's Internal Representation**: +**Libra's Internal Representation**: ```python -# User provides or Castle calculates: +# User provides or Libra calculates: fiat_amount = Decimal("36.93") # EUR fiat_currency = "EUR" amount_sats = 39669 # Calculated from exchange rate @@ -232,16 +232,16 @@ line = CreateEntryLine( # - Apply sign: -36.93 is negative → sats = -39669 # - Accumulate: user_balance_sats += -39669 -# Result: negative balance = Castle owes user +# Result: negative balance = Libra owes user ``` **User Balance Response**: ```json { "user_id": "5987ae95", - "balance": -39669, // Castle owes user 39,669 sats + "balance": -39669, // Libra owes user 39,669 sats "fiat_balances": { - "EUR": "-36.93" // Castle owes user €36.93 + "EUR": "-36.93" // Libra owes user €36.93 } } ``` @@ -306,7 +306,7 @@ The `sats-equivalent` is the **exact satoshi amount at transaction time**. It do ### 3. Separate Fiat and Sats Balances -Castle tracks TWO independent balances: +Libra tracks TWO independent balances: - **Satoshi balance**: Sum of `sats-equivalent` metadata (primary) - **Fiat balances**: Sum of EUR/USD position amounts (secondary) diff --git a/docs/UI-IMPROVEMENTS-PLAN.md b/docs/UI-IMPROVEMENTS-PLAN.md index 97bc9d3..ca3d828 100644 --- a/docs/UI-IMPROVEMENTS-PLAN.md +++ b/docs/UI-IMPROVEMENTS-PLAN.md @@ -1,4 +1,4 @@ -# Castle UI Improvements Plan +# Libra UI Improvements Plan **Date**: November 10, 2025 **Status**: 📋 **Planning Document** @@ -8,7 +8,7 @@ ## Overview -Enhance the Castle permissions UI to showcase new bulk permission management and account sync features, making admin tasks faster and more intuitive. +Enhance the Libra permissions UI to showcase new bulk permission management and account sync features, making admin tasks faster and more intuitive. --- @@ -230,7 +230,7 @@ Enhance the Castle permissions UI to showcase new bulk permission management and │ │ │ ⚠️ Warning: This will revoke ALL │ │ permissions for this user. They will │ -│ immediately lose access to Castle. │ +│ immediately lose access to Libra. │ │ │ │ Reason for Offboarding │ │ [Employee departure - last day] │ @@ -257,13 +257,13 @@ Enhance the Castle permissions UI to showcase new bulk permission management and ├───────────────────────────────────────────┤ │ │ │ Sync accounts from your Beancount ledger │ -│ to Castle database for permission mgmt. │ +│ to Libra database for permission mgmt. │ │ │ │ Last Sync: 2 hours ago │ │ Status: ✅ Up to date │ │ │ │ Accounts in Beancount: 150 │ -│ Accounts in Castle DB: 150 │ +│ Accounts in Libra DB: 150 │ │ │ │ Options: │ │ ☐ Force full sync (re-check all) │ @@ -509,7 +509,7 @@ permissions.html syncStatus: { lastSync: null, beancountAccounts: 0, - castleAccounts: 0, + libraAccounts: 0, status: 'idle' } } diff --git a/fava_client.py b/fava_client.py index 27e147d..572cd95 100644 --- a/fava_client.py +++ b/fava_client.py @@ -1,5 +1,5 @@ """ -Fava API client for Castle. +Fava API client for Libra. This module provides an async HTTP client for interacting with Fava's JSON API. All accounting logic is delegated to Fava/Beancount. @@ -46,7 +46,7 @@ class FavaClient: Args: fava_url: Base URL of Fava server (e.g., http://localhost:3333) - ledger_slug: URL-safe ledger identifier (e.g., castle-accounting) + ledger_slug: URL-safe ledger identifier (e.g., libra-accounting) timeout: Request timeout in seconds """ self.fava_url = fava_url.rstrip('/') @@ -169,7 +169,7 @@ class FavaClient: Args: entry: Beancount entry dict (same format as add_entry) - idempotency_key: Unique key for this operation (e.g., "castle-{uuid}" or "ln-{payment_hash}") + idempotency_key: Unique key for this operation (e.g., "libra-{uuid}" or "ln-{payment_hash}") Returns: Response from Fava if entry was created, or existing entry data if already exists @@ -289,18 +289,18 @@ class FavaClient: async def get_user_balance(self, user_id: str) -> Dict[str, Any]: """ - Get user's balance from castle's perspective. + Get user's balance from libra's perspective. Aggregates: - - Liabilities:Payable:User-{user_id} (negative = castle owes user) - - Assets:Receivable:User-{user_id} (positive = user owes castle) + - Liabilities:Payable:User-{user_id} (negative = libra owes user) + - Assets:Receivable:User-{user_id} (positive = user owes libra) Args: user_id: User ID Returns: { - "balance": int (sats, positive = user owes castle, negative = castle owes user), + "balance": int (sats, positive = user owes libra, negative = libra owes user), "fiat_balances": {"EUR": Decimal("100.50")}, "accounts": [list of account dicts with balances] } @@ -676,12 +676,12 @@ class FavaClient: Use this for efficient aggregations, filtering, and data retrieval. ⚠️ LIMITATION: BQL can only query position amounts and transaction-level data. - It CANNOT access posting metadata (like 'sats-equivalent'). For Castle's current + It CANNOT access posting metadata (like 'sats-equivalent'). For Libra's current ledger format where SATS are stored in metadata, manual aggregation is required. See: docs/BQL-BALANCE-QUERIES.md for detailed analysis and test results. - FUTURE CONSIDERATION: If Castle's ledger format changes to use SATS as position + FUTURE CONSIDERATION: If Libra's ledger format changes to use SATS as position amounts (instead of metadata), BQL could provide significant performance benefits. Args: @@ -1031,7 +1031,7 @@ class FavaClient: Get total expense contributions per user using BQL. Uses sum(weight) to aggregate all expenses each user has submitted - that created liabilities (castle owes user). + that created liabilities (libra owes user). Returns: List of user contribution summaries: @@ -1601,8 +1601,8 @@ class FavaClient: Args: user_id: User ID (first 8 characters used for account matching) - entry_type: "expense" (payables - castle owes user) or - "receivable" (user owes castle) + entry_type: "expense" (payables - libra owes user) or + "receivable" (user owes libra) Returns: List of unsettled entries with: @@ -1742,8 +1742,8 @@ class FavaClient: Args: user_id: User ID (first 8 characters used for account matching) - entry_type: "expense" (payables - castle owes user) or - "receivable" (user owes castle) + entry_type: "expense" (payables - libra owes user) or + "receivable" (user owes libra) Returns: List of unsettled entries with: @@ -1896,6 +1896,6 @@ def get_fava_client() -> FavaClient: if _fava_client is None: raise RuntimeError( "Fava client not initialized. Call init_fava_client() first. " - "Castle requires Fava for all accounting operations." + "Libra requires Fava for all accounting operations." ) return _fava_client diff --git a/helper/README.md b/helper/README.md index 648b987..c9dabaa 100644 --- a/helper/README.md +++ b/helper/README.md @@ -1,6 +1,6 @@ -# Castle Beancount Import Helper +# Libra Beancount Import Helper -Import Beancount ledger transactions into Castle accounting extension. +Import Beancount ledger transactions into Libra accounting extension. ## 📁 Files @@ -40,14 +40,14 @@ USER_MAPPINGS = { ### 3. Set API Key ```bash -export CASTLE_ADMIN_KEY="your_lnbits_admin_invoice_key" +export LIBRA_ADMIN_KEY="your_lnbits_admin_invoice_key" export LNBITS_URL="http://localhost:5000" # Optional ``` ## 📖 Usage ```bash -cd /path/to/castle/helper +cd /path/to/libra/helper # Test with dry run python import_beancount.py ledger.beancount --dry-run @@ -72,30 +72,30 @@ Your Beancount transactions must have an `Equity:` account: **Requirements:** - Every transaction must have an `Equity:` account -- Account names must match exactly what's in Castle +- Account names must match exactly what's in Libra - The name after `Equity:` must be in `USER_MAPPINGS` ## 🔄 How It Works 1. **Loads rates** from `btc_eur_rates.csv` -2. **Loads accounts** from Castle API automatically +2. **Loads accounts** from Libra API automatically 3. **Maps users** - Extracts user name from `Equity:Name` accounts 4. **Parses** Beancount transactions 5. **Converts** EUR → sats using daily rate -6. **Uploads** to Castle with metadata +6. **Uploads** to Libra with metadata ## 📊 Example Output ```bash $ python import_beancount.py ledger.beancount ====================================================================== -🏰 Beancount to Castle Import Script +🏰 Beancount to Libra Import Script ====================================================================== 📊 Loaded 15 daily rates from btc_eur_rates.csv Date range: 2025-07-01 to 2025-07-15 -🏦 Loaded 28 accounts from Castle +🏦 Loaded 28 accounts from Libra 👥 User ID mappings: - Pat → wallet_abc123 @@ -112,15 +112,15 @@ $ python import_beancount.py ledger.beancount 📊 Summary: 25 succeeded, 0 failed, 0 skipped ====================================================================== -✅ Successfully imported 25 transactions to Castle! +✅ Successfully imported 25 transactions to Libra! ``` ## ❓ Troubleshooting -### "No account found in Castle" -**Error:** `No account found in Castle with name 'Expenses:XYZ'` +### "No account found in Libra" +**Error:** `No account found in Libra with name 'Expenses:XYZ'` -**Solution:** Create the account in Castle first with that exact name. +**Solution:** Create the account in Libra first with that exact name. ### "No user ID mapping found" **Error:** `No user ID mapping found for 'Pat'` diff --git a/helper/import_beancount.py b/helper/import_beancount.py index 417d0fd..0632364 100755 --- a/helper/import_beancount.py +++ b/helper/import_beancount.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 """ -Beancount to Castle Import Script +Beancount to Libra Import Script ⚠️ NOTE: This script is for ONE-OFF MIGRATION purposes only. - Now that Castle uses Fava/Beancount as the single source of truth, - the data flow is: Castle → Fava/Beancount (not the reverse). + Now that Libra uses Fava/Beancount as the single source of truth, + the data flow is: Libra → Fava/Beancount (not the reverse). This script was used for initial data import from existing Beancount files. @@ -14,7 +14,7 @@ Beancount to Castle Import Script - REPURPOSE for bidirectional sync if that becomes a requirement - ARCHIVE to misc-docs/old-helpers/ if keeping for reference -Imports Beancount ledger transactions into Castle accounting extension. +Imports Beancount ledger transactions into Libra accounting extension. Reads daily BTC/EUR rates from btc_eur_rates.csv in the same directory. Usage: @@ -35,14 +35,14 @@ from typing import Dict, Optional # LNbits URL and API Key LNBITS_URL = os.environ.get("LNBITS_URL", "http://localhost:5000") -ADMIN_API_KEY = os.environ.get("CASTLE_ADMIN_KEY", "48d787d862484a6c89d6a557b4d5be9d") +ADMIN_API_KEY = os.environ.get("LIBRA_ADMIN_KEY", "48d787d862484a6c89d6a557b4d5be9d") # Rates CSV file (looks in same directory as this script) SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) RATES_CSV_FILE = os.path.join(SCRIPT_DIR, "btc_eur_rates.csv") -# User ID mappings: Equity account name -> Castle user ID (wallet ID) -# TODO: Update these with your actual Castle user/wallet IDs +# User ID mappings: Equity account name -> Libra user ID (wallet ID) +# TODO: Update these with your actual Libra user/wallet IDs USER_MAPPINGS = { "Pat": "75be145a42884b22b60bf97510ed46e3", "Coco": "375ec158ceca46de86cf6561ca20f881", @@ -116,7 +116,7 @@ class RateLookup: # ===== ACCOUNT LOOKUP ===== class AccountLookup: - """Fetch and lookup Castle accounts from API""" + """Fetch and lookup Libra accounts from API""" def __init__(self, lnbits_url: str, api_key: str): self.accounts = {} # name -> account_id @@ -125,8 +125,8 @@ class AccountLookup: self._fetch_accounts(lnbits_url, api_key) def _fetch_accounts(self, lnbits_url: str, api_key: str): - """Fetch all accounts from Castle API""" - url = f"{lnbits_url}/castle/api/v1/accounts" + """Fetch all accounts from Libra API""" + url = f"{lnbits_url}/libra/api/v1/accounts" headers = {"X-Api-Key": api_key} try: @@ -153,28 +153,28 @@ class AccountLookup: self.accounts_by_user[user_id] = {} self.accounts_by_user[user_id][account_type] = account_id - print(f"🏦 Loaded {len(self.accounts)} accounts from Castle") + print(f"🏦 Loaded {len(self.accounts)} accounts from Libra") except requests.RequestException as e: - raise ConnectionError(f"Failed to fetch accounts from Castle API: {e}") + raise ConnectionError(f"Failed to fetch accounts from Libra API: {e}") def get_account_id(self, account_name: str) -> Optional[str]: """ - Get Castle account ID for a Beancount account name. + Get Libra account ID for a Beancount account name. Special handling for user-specific accounts: - - "Liabilities:Payable:Pat" -> looks up Pat's user_id and finds their Castle payable account - - "Assets:Receivable:Pat" -> looks up Pat's user_id and finds their Castle receivable account - - "Equity:Pat" -> looks up Pat's user_id and finds their Castle equity account + - "Liabilities:Payable:Pat" -> looks up Pat's user_id and finds their Libra payable account + - "Assets:Receivable:Pat" -> looks up Pat's user_id and finds their Libra receivable account + - "Equity:Pat" -> looks up Pat's user_id and finds their Libra equity account Args: account_name: Beancount account name (e.g., "Expenses:Food:Supplies", "Liabilities:Payable:Pat", "Assets:Receivable:Pat", "Equity:Pat") Returns: - Castle account UUID or None if not found + Libra account UUID or None if not found """ # Check if this is a Liabilities:Payable: account - # Map Beancount Liabilities:Payable:Pat to Castle Liabilities:Payable:User- + # Map Beancount Liabilities:Payable:Pat to Libra Liabilities:Payable:User- if account_name.startswith("Liabilities:Payable:"): user_name = extract_user_from_user_account(account_name) if user_name: @@ -182,7 +182,7 @@ class AccountLookup: user_id = USER_MAPPINGS.get(user_name) if user_id: # Find this user's liability (payable) account - # This is the Liabilities:Payable:User- account in Castle + # This is the Liabilities:Payable:User- account in Libra if user_id in self.accounts_by_user: liability_account_id = self.accounts_by_user[user_id].get('liability') if liability_account_id: @@ -196,7 +196,7 @@ class AccountLookup: ) # Check if this is an Assets:Receivable: account - # Map Beancount Assets:Receivable:Pat to Castle Assets:Receivable:User- + # Map Beancount Assets:Receivable:Pat to Libra Assets:Receivable:User- elif account_name.startswith("Assets:Receivable:"): user_name = extract_user_from_user_account(account_name) if user_name: @@ -204,7 +204,7 @@ class AccountLookup: user_id = USER_MAPPINGS.get(user_name) if user_id: # Find this user's asset (receivable) account - # This is the Assets:Receivable:User- account in Castle + # This is the Assets:Receivable:User- account in Libra if user_id in self.accounts_by_user: asset_account_id = self.accounts_by_user[user_id].get('asset') if asset_account_id: @@ -218,7 +218,7 @@ class AccountLookup: ) # Check if this is an Equity: account - # Map Beancount Equity:Pat to Castle Equity:User- + # Map Beancount Equity:Pat to Libra Equity:User- elif account_name.startswith("Equity:"): user_name = extract_user_from_user_account(account_name) if user_name: @@ -226,7 +226,7 @@ class AccountLookup: user_id = USER_MAPPINGS.get(user_name) if user_id: # Find this user's equity account - # This is the Equity:User- account in Castle + # This is the Equity:User- account in Libra if user_id in self.accounts_by_user: equity_account_id = self.accounts_by_user[user_id].get('equity') if equity_account_id: @@ -235,7 +235,7 @@ class AccountLookup: # If not found, provide helpful error raise ValueError( f"User '{user_name}' (ID: {user_id}) does not have an equity account.\n" - f"Equity eligibility must be enabled for this user in Castle.\n" + f"Equity eligibility must be enabled for this user in Libra.\n" f"Please enable equity for user ID: {user_id}" ) @@ -282,7 +282,7 @@ def eur_to_sats(eur_amount: Decimal, btc_eur_rate: float) -> int: def build_metadata(eur_amount: Decimal, btc_eur_rate: float) -> dict: """ - Build metadata dict for Castle entry line. + Build metadata dict for Libra entry line. The API will extract fiat_currency and fiat_amount and use them to create proper EUR-based postings with SATS in metadata. @@ -441,13 +441,13 @@ def determine_user_id(postings: list) -> Optional[str]: # No user-specific account found - this shouldn't happen for typical transactions return None -# ===== CASTLE CONVERTER ===== +# ===== LIBRA CONVERTER ===== -def convert_to_castle_entry(parsed: dict, btc_eur_rate: float, account_lookup: AccountLookup) -> dict: +def convert_to_libra_entry(parsed: dict, btc_eur_rate: float, account_lookup: AccountLookup) -> dict: """ - Convert parsed Beancount transaction to Castle format. + Convert parsed Beancount transaction to Libra format. - Sends SATS amounts with fiat metadata. The Castle API will automatically + Sends SATS amounts with fiat metadata. The Libra API will automatically convert to EUR-based postings with SATS stored in metadata. """ @@ -469,8 +469,8 @@ def convert_to_castle_entry(parsed: dict, btc_eur_rate: float, account_lookup: A account_id = account_lookup.get_account_id(posting['account']) if not account_id: raise ValueError( - f"No account found in Castle with name '{posting['account']}'.\n" - f"Please create this account in Castle first." + f"No account found in Libra with name '{posting['account']}'.\n" + f"Please create this account in Libra first." ) eur_amount = posting['eur_amount'] @@ -510,7 +510,7 @@ def convert_to_castle_entry(parsed: dict, btc_eur_rate: float, account_lookup: A # ===== API UPLOAD ===== def upload_entry(entry: dict, api_key: str, dry_run: bool = False) -> dict: - """Upload journal entry to Castle API""" + """Upload journal entry to Libra API""" if dry_run: print(f"\n[DRY RUN] Entry preview:") print(f" Description: {entry['description']}") @@ -525,7 +525,7 @@ def upload_entry(entry: dict, api_key: str, dry_run: bool = False) -> dict: print(f" Balance check: {total_sats} (should be 0)") return {"id": "dry-run"} - url = f"{LNBITS_URL}/castle/api/v1/entries" + url = f"{LNBITS_URL}/libra/api/v1/entries" headers = { "X-Api-Key": api_key, "Content-Type": "application/json" @@ -551,7 +551,7 @@ def import_beancount_file(beancount_file: str, dry_run: bool = False): # Validate configuration if not ADMIN_API_KEY: - print("❌ Error: CASTLE_ADMIN_KEY not set!") + print("❌ Error: LIBRA_ADMIN_KEY not set!") print(" Set it as environment variable or update ADMIN_API_KEY in the script.") return @@ -562,7 +562,7 @@ def import_beancount_file(beancount_file: str, dry_run: bool = False): print(f"❌ Error loading rates: {e}") return - # Load accounts from Castle + # Load accounts from Libra try: account_lookup = AccountLookup(LNBITS_URL, ADMIN_API_KEY) except (ConnectionError, ValueError) as e: @@ -574,7 +574,7 @@ def import_beancount_file(beancount_file: str, dry_run: bool = False): for name, user_id in USER_MAPPINGS.items(): has_equity = user_id in account_lookup.accounts_by_user and 'equity' in account_lookup.accounts_by_user[user_id] status = "✅" if has_equity else "❌" - print(f" {status} {name} → {user_id} {'(has equity account)' if has_equity else '(NO EQUITY ACCOUNT - create in Castle!)'}") + print(f" {status} {name} → {user_id} {'(has equity account)' if has_equity else '(NO EQUITY ACCOUNT - create in Libra!)'}") # Read beancount file if not os.path.exists(beancount_file): @@ -612,8 +612,8 @@ def import_beancount_file(beancount_file: str, dry_run: bool = False): if not btc_eur_rate: raise ValueError(f"No BTC/EUR rate found for {parsed['date'].date()}") - castle_entry = convert_to_castle_entry(parsed, btc_eur_rate, account_lookup) - result = upload_entry(castle_entry, ADMIN_API_KEY, dry_run) + libra_entry = convert_to_libra_entry(parsed, btc_eur_rate, account_lookup) + result = upload_entry(libra_entry, ADMIN_API_KEY, dry_run) # Get user name for display user_name = None @@ -643,7 +643,7 @@ def import_beancount_file(beancount_file: str, dry_run: bool = False): print(f" {item}") if success_count > 0 and not dry_run: - print(f"\n✅ Successfully imported {success_count} transactions to Castle!") + print(f"\n✅ Successfully imported {success_count} transactions to Libra!") print(f"\n💡 Note: Transactions are stored in EUR with SATS in metadata.") print(f" Check Fava to see the imported entries.") @@ -653,7 +653,7 @@ if __name__ == "__main__": import sys print("=" * 70) - print("🏰 Beancount to Castle Import Script") + print("🏰 Beancount to Libra Import Script") print("=" * 70) if len(sys.argv) < 2: @@ -664,7 +664,7 @@ if __name__ == "__main__": print("\nConfiguration:") print(f" LNBITS_URL: {LNBITS_URL}") print(f" RATES_CSV: {RATES_CSV_FILE}") - print(f" API Key set: {'Yes' if ADMIN_API_KEY else 'No (set CASTLE_ADMIN_KEY env var)'}") + print(f" API Key set: {'Yes' if ADMIN_API_KEY else 'No (set LIBRA_ADMIN_KEY env var)'}") sys.exit(1) beancount_file = sys.argv[1] diff --git a/manifest.json b/manifest.json index 1a8c320..d7e25d2 100644 --- a/manifest.json +++ b/manifest.json @@ -1,9 +1,9 @@ { "repos": [ { - "id": "castle", + "id": "libra", "organisation": "lnbits", - "repository": "castle" + "repository": "libra" } ] } diff --git a/migrations.py b/migrations.py index c9a7e30..3cff47b 100644 --- a/migrations.py +++ b/migrations.py @@ -1,8 +1,8 @@ """ -Castle Extension Database Migrations +Libra Extension Database Migrations This file contains a single squashed migration that creates the complete -database schema for the Castle extension. +database schema for the Libra extension. MIGRATION HISTORY: This is a squashed migration that combines m001-m016 from the original @@ -39,19 +39,19 @@ Original migration sequence (Nov 2025): async def m001_initial(db): """ - Initial Castle database schema (squashed from m001-m016). + Initial Libra database schema (squashed from m001-m016). - Creates complete database structure for Castle accounting extension: + Creates complete database structure for Libra accounting extension: - Accounts: Chart of accounts with hierarchical Beancount-style names - - Extension settings: Castle-wide configuration + - Extension settings: Libra-wide configuration - User wallet settings: Per-user wallet configuration - - Manual payment requests: User-submitted payment requests to Castle + - Manual payment requests: User-submitted payment requests to Libra - Balance assertions: Reconciliation and balance checking - User equity status: Equity contribution eligibility - Account permissions: Granular access control Note: Journal entries are managed by Fava/Beancount (external source of truth). - Castle submits entries to Fava and queries Fava for journal data. + Libra submits entries to Fava and queries Fava for journal data. """ # ========================================================================= @@ -89,15 +89,15 @@ async def m001_initial(db): # ========================================================================= # EXTENSION SETTINGS TABLE # ========================================================================= - # Castle-wide configuration settings + # Libra-wide configuration settings await db.execute( f""" CREATE TABLE extension_settings ( id TEXT NOT NULL PRIMARY KEY, - castle_wallet_id TEXT, + libra_wallet_id TEXT, fava_url TEXT NOT NULL DEFAULT 'http://localhost:3333', - fava_ledger_slug TEXT NOT NULL DEFAULT 'castle-ledger', + fava_ledger_slug TEXT NOT NULL DEFAULT 'libra-ledger', fava_timeout REAL NOT NULL DEFAULT 10.0, updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} ); @@ -122,7 +122,7 @@ async def m001_initial(db): # ========================================================================= # MANUAL PAYMENT REQUESTS TABLE # ========================================================================= - # User-submitted payment requests to Castle (reviewed by admins) + # User-submitted payment requests to Libra (reviewed by admins) await db.execute( f""" @@ -362,7 +362,7 @@ async def m003_add_account_is_virtual(db): Add is_virtual field to accounts table for virtual parent accounts. Virtual parent accounts: - - Exist only in Castle DB (metadata-only, not in Beancount) + - Exist only in Libra DB (metadata-only, not in Beancount) - Used solely for permission inheritance - Allow granting permissions on top-level accounts like "Expenses", "Assets" - Are not synced to/from Beancount diff --git a/models.py b/models.py index 3b47210..eaf75c4 100644 --- a/models.py +++ b/models.py @@ -87,7 +87,7 @@ class CreateJournalEntry(BaseModel): class UserBalance(BaseModel): user_id: str - balance: int # positive = castle owes user, negative = user owes castle + balance: int # positive = libra owes user, negative = user owes libra accounts: list[Account] = [] fiat_balances: dict[str, Decimal] = {} # e.g. {"EUR": Decimal("250.0"), "USD": Decimal("100.0")} @@ -98,7 +98,7 @@ class ExpenseEntry(BaseModel): description: str amount: Decimal # Amount in the specified currency (or satoshis if currency is None) expense_account: str # account name or ID - is_equity: bool = False # True = equity contribution, False = liability (castle owes user) + is_equity: bool = False # True = equity contribution, False = liability (libra owes user) user_wallet: str reference: Optional[str] = None currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code (EUR, USD, etc.) @@ -111,7 +111,7 @@ class ReceivableEntry(BaseModel): description: str amount: Decimal # Amount in the specified currency (or satoshis if currency is None) revenue_account: str # account name or ID - user_id: str # The user_id (not wallet_id) of the user who owes the castle + user_id: str # The user_id (not wallet_id) of the user who owes the libra reference: Optional[str] = None currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code @@ -127,14 +127,14 @@ class RevenueEntry(BaseModel): currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code -class CastleSettings(BaseModel): - """Settings for the Castle extension""" +class LibraSettings(BaseModel): + """Settings for the Libra extension""" - castle_wallet_id: Optional[str] = None # The wallet ID that represents the Castle + libra_wallet_id: Optional[str] = None # The wallet ID that represents the Libra # Fava/Beancount integration - ALL accounting is done via Fava fava_url: str = "http://localhost:3333" # Base URL of Fava server - fava_ledger_slug: str = "castle-ledger" # Ledger identifier in Fava URL + fava_ledger_slug: str = "libra-ledger" # Ledger identifier in Fava URL fava_timeout: float = 10.0 # Request timeout in seconds updated_at: datetime = Field(default_factory=lambda: datetime.now()) @@ -144,7 +144,7 @@ class CastleSettings(BaseModel): return True -class UserCastleSettings(CastleSettings): +class UserLibraSettings(LibraSettings): """User-specific settings (stored with user_id)""" id: str @@ -164,7 +164,7 @@ class StoredUserWalletSettings(UserWalletSettings): class ManualPaymentRequest(BaseModel): - """Manual payment request from user to castle""" + """Manual payment request from user to libra""" id: str user_id: str @@ -173,7 +173,7 @@ class ManualPaymentRequest(BaseModel): status: str = "pending" # pending, approved, rejected created_at: datetime reviewed_at: Optional[datetime] = None - reviewed_by: Optional[str] = None # user_id of castle admin who reviewed + reviewed_by: Optional[str] = None # user_id of libra admin who reviewed journal_entry_id: Optional[str] = None # set when approved @@ -198,7 +198,7 @@ class RecordPayment(BaseModel): class SettleReceivable(BaseModel): - """Manually settle a receivable (user pays castle in person)""" + """Manually settle a receivable (user pays libra in person)""" user_id: str amount: Decimal # Amount in the specified currency (or satoshis if currency is None) @@ -213,7 +213,7 @@ class SettleReceivable(BaseModel): class PayUser(BaseModel): - """Pay a user (castle pays user for expense/liability)""" + """Pay a user (libra pays user for expense/liability)""" user_id: str amount: Decimal # Amount in the specified currency (or satoshis if currency is None) diff --git a/package.json b/package.json index f479115..8965642 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "castle", + "name": "libra", "version": "0.0.2", "description": "Accounting for a collective entity", "main": "index.js", diff --git a/permission_management.py b/permission_management.py index 7dea217..80f63ff 100644 --- a/permission_management.py +++ b/permission_management.py @@ -361,7 +361,7 @@ async def get_permission_analytics() -> dict: """ SELECT ap.*, a.name as account_name FROM account_permissions ap - JOIN castle_accounts a ON ap.account_id = a.id + JOIN libra_accounts a ON ap.account_id = a.id WHERE ap.expires_at IS NOT NULL AND ap.expires_at > :now AND ap.expires_at <= :seven_days @@ -385,7 +385,7 @@ async def get_permission_analytics() -> dict: top_accounts_result = await db.fetchall( """ SELECT a.name, COUNT(ap.id) as permission_count - FROM castle_accounts a + FROM libra_accounts a LEFT JOIN account_permissions ap ON a.id = ap.account_id GROUP BY a.id, a.name HAVING COUNT(ap.id) > 0 diff --git a/services.py b/services.py index 51c4bd8..6776c05 100644 --- a/services.py +++ b/services.py @@ -1,32 +1,32 @@ from .crud import ( - create_castle_settings, + create_libra_settings, create_user_wallet_settings, - get_castle_settings, + get_libra_settings, get_or_create_user_account, get_user_wallet_settings, - update_castle_settings, + update_libra_settings, update_user_wallet_settings, ) -from .models import AccountType, CastleSettings, UserWalletSettings +from .models import AccountType, LibraSettings, UserWalletSettings -async def get_settings(user_id: str) -> CastleSettings: - settings = await get_castle_settings(user_id) +async def get_settings(user_id: str) -> LibraSettings: + settings = await get_libra_settings(user_id) if not settings: - settings = await create_castle_settings(user_id, CastleSettings()) + settings = await create_libra_settings(user_id, LibraSettings()) return settings -async def update_settings(user_id: str, data: CastleSettings) -> CastleSettings: +async def update_settings(user_id: str, data: LibraSettings) -> LibraSettings: from loguru import logger from .fava_client import init_fava_client - settings = await get_castle_settings(user_id) + settings = await get_libra_settings(user_id) if not settings: - settings = await create_castle_settings(user_id, data) + settings = await create_libra_settings(user_id, data) else: - settings = await update_castle_settings(user_id, data) + settings = await update_libra_settings(user_id, data) # Reinitialize Fava client with new settings try: diff --git a/static/image/castle.png b/static/image/libra.png similarity index 100% rename from static/image/castle.png rename to static/image/libra.png diff --git a/static/js/index.js b/static/js/index.js index 4cc5c06..7c01c07 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -32,7 +32,7 @@ window.app = Vue.createApp({ isAdmin: false, isSuperUser: false, settingsLoaded: false, // Flag to prevent race conditions on toolbar buttons - castleWalletConfigured: false, + libraWalletConfigured: false, userWalletConfigured: false, syncingAccounts: false, currentExchangeRate: null, // BTC/EUR rate (sats per EUR) @@ -58,9 +58,9 @@ window.app = Vue.createApp({ }, settingsDialog: { show: false, - castleWalletId: '', + libraWalletId: '', favaUrl: 'http://localhost:3333', - favaLedgerSlug: 'castle-ledger', + favaLedgerSlug: 'libra-ledger', favaTimeout: 10.0, loading: false }, @@ -208,8 +208,8 @@ window.app = Vue.createApp({ accountTypeOptions() { return [ { label: 'All Types', value: null }, - { label: 'Receivable (User owes Castle)', value: 'asset' }, - { label: 'Payable (Castle owes User)', value: 'liability' }, + { label: 'Receivable (User owes Libra)', value: 'asset' }, + { label: 'Payable (Libra owes User)', value: 'liability' }, { label: 'Equity (User Balance)', value: 'equity' } ] }, @@ -318,7 +318,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/castle/api/v1/balance', + '/libra/api/v1/balance', this.g.user.wallets[0].inkey ) this.balance = response.data @@ -341,7 +341,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/castle/api/v1/balances/all', + '/libra/api/v1/balances/all', this.g.user.wallets[0].adminkey ) this.allUserBalances = response.data @@ -389,7 +389,7 @@ window.app = Vue.createApp({ const response = await LNbits.api.request( 'GET', - `/castle/api/v1/entries/user?${queryParams}`, + `/libra/api/v1/entries/user?${queryParams}`, this.g.user.wallets[0].inkey ) @@ -458,7 +458,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/castle/api/v1/accounts?filter_by_user=true&exclude_virtual=true', + '/libra/api/v1/accounts?filter_by_user=true&exclude_virtual=true', this.g.user.wallets[0].inkey ) this.accounts = response.data @@ -472,7 +472,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/castle/api/v1/currencies', + '/libra/api/v1/currencies', this.g.user.wallets[0].inkey ) this.currencies = response.data @@ -484,7 +484,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/castle/api/v1/users', + '/libra/api/v1/users', this.g.user.wallets[0].adminkey ) this.users = response.data @@ -496,7 +496,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/castle/api/v1/user/info', + '/libra/api/v1/user/info', this.g.user.wallets[0].inkey ) this.userInfo = response.data @@ -510,18 +510,18 @@ window.app = Vue.createApp({ // Try with admin key first to check settings const response = await LNbits.api.request( 'GET', - '/castle/api/v1/settings', + '/libra/api/v1/settings', this.g.user.wallets[0].inkey ) this.settings = response.data - this.castleWalletConfigured = !!(this.settings && this.settings.castle_wallet_id) + this.libraWalletConfigured = !!(this.settings && this.settings.libra_wallet_id) // Check if user is super user by seeing if they can access admin features this.isSuperUser = this.g.user.super_user || false this.isAdmin = this.g.user.admin || this.isSuperUser } catch (error) { // Settings not available - this.castleWalletConfigured = false + this.libraWalletConfigured = false } finally { // Mark settings as loaded to enable toolbar buttons this.settingsLoaded = true @@ -531,7 +531,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/castle/api/v1/user/wallet', + '/libra/api/v1/user/wallet', this.g.user.wallets[0].inkey ) this.userWalletSettings = response.data @@ -545,7 +545,7 @@ window.app = Vue.createApp({ try { const {data} = await LNbits.api.request( 'POST', - '/castle/api/v1/admin/accounts/sync', + '/libra/api/v1/admin/accounts/sync', this.g.user.wallets[0].adminkey ) const errors = (data?.errors || []).length @@ -567,9 +567,9 @@ window.app = Vue.createApp({ } }, showSettingsDialog() { - this.settingsDialog.castleWalletId = this.settings?.castle_wallet_id || '' + this.settingsDialog.libraWalletId = this.settings?.libra_wallet_id || '' this.settingsDialog.favaUrl = this.settings?.fava_url || 'http://localhost:3333' - this.settingsDialog.favaLedgerSlug = this.settings?.fava_ledger_slug || 'castle-ledger' + this.settingsDialog.favaLedgerSlug = this.settings?.fava_ledger_slug || 'libra-ledger' this.settingsDialog.favaTimeout = this.settings?.fava_timeout || 10.0 this.settingsDialog.show = true }, @@ -578,10 +578,10 @@ window.app = Vue.createApp({ this.userWalletDialog.show = true }, async submitSettings() { - if (!this.settingsDialog.castleWalletId) { + if (!this.settingsDialog.libraWalletId) { this.$q.notify({ type: 'warning', - message: 'Castle Wallet ID is required' + message: 'Libra Wallet ID is required' }) return } @@ -598,12 +598,12 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'PUT', - '/castle/api/v1/settings', + '/libra/api/v1/settings', this.g.user.wallets[0].adminkey, { - castle_wallet_id: this.settingsDialog.castleWalletId, + libra_wallet_id: this.settingsDialog.libraWalletId, fava_url: this.settingsDialog.favaUrl, - fava_ledger_slug: this.settingsDialog.favaLedgerSlug || 'castle-ledger', + fava_ledger_slug: this.settingsDialog.favaLedgerSlug || 'libra-ledger', fava_timeout: parseFloat(this.settingsDialog.favaTimeout) || 10.0 } ) @@ -613,7 +613,7 @@ window.app = Vue.createApp({ }) this.settingsDialog.show = false await this.loadSettings() - // Reload user wallet to reflect castle wallet for super user + // Reload user wallet to reflect libra wallet for super user if (this.isSuperUser) { await this.loadUserWallet() } @@ -636,7 +636,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'PUT', - '/castle/api/v1/user/wallet', + '/libra/api/v1/user/wallet', this.g.user.wallets[0].inkey, { user_wallet_id: this.userWalletDialog.userWalletId @@ -659,7 +659,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'POST', - '/castle/api/v1/entries/expense', + '/libra/api/v1/entries/expense', this.g.user.wallets[0].inkey, { description: this.expenseDialog.description, @@ -696,10 +696,10 @@ window.app = Vue.createApp({ } try { - // Generate an invoice on the Castle wallet + // Generate an invoice on the Libra wallet const response = await LNbits.api.request( 'POST', - '/castle/api/v1/generate-payment-invoice', + '/libra/api/v1/generate-payment-invoice', this.g.user.wallets[0].inkey, { amount: this.payDialog.amount @@ -745,7 +745,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'POST', - '/castle/api/v1/record-payment', + '/libra/api/v1/record-payment', this.g.user.wallets[0].inkey, { payment_hash: paymentHash @@ -788,15 +788,15 @@ window.app = Vue.createApp({ }, showManualPaymentOption() { // This is for when user wants to pay their debt manually - // For now, just notify them to contact castle + // For now, just notify them to contact libra this.$q.notify({ type: 'info', - message: 'Please contact Castle directly to arrange manual payment.', + message: 'Please contact Libra directly to arrange manual payment.', timeout: 3000 }) }, showManualPaymentDialog() { - // This is for when Castle owes user and they want to request manual payment + // This is for when Libra owes user and they want to request manual payment this.manualPaymentDialog.amount = Math.abs(this.balance.balance) this.manualPaymentDialog.description = '' this.manualPaymentDialog.show = true @@ -806,7 +806,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'POST', - '/castle/api/v1/manual-payment-request', + '/libra/api/v1/manual-payment-request', this.g.user.wallets[0].inkey, { amount: this.manualPaymentDialog.amount, @@ -831,8 +831,8 @@ window.app = Vue.createApp({ try { // If super user, load all requests; otherwise load user's own requests const endpoint = this.isSuperUser - ? '/castle/api/v1/manual-payment-requests/all' - : '/castle/api/v1/manual-payment-requests' + ? '/libra/api/v1/manual-payment-requests/all' + : '/libra/api/v1/manual-payment-requests' const key = this.isSuperUser ? this.g.user.wallets[0].adminkey : this.g.user.wallets[0].inkey @@ -855,7 +855,7 @@ window.app = Vue.createApp({ const response = await LNbits.api.request( 'GET', - '/castle/api/v1/entries/pending', + '/libra/api/v1/entries/pending', this.g.user.wallets[0].adminkey ) this.pendingExpenses = response.data @@ -867,7 +867,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'POST', - `/castle/api/v1/manual-payment-requests/${requestId}/approve`, + `/libra/api/v1/manual-payment-requests/${requestId}/approve`, this.g.user.wallets[0].adminkey ) this.$q.notify({ @@ -885,7 +885,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'POST', - `/castle/api/v1/manual-payment-requests/${requestId}/reject`, + `/libra/api/v1/manual-payment-requests/${requestId}/reject`, this.g.user.wallets[0].adminkey ) this.$q.notify({ @@ -901,7 +901,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'POST', - `/castle/api/v1/entries/${entryId}/approve`, + `/libra/api/v1/entries/${entryId}/approve`, this.g.user.wallets[0].adminkey ) this.$q.notify({ @@ -920,7 +920,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'POST', - `/castle/api/v1/entries/${entryId}/reject`, + `/libra/api/v1/entries/${entryId}/reject`, this.g.user.wallets[0].adminkey ) this.$q.notify({ @@ -939,7 +939,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/castle/api/v1/assertions', + '/libra/api/v1/assertions', this.g.user.wallets[0].adminkey ) this.balanceAssertions = response.data @@ -965,7 +965,7 @@ window.app = Vue.createApp({ await LNbits.api.request( 'POST', - '/castle/api/v1/assertions', + '/libra/api/v1/assertions', this.g.user.wallets[0].adminkey, payload ) @@ -1014,7 +1014,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'POST', - `/castle/api/v1/assertions/${assertionId}/check`, + `/libra/api/v1/assertions/${assertionId}/check`, this.g.user.wallets[0].adminkey ) @@ -1033,7 +1033,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'DELETE', - `/castle/api/v1/assertions/${assertionId}`, + `/libra/api/v1/assertions/${assertionId}`, this.g.user.wallets[0].adminkey ) @@ -1062,7 +1062,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/castle/api/v1/reconciliation/summary', + '/libra/api/v1/reconciliation/summary', this.g.user.wallets[0].adminkey ) this.reconciliation.summary = response.data @@ -1076,7 +1076,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/castle/api/v1/reconciliation/discrepancies', + '/libra/api/v1/reconciliation/discrepancies', this.g.user.wallets[0].adminkey ) this.reconciliation.discrepancies = response.data @@ -1089,7 +1089,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'POST', - '/castle/api/v1/reconciliation/check-all', + '/libra/api/v1/reconciliation/check-all', this.g.user.wallets[0].adminkey ) @@ -1143,7 +1143,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'POST', - '/castle/api/v1/entries/receivable', + '/libra/api/v1/entries/receivable', this.g.user.wallets[0].adminkey, { description: this.receivableDialog.description, @@ -1186,7 +1186,7 @@ window.app = Vue.createApp({ this.receivableDialog.currency = null }, async showSettleReceivableDialog(userBalance) { - // Only show for users who owe castle (positive balance = receivable) + // Only show for users who owe libra (positive balance = receivable) if (userBalance.balance <= 0) return // Clear any existing polling @@ -1202,19 +1202,19 @@ window.app = Vue.createApp({ // Fetch unsettled entries for this user (BOTH receivables AND expenses for net settlement) let allEntryLinks = [] try { - // Fetch receivable entries (user owes castle) + // Fetch receivable entries (user owes libra) const receivableResponse = await LNbits.api.request( 'GET', - `/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`, + `/libra/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`, this.g.user.wallets[0].adminkey ) const receivableEntries = receivableResponse.data.unsettled_entries || [] allEntryLinks.push(...receivableEntries.map(e => e.link).filter(l => l)) - // Also fetch expense entries (castle owes user) - these are netted in the settlement + // Also fetch expense entries (libra owes user) - these are netted in the settlement const expenseResponse = await LNbits.api.request( 'GET', - `/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`, + `/libra/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`, this.g.user.wallets[0].adminkey ) const expenseEntries = expenseResponse.data.unsettled_entries || [] @@ -1254,10 +1254,10 @@ window.app = Vue.createApp({ } try { - // Generate an invoice on the Castle wallet for the user to pay + // Generate an invoice on the Libra wallet for the user to pay const response = await LNbits.api.request( 'POST', - '/castle/api/v1/generate-payment-invoice', + '/libra/api/v1/generate-payment-invoice', this.g.user.wallets[0].adminkey, { amount: this.settleReceivableDialog.amount, @@ -1384,7 +1384,7 @@ window.app = Vue.createApp({ const response = await LNbits.api.request( 'POST', - '/castle/api/v1/receivables/settle', + '/libra/api/v1/receivables/settle', this.g.user.wallets[0].adminkey, payload ) @@ -1408,7 +1408,7 @@ window.app = Vue.createApp({ } }, async showPayUserDialog(userBalance) { - // Only show for users castle owes (negative balance = payable) + // Only show for users libra owes (negative balance = payable) if (userBalance.balance >= 0) return // Extract fiat balances (e.g., EUR) @@ -1416,26 +1416,26 @@ window.app = Vue.createApp({ const fiatCurrency = Object.keys(fiatBalances)[0] || null const fiatAmount = fiatCurrency ? fiatBalances[fiatCurrency] : 0 - // Use absolute values since balance is negative (liability = castle owes user) + // Use absolute values since balance is negative (liability = libra owes user) const maxAmountSats = Math.abs(userBalance.balance) const maxAmountFiat = Math.abs(fiatAmount) // Fetch unsettled entries for this user (BOTH expenses AND receivables for net settlement) let allEntryLinks = [] try { - // Fetch expense entries (castle owes user) + // Fetch expense entries (libra owes user) const expenseResponse = await LNbits.api.request( 'GET', - `/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`, + `/libra/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`, this.g.user.wallets[0].adminkey ) const expenseEntries = expenseResponse.data.unsettled_entries || [] allEntryLinks.push(...expenseEntries.map(e => e.link).filter(l => l)) - // Also fetch receivable entries (user owes castle) - these are netted in the settlement + // Also fetch receivable entries (user owes libra) - these are netted in the settlement const receivableResponse = await LNbits.api.request( 'GET', - `/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`, + `/libra/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`, this.g.user.wallets[0].adminkey ) const receivableEntries = receivableResponse.data.unsettled_entries || [] @@ -1448,7 +1448,7 @@ window.app = Vue.createApp({ show: true, user_id: userBalance.user_id, username: userBalance.username, - maxAmount: maxAmountSats, // Positive sats amount castle owes + maxAmount: maxAmountSats, // Positive sats amount libra owes maxAmountFiat: maxAmountFiat, // EUR or other fiat amount (positive) fiatCurrency: fiatCurrency, amount: maxAmountSats, // Default to sats since lightning is the default payment method @@ -1480,14 +1480,14 @@ window.app = Vue.createApp({ { out: false, amount: this.payUserDialog.amount, - memo: `Payment from Castle to ${this.payUserDialog.username}` + memo: `Payment from Libra to ${this.payUserDialog.username}` } ) console.log(invoiceResponse) const paymentRequest = invoiceResponse.data.bolt11 - // Pay the invoice from Castle's wallet + // Pay the invoice from Libra's wallet const paymentResponse = await LNbits.api.request( 'POST', `/api/v1/payments`, @@ -1498,7 +1498,7 @@ window.app = Vue.createApp({ } ) - // Record the payment in Castle accounting + // Record the payment in Libra accounting const payPayload = { user_id: this.payUserDialog.user_id, amount: this.payUserDialog.amount, @@ -1513,7 +1513,7 @@ window.app = Vue.createApp({ await LNbits.api.request( 'POST', - '/castle/api/v1/payables/pay', + '/libra/api/v1/payables/pay', this.g.user.wallets[0].adminkey, payPayload ) @@ -1579,7 +1579,7 @@ window.app = Vue.createApp({ const response = await LNbits.api.request( 'POST', - '/castle/api/v1/payables/pay', + '/libra/api/v1/payables/pay', this.g.user.wallets[0].adminkey, payload ) @@ -1606,7 +1606,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - `/castle/api/v1/user-wallet/${userId}`, + `/libra/api/v1/user-wallet/${userId}`, this.g.user.wallets[0].adminkey ) return response.data @@ -1663,13 +1663,13 @@ window.app = Vue.createApp({ return null }, isReceivable(entry) { - // Check if this is a receivable entry (user owes castle) + // Check if this is a receivable entry (user owes libra) if (entry.tags && entry.tags.includes('receivable-entry')) return true if (entry.account && entry.account.includes('Receivable')) return true return false }, isPayable(entry) { - // Check if this is a payable entry (castle owes user) + // Check if this is a payable entry (libra owes user) if (entry.tags && entry.tags.includes('expense-entry')) return true if (entry.account && entry.account.includes('Payable')) return true return false diff --git a/static/js/permissions.js b/static/js/permissions.js index 4cc54f0..948ac3a 100644 --- a/static/js/permissions.js +++ b/static/js/permissions.js @@ -206,7 +206,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/castle/api/v1/admin/permissions', + '/libra/api/v1/admin/permissions', this.g.user.wallets[0].adminkey ) this.permissions = response.data @@ -228,7 +228,7 @@ window.app = Vue.createApp({ // Admin permissions UI needs to see virtual accounts to grant permissions on them const response = await LNbits.api.request( 'GET', - '/castle/api/v1/accounts?exclude_virtual=false', + '/libra/api/v1/accounts?exclude_virtual=false', this.g.user.wallets[0].inkey ) this.accounts = response.data @@ -251,7 +251,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/castle/api/v1/admin/castle-users', + '/libra/api/v1/admin/libra-users', this.g.user.wallets[0].adminkey ) this.users = response.data || [] @@ -318,7 +318,7 @@ window.app = Vue.createApp({ await LNbits.api.request( 'POST', - '/castle/api/v1/admin/permissions', + '/libra/api/v1/admin/permissions', this.g.user.wallets[0].adminkey, payload ) @@ -357,7 +357,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'DELETE', - `/castle/api/v1/admin/permissions/${this.permissionToRevoke.id}`, + `/libra/api/v1/admin/permissions/${this.permissionToRevoke.id}`, this.g.user.wallets[0].adminkey ) @@ -428,7 +428,7 @@ window.app = Vue.createApp({ const response = await LNbits.api.request( 'POST', - '/castle/api/v1/admin/permissions/bulk-grant', + '/libra/api/v1/admin/permissions/bulk-grant', this.g.user.wallets[0].adminkey, payload ) @@ -535,7 +535,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/castle/api/v1/admin/equity-eligibility', + '/libra/api/v1/admin/equity-eligibility', this.g.user.wallets[0].adminkey ) this.equityEligibleUsers = response.data || [] @@ -573,7 +573,7 @@ window.app = Vue.createApp({ await LNbits.api.request( 'POST', - '/castle/api/v1/admin/equity-eligibility', + '/libra/api/v1/admin/equity-eligibility', this.g.user.wallets[0].adminkey, payload ) @@ -612,7 +612,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'DELETE', - `/castle/api/v1/admin/equity-eligibility/${this.equityToRevoke.user_id}`, + `/libra/api/v1/admin/equity-eligibility/${this.equityToRevoke.user_id}`, this.g.user.wallets[0].adminkey ) @@ -655,7 +655,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/castle/api/v1/admin/roles', + '/libra/api/v1/admin/roles', this.g.user.wallets[0].adminkey ) this.roles = response.data || [] @@ -678,7 +678,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - `/castle/api/v1/admin/roles/${role.id}`, + `/libra/api/v1/admin/roles/${role.id}`, this.g.user.wallets[0].adminkey ) @@ -733,7 +733,7 @@ window.app = Vue.createApp({ // Update existing role await LNbits.api.request( 'PUT', - `/castle/api/v1/admin/roles/${this.selectedRole.id}`, + `/libra/api/v1/admin/roles/${this.selectedRole.id}`, this.g.user.wallets[0].adminkey, payload ) @@ -747,7 +747,7 @@ window.app = Vue.createApp({ // Create new role await LNbits.api.request( 'POST', - '/castle/api/v1/admin/roles', + '/libra/api/v1/admin/roles', this.g.user.wallets[0].adminkey, payload ) @@ -786,7 +786,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'DELETE', - `/castle/api/v1/admin/roles/${this.roleToDelete.id}`, + `/libra/api/v1/admin/roles/${this.roleToDelete.id}`, this.g.user.wallets[0].adminkey ) @@ -862,7 +862,7 @@ window.app = Vue.createApp({ await LNbits.api.request( 'POST', - '/castle/api/v1/admin/user-roles', + '/libra/api/v1/admin/user-roles', this.g.user.wallets[0].adminkey, payload ) @@ -920,7 +920,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/castle/api/v1/admin/users/roles', + '/libra/api/v1/admin/users/roles', this.g.user.wallets[0].adminkey ) @@ -984,7 +984,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'DELETE', - `/castle/api/v1/admin/user-roles/${this.userRoleToRevoke.id}`, + `/libra/api/v1/admin/user-roles/${this.userRoleToRevoke.id}`, this.g.user.wallets[0].adminkey ) @@ -1033,7 +1033,7 @@ window.app = Vue.createApp({ } await LNbits.api.request( 'POST', - `/castle/api/v1/admin/roles/${this.selectedRole.id}/permissions`, + `/libra/api/v1/admin/roles/${this.selectedRole.id}/permissions`, this.g.user.wallets[0].adminkey, payload ) @@ -1067,7 +1067,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'DELETE', - `/castle/api/v1/admin/roles/${this.selectedRole.id}/permissions/${permissionId}`, + `/libra/api/v1/admin/roles/${this.selectedRole.id}/permissions/${permissionId}`, this.g.user.wallets[0].adminkey ) // Reload role permissions diff --git a/tasks.py b/tasks.py index 8ec83b9..f6f84cb 100644 --- a/tasks.py +++ b/tasks.py @@ -1,5 +1,5 @@ """ -Background tasks for Castle accounting extension. +Background tasks for Libra accounting extension. These tasks handle automated reconciliation checks and maintenance. """ @@ -62,11 +62,11 @@ async def check_all_balance_assertions() -> dict: # Log results if results["failed"] > 0: - print(f"[CASTLE] Daily reconciliation check: {results['failed']} FAILED assertions!") + print(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']}") else: - print(f"[CASTLE] Daily reconciliation check: All {results['passed']} assertions passed ✓") + print(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"[CASTLE] Running scheduled daily reconciliation check at {datetime.now()}") + print(f"[LIBRA] Running scheduled daily reconciliation check at {datetime.now()}") try: results = await check_all_balance_assertions() @@ -86,38 +86,38 @@ 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"[CASTLE] WARNING: {results['failed']} balance assertions failed!") + print(f"[LIBRA] WARNING: {results['failed']} balance assertions failed!") # Future: Send alert notification return results except Exception as e: - print(f"[CASTLE] Error in scheduled reconciliation: {e}") + print(f"[LIBRA] Error in scheduled reconciliation: {e}") raise async def scheduled_account_sync(): """ - Scheduled task that runs hourly to sync accounts from Beancount to Castle DB. + Scheduled task that runs hourly to sync accounts from Beancount to Libra DB. - This ensures Castle DB stays in sync with Beancount (source of truth) by - automatically adding any new accounts created in Beancount to Castle's + This ensures Libra DB stays in sync with Beancount (source of truth) by + automatically adding any new accounts created in Beancount to Libra's metadata database for permission tracking. """ from .account_sync import sync_accounts_from_beancount - logger.info(f"[CASTLE] Running scheduled account sync at {datetime.now()}") + logger.info(f"[LIBRA] Running scheduled account sync at {datetime.now()}") try: stats = await sync_accounts_from_beancount(force_full_sync=False) if stats["accounts_added"] > 0: logger.info( - f"[CASTLE] Account sync: Added {stats['accounts_added']} new accounts" + f"[LIBRA] Account sync: Added {stats['accounts_added']} new accounts" ) if stats["errors"]: logger.warning( - f"[CASTLE] Account sync: {len(stats['errors'])} errors encountered" + f"[LIBRA] Account sync: {len(stats['errors'])} errors encountered" ) for error in stats["errors"][:5]: # Log first 5 errors logger.error(f" - {error}") @@ -125,24 +125,24 @@ async def scheduled_account_sync(): return stats except Exception as e: - logger.error(f"[CASTLE] Error in scheduled account sync: {e}") + logger.error(f"[LIBRA] Error in scheduled account sync: {e}") raise async def wait_for_account_sync(): """ - Background task that periodically syncs accounts from Beancount to Castle DB. + Background task that periodically syncs accounts from Beancount to Libra DB. - Runs hourly to ensure Castle DB stays in sync with Beancount. + Runs hourly to ensure Libra DB stays in sync with Beancount. """ - logger.info("[CASTLE] Account sync background task started") + logger.info("[LIBRA] Account sync background task started") while True: try: # Run sync await scheduled_account_sync() except Exception as e: - logger.error(f"[CASTLE] Account sync error: {e}") + logger.error(f"[LIBRA] Account sync error: {e}") # Wait 1 hour before next sync await asyncio.sleep(3600) # 3600 seconds = 1 hour @@ -157,9 +157,9 @@ def start_daily_reconciliation_task(): For cron setup: # Run daily at 2 AM - 0 2 * * * curl -X POST http://localhost:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY" + 0 2 * * * curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY" """ - print("[CASTLE] Daily reconciliation task registered") + print("[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 @@ -173,7 +173,7 @@ async def wait_for_paid_invoices(): before the payment is detected by client-side polling. """ invoice_queue = Queue() - register_invoice_listener(invoice_queue, "ext_castle") + register_invoice_listener(invoice_queue, "ext_libra") while True: payment = await invoice_queue.get() @@ -182,10 +182,10 @@ async def wait_for_paid_invoices(): async def on_invoice_paid(payment: Payment) -> None: """ - Handle a paid Castle invoice by automatically submitting to Fava. + Handle a paid Libra invoice by automatically submitting to Fava. - This function is called automatically when any invoice on the Castle wallet - is paid. It checks if the invoice is a Castle payment and records it in + This function is called automatically when any invoice on the Libra wallet + is paid. It checks if the invoice is a Libra payment and records it in Beancount via Fava. Concurrency Protection: @@ -194,13 +194,13 @@ async def on_invoice_paid(payment: Payment) -> None: - Uses idempotent entry creation to prevent duplicate entries even if the same payment is processed multiple times """ - # Only process Castle-specific payments - if not payment.extra or payment.extra.get("tag") != "castle": + # Only process Libra-specific payments + if not payment.extra or payment.extra.get("tag") != "libra": return user_id = payment.extra.get("user_id") if not user_id: - logger.warning(f"Castle invoice {payment.payment_hash} missing user_id in metadata") + logger.warning(f"Libra invoice {payment.payment_hash} missing user_id in metadata") return from .fava_client import get_fava_client @@ -216,7 +216,7 @@ async def on_invoice_paid(payment: Payment) -> None: user_lock = fava.get_user_lock(user_id) async with user_lock: - logger.info(f"Recording Castle payment {payment.payment_hash} for user {user_id[:8]} to Fava") + logger.info(f"Recording Libra payment {payment.payment_hash} for user {user_id[:8]} to Fava") try: from decimal import Decimal @@ -246,14 +246,14 @@ async def on_invoice_paid(payment: Payment) -> None: total_fiat_balance = fiat_balances.get(fiat_currency, Decimal(0)) # Determine receivables and payables based on balance - # Positive balance = user owes castle (receivable) - # Negative balance = castle owes user (payable) + # Positive balance = user owes libra (receivable) + # Negative balance = libra owes user (payable) if total_fiat_balance > 0: - # User owes castle + # User owes libra total_receivable = total_fiat_balance total_payable = Decimal(0) else: - # Castle owes user + # Libra owes user total_receivable = Decimal(0) total_payable = abs(total_fiat_balance) @@ -318,5 +318,5 @@ async def on_invoice_paid(payment: Payment) -> None: ) except Exception as e: - logger.error(f"Error recording Castle payment {payment.payment_hash}: {e}") + logger.error(f"Error recording Libra payment {payment.payment_hash}: {e}") raise diff --git a/templates/castle/index.html b/templates/libra/index.html similarity index 95% rename from templates/castle/index.html rename to templates/libra/index.html index 98b1625..0286927 100644 --- a/templates/castle/index.html +++ b/templates/libra/index.html @@ -3,7 +3,7 @@ {% block scripts %} {{ window_vars(user) }} - + {% endblock %} {% block page %} @@ -13,7 +13,7 @@
    -
    🏰 Castle Accounting
    +
    Libra

    Track expenses, receivables, and balances for the collective

    @@ -21,14 +21,14 @@ Configure Your Wallet - + Manage Permissions (Admin) Sync Accounts from Beancount - Castle Settings (Super User Only) + Libra Settings (Super User Only)
    @@ -36,19 +36,19 @@ - +
    - Setup Required: Castle Wallet ID must be configured before the extension can function. + Setup Required: Libra Wallet ID must be configured before the extension can function.
    - + @@ -57,7 +57,7 @@ - + @@ -131,13 +131,13 @@ Add Expense - - Castle wallet must be configured first + + Libra wallet must be configured first - + You must configure your wallet first @@ -145,14 +145,14 @@ v-if="isSuperUser" color="orange" @click="showReceivableDialog" - :disable="!castleWalletConfigured" + :disable="!libraWalletConfigured" > Add Receivable - - Castle wallet must be configured first + + Libra wallet must be configured first - Record when a user owes the Castle + Record when a user owes the Libra
    @@ -201,7 +201,7 @@ @@ -257,7 +257,7 @@ {% raw %}{{ balance.balance > 0 ? 'Total owed to you' : balance.balance < 0 ? 'Total you owe' : 'No outstanding balances' }}{% endraw %}
    - {% raw %}{{ balance.balance >= 0 ? 'You owe Castle' : 'Castle owes you' }}{% endraw %} + {% raw %}{{ balance.balance >= 0 ? 'You owe Libra' : 'Libra owes you' }}{% endraw %}
    @@ -941,9 +941,9 @@ filled dense readonly - :model-value="'Liability (Castle owes me)'" + :model-value="'Liability (Libra owes me)'" label="Type" - hint="This expense will be recorded as a liability (Castle owes you)" + hint="This expense will be recorded as a liability (Libra owes you)" > From 1201557f0c8db6f1534f6032ea1b8377ea351247 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 5 Jun 2026 22:57:59 +0200 Subject: [PATCH 15/40] Gate cross-user admin endpoints behind require_super_user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-six endpoints documented "(admin only)" were using require_admin_key, which only checks the caller owns a wallet with its admin key — not LNbits-instance admin. Any logged-in user could fabricate receivables against any other user_id, grant themselves MANAGE permission on any account, create + self-assign privileged roles, etc. Swaps Depends(require_admin_key) -> Depends(require_super_user) on: receivable/revenue creation, equity-eligibility grant/revoke/list, permission grant/list/revoke/bulk/bulk-grant, account-sync admin, role + role-permission + user-role CRUD, cross-user contributions and unsettled-entries reports. Also deletes the unsafe duplicate /api/v1/pay-user — both function defs shared the name api_pay_user, the second shadowed the first at module scope but FastAPI registered both routes. /api/v1/payables/pay already provides the super-user-gated equivalent. Two pre-existing orphan wallet.wallet.user references inside api_settle_receivable and api_approve_manual_payment_request (both already used the auth parameter) would have raised NameError at runtime; fixed in passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- views_api.py | 134 ++++++++++++++------------------------------------- 1 file changed, 37 insertions(+), 97 deletions(-) diff --git a/views_api.py b/views_api.py index 3e139d6..d31f881 100644 --- a/views_api.py +++ b/views_api.py @@ -9,7 +9,6 @@ from lnbits.core.models import User, WalletTypeInfo from lnbits.decorators import ( check_super_user, check_user_exists, - require_admin_key, require_invoice_key, ) from lnbits.utils.exchange_rates import allowed_currencies, fiat_amount_as_satoshis @@ -1334,7 +1333,7 @@ async def api_create_income_entry( @libra_api_router.post("/api/v1/entries/receivable", status_code=HTTPStatus.CREATED) async def api_create_receivable_entry( data: ReceivableEntry, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> JournalEntry: """ Create an accounts receivable entry (user owes libra). @@ -1433,7 +1432,7 @@ async def api_create_receivable_entry( id=entry_id, # Use the generated libra entry ID description=data.description + description_suffix, entry_date=datetime.now(), - created_by=wallet.wallet.user, # Use user_id, not wallet_id + created_by=auth.user_id, created_at=datetime.now(), reference=libra_reference, # Use libra reference with unique ID flag=JournalEntryFlag.PENDING, @@ -1462,7 +1461,7 @@ async def api_create_receivable_entry( @libra_api_router.post("/api/v1/entries/revenue", status_code=HTTPStatus.CREATED) async def api_create_revenue_entry( data: RevenueEntry, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> JournalEntry: """ Create a revenue entry (libra receives payment). @@ -1547,7 +1546,7 @@ async def api_create_revenue_entry( id=entry_id, description=data.description, entry_date=datetime.now(), - created_by=wallet.wallet.user, # Use user_id, not wallet_id + created_by=auth.user_id, created_at=datetime.now(), reference=libra_reference, flag=JournalEntryFlag.CLEARED, @@ -1934,65 +1933,6 @@ async def api_record_payment( } -@libra_api_router.post("/api/v1/pay-user") -async def api_pay_user( - user_id: str, - amount: int, - wallet: WalletTypeInfo = Depends(require_admin_key), -) -> dict: - """ - Record a payment from libra to user (reduces what libra owes user). - Admin only. - """ - # Get user's payable account (what libra owes) - user_payable = await get_or_create_user_account( - user_id, AccountType.LIABILITY, "Accounts Payable" - ) - - # Get lightning account - lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning") - if not lightning_account: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Lightning account not found" - ) - - # Format payment entry and submit to Fava - # DR Liabilities:Payable (User), CR Assets:Bitcoin:Lightning - from .fava_client import get_fava_client - from .beancount_format import format_payment_entry - - fava = get_fava_client() - - # Get unsettled expense entries to link to this settlement - unsettled = await fava.get_unsettled_entries_bql(user_id, "expense") - settled_links = [e["link"] for e in unsettled if e.get("link")] - - entry = format_payment_entry( - user_id=user_id, - payment_account=lightning_account.name, - payable_or_receivable_account=user_payable.name, - amount_sats=amount, - description=f"Payment to user {user_id[:8]}", - entry_date=datetime.now().date(), - is_payable=True, # Libra paying user - reference=f"PAY-{user_id[:8]}", - settled_entry_links=settled_links - ) - - # Submit to Fava - result = await fava.add_entry(entry) - logger.info(f"Payment submitted to Fava: {result.get('data', 'Unknown')}") - - # Get updated balance from Fava - balance_data = await fava.get_user_balance_bql(user_id) - - return { - "journal_entry_id": f"fava-{datetime.now().timestamp()}", - "new_balance": balance_data["balance"], - "message": "Payment recorded successfully", - } - - @libra_api_router.post("/api/v1/receivables/settle") async def api_settle_receivable( data: SettleReceivable, @@ -2119,7 +2059,7 @@ async def api_settle_receivable( if "meta" not in entry: entry["meta"] = {} entry["meta"]["payment-method"] = data.payment_method - entry["meta"]["settled-by"] = wallet.wallet.user + entry["meta"]["settled-by"] = auth.user_id if data.txid: entry["meta"]["txid"] = data.txid @@ -2493,7 +2433,7 @@ async def api_expense_report( @libra_api_router.get("/api/v1/reports/contributions") async def api_contributions_report( - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> dict: """ Get user contribution report using BQL. @@ -2562,7 +2502,7 @@ async def api_contributions_report( async def api_get_unsettled_entries( user_id: str, entry_type: str = "expense", - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> dict: """ Get unsettled expense or receivable entries for a user. @@ -2770,7 +2710,7 @@ async def api_approve_manual_payment_request( # Approve the request with Fava entry reference entry_id = f"fava-{datetime.now().timestamp()}" return await approve_manual_payment_request( - request_id, wallet.wallet.user, entry_id + request_id, auth.user_id, entry_id ) @@ -3344,18 +3284,18 @@ async def api_get_user_info( @libra_api_router.post("/api/v1/admin/equity-eligibility", status_code=HTTPStatus.CREATED) async def api_grant_equity_eligibility( data: CreateUserEquityStatus, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> UserEquityStatus: """Grant equity contribution eligibility to a user (admin only)""" from .crud import create_or_update_user_equity_status - return await create_or_update_user_equity_status(data, wallet.wallet.user) + return await create_or_update_user_equity_status(data, auth.user_id) @libra_api_router.delete("/api/v1/admin/equity-eligibility/{user_id}") async def api_revoke_equity_eligibility( user_id: str, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> UserEquityStatus: """Revoke equity contribution eligibility from a user (admin only)""" from .crud import revoke_user_equity_eligibility @@ -3371,7 +3311,7 @@ async def api_revoke_equity_eligibility( @libra_api_router.get("/api/v1/admin/equity-eligibility") async def api_list_equity_eligible_users( - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> list[UserEquityStatus]: """List all equity-eligible users (admin only)""" from .crud import get_all_equity_eligible_users @@ -3385,7 +3325,7 @@ async def api_list_equity_eligible_users( @libra_api_router.post("/api/v1/admin/permissions", status_code=HTTPStatus.CREATED) async def api_grant_permission( data: CreateAccountPermission, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> AccountPermission: """Grant account permission to a user (admin only)""" # Validate that account exists @@ -3396,14 +3336,14 @@ async def api_grant_permission( detail=f"Account with ID '{data.account_id}' not found", ) - return await create_account_permission(data, wallet.wallet.user) + return await create_account_permission(data, auth.user_id) @libra_api_router.get("/api/v1/admin/permissions") async def api_list_permissions( user_id: str | None = None, account_id: str | None = None, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> list[AccountPermission]: """ List account permissions (admin only). @@ -3436,7 +3376,7 @@ async def api_list_permissions( @libra_api_router.delete("/api/v1/admin/permissions/{permission_id}") async def api_revoke_permission( permission_id: str, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> dict: """Revoke (delete) an account permission (admin only)""" # Verify permission exists @@ -3458,7 +3398,7 @@ async def api_revoke_permission( @libra_api_router.post("/api/v1/admin/permissions/bulk", status_code=HTTPStatus.CREATED) async def api_bulk_grant_permissions( permissions: list[CreateAccountPermission], - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> list[AccountPermission]: """Grant multiple account permissions at once (admin only)""" created_permissions = [] @@ -3472,7 +3412,7 @@ async def api_bulk_grant_permissions( detail=f"Account with ID '{perm_data.account_id}' not found", ) - perm = await create_account_permission(perm_data, wallet.wallet.user) + perm = await create_account_permission(perm_data, auth.user_id) created_permissions.append(perm) return created_permissions @@ -3481,7 +3421,7 @@ async def api_bulk_grant_permissions( @libra_api_router.post("/api/v1/admin/permissions/bulk-grant", status_code=HTTPStatus.CREATED) async def api_bulk_grant_permission_to_users( data: "BulkGrantPermission", - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> "BulkGrantResult": """ Grant the same permission to multiple users at once (admin only). @@ -3515,7 +3455,7 @@ async def api_bulk_grant_permission_to_users( expires_at=data.expires_at, notes=data.notes, ) - perm = await create_account_permission(perm_data, wallet.wallet.user) + perm = await create_account_permission(perm_data, auth.user_id) granted.append(perm) except Exception as e: failed.append({ @@ -3628,7 +3568,7 @@ async def api_get_account_hierarchy( @libra_api_router.post("/api/v1/admin/accounts/sync") async def api_sync_all_accounts( force_full_sync: bool = False, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> dict: """ Sync all accounts from Beancount to Libra DB (admin only). @@ -3644,7 +3584,7 @@ async def api_sync_all_accounts( """ from .account_sync import sync_accounts_from_beancount - logger.info(f"Admin {wallet.wallet.user[:8]} triggered account sync (force={force_full_sync})") + logger.info(f"Admin {auth.user_id[:8]} triggered account sync (force={force_full_sync})") try: stats = await sync_accounts_from_beancount(force_full_sync=force_full_sync) @@ -3661,7 +3601,7 @@ async def api_sync_all_accounts( @libra_api_router.post("/api/v1/admin/accounts/sync/{account_name:path}") async def api_sync_single_account( account_name: str, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> dict: """ Sync a single account from Beancount to Libra DB (admin only). @@ -3677,7 +3617,7 @@ async def api_sync_single_account( """ from .account_sync import sync_single_account_from_beancount - logger.info(f"Admin {wallet.wallet.user[:8]} triggered sync for account: {account_name}") + logger.info(f"Admin {auth.user_id[:8]} triggered sync for account: {account_name}") try: created = await sync_single_account_from_beancount(account_name) @@ -3707,7 +3647,7 @@ async def api_sync_single_account( @libra_api_router.get("/api/v1/admin/roles") async def api_get_all_roles( - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> list: """Get all roles (admin only)""" from . import crud @@ -3737,13 +3677,13 @@ async def api_get_all_roles( @libra_api_router.post("/api/v1/admin/roles", status_code=HTTPStatus.CREATED) async def api_create_role( data: CreateRole, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ): """Create a new role (admin only)""" from . import crud try: - role = await crud.create_role(data, created_by=wallet.wallet.user) + role = await crud.create_role(data, created_by=auth.user_id) return { "id": role.id, "name": role.name, @@ -3763,7 +3703,7 @@ async def api_create_role( @libra_api_router.get("/api/v1/admin/roles/{role_id}") async def api_get_role( role_id: str, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ): """Get a specific role with its permissions and users (admin only)""" from . import crud @@ -3813,7 +3753,7 @@ async def api_get_role( async def api_update_role( role_id: str, data: UpdateRole, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ): """Update a role (admin only)""" from . import crud @@ -3838,7 +3778,7 @@ async def api_update_role( @libra_api_router.delete("/api/v1/admin/roles/{role_id}") async def api_delete_role( role_id: str, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ): """Delete a role (admin only) - cascades to role_permissions and user_roles""" from . import crud @@ -3861,7 +3801,7 @@ async def api_delete_role( async def api_add_role_permission( role_id: str, data: CreateRolePermission, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ): """Add a permission to a role (admin only)""" from . import crud @@ -3899,7 +3839,7 @@ async def api_add_role_permission( async def api_delete_role_permission( role_id: str, permission_id: str, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ): """Remove a permission from a role (admin only)""" from . import crud @@ -3914,7 +3854,7 @@ async def api_delete_role_permission( @libra_api_router.post("/api/v1/admin/user-roles", status_code=HTTPStatus.CREATED) async def api_assign_user_role( data: AssignUserRole, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ): """Assign a user to a role (admin only)""" from . import crud @@ -3928,7 +3868,7 @@ async def api_assign_user_role( ) try: - user_role = await crud.assign_user_role(data, granted_by=wallet.wallet.user) + user_role = await crud.assign_user_role(data, granted_by=auth.user_id) return { "id": user_role.id, "user_id": user_role.user_id, @@ -3949,7 +3889,7 @@ async def api_assign_user_role( @libra_api_router.get("/api/v1/admin/user-roles/{user_id}") async def api_get_user_roles( user_id: str, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ): """Get all roles assigned to a user (admin only)""" from . import crud @@ -3982,7 +3922,7 @@ async def api_get_user_roles( @libra_api_router.delete("/api/v1/admin/user-roles/{user_role_id}") async def api_revoke_user_role( user_role_id: str, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ): """Revoke a user's role assignment (admin only)""" from . import crud @@ -3993,7 +3933,7 @@ async def api_revoke_user_role( @libra_api_router.get("/api/v1/admin/users/roles") async def api_get_all_user_roles( - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ): """Get all user role assignments (admin only)""" from . import crud From f0899bf7889fbd0e463844ed23e9444550b42cdb Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 6 Jun 2026 14:48:57 +0200 Subject: [PATCH 16/40] Update CLAUDE.md to reflect Fava-as-sole-source-of-truth for journal entries The previous journal_entries/entry_lines local mirror was removed during the Fava migration but the docs still described it as a local cache. Replace with explicit statement that Fava is canonical and the remaining SQLite tables hold orthogonal operational state. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b58591c..77bcd65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,13 +49,9 @@ Libra is a double-entry bookkeeping extension for LNbits that enables collective ### Database Schema -**Note**: With Fava integration, Libra maintains a local cache of some data but delegates authoritative balance calculations to Beancount/Fava. +**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. -**journal_entries**: Transaction headers stored locally and synced to Fava -- `flag` field: `*` (cleared), `!` (pending), `#` (flagged), `x` (void) -- `meta` field: JSON storing source, tags, audit info -- `reference` field: Links to payment_hash, invoice numbers, etc. -- Enriched with `username` field when retrieved via API (added 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 From 894de72953fad2d258a533957a68e5a32db147e2 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 6 Jun 2026 15:27:28 +0200 Subject: [PATCH 17/40] Route fava_client.add_account writes per account type The Fava-backed ledger is being split into purpose-specific files (see aiolabs/server-deploy#4): accounts/chart.beancount for static + admin-managed opens, accounts/users.beancount for libra-appended per-user opens. Add a `target_file` parameter to `add_account` that defaults to inference from the account name (`:User-[0-9a-f]{8}$` -> users.beancount, otherwise chart.beancount). Drop the now-redundant `GET /api/options` call that was only used to discover the root file path. Callers that need explicit control (e.g. the upcoming admin chart-edit endpoint) can pass `target_file=` directly. The retry loop, write lock, and insertion-point search are unchanged -- each included file is a self-contained source the existing logic operates on cleanly. Refs: aiolabs/libra#28 --- fava_client.py | 69 ++++++++++++++++++++++++++++++++------------------ 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/fava_client.py b/fava_client.py index 277120e..8f60b23 100644 --- a/fava_client.py +++ b/fava_client.py @@ -18,6 +18,7 @@ See: https://github.com/beancount/fava/blob/main/src/fava/json_api.py """ import asyncio +import re import httpx from typing import Any, Dict, List, Optional from decimal import Decimal @@ -30,6 +31,19 @@ class ChecksumConflictError(Exception): pass +# Per-user account names end with :User-{user_id[:8]} (8 hex chars). Anything +# matching is routed to accounts/users.beancount; anything else goes to +# accounts/chart.beancount. See `_infer_target_file` and `add_account`. +_USER_ACCT_RE = re.compile(r":User-[0-9a-f]{8}$") + + +def _infer_target_file(account_name: str) -> str: + """Pick the Beancount include file for an Open directive based on account name.""" + if _USER_ACCT_RE.search(account_name): + return "accounts/users.beancount" + return "accounts/chart.beancount" + + class FavaClient: """ Async client for Fava REST API. @@ -1487,13 +1501,20 @@ class FavaClient: currencies: list[str], opening_date: Optional[date] = None, metadata: Optional[Dict[str, Any]] = None, + target_file: Optional[str] = None, max_retries: int = 3 ) -> Dict[str, Any]: """ Add an account to the Beancount ledger via an Open directive. NOTE: Fava's /api/add_entries endpoint does NOT support Open directives. - This method uses /api/source to directly edit the Beancount file. + This method uses /api/source to directly edit a Beancount file. + + The ledger is split across multiple include files + (see modules/services/fava-seeds.nix in server-deploy). Per-user + opens go to accounts/users.beancount; admin/static chart opens go to + accounts/chart.beancount. If `target_file` is not passed, it is + inferred from the account name via `_infer_target_file`. This method implements optimistic concurrency control with retry logic: - Acquires a global write lock before modifying the ledger @@ -1506,6 +1527,8 @@ class FavaClient: currencies: List of currencies for this account (e.g., ["EUR", "SATS"]) opening_date: Date to open the account (defaults to today) metadata: Optional metadata for the account + target_file: Beancount file path (relative to ledger root) to append + the Open directive to. Defaults to inference from `account_name`. max_retries: Maximum number of retry attempts on checksum conflict (default: 3) Returns: @@ -1515,17 +1538,18 @@ class FavaClient: ChecksumConflictError: If all retry attempts fail due to concurrent modifications Example: - # Add a user's receivable account + # User-account names route to accounts/users.beancount automatically. result = await fava.add_account( - account_name="Assets:Receivable:User-abc123", + account_name="Assets:Receivable:User-abc12345", currencies=["EUR", "SATS", "USD"], - metadata={"user_id": "abc123", "description": "User receivables"} + metadata={"user_id": "abc12345", "description": "User receivables"} ) - # Add a user's payable account + # Static / admin-added chart entries route to accounts/chart.beancount. result = await fava.add_account( - account_name="Liabilities:Payable:User-abc123", - currencies=["EUR", "SATS"] + account_name="Expenses:NewCategory", + currencies=["EUR"], + target_file="accounts/chart.beancount", ) """ from datetime import date as date_type @@ -1533,6 +1557,9 @@ class FavaClient: if opening_date is None: opening_date = date_type.today() + if target_file is None: + target_file = _infer_target_file(account_name) + last_error = None for attempt in range(max_retries): @@ -1540,18 +1567,10 @@ class FavaClient: async with self._write_lock: try: async with httpx.AsyncClient(timeout=self.timeout) as client: - # Step 1: Get the main Beancount file path from Fava - options_response = await client.get(f"{self.base_url}/options") - options_response.raise_for_status() - options_data = options_response.json()["data"] - file_path = options_data["beancount_options"]["filename"] - - logger.debug(f"Fava main file: {file_path}") - - # Step 2: Get current source file (fresh read on each attempt) + # Step 1: Get current source file (fresh read on each attempt) response = await client.get( f"{self.base_url}/source", - params={"filename": file_path} + params={"filename": target_file} ) response.raise_for_status() source_data = response.json()["data"] @@ -1559,12 +1578,12 @@ class FavaClient: sha256sum = source_data["sha256sum"] source = source_data["source"] - # Step 3: Check if account already exists (may have been created by concurrent request) + # Step 2: Check if account already exists (may have been created by concurrent request) if f"open {account_name}" in source: - logger.info(f"Account {account_name} already exists in Beancount file") + logger.info(f"Account {account_name} already exists in {target_file}") return {"data": sha256sum, "mtime": source_data.get("mtime", "")} - # Step 4: Find insertion point (after last Open directive AND its metadata) + # Step 3: Find insertion point (after last Open directive AND its metadata) lines = source.split('\n') insert_index = 0 for i, line in enumerate(lines): @@ -1575,7 +1594,7 @@ class FavaClient: while insert_index < len(lines) and lines[insert_index].startswith((' ', '\t')) and lines[insert_index].strip(): insert_index += 1 - # Step 5: Format Open directive as Beancount text + # Step 4: Format Open directive as Beancount text currencies_str = ", ".join(currencies) open_lines = [ "", @@ -1591,15 +1610,15 @@ class FavaClient: else: open_lines.append(f' {key}: {value}') - # Step 6: Insert into source + # Step 5: Insert into source for i, line in enumerate(open_lines): lines.insert(insert_index + i, line) new_source = '\n'.join(lines) - # Step 7: Update source file via PUT /api/source + # Step 6: Update source file via PUT /api/source update_payload = { - "file_path": file_path, + "file_path": target_file, "source": new_source, "sha256sum": sha256sum } @@ -1612,7 +1631,7 @@ class FavaClient: response.raise_for_status() result = response.json() - logger.info(f"Added account {account_name} to Beancount file with currencies {currencies}") + logger.info(f"Added account {account_name} to {target_file} with currencies {currencies}") return result except httpx.HTTPStatusError as e: From 34ecb3f2492be5903b109730845a9c3de50edb4e Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 6 Jun 2026 15:30:23 +0200 Subject: [PATCH 18/40] Add POST /api/v1/admin/accounts for chart-of-accounts entries Companion to the fava ledger split (aiolabs/server-deploy#4). Super-user endpoint that adds a new Open directive to accounts/chart.beancount via fava_client.add_account (explicit target_file), then mirrors the account into Libra's DB via sync_single_account_from_beancount so permissions can be granted on it. Validates the account name against the five Beancount top-level prefixes (Assets:/Liabilities:/Equity:/Income:/Expenses:) and returns 400 on a bad prefix. Per-user accounts (matching :User-xxxxxxxx) keep their existing code path via crud.get_or_create_user_account, which inherits the inferred target_file (accounts/users.beancount) from the add_account default. Backend only -- the LNbits admin UI on top is tracked separately as aiolabs/libra#30. Refs: aiolabs/libra#29 --- models.py | 7 +++++++ views_api.py | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/models.py b/models.py index 22d4503..70abca4 100644 --- a/models.py +++ b/models.py @@ -48,6 +48,13 @@ class CreateAccount(BaseModel): is_virtual: bool = False # Set to True to create virtual parent account +class CreateChartAccount(BaseModel): + """Admin-created chart-of-accounts entry written to accounts/chart.beancount.""" + name: str # Full hierarchical account name, e.g. "Expenses:Services:Domain" + currencies: list[str] = ["EUR", "SATS", "USD"] + description: Optional[str] = None + + class EntryLine(BaseModel): id: str journal_entry_id: str diff --git a/views_api.py b/views_api.py index d31f881..eab2617 100644 --- a/views_api.py +++ b/views_api.py @@ -52,6 +52,7 @@ from .models import ( LibraSettings, CreateAccount, CreateAccountPermission, + CreateChartAccount, CreateBalanceAssertion, CreateEntryLine, CreateJournalEntry, @@ -3565,6 +3566,61 @@ async def api_get_account_hierarchy( # ===== ACCOUNT SYNC ENDPOINTS ===== +_VALID_ACCOUNT_PREFIXES = ("Assets:", "Liabilities:", "Equity:", "Income:", "Expenses:") + + +@libra_api_router.post("/api/v1/admin/accounts", status_code=HTTPStatus.CREATED) +async def api_admin_add_chart_account( + payload: CreateChartAccount, + auth: AuthContext = Depends(require_super_user), +) -> dict: + """ + Add a chart-of-accounts entry (super-user only). + + Writes an Open directive to accounts/chart.beancount via Fava's /api/source, + then syncs the account into Libra's DB so permissions can be granted on it. + Per-user accounts (matching :User-xxxxxxxx) take a different code path via + crud.get_or_create_user_account and are not created through this endpoint. + """ + from .fava_client import get_fava_client + + if not payload.name.startswith(_VALID_ACCOUNT_PREFIXES): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=( + f"Account name must start with one of " + f"{', '.join(_VALID_ACCOUNT_PREFIXES)} (got {payload.name!r})" + ), + ) + + logger.info( + f"Admin {auth.user_id[:8]} adding chart account {payload.name} " + f"with currencies {payload.currencies}" + ) + + fava = get_fava_client() + metadata: dict = {"added_by": auth.user_id[:8], "source": "admin-ui"} + if payload.description: + metadata["description"] = payload.description + + await fava.add_account( + account_name=payload.name, + currencies=payload.currencies, + target_file="accounts/chart.beancount", + metadata=metadata, + ) + + # Mirror into libra DB so permissions / metadata layer sees it. + from .account_sync import sync_single_account_from_beancount + synced = await sync_single_account_from_beancount(payload.name) + + return { + "success": True, + "account_name": payload.name, + "synced_to_libra_db": synced, + } + + @libra_api_router.post("/api/v1/admin/accounts/sync") async def api_sync_all_accounts( force_full_sync: bool = False, From d82443d04041ef7c2d1e5ab6f665c194cb136818 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 6 Jun 2026 19:16:50 +0200 Subject: [PATCH 19/40] fava_client: resolve relative target_file paths against the ledger root Fava's /api/source endpoint rejects relative paths with HTTP 500 (NonSourceFileError: "Trying to read a non-source file at '...'"). The include-aware `_infer_target_file` helper returns relative paths (e.g. "accounts/users.beancount"), so add a `_resolve_target_file` hook that prepends the ledger root directory. The dirname is derived from a one-time GET /api/options and cached on the FavaClient instance (which is a module-level singleton), guarded by an asyncio.Lock so concurrent first-callers don't double-fetch. Absolute paths pass through unchanged, so the admin endpoint that explicitly passes target_file="accounts/chart.beancount" works the same as one that passes "/var/lib/fava/accounts/chart.beancount". Verified against aio-demo's live fava: relative paths now produce HTTP 200 reads on options.beancount, accounts/chart.beancount, accounts/users.beancount, and transactions.beancount. Refs: aiolabs/libra#28 --- fava_client.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/fava_client.py b/fava_client.py index 8f60b23..578f077 100644 --- a/fava_client.py +++ b/fava_client.py @@ -80,6 +80,46 @@ class FavaClient: # Per-user locks for user-specific operations (reduces contention) self._user_locks: Dict[str, asyncio.Lock] = {} + # Cached absolute dirname of the root ledger file, derived from + # GET /api/options on first need. Used by `_resolve_target_file` to + # turn relative include paths (e.g. "accounts/users.beancount") into + # the absolute paths fava's /api/source endpoint requires. + self._main_dir_cache: Optional[str] = None + self._main_dir_lock = asyncio.Lock() + + async def _resolve_target_file(self, target_file: str) -> str: + """ + Turn a relative include path into the absolute path fava expects. + + Fava's /api/source endpoint refuses relative paths with HTTP 500 + (NonSourceFileError). Resolve any non-absolute target_file by + prepending the directory of the root ledger file (cached after + the first GET /api/options). + + Args: + target_file: Relative (e.g. "accounts/users.beancount") or + absolute path. + + Returns: + Absolute path under fava's ledger root. + """ + import os + + if os.path.isabs(target_file): + return target_file + + if self._main_dir_cache is None: + async with self._main_dir_lock: + if self._main_dir_cache is None: + async with httpx.AsyncClient(timeout=self.timeout) as client: + resp = await client.get(f"{self.base_url}/options") + resp.raise_for_status() + main_file = resp.json()["data"]["beancount_options"]["filename"] + self._main_dir_cache = os.path.dirname(main_file) + logger.debug(f"Cached fava ledger root dir: {self._main_dir_cache}") + + return os.path.join(self._main_dir_cache, target_file) + def get_user_lock(self, user_id: str) -> asyncio.Lock: """ Get or create a lock for a specific user. @@ -1560,6 +1600,9 @@ class FavaClient: if target_file is None: target_file = _infer_target_file(account_name) + # Fava's /api/source requires absolute paths; convert if needed. + target_file = await self._resolve_target_file(target_file) + last_error = None for attempt in range(max_retries): From 09a5d6ed55239690286ce92746593560d1ce7315 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 6 Jun 2026 19:36:39 +0200 Subject: [PATCH 20/40] Polish account-creation flow: insertion point, user_id consistency, startup race Three small fixes shaken out by live testing on aio-demo: 1. fava_client.add_account: when the target file has no Open directives yet (e.g. the empty accounts/users.beancount seed), append at end of file instead of inserting at index 0. Keeps the seed header comments at the top where they belong. 2. account_sync.sync_single_account_from_beancount: read the full user_id from Beancount metadata when present, fall back to the name-derived 8-char prefix otherwise. crud.get_or_create_user_account writes the full 32-char user_id into Beancount metadata when creating per-user accounts; the sync function was only looking at the account name and returning the prefix, so the post-sync `WHERE user_id=:user_id` query in crud.py missed the row and fell through the UNIQUE-constraint recovery path. Three lines of warning noise per user-account creation. 3. tasks.wait_for_account_sync: await `wait_for_fava_client()` (new helper backed by an asyncio.Event in fava_client.py) before the first sync iteration. Previously the sync task started in libra_start() raced the fire-and-forget `_init_fava()` coroutine and reliably crashed the first run with "Fava client not initialized". Refs: aiolabs/libra#28 --- account_sync.py | 11 ++++++++++- fava_client.py | 25 +++++++++++++++++++++++-- tasks.py | 7 +++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/account_sync.py b/account_sync.py index 7e875f8..3d82381 100644 --- a/account_sync.py +++ b/account_sync.py @@ -320,11 +320,20 @@ async def sync_single_account_from_beancount(account_name: str) -> bool: # Create in Libra DB account_type = infer_account_type_from_name(account_name) - user_id = extract_user_id_from_account_name(account_name) + # Prefer the full user_id stored in Beancount metadata (libra writes it + # when crud.get_or_create_user_account calls fava.add_account). Fall + # back to the name-derived 8-char prefix for accounts imported without + # metadata. This keeps user_id consistent with what the caller will + # query for, avoiding a churn cycle through the UNIQUE-constraint + # recovery path in crud.py. description = None + meta_user_id = None if "meta" in bc_account and isinstance(bc_account["meta"], dict): description = bc_account["meta"].get("description") + meta_user_id = bc_account["meta"].get("user_id") + + user_id = meta_user_id or extract_user_id_from_account_name(account_name) await create_account( CreateAccount( diff --git a/fava_client.py b/fava_client.py index 578f077..7719c38 100644 --- a/fava_client.py +++ b/fava_client.py @@ -1626,9 +1626,12 @@ class FavaClient: logger.info(f"Account {account_name} already exists in {target_file}") return {"data": sha256sum, "mtime": source_data.get("mtime", "")} - # Step 3: Find insertion point (after last Open directive AND its metadata) + # Step 3: Find insertion point (after last Open directive AND its metadata). + # If the file has no Open directives yet (e.g. the empty + # accounts/users.beancount seed), append at end of file + # so the seed header comments stay at the top. lines = source.split('\n') - insert_index = 0 + insert_index = None for i, line in enumerate(lines): if line.strip().startswith(('open ', f'{opening_date.year}-')) and 'open' in line: # Found an Open directive, now skip over any metadata lines @@ -1636,6 +1639,8 @@ class FavaClient: # Skip metadata lines (lines starting with whitespace) while insert_index < len(lines) and lines[insert_index].startswith((' ', '\t')) and lines[insert_index].strip(): insert_index += 1 + if insert_index is None: + insert_index = len(lines) # Step 4: Format Open directive as Beancount text currencies_str = ", ".join(currencies) @@ -1989,6 +1994,10 @@ class FavaClient: # Singleton instance (configured from settings) _fava_client: Optional[FavaClient] = None +# Set by init_fava_client; await for background tasks that must not run +# before the client exists (otherwise they raise "Fava client not initialized" +# during the first ~500ms of startup). +_fava_client_ready: asyncio.Event = asyncio.Event() def init_fava_client(fava_url: str, ledger_slug: str, timeout: float = 10.0): @@ -2002,9 +2011,21 @@ def init_fava_client(fava_url: str, ledger_slug: str, timeout: float = 10.0): """ global _fava_client _fava_client = FavaClient(fava_url, ledger_slug, timeout) + _fava_client_ready.set() logger.info(f"Fava client initialized: {fava_url}/{ledger_slug}") +async def wait_for_fava_client() -> FavaClient: + """Block until init_fava_client() has been called, then return the client. + + Use this from background tasks started in libra_start() — they otherwise + race the fire-and-forget _init_fava() coroutine and crash with + "Fava client not initialized" on first iteration. + """ + await _fava_client_ready.wait() + return get_fava_client() + + def get_fava_client() -> FavaClient: """ Get the configured Fava client. diff --git a/tasks.py b/tasks.py index f6f84cb..8ed5a33 100644 --- a/tasks.py +++ b/tasks.py @@ -134,8 +134,15 @@ async def wait_for_account_sync(): Background task that periodically syncs accounts from Beancount to Libra DB. Runs hourly to ensure Libra DB stays in sync with Beancount. + + Blocks on `wait_for_fava_client()` before the first iteration so we don't + race the fire-and-forget `_init_fava()` started in `libra_start()` and + fail the first sync with "Fava client not initialized". """ + from .fava_client import wait_for_fava_client + logger.info("[LIBRA] Account sync background task started") + await wait_for_fava_client() while True: try: From 9e7795b541ff53ddd1a95468000dae09cd70e4de Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 6 Jun 2026 19:39:55 +0200 Subject: [PATCH 21/40] add_account: always append at end of file The original "find last Open directive, insert after its metadata" logic was a clever optimisation for the monolithic ledger where opens, txns, and assertions all lived in one file -- you wanted new opens grouped with existing opens, not appended after a long transaction tail. Post-split, each include file has one mutation profile: - accounts/chart.beancount: only Open directives - accounts/users.beancount: only Open directives - transactions.beancount: only Transactions There is no longer a content shape that benefits from mid-file insertion; the existing heuristic also had a pre-existing bug where it only matched 'open ' OR '{current_year}-' as line prefixes, so 1970-* seed opens were invisible and the search "stuck" to the first current-year line in the file (which on aio-demo ended up being the wrong place). Drop the search; always append. Simpler, chronological, append-only friendly. Refs: aiolabs/libra#28 --- fava_client.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/fava_client.py b/fava_client.py index 7719c38..8dcf31c 100644 --- a/fava_client.py +++ b/fava_client.py @@ -1626,21 +1626,15 @@ class FavaClient: logger.info(f"Account {account_name} already exists in {target_file}") return {"data": sha256sum, "mtime": source_data.get("mtime", "")} - # Step 3: Find insertion point (after last Open directive AND its metadata). - # If the file has no Open directives yet (e.g. the empty - # accounts/users.beancount seed), append at end of file - # so the seed header comments stay at the top. + # Step 3: Always append at end of file. + # Post-split layout, each include file has one mutation + # profile (only Open directives in chart/users, only + # Transactions in transactions.beancount), so there's no + # reason to slot new entries mid-file. Append-only also + # keeps the seed header comments at the top and makes + # the file's evolution trivially readable. lines = source.split('\n') - insert_index = None - for i, line in enumerate(lines): - if line.strip().startswith(('open ', f'{opening_date.year}-')) and 'open' in line: - # Found an Open directive, now skip over any metadata lines - insert_index = i + 1 - # Skip metadata lines (lines starting with whitespace) - while insert_index < len(lines) and lines[insert_index].startswith((' ', '\t')) and lines[insert_index].strip(): - insert_index += 1 - if insert_index is None: - insert_index = len(lines) + insert_index = len(lines) # Step 4: Format Open directive as Beancount text currencies_str = ", ".join(currencies) From 1c89e690308512c558ad45cd0de4e820629b63c7 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 6 Jun 2026 20:47:27 +0200 Subject: [PATCH 22/40] feat(api): include voided transactions in user-entries endpoint The /api/v1/entries/user view was silently dropping any transaction tagged 'voided', so users couldn't see entries that had been rejected against their accounts. Per the libra reject convention, voided entries keep the '!' flag and carry a 'voided' tag for audit; clients can use the tag to style them distinctly. Pending-approval listing still filters voided. Co-Authored-By: Claude Opus 4.7 (1M context) --- views_api.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/views_api.py b/views_api.py index d31f881..90aabf1 100644 --- a/views_api.py +++ b/views_api.py @@ -486,10 +486,6 @@ async def api_get_user_entries( if e.get("flag") in _SYNTHETIC_FLAGS: continue - # Skip voided transactions - if "voided" in e.get("tags", []): - continue - # Extract user ID from metadata or account names user_id_match = None entry_meta = e.get("meta", {}) From 781059af5fb633071596c9b06a18ea8b0fc194b1 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 6 Jun 2026 20:47:42 +0200 Subject: [PATCH 23/40] fix(dashboard): detect voided rows by tag, not by flag char The admin transactions table assumed voided entries used flag='x', but the libra reject convention keeps the '!' flag and appends a 'voided' tag. Without this, the dashboard rendered voided rows as orange 'Pending' once they started reaching it. Detect via tag and give the voided icon precedence over the flag-based branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- static/js/index.js | 4 ++++ templates/libra/index.html | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index 3905bf3..418ec41 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1707,6 +1707,10 @@ window.app = Vue.createApp({ if (entry.tags && entry.tags.includes('equity-contribution')) return true if (entry.account && entry.account.includes('Equity')) return true return false + }, + isVoided(entry) { + // Voided entries keep '!' flag and carry a 'voided' tag (libra convention). + return Array.isArray(entry.tags) && entry.tags.includes('voided') } }, async created() { diff --git a/templates/libra/index.html b/templates/libra/index.html index 01de17a..0de0e71 100644 --- a/templates/libra/index.html +++ b/templates/libra/index.html @@ -497,7 +497,10 @@ From 7a4b3022c2fc46b89c1cf129958fad9de439f3e9 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 7 Jun 2026 09:47:09 +0200 Subject: [PATCH 24/40] Add integration test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 113 passing tests + 3 skipped + 8 xfailed across 10 files, covering user expense and income flow, admin receivable/revenue, settings + auth gates, void/reject, manual payment requests, balance display, Lightning auth paths, reconciliation API, and pure-function units. Runs against a real Fava subprocess and full LNbits app via asgi_lifespan; the harness captures the auth-flow / settings / env-var disciplines surfaced during build-out (see tests/README.md and tests/conftest.py docstring). Eight xfailed/skipped tests carry full implementations gated behind issues #38, #39, #40 — they flip back on automatically when those land. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/README.md | 48 ++ tests/__init__.py | 0 tests/conftest.py | 686 ++++++++++++++++++++++ tests/helpers.py | 392 +++++++++++++ tests/test_balances_api.py | 452 ++++++++++++++ tests/test_entries_admin_api.py | 219 +++++++ tests/test_entries_user_api.py | 211 +++++++ tests/test_lightning_api.py | 205 +++++++ tests/test_manual_payment_requests_api.py | 307 ++++++++++ tests/test_reconciliation_api.py | 294 ++++++++++ tests/test_settings_auth_api.py | 202 +++++++ tests/test_smoke.py | 66 +++ tests/test_unit.py | 416 +++++++++++++ tests/test_void_reject_api.py | 212 +++++++ 14 files changed, 3710 insertions(+) create mode 100644 tests/README.md create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/helpers.py create mode 100644 tests/test_balances_api.py create mode 100644 tests/test_entries_admin_api.py create mode 100644 tests/test_entries_user_api.py create mode 100644 tests/test_lightning_api.py create mode 100644 tests/test_manual_payment_requests_api.py create mode 100644 tests/test_reconciliation_api.py create mode 100644 tests/test_settings_auth_api.py create mode 100644 tests/test_smoke.py create mode 100644 tests/test_unit.py create mode 100644 tests/test_void_reject_api.py diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..efdab4b --- /dev/null +++ b/tests/README.md @@ -0,0 +1,48 @@ +# Libra extension tests + +Integration tests covering the user- and admin-facing flows of the libra extension. Tests run against a real `fava` subprocess and a full LNbits app so they catch behaviour that mocks would miss (BQL semantics, Beancount arithmetic, multi-currency aggregation, HTTP boundary). + +## Layout + +- `conftest.py` — session-scoped Fava subprocess + LNbits app + user/wallet fixtures. +- `helpers.py` — high-level wrappers for the common API flows (`post_expense`, `settle_receivable`, `approve_manual_payment_request`, …). One per intention, so test bodies read as sequences of actions rather than HTTP calls. +- `test_smoke.py` — single end-to-end test; run first to validate the harness. +- `test__api.py` — per-flow coverage (entries, balances, settlement, manual payment requests, lightning, reconciliation, settings/auth, void/reject). +- `test_unit.py` — pure functions (`beancount_format`, `account_utils`, `core/validation`); no harness. + +## Prerequisites + +The harness requires `fava` on PATH. On NixOS: + +```bash +nix-shell -p python3Packages.fava +``` + +Inside the regtest container `fava` is already provisioned. + +## Running + +From the LNbits source root (with the libra extension reachable via `LNBITS_EXTENSIONS_PATH` or symlinked into `lnbits/extensions/`): + +```bash +# Whole suite +pytest path/to/libra/tests + +# Smoke test only (validate the harness before running everything) +pytest path/to/libra/tests/test_smoke.py + +# One area +pytest path/to/libra/tests/test_balances_api.py + +# Single test, verbose +pytest path/to/libra/tests/test_balances_api.py::test_mixed_income_expense_nets_correctly -v +``` + +The Fava subprocess starts once per session (~1-2s) and is shared across tests; each test creates its own LNbits user so the shared ledger doesn't cause inter-test interference. + +## Conventions + +- **Tests assert intent, not shape.** Use the helpers in `helpers.py` for the request and assert on the *meaning* of the response (balance values, account names, settlement state), not on incidental keys in the JSON. This keeps tests resilient to non-behavioural API tweaks. +- **Currency-handling assertions use `pytest.approx`** for `Decimal`/`float` tolerance. +- **One canonical happy path per flow, plus boundary cases that matter** (voided entries excluded, pending entries excluded, cross-user isolation, auth gate rejection). Don't over-matrix. +- **Each test creates its own users** via the function-scoped `libra_user` / `libra_user_b` fixtures. The ledger is session-shared and accumulates entries; test isolation comes from unique user IDs, not ledger resets. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6698018 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,686 @@ +"""Libra test infrastructure. + +Brings up: + - A session-scoped Fava subprocess against a temp .beancount ledger + - A session-scoped LNbits FastAPI app with Libra extension activated + - The Libra FavaClient pointed at the test Fava instance + - Function-scoped user/wallet fixtures, plus a session-scoped superuser + +Run from the LNbits source root:: + + PYTHONPATH=. pytest lnbits/extensions/libra/tests + +Requires the `fava` binary on PATH. On NixOS:: + + nix-shell -p python3Packages.fava --run "pytest lnbits/extensions/libra/tests" +""" +import os +import tempfile + +# IMPORTANT: configure the LNbits data folder BEFORE importing anything from +# lnbits. `lnbits/db.py` constructs Database instances at module-import time +# and freezes `settings.lnbits_data_folder` at that moment — overriding it in +# a fixture later is too late to redirect the SQLite files. +_SESSION_DATA_DIR = tempfile.mkdtemp(prefix="libra-lnbits-data-") +os.environ.setdefault("LNBITS_DATA_FOLDER", _SESSION_DATA_DIR) + +# Lightning-invoice tests need a non-VoidWallet backend, but switching to +# FakeWallet here causes the LifespanManager teardown to hang indefinitely +# (the Lightning subsystem's background tasks don't unwind cleanly under +# anyio's TestRunner). Keeping VoidWallet — Lightning-invoice-generation +# tests are marked `skip` until a separate LN-harness strategy lands. + +import asyncio # noqa: E402 +import copy # noqa: E402 +import inspect # noqa: E402 +import shutil # noqa: E402 +import socket # noqa: E402 +import subprocess # noqa: E402 +import time # noqa: E402 +from pathlib import Path # noqa: E402 +from typing import AsyncIterator, Iterator # noqa: E402 +from uuid import uuid4 # noqa: E402 + +import httpx +import pytest +from asgi_lifespan import LifespanManager +from httpx import ASGITransport, AsyncClient + +from lnbits.app import create_app +from lnbits.core.crud import ( + create_wallet, + delete_account, + get_user, +) +from lnbits.core.models.users import UpdateSuperuserPassword +from lnbits.core.services import create_user_account +from lnbits.core.views.auth_api import first_install +from lnbits.settings import AuthMethods, EditableSettings, Settings +from lnbits.settings import settings as lnbits_settings + + +LEDGER_SLUG = "libra-test" + + +# --------------------------------------------------------------------------- +# Settings overrides +# --------------------------------------------------------------------------- + +_PURE_SETTINGS = copy.deepcopy(lnbits_settings) +_PURE_SETTINGS_FIELDS = tuple( + sorted( + { + f + for f in Settings.readonly_fields() + if f != "super_user" + } + | { + name + for name in inspect.signature(EditableSettings).parameters + if not name.startswith("_") + } + ) +) + + +def _settings_cleanup(settings: Settings) -> None: + """Reset mutable settings to their pre-test snapshot, then re-apply + test-specific overrides on top so each test starts from the same baseline. + + Mirrors the shape of lnbits/main/tests/conftest.py: restore PURE, then + set the values the tests rely on. Without this, autouse cleanup wipes + out everything the session-scoped `settings` fixture set up. + """ + for field in _PURE_SETTINGS_FIELDS: + setattr(settings, field, getattr(_PURE_SETTINGS, field)) + # Test-specific overrides — these must survive cleanup between tests. + settings.auth_https_only = False + settings.lnbits_data_folder = _SESSION_DATA_DIR + settings.lnbits_admin_extensions = [] # libra is a multi-user extension, not admin-only + settings.lnbits_admin_ui = True + settings.lnbits_extensions_default_install = [] + settings.lnbits_extensions_deactivate_all = False + settings.lnbits_allow_new_accounts = True + settings.lnbits_allowed_users = [] + settings.auth_allowed_methods = AuthMethods.all() + settings.auth_credetials_update_threshold = 120 + settings.lnbits_require_user_activation = False + settings.lnbits_user_activation_by_invitation_code = False + settings.lnbits_register_reusable_activation_code = "" + settings.lnbits_register_one_time_activation_codes = [] + + +@pytest.fixture(scope="session") +def anyio_backend() -> str: + return "asyncio" + + +@pytest.fixture(scope="session") +def settings() -> Iterator[Settings]: + """LNbits settings configured for the libra test session. + + Mirrors lnbits/main/tests/conftest.py: do NOT pre-set super_user; the boot + sequence assigns a UUID and creates the matching account. The `super_user` + fixture reads settings.super_user after first_install completes. + + The data folder was set via LNBITS_DATA_FOLDER at the top of this module + so the lnbits/db.py import-time directory creation lands in the right + place; nothing to do here except make sure it stays consistent. + """ + lnbits_settings.auth_https_only = False + lnbits_settings.lnbits_admin_extensions = ["libra"] + lnbits_settings.lnbits_data_folder = _SESSION_DATA_DIR + lnbits_settings.lnbits_admin_ui = True + lnbits_settings.lnbits_extensions_default_install = [] + lnbits_settings.lnbits_extensions_deactivate_all = False + yield lnbits_settings + + +@pytest.fixture(autouse=True) +def _per_test_settings_reset(settings: Settings) -> Iterator[None]: + _settings_cleanup(settings) + yield + _settings_cleanup(settings) + + +# --------------------------------------------------------------------------- +# Fava subprocess +# --------------------------------------------------------------------------- + + +def _find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +MINIMAL_LEDGER = """; Test ledger for Libra extension integration tests +; Title must slugify to match LEDGER_SLUG — Fava derives the URL slug from this. +option "title" "libra-test" +option "operating_currency" "EUR" +option "operating_currency" "SATS" +option "render_commas" "TRUE" + +2020-01-01 commodity EUR +2020-01-01 commodity SATS + +2020-01-01 open Assets:Lightning:Balance EUR,SATS +2020-01-01 open Assets:Bitcoin:Lightning EUR,SATS +2020-01-01 open Assets:Cash EUR,SATS +2020-01-01 open Equity:Opening-Balances EUR,SATS +2020-01-01 open Income:Generic EUR,SATS +2020-01-01 open Expenses:Generic EUR,SATS +""" + + +@pytest.fixture(scope="session") +def fava_ledger_path(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Session-scoped .beancount file Fava reads from.""" + ledger_dir = tmp_path_factory.mktemp("libra-ledger") + ledger = ledger_dir / f"{LEDGER_SLUG}.beancount" + ledger.write_text(MINIMAL_LEDGER) + return ledger + + +@pytest.fixture(scope="session") +def fava_process(fava_ledger_path: Path) -> Iterator[str]: + """Spawn fava as a subprocess, yield its base URL, terminate on teardown.""" + fava_bin = shutil.which("fava") + if not fava_bin: + pytest.skip( + "fava not found on PATH; " + "install with `pip install fava` or `nix-shell -p python3Packages.fava`" + ) + + port = _find_free_port() + base_url = f"http://127.0.0.1:{port}" + + proc = subprocess.Popen( + [ + fava_bin, + "--host", "127.0.0.1", + "--port", str(port), + str(fava_ledger_path), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env={**os.environ, "BEANCOUNT_FILE": str(fava_ledger_path)}, + ) + + deadline = time.monotonic() + 15.0 + ready = False + while time.monotonic() < deadline: + if proc.poll() is not None: + raise RuntimeError( + f"fava exited early with returncode {proc.returncode}" + ) + try: + r = httpx.get(f"{base_url}/{LEDGER_SLUG}/api/changed", timeout=0.5) + if r.status_code == 200: + ready = True + break + except httpx.RequestError: + pass + time.sleep(0.1) + + if not ready: + proc.terminate() + raise RuntimeError("fava did not become ready within 15s") + + try: + yield base_url + finally: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + + +# --------------------------------------------------------------------------- +# LNbits app + Libra extension +# --------------------------------------------------------------------------- + + +def _import_libra(submodule: str): + """Import a libra submodule under whichever path the active LNbits setup uses. + + LNbits resolves an extension's module name dynamically: `lnbits.extensions.` + when extensions live in the default `lnbits/extensions/` directory, or just + `` when `LNBITS_EXTENSIONS_PATH` points elsewhere. Tests should work in + both setups. + """ + import importlib + for prefix in ("lnbits.extensions.libra", "libra"): + try: + return importlib.import_module(f"{prefix}.{submodule}") + except ModuleNotFoundError: + continue + raise ModuleNotFoundError( + f"libra.{submodule}: tried 'lnbits.extensions.libra.{submodule}' and " + f"'libra.{submodule}'. Is LNBITS_EXTENSIONS_PATH pointing at the libra parent dir, " + f"or is libra symlinked into lnbits/extensions/?" + ) + + +async def _enable_libra_for_user(user_id: str) -> None: + """Set libra to active in the user_extensions table for `user_id`. + + LNbits gates every extension API path through `check_user_extension_access`, + which requires the calling user to have the extension marked active in + `user_extensions`. New accounts have no extensions enabled, so the API + rejects them with 403 until we flip the row. + """ + from lnbits.core.services.users import update_user_extensions + await update_user_extensions(user_id, ["libra"]) + + +async def _activate_libra(fava_url: str, super_user_id: str) -> None: + """Point libra at the test Fava instance and enable it for the superuser. + + Libra is auto-discovered + auto-installed at LNbits boot via + `LNBITS_EXTENSIONS_PATH`, so its router is already mounted, migrations + already ran, and `libra_start()` already initialised a FavaClient with + the default `http://localhost:3333/libra-ledger` URL. Three things still + need doing: + + 1. Redirect the FavaClient at the test Fava instance. + 2. Persist the override in `extension_settings` so any caller that goes + through `services.get_settings()` picks it up too. + 3. Enable libra for the superuser — per-user activation isn't automatic. + """ + libra_fava_client = _import_libra("fava_client") + libra_crud = _import_libra("crud") + + libra_fava_client.init_fava_client( + fava_url=fava_url, + ledger_slug=LEDGER_SLUG, + timeout=5.0, + ) + + await libra_crud.db.execute("DELETE FROM extension_settings") + await libra_crud.db.execute( + """ + INSERT INTO extension_settings (id, fava_url, fava_ledger_slug, fava_timeout) + VALUES (:id, :fava_url, :slug, :timeout) + """, + { + "id": uuid4().hex, + "fava_url": fava_url, + "slug": LEDGER_SLUG, + "timeout": 5.0, + }, + ) + + await _enable_libra_for_user(super_user_id) + + +@pytest.fixture(scope="session") +async def app(settings: Settings, fava_process: str) -> AsyncIterator: + """Session-scoped LNbits app with Libra activated.""" + app = create_app() + # First-time startup runs all core + libra migrations (~3-5s on cold disk), + # plus libra_start() initialises the Fava client and background tasks. + # Bump the timeout well above asgi_lifespan's 10s default so a slow + # migration step or Fava startup race doesn't spuriously fail the session. + async with LifespanManager(app, startup_timeout=60, shutdown_timeout=20) as manager: + settings.first_install = True + # pragma: allowlist secret start + await first_install( + UpdateSuperuserPassword( + username="superadmin", + password="secret1234", + password_repeat="secret1234", + first_install_token=settings.first_install_token, + ) + # pragma: allowlist secret end + ) + await _activate_libra( + fava_url=fava_process, + super_user_id=settings.super_user, + ) + yield manager.app + + +@pytest.fixture(scope="session") +async def client(app, settings: Settings) -> AsyncIterator[AsyncClient]: + url = f"http://{settings.host}:{settings.port}" + async with AsyncClient(transport=ASGITransport(app=app), base_url=url) as client: + yield client + + +# --------------------------------------------------------------------------- +# Users +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +async def super_user(app, settings: Settings): + """The superadmin account created by first_install.""" + # first_install sets settings.super_user to the actual ID it created. + user = await get_user(settings.super_user) + assert user is not None, "superadmin was not created by first_install" + return user + + +@pytest.fixture +async def libra_user(app): + """A fresh non-admin user with a wallet. Function-scoped — each test gets its own. + + Libra is enabled in the user_extensions table for this user so the API + doesn't 403 with "Extension 'libra' not enabled." + """ + user = await create_user_account() + wallet = await create_wallet( + user_id=user.id, wallet_name=f"libra-test-{uuid4().hex[:6]}" + ) + await _enable_libra_for_user(user.id) + yield user, wallet + # Cleanup: best-effort + try: + await delete_account(user.id) + except Exception: + pass + + +@pytest.fixture +async def libra_user_b(app): + """A second fresh non-admin user, for tests that need cross-user assertions.""" + user = await create_user_account() + wallet = await create_wallet( + user_id=user.id, wallet_name=f"libra-test-{uuid4().hex[:6]}" + ) + await _enable_libra_for_user(user.id) + yield user, wallet + try: + await delete_account(user.id) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Auth headers +# --------------------------------------------------------------------------- + + +async def _user_bearer(client: AsyncClient, user_id: str) -> dict: + """Bearer headers for a non-admin user via the `/auth/usr` user-id-only flow. + + Admin/super accounts are blocked from this flow (LNbits forces them to + use username+password); regular users use it freely. Required for libra + endpoints that depend on `check_user_exists` (Bearer/cookie/usr) rather + than on a wallet API key. + """ + r = await client.post("/api/v1/auth/usr", json={"usr": user_id}) + client.cookies.clear() + token = r.json().get("access_token") + assert token, f"user-id login failed: {r.status_code} {r.text}" + return { + "Authorization": f"Bearer {token}", + "Content-type": "application/json", + } + + +async def _superadmin_bearer(client: AsyncClient) -> dict: + """Bearer headers for the superadmin via username+password auth. + + `/api/v1/auth/usr` (user-id-only auth) is rejected for admin users — + LNbits enforces username+password for accounts in `lnbits_admin_users` + or the super_user account. So super-user fixtures use the username + flow that `first_install` configured. + """ + r = await client.post( + "/api/v1/auth", json={"username": "superadmin", "password": "secret1234"} + ) + client.cookies.clear() + token = r.json().get("access_token") + assert token, f"superadmin login failed: {r.status_code} {r.text}" + return { + "Authorization": f"Bearer {token}", + "Content-type": "application/json", + } + + +@pytest.fixture +async def super_user_bearer_headers(client: AsyncClient, super_user) -> dict: + """Bearer headers for the few endpoints that use LNbits `check_super_user`. + + The `/libra/api/v1/settings` endpoints (and other libra paths that take + `User = Depends(check_super_user)`) require a Bearer token from + username+password login. Most other libra admin endpoints use the + wallet-admin-key auth flow — use `super_user_headers` for those. + """ + return await _superadmin_bearer(client) + + +@pytest.fixture +async def super_user_headers(super_user, libra_wallet) -> dict: + """Admin-key headers for libra admin endpoints that use the wallet auth flow. + + Libra's `require_super_user` dependency takes a `WalletTypeInfo` via + `require_admin_key` and verifies the wallet's owner is the LNbits + super user. So we authenticate by sending the super-user-owned wallet's + admin key as `X-Api-Key`. + """ + return admin_key_headers(libra_wallet) + + +def invoice_key_headers(wallet) -> dict: + """Wallet invoice-key headers (X-Api-Key) — for require_invoice_key endpoints.""" + return {"X-Api-Key": wallet.inkey, "Content-type": "application/json"} + + +def admin_key_headers(wallet) -> dict: + """Wallet admin-key headers (X-Api-Key) — for require_admin_key endpoints.""" + return {"X-Api-Key": wallet.adminkey, "Content-type": "application/json"} + + +# --------------------------------------------------------------------------- +# Libra-specific session setup: wallet, accounts +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +async def libra_wallet( + app, settings: Settings, super_user, fava_process: str, client: AsyncClient, +): + """Session-scoped: create a wallet for the super user and register it + as the libra wallet in extension_settings. + + Most flows (expense, income, settle, pay-user) refuse to operate until + this is set. Session-scoped because it's a one-time setup that any test + can share. + """ + wallet = await create_wallet(user_id=super_user.id, wallet_name="libra-main") + + # Configure libra_wallet_id via the settings API so the in-memory cache + # (services.update_settings) refreshes too. + # + # Critical: include fava_url + fava_ledger_slug in the body so that + # services.update_settings()'s re-init of the FavaClient doesn't reset + # us to the default `http://localhost:3333/libra-ledger`. The settings + # endpoint rewrites the global FavaClient from the body's contents on + # every call. + headers = await _superadmin_bearer(client) + r = await client.put( + "/libra/api/v1/settings", + headers=headers, + json={ + "libra_wallet_id": wallet.id, + "fava_url": fava_process, + "fava_ledger_slug": LEDGER_SLUG, + }, + ) + assert r.status_code == 200, f"libra_wallet setup failed: {r.status_code} {r.text}" + return wallet + + +@pytest.fixture(scope="session") +async def standard_accounts(app, super_user, libra_wallet, client: AsyncClient): + """Session-scoped: create a small set of accounts used across tests. + + Returns a dict of {short_name: account_dict}. Each account has at least + `id` and `name` keys. + """ + # `/accounts` POST is gated by `require_super_user` (libra-level, wallet + # admin-key flow), so we authenticate with the super-user's wallet key. + headers = admin_key_headers(libra_wallet) + + async def _list_lookup(name: str) -> dict | None: + r = await client.get("/libra/api/v1/accounts", headers=headers) + if r.status_code != 200: + return None + for a in r.json(): + if a.get("name") == name: + return a + return None + + async def _create(name: str, account_type: str) -> dict: + # Get-then-create. Some accounts (Assets:Cash, etc.) are auto-synced + # into libra's DB from the Beancount Open directives by the account-sync + # background task. Posting a duplicate raises IntegrityError → 500; + # checking first avoids the race and the noisy error log. + existing = await _list_lookup(name) + if existing: + return existing + + r = await client.post( + "/libra/api/v1/accounts", + headers=headers, + json={"name": name, "account_type": account_type}, + ) + if r.status_code == 201: + return r.json() + # Lost the race between our GET and POST — sync ran in between. + existing = await _list_lookup(name) + if existing: + return existing + raise AssertionError(f"create account {name}: {r.status_code} {r.text}") + + return { + "expense_food": await _create("Expenses:Test:Food", "expense"), + "expense_supplies": await _create("Expenses:Test:Supplies", "expense"), + "revenue_rent": await _create("Income:Test:Rent", "revenue"), + "revenue_fees": await _create("Income:Test:Fees", "revenue"), + # Cash for revenue/settlement payment-method tests. Already declared + # as an Open directive in the Beancount file (see MINIMAL_LEDGER), + # but needs a libra-DB row too because the revenue endpoint validates + # payment-method-account via libra's local lookup. + "assets_cash": await _create("Assets:Cash", "asset"), + # Lightning balance account — the manual-payment-request approve + # endpoint posts the payment leg against this. Open directive lives + # in MINIMAL_LEDGER's Assets:Lightning:Balance, but the API code + # looks up `Assets:Bitcoin:Lightning` specifically. + "assets_lightning": await _create("Assets:Bitcoin:Lightning", "asset"), + } + + +# --------------------------------------------------------------------------- +# Configured user — wallet set + can submit expenses to the standard accounts +# --------------------------------------------------------------------------- + + +async def _grant_account_permissions( + client: AsyncClient, + libra_wallet, + user_id: str, + grants: list[tuple[str, str]], +) -> None: + """Grant a list of (account_id, permission_type) pairs to a user. + + Existing perms come back as 409; that's idempotent for fixture re-runs. + """ + headers = admin_key_headers(libra_wallet) + for account_id, permission_type in grants: + r = await client.post( + "/libra/api/v1/admin/permissions", + headers=headers, + json={ + "user_id": user_id, + "account_id": account_id, + "permission_type": permission_type, + }, + ) + # 201 created; 409 if it already existed (idempotent). + assert r.status_code in (200, 201, 409), ( + f"grant permission failed: {r.status_code} {r.text}" + ) + + +@pytest.fixture +async def configured_user( + app, super_user, libra_wallet, standard_accounts, client: AsyncClient, +): + """Function-scoped: fresh user with a wallet, configured for libra, + permitted to submit expenses to the standard test accounts. + + Yields (user, wallet) ready to make any user-facing API call. + """ + user = await create_user_account() + wallet = await create_wallet( + user_id=user.id, wallet_name=f"libra-test-{uuid4().hex[:6]}" + ) + await _enable_libra_for_user(user.id) + + # User registers their own wallet with libra. The endpoint uses + # `check_user_exists` which accepts either a Bearer access token OR + # a `?usr=` query param — we use the query param to avoid the + # cookie-state interleaving that bites when two configured_user + # fixtures stack in the same test. + r = await client.put( + f"/libra/api/v1/user/wallet?usr={user.id}", + json={"user_wallet_id": wallet.id}, + ) + assert r.status_code == 200, f"user wallet setup failed: {r.status_code} {r.text}" + + # Grant submit_expense on every expense account, submit_income on every + # revenue account, so tests can hit either user-side entry endpoint. + grants = [ + (a["id"], "submit_expense") + for k, a in standard_accounts.items() if k.startswith("expense_") + ] + [ + (a["id"], "submit_income") + for k, a in standard_accounts.items() if k.startswith("revenue_") + ] + await _grant_account_permissions(client, libra_wallet, user.id, grants) + + yield user, wallet + + try: + await delete_account(user.id) + except Exception: + pass + + +@pytest.fixture +async def configured_user_b( + app, super_user, libra_wallet, standard_accounts, client: AsyncClient, +): + """A second configured user for cross-user tests.""" + user = await create_user_account() + wallet = await create_wallet( + user_id=user.id, wallet_name=f"libra-test-{uuid4().hex[:6]}" + ) + await _enable_libra_for_user(user.id) + + r = await client.put( + f"/libra/api/v1/user/wallet?usr={user.id}", + json={"user_wallet_id": wallet.id}, + ) + assert r.status_code == 200, f"user wallet setup failed: {r.status_code} {r.text}" + + grants = [ + (a["id"], "submit_expense") + for k, a in standard_accounts.items() if k.startswith("expense_") + ] + [ + (a["id"], "submit_income") + for k, a in standard_accounts.items() if k.startswith("revenue_") + ] + await _grant_account_permissions(client, libra_wallet, user.id, grants) + + yield user, wallet + + try: + await delete_account(user.id) + except Exception: + pass diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..4bc0105 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,392 @@ +"""Convenience helpers for Libra integration tests. + +Wrap the most common multi-step flows so each test reads as a sequence of +intentions rather than as a sequence of HTTP calls. Every helper returns the +parsed JSON response and asserts a successful status code — tests that want +to assert on failures should call the endpoint directly. + +All amounts are passed as Decimal (or numeric string). Currency goes as a +separate ISO code field — this matches `models.ExpenseEntry` / `ReceivableEntry` +/ `SettleReceivable` / `PayUser` etc., which all carry `amount: Decimal` and +`currency: Optional[str]` independently. +""" +from decimal import Decimal +from typing import Any, Optional, Union + +from httpx import AsyncClient + +Amount = Union[Decimal, int, float, str] + + +def _amount(value: Amount) -> str: + """Coerce amount to a JSON-serialisable string Pydantic will parse as Decimal.""" + return str(value) + + +# --------------------------------------------------------------------------- +# Setup — libra wallet + per-user wallet + accounts + permissions +# --------------------------------------------------------------------------- + + +async def configure_libra_wallet( + client: AsyncClient, + *, + super_user_headers: dict, + libra_wallet_id: str, +) -> dict: + """Super user sets the libra wallet (required before any entry endpoint works).""" + r = await client.put( + "/libra/api/v1/settings", + headers=super_user_headers, + json={"libra_wallet_id": libra_wallet_id}, + ) + assert r.status_code == 200, f"configure_libra_wallet failed: {r.status_code} {r.text}" + return r.json() + + +async def configure_user_wallet( + client: AsyncClient, + *, + wallet_inkey: str, + user_wallet_id: str, +) -> dict: + """User sets their personal wallet (required before they can submit entries).""" + r = await client.put( + "/libra/api/v1/user/wallet", + headers={"X-Api-Key": wallet_inkey, "Content-type": "application/json"}, + json={"user_wallet_id": user_wallet_id}, + ) + assert r.status_code == 200, f"configure_user_wallet failed: {r.status_code} {r.text}" + return r.json() + + +async def create_account( + client: AsyncClient, + *, + super_user_headers: dict, + name: str, + account_type: str, + description: Optional[str] = None, +) -> dict: + """Super user creates an account in the libra local DB. + + `account_type` is one of "asset", "liability", "equity", "revenue", "expense". + """ + r = await client.post( + "/libra/api/v1/accounts", + headers=super_user_headers, + json={ + "name": name, + "account_type": account_type, + "description": description, + }, + ) + assert r.status_code == 201, f"create_account failed: {r.status_code} {r.text}" + return r.json() + + +async def grant_permission( + client: AsyncClient, + *, + super_user_headers: dict, + user_id: str, + account_id: str, + permission_type: str = "submit_expense", +) -> dict: + r = await client.post( + "/libra/api/v1/admin/permissions", + headers=super_user_headers, + json={ + "user_id": user_id, + "account_id": account_id, + "permission_type": permission_type, + }, + ) + assert r.status_code == 201, f"grant_permission failed: {r.status_code} {r.text}" + return r.json() + + +# --------------------------------------------------------------------------- +# Entries — user side +# --------------------------------------------------------------------------- + + +async def post_expense( + client: AsyncClient, + *, + wallet_inkey: str, + user_wallet_id: str, + amount: Amount, + description: str, + expense_account: str, + currency: Optional[str] = "EUR", + is_equity: bool = False, +) -> dict[str, Any]: + """User submits an expense — creates Liability (libra owes user) or Equity contribution. + + Returns the created JournalEntry payload. + """ + r = await client.post( + "/libra/api/v1/entries/expense", + headers={"X-Api-Key": wallet_inkey, "Content-type": "application/json"}, + json={ + "description": description, + "amount": _amount(amount), + "expense_account": expense_account, + "user_wallet": user_wallet_id, + "currency": currency, + "is_equity": is_equity, + }, + ) + assert r.status_code == 201, f"post_expense failed: {r.status_code} {r.text}" + return r.json() + + +async def post_income( + client: AsyncClient, + *, + wallet_inkey: str, + amount: Amount, + description: str, + revenue_account: str, + currency: str = "EUR", +) -> dict[str, Any]: + """User submits income on libra's behalf — creates Receivable (user owes libra).""" + r = await client.post( + "/libra/api/v1/entries/income", + headers={"X-Api-Key": wallet_inkey, "Content-type": "application/json"}, + json={ + "description": description, + "amount": _amount(amount), + "revenue_account": revenue_account, + "currency": currency, + }, + ) + assert r.status_code == 201, f"post_income failed: {r.status_code} {r.text}" + return r.json() + + +async def list_user_entries(client: AsyncClient, *, wallet_inkey: str) -> list[dict]: + r = await client.get( + "/libra/api/v1/entries/user", + headers={"X-Api-Key": wallet_inkey}, + ) + assert r.status_code == 200, f"list_user_entries failed: {r.status_code} {r.text}" + return r.json() + + +# --------------------------------------------------------------------------- +# Entries — admin side +# --------------------------------------------------------------------------- + + +async def post_receivable( + client: AsyncClient, + *, + super_user_headers: dict, + user_id: str, + amount: Amount, + description: str, + revenue_account: str, + currency: str = "EUR", +) -> dict[str, Any]: + """Admin records a receivable — user owes libra.""" + r = await client.post( + "/libra/api/v1/entries/receivable", + headers=super_user_headers, + json={ + "user_id": user_id, + "amount": _amount(amount), + "description": description, + "revenue_account": revenue_account, + "currency": currency, + }, + ) + assert r.status_code == 201, f"post_receivable failed: {r.status_code} {r.text}" + return r.json() + + +async def post_revenue( + client: AsyncClient, + *, + super_user_headers: dict, + amount: Amount, + description: str, + revenue_account: str, + payment_method_account: str, + currency: str = "EUR", +) -> dict[str, Any]: + r = await client.post( + "/libra/api/v1/entries/revenue", + headers=super_user_headers, + json={ + "amount": _amount(amount), + "description": description, + "revenue_account": revenue_account, + "payment_method_account": payment_method_account, + "currency": currency, + }, + ) + assert r.status_code == 201, f"post_revenue failed: {r.status_code} {r.text}" + return r.json() + + +# --------------------------------------------------------------------------- +# Balances +# --------------------------------------------------------------------------- + + +async def get_balance(client: AsyncClient, *, wallet_inkey: str) -> dict[str, Any]: + """Calling user's balance (or libra total if invoked by super user).""" + r = await client.get( + "/libra/api/v1/balance", + headers={"X-Api-Key": wallet_inkey}, + ) + assert r.status_code == 200, f"get_balance failed: {r.status_code} {r.text}" + return r.json() + + +async def get_all_balances( + client: AsyncClient, *, super_user_headers: dict +) -> list[dict]: + r = await client.get( + "/libra/api/v1/balances/all", + headers=super_user_headers, + ) + assert r.status_code == 200, f"get_all_balances failed: {r.status_code} {r.text}" + return r.json() + + +# --------------------------------------------------------------------------- +# Settlement +# --------------------------------------------------------------------------- + + +async def settle_receivable( + client: AsyncClient, + *, + super_user_headers: dict, + user_id: str, + amount: Amount, + description: str = "Cash settlement", + payment_method: str = "cash", + currency: str = "EUR", +) -> dict[str, Any]: + """Admin records that user paid libra (e.g. cash, bank transfer).""" + r = await client.post( + "/libra/api/v1/receivables/settle", + headers=super_user_headers, + json={ + "user_id": user_id, + "amount": _amount(amount), + "description": description, + "payment_method": payment_method, + "currency": currency, + }, + ) + assert r.status_code == 200, f"settle_receivable failed: {r.status_code} {r.text}" + return r.json() + + +async def pay_user( + client: AsyncClient, + *, + super_user_headers: dict, + user_id: str, + amount: Amount, + description: str = "Libra pays user", + payment_method: str = "cash", + currency: str = "EUR", +) -> dict[str, Any]: + """Admin records that libra paid user (e.g. cash, bank, lightning).""" + r = await client.post( + "/libra/api/v1/payables/pay", + headers=super_user_headers, + json={ + "user_id": user_id, + "amount": _amount(amount), + "description": description, + "payment_method": payment_method, + "currency": currency, + }, + ) + assert r.status_code == 200, f"pay_user failed: {r.status_code} {r.text}" + return r.json() + + +# --------------------------------------------------------------------------- +# Manual payment requests +# --------------------------------------------------------------------------- + + +async def submit_manual_payment_request( + client: AsyncClient, + *, + wallet_inkey: str, + amount_sats: int, + description: str, +) -> dict[str, Any]: + """User asks for libra to pay them via a manual (non-Lightning) route. + + Body matches `CreateManualPaymentRequest`: amount in satoshis (no fiat + conversion at this endpoint), description for the admin to review. + """ + r = await client.post( + "/libra/api/v1/manual-payment-request", + headers={"X-Api-Key": wallet_inkey, "Content-type": "application/json"}, + json={"amount": amount_sats, "description": description}, + ) + assert r.status_code in (200, 201), ( + f"submit_manual_payment_request failed: {r.status_code} {r.text}" + ) + return r.json() + + +async def approve_manual_payment_request( + client: AsyncClient, *, super_user_headers: dict, request_id: str, +) -> dict[str, Any]: + r = await client.post( + f"/libra/api/v1/manual-payment-requests/{request_id}/approve", + headers=super_user_headers, + ) + assert r.status_code == 200, ( + f"approve_manual_payment_request failed: {r.status_code} {r.text}" + ) + return r.json() + + +async def approve_entry( + client: AsyncClient, *, super_user_headers: dict, entry_id: str, +) -> dict[str, Any]: + """Admin approves a pending journal entry, flipping its flag from `!` to `*`.""" + r = await client.post( + f"/libra/api/v1/entries/{entry_id}/approve", + headers=super_user_headers, + ) + assert r.status_code == 200, f"approve_entry failed: {r.status_code} {r.text}" + return r.json() + + +async def reject_entry( + client: AsyncClient, *, super_user_headers: dict, entry_id: str, +) -> dict[str, Any]: + """Admin rejects a pending journal entry, marking it #voided.""" + r = await client.post( + f"/libra/api/v1/entries/{entry_id}/reject", + headers=super_user_headers, + ) + assert r.status_code == 200, f"reject_entry failed: {r.status_code} {r.text}" + return r.json() + + +async def reject_manual_payment_request( + client: AsyncClient, *, super_user_headers: dict, request_id: str, +) -> dict[str, Any]: + r = await client.post( + f"/libra/api/v1/manual-payment-requests/{request_id}/reject", + headers=super_user_headers, + ) + assert r.status_code == 200, ( + f"reject_manual_payment_request failed: {r.status_code} {r.text}" + ) + return r.json() diff --git a/tests/test_balances_api.py b/tests/test_balances_api.py new file mode 100644 index 0000000..951f470 --- /dev/null +++ b/tests/test_balances_api.py @@ -0,0 +1,452 @@ +"""Balance display tests — the user-named "mixture of income and expenses +displayed correctly" scenario. + +The balance API returns figures from libra's perspective: + - Negative `fiat_balances[CCY]` → libra owes the user + - Positive `fiat_balances[CCY]` → user owes libra + - Sum across Payable + Receivable + Credit per currency + (Credit added per libra-#41: overpayment lands as a liability that + libra owes the user going forward, naturally subtracting from net.) + +Lifetime totals (`total_expenses_fiat`, `total_income_fiat`) are kept +separate per the `models.py:93` comment — "original entries only; not net of +reconciliation" — so they don't reflect settlement activity or credit. + +Excluded from the balance query: pending entries (flag `!`), voided entries +(tag `voided`). Tested explicitly here so the contract is locked in. + +Note: this file does NOT cover post-settlement netting; that's blocked on +issue #33 (settlement leaves both per-user accounts non-zero) and lives in +the settlement test file. +""" +import importlib +from datetime import date +from uuid import uuid4 + +import pytest + +from .helpers import ( + approve_entry, + get_all_balances, + get_balance, + list_user_entries, + post_expense, + post_income, + post_receivable, + reject_entry, +) + + +def _libra_module(submodule: str): + """Import a libra submodule via whichever path the harness uses (matches + the resolver in conftest.py).""" + for prefix in ("lnbits.extensions.libra", "libra"): + try: + return importlib.import_module(f"{prefix}.{submodule}") + except ModuleNotFoundError: + continue + raise ModuleNotFoundError(f"libra.{submodule}") + + +async def _approve_and_refresh(client, wallet, super_user_headers, entry_id): + """Approve a pending entry then force a fresh Fava read. + + Workaround for libra issue #37 — BQL balance reads can lag add_entry + by a few ms. The user-journal endpoint forces a Fava reload. + """ + await approve_entry( + client, super_user_headers=super_user_headers, entry_id=entry_id, + ) + await list_user_entries(client, wallet_inkey=wallet.inkey) + + +# --------------------------------------------------------------------------- +# Single-direction balances +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_pure_expense_balance_is_negative( + client, super_user_headers, configured_user, standard_accounts, +): + """User submits a single expense → libra owes them → balance < 0 EUR.""" + _, wallet = configured_user + entry = await post_expense( + client, + wallet_inkey=wallet.inkey, + user_wallet_id=wallet.id, + amount="40.00", currency="EUR", + description=f"Pure expense {uuid4().hex[:6]}", + expense_account=standard_accounts["expense_food"]["name"], + ) + await _approve_and_refresh(client, wallet, super_user_headers, entry["id"]) + + balance = await get_balance(client, wallet_inkey=wallet.inkey) + eur = balance.get("fiat_balances", {}).get("EUR") + assert float(eur) == pytest.approx(-40.0), ( + f"expected -40 EUR (libra owes user), got {eur}" + ) + + +@pytest.mark.anyio +async def test_pure_income_balance_is_positive( + client, super_user_headers, configured_user, standard_accounts, +): + """User submits a single income → user owes libra → balance > 0 EUR. + + `/entries/income` records that the user collected money on libra's + behalf, creating an `Assets:Receivable:User-{id}` debit until they + settle by handing the cash over. + """ + _, wallet = configured_user + entry = await post_income( + client, + wallet_inkey=wallet.inkey, + amount="120.00", currency="EUR", + description=f"Pure income {uuid4().hex[:6]}", + revenue_account=standard_accounts["revenue_rent"]["name"], + ) + await _approve_and_refresh(client, wallet, super_user_headers, entry["id"]) + + balance = await get_balance(client, wallet_inkey=wallet.inkey) + eur = balance.get("fiat_balances", {}).get("EUR") + assert float(eur) == pytest.approx(120.0), ( + f"expected +120 EUR (user owes libra), got {eur}" + ) + + +# --------------------------------------------------------------------------- +# Mixed direction — the headline scenario +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_mixed_expense_and_income_nets_correctly( + client, super_user_headers, configured_user, standard_accounts, +): + """User has 50 EUR expense + 120 EUR income (both approved) → net + balance is +70 EUR (user owes libra 70). + + This is the user's headline "displayed correctly" scenario — the + Payable and Receivable rows sum into one EUR figure. + """ + _, wallet = configured_user + + expense = await post_expense( + client, + wallet_inkey=wallet.inkey, + user_wallet_id=wallet.id, + amount="50.00", currency="EUR", + description=f"Coffee {uuid4().hex[:6]}", + expense_account=standard_accounts["expense_food"]["name"], + ) + income = await post_income( + client, + wallet_inkey=wallet.inkey, + amount="120.00", currency="EUR", + description=f"Cash deposit {uuid4().hex[:6]}", + revenue_account=standard_accounts["revenue_rent"]["name"], + ) + await _approve_and_refresh(client, wallet, super_user_headers, expense["id"]) + await _approve_and_refresh(client, wallet, super_user_headers, income["id"]) + + balance = await get_balance(client, wallet_inkey=wallet.inkey) + eur = balance.get("fiat_balances", {}).get("EUR") + assert float(eur) == pytest.approx(70.0), ( + f"expected +70 EUR (120 - 50, user-owes-libra), got {eur} from {balance}" + ) + + +@pytest.mark.anyio +async def test_mixed_expense_and_receivable_nets_correctly( + client, super_user_headers, configured_user, standard_accounts, +): + """Admin-recorded receivable + user-submitted expense should net the + same way as expense + income — both push the receivable side.""" + user, wallet = configured_user + + await post_receivable( + client, + super_user_headers=super_user_headers, + user_id=user.id, + amount="80.00", currency="EUR", + description=f"Admin debt {uuid4().hex[:6]}", + revenue_account=standard_accounts["revenue_rent"]["name"], + ) + expense = await post_expense( + client, + wallet_inkey=wallet.inkey, + user_wallet_id=wallet.id, + amount="30.00", currency="EUR", + description=f"User expense {uuid4().hex[:6]}", + expense_account=standard_accounts["expense_food"]["name"], + ) + await _approve_and_refresh(client, wallet, super_user_headers, expense["id"]) + + balance = await get_balance(client, wallet_inkey=wallet.inkey) + eur = balance.get("fiat_balances", {}).get("EUR") + assert float(eur) == pytest.approx(50.0), ( + f"expected +50 EUR (80 - 30), got {eur} from {balance}" + ) + + +# --------------------------------------------------------------------------- +# Lifetime totals (separate from net balance) +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_lifetime_totals_track_originals_not_net( + client, super_user_headers, configured_user, standard_accounts, +): + """`total_expenses_fiat` and `total_income_fiat` track originally-entered + amounts, not net obligation — see the `models.py:93` invariant. Even + after partial-direction submissions, the totals should equal the gross. + """ + _, wallet = configured_user + + expense = await post_expense( + client, + wallet_inkey=wallet.inkey, + user_wallet_id=wallet.id, + amount="45.00", currency="EUR", + description=f"e1 {uuid4().hex[:6]}", + expense_account=standard_accounts["expense_food"]["name"], + ) + income = await post_income( + client, + wallet_inkey=wallet.inkey, + amount="80.00", currency="EUR", + description=f"i1 {uuid4().hex[:6]}", + revenue_account=standard_accounts["revenue_rent"]["name"], + ) + await _approve_and_refresh(client, wallet, super_user_headers, expense["id"]) + await _approve_and_refresh(client, wallet, super_user_headers, income["id"]) + + balance = await get_balance(client, wallet_inkey=wallet.inkey) + exp_eur = balance.get("total_expenses_fiat", {}).get("EUR", 0) + inc_eur = balance.get("total_income_fiat", {}).get("EUR", 0) + assert float(exp_eur) == pytest.approx(45.0), ( + f"total_expenses_fiat should be gross 45, got {exp_eur}" + ) + assert float(inc_eur) == pytest.approx(80.0), ( + f"total_income_fiat should be gross 80, got {inc_eur}" + ) + + +# --------------------------------------------------------------------------- +# Exclusions — pending and voided +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_pending_entries_excluded_from_balance( + client, super_user_headers, configured_user, standard_accounts, +): + """Two expenses submitted, only one approved → only the approved one + moves the balance.""" + _, wallet = configured_user + + approved = await post_expense( + client, + wallet_inkey=wallet.inkey, + user_wallet_id=wallet.id, + amount="25.00", currency="EUR", + description=f"approved-only {uuid4().hex[:6]}", + expense_account=standard_accounts["expense_food"]["name"], + ) + # Submit a second expense but leave it pending. + await post_expense( + client, + wallet_inkey=wallet.inkey, + user_wallet_id=wallet.id, + amount="1000.00", currency="EUR", + description=f"pending-not-counted {uuid4().hex[:6]}", + expense_account=standard_accounts["expense_supplies"]["name"], + ) + await _approve_and_refresh(client, wallet, super_user_headers, approved["id"]) + + balance = await get_balance(client, wallet_inkey=wallet.inkey) + eur = balance.get("fiat_balances", {}).get("EUR") + assert float(eur) == pytest.approx(-25.0), ( + f"only approved expense should count; pending 1000 must be excluded. " + f"got {eur}" + ) + + +@pytest.mark.anyio +async def test_voided_entries_excluded_from_balance( + client, super_user_headers, configured_user, standard_accounts, +): + """A voided entry stops contributing to the balance the moment it's + rejected — verified by submitting then rejecting and confirming the + balance is what it would be without that entry.""" + _, wallet = configured_user + + keep = await post_expense( + client, + wallet_inkey=wallet.inkey, + user_wallet_id=wallet.id, + amount="35.00", currency="EUR", + description=f"keep {uuid4().hex[:6]}", + expense_account=standard_accounts["expense_food"]["name"], + ) + rejected = await post_expense( + client, + wallet_inkey=wallet.inkey, + user_wallet_id=wallet.id, + amount="500.00", currency="EUR", + description=f"will-be-voided {uuid4().hex[:6]}", + expense_account=standard_accounts["expense_food"]["name"], + ) + await _approve_and_refresh(client, wallet, super_user_headers, keep["id"]) + await reject_entry( + client, super_user_headers=super_user_headers, entry_id=rejected["id"], + ) + + balance = await get_balance(client, wallet_inkey=wallet.inkey) + eur = balance.get("fiat_balances", {}).get("EUR") + assert float(eur) == pytest.approx(-35.0), ( + f"voided 500 must not contribute; only the 35 EUR keeper. got {eur}" + ) + + +# --------------------------------------------------------------------------- +# Admin /balances/all +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_admin_balances_all_includes_users_with_obligations( + client, super_user_headers, configured_user, configured_user_b, + standard_accounts, +): + """`/balances/all` returns one row per user that has any Payable or + Receivable activity. Two users → two rows after both submit + approve. + """ + user_a, wallet_a = configured_user + user_b, wallet_b = configured_user_b + + a_entry = await post_expense( + client, + wallet_inkey=wallet_a.inkey, + user_wallet_id=wallet_a.id, + amount="60.00", currency="EUR", + description=f"A-bal {uuid4().hex[:6]}", + expense_account=standard_accounts["expense_food"]["name"], + ) + b_entry = await post_expense( + client, + wallet_inkey=wallet_b.inkey, + user_wallet_id=wallet_b.id, + amount="90.00", currency="EUR", + description=f"B-bal {uuid4().hex[:6]}", + expense_account=standard_accounts["expense_food"]["name"], + ) + await _approve_and_refresh(client, wallet_a, super_user_headers, a_entry["id"]) + await _approve_and_refresh(client, wallet_b, super_user_headers, b_entry["id"]) + + rows = await get_all_balances(client, super_user_headers=super_user_headers) + by_id = {r.get("user_id")[:8]: r for r in rows if r.get("user_id")} + assert user_a.id[:8] in by_id, f"user A missing from /balances/all" + assert user_b.id[:8] in by_id, f"user B missing from /balances/all" + + a_eur = by_id[user_a.id[:8]].get("fiat_balances", {}).get("EUR") + b_eur = by_id[user_b.id[:8]].get("fiat_balances", {}).get("EUR") + assert float(a_eur) == pytest.approx(-60.0), ( + f"user A EUR balance wrong in /balances/all: {a_eur}" + ) + assert float(b_eur) == pytest.approx(-90.0), ( + f"user B EUR balance wrong in /balances/all: {b_eur}" + ) + + +@pytest.mark.anyio +async def test_non_super_user_cannot_get_all_balances( + client, configured_user, +): + """`/balances/all` is admin-only — regular user wallet admin-key 403s.""" + _, wallet = configured_user + r = await client.get( + "/libra/api/v1/balances/all", + headers={"X-Api-Key": wallet.adminkey, "Content-type": "application/json"}, + ) + assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}" + assert "super" in r.text.lower() + + +# --------------------------------------------------------------------------- +# Credit balance — libra-#41 +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_credit_balance_subtracts_from_net( + client, configured_user, +): + """A user-credit balance on `Liabilities:Credit:User-X` flows into the + displayed net so the user-facing balance is always honest about what + libra owes them. + + `#41` will land the settlement-side overflow logic that writes credit + automatically. This test pre-creates the credit account and posts a + balanced credit-bearing transaction directly via Fava so we can lock + in the BQL-side behaviour (`get_user_balance_bql` includes the Credit + namespace alongside Payable + Receivable) ahead of the settlement + endpoint changes in #14. + """ + user, wallet = configured_user + + fava_client_mod = _libra_module("fava_client") + fava = fava_client_mod.get_fava_client() + + # Open the per-user credit account in Beancount. The settlement endpoint + # will do this via `get_or_create_user_account` when #14 lands. + credit_account = f"Liabilities:Credit:User-{user.id[:8]}" + await fava.add_account(credit_account, currencies=["EUR", "SATS"]) + + # Manually post a balanced entry mimicking what the future settlement + # overflow leg looks like in isolation: + # DR Assets:Cash +30 EUR (libra receives cash) + # CR Liabilities:Credit -30 EUR (libra owes user that 30 going forward) + tag = uuid4().hex[:6] + beancount_format = _libra_module("beancount_format") + entry = beancount_format.format_transaction( + date_val=date.today(), + flag="*", + narration=f"Credit-balance test {tag}", + postings=[ + {"account": "Assets:Cash", "amount": "30.00 EUR"}, + {"account": credit_account, "amount": "-30.00 EUR"}, + ], + tags=["credit-test"], + links=[f"credit-test-{tag}"], + meta={"user-id": user.id, "source": "test"}, + ) + await fava.add_entry(entry) + + # Force a fresh Fava read before the BQL balance query (libra-#37). + await list_user_entries(client, wallet_inkey=wallet.inkey) + + # The user's EUR balance should now read -30 (libra owes user 30 via + # credit). Without the BQL change, this would read 0 because the query + # would skip the Credit namespace entirely. + balance = await get_balance(client, wallet_inkey=wallet.inkey) + eur = balance.get("fiat_balances", {}).get("EUR") + assert eur is not None, f"missing EUR in fiat_balances: {balance}" + assert float(eur) == pytest.approx(-30.0), ( + f"expected -30 EUR (libra owes user via credit), got {eur} from {balance}" + ) + + # The accounts breakdown should surface the credit row so UIs can render + # it as a distinct line item per #41's display contract. `accounts` (the + # legacy field on UserBalance) stays empty for back-compat; the new + # `account_balances` field carries the BQL per-account breakdown. + account_balances = balance.get("account_balances", []) + credit_rows = [ + a for a in account_balances if "Credit" in (a.get("account") or "") + ] + assert credit_rows, ( + f"credit account missing from breakdown — UI can't render 'You have " + f"30 EUR credit' line item. account_balances: {account_balances}" + ) diff --git a/tests/test_entries_admin_api.py b/tests/test_entries_admin_api.py new file mode 100644 index 0000000..9cdc164 --- /dev/null +++ b/tests/test_entries_admin_api.py @@ -0,0 +1,219 @@ +"""Admin-side journal entry endpoints — receivable and revenue. + + - `POST /libra/api/v1/entries/receivable` — admin records that a user owes + libra. Lands as a pending (`!`) entry, balance untouched until approve. + - `POST /libra/api/v1/entries/revenue` — admin records that libra received + a payment unrelated to any user. Lands as a cleared (`*`) entry, no + approval needed. + +Auth gate covered too: a regular user's wallet admin-key passes +`require_admin_key` but fails the super-user identity check in libra's own +`require_super_user`, so the endpoint returns 403. +""" +from uuid import uuid4 + +import pytest + +from .helpers import ( + get_balance, + list_user_entries, + post_receivable, + post_revenue, +) + + +@pytest.mark.anyio +async def test_admin_records_receivable_lands_cleared( + client, super_user_headers, configured_user, standard_accounts, +): + """Admin posts a receivable for a user — the Beancount entry is written + with the cleared `*` flag immediately (not pending). The user's balance + reflects the debt without an approve step. + + Note: `JournalEntry.flag` in the API response is misleading — it's a + leftover of the legacy model and reports PENDING, but the entry in + Beancount is written as `*`. The on-disk reality is what affects the + balance, so that's what we assert. + """ + user, wallet = configured_user + + response = await post_receivable( + client, + super_user_headers=super_user_headers, + user_id=user.id, + amount="200.00", + currency="EUR", + description=f"December rent share {uuid4().hex[:6]}", + revenue_account=standard_accounts["revenue_rent"]["name"], + ) + assert response.get("id"), f"expected id in response, got {response}" + + # Force a fresh Fava read before checking balance — Fava lazily reloads + # the .beancount file and a balance call right after add_entry can hit + # a stale view. + await list_user_entries(client, wallet_inkey=wallet.inkey) + + balance = await get_balance(client, wallet_inkey=wallet.inkey) + eur = balance.get("fiat_balances", {}).get("EUR") + assert eur is not None, f"expected EUR in fiat_balances, got {balance}" + assert float(eur) == pytest.approx(200.0), ( + f"expected +200 EUR (user-owes-libra) after receivable, got {eur}" + ) + + +@pytest.mark.anyio +async def test_receivable_visible_in_target_users_journal( + client, super_user_headers, configured_user, standard_accounts, +): + """The receivable shows up in the *debtor* user's journal listing + (not just in the admin view).""" + user, wallet = configured_user + tag = uuid4().hex[:6] + + await post_receivable( + client, + super_user_headers=super_user_headers, + user_id=user.id, + amount="75.00", + currency="EUR", + description=f"Workshop fee {tag}", + revenue_account=standard_accounts["revenue_fees"]["name"], + ) + + listing = await list_user_entries(client, wallet_inkey=wallet.inkey) + descriptions = [e.get("description") or "" for e in listing.get("entries", [])] + assert any(tag in d for d in descriptions), ( + f"receivable missing from debtor's journal: {descriptions}" + ) + + +@pytest.mark.anyio +async def test_admin_records_revenue_clears_immediately( + client, super_user_headers, standard_accounts, +): + """Revenue (libra received money, no user debt) is cleared on creation — + no admin approval step.""" + response = await post_revenue( + client, + super_user_headers=super_user_headers, + amount="500.00", + currency="EUR", + description=f"Workshop fees collected {uuid4().hex[:6]}", + revenue_account=standard_accounts["revenue_fees"]["name"], + payment_method_account="Assets:Cash", + ) + assert response.get("id"), f"expected id in response, got {response}" + # Cleared on creation — flag is `*`, no approve_entry call needed. + + +@pytest.mark.anyio +async def test_non_super_user_cannot_post_receivable( + client, configured_user, standard_accounts, +): + """A regular user's wallet admin key passes `require_admin_key` but + fails libra's super-user identity check. Returns 403.""" + user, wallet = configured_user + admin_key_headers = {"X-Api-Key": wallet.adminkey, "Content-type": "application/json"} + + r = await client.post( + "/libra/api/v1/entries/receivable", + headers=admin_key_headers, + json={ + "user_id": user.id, + "amount": "10.00", + "currency": "EUR", + "description": "Should be denied", + "revenue_account": standard_accounts["revenue_rent"]["name"], + }, + ) + assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}" + assert "super" in r.text.lower(), ( + f"expected super-user error message, got {r.text!r}" + ) + + +@pytest.mark.anyio +async def test_non_super_user_cannot_post_revenue( + client, configured_user, standard_accounts, +): + """Same super-user gate covers the revenue endpoint.""" + _, wallet = configured_user + admin_key_headers = {"X-Api-Key": wallet.adminkey, "Content-type": "application/json"} + + r = await client.post( + "/libra/api/v1/entries/revenue", + headers=admin_key_headers, + json={ + "amount": "10.00", + "currency": "EUR", + "description": "Should be denied", + "revenue_account": standard_accounts["revenue_fees"]["name"], + "payment_method_account": "Assets:Cash", + }, + ) + assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}" + assert "super" in r.text.lower() + + +@pytest.mark.anyio +async def test_receivable_unknown_revenue_account_returns_404( + client, super_user_headers, configured_user, +): + """An admin posting against a non-existent revenue account gets 404.""" + user, _ = configured_user + + r = await client.post( + "/libra/api/v1/entries/receivable", + headers=super_user_headers, + json={ + "user_id": user.id, + "amount": "10.00", + "currency": "EUR", + "description": "Bad account", + "revenue_account": f"Income:Test:DoesNotExist-{uuid4().hex[:6]}", + }, + ) + assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}" + assert "not found" in r.text.lower() + + +@pytest.mark.anyio +async def test_receivable_unknown_currency_returns_400( + client, super_user_headers, configured_user, standard_accounts, +): + """Currency validation hits before account lookups.""" + user, _ = configured_user + + r = await client.post( + "/libra/api/v1/entries/receivable", + headers=super_user_headers, + json={ + "user_id": user.id, + "amount": "10.00", + "currency": "XYZ", + "description": "Bogus currency", + "revenue_account": standard_accounts["revenue_rent"]["name"], + }, + ) + assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}" + assert "currency" in r.text.lower() or "xyz" in r.text.lower() + + +@pytest.mark.anyio +async def test_revenue_unknown_payment_account_returns_404( + client, super_user_headers, standard_accounts, +): + """Revenue endpoint validates BOTH accounts; the payment-method one too.""" + r = await client.post( + "/libra/api/v1/entries/revenue", + headers=super_user_headers, + json={ + "amount": "10.00", + "currency": "EUR", + "description": "Bad payment account", + "revenue_account": standard_accounts["revenue_fees"]["name"], + "payment_method_account": f"Assets:DoesNotExist-{uuid4().hex[:6]}", + }, + ) + assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}" + assert "not found" in r.text.lower() diff --git a/tests/test_entries_user_api.py b/tests/test_entries_user_api.py new file mode 100644 index 0000000..bdcaf4e --- /dev/null +++ b/tests/test_entries_user_api.py @@ -0,0 +1,211 @@ +"""User-side expense submission flow — `POST /libra/api/v1/entries/expense`. + +Covers: + - Submission lands as a pending entry, visible to the user, doesn't move + the cleared-only balance. + - Cross-user isolation — user B can't see user A's entries. + - Permission gating, currency validation, missing user-wallet setup. + - Multiple submissions accumulate in the user journal listing. + +Settlement, approval, and balance-after-approval are exercised in +`test_smoke.py` (one canonical path) and `test_balances_api.py` (the mixed +income+expense display scenario the user named). +""" +from uuid import uuid4 + +import pytest + +from .helpers import ( + create_account, + get_balance, + list_user_entries, + post_expense, +) + + +@pytest.mark.anyio +async def test_expense_creates_pending_entry_visible_in_user_journal( + client, configured_user, standard_accounts, +): + """Submitting an expense creates a pending (`!`) entry the user can see + immediately. The cleared-only balance query is unchanged because pending + entries are excluded.""" + _, wallet = configured_user + + response = await post_expense( + client, + wallet_inkey=wallet.inkey, + user_wallet_id=wallet.id, + amount="25.00", + currency="EUR", + description="Test groceries", + expense_account=standard_accounts["expense_food"]["name"], + ) + assert response.get("id"), f"expected id in response, got {response}" + + listing = await list_user_entries(client, wallet_inkey=wallet.inkey) + entries = listing.get("entries", []) + assert any( + "Test groceries" in (e.get("description") or "") for e in entries + ), f"submitted expense missing from /entries/user: {entries}" + + bal = await get_balance(client, wallet_inkey=wallet.inkey) + assert not bal.get("fiat_balances"), ( + f"pending entry should not affect cleared balance, got {bal}" + ) + + +@pytest.mark.anyio +async def test_user_cannot_see_other_users_entries( + client, configured_user, configured_user_b, standard_accounts, +): + """User A submits an expense; user B's `/entries/user` listing is + scoped to B and never references A's user-id account fragment.""" + user_a, wallet_a = configured_user + _, wallet_b = configured_user_b + + await post_expense( + client, + wallet_inkey=wallet_a.inkey, + user_wallet_id=wallet_a.id, + amount="40.00", + currency="EUR", + description=f"A-private-{uuid4().hex[:6]}", + expense_account=standard_accounts["expense_food"]["name"], + ) + + listing_b = await list_user_entries(client, wallet_inkey=wallet_b.inkey) + a_short = user_a.id[:8] + for entry in listing_b.get("entries", []): + for posting in entry.get("postings", []): + assert a_short not in posting.get("account", ""), ( + f"user B's listing leaked user A's account: {posting}" + ) + + +@pytest.mark.anyio +async def test_expense_without_permission_returns_403( + client, super_user_headers, configured_user, +): + """Submitting to an expense account the user has no `submit_expense` + permission on returns 403 with a permission-error detail.""" + _, wallet = configured_user + + # Fresh expense account that no permission was granted on. + new_account = await create_account( + client, + super_user_headers=super_user_headers, + name=f"Expenses:Test:Unguarded-{uuid4().hex[:6]}", + account_type="expense", + ) + + r = await client.post( + "/libra/api/v1/entries/expense", + headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"}, + json={ + "description": "Should be denied", + "amount": "10.00", + "currency": "EUR", + "expense_account": new_account["name"], + "user_wallet": wallet.id, + "is_equity": False, + }, + ) + assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}" + assert "permission" in r.text.lower(), ( + f"expected permission error message, got {r.text!r}" + ) + + +@pytest.mark.anyio +async def test_expense_with_unknown_currency_returns_400( + client, configured_user, standard_accounts, +): + """An unsupported currency is rejected with 400 before any Fava call.""" + _, wallet = configured_user + + r = await client.post( + "/libra/api/v1/entries/expense", + headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"}, + json={ + "description": "Unknown currency", + "amount": "10.00", + "currency": "XYZ", + "expense_account": standard_accounts["expense_food"]["name"], + "user_wallet": wallet.id, + "is_equity": False, + }, + ) + assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}" + assert "currency" in r.text.lower(), ( + f"expected currency error message, got {r.text!r}" + ) + + +@pytest.mark.anyio +async def test_expense_without_user_wallet_configured_returns_400( + client, libra_user, libra_wallet, standard_accounts, # noqa: ARG001 (libra_wallet ensures session-level setup) +): + """A user whose own libra wallet isn't configured can't submit expenses. + + `libra_user` (vs `configured_user`) skips the `PUT /user/wallet` step + on purpose so the precondition fires. + """ + _, wallet = libra_user + + r = await client.post( + "/libra/api/v1/entries/expense", + headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"}, + json={ + "description": "Missing user wallet setup", + "amount": "10.00", + "currency": "EUR", + "expense_account": standard_accounts["expense_food"]["name"], + "user_wallet": wallet.id, + "is_equity": False, + }, + ) + assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}" + assert "wallet" in r.text.lower(), ( + f"expected wallet-config error, got {r.text!r}" + ) + + +@pytest.mark.anyio +async def test_multiple_expenses_accumulate_in_user_journal( + client, configured_user, standard_accounts, +): + """Each submission shows up in `/entries/user`; the listing's `total` + grows by exactly the number of submissions.""" + _, wallet = configured_user + + initial = await list_user_entries(client, wallet_inkey=wallet.inkey) + initial_total = initial.get("total", 0) + + tag = uuid4().hex[:6] + descriptions = [f"Coffee-{tag}", f"Bread-{tag}", f"Vegetables-{tag}"] + for description in descriptions: + await post_expense( + client, + wallet_inkey=wallet.inkey, + user_wallet_id=wallet.id, + amount="7.50", + currency="EUR", + description=description, + expense_account=standard_accounts["expense_food"]["name"], + ) + + final = await list_user_entries(client, wallet_inkey=wallet.inkey) + final_total = final.get("total", 0) + assert final_total - initial_total == len(descriptions), ( + f"expected total to grow by {len(descriptions)}, " + f"went from {initial_total} to {final_total}" + ) + + # Libra appends " ( )" to entry descriptions, so check + # substring rather than exact match. + final_descs = [e.get("description") or "" for e in final.get("entries", [])] + for description in descriptions: + assert any(description in d for d in final_descs), ( + f"missing {description} from journal listing: {final_descs}" + ) diff --git a/tests/test_lightning_api.py b/tests/test_lightning_api.py new file mode 100644 index 0000000..03a2ee2 --- /dev/null +++ b/tests/test_lightning_api.py @@ -0,0 +1,205 @@ +"""Lightning payment flow — `POST /generate-payment-invoice` and +`POST /record-payment`. + + - User has a balance owed to libra → user generates an invoice on the libra + wallet → user pays it → `/record-payment` records the settlement entry. + +## Coverage status + +This file covers auth gates and error paths that don't require an active +Lightning backend. Tests that actually need invoice generation are skipped +because: + + - The default `VoidWallet` 500s on any invoice operation. + - Switching to `FakeWallet` (via `settings.lnbits_backend_wallet_class`) + DOES enable invoice generation, but the LifespanManager teardown then + hangs indefinitely under anyio's TestRunner — some Lightning-side + background task doesn't unwind cleanly. Investigation deferred; the + auth gates + 404/400 error paths are what we can lock in for now. + +The skipped tests carry full implementations so flipping them back on is +a one-line change once the teardown issue is resolved (or once we move to +a subprocess-based runner for the LN file). +""" +from uuid import uuid4 + +import pytest + +from .helpers import ( + list_user_entries, + post_receivable, +) + + +NEEDS_LIGHTNING_BACKEND = pytest.mark.skip( + reason="Tracked by libra/issues/40 — VoidWallet 500s, FakeWallet hangs the " + "LifespanManager teardown under anyio's TestRunner. Flip when resolved." +) + + +async def _setup_receivable_balance( + client, super_user_headers, configured_user, standard_accounts, + amount="100.00", +): + """Helper: create + (auto-cleared) receivable so the user has a balance + owed to libra. Returns the (user, wallet) pair.""" + user, wallet = configured_user + await post_receivable( + client, + super_user_headers=super_user_headers, + user_id=user.id, + amount=amount, currency="EUR", + description=f"Setup debt {uuid4().hex[:6]}", + revenue_account=standard_accounts["revenue_rent"]["name"], + ) + # Force a Fava reload before downstream BQL balance reads (see #37). + await list_user_entries(client, wallet_inkey=wallet.inkey) + return user, wallet + + +# --------------------------------------------------------------------------- +# /generate-payment-invoice +# --------------------------------------------------------------------------- + + +@NEEDS_LIGHTNING_BACKEND +@pytest.mark.anyio +async def test_user_can_generate_invoice_for_own_balance( + client, super_user_headers, configured_user, standard_accounts, +): + """User with a receivable generates an invoice on the libra wallet. + Response carries the bolt11 string and the libra wallet's inkey for + the client to poll payment status.""" + _, wallet = await _setup_receivable_balance( + client, super_user_headers, configured_user, standard_accounts, + ) + + r = await client.post( + "/libra/api/v1/generate-payment-invoice", + headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"}, + json={"amount": 50_000}, # 50k sats partial settlement + ) + assert r.status_code == 200, f"generate-invoice: {r.status_code} {r.text}" + payload = r.json() + assert payload.get("payment_hash"), f"missing payment_hash: {payload}" + assert payload.get("payment_request"), f"missing bolt11 payment_request: {payload}" + assert payload.get("amount") == 50_000 + assert payload.get("check_wallet_key"), f"missing check_wallet_key: {payload}" + + +@NEEDS_LIGHTNING_BACKEND +@pytest.mark.anyio +async def test_super_user_can_generate_invoice_for_another_user( + client, super_user_headers, libra_wallet, configured_user, standard_accounts, +): + """Admin generating an invoice on behalf of a user — uses the libra + wallet's admin key + body `user_id`. The endpoint actually requires + `wallet.wallet.user == super_user` (which is the libra wallet owner). + + Generate-invoice is `require_invoice_key`-gated so we pass the libra + wallet's invoice key, and the user_id field opts into "for that user". + """ + user, _ = await _setup_receivable_balance( + client, super_user_headers, configured_user, standard_accounts, + ) + + r = await client.post( + "/libra/api/v1/generate-payment-invoice", + headers={"X-Api-Key": libra_wallet.inkey, "Content-type": "application/json"}, + json={"amount": 30_000, "user_id": user.id}, + ) + assert r.status_code == 200, f"admin generate-invoice: {r.status_code} {r.text}" + assert r.json().get("payment_request"), "admin-generated invoice missing bolt11" + + +@pytest.mark.anyio +async def test_non_super_user_cannot_generate_invoice_for_another_user( + client, super_user_headers, configured_user, configured_user_b, + standard_accounts, +): + """A regular user cannot pass `user_id` and have libra generate an + invoice on someone else's behalf — 403.""" + user_a, _ = await _setup_receivable_balance( + client, super_user_headers, configured_user, standard_accounts, + ) + _, wallet_b = configured_user_b + + r = await client.post( + "/libra/api/v1/generate-payment-invoice", + headers={"X-Api-Key": wallet_b.inkey, "Content-type": "application/json"}, + json={"amount": 10_000, "user_id": user_a.id}, + ) + assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}" + + +@pytest.mark.anyio +async def test_generate_invoice_without_auth_returns_401(client): + """Invoice-key auth required — no header → 401.""" + r = await client.post( + "/libra/api/v1/generate-payment-invoice", + json={"amount": 10_000}, + ) + assert r.status_code == 401, f"expected 401, got {r.status_code}: {r.text}" + + +# --------------------------------------------------------------------------- +# /record-payment +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_record_payment_unknown_hash_returns_404( + client, configured_user, +): + """Recording a payment hash that doesn't correspond to a real payment + in LNbits returns 404.""" + _, wallet = configured_user + r = await client.post( + "/libra/api/v1/record-payment", + headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"}, + json={"payment_hash": "0" * 64}, + ) + assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}" + assert "payment not found" in r.text.lower() or "payment" in r.text.lower() + + +@NEEDS_LIGHTNING_BACKEND +@pytest.mark.anyio +async def test_record_payment_pending_invoice_returns_400( + client, super_user_headers, configured_user, standard_accounts, +): + """A freshly-generated invoice that hasn't been paid yet is pending — + `/record-payment` must reject it with 400 rather than silently + recording a non-existent settlement.""" + _, wallet = await _setup_receivable_balance( + client, super_user_headers, configured_user, standard_accounts, + ) + + # Generate an invoice on the libra wallet. + gen = await client.post( + "/libra/api/v1/generate-payment-invoice", + headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"}, + json={"amount": 15_000}, + ) + assert gen.status_code == 200 + payment_hash = gen.json()["payment_hash"] + + # Try to record it before any payment lands. + r = await client.post( + "/libra/api/v1/record-payment", + headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"}, + json={"payment_hash": payment_hash}, + ) + assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}" + assert "not yet settled" in r.text.lower() or "pending" in r.text.lower(), ( + f"expected pending/settled message, got {r.text!r}" + ) + + +@pytest.mark.anyio +async def test_record_payment_without_auth_returns_401(client): + r = await client.post( + "/libra/api/v1/record-payment", + json={"payment_hash": "abc"}, + ) + assert r.status_code == 401, f"expected 401, got {r.status_code}: {r.text}" diff --git a/tests/test_manual_payment_requests_api.py b/tests/test_manual_payment_requests_api.py new file mode 100644 index 0000000..6ddcc1b --- /dev/null +++ b/tests/test_manual_payment_requests_api.py @@ -0,0 +1,307 @@ +"""Manual payment request flow — user asks for libra to pay them via a +non-Lightning route (cash, bank, etc.); admin approves or rejects. + +Endpoints: + - `POST /libra/api/v1/manual-payment-request` (invoice key, user) + - `GET /libra/api/v1/manual-payment-requests` (invoice key, own only) + - `GET /libra/api/v1/manual-payment-requests/all` (super user, all) + - `POST /libra/api/v1/manual-payment-requests/{id}/approve` (super user) + - `POST /libra/api/v1/manual-payment-requests/{id}/reject` (super user) + +The amount in the request body is in **satoshis** (no fiat conversion at this +endpoint — `CreateManualPaymentRequest` has `amount: int`). + +Approve creates a Beancount payment entry: + DR Liabilities:Payable:User-{id} (zeroes libra's debt to the user) + CR Assets:Bitcoin:Lightning (cash leaves libra) +""" +from uuid import uuid4 + +import pytest + +from .helpers import ( + approve_manual_payment_request, + reject_manual_payment_request, + submit_manual_payment_request, +) + + +# --------------------------------------------------------------------------- +# User-side submission +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_user_can_submit_manual_payment_request( + client, configured_user, +): + """Submission returns 200 with a pending request and the user's id.""" + user, wallet = configured_user + desc = f"Coffee reimbursement {uuid4().hex[:6]}" + + result = await submit_manual_payment_request( + client, + wallet_inkey=wallet.inkey, + amount_sats=50_000, + description=desc, + ) + assert result.get("id"), f"missing id: {result}" + assert result.get("user_id") == user.id + assert result.get("amount") == 50_000 + assert result.get("description") == desc + assert result.get("status") == "pending" + + +@pytest.mark.anyio +async def test_user_lists_own_manual_payment_requests( + client, configured_user, +): + """The user-side listing returns the requests this user submitted.""" + _, wallet = configured_user + + tag = uuid4().hex[:6] + submitted = await submit_manual_payment_request( + client, + wallet_inkey=wallet.inkey, + amount_sats=12_000, + description=f"list-test {tag}", + ) + + r = await client.get( + "/libra/api/v1/manual-payment-requests", + headers={"X-Api-Key": wallet.inkey}, + ) + assert r.status_code == 200, f"list: {r.status_code} {r.text}" + ids = [req.get("id") for req in r.json()] + assert submitted["id"] in ids, f"submitted request missing from listing: {ids}" + + +@pytest.mark.anyio +async def test_user_cannot_see_another_users_manual_payment_requests( + client, configured_user, configured_user_b, +): + """User-side listing is scoped to the calling user, not all requests.""" + user_a, wallet_a = configured_user + _, wallet_b = configured_user_b + + submitted_a = await submit_manual_payment_request( + client, + wallet_inkey=wallet_a.inkey, + amount_sats=8_000, + description=f"A-private {uuid4().hex[:6]}", + ) + + r = await client.get( + "/libra/api/v1/manual-payment-requests", + headers={"X-Api-Key": wallet_b.inkey}, + ) + assert r.status_code == 200 + user_ids = {req.get("user_id") for req in r.json()} + ids = [req.get("id") for req in r.json()] + assert submitted_a["id"] not in ids, ( + f"user B saw user A's request: {submitted_a['id']} in {ids}" + ) + assert user_a.id not in user_ids, ( + f"user B's listing contained user A's id: {user_ids}" + ) + + +# --------------------------------------------------------------------------- +# Admin listing +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_admin_can_list_all_manual_payment_requests( + client, super_user_headers, configured_user, configured_user_b, +): + """The admin listing returns requests from any user.""" + _, wallet_a = configured_user + _, wallet_b = configured_user_b + + a_req = await submit_manual_payment_request( + client, + wallet_inkey=wallet_a.inkey, + amount_sats=10_000, + description=f"A {uuid4().hex[:6]}", + ) + b_req = await submit_manual_payment_request( + client, + wallet_inkey=wallet_b.inkey, + amount_sats=20_000, + description=f"B {uuid4().hex[:6]}", + ) + + r = await client.get( + "/libra/api/v1/manual-payment-requests/all", + headers=super_user_headers, + ) + assert r.status_code == 200, f"admin list: {r.status_code} {r.text}" + ids = [req.get("id") for req in r.json()] + assert a_req["id"] in ids and b_req["id"] in ids, ( + f"admin list missing entries: ids={ids}" + ) + + +@pytest.mark.anyio +async def test_admin_listing_status_filter( + client, super_user_headers, configured_user, +): + """`?status=pending` returns only the pending requests.""" + _, wallet = configured_user + submitted = await submit_manual_payment_request( + client, + wallet_inkey=wallet.inkey, + amount_sats=5_000, + description=f"pending-filter {uuid4().hex[:6]}", + ) + + r = await client.get( + "/libra/api/v1/manual-payment-requests/all?status=pending", + headers=super_user_headers, + ) + assert r.status_code == 200, f"filtered list: {r.status_code} {r.text}" + statuses = {req.get("status") for req in r.json()} + assert statuses == {"pending"}, f"non-pending rows in filtered list: {statuses}" + assert submitted["id"] in [req.get("id") for req in r.json()] + + +@pytest.mark.anyio +async def test_non_super_user_cannot_list_all_requests( + client, configured_user, +): + """Wallet admin-key of a non-super user fails the super-user check.""" + _, wallet = configured_user + + r = await client.get( + "/libra/api/v1/manual-payment-requests/all", + headers={"X-Api-Key": wallet.adminkey, "Content-type": "application/json"}, + ) + assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}" + assert "super" in r.text.lower() + + +# --------------------------------------------------------------------------- +# Approve / reject +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_admin_can_reject_manual_payment_request( + client, super_user_headers, configured_user, +): + """Reject flips status to 'rejected' and doesn't touch Beancount.""" + _, wallet = configured_user + submitted = await submit_manual_payment_request( + client, + wallet_inkey=wallet.inkey, + amount_sats=3_500, + description=f"reject me {uuid4().hex[:6]}", + ) + + result = await reject_manual_payment_request( + client, super_user_headers=super_user_headers, request_id=submitted["id"], + ) + assert result.get("status") == "rejected" + + +@pytest.mark.anyio +async def test_rejecting_already_rejected_returns_400( + client, super_user_headers, configured_user, +): + """The endpoint guards against double-decisions.""" + _, wallet = configured_user + submitted = await submit_manual_payment_request( + client, + wallet_inkey=wallet.inkey, + amount_sats=4_000, + description=f"double reject {uuid4().hex[:6]}", + ) + await reject_manual_payment_request( + client, super_user_headers=super_user_headers, request_id=submitted["id"], + ) + + r = await client.post( + f"/libra/api/v1/manual-payment-requests/{submitted['id']}/reject", + headers=super_user_headers, + ) + assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}" + assert "reject" in r.text.lower() + + +@pytest.mark.anyio +async def test_approve_unknown_request_returns_404( + client, super_user_headers, +): + r = await client.post( + f"/libra/api/v1/manual-payment-requests/{uuid4().hex[:16]}/approve", + headers=super_user_headers, + ) + assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}" + + +@pytest.mark.anyio +async def test_non_super_user_cannot_approve( + client, configured_user, +): + _, wallet = configured_user + submitted = await submit_manual_payment_request( + client, + wallet_inkey=wallet.inkey, + amount_sats=2_000, + description=f"no approve for you {uuid4().hex[:6]}", + ) + + r = await client.post( + f"/libra/api/v1/manual-payment-requests/{submitted['id']}/approve", + headers={"X-Api-Key": wallet.adminkey, "Content-type": "application/json"}, + ) + assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}" + + +@pytest.mark.anyio +async def test_admin_can_approve_manual_payment_request( + client, super_user_headers, configured_user, standard_accounts, + # noqa: ARG001 (standard_accounts ensures Assets:Bitcoin:Lightning exists) +): + """Approve creates a Beancount payment entry and flips status to + 'approved'. Requires `Assets:Bitcoin:Lightning` to exist in libra's + local DB (provided by the `standard_accounts` fixture).""" + _, wallet = configured_user + submitted = await submit_manual_payment_request( + client, + wallet_inkey=wallet.inkey, + amount_sats=6_000, + description=f"approve me {uuid4().hex[:6]}", + ) + + result = await approve_manual_payment_request( + client, super_user_headers=super_user_headers, request_id=submitted["id"], + ) + assert result.get("status") == "approved" + assert result.get("id") == submitted["id"] + + +@pytest.mark.anyio +async def test_approving_already_approved_returns_400( + client, super_user_headers, configured_user, standard_accounts, +): + """Idempotency guard: second approve on the same request is rejected + explicitly rather than producing a duplicate Beancount entry.""" + _, wallet = configured_user + submitted = await submit_manual_payment_request( + client, + wallet_inkey=wallet.inkey, + amount_sats=7_500, + description=f"approve once {uuid4().hex[:6]}", + ) + await approve_manual_payment_request( + client, super_user_headers=super_user_headers, request_id=submitted["id"], + ) + + r = await client.post( + f"/libra/api/v1/manual-payment-requests/{submitted['id']}/approve", + headers=super_user_headers, + ) + assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}" + assert "approve" in r.text.lower() diff --git a/tests/test_reconciliation_api.py b/tests/test_reconciliation_api.py new file mode 100644 index 0000000..66757be --- /dev/null +++ b/tests/test_reconciliation_api.py @@ -0,0 +1,294 @@ +"""Balance assertion CRUD + reconciliation summary endpoints. + +Endpoints: + - `POST /libra/api/v1/assertions` — create + check + - `GET /libra/api/v1/assertions` — list with filters + - `GET /libra/api/v1/assertions/{id}` — fetch one + - `POST /libra/api/v1/assertions/{id}/check` — re-check + - `DELETE /libra/api/v1/assertions/{id}` — remove + +All `require_super_user` (libra-level, wallet admin-key). + +The create endpoint is hybrid: it posts a Beancount `balance` directive via +Fava (source of truth), persists the assertion metadata in libra's DB, and +re-checks immediately. On mismatch it returns 409 with the diff payload. +""" +from uuid import uuid4 + +import pytest + + +# Tests that try to actually create + check an assertion all hit issue #39: +# `format_balance` returns a Beancount source string but `fava.add_entry` +# expects a dict, so Fava 500s on every assertion-create call. The contract +# violation is on libra's side; mark these strict-xfail so they go green +# automatically once #39 lands and the format_balance return shape is fixed. +ASSERTION_CREATE_BROKEN = pytest.mark.xfail( + reason="libra/issues/39 — POST /assertions submits a Beancount source string " + "to Fava's JSON API and 500s. Drop this marker when the format_balance " + "return type is changed to a dict.", + strict=True, +) + + +# --------------------------------------------------------------------------- +# helpers (local — assertion endpoints don't have wrapper helpers yet) +# --------------------------------------------------------------------------- + + +async def _create_assertion( + client, *, super_user_headers, account_id, expected_sats, + tolerance_sats=0, fiat_currency=None, expected_fiat=None, +): + body = { + "account_id": account_id, + "expected_balance_sats": expected_sats, + "tolerance_sats": tolerance_sats, + } + if fiat_currency: + body["fiat_currency"] = fiat_currency + body["expected_balance_fiat"] = str(expected_fiat) if expected_fiat is not None else "0" + return await client.post( + "/libra/api/v1/assertions", headers=super_user_headers, json=body, + ) + + +# --------------------------------------------------------------------------- +# tests +# --------------------------------------------------------------------------- + + +@ASSERTION_CREATE_BROKEN +@pytest.mark.anyio +async def test_assertion_against_empty_account_passes( + client, super_user_headers, standard_accounts, +): + """An asset account with no postings has a 0 balance — asserting 0 + should pass and the resulting assertion has status='passed'.""" + r = await _create_assertion( + client, + super_user_headers=super_user_headers, + account_id=standard_accounts["assets_cash"]["id"], + expected_sats=0, + ) + assert r.status_code == 200, f"expected 200, got {r.status_code}: {r.text}" + body = r.json() + assert body.get("status") == "passed", ( + f"expected status='passed' for 0=0, got {body.get('status')} body={body}" + ) + assert body.get("difference_sats", 0) == 0 + + +@ASSERTION_CREATE_BROKEN +@pytest.mark.anyio +async def test_assertion_with_wrong_balance_returns_409( + client, super_user_headers, standard_accounts, +): + """When the actual balance doesn't match expected, the create endpoint + returns 409 Conflict with the diff payload — Beancount validates it + server-side after the directive lands.""" + r = await _create_assertion( + client, + super_user_headers=super_user_headers, + account_id=standard_accounts["assets_cash"]["id"], + expected_sats=999_999, # wildly wrong for empty account + ) + assert r.status_code == 409, f"expected 409, got {r.status_code}: {r.text}" + # 409 body should expose the diff so a UI can render the gap. + detail = r.json().get("detail") + assert isinstance(detail, dict), f"expected structured detail, got {detail!r}" + assert detail.get("expected_sats") == 999_999 + assert detail.get("actual_sats") == 0 + assert detail.get("difference_sats") == 999_999 or detail.get("difference_sats") == -999_999 + + +@ASSERTION_CREATE_BROKEN +@pytest.mark.anyio +async def test_assertion_with_tolerance_accepts_small_diff( + client, super_user_headers, standard_accounts, +): + """A tolerance of N sats lets actual-vs-expected diverge by ≤N.""" + r = await _create_assertion( + client, + super_user_headers=super_user_headers, + account_id=standard_accounts["assets_cash"]["id"], + expected_sats=50, + tolerance_sats=100, # actual=0, expected=50, diff=50, tolerance=100 → passes + ) + assert r.status_code == 200, f"expected 200 within tolerance, got {r.status_code}: {r.text}" + assert r.json().get("status") == "passed" + + +@ASSERTION_CREATE_BROKEN +@pytest.mark.anyio +async def test_list_assertions_returns_created( + client, super_user_headers, standard_accounts, +): + """Newly created assertions show up in the list filtered by account.""" + account_id = standard_accounts["assets_cash"]["id"] + + create = await _create_assertion( + client, + super_user_headers=super_user_headers, + account_id=account_id, + expected_sats=0, + ) + assert create.status_code == 200 + assertion_id = create.json()["id"] + + r = await client.get( + f"/libra/api/v1/assertions?account_id={account_id}", + headers=super_user_headers, + ) + assert r.status_code == 200, f"list assertions: {r.status_code} {r.text}" + ids = [a.get("id") for a in r.json()] + assert assertion_id in ids, f"created assertion {assertion_id} missing from list {ids}" + + +@ASSERTION_CREATE_BROKEN +@pytest.mark.anyio +async def test_get_assertion_by_id( + client, super_user_headers, standard_accounts, +): + create = await _create_assertion( + client, + super_user_headers=super_user_headers, + account_id=standard_accounts["assets_cash"]["id"], + expected_sats=0, + ) + assert create.status_code == 200 + assertion_id = create.json()["id"] + + r = await client.get( + f"/libra/api/v1/assertions/{assertion_id}", + headers=super_user_headers, + ) + assert r.status_code == 200, f"get assertion: {r.status_code} {r.text}" + assert r.json().get("id") == assertion_id + + +@ASSERTION_CREATE_BROKEN +@pytest.mark.anyio +async def test_recheck_assertion_via_check_endpoint( + client, super_user_headers, standard_accounts, +): + """`POST /assertions/{id}/check` re-evaluates and returns the updated + assertion record. Idempotent against a stable ledger state.""" + create = await _create_assertion( + client, + super_user_headers=super_user_headers, + account_id=standard_accounts["assets_cash"]["id"], + expected_sats=0, + ) + assertion_id = create.json()["id"] + + r = await client.post( + f"/libra/api/v1/assertions/{assertion_id}/check", + headers=super_user_headers, + ) + assert r.status_code == 200, f"recheck: {r.status_code} {r.text}" + assert r.json().get("status") == "passed" + + +@ASSERTION_CREATE_BROKEN +@pytest.mark.anyio +async def test_delete_assertion_removes_it( + client, super_user_headers, standard_accounts, +): + create = await _create_assertion( + client, + super_user_headers=super_user_headers, + account_id=standard_accounts["assets_cash"]["id"], + expected_sats=0, + ) + assertion_id = create.json()["id"] + + r = await client.delete( + f"/libra/api/v1/assertions/{assertion_id}", + headers=super_user_headers, + ) + assert r.status_code in (200, 204), f"delete: {r.status_code} {r.text}" + + # Subsequent GET should 404. + r = await client.get( + f"/libra/api/v1/assertions/{assertion_id}", + headers=super_user_headers, + ) + assert r.status_code == 404, f"expected 404 after delete, got {r.status_code}" + + +@pytest.mark.anyio +async def test_assertion_unknown_account_returns_404( + client, super_user_headers, +): + """Account-not-found check happens before any Beancount write.""" + r = await _create_assertion( + client, + super_user_headers=super_user_headers, + account_id=f"nonexistent-{uuid4().hex[:6]}", + expected_sats=0, + ) + assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}" + + +@pytest.mark.anyio +async def test_non_super_user_cannot_create_assertion( + client, configured_user, standard_accounts, +): + """Wallet admin-key of a regular user fails the super-user identity + check — 403.""" + _, wallet = configured_user + r = await client.post( + "/libra/api/v1/assertions", + headers={"X-Api-Key": wallet.adminkey, "Content-type": "application/json"}, + json={ + "account_id": standard_accounts["assets_cash"]["id"], + "expected_balance_sats": 0, + }, + ) + assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}" + assert "super" in r.text.lower() + + +@pytest.mark.anyio +async def test_list_assertions_invalid_status_returns_400( + client, super_user_headers, +): + """Status filter is validated against the AssertionStatus enum.""" + r = await client.get( + "/libra/api/v1/assertions?status=not_a_status", + headers=super_user_headers, + ) + assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}" + assert "status" in r.text.lower() + + +@pytest.mark.anyio +async def test_reconciliation_summary_endpoint(client, super_user_headers): + """`GET /reconciliation/summary` responds 200 and returns a structured + payload even when no assertions exist. Smoke-shape only — exact counts + depend on ledger history. + + Doesn't pre-create an assertion (#39 blocks that path); the summary + endpoint should still serve a default empty shape. + """ + r = await client.get( + "/libra/api/v1/reconciliation/summary", + headers=super_user_headers, + ) + assert r.status_code == 200, f"reconciliation summary: {r.status_code} {r.text}" + payload = r.json() + assert isinstance(payload, dict), f"expected dict, got {type(payload)}" + + +@pytest.mark.anyio +async def test_daily_reconciliation_task_runs( + client, super_user_headers, +): + """The daily-reconciliation task endpoint returns 200 even when no + assertions exist — it's the entry point that ops cron hits.""" + r = await client.post( + "/libra/api/v1/tasks/daily-reconciliation", + headers=super_user_headers, + ) + assert r.status_code == 200, f"daily-reconciliation: {r.status_code} {r.text}" diff --git a/tests/test_settings_auth_api.py b/tests/test_settings_auth_api.py new file mode 100644 index 0000000..b46b156 --- /dev/null +++ b/tests/test_settings_auth_api.py @@ -0,0 +1,202 @@ +"""Settings and per-user wallet endpoints, plus the auth gates around them. + +Endpoints and their auth profiles: + + - `GET /libra/api/v1/settings` — any authenticated user. + - `PUT /libra/api/v1/settings` — `check_super_user` (Bearer, super-user only). + - `GET /libra/api/v1/user/wallet` — `check_user_exists` (any authed user). + - `PUT /libra/api/v1/user/wallet` — `check_user_exists`. + - `GET /libra/api/v1/user-wallet/{user_id}` — `require_super_user` (libra + super-user via wallet admin-key auth). + +Two distinct super-user auth flows live here side by side: + - LNbits-level `check_super_user` → Bearer token from username/password login. + - Libra-level `require_super_user` → wallet admin-key of the super-user-owned + wallet. + +Tests use the `super_user_bearer_headers` fixture for the first, the +`super_user_headers` fixture for the second, and `?usr=` for +non-admin authed calls. +""" +from uuid import uuid4 + +import pytest + + +@pytest.mark.anyio +async def test_super_user_can_get_and_update_settings( + client, super_user_bearer_headers, libra_wallet, fava_process, +): + """Super user round-trips through `GET /settings` → mutate → `PUT /settings`. + + Verifies the Bearer-auth happy path and confirms `update_settings` + persists what we sent (modulo defaults libra fills in). + """ + r = await client.get( + "/libra/api/v1/settings", headers=super_user_bearer_headers, + ) + assert r.status_code == 200, f"GET /settings: {r.status_code} {r.text}" + original = r.json() + assert original.get("libra_wallet_id") == libra_wallet.id, ( + f"libra_wallet fixture should have configured wallet_id, got {original}" + ) + + new_timeout = 7.5 + r = await client.put( + "/libra/api/v1/settings", + headers=super_user_bearer_headers, + json={ + "libra_wallet_id": libra_wallet.id, + "fava_url": fava_process, + "fava_ledger_slug": "libra-test", + "fava_timeout": new_timeout, + }, + ) + assert r.status_code == 200, f"PUT /settings: {r.status_code} {r.text}" + assert float(r.json().get("fava_timeout", 0)) == pytest.approx(new_timeout) + + # Reset to keep other tests' baseline intact. + await client.put( + "/libra/api/v1/settings", + headers=super_user_bearer_headers, + json={ + "libra_wallet_id": libra_wallet.id, + "fava_url": fava_process, + "fava_ledger_slug": "libra-test", + "fava_timeout": original.get("fava_timeout", 5.0), + }, + ) + + +@pytest.mark.anyio +async def test_put_settings_without_libra_wallet_id_returns_400( + client, super_user_bearer_headers, +): + """The settings endpoint explicitly rejects updates with no wallet id. + + This is the validation libra applies before any persistence so we don't + silently accept a settings row that breaks all entry endpoints. + """ + r = await client.put( + "/libra/api/v1/settings", + headers=super_user_bearer_headers, + json={"fava_url": "http://example.test"}, + ) + assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}" + assert "wallet" in r.text.lower() + + +@pytest.mark.anyio +async def test_put_settings_without_auth_returns_401(client, libra_wallet): + """No auth at all → LNbits's `check_admin` rejects with 401.""" + r = await client.put( + "/libra/api/v1/settings", + json={"libra_wallet_id": libra_wallet.id}, + ) + assert r.status_code == 401, f"expected 401, got {r.status_code}: {r.text}" + + +@pytest.mark.anyio +async def test_regular_user_cannot_put_settings( + client, configured_user, libra_wallet, +): + """A non-super user (regardless of auth method they try) cannot update + libra settings. Using `?usr=` to mimic user-id login.""" + user, _ = configured_user + + r = await client.put( + f"/libra/api/v1/settings?usr={user.id}", + json={"libra_wallet_id": libra_wallet.id}, + ) + # `_check_account_exists` forbids user-id login for admin accounts and + # rejects regular users from `check_admin` paths — either 401 or 403 + # is a valid no-access response here. + assert r.status_code in (401, 403), ( + f"expected 401/403, got {r.status_code}: {r.text}" + ) + + +@pytest.mark.anyio +async def test_regular_user_can_get_and_update_own_user_wallet( + client, libra_user, libra_wallet, # noqa: ARG001 (libra_wallet ensures session setup) +): + """A regular user (no admin perm) can read and update their own + `user_wallet_id` via `?usr=`.""" + user, wallet = libra_user + + r = await client.get(f"/libra/api/v1/user/wallet?usr={user.id}") + assert r.status_code == 200, f"GET /user/wallet: {r.status_code} {r.text}" + + r = await client.put( + f"/libra/api/v1/user/wallet?usr={user.id}", + json={"user_wallet_id": wallet.id}, + ) + assert r.status_code == 200, f"PUT /user/wallet: {r.status_code} {r.text}" + + r = await client.get(f"/libra/api/v1/user/wallet?usr={user.id}") + assert r.json().get("user_wallet_id") == wallet.id, ( + f"GET after PUT should echo wallet id, got {r.json()}" + ) + + +@pytest.mark.anyio +async def test_super_user_can_get_any_user_wallet( + client, super_user_headers, configured_user, +): + """The `/user-wallet/{user_id}` endpoint (libra `require_super_user`, + wallet-admin-key auth) returns wallet info for any user.""" + user, wallet = configured_user + + r = await client.get( + f"/libra/api/v1/user-wallet/{user.id}", headers=super_user_headers, + ) + assert r.status_code == 200, f"GET /user-wallet/{user.id}: {r.status_code} {r.text}" + payload = r.json() + assert payload.get("user_id") == user.id + assert payload.get("user_wallet_id") == wallet.id, ( + f"expected user_wallet_id={wallet.id}, got {payload}" + ) + + +@pytest.mark.anyio +async def test_regular_user_cannot_use_super_only_user_wallet_endpoint( + client, configured_user, configured_user_b, +): + """User B can't see user A's wallet info via the super-only admin + endpoint, even with B's own wallet admin-key.""" + user_a, _ = configured_user + _, wallet_b = configured_user_b + + r = await client.get( + f"/libra/api/v1/user-wallet/{user_a.id}", + headers={"X-Api-Key": wallet_b.adminkey, "Content-type": "application/json"}, + ) + assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}" + assert "super" in r.text.lower() + + +@pytest.mark.anyio +async def test_unknown_currency_in_settings_does_not_corrupt( + client, super_user_bearer_headers, libra_wallet, fava_process, +): + """Passing an unexpected field in the settings body shouldn't bring the + endpoint down — pydantic should ignore extras and persist the rest. + + A canary for "what if the UI sends a slightly-stale settings shape?" + """ + r = await client.put( + "/libra/api/v1/settings", + headers=super_user_bearer_headers, + json={ + "libra_wallet_id": libra_wallet.id, + "fava_url": fava_process, + "fava_ledger_slug": "libra-test", + "some_unexpected_field_": str(uuid4()), + }, + ) + # Either 200 (extras dropped) or 422 (strict validation) — both are + # acceptable defensive behaviours; just don't 500. + assert r.status_code in (200, 422), ( + f"unexpected field should be ignored or rejected cleanly, " + f"got {r.status_code}: {r.text}" + ) diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..5ee4e2a --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,66 @@ +"""Smoke test: validates the test harness end-to-end. + +If this passes, the rest of the test files can be trusted to actually exercise +real code paths (Fava up, app up, Libra activated, FavaClient pointed at the +test instance, BQL round-trips working, libra wallet configured, user wallet +configured, account exists, permission granted). + +If this fails, no point running anything else — fix the harness first. +""" +import pytest + +from .helpers import approve_entry, get_balance, post_expense + + +@pytest.mark.anyio +async def test_smoke_submit_approve_and_see_balance( + client, super_user_headers, configured_user, standard_accounts, +): + """Full stack round-trip: user submits an expense, admin approves it, + balance reflects it. + + Exercises: libra wallet config (session fixture), user wallet config + (configured_user fixture), permission grant (configured_user fixture), + Beancount entry construction, Fava add_entries HTTP call, pending→cleared + flag transition via the source-slice mutation path, BQL balance query + (which filters by flag = '*' so the approve step is load-bearing). + """ + _, wallet = configured_user + + # User pays 50 EUR for groceries — entry posted with flag `!` (pending). + entry = await post_expense( + client, + wallet_inkey=wallet.inkey, + user_wallet_id=wallet.id, + amount="50.00", + currency="EUR", + description="Smoke test expense", + expense_account=standard_accounts["expense_food"]["name"], + ) + entry_id = entry.get("id") + assert entry_id, f"expense response missing id: {entry}" + + # Pending entries are excluded from the cleared-only balance query — + # confirm balance is still zero at this point. + pending_balance = await get_balance(client, wallet_inkey=wallet.inkey) + pending_eur = pending_balance.get("fiat_balances", {}).get("EUR") + assert pending_eur in (None, 0, "0", "0.00"), ( + f"pending expense should not affect cleared balance, got {pending_eur}" + ) + + # Admin approves the pending entry, flipping its flag from `!` to `*`. + await approve_entry( + client, super_user_headers=super_user_headers, entry_id=entry_id, + ) + + # Balance now reflects the 50 EUR Libra owes the user. + # Sign convention (per get_user_balance_bql docstring): the API returns + # the balance from libra's perspective — negative on Liabilities:Payable + # means libra owes the user. So a 50 EUR expense surfaces as -50 EUR. + balance = await get_balance(client, wallet_inkey=wallet.inkey) + fiat = balance.get("fiat_balances", {}) + eur = fiat.get("EUR") + assert eur is not None, f"expected EUR in fiat_balances after approve, got {balance}" + assert float(eur) == pytest.approx(-50.0), ( + f"expected EUR balance of -50.00 (libra-owes-user) after approve, got {eur}" + ) diff --git a/tests/test_unit.py b/tests/test_unit.py new file mode 100644 index 0000000..bb74a9c --- /dev/null +++ b/tests/test_unit.py @@ -0,0 +1,416 @@ +"""Pure-function unit tests — no harness, no Fava, no LNbits app. + +Covers `libra.beancount_format`, `libra.account_utils`, `libra.core.validation`. +These modules have no external dependencies (stdlib + pydantic for models), so +they run fast and don't need fixtures. + +The libra package is importable under either `lnbits.extensions.libra.*` +(default lnbits layout) or `libra.*` (LNBITS_EXTENSIONS_PATH override). The +`_module` helper tries both, mirroring the runtime-path discipline already +established in `conftest.py`. +""" +import importlib +from datetime import date +from decimal import Decimal + +import pytest + + +def _module(name: str): + """Import a libra submodule under whichever path the active LNbits layout + uses (default `lnbits.extensions.libra` or bare `libra`).""" + for prefix in ("lnbits.extensions.libra", "libra"): + try: + return importlib.import_module(f"{prefix}.{name}") + except ModuleNotFoundError: + continue + raise ModuleNotFoundError(f"libra.{name}: tried both import paths") + + +bf = _module("beancount_format") +au = _module("account_utils") +val = _module("core.validation") +mdl = _module("models") +AccountType = mdl.AccountType + + +# --------------------------------------------------------------------------- +# beancount_format.sanitize_link +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("libra-abc123", "libra-abc123"), + ("Invoice #123", "Invoice-123"), + ("Test (pending)", "Test-pending"), + ("a/b.c-d_e", "a/b.c-d_e"), # all permitted chars survive + ("multiple spaces", "multiple-spaces"), # collapsed + ("---leading-trailing---", "leading-trailing"), + ("ascii_only", "ascii_only"), + ], +) +def test_sanitize_link_strips_unsafe_chars(raw, expected): + assert bf.sanitize_link(raw) == expected + + +def test_sanitize_link_empty_string_stays_empty(): + assert bf.sanitize_link("") == "" + + +def test_sanitize_link_unicode_replaced_with_hyphens(): + # Non-ascii chars all collapse to single hyphens, stripped from edges. + result = bf.sanitize_link("café résumé") + assert all(ch in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_/." + for ch in result), f"unsanitized chars in {result!r}" + assert not result.startswith("-") + assert not result.endswith("-") + + +# --------------------------------------------------------------------------- +# beancount_format.format_transaction +# --------------------------------------------------------------------------- + + +def test_format_transaction_minimum_shape(): + entry = bf.format_transaction( + date_val=date(2026, 6, 6), + flag="*", + narration="hello", + postings=[{"account": "Assets:Cash", "amount": "10 EUR"}], + ) + # Fava's required fields. + assert entry["t"] == "Transaction" + assert entry["date"] == "2026-06-06" + assert entry["flag"] == "*" + assert entry["narration"] == "hello" + assert entry["payee"] == "" # empty string, not None + assert entry["tags"] == [] + assert entry["links"] == [] + assert entry["meta"] == {} + assert entry["postings"] == [{"account": "Assets:Cash", "amount": "10 EUR"}] + + +def test_format_transaction_optional_fields_are_passed_through(): + entry = bf.format_transaction( + date_val=date(2026, 6, 6), + flag="!", + narration="pending lunch", + postings=[{"account": "Expenses:Food", "amount": "8 EUR"}], + payee="Bistro Local", + tags=["expense-entry"], + links=["libra-abc123"], + meta={"user-id": "abc12345"}, + ) + assert entry["flag"] == "!" + assert entry["payee"] == "Bistro Local" + assert entry["tags"] == ["expense-entry"] + assert entry["links"] == ["libra-abc123"] + assert entry["meta"] == {"user-id": "abc12345"} + + +def test_format_transaction_does_not_share_mutable_defaults(): + """Regression guard: passing `tags=None` shouldn't return the same list + every call (the classic Python mutable-default-argument trap).""" + a = bf.format_transaction(date(2026, 1, 1), "*", "a", [{"account": "X", "amount": "1 EUR"}]) + b = bf.format_transaction(date(2026, 1, 2), "*", "b", [{"account": "Y", "amount": "1 EUR"}]) + a["tags"].append("touched-a") + assert b["tags"] == [], "tags from one entry leaked into another" + + +# --------------------------------------------------------------------------- +# beancount_format.generate_entry_id +# --------------------------------------------------------------------------- + + +def test_generate_entry_id_shape(): + eid = bf.generate_entry_id() + assert len(eid) == 16 + assert all(c in "0123456789abcdef" for c in eid), f"non-hex in {eid!r}" + + +def test_generate_entry_ids_are_unique(): + ids = {bf.generate_entry_id() for _ in range(100)} + assert len(ids) == 100 # 16 hex chars = 64 bits; collisions in 100 are negligible + + +# --------------------------------------------------------------------------- +# account_utils.format_hierarchical_account_name +# --------------------------------------------------------------------------- + + +def test_format_hierarchical_simple_asset(): + assert au.format_hierarchical_account_name(AccountType.ASSET, "Cash") == "Assets:Cash" + + +def test_format_hierarchical_user_specific_uses_8_char_prefix(): + full_user_id = "af983632aabbccddeeff00112233445566" + name = au.format_hierarchical_account_name( + AccountType.ASSET, "Accounts Receivable", user_id=full_user_id, + ) + assert name == "Assets:Receivable:User-af983632" # 8-char prefix, "Accounts " stripped + + +def test_format_hierarchical_ampersand_expands_to_colon(): + """`Food & Supplies` is a legacy display form; it becomes a hierarchy.""" + name = au.format_hierarchical_account_name(AccountType.EXPENSE, "Food & Supplies") + assert name == "Expenses:Food:Supplies" + + +def test_format_hierarchical_revenue_uses_income_root(): + """Beancount uses `Income`, not `Revenue` — the mapping is in + `ACCOUNT_TYPE_ROOTS`.""" + name = au.format_hierarchical_account_name(AccountType.REVENUE, "Accommodation") + assert name == "Income:Accommodation" + + +# --------------------------------------------------------------------------- +# account_utils.parse_legacy_account_name +# --------------------------------------------------------------------------- + + +def test_parse_legacy_with_user_suffix(): + assert au.parse_legacy_account_name("Accounts Receivable - af983632") == ( + "Accounts Receivable", "af983632", + ) + + +def test_parse_legacy_without_user_suffix(): + assert au.parse_legacy_account_name("Cash") == ("Cash", None) + + +# --------------------------------------------------------------------------- +# account_utils.format_account_display_name +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("hierarchical", "expected"), + [ + ("Assets:Receivable:User-af983632", "Accounts Receivable - af983632"), + ("Liabilities:Payable:User-cafebabe", "Accounts Payable - cafebabe"), + ("Expenses:Food:Supplies", "Food & Supplies"), + ("Assets:Cash", "Cash"), + ("Assets", "Assets"), # too short — passes through + ], +) +def test_format_account_display_name(hierarchical, expected): + assert au.format_account_display_name(hierarchical) == expected + + +# --------------------------------------------------------------------------- +# account_utils.get_account_type_from_hierarchical +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("name", "expected_type"), + [ + ("Assets:Cash", AccountType.ASSET), + ("Liabilities:Payable:User-x", AccountType.LIABILITY), + ("Equity:User-x", AccountType.EQUITY), + ("Income:Accommodation", AccountType.REVENUE), + ("Expenses:Food", AccountType.EXPENSE), + ], +) +def test_get_account_type_from_hierarchical(name, expected_type): + assert au.get_account_type_from_hierarchical(name) == expected_type + + +def test_get_account_type_unknown_root_returns_none(): + assert au.get_account_type_from_hierarchical("Other:Random") is None + + +# --------------------------------------------------------------------------- +# account_utils.migrate_account_name — round-trip legacy → hierarchical +# --------------------------------------------------------------------------- + + +def test_migrate_account_name_receivable(): + out = au.migrate_account_name("Accounts Receivable - af983632", AccountType.ASSET) + assert out == "Assets:Receivable:User-af983632" + + +def test_migrate_account_name_expense_with_ampersand(): + assert au.migrate_account_name("Food & Supplies", AccountType.EXPENSE) == ( + "Expenses:Food:Supplies" + ) + + +# --------------------------------------------------------------------------- +# 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 +# --------------------------------------------------------------------------- + + +def test_validate_balance_exact_match_passes(): + val.validate_balance("acct", expected_balance_sats=1000, actual_balance_sats=1000) + + +def test_validate_balance_within_tolerance_passes(): + val.validate_balance( + "acct", expected_balance_sats=1000, actual_balance_sats=1005, tolerance_sats=10, + ) + + +def test_validate_balance_outside_tolerance_raises(): + with pytest.raises(val.ValidationError) as exc: + val.validate_balance( + "acct", expected_balance_sats=1000, actual_balance_sats=1100, tolerance_sats=10, + ) + assert "Balance assertion failed" in str(exc.value) + + +def test_validate_balance_fiat_mismatch_raises(): + with pytest.raises(val.ValidationError) as exc: + val.validate_balance( + "acct", + expected_balance_sats=1000, + actual_balance_sats=1000, + expected_balance_fiat=Decimal("100.00"), + actual_balance_fiat=Decimal("99.50"), + tolerance_fiat=Decimal("0.10"), + fiat_currency="EUR", + ) + assert "Fiat balance" in str(exc.value) + + +# --------------------------------------------------------------------------- +# core.validation — entry-specific validators +# --------------------------------------------------------------------------- + + +def test_validate_receivable_entry_positive_revenue_passes(): + val.validate_receivable_entry("u", amount=100, revenue_account_type="revenue") + + +def test_validate_receivable_entry_zero_amount_raises(): + with pytest.raises(val.ValidationError): + val.validate_receivable_entry("u", amount=0, revenue_account_type="revenue") + + +def test_validate_receivable_entry_wrong_account_type_raises(): + with pytest.raises(val.ValidationError) as exc: + val.validate_receivable_entry("u", amount=100, revenue_account_type="expense") + assert "revenue account" in str(exc.value) + + +def test_validate_expense_entry_non_equity_requires_expense_account(): + with pytest.raises(val.ValidationError) as exc: + val.validate_expense_entry( + "u", amount=100, expense_account_type="asset", is_equity=False, + ) + assert "expense account" in str(exc.value) + + +def test_validate_expense_entry_equity_allows_non_expense_account(): + """Equity contributions bypass the expense-account requirement.""" + val.validate_expense_entry( + "u", amount=100, expense_account_type="equity", is_equity=True, + ) + + +def test_validate_payment_entry_negative_raises(): + with pytest.raises(val.ValidationError): + val.validate_payment_entry("u", amount=-1) + + +# --------------------------------------------------------------------------- +# core.validation — validate_metadata +# --------------------------------------------------------------------------- + + +def test_validate_metadata_required_keys_missing_raises(): + with pytest.raises(val.ValidationError) as exc: + val.validate_metadata({"foo": 1}, required_keys=["bar", "baz"]) + assert "bar" in str(exc.value) and "baz" in str(exc.value) + + +def test_validate_metadata_fiat_currency_without_amount_raises(): + with pytest.raises(val.ValidationError) as exc: + val.validate_metadata({"fiat_currency": "EUR"}) + assert "both be present or both absent" in str(exc.value) + + +def test_validate_metadata_fiat_amount_without_currency_raises(): + with pytest.raises(val.ValidationError): + val.validate_metadata({"fiat_amount": "10.00"}) + + +@pytest.mark.xfail( + reason="libra/issues/38 — except clause doesn't catch decimal.InvalidOperation, " + "so the raw exception leaks instead of becoming ValidationError. Flip when fixed.", + strict=True, +) +def test_validate_metadata_fiat_amount_invalid_decimal_raises(): + with pytest.raises(val.ValidationError) as exc: + val.validate_metadata({"fiat_amount": "not-a-number", "fiat_currency": "EUR"}) + assert "Invalid fiat_amount" in str(exc.value) + + +def test_validate_metadata_both_present_passes(): + val.validate_metadata({"fiat_amount": "100.50", "fiat_currency": "EUR"}) + + +def test_validate_metadata_neither_present_passes(): + val.validate_metadata({"source": "api"}) diff --git a/tests/test_void_reject_api.py b/tests/test_void_reject_api.py new file mode 100644 index 0000000..66e2180 --- /dev/null +++ b/tests/test_void_reject_api.py @@ -0,0 +1,212 @@ +"""Reject / void pending entry flow — `POST /libra/api/v1/entries/{id}/reject`. + +Captures the current (pre-issue #24) in-place mutation behaviour: + + - Pending entries (`!` flag) can be rejected by a super user. + - Rejection appends `#voided` to the transaction line in the .beancount file + (no new transaction posted — this is the only in-place edit path in libra). + - Voided entries are filtered out of balance queries. + - The reject endpoint only matches pending entries; cleared (`*`) ones return + 404 because the search loop filters by `flag == '!'`. + +PR #34 changes whether the user's `/entries/user` listing surfaces voided rows. +The test `test_voided_entry_excluded_from_user_journal` documents the current +("filtered") behaviour; flip it if/when that change lands. + +When the reversing-entry refactor in issue #24 ships, these tests will need to +move from "void via tag append" to "void via reversal transaction." The shape +of the tests should still hold — what changes is the on-disk evidence. +""" +from uuid import uuid4 + +import pytest + +from .helpers import ( + approve_entry, + get_balance, + list_user_entries, + post_expense, + reject_entry, +) + + +@pytest.mark.anyio +async def test_admin_can_reject_pending_expense( + client, super_user_headers, configured_user, standard_accounts, +): + """Happy path: user submits expense → admin rejects → response includes + the entry id, balance still zero.""" + _, wallet = configured_user + posted = await post_expense( + client, + wallet_inkey=wallet.inkey, + user_wallet_id=wallet.id, + amount="15.00", + currency="EUR", + description=f"Reject me {uuid4().hex[:6]}", + expense_account=standard_accounts["expense_food"]["name"], + ) + + result = await reject_entry( + client, super_user_headers=super_user_headers, entry_id=posted["id"], + ) + assert result.get("entry_id") == posted["id"] + + balance = await get_balance(client, wallet_inkey=wallet.inkey) + assert not balance.get("fiat_balances"), ( + f"voided entry should not surface in balance, got {balance}" + ) + + +@pytest.mark.anyio +async def test_voided_entry_visible_in_user_journal( + client, super_user_headers, configured_user, standard_accounts, +): + """Post-commit-1c89e69 behaviour: rejected entries remain visible in + the user's `/entries/user` listing so the user can see their own + rejected history rather than having it silently disappear. + + The UI is expected to render these with a 'voided' visual marker + (PR #34 webapp companion). The balance query still excludes them + via the separate `tags` filter — covered in + `test_admin_can_reject_pending_expense`. + """ + _, wallet = configured_user + tag = f"void-marker-{uuid4().hex[:6]}" + + posted = await post_expense( + client, + wallet_inkey=wallet.inkey, + user_wallet_id=wallet.id, + amount="20.00", + currency="EUR", + description=tag, + expense_account=standard_accounts["expense_food"]["name"], + ) + await reject_entry( + client, super_user_headers=super_user_headers, entry_id=posted["id"], + ) + + listing = await list_user_entries(client, wallet_inkey=wallet.inkey) + entries = listing.get("entries", []) + descriptions = [e.get("description") or "" for e in entries] + assert any(tag in d for d in descriptions), ( + f"voided entry should remain visible in user journal post-#34, " + f"got descriptions: {descriptions}" + ) + + voided = next( + (e for e in entries if tag in (e.get("description") or "")), None, + ) + assert voided is not None + assert "voided" in voided.get("tags", []), ( + f"voided entry should be tagged 'voided' for UI styling, " + f"got tags: {voided.get('tags')}" + ) + + +@pytest.mark.anyio +async def test_reject_unknown_entry_returns_404( + client, super_user_headers, +): + """An entry id that doesn't exist anywhere in the ledger 404s.""" + bogus_id = uuid4().hex[:16] + r = await client.post( + f"/libra/api/v1/entries/{bogus_id}/reject", + headers=super_user_headers, + ) + assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}" + assert "not found" in r.text.lower() + + +@pytest.mark.anyio +async def test_reject_already_cleared_entry_returns_404( + client, super_user_headers, configured_user, standard_accounts, +): + """The reject lookup filters by `flag == '!'` so already-approved + (cleared) entries are indistinguishable from non-existent ones — + both 404.""" + _, wallet = configured_user + posted = await post_expense( + client, + wallet_inkey=wallet.inkey, + user_wallet_id=wallet.id, + amount="11.00", + currency="EUR", + description=f"Approve-then-reject {uuid4().hex[:6]}", + expense_account=standard_accounts["expense_food"]["name"], + ) + await approve_entry( + client, super_user_headers=super_user_headers, entry_id=posted["id"], + ) + + r = await client.post( + f"/libra/api/v1/entries/{posted['id']}/reject", + headers=super_user_headers, + ) + assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}" + + +@pytest.mark.anyio +async def test_non_super_user_cannot_reject( + client, configured_user, standard_accounts, +): + """Reject endpoint uses libra's `require_super_user` — wallet + admin-key of a non-super user is forbidden.""" + _, wallet = configured_user + posted = await post_expense( + client, + wallet_inkey=wallet.inkey, + user_wallet_id=wallet.id, + amount="13.00", + currency="EUR", + description=f"Forbidden reject {uuid4().hex[:6]}", + expense_account=standard_accounts["expense_food"]["name"], + ) + + r = await client.post( + f"/libra/api/v1/entries/{posted['id']}/reject", + headers={"X-Api-Key": wallet.adminkey, "Content-type": "application/json"}, + ) + assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}" + assert "super" in r.text.lower() + + +@pytest.mark.anyio +async def test_double_reject_returns_404_on_second_call( + client, super_user_headers, configured_user, standard_accounts, +): + """After a successful reject the entry is no longer matched by the + lookup (it's still flag `!` but its journal-listing-filter behaviour + is "voided"). A second reject 404s rather than mutating again. + + Documents the de-facto idempotency story: it's "first wins, repeat + fails cleanly" rather than "repeat is a no-op success." If the + reversing-entry refactor (#24) reshapes this, the test will reveal it. + """ + _, wallet = configured_user + posted = await post_expense( + client, + wallet_inkey=wallet.inkey, + user_wallet_id=wallet.id, + amount="9.00", + currency="EUR", + description=f"Double reject {uuid4().hex[:6]}", + expense_account=standard_accounts["expense_food"]["name"], + ) + + await reject_entry( + client, super_user_headers=super_user_headers, entry_id=posted["id"], + ) + + r = await client.post( + f"/libra/api/v1/entries/{posted['id']}/reject", + headers=super_user_headers, + ) + # First reject succeeded; second reject either 404 (entry still flag ! + # but matched-by-tag elsewhere) or 200 with idempotent no-op. Lock in + # whichever the current code does so a future change to the reject + # path forces a deliberate decision. + assert r.status_code in (200, 404), ( + f"second reject should be deterministic, got {r.status_code}: {r.text}" + ) From 50658440a4824649d5694794d2adad3399dfce7c Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 7 Jun 2026 11:56:35 +0200 Subject: [PATCH 25/40] Surface user credit balance in GET /balance per libra-#41 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends get_user_balance_bql and get_all_user_balances_bql to fold Liabilities:Credit:User-X into the same query as Payable and Receivable. Credit is the overpay-absorbing liability that libra owes the user going forward — it carries the same sign as Payable, so the existing fiat aggregation subtracts it from net obligation without further changes. Adds UserBalance.account_balances to surface the BQL per-account breakdown so libra extension UI and webapp can render Payable / Receivable / Credit as distinct line items. The legacy `accounts` field stays empty for back-compat with anything reading the older shape. Prepares for libra-#33 / libra-#41: settlement netting (#14 task) will write the overflow leg to credit; this changeset makes sure that, the moment credit exists, the displayed net everywhere already reflects it. Co-Authored-By: Claude Opus 4.7 (1M context) --- fava_client.py | 12 +++++++++--- models.py | 5 +++++ views_api.py | 3 ++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/fava_client.py b/fava_client.py index 8dcf31c..c61e1d9 100644 --- a/fava_client.py +++ b/fava_client.py @@ -820,10 +820,15 @@ class FavaClient: # GROUP BY currency prevents mixing EUR and SATS face values in sum(number). # sum(weight) gives SATS for both EUR @@ SATS entries and plain SATS entries. # sum(number) on EUR rows gives the fiat amount; on SATS rows gives sats paid. + # Credit is the overpay-absorbing liability per libra-#41 — it lives + # on the same per-user namespace as Payable and contributes to the + # user's net obligation with the same sign as Payable (negative on + # Liabilities means libra owes user). Folding it into the same query + # means the displayed net always already accounts for credit. query = f""" SELECT account, currency, sum(number), sum(weight) WHERE account ~ ':User-{user_id_prefix}' - AND (account ~ 'Payable' OR account ~ 'Receivable') + AND (account ~ 'Payable' OR account ~ 'Receivable' OR account ~ 'Credit') AND flag = '*' GROUP BY account, currency """ @@ -970,10 +975,11 @@ class FavaClient: """ from decimal import Decimal - # GROUP BY currency prevents mixing EUR and SATS face values in sum(number) + # GROUP BY currency prevents mixing EUR and SATS face values in sum(number). + # Credit per libra-#41 — see get_user_balance_bql for the rationale. query = """ SELECT account, currency, sum(number), sum(weight) - WHERE (account ~ 'Payable:User-' OR account ~ 'Receivable:User-') + WHERE (account ~ 'Payable:User-' OR account ~ 'Receivable:User-' OR account ~ 'Credit:User-') AND flag = '*' GROUP BY account, currency """ diff --git a/models.py b/models.py index 70abca4..8be64c9 100644 --- a/models.py +++ b/models.py @@ -96,6 +96,11 @@ class UserBalance(BaseModel): user_id: str balance: int # positive = libra owes user, negative = user owes libra accounts: list[Account] = [] + # Per-account breakdown surfaced from get_user_balance_bql so UIs (libra + # extension dashboard + webapp) can render Payable / Receivable / Credit + # as distinct line items. Each entry: {"account": str, "sats": int, + # "eur": Decimal}. Wired up for libra-#41's display contract. + account_balances: list[dict] = [] fiat_balances: dict[str, Decimal] = {} # e.g. {"EUR": Decimal("250.0"), "USD": Decimal("100.0")} # Lifetime totals (original entries only; not net of reconciliation) total_expenses_sats: int = 0 diff --git a/views_api.py b/views_api.py index 2f41cec..032c15d 100644 --- a/views_api.py +++ b/views_api.py @@ -1609,7 +1609,8 @@ async def api_get_my_balance( return UserBalance( user_id=wallet.wallet.user, balance=balance_data["balance"], - accounts=[], # Could populate from balance_data["accounts"] if needed + accounts=[], + account_balances=balance_data.get("accounts", []), fiat_balances=balance_data["fiat_balances"], total_expenses_sats=totals["total_expenses_sats"], total_expenses_fiat=totals["total_expenses_fiat"], From 116df46d389306421a3113f1b991fbde5a9f409d Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 7 Jun 2026 14:51:43 +0200 Subject: [PATCH 26/40] Net settlement + credit overflow on /receivables/settle (libra-#33, libra-#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the caller omits settled_entry_links (the default), the endpoint auto-detects open entries across both directions for the user and writes a single transaction that: - Zeros every per-user account that has an open balance, not just the net (the libra-#33 bug — previously the 2-leg form left both Payable and Receivable carrying non-zero balances after a complete cash settlement, while only netting the cash side). - Routes any cash above the net obligation to Liabilities:Credit:User-X (libra-#41), so over-payment lands on a real liability account instead of silently drifting. - Attaches every reconciled source entry's link (exp-..., rcv-...) so a reader scanning the settlement transaction can trace what it cleared. Cash less than the net obligation, with no explicit links, returns 400 with a structured diff (cash_paid, net_obligation, receivable_total, payable_total). The operator either pays the exact net or passes settled_entry_links to settle a specific subset; partial settlement without a coherent target is not silently absorbed. The legacy explicit-links code path is unchanged — callers that pass settled_entry_links keep the 2-leg shape with no auto-detection. None of the callers in libra or aiolabs/webapp currently use that field, but the contract is preserved for the partial-settle-of-specific-entries flow. format_fiat_net_settlement_entry is the new helper for the 2/3/4-leg shape; it enforces the cash-balance constraint inline so callers can't accidentally produce an unbalanced transaction. tests/test_settlement_api.py (6 tests) locks in: - Nancy's #33 scenario: receivable 100 + payable 50 + cash 50 zeros both per-user accounts, links both source entries - Overpay: cash 70 against net 50 → credit balance 20 - Pure receivable overpay → credit appears - Underpay without explicit links → 400 with diff - No open receivables → 400 with hint pointing at /payables/pay - Explicit settled_entry_links uses legacy 2-leg path Co-Authored-By: Claude Opus 4.7 (1M context) --- beancount_format.py | 133 ++++++++++++++ tests/test_settlement_api.py | 342 +++++++++++++++++++++++++++++++++++ views_api.py | 109 ++++++++++- 3 files changed, 580 insertions(+), 4 deletions(-) create mode 100644 tests/test_settlement_api.py diff --git a/beancount_format.py b/beancount_format.py index a1bf874..486ad57 100644 --- a/beancount_format.py +++ b/beancount_format.py @@ -804,6 +804,139 @@ def format_net_settlement_entry( ) +def format_fiat_net_settlement_entry( + user_id: str, + cash_account: str, + receivable_account: str, + payable_account: Optional[str], + credit_account: Optional[str], + cash_paid_fiat: Decimal, + total_receivable_fiat: Decimal, + total_payable_fiat: Decimal, + credit_overflow_fiat: Decimal, + fiat_currency: str, + description: str, + entry_date: date, + payment_method: str = "cash", + reference: Optional[str] = None, + settled_entry_links: Optional[List[str]] = None, +) -> Dict[str, Any]: + """Fiat cash settlement that nets receivable and payable for one user. + + Implements the contract from libra-#33 (settlement netting) and + libra-#41 (credit-balance overflow). Builds a 2- to 4-leg transaction + depending on what the user has open: + + - Cash + Receivable only (2-leg) — pure receivable, exact pay + - Cash + Receivable + Credit (3-leg) — overpay against pure receivable + - Cash + Receivable + Payable (3-leg) — nancy's #33 scenario, exact pay + - Cash + Receivable + Payable + Credit (4-leg) — net + overpay + + The receivable leg is always present (this endpoint is `/receivables/settle`). + The payable leg appears when the user has open expenses being netted against + the receivable. The credit leg appears when cash > settle target, absorbing + the overflow as a liability libra owes the user going forward. + + Constraint enforced inline: + cash_paid_fiat = total_receivable_fiat - total_payable_fiat + credit_overflow_fiat + + Args: + user_id: User ID + cash_account: Payment-method account name (e.g. "Assets:Cash") + receivable_account: User's receivable account being cleared + payable_account: User's payable account being cleared (omit when no payable) + credit_account: User's credit account receiving overflow (omit when no overflow) + cash_paid_fiat: What the user paid in cash, unsigned + total_receivable_fiat: Gross receivable being cleared (unsigned, 0 if none) + total_payable_fiat: Gross payable being cleared (unsigned, 0 if none) + credit_overflow_fiat: Excess cash going to credit (unsigned, 0 if none) + fiat_currency: Currency code (EUR, USD, etc.) + description: Entry narration + entry_date: Date of settlement + payment_method: cash / bank_transfer / check / other + reference: Optional caller-supplied reference (becomes an extra link) + settled_entry_links: Source entry links being cleared + (e.g. `["exp-abc", "rcv-def"]`). The audit trail for which + originals this settlement reconciles. + + Returns: + Fava API entry dict ready for `fava.add_entry`. + + Raises: + ValueError: if any amount is negative, or if the cash-balance + constraint above is not satisfied. + """ + for label, value in ( + ("cash_paid_fiat", cash_paid_fiat), + ("total_receivable_fiat", total_receivable_fiat), + ("total_payable_fiat", total_payable_fiat), + ("credit_overflow_fiat", credit_overflow_fiat), + ): + if value < 0: + raise ValueError(f"{label} must be non-negative; got {value}") + + expected_cash = total_receivable_fiat - total_payable_fiat + credit_overflow_fiat + if abs(cash_paid_fiat - expected_cash) > Decimal("0.01"): + raise ValueError( + f"cash_paid_fiat {cash_paid_fiat} does not match expected " + f"{expected_cash} (= receivable {total_receivable_fiat} " + f"- payable {total_payable_fiat} + credit {credit_overflow_fiat})" + ) + + if total_payable_fiat > 0 and not payable_account: + raise ValueError("payable_account required when total_payable_fiat > 0") + if credit_overflow_fiat > 0 and not credit_account: + raise ValueError("credit_account required when credit_overflow_fiat > 0") + + postings: List[Dict[str, Any]] = [ + {"account": cash_account, "amount": f"{cash_paid_fiat:.2f} {fiat_currency}"}, + {"account": receivable_account, "amount": f"-{total_receivable_fiat:.2f} {fiat_currency}"}, + ] + if total_payable_fiat > 0: + postings.append({ + "account": payable_account, + "amount": f"{total_payable_fiat:.2f} {fiat_currency}", + }) + if credit_overflow_fiat > 0: + postings.append({ + "account": credit_account, + "amount": f"-{credit_overflow_fiat:.2f} {fiat_currency}", + }) + + payment_method_map = { + "cash": ("cash_settlement", "cash-payment"), + "bank_transfer": ("bank_settlement", "bank-transfer"), + "check": ("check_settlement", "check-payment"), + "btc_onchain": ("onchain_settlement", "onchain-payment"), + "other": ("manual_settlement", "manual-payment"), + } + source, tag = payment_method_map.get( + payment_method.lower(), ("manual_settlement", "manual-payment"), + ) + + entry_meta: Dict[str, Any] = { + "user-id": user_id, + "source": source, + "payment-type": "net-settlement", + } + + links: List[str] = [] + if settled_entry_links: + links.extend(settled_entry_links) + if reference: + links.append(sanitize_link(reference)) + + return format_transaction( + date_val=entry_date, + flag="*", + narration=description, + postings=postings, + tags=[tag, "settlement", "net-settlement"], + links=links, + meta=entry_meta, + ) + + def format_revenue_entry( payment_account: str, revenue_account: str, diff --git a/tests/test_settlement_api.py b/tests/test_settlement_api.py new file mode 100644 index 0000000..442a01e --- /dev/null +++ b/tests/test_settlement_api.py @@ -0,0 +1,342 @@ +"""Settlement netting + credit overflow — libra-#33 + libra-#41. + +`POST /libra/api/v1/receivables/settle` with `settled_entry_links=None` +(the default) auto-detects open entries in both directions, builds a +3-leg settlement transaction that zeros out both per-user accounts when +the user has open balances on both sides (libra-#33's nancy scenario), +and routes any excess cash to `Liabilities:Credit:User-X` (libra-#41). + +Underpay without explicit entry-picks returns 400 with diff details so +the operator can either pay the exact net or specify `settled_entry_links`. +""" +import importlib +from uuid import uuid4 + +import pytest + +from .helpers import ( + approve_entry, + get_balance, + list_user_entries, + post_expense, + post_receivable, + settle_receivable, +) + + +def _libra_module(submodule: str): + for prefix in ("lnbits.extensions.libra", "libra"): + try: + return importlib.import_module(f"{prefix}.{submodule}") + except ModuleNotFoundError: + continue + raise ModuleNotFoundError(f"libra.{submodule}") + + +async def _approve_and_refresh(client, wallet, super_user_headers, entry_id): + """Approve a pending entry and force a Fava reload (libra-#37 workaround).""" + await approve_entry(client, super_user_headers=super_user_headers, entry_id=entry_id) + await list_user_entries(client, wallet_inkey=wallet.inkey) + + +# --------------------------------------------------------------------------- +# Nancy's #33 scenario and variants +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_exact_net_settlement_zeroes_both_per_user_accounts( + client, super_user_headers, configured_user, standard_accounts, +): + """Nancy: receivable 100 EUR + payable 50 EUR + 50 EUR cash → 3-leg + settlement that zeros both Receivable and Payable for this user. + + Acceptance criteria from libra-#33: + - Settlement links every source entry it reconciles. + - Per-user balances drop to 0 (not just net to 0 leaving each side open). + """ + user, wallet = configured_user + tag = uuid4().hex[:6] + + # Admin records the receivable (cleared on creation). + await post_receivable( + client, + super_user_headers=super_user_headers, + user_id=user.id, + amount="100.00", currency="EUR", + description=f"Rent share {tag}", + revenue_account=standard_accounts["revenue_rent"]["name"], + ) + # User submits an expense (pending until admin approves). + exp = await post_expense( + client, + wallet_inkey=wallet.inkey, + user_wallet_id=wallet.id, + amount="50.00", currency="EUR", + description=f"Drill purchase {tag}", + expense_account=standard_accounts["expense_food"]["name"], + ) + await _approve_and_refresh(client, wallet, super_user_headers, exp["id"]) + + # Sanity check: user owes 50 EUR net (100 receivable - 50 payable). + balance_before = await get_balance(client, wallet_inkey=wallet.inkey) + eur_before = balance_before.get("fiat_balances", {}).get("EUR") + assert float(eur_before) == pytest.approx(50.0), ( + f"expected +50 EUR net (user owes libra), got {eur_before}" + ) + + # Settle the net cash: 50 EUR. + await settle_receivable( + client, + super_user_headers=super_user_headers, + user_id=user.id, + amount="50.00", currency="EUR", + description=f"Cash settlement {tag}", + ) + await list_user_entries(client, wallet_inkey=wallet.inkey) + + # After settlement: net balance is 0. + balance_after = await get_balance(client, wallet_inkey=wallet.inkey) + eur_after = balance_after.get("fiat_balances", {}).get("EUR", 0) + assert float(eur_after or 0) == pytest.approx(0.0), ( + f"expected 0 EUR after exact net settlement, got {eur_after}" + ) + + # Per-account breakdown: every user-side account is at 0. + # (The acceptance criterion is that NEITHER Receivable nor Payable + # carries an open balance — not just that they net to 0.) + breakdown = balance_after.get("account_balances", []) + for row in breakdown: + if user.id[:8] in (row.get("account") or ""): + assert float(row.get("eur", 0) or 0) == pytest.approx(0.0), ( + f"per-user account {row['account']} still has " + f"{row.get('eur')} EUR open after complete settlement; " + f"libra-#33 acceptance criterion violated" + ) + + # The settlement entry's links must cover both source entries. + # Both rcv-* and exp-* links should appear via Fava query. + fava_client_mod = _libra_module("fava_client") + fava = fava_client_mod.get_fava_client() + unsettled_receivables = await fava.get_unsettled_entries_bql(user.id, "receivable") + unsettled_payables = await fava.get_unsettled_entries_bql(user.id, "expense") + assert not unsettled_receivables, ( + f"receivable left as unsettled after complete settlement: " + f"{unsettled_receivables}" + ) + assert not unsettled_payables, ( + f"payable left as unsettled after complete settlement: " + f"{unsettled_payables}" + ) + + +@pytest.mark.anyio +async def test_overpay_routes_excess_to_credit( + client, super_user_headers, configured_user, standard_accounts, +): + """Receivable 100 + payable 50 + cash 70 EUR → settles both per-user + accounts to 0, and the 20 EUR excess lands on Liabilities:Credit:User-X + (libra now owes the user 20 going forward). + + Headline libra-#41 case: cash > net obligation absorbed into credit. + """ + user, wallet = configured_user + tag = uuid4().hex[:6] + + await post_receivable( + client, + super_user_headers=super_user_headers, + user_id=user.id, + amount="100.00", currency="EUR", + description=f"Receivable {tag}", + revenue_account=standard_accounts["revenue_rent"]["name"], + ) + exp = await post_expense( + client, + wallet_inkey=wallet.inkey, + user_wallet_id=wallet.id, + amount="50.00", currency="EUR", + description=f"Payable {tag}", + expense_account=standard_accounts["expense_food"]["name"], + ) + await _approve_and_refresh(client, wallet, super_user_headers, exp["id"]) + + # User pays 70 EUR — 20 EUR over the 50 EUR net obligation. + await settle_receivable( + client, + super_user_headers=super_user_headers, + user_id=user.id, + amount="70.00", currency="EUR", + description=f"Overpay settlement {tag}", + ) + await list_user_entries(client, wallet_inkey=wallet.inkey) + + # Net balance should be -20 EUR (libra owes user 20 via credit). + balance = await get_balance(client, wallet_inkey=wallet.inkey) + eur = balance.get("fiat_balances", {}).get("EUR") + assert float(eur) == pytest.approx(-20.0), ( + f"expected -20 EUR (libra owes user via credit), got {eur} from {balance}" + ) + + # Credit account should appear in the breakdown with -20 EUR. + breakdown = balance.get("account_balances", []) + credit_row = next( + (r for r in breakdown if "Credit" in (r.get("account") or "")), None, + ) + assert credit_row is not None, ( + f"Credit account missing from breakdown: {breakdown}" + ) + assert float(credit_row.get("eur", 0)) == pytest.approx(-20.0), ( + f"expected -20 EUR on Credit:User-X, got {credit_row.get('eur')}" + ) + + +@pytest.mark.anyio +async def test_pure_receivable_overpay_creates_credit( + client, super_user_headers, configured_user, standard_accounts, +): + """No payable side — receivable 50 + cash 70 → receivable cleared, + 20 EUR moves to credit. 2-leg + credit overflow leg.""" + user, wallet = configured_user + tag = uuid4().hex[:6] + + await post_receivable( + client, + super_user_headers=super_user_headers, + user_id=user.id, + amount="50.00", currency="EUR", + description=f"Pure receivable {tag}", + revenue_account=standard_accounts["revenue_rent"]["name"], + ) + await list_user_entries(client, wallet_inkey=wallet.inkey) + + await settle_receivable( + client, + super_user_headers=super_user_headers, + user_id=user.id, + amount="70.00", currency="EUR", + description=f"Pure overpay {tag}", + ) + await list_user_entries(client, wallet_inkey=wallet.inkey) + + balance = await get_balance(client, wallet_inkey=wallet.inkey) + eur = balance.get("fiat_balances", {}).get("EUR") + # Receivable cleared (0) - credit (-20) = -20 net + assert float(eur) == pytest.approx(-20.0), ( + f"expected -20 EUR after pure overpay, got {eur}" + ) + + +# --------------------------------------------------------------------------- +# Validation: underpay without explicit links → 400 with diff +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_underpay_without_explicit_links_returns_400( + client, super_user_headers, configured_user, standard_accounts, +): + """Cash < net obligation and no `settled_entry_links` → 400 with the + diff payload so operator can fix the amount or specify entries. + + Without #41's credit overflow + #33's auto-detect, this was the + silent-drift case that motivated both issues. Now: explicit, recoverable. + """ + user, wallet = configured_user + + await post_receivable( + client, + super_user_headers=super_user_headers, + user_id=user.id, + amount="100.00", currency="EUR", + description="Receivable to underpay against", + revenue_account=standard_accounts["revenue_rent"]["name"], + ) + await list_user_entries(client, wallet_inkey=wallet.inkey) + + r = await client.post( + "/libra/api/v1/receivables/settle", + headers=super_user_headers, + json={ + "user_id": user.id, + "amount": "30.00", + "currency": "EUR", + "payment_method": "cash", + "description": "Underpay attempt", + }, + ) + assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}" + payload = r.json().get("detail") + assert isinstance(payload, dict), f"expected structured detail, got {payload!r}" + assert payload.get("cash_paid") == 30.0 + assert payload.get("net_obligation") == 100.0 + assert payload.get("receivable_total") == 100.0 + assert payload.get("payable_total") == 0.0 + + +@pytest.mark.anyio +async def test_no_open_receivable_returns_400( + client, super_user_headers, configured_user, +): + """User has no open receivables → endpoint can't settle. 400 with a + hint pointing at `/payables/pay` for the inverse direction.""" + user, _ = configured_user + + r = await client.post( + "/libra/api/v1/receivables/settle", + headers=super_user_headers, + json={ + "user_id": user.id, + "amount": "50.00", + "currency": "EUR", + "payment_method": "cash", + "description": "Random deposit attempt", + }, + ) + assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}" + assert "no open receivables" in r.text.lower() or "payables/pay" in r.text + + +# --------------------------------------------------------------------------- +# Legacy explicit-links path: preserved for partial-settle-of-specific-entries +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_explicit_settled_entry_links_uses_legacy_2_leg_path( + client, super_user_headers, configured_user, standard_accounts, +): + """When `settled_entry_links` is provided, backend trusts the caller's + list and writes the legacy 2-leg shape. No auto-netting, no credit + overflow validation. Required for callers that want to settle a + specific subset of entries. + + Requires `amount_sats` per the legacy path's existing contract. + """ + user, wallet = configured_user + + await post_receivable( + client, + super_user_headers=super_user_headers, + user_id=user.id, + amount="50.00", currency="EUR", + description="Receivable for explicit-link test", + revenue_account=standard_accounts["revenue_rent"]["name"], + ) + await list_user_entries(client, wallet_inkey=wallet.inkey) + + # Caller passes explicit (but possibly empty) link list → legacy path. + r = await client.post( + "/libra/api/v1/receivables/settle", + headers=super_user_headers, + json={ + "user_id": user.id, + "amount": "50.00", + "currency": "EUR", + "amount_sats": 55_000, + "payment_method": "cash", + "description": "Explicit-link settle", + "settled_entry_links": [], # opts out of auto-detect + }, + ) + assert r.status_code == 200, f"legacy explicit-link path: {r.status_code} {r.text}" diff --git a/views_api.py b/views_api.py index 032c15d..fb46809 100644 --- a/views_api.py +++ b/views_api.py @@ -1992,7 +1992,11 @@ async def api_settle_receivable( # DR Cash/Bank (asset increased), CR Accounts Receivable (asset decreased) # This records that user paid their debt from .fava_client import get_fava_client - from .beancount_format import format_payment_entry, format_fiat_settlement_entry + from .beancount_format import ( + format_payment_entry, + format_fiat_settlement_entry, + format_fiat_net_settlement_entry, + ) from decimal import Decimal fava = get_fava_client() @@ -2002,9 +2006,106 @@ async def api_settle_receivable( "cash", "bank_transfer", "check", "other" ] - if is_fiat_payment: - # Fiat currency payment (cash, bank transfer, etc.) - # Record in fiat currency with sats as metadata + if is_fiat_payment and data.settled_entry_links is None: + # Auto-detect netting + credit-overflow path (libra-#33 + libra-#41). + # The operator hasn't picked specific entries — backend nets all + # open balances in both directions, validates cash matches the net + # obligation (or absorbs excess into credit), and writes a single + # transaction that links every reconciled source entry. + + unsettled_payables = await fava.get_unsettled_entries_bql(data.user_id, "expense") + unsettled_receivables = await fava.get_unsettled_entries_bql(data.user_id, "receivable") + + payable_total = sum( + (Decimal(str(e["fiat_amount"])) for e in unsettled_payables), + Decimal(0), + ) + receivable_total = sum( + (Decimal(str(e["fiat_amount"])) for e in unsettled_receivables), + Decimal(0), + ) + all_links = ( + [e["link"] for e in unsettled_payables if e.get("link")] + + [e["link"] for e in unsettled_receivables if e.get("link")] + ) + + if receivable_total <= 0: + # Endpoint is `/receivables/settle` — user paying off something + # they owe. With no open receivable there's nothing this endpoint + # can settle. Operator should use `/payables/pay` (libra pays user) + # or wait until the user has open receivables. + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=( + f"User {data.user_id[:8]} has no open receivables to settle. " + f"If libra owes them, use `/payables/pay`. If they want to " + f"deposit credit without an open obligation, that's a future " + f"feature (libra-#41 follow-up)." + ), + ) + + cash_paid = Decimal(str(data.amount)) + net_obligation = receivable_total - payable_total + tolerance = Decimal("0.01") # forex rounding slack + + if cash_paid + tolerance < net_obligation: + # Under-pay without explicit entry-picks — backend can't guess + # which receivable(s) the operator means to settle. + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail={ + "message": ( + "Cash paid is less than net obligation. Pay the exact " + "net to clear all open entries, or pass " + "`settled_entry_links` to settle a specific subset." + ), + "cash_paid": float(cash_paid), + "net_obligation": float(net_obligation), + "receivable_total": float(receivable_total), + "payable_total": float(payable_total), + "currency": data.currency.upper(), + }, + ) + + credit_overflow = cash_paid - net_obligation + if credit_overflow < tolerance: + credit_overflow = Decimal(0) + + # Auto-create the user-side accounts as needed. + user_payable = None + if payable_total > 0: + user_payable = await get_or_create_user_account( + data.user_id, AccountType.LIABILITY, "Accounts Payable", + ) + user_credit = None + if credit_overflow > 0: + user_credit = await get_or_create_user_account( + data.user_id, AccountType.LIABILITY, "Credit", + ) + + entry = format_fiat_net_settlement_entry( + user_id=data.user_id, + cash_account=payment_account.name, + receivable_account=user_receivable.name, + payable_account=user_payable.name if user_payable else None, + credit_account=user_credit.name if user_credit else None, + cash_paid_fiat=cash_paid, + total_receivable_fiat=receivable_total, + total_payable_fiat=payable_total, + credit_overflow_fiat=credit_overflow, + fiat_currency=data.currency.upper(), + description=data.description, + entry_date=datetime.now().date(), + payment_method=data.payment_method, + reference=data.reference or f"MANUAL-{data.user_id[:8]}", + settled_entry_links=all_links, + ) + elif is_fiat_payment: + # Legacy fiat path — operator provided `settled_entry_links` explicitly, + # meaning they're settling a specific subset. Backwards-compatible + # 2-leg behaviour: trust the caller's list, no auto-netting, no + # credit-overflow validation. Use the auto-detect path above (omit + # settled_entry_links) to get netting + credit handling. if not data.amount_sats: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, From 15d991007344b904578b067b1c827ea921794be7 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 12 Jun 2026 20:39:06 +0200 Subject: [PATCH 27/40] Resolve entry identity via entry-id metadata; unfuse user references (libra-#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 15 ++- beancount_format.py | 14 ++- tests/helpers.py | 18 +++- tests/test_entry_identity_api.py | 168 +++++++++++++++++++++++++++++++ tests/test_unit.py | 90 +++++++++++++++++ views_api.py | 158 ++++++++++++++--------------- 6 files changed, 374 insertions(+), 89 deletions(-) create mode 100644 tests/test_entry_identity_api.py diff --git a/CLAUDE.md b/CLAUDE.md index 77bcd65..97e546f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -209,7 +209,8 @@ entry = format_transaction( {"account": "Liabilities:Payable:User-abc123", "amount": "-50000 SATS"} ], tags=["groceries"], - links=["libra-entry-123"] + links=["exp-a1b2c3d4e5f60708"], # typed settlement link; identity goes in entry-id metadata + meta={"entry-id": "a1b2c3d4e5f60708"} ) # Submit to Fava @@ -217,6 +218,8 @@ client = get_fava_client() result = await client.add_entry(entry) ``` +Prefer the purpose-built formatters (`format_expense_entry`, `format_income_entry`, …) over raw `format_transaction` — they write the `entry-id` metadata and typed links for you (see Data Integrity → Entry Identity & Links). + **Querying Balances**: ```python # Query user balance from Fava @@ -278,7 +281,8 @@ entry = format_transaction( {"account": "Assets:Lightning:Balance", "amount": "-50000 SATS"} ], tags=["utilities"], - links=["libra-tx-123"] + links=["exp-0123456789abcdef"], + meta={"entry-id": "0123456789abcdef"} ) client = get_fava_client() @@ -306,6 +310,13 @@ result = await client.query(query) 3. User accounts use `user_id` (NOT `wallet_id`) for consistency 4. All accounting calculations delegated to Beancount/Fava +**Entry Identity & Links** (the contract `_extract_entry_id()` in views_api.py relies on): +- The `entry-id` **transaction metadata** is the single canonical entry identifier. Every libra-authored entry formatter (`format_expense_entry`, `format_receivable_entry`, `format_income_entry`, `format_revenue_entry`) writes it. All id resolution (pending list, user journal, approve, reject) reads it — never parse links to recover an id. +- Typed links `exp-{id}` / `rcv-{id}` / `inc-{id}` exist for **settlement tracking** (BQL queries match settlements to source entries by these links). They duplicate the id but are not the identity source. +- A user-supplied `reference` (invoice number, receipt id) becomes **its own sanitized link**, verbatim — never fused with the entry id, never displacing a system link. Two entries sharing a reference share the link (desired Beancount semantics). +- `ln-{payment_hash[:16]}` links mark Lightning payments. +- Legacy ledger history (pre-dfdcc44) carries a single `libra-{id}` link and no `entry-id` metadata — `_extract_entry_id()` falls back to parsing it. Do not write `libra-` links in new code. + **Validation** is performed in `core/validation.py`: - Pure validation functions for entry correctness before submitting to Fava diff --git a/beancount_format.py b/beancount_format.py index 486ad57..f233ee5 100644 --- a/beancount_format.py +++ b/beancount_format.py @@ -945,7 +945,8 @@ def format_revenue_entry( entry_date: date, fiat_currency: Optional[str] = None, fiat_amount: Optional[Decimal] = None, - reference: Optional[str] = None + reference: Optional[str] = None, + entry_id: Optional[str] = None ) -> Dict[str, Any]: """ Format a revenue entry (libra receives payment directly). @@ -962,7 +963,8 @@ def format_revenue_entry( entry_date: Date of payment fiat_currency: Optional fiat currency fiat_amount: Optional fiat amount (unsigned) - reference: Optional reference + reference: Optional reference (invoice ID, etc.) — stored as its own link + entry_id: Optional unique entry ID (generated if not provided) Returns: Fava API entry dict @@ -978,6 +980,9 @@ def format_revenue_entry( fiat_amount=Decimal("50.00") ) """ + if not entry_id: + entry_id = generate_entry_id() + amount_sats_abs = abs(amount_sats) fiat_amount_abs = abs(fiat_amount) if fiat_amount else None @@ -1002,12 +1007,13 @@ def format_revenue_entry( # Note: created-via is redundant with #revenue-entry tag entry_meta = { - "source": "libra-api" + "source": "libra-api", + "entry-id": entry_id } links = [] if reference: - links.append(reference) + links.append(sanitize_link(reference)) return format_transaction( date_val=entry_date, diff --git a/tests/helpers.py b/tests/helpers.py index 4bc0105..80ad343 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -121,6 +121,7 @@ async def post_expense( expense_account: str, currency: Optional[str] = "EUR", is_equity: bool = False, + reference: Optional[str] = None, ) -> dict[str, Any]: """User submits an expense — creates Liability (libra owes user) or Equity contribution. @@ -136,6 +137,7 @@ async def post_expense( "user_wallet": user_wallet_id, "currency": currency, "is_equity": is_equity, + "reference": reference, }, ) assert r.status_code == 201, f"post_expense failed: {r.status_code} {r.text}" @@ -150,6 +152,7 @@ async def post_income( description: str, revenue_account: str, currency: str = "EUR", + reference: Optional[str] = None, ) -> dict[str, Any]: """User submits income on libra's behalf — creates Receivable (user owes libra).""" r = await client.post( @@ -160,13 +163,14 @@ async def post_income( "amount": _amount(amount), "revenue_account": revenue_account, "currency": currency, + "reference": reference, }, ) assert r.status_code == 201, f"post_income failed: {r.status_code} {r.text}" return r.json() -async def list_user_entries(client: AsyncClient, *, wallet_inkey: str) -> list[dict]: +async def list_user_entries(client: AsyncClient, *, wallet_inkey: str) -> dict[str, Any]: r = await client.get( "/libra/api/v1/entries/user", headers={"X-Api-Key": wallet_inkey}, @@ -175,6 +179,18 @@ async def list_user_entries(client: AsyncClient, *, wallet_inkey: str) -> list[d return r.json() +async def list_pending_entries( + client: AsyncClient, *, super_user_headers: dict, +) -> list[dict]: + """Admin lists pending (`!`) entries awaiting approval.""" + r = await client.get( + "/libra/api/v1/entries/pending", + headers=super_user_headers, + ) + assert r.status_code == 200, f"list_pending_entries failed: {r.status_code} {r.text}" + return r.json() + + # --------------------------------------------------------------------------- # Entries — admin side # --------------------------------------------------------------------------- diff --git a/tests/test_entry_identity_api.py b/tests/test_entry_identity_api.py new file mode 100644 index 0000000..2b893ca --- /dev/null +++ b/tests/test_entry_identity_api.py @@ -0,0 +1,168 @@ +"""Entry identity resolution — the canonical id must survive a user reference. + +Regression coverage for the production bug where a pending income entry +created with a `reference` (e.g. an invoice number like "42-144") could +not be approved: the admin UI's pending list resolved the entry id by +parsing links for a `libra-` prefix, but reference-bearing entries carry +typed links (`inc-/exp-/rcv-{id}`) plus the reference as its own link — +no `libra-` link. The id surfaced as the literal string "unknown" and +`POST /entries/unknown/approve` 404'd. + +The fix makes the `entry-id` transaction metadata the single source of +truth (list, approve, and reject endpoints), with link parsing kept only +for pre-metadata ledger history. These tests pin that contract: + + - pending list returns the real id for reference-bearing entries + - approve/reject resolve that id end-to-end + - the user reference round-trips as `reference`, never as a system link +""" +from uuid import uuid4 + +import pytest + +from .helpers import ( + approve_entry, + list_pending_entries, + list_user_entries, + post_expense, + post_income, + reject_entry, +) + + +@pytest.mark.anyio +async def test_pending_income_with_reference_resolves_real_id( + client, super_user_headers, configured_user, standard_accounts, +): + """The production repro: income + reference must list with its real + id (not 'unknown') and approve successfully.""" + _, wallet = configured_user + marker = f"Membership dues {uuid4().hex[:6]}" + + posted = await post_income( + client, + wallet_inkey=wallet.inkey, + amount="700.00", currency="EUR", + description=marker, + revenue_account=standard_accounts["revenue_rent"]["name"], + reference="42-144", + ) + + pending = await list_pending_entries( + client, super_user_headers=super_user_headers, + ) + entry = next( + (e for e in pending if marker in (e.get("description") or "")), None, + ) + assert entry is not None, f"income entry not in pending list: {pending}" + assert entry["id"] == posted["id"], ( + f"pending list must surface the canonical entry id, " + f"got {entry['id']!r} (expected {posted['id']!r})" + ) + assert entry["id"] != "unknown" + + # The id from the listing must drive approval end-to-end. + result = await approve_entry( + client, super_user_headers=super_user_headers, entry_id=entry["id"], + ) + assert result.get("entry_id") == posted["id"] + + +@pytest.mark.anyio +async def test_pending_expense_with_reference_resolves_real_id_and_rejects( + client, super_user_headers, configured_user, standard_accounts, +): + """Same contract on the expense path, exercised through reject.""" + _, wallet = configured_user + marker = f"Receipted groceries {uuid4().hex[:6]}" + + posted = await post_expense( + client, + wallet_inkey=wallet.inkey, + user_wallet_id=wallet.id, + amount="36.93", currency="EUR", + description=marker, + expense_account=standard_accounts["expense_food"]["name"], + reference="RECEIPT/2026-06-12", + ) + + pending = await list_pending_entries( + client, super_user_headers=super_user_headers, + ) + entry = next( + (e for e in pending if marker in (e.get("description") or "")), None, + ) + assert entry is not None, f"expense entry not in pending list: {pending}" + assert entry["id"] == posted["id"] + + result = await reject_entry( + client, super_user_headers=super_user_headers, entry_id=entry["id"], + ) + assert result.get("entry_id") == posted["id"] + + +@pytest.mark.anyio +async def test_reference_round_trips_in_user_journal( + client, configured_user, standard_accounts, +): + """The user journal must report the user's reference, not a system + link (typed inc-/exp- links used to leak into the reference field).""" + _, wallet = configured_user + marker = f"Referenced expense {uuid4().hex[:6]}" + + posted = await post_expense( + client, + wallet_inkey=wallet.inkey, + user_wallet_id=wallet.id, + amount="12.00", currency="EUR", + description=marker, + expense_account=standard_accounts["expense_food"]["name"], + reference="INV-7731", + ) + assert posted.get("reference") == "INV-7731" + + listing = await list_user_entries(client, wallet_inkey=wallet.inkey) + entry = next( + ( + e for e in listing.get("entries", []) + if marker in (e.get("description") or "") + ), + None, + ) + assert entry is not None + assert entry["id"] == posted["id"] + assert entry.get("reference") == "INV-7731", ( + f"reference field must carry the user's reference, " + f"got {entry.get('reference')!r}" + ) + + +@pytest.mark.anyio +async def test_entry_without_reference_still_resolves( + client, super_user_headers, configured_user, standard_accounts, +): + """No-reference entries keep working (the case that always worked).""" + _, wallet = configured_user + marker = f"Plain income {uuid4().hex[:6]}" + + posted = await post_income( + client, + wallet_inkey=wallet.inkey, + amount="55.00", currency="EUR", + description=marker, + revenue_account=standard_accounts["revenue_rent"]["name"], + ) + + pending = await list_pending_entries( + client, super_user_headers=super_user_headers, + ) + entry = next( + (e for e in pending if marker in (e.get("description") or "")), None, + ) + assert entry is not None + assert entry["id"] == posted["id"] + + result = await approve_entry( + client, super_user_headers=super_user_headers, entry_id=entry["id"], + ) + assert result.get("entry_id") == posted["id"] diff --git a/tests/test_unit.py b/tests/test_unit.py index bb74a9c..2fa41ec 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -135,6 +135,96 @@ def test_generate_entry_ids_are_unique(): assert len(ids) == 100 # 16 hex chars = 64 bits; collisions in 100 are negligible +# --------------------------------------------------------------------------- +# Entry identity contract — every libra-authored entry formatter must write +# `entry-id` metadata (the canonical id) and keep the user reference as its +# own sanitized link, never fused with the id. +# --------------------------------------------------------------------------- + + +def test_format_expense_entry_identity_contract(): + entry = bf.format_expense_entry( + user_id="abc12345", + expense_account="Expenses:Food", + user_account="Liabilities:Payable:User-abc12345", + amount_sats=50000, + description="Groceries", + entry_date=date(2026, 6, 12), + fiat_currency="EUR", + fiat_amount=Decimal("46.50"), + reference="Invoice #123", + entry_id="deadbeef00000001", + ) + assert entry["meta"]["entry-id"] == "deadbeef00000001" + assert "exp-deadbeef00000001" in entry["links"] + assert "Invoice-123" in entry["links"] # sanitized, standalone + + +def test_format_receivable_entry_identity_contract(): + entry = bf.format_receivable_entry( + user_id="abc12345", + revenue_account="Income:Accommodation", + receivable_account="Assets:Receivable:User-abc12345", + amount_sats=100000, + description="2-night stay", + entry_date=date(2026, 6, 12), + fiat_currency="EUR", + fiat_amount=Decimal("93.00"), + reference="BOOKING/42", + entry_id="deadbeef00000002", + ) + assert entry["meta"]["entry-id"] == "deadbeef00000002" + assert "rcv-deadbeef00000002" in entry["links"] + assert "BOOKING/42" in entry["links"] + + +def test_format_income_entry_identity_contract(): + """The production-bug shape: income + reference like '42-144'.""" + entry = bf.format_income_entry( + user_id="abc12345", + user_account="Assets:Receivable:User-abc12345", + revenue_account="Income:MemberDuesContributions", + amount_sats=1112490, + description="2 Memberships", + entry_date=date(2026, 6, 12), + fiat_currency="USD", + fiat_amount=Decimal("700.00"), + reference="42-144", + entry_id="deadbeef00000003", + ) + assert entry["meta"]["entry-id"] == "deadbeef00000003" + assert "inc-deadbeef00000003" in entry["links"] + assert "42-144" in entry["links"] + + +def test_format_revenue_entry_identity_contract(): + entry = bf.format_revenue_entry( + payment_account="Assets:Cash", + revenue_account="Income:Sales", + amount_sats=100000, + description="Product sale", + entry_date=date(2026, 6, 12), + fiat_currency="EUR", + fiat_amount=Decimal("50.00"), + reference="Till receipt 9", + entry_id="deadbeef00000004", + ) + assert entry["meta"]["entry-id"] == "deadbeef00000004" + assert "Till-receipt-9" in entry["links"] # sanitized + + +def test_format_revenue_entry_generates_entry_id_when_absent(): + entry = bf.format_revenue_entry( + payment_account="Assets:Cash", + revenue_account="Income:Sales", + amount_sats=100000, + description="Product sale", + entry_date=date(2026, 6, 12), + ) + eid = entry["meta"]["entry-id"] + assert len(eid) == 16 and all(c in "0123456789abcdef" for c in eid) + + # --------------------------------------------------------------------------- # account_utils.format_hierarchical_account_name # --------------------------------------------------------------------------- diff --git a/views_api.py b/views_api.py index fb46809..7dce7c3 100644 --- a/views_api.py +++ b/views_api.py @@ -434,6 +434,39 @@ async def api_get_journal_entries( return enriched_entries +# Link prefixes written by libra itself (vs user-supplied references): +# exp-/rcv-/inc- typed entry links, ln- lightning payment links, and the +# legacy libra-{id} identity link. +_SYSTEM_LINK_PREFIXES = ("exp-", "rcv-", "inc-", "ln-", "libra-") + + +def _extract_entry_id(entry: dict) -> Optional[str]: + """Resolve the canonical libra entry id for a Fava transaction. + + The ``entry-id`` transaction metadata is the single source of truth — + written by every libra entry formatter since dfdcc44. Ledger history + predating it carries only a ``libra-{id}`` link; parse that as a + fallback so old entries still resolve. + + Returns None when no id can be determined (e.g. settlement/payment + transactions, which are not approvable). + """ + meta = entry.get("meta", {}) + entry_id = meta.get("entry-id") + if entry_id: + return str(entry_id) + + # Legacy fallback: pre-entry-id ledger history (single libra-{id} link) + links = entry.get("links", []) + if isinstance(links, (list, set)): + for link in links: + if isinstance(link, str): + link_clean = link.lstrip('^') + if link_clean.startswith("libra-"): + return link_clean[len("libra-"):] + return None + + @libra_api_router.get("/api/v1/entries/user") async def api_get_user_entries( wallet: WalletTypeInfo = Depends(require_invoice_key), @@ -524,18 +557,9 @@ async def api_get_user_entries( continue # Extract data for frontend - # Extract entry ID from links - entry_id = None + # Resolve canonical entry ID (metadata first, link fallback) + entry_id = _extract_entry_id(e) links = e.get("links", []) - if isinstance(links, (list, set)): - for link in links: - if isinstance(link, str): - link_clean = link.lstrip('^') - if "libra-" in link_clean: - parts = link_clean.split("libra-") - if len(parts) > 1: - entry_id = parts[-1] - break # Extract amount from postings amount_sats = 0 @@ -592,13 +616,15 @@ async def api_get_user_entries( fiat_amount = float(cost_match.group(1)) fiat_currency = cost_match.group(2) - # Extract reference from links (first non-libra link) + # Extract reference from links (first link that isn't a + # libra-system link: typed entry/settlement links, lightning + # payment links, or the legacy libra-{id} identity link) reference = None if isinstance(links, (list, set)): for link in links: if isinstance(link, str): link_clean = link.lstrip('^') - if not link_clean.startswith("libra-") and not link_clean.startswith("ln-"): + if not link_clean.startswith(_SYSTEM_LINK_PREFIXES): reference = link_clean break @@ -778,19 +804,9 @@ async def api_get_pending_entries( for e in all_entries: # Only include pending transactions that are NOT voided if e.get("t") == "Transaction" and e.get("flag") == "!" and "voided" not in e.get("tags", []): - # Extract entry ID from links field - entry_id = None + # Resolve canonical entry ID (metadata first, link fallback) + entry_id = _extract_entry_id(e) links = e.get("links", []) - if isinstance(links, (list, set)): - for link in links: - if isinstance(link, str): - # Strip ^ prefix if present (Beancount link syntax) - link_clean = link.lstrip('^') - if "libra-" in link_clean: - parts = link_clean.split("libra-") - if len(parts) > 1: - entry_id = parts[-1] - break # Extract user ID from metadata or account names user_id = None @@ -906,7 +922,11 @@ async def api_create_journal_entry( Submits entry to Fava/Beancount. """ from .fava_client import get_fava_client - from .beancount_format import format_transaction, format_posting_with_cost + from .beancount_format import ( + format_transaction, + format_posting_with_cost, + sanitize_link, + ) # Validate that entry balances to zero total = sum(line.amount for line in data.lines) @@ -975,7 +995,7 @@ async def api_create_journal_entry( tags = data.meta.get("tags", []) links = data.meta.get("links", []) if data.reference: - links.append(data.reference) + links.append(sanitize_link(data.reference)) # Entry metadata (excluding tags and links which go at transaction level) entry_meta = {k: v for k, v in data.meta.items() if k not in ["tags", "links"]} @@ -1128,7 +1148,7 @@ async def api_create_expense_entry( # Format as Beancount entry and submit to Fava from .fava_client import get_fava_client - from .beancount_format import format_expense_entry, sanitize_link + from .beancount_format import format_expense_entry fava = get_fava_client() @@ -1140,12 +1160,8 @@ async def api_create_expense_entry( import uuid entry_id = str(uuid.uuid4()).replace("-", "")[:16] - # Add libra ID as reference/link (sanitized for Beancount) - libra_reference = f"libra-{entry_id}" - if data.reference: - libra_reference = f"{sanitize_link(data.reference)}-{entry_id}" - - # Format Beancount entry + # Format Beancount entry. Identity travels as entry-id metadata + + # exp-{entry_id} link; the user reference becomes its own link. entry = format_expense_entry( user_id=wallet.wallet.user, expense_account=expense_account.name, @@ -1156,8 +1172,8 @@ async def api_create_expense_entry( is_equity=data.is_equity, fiat_currency=fiat_currency, fiat_amount=fiat_amount, - reference=libra_reference, - entry_id=entry_id # Pass entry_id so all links match + reference=data.reference, + entry_id=entry_id ) # Submit to Fava @@ -1171,7 +1187,7 @@ async def api_create_expense_entry( entry_date=data.entry_date if data.entry_date else datetime.now(), created_by=wallet.wallet.user, # Use user_id, not wallet_id created_at=datetime.now(), - reference=libra_reference, + reference=data.reference, flag=JournalEntryFlag.PENDING, meta=entry_meta, lines=[ @@ -1262,17 +1278,15 @@ async def api_create_income_entry( # Submit to Fava from .fava_client import get_fava_client - from .beancount_format import format_income_entry, sanitize_link + from .beancount_format import format_income_entry fava = get_fava_client() import uuid entry_id = str(uuid.uuid4()).replace("-", "")[:16] - libra_reference = f"libra-{entry_id}" - if data.reference: - libra_reference = f"{sanitize_link(data.reference)}-{entry_id}" - + # Identity travels as entry-id metadata + inc-{entry_id} link; the + # user reference becomes its own link. entry = format_income_entry( user_id=wallet.wallet.user, user_account=user_account.name, @@ -1282,7 +1296,7 @@ async def api_create_income_entry( entry_date=data.entry_date.date() if data.entry_date else datetime.now().date(), fiat_currency=fiat_currency, fiat_amount=data.amount, - reference=libra_reference, + reference=data.reference, entry_id=entry_id, ) @@ -1303,7 +1317,7 @@ async def api_create_income_entry( entry_date=data.entry_date if data.entry_date else datetime.now(), created_by=wallet.wallet.user, created_at=datetime.now(), - reference=libra_reference, + reference=data.reference, flag=JournalEntryFlag.PENDING, meta=entry_meta, lines=[ @@ -1389,7 +1403,7 @@ async def api_create_receivable_entry( # Format as Beancount entry and submit to Fava from .fava_client import get_fava_client - from .beancount_format import format_receivable_entry, sanitize_link + from .beancount_format import format_receivable_entry fava = get_fava_client() @@ -1401,12 +1415,8 @@ async def api_create_receivable_entry( import uuid entry_id = str(uuid.uuid4()).replace("-", "")[:16] - # Add libra ID as reference/link (sanitized for Beancount) - libra_reference = f"libra-{entry_id}" - if data.reference: - libra_reference = f"{sanitize_link(data.reference)}-{entry_id}" - - # Format Beancount entry + # Format Beancount entry. Identity travels as entry-id metadata + + # rcv-{entry_id} link; the user reference becomes its own link. entry = format_receivable_entry( user_id=data.user_id, revenue_account=revenue_account.name, @@ -1416,8 +1426,8 @@ async def api_create_receivable_entry( entry_date=datetime.now().date(), fiat_currency=fiat_currency, fiat_amount=fiat_amount, - reference=libra_reference, - entry_id=entry_id # Pass entry_id so all links match + reference=data.reference, + entry_id=entry_id ) # Submit to Fava @@ -1431,7 +1441,7 @@ async def api_create_receivable_entry( entry_date=datetime.now(), created_by=auth.user_id, created_at=datetime.now(), - reference=libra_reference, # Use libra reference with unique ID + reference=data.reference, flag=JournalEntryFlag.PENDING, meta=entry_meta, lines=[ @@ -1467,7 +1477,7 @@ async def api_create_revenue_entry( Submits entry to Fava/Beancount. """ from .fava_client import get_fava_client - from .beancount_format import format_revenue_entry, sanitize_link + from .beancount_format import format_revenue_entry # Get revenue account revenue_account = await get_account_by_name(data.revenue_account) @@ -1517,11 +1527,8 @@ async def api_create_revenue_entry( import uuid entry_id = str(uuid.uuid4()).replace("-", "")[:16] - # Add libra ID as reference/link (sanitized for Beancount) - libra_reference = f"libra-{entry_id}" - if data.reference: - libra_reference = f"{sanitize_link(data.reference)}-{entry_id}" - + # Identity travels as entry-id metadata; the user reference becomes + # its own link. entry = format_revenue_entry( payment_account=payment_account.name, revenue_account=revenue_account.name, @@ -1530,7 +1537,8 @@ async def api_create_revenue_entry( entry_date=datetime.now().date(), fiat_currency=fiat_currency, fiat_amount=fiat_amount, - reference=libra_reference # Use libra reference with unique ID + reference=data.reference, + entry_id=entry_id, ) # Submit to Fava @@ -1545,7 +1553,7 @@ async def api_create_revenue_entry( entry_date=datetime.now(), created_by=auth.user_id, created_at=datetime.now(), - reference=libra_reference, + reference=data.reference, flag=JournalEntryFlag.CLEARED, lines=[], # Empty - entry is stored in Fava, not Libra DB meta={"source": "fava", "fava_response": result.get('data', 'Unknown')} @@ -2857,21 +2865,14 @@ async def api_approve_expense_entry( # 1. Get all journal entries from Fava all_entries = await fava.get_journal_entries() - # 2. Find the entry with matching libra ID in links + # 2. Find the pending transaction with matching canonical entry id target_entry = None for entry in all_entries: # Only look at transactions with pending flag if entry.get("t") == "Transaction" and entry.get("flag") == "!": - links = entry.get("links", []) - for link in links: - # Strip ^ prefix if present (Beancount link syntax) - link_clean = link.lstrip('^') - # Check if this entry has our libra ID - if link_clean == f"libra-{entry_id}" or link_clean.endswith(f"-{entry_id}"): - target_entry = entry - break - if target_entry: + if _extract_entry_id(entry) == entry_id: + target_entry = entry break if not target_entry: @@ -2973,21 +2974,14 @@ async def api_reject_expense_entry( # 1. Get all journal entries from Fava all_entries = await fava.get_journal_entries() - # 2. Find the entry with matching libra ID in links + # 2. Find the pending transaction with matching canonical entry id target_entry = None for entry in all_entries: # Only look at transactions with pending flag if entry.get("t") == "Transaction" and entry.get("flag") == "!": - links = entry.get("links", []) - for link in links: - # Strip ^ prefix if present (Beancount link syntax) - link_clean = link.lstrip('^') - # Check if this entry has our libra ID - if link_clean == f"libra-{entry_id}" or link_clean.endswith(f"-{entry_id}"): - target_entry = entry - break - if target_entry: + if _extract_entry_id(entry) == entry_id: + target_entry = entry break if not target_entry: From 16ae6c2000c00f22bb57de3a2f6516c91ccf1dd9 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 12 Jun 2026 20:39:14 +0200 Subject: [PATCH 28/40] docs(tests): record known-good lnbits/dev invocation + env gotchas The suite targets the lnbits dev worktree (needs lnbits.core.signers) and trips on three non-obvious environment requirements, each of which cost a failed run today: LNBITS_EXTENSIONS_PATH is the parent of an extensions/ dir, the data folder must be a fresh temp dir per run, and lnbits dev mandates LNBITS_KEY_MASTER at boot. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/README.md | 46 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/tests/README.md b/tests/README.md index efdab4b..4c6c30b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -22,22 +22,56 @@ Inside the regtest container `fava` is already provisioned. ## Running -From the LNbits source root (with the libra extension reachable via `LNBITS_EXTENSIONS_PATH` or symlinked into `lnbits/extensions/`): +The suite targets the **`lnbits/dev` worktree** (`~/dev/lnbits/dev`) — it +relies on dev-branch modules (`lnbits.core.signers`, the bunker work) that +`main` doesn't carry. A known-good invocation from scratch: ```bash -# Whole suite -pytest path/to/libra/tests +# One-time: build a venv with lnbits (dev) + test deps + fava +nix-shell -p uv --run "uv venv /tmp/libra-test-venv --python 3.12 && \ + uv pip install --python /tmp/libra-test-venv/bin/python \ + -e ~/dev/lnbits/dev pytest asgi-lifespan fava" +# Run (each invocation gets a fresh data folder — REQUIRED, see gotchas) +cd ~/dev/lnbits/dev && \ +env LNBITS_KEY_MASTER=$(openssl rand -hex 32) \ + LNBITS_DATA_FOLDER=$(mktemp -d -t libra-test-data-XXXX) \ + LNBITS_EXTENSIONS_PATH=$HOME/dev/shared \ + PYTHONPATH=$HOME/dev/shared/extensions:. \ + PATH=/tmp/libra-test-venv/bin:$PATH \ + /tmp/libra-test-venv/bin/pytest ~/dev/shared/extensions/libra/tests -q +``` + +```bash # Smoke test only (validate the harness before running everything) -pytest path/to/libra/tests/test_smoke.py +... pytest path/to/libra/tests/test_smoke.py # One area -pytest path/to/libra/tests/test_balances_api.py +... pytest path/to/libra/tests/test_balances_api.py # Single test, verbose -pytest path/to/libra/tests/test_balances_api.py::test_mixed_income_expense_nets_correctly -v +... pytest path/to/libra/tests/test_balances_api.py::test_mixed_income_expense_nets_correctly -v ``` +### Environment gotchas (each cost a failed run on 2026-06-12) + +- **`LNBITS_EXTENSIONS_PATH` is the *parent* of an `extensions/` dir** — + lnbits scans `{path}/extensions/` (`lnbits/app.py`, + `build_all_installed_extensions_list`). For extensions at + `~/dev/shared/extensions/libra`, pass `~/dev/shared`. Pointing it at + `~/dev/shared/extensions` makes libra invisible: zero extensions install, + migrations never run, and every test errors with + `no such table: extension_settings`. +- **Set `LNBITS_DATA_FOLDER` to a fresh temp dir explicitly.** The + conftest's `os.environ.setdefault` redirect is not always effective; + reusing a previous run's database fails `first_install` with + "Username already exists" during app-fixture setup. +- **`LNBITS_KEY_MASTER` (32-byte hex) is mandatory on lnbits dev** — the + signer migration aborts startup without it (issue lnbits#9 + encrypt-at-rest). Any random value is fine for tests. +- **lnbits `main` does not work**: extensions importing + `lnbits.core.signers` fail to load, and libra's app fixture errors. + The Fava subprocess starts once per session (~1-2s) and is shared across tests; each test creates its own LNbits user so the shared ledger doesn't cause inter-test interference. ## Conventions From 788a9998f609bff212b95d8a876fb545865456b8 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 15 Jun 2026 20:31:27 +0200 Subject: [PATCH 29/40] fix(fava): escape string metadata + make Open currencies optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add_account wrote free-text metadata values straight into the ledger source via /api/source with no escaping — an unescaped quote or newline in an admin-supplied description would corrupt the Beancount file (or forge extra metadata lines). Escape backslash/quote/newline per the tokenizer's cunescape rules (verified round-trip through beancount's parser). Also make the currency constraint list optional so an Open directive can be written unconstrained (currencies are an optional part of the directive, not required). Co-Authored-By: Claude Opus 4.8 (1M context) --- fava_client.py | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/fava_client.py b/fava_client.py index c61e1d9..afc964f 100644 --- a/fava_client.py +++ b/fava_client.py @@ -44,6 +44,22 @@ def _infer_target_file(account_name: str) -> str: return "accounts/chart.beancount" +def _escape_beancount_string(value: str) -> str: + """Escape a value for safe inclusion in a Beancount string literal. + + Beancount's tokenizer unescapes \\", \\n, \\t, \\r, \\\\ etc. (tokens.c + cunescape). Unescaped quotes or newlines in free-text metadata written + straight into the ledger source would corrupt the file, so escape the + backslash first (to keep it round-tripping) then quotes and newlines. + """ + return ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + ) + + class FavaClient: """ Async client for Fava REST API. @@ -1544,7 +1560,7 @@ class FavaClient: async def add_account( self, account_name: str, - currencies: list[str], + currencies: Optional[list[str]] = None, opening_date: Optional[date] = None, metadata: Optional[Dict[str, Any]] = None, target_file: Optional[str] = None, @@ -1642,19 +1658,23 @@ class FavaClient: lines = source.split('\n') insert_index = len(lines) - # Step 4: Format Open directive as Beancount text - currencies_str = ", ".join(currencies) - open_lines = [ - "", - f"{opening_date.isoformat()} open {account_name} {currencies_str}" - ] + # Step 4: Format Open directive as Beancount text. + # Currencies are an optional constraint on an Open + # directive; when none are given the account accepts + # any commodity. + open_directive = f"{opening_date.isoformat()} open {account_name}" + if currencies: + open_directive += f" {', '.join(currencies)}" + open_lines = ["", open_directive] # Add metadata if provided if metadata: for key, value in metadata.items(): # Format metadata with proper indentation if isinstance(value, str): - open_lines.append(f' {key}: "{value}"') + open_lines.append( + f' {key}: "{_escape_beancount_string(value)}"' + ) else: open_lines.append(f' {key}: {value}') From 9dd46e818cfeee1cf16b7892e08dec456c5eed7f Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 15 Jun 2026 20:31:42 +0200 Subject: [PATCH 30/40] feat(ui): wire admin add-account endpoint into Chart of Accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the existing POST /api/v1/admin/accounts endpoint in the UI: a super-user-only 'Add Account' button on the Chart of Accounts card opens a dialog for the hierarchical account name + optional description, posts with the wallet admin key (require_super_user), then reloads accounts. Client-side prefix validation mirrors the server's _VALID_ACCOUNT_PREFIXES. No currency input — an Open directive does not require currency constraints. Co-Authored-By: Claude Opus 4.8 (1M context) --- static/js/index.js | 45 +++++++++++++++++++++++++++++ templates/libra/index.html | 59 +++++++++++++++++++++++++++++++++++++- 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/static/js/index.js b/static/js/index.js index 418ec41..a10d50c 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -69,6 +69,12 @@ window.app = Vue.createApp({ userWalletId: '', loading: false }, + addAccountDialog: { + show: false, + name: '', + description: '', + loading: false + }, receivableDialog: { show: false, selectedUser: '', @@ -566,6 +572,45 @@ window.app = Vue.createApp({ this.syncingAccounts = false } }, + showAddAccountDialog() { + this.addAccountDialog.name = '' + this.addAccountDialog.description = '' + this.addAccountDialog.show = true + }, + async submitAddAccount() { + const name = (this.addAccountDialog.name || '').trim() + const validPrefixes = ['Assets:', 'Liabilities:', 'Equity:', 'Income:', 'Expenses:'] + if (!validPrefixes.some(p => name.startsWith(p))) { + this.$q.notify({ + type: 'warning', + message: `Account name must start with one of: ${validPrefixes.join(', ')}` + }) + return + } + this.addAccountDialog.loading = true + try { + const {data} = await LNbits.api.request( + 'POST', + '/libra/api/v1/admin/accounts', + this.g.user.wallets[0].adminkey, + { + name, + description: this.addAccountDialog.description || null + } + ) + this.$q.notify({ + type: 'positive', + message: `Account ${data.account_name} created` + + (data.synced_to_libra_db ? '' : ' (sync pending)') + }) + this.addAccountDialog.show = false + await this.loadAccounts() + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.addAccountDialog.loading = false + } + }, showSettingsDialog() { this.settingsDialog.libraWalletId = this.settings?.libra_wallet_id || '' this.settingsDialog.favaUrl = this.settings?.fava_url || 'http://localhost:3333' diff --git a/templates/libra/index.html b/templates/libra/index.html index 0de0e71..f36c466 100644 --- a/templates/libra/index.html +++ b/templates/libra/index.html @@ -857,7 +857,20 @@ -
    Chart of Accounts
    +
    +
    Chart of Accounts
    + + +
    @@ -1232,6 +1245,50 @@
    + + + + +
    Add Account
    + + + + + +
    + Creates an Open directive in the Beancount ledger and syncs it into Libra + so permissions can be granted. Per-user accounts are managed automatically. +
    + +
    + + Create Account + + Cancel +
    +
    +
    +
    + From 7456574f65af39adf519372212081c3e5e2c59cb Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 15 Jun 2026 23:53:04 +0200 Subject: [PATCH 31/40] fix(accounts): default CreateChartAccount.currencies to None MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI omits currencies so the Open directive is written unconstrained, but the model defaulted currencies to ["EUR","SATS","USD"], so Pydantic refilled them and the endpoint passed the constraint through — every admin-created account got a currency-constrained Open (which would reject postings in other currencies, the same CAD/GBP/JPY bean-check class we hit on user accounts). Default to None so omission reaches add_account and the directive is unconstrained; an explicit list still works for API callers. Co-Authored-By: Claude Opus 4.8 (1M context) --- models.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/models.py b/models.py index 8be64c9..c8632d0 100644 --- a/models.py +++ b/models.py @@ -51,7 +51,11 @@ class CreateAccount(BaseModel): class CreateChartAccount(BaseModel): """Admin-created chart-of-accounts entry written to accounts/chart.beancount.""" name: str # Full hierarchical account name, e.g. "Expenses:Services:Domain" - currencies: list[str] = ["EUR", "SATS", "USD"] + # Optional currency constraint. Omitted by the UI: an Open directive needs + # no currency list, and constraining it would reject postings in other + # currencies (the CAD/GBP/JPY bean-check errors we saw on user accounts). + # None → unconstrained Open; a list → explicit constraint for API callers. + currencies: Optional[list[str]] = None description: Optional[str] = None From caef3cf5e8981b031907a852680fb03b3272e53a Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 16 Jun 2026 00:07:39 +0200 Subject: [PATCH 32/40] fix(accounts): 409 when admin-adding an account that already exists add_account no-ops if the Open directive is already present but returned a normal-looking dict, so the admin endpoint reported success ('created (sync pending)') for a duplicate. Return an already_existed flag and raise 409 from the endpoint. Also anchor the existence check on the Open directive with a trailing-boundary match so a prefix (Expenses:Gas) doesn't match a longer sibling (Expenses:GasStation). The flag is additive, so the idempotent user-account path keeps no-opping silently. Co-Authored-By: Claude Opus 4.8 (1M context) --- fava_client.py | 20 ++++++++++++++++---- views_api.py | 8 +++++++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/fava_client.py b/fava_client.py index afc964f..f176031 100644 --- a/fava_client.py +++ b/fava_client.py @@ -1643,10 +1643,22 @@ class FavaClient: sha256sum = source_data["sha256sum"] source = source_data["source"] - # Step 2: Check if account already exists (may have been created by concurrent request) - if f"open {account_name}" in source: + # Step 2: Check if account already exists (may have been + # created by a concurrent request). Anchor on the Open + # directive and require the account to be followed by + # whitespace/end-of-line so a prefix (Expenses:Gas) does + # not match a longer sibling (Expenses:GasStation). + if re.search( + rf"open {re.escape(account_name)}(?:\s|$)", + source, + re.MULTILINE, + ): logger.info(f"Account {account_name} already exists in {target_file}") - return {"data": sha256sum, "mtime": source_data.get("mtime", "")} + return { + "data": sha256sum, + "mtime": source_data.get("mtime", ""), + "already_existed": True, + } # Step 3: Always append at end of file. # Post-split layout, each include file has one mutation @@ -1700,7 +1712,7 @@ class FavaClient: result = response.json() logger.info(f"Added account {account_name} to {target_file} with currencies {currencies}") - return result + return {**result, "already_existed": False} except httpx.HTTPStatusError as e: # Check for checksum conflict (HTTP 412 Precondition Failed or similar) diff --git a/views_api.py b/views_api.py index 7dce7c3..d88b292 100644 --- a/views_api.py +++ b/views_api.py @@ -3695,13 +3695,19 @@ async def api_admin_add_chart_account( if payload.description: metadata["description"] = payload.description - await fava.add_account( + result = await fava.add_account( account_name=payload.name, currencies=payload.currencies, target_file="accounts/chart.beancount", metadata=metadata, ) + if result.get("already_existed"): + raise HTTPException( + status_code=HTTPStatus.CONFLICT, + detail=f"Account {payload.name} already exists", + ) + # Mirror into libra DB so permissions / metadata layer sees it. from .account_sync import sync_single_account_from_beancount synced = await sync_single_account_from_beancount(payload.name) From 051c9f0c221b462ea6a35fbb159ec016f6c61420 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 16 Jun 2026 01:15:06 +0200 Subject: [PATCH 33/40] feat(ui): constrain add-account to a root-type dropdown + sub-path Free-typing the full hierarchical name let admins fat-finger the parent (wrong/invalid root). Replace the single name field with a required Account Type select (the 5 valid roots, mirroring _VALID_ACCOUNT_PREFIXES) plus a sub-account input, a live 'Will create: ...' preview, and per-segment validation (each part must be a capitalized Beancount account component). The root prefix is now structurally guaranteed valid. Co-Authored-By: Claude Opus 4.8 (1M context) --- static/js/index.js | 32 ++++++++++++++++++++++++++------ templates/libra/index.html | 23 ++++++++++++++++++----- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index a10d50c..2b4c750 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -71,7 +71,8 @@ window.app = Vue.createApp({ }, addAccountDialog: { show: false, - name: '', + rootType: 'Expenses', + subPath: '', description: '', loading: false }, @@ -292,6 +293,16 @@ window.app = Vue.createApp({ }) return options }, + accountRootTypes() { + // The five Beancount root account types — the only valid parents. + // Mirrors the server's _VALID_ACCOUNT_PREFIXES. + return ['Assets', 'Liabilities', 'Equity', 'Income', 'Expenses'] + }, + addAccountFullName() { + const sub = (this.addAccountDialog.subPath || '').trim().replace(/^:+|:+$/g, '') + if (!this.addAccountDialog.rootType || !sub) return '' + return `${this.addAccountDialog.rootType}:${sub}` + }, userOptions() { const options = [] this.users.forEach(user => { @@ -573,17 +584,26 @@ window.app = Vue.createApp({ } }, showAddAccountDialog() { - this.addAccountDialog.name = '' + this.addAccountDialog.rootType = 'Expenses' + this.addAccountDialog.subPath = '' this.addAccountDialog.description = '' this.addAccountDialog.show = true }, async submitAddAccount() { - const name = (this.addAccountDialog.name || '').trim() - const validPrefixes = ['Assets:', 'Liabilities:', 'Equity:', 'Income:', 'Expenses:'] - if (!validPrefixes.some(p => name.startsWith(p))) { + const name = this.addAccountFullName + if (!name) { + this.$q.notify({type: 'warning', message: 'Enter a sub-account name'}) + return + } + // Each segment under the root must be a valid Beancount account + // component: start with an uppercase letter, then letters/digits/hyphens. + const badSegment = name.split(':').slice(1).find( + seg => !/^[A-Z][A-Za-z0-9-]*$/.test(seg) + ) + if (badSegment !== undefined) { this.$q.notify({ type: 'warning', - message: `Account name must start with one of: ${validPrefixes.join(', ')}` + message: `Invalid segment "${badSegment}" — each part must start with a capital letter (letters, digits, hyphens only)` }) return } diff --git a/templates/libra/index.html b/templates/libra/index.html index f36c466..5369f72 100644 --- a/templates/libra/index.html +++ b/templates/libra/index.html @@ -1251,15 +1251,28 @@
    Add Account
    + + +
    + Will create: {% raw %}{{ addAccountFullName }}{% endraw %} +
    + Create Account
    From cd5a6edb7dc2629dd3a4b55c9073136d02a363ba Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 16 Jun 2026 22:55:45 +0200 Subject: [PATCH 34/40] feat(accounts): validate account-name characters server-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoint only checked the root prefix, so a direct API call (bypassing the UI) could write a malformed Open directive into the ledger source. Add _validate_account_name mirroring Beancount's core/account.py grammar (root [\p{Lu}][\p{L}\p{Nd}-]*, sub [\p{Lu}\p{Nd}][\p{L}\p{Nd}-]*, >=1 sub-account) — verified to match beancount.core.account.is_valid across 20 cases incl. Unicode, digit-start subs, hyphens. Align the client segment regex to the same rule (was ASCII-only, rejected valid names). Co-Authored-By: Claude Opus 4.8 (1M context) --- static/js/index.js | 7 ++++--- views_api.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index 2b4c750..6f451f7 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -596,14 +596,15 @@ window.app = Vue.createApp({ return } // Each segment under the root must be a valid Beancount account - // component: start with an uppercase letter, then letters/digits/hyphens. + // component (core/account.py ACC_COMP_NAME_RE): starts with an uppercase + // letter or digit, then letters/digits/hyphens (Unicode letters allowed). const badSegment = name.split(':').slice(1).find( - seg => !/^[A-Z][A-Za-z0-9-]*$/.test(seg) + seg => !/^[\p{Lu}\p{Nd}][\p{L}\p{Nd}-]*$/u.test(seg) ) if (badSegment !== undefined) { this.$q.notify({ type: 'warning', - message: `Invalid segment "${badSegment}" — each part must start with a capital letter (letters, digits, hyphens only)` + message: `Invalid segment "${badSegment}" — letters, digits and hyphens only, starting with a capital letter or digit` }) return } diff --git a/views_api.py b/views_api.py index d88b292..3e8647a 100644 --- a/views_api.py +++ b/views_api.py @@ -3661,6 +3661,52 @@ async def api_get_account_hierarchy( _VALID_ACCOUNT_PREFIXES = ("Assets:", "Liabilities:", "Equity:", "Income:", "Expenses:") +def _is_valid_account_component(component: str, *, is_root: bool) -> bool: + """Validate one ':'-separated account component against Beancount's grammar. + + Mirrors core/account.py: a root component matches ``[\\p{Lu}][\\p{L}\\p{Nd}-]*`` + (must start with an uppercase letter); a sub component matches + ``[\\p{Lu}\\p{Nd}][\\p{L}\\p{Nd}-]*`` (may also start with a digit). Body + chars are letters, decimal digits, or hyphen. Implemented with Unicode-aware + str methods (libra's runtime has no beancount — Fava is a separate service), + so non-ASCII letters are accepted exactly as Beancount accepts them. + """ + if not component: + return False + first, rest = component[0], component[1:] + first_ok = (first.isalpha() and first.isupper()) or ( + not is_root and first.isdecimal() + ) + if not first_ok: + return False + return all(ch == "-" or ch.isalpha() or ch.isdecimal() for ch in rest) + + +def _validate_account_name(name: str) -> None: + """Raise HTTP 400 if ``name`` is not a syntactically valid Beancount account. + + The UI guards this client-side, but the endpoint is reachable directly via + API, so this is the load-bearing check before the name is written into the + ledger source. Requires a root plus at least one sub-component. + """ + parts = name.split(":") + valid = ( + len(parts) >= 2 + and _is_valid_account_component(parts[0], is_root=True) + and all(_is_valid_account_component(p, is_root=False) for p in parts[1:]) + ) + if not valid: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=( + f"Invalid account name {name!r}: each ':'-separated part must be " + "letters/digits/hyphens, the root starting with an uppercase " + "letter (sub-accounts may start with a digit), with at least one " + "sub-account (e.g. Expenses:Food)." + ), + ) + + @libra_api_router.post("/api/v1/admin/accounts", status_code=HTTPStatus.CREATED) async def api_admin_add_chart_account( payload: CreateChartAccount, @@ -3685,6 +3731,8 @@ async def api_admin_add_chart_account( ), ) + _validate_account_name(payload.name) + logger.info( f"Admin {auth.user_id[:8]} adding chart account {payload.name} " f"with currencies {payload.currencies}" From 87a45ee4d5c25d05e8454b9981777cae9265c8e2 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 16 Jun 2026 23:25:27 +0200 Subject: [PATCH 35/40] test(harness): split-layout ledger + disable rate limiter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test harness was never updated to the post-server-deploy#4 split ledger layout, so libra's per-user account opens (routed to accounts/users.beancount by fava_client._infer_target_file) 500'd as a 'non-source file' and fell back to DB-only — breaking the balance test and contributing to settlement errors. Make the harness ledger a faithful split (root includes accounts/chart.beancount + accounts/users.beancount; title stays in root so the slug still matches). Also raise lnbits_rate_limit_no for the session: the full suite fires >200 req/min and the default limiter 429'd fixture setup intermittently (10-11 errors). The limiter is built once at app creation, so setting it in the session settings fixture (before the app fixture) disables it suite-wide. Net: full suite goes from 1 failed / ~10 errors to fully green. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/conftest.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 6698018..44b5c26 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -108,6 +108,9 @@ 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") @@ -133,6 +136,12 @@ def settings() -> Iterator[Settings]: lnbits_settings.lnbits_admin_ui = True lnbits_settings.lnbits_extensions_default_install = [] lnbits_settings.lnbits_extensions_deactivate_all = False + # The full suite fires >200 requests/minute; the default rate limit (200/min) + # otherwise 429s fixture setup intermittently. The limiter is built once at + # app creation from this value (lnbits/app.py register_new_ratelimiter), and + # this fixture runs before the `app` fixture, so raising it here disables it + # for the session. + lnbits_settings.lnbits_rate_limit_no = 1_000_000 yield lnbits_settings @@ -170,13 +179,32 @@ option "render_commas" "TRUE" 2020-01-01 open Equity:Opening-Balances EUR,SATS 2020-01-01 open Income:Generic EUR,SATS 2020-01-01 open Expenses:Generic EUR,SATS + +include "accounts/chart.beancount" +include "accounts/users.beancount" """ +# Split-layout include targets, mirroring the production fava layout +# (aiolabs/server-deploy#4). libra's fava_client routes Open directives by +# account name (fava_client._infer_target_file): per-user accounts +# (:User-xxxxxxxx) to accounts/users.beancount, everything else to +# accounts/chart.beancount. Both must exist as Fava *source* files (i.e. be +# included) or /api/source writes 500 with "non-source file". The title stays +# in the root ledger above so Fava's slug still matches LEDGER_SLUG (scalar +# options don't propagate from includes — see aiolabs/server-deploy#9). +CHART_SEED = "; Admin-mutable chart of accounts (libra appends Open directives).\n" +USERS_SEED = "; Per-user account opens (libra appends at signup).\n" + @pytest.fixture(scope="session") def fava_ledger_path(tmp_path_factory: pytest.TempPathFactory) -> Path: - """Session-scoped .beancount file Fava reads from.""" + """Session-scoped split ledger Fava reads from: a root file that includes + accounts/chart.beancount (admin add-account target) and + accounts/users.beancount (per-user opens target).""" ledger_dir = tmp_path_factory.mktemp("libra-ledger") + (ledger_dir / "accounts").mkdir() + (ledger_dir / "accounts" / "chart.beancount").write_text(CHART_SEED) + (ledger_dir / "accounts" / "users.beancount").write_text(USERS_SEED) ledger = ledger_dir / f"{LEDGER_SLUG}.beancount" ledger.write_text(MINIMAL_LEDGER) return ledger From 89f0f8ac3a8cee2014ef502771a8757791f46f89 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 16 Jun 2026 23:25:27 +0200 Subject: [PATCH 36/40] test(accounts): cover admin add-account endpoint 10 integration tests for POST /api/v1/admin/accounts: unconstrained Open write + escaped description metadata, explicit-currency path, duplicate->409, invalid-prefix->400, invalid-characters->400 (parametrized), super-user-only ->403. Adds the add_chart_account helper. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/helpers.py | 22 +++- tests/test_admin_chart_accounts_api.py | 144 +++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 tests/test_admin_chart_accounts_api.py diff --git a/tests/helpers.py b/tests/helpers.py index 80ad343..02d8f78 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -13,7 +13,7 @@ separate ISO code field — this matches `models.ExpenseEntry` / `ReceivableEntr from decimal import Decimal from typing import Any, Optional, Union -from httpx import AsyncClient +from httpx import AsyncClient, Response Amount = Union[Decimal, int, float, str] @@ -106,6 +106,26 @@ async def grant_permission( return r.json() +async def add_chart_account( + client: AsyncClient, + *, + super_user_headers: dict, + name: str, + description: Optional[str] = None, +) -> Response: + """Super user adds a chart-of-accounts entry via the admin endpoint + (POST /api/v1/admin/accounts). Returns the raw Response so callers can + assert on status codes (201 / 400 / 409 / 403).""" + body: dict[str, Any] = {"name": name} + if description is not None: + body["description"] = description + return await client.post( + "/libra/api/v1/admin/accounts", + headers=super_user_headers, + json=body, + ) + + # --------------------------------------------------------------------------- # Entries — user side # --------------------------------------------------------------------------- diff --git a/tests/test_admin_chart_accounts_api.py b/tests/test_admin_chart_accounts_api.py new file mode 100644 index 0000000..1f574f9 --- /dev/null +++ b/tests/test_admin_chart_accounts_api.py @@ -0,0 +1,144 @@ +"""Admin chart-of-accounts endpoint — POST /api/v1/admin/accounts. + +Covers the endpoint wired into the UI's "Add Account" dialog: + + - Writes an Open directive to accounts/chart.beancount via Fava /api/source, + *unconstrained* by currency (the directive needs no currency list), with + provenance + description metadata (escaped for Beancount). + - Mirrors the account into libra's DB (synced_to_libra_db). + - Rejects duplicates with 409, malformed names with 400, and non-super-users + with 403. + +The harness ledger is the split layout (root includes accounts/chart.beancount) +so the endpoint's hardcoded target_file resolves — see conftest.CHART_SEED. +""" +import re +from pathlib import Path +from uuid import uuid4 + +import pytest + +from .helpers import add_chart_account + + +def _chart_text(fava_ledger_path: Path) -> str: + return (fava_ledger_path.parent / "accounts" / "chart.beancount").read_text() + + +def _unique(prefix: str = "Expenses:Test") -> str: + # Capitalized leaf (valid Beancount component) unique per call so the + # session-scoped ledger doesn't collide across tests. + return f"{prefix}:T{uuid4().hex[:8].upper()}" + + +@pytest.mark.anyio +async def test_add_chart_account_writes_unconstrained_open_with_escaped_meta( + client, super_user_headers, fava_ledger_path, +): + """Happy path: 201, the Open directive carries no currency constraint, the + description metadata is escaped, and the account is synced into libra's DB.""" + name = _unique() + r = await add_chart_account( + client, + super_user_headers=super_user_headers, + name=name, + description='has a "quote" and ok', + ) + assert r.status_code == 201, f"expected 201, got {r.status_code}: {r.text}" + body = r.json() + assert body["account_name"] == name + assert body["synced_to_libra_db"] is True + + chart = _chart_text(fava_ledger_path) + # Open present and UNCONSTRAINED: the account name is followed directly by + # end-of-line, not " EUR, SATS, USD". + assert re.search(rf"^\d{{4}}-\d{{2}}-\d{{2}} open {re.escape(name)}$", chart, re.MULTILINE), ( + f"expected an unconstrained Open for {name}, chart was:\n{chart}" + ) + # Description metadata is escaped so the quote can't break the ledger. + assert r'description: "has a \"quote\" and ok"' in chart + assert 'source: "admin-ui"' in chart + + +@pytest.mark.anyio +async def test_add_chart_account_with_explicit_currencies_constrains_open( + client, super_user_headers, fava_ledger_path, +): + """API callers may still pass an explicit currency constraint (the UI never + does). When provided, it lands on the Open directive.""" + name = _unique() + r = await client.post( + "/libra/api/v1/admin/accounts", + headers=super_user_headers, + json={"name": name, "currencies": ["EUR", "SATS"]}, + ) + assert r.status_code == 201, f"expected 201, got {r.status_code}: {r.text}" + chart = _chart_text(fava_ledger_path) + assert re.search(rf"open {re.escape(name)} EUR, SATS$", chart, re.MULTILINE), ( + f"expected a currency-constrained Open for {name}, chart was:\n{chart}" + ) + + +@pytest.mark.anyio +async def test_add_chart_account_duplicate_returns_409( + client, super_user_headers, +): + """Adding the same account twice: first 201, second 409 (not a false success).""" + name = _unique() + first = await add_chart_account(client, super_user_headers=super_user_headers, name=name) + assert first.status_code == 201, f"first add: {first.status_code} {first.text}" + + second = await add_chart_account(client, super_user_headers=super_user_headers, name=name) + assert second.status_code == 409, f"expected 409, got {second.status_code}: {second.text}" + assert "already exists" in second.json().get("detail", "").lower() + + +@pytest.mark.anyio +async def test_add_chart_account_invalid_prefix_returns_400( + client, super_user_headers, fava_ledger_path, +): + """A root outside the five valid types is rejected and never written.""" + before = _chart_text(fava_ledger_path) + r = await add_chart_account(client, super_user_headers=super_user_headers, name="Foo:Bar") + assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}" + assert _chart_text(fava_ledger_path) == before, "rejected account must not be written" + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "bad_name", + [ + "Expenses:Foo Bar", # space + "Expenses:foo", # lowercase sub-component start + "Expenses:Foo!", # punctuation + "Expenses:", # no sub-account + "Expenses:Foo::Bar", # empty component + ], +) +async def test_add_chart_account_invalid_characters_returns_400( + client, super_user_headers, fava_ledger_path, bad_name, +): + """Malformed account names are rejected server-side (the UI guard can be + bypassed via the API) and never reach the ledger.""" + before = _chart_text(fava_ledger_path) + r = await add_chart_account(client, super_user_headers=super_user_headers, name=bad_name) + assert r.status_code == 400, f"expected 400 for {bad_name!r}, got {r.status_code}: {r.text}" + assert _chart_text(fava_ledger_path) == before, "rejected account must not be written" + + +@pytest.mark.anyio +async def test_add_chart_account_requires_super_user( + client, configured_user, fava_ledger_path, +): + """A regular user's wallet admin-key passes require_admin_key but fails the + super-user identity check → 403, nothing written.""" + _user, wallet = configured_user + name = _unique() + before = _chart_text(fava_ledger_path) + r = await client.post( + "/libra/api/v1/admin/accounts", + headers={"X-Api-Key": wallet.adminkey, "Content-type": "application/json"}, + json={"name": name}, + ) + assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}" + assert _chart_text(fava_ledger_path) == before, "unauthorized add must not be written" From 0ea96cd38439ac202a244a69b07b1609863ef76d Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 17 Jun 2026 10:06:28 +0200 Subject: [PATCH 37/40] fix(accounts): anchor duplicate-account detection to a real Open directive (libra-#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existence check matched 'open ' anywhere in the chart source, so a prior account's description metadata or a comment mentioning the name produced a false 409, while a real directive with an inline comment and no space ('open X;legacy') was missed → a duplicate Open was appended and bean-check then rejected the file, breaking every later /api/source write. Extract the check into a pure _open_directive_exists() anchored to '^YYYY-MM-DD open ' with an account-boundary negative-lookahead, and unit-test both failure directions plus prefix/child non-matches. Co-Authored-By: Claude Opus 4.8 (1M context) --- fava_client.py | 36 ++++++++++++++++++++++++--------- tests/test_unit.py | 50 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/fava_client.py b/fava_client.py index f176031..053ed0d 100644 --- a/fava_client.py +++ b/fava_client.py @@ -60,6 +60,30 @@ def _escape_beancount_string(value: str) -> str: ) +def _open_directive_exists(source: str, account_name: str) -> bool: + """Return True if `source` already contains an Open directive for exactly + `account_name`. + + Anchored to a real `YYYY-MM-DD open ` directive line (re.MULTILINE) + so the account name can't match text inside another account's description + metadata or a comment (false positive → spurious 409). The trailing + negative-lookahead `(?![\\w:-])` requires the next char not to be an + account-continuation char, so: + - a prefix (Expenses:Gas) does not match a longer sibling + (Expenses:GasStation / Expenses:Gas:Vehicle), and + - a real directive with an inline comment and no space + (`open Expenses:Gas;legacy`) is still detected (`;` ends the name), + which the previous `(?:\\s|$)` boundary missed → duplicate write. + """ + return bool( + re.search( + rf"^\d{{4}}-\d{{2}}-\d{{2}} open {re.escape(account_name)}(?![\w:-])", + source, + re.MULTILINE, + ) + ) + + class FavaClient: """ Async client for Fava REST API. @@ -1644,15 +1668,9 @@ class FavaClient: source = source_data["source"] # Step 2: Check if account already exists (may have been - # created by a concurrent request). Anchor on the Open - # directive and require the account to be followed by - # whitespace/end-of-line so a prefix (Expenses:Gas) does - # not match a longer sibling (Expenses:GasStation). - if re.search( - rf"open {re.escape(account_name)}(?:\s|$)", - source, - re.MULTILINE, - ): + # created by a concurrent request). See + # _open_directive_exists for the anchoring rationale. + if _open_directive_exists(source, account_name): logger.info(f"Account {account_name} already exists in {target_file}") return { "data": sha256sum, diff --git a/tests/test_unit.py b/tests/test_unit.py index 2fa41ec..4f97b03 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -31,9 +31,59 @@ bf = _module("beancount_format") au = _module("account_utils") val = _module("core.validation") mdl = _module("models") +fc = _module("fava_client") AccountType = mdl.AccountType +# --------------------------------------------------------------------------- +# fava_client._open_directive_exists — duplicate-account detection +# --------------------------------------------------------------------------- + + +def test_open_directive_exists_matches_real_directive(): + src = "2020-01-01 open Expenses:Vehicle:Gas\n" + assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is True + + +def test_open_directive_exists_matches_currency_constrained_and_metadata(): + src = ( + "2020-01-01 open Expenses:Vehicle:Gas EUR, SATS\n" + ' added_by: "abc"\n' + ) + assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is True + + +def test_open_directive_exists_matches_inline_comment_without_space(): + # Valid Beancount: the account token ends at ';'. The old (?:\\s|$) boundary + # missed this → duplicate Open written → bean-check breaks. + src = "2020-01-01 open Expenses:Vehicle:Gas;legacy-import\n" + assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is True + + +def test_open_directive_exists_ignores_name_inside_description(): + # The name appears only inside another account's description metadata. + src = ( + "2020-01-01 open Expenses:Notes\n" + ' description: "remember to open Expenses:Vehicle:Gas next month"\n' + ) + assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False + + +def test_open_directive_exists_ignores_comment_line(): + src = "; TODO: open Expenses:Vehicle:Gas eventually\n" + assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False + + +def test_open_directive_exists_does_not_match_longer_sibling(): + src = "2020-01-01 open Expenses:Vehicle:GasStation\n" + assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False + + +def test_open_directive_exists_does_not_match_deeper_child(): + src = "2020-01-01 open Expenses:Vehicle:Gas:Premium\n" + assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False + + # --------------------------------------------------------------------------- # beancount_format.sanitize_link # --------------------------------------------------------------------------- From 26eb9d457979fcf631f12ec4d271c867471e5f1b Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 17 Jun 2026 10:06:57 +0200 Subject: [PATCH 38/40] fix(accounts): don't currency-constrain per-user account opens (libra-#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_or_create_user_account opened per-user receivable/payable accounts constrained to EUR/SATS/USD, so a posting in any other currency tripped 'Invalid currency CAD/GBP/JPY for account Assets:Receivable:User-…' at bean-check — the exact errors the optional-currencies work set out to fix, which had only reached the admin chart-account path. Open user accounts unconstrained (currencies=None) so they hold arbitrary fiat. Co-Authored-By: Claude Opus 4.8 (1M context) --- crud.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crud.py b/crud.py index b2b43dd..0692806 100644 --- a/crud.py +++ b/crud.py @@ -250,9 +250,13 @@ async def get_or_create_user_account( if not fava_account_exists: # Create account in Fava/Beancount via Open directive logger.info(f"[FAVA CREATE] Creating account in Fava: {account_name}") + # Unconstrained Open: a per-user receivable/payable legitimately + # holds arbitrary fiat (CAD/GBP/JPY/…). Constraining it to + # EUR/SATS/USD made any posting in another currency fail + # bean-check (the errors this account path originally exhibited). await fava.add_account( account_name=account_name, - currencies=["EUR", "SATS", "USD"], # Support common currencies + currencies=None, metadata={ "user_id": user_id, "description": f"User-specific {account_type.value} account" From 39440b75a7f3a076a522dfa3277c24b66999d20d Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 17 Jun 2026 10:08:47 +0200 Subject: [PATCH 39/40] fix(accounts): recover ledger-only account instead of blanket 409 (libra-#50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When add_account reported the Open already existed, the endpoint raised 409 before the DB-mirror step — so an account present in the ledger but missing from libra's DB (a prior sync failure with no cross-DB atomicity, or an out-of-band open) was stranded: invisible to permissions with no recovery path. Now 409 only when the account is already in the DB too; otherwise sync it and return success. Adds a recovery test. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_admin_chart_accounts_api.py | 26 +++++++++++++++++++++++++ views_api.py | 27 +++++++++++++++++++++----- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/tests/test_admin_chart_accounts_api.py b/tests/test_admin_chart_accounts_api.py index 1f574f9..023835f 100644 --- a/tests/test_admin_chart_accounts_api.py +++ b/tests/test_admin_chart_accounts_api.py @@ -93,6 +93,32 @@ async def test_add_chart_account_duplicate_returns_409( assert "already exists" in second.json().get("detail", "").lower() +@pytest.mark.anyio +async def test_add_chart_account_recovers_ledger_only_account( + client, super_user_headers, +): + """An account present in the ledger but absent from libra's DB (prior sync + failure / out-of-band edit) is recovered (synced), not 409'd — otherwise it + would be permanently un-grantable with no path back. + + Reproduce the ledger-only state by creating normally (so Fava parses the + Open) then deleting only the libra-DB row — appending to the ledger file + directly would race Fava's parse cache.""" + from ..crud import db # the same singleton the app uses + + name = _unique("Expenses:Recover") + first = await add_chart_account(client, super_user_headers=super_user_headers, name=name) + assert first.status_code == 201, f"setup create failed: {first.status_code} {first.text}" + + await db.execute("DELETE FROM accounts WHERE name = :name", {"name": name}) + + r = await add_chart_account(client, super_user_headers=super_user_headers, name=name) + assert r.status_code == 201, f"expected 201 recovery, got {r.status_code}: {r.text}" + body = r.json() + assert body.get("already_existed") is True, body + assert body["synced_to_libra_db"] is True, body + + @pytest.mark.anyio async def test_add_chart_account_invalid_prefix_returns_400( client, super_user_headers, fava_ledger_path, diff --git a/views_api.py b/views_api.py index 3e8647a..1b3149a 100644 --- a/views_api.py +++ b/views_api.py @@ -3750,14 +3750,31 @@ async def api_admin_add_chart_account( metadata=metadata, ) + from .account_sync import sync_single_account_from_beancount + if result.get("already_existed"): - raise HTTPException( - status_code=HTTPStatus.CONFLICT, - detail=f"Account {payload.name} already exists", - ) + # The Open directive is already in the ledger. If it's also already + # mirrored into libra's DB, it's a true duplicate → 409. If not (a prior + # sync failed — there's no cross-DB atomicity — or it was opened out of + # band), mirror it now so it becomes grantable instead of being stranded + # with no recovery path. + from .crud import get_account_by_name + + if await get_account_by_name(payload.name) is not None: + raise HTTPException( + status_code=HTTPStatus.CONFLICT, + detail=f"Account {payload.name} already exists", + ) + + synced = await sync_single_account_from_beancount(payload.name) + return { + "success": True, + "account_name": payload.name, + "synced_to_libra_db": synced, + "already_existed": True, + } # Mirror into libra DB so permissions / metadata layer sees it. - from .account_sync import sync_single_account_from_beancount synced = await sync_single_account_from_beancount(payload.name) return { From 3adb3d356af693d2ec5994c1aec0d0388a346cb1 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 17 Jun 2026 10:27:18 +0200 Subject: [PATCH 40/40] fix(accounts): match Beancount's DATE grammar in duplicate detection (libra-#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _open_directive_exists hardcoded '^YYYY-MM-DD open ' (dash-only, 2-digit, single-space), but Beancount's DATE token (parser/lexer.l) is (17|18|19|20)[0-9]{2}[-/][0-9]+[-/][0-9]+ and inter-token whitespace is any [ \t\r] run. So a validly-formatted existing Open written as '2024/3/5 open X' or '2020-01-01 open X' escaped detection → duplicate Open appended → bean-check rejects the file. Anchor on Beancount's actual date pattern and [ \t]+ separators. Adds parametrized coverage for slash/single-digit/multi- space/tab variants. Found in a coherence pass over the Beancount source. Co-Authored-By: Claude Opus 4.8 (1M context) --- fava_client.py | 17 +++++++++++++---- tests/test_unit.py | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/fava_client.py b/fava_client.py index 053ed0d..eaed06b 100644 --- a/fava_client.py +++ b/fava_client.py @@ -60,11 +60,21 @@ def _escape_beancount_string(value: str) -> str: ) +# Beancount's DATE token (parser/lexer.l): (17|18|19|20)[0-9]{2}[-/][0-9]+[-/][0-9]+ +# — '-' OR '/' separators, 1+ digit month/day. Inter-token whitespace is any +# run of [ \t\r] (ignored by the lexer). The duplicate-detection regex must +# mirror this, or a validly-formatted existing Open (e.g. '2024/3/5 open X' or +# '2020-01-01 open X') escapes detection and a duplicate Open is appended, +# which bean-check then rejects — breaking every later write. +_OPEN_DATE = r"(?:17|18|19|20)\d\d[-/]\d+[-/]\d+" + + def _open_directive_exists(source: str, account_name: str) -> bool: """Return True if `source` already contains an Open directive for exactly `account_name`. - Anchored to a real `YYYY-MM-DD open ` directive line (re.MULTILINE) + Anchored to a real ` open ` directive line (re.MULTILINE), + with `` and the inter-token whitespace matching Beancount's grammar, so the account name can't match text inside another account's description metadata or a comment (false positive → spurious 409). The trailing negative-lookahead `(?![\\w:-])` requires the next char not to be an @@ -72,12 +82,11 @@ def _open_directive_exists(source: str, account_name: str) -> bool: - a prefix (Expenses:Gas) does not match a longer sibling (Expenses:GasStation / Expenses:Gas:Vehicle), and - a real directive with an inline comment and no space - (`open Expenses:Gas;legacy`) is still detected (`;` ends the name), - which the previous `(?:\\s|$)` boundary missed → duplicate write. + (`open Expenses:Gas;legacy`) is still detected (`;` ends the name). """ return bool( re.search( - rf"^\d{{4}}-\d{{2}}-\d{{2}} open {re.escape(account_name)}(?![\w:-])", + rf"^{_OPEN_DATE}[ \t]+open[ \t]+{re.escape(account_name)}(?![\w:-])", source, re.MULTILINE, ) diff --git a/tests/test_unit.py b/tests/test_unit.py index 4f97b03..1c7dabc 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -84,6 +84,22 @@ def test_open_directive_exists_does_not_match_deeper_child(): assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False +@pytest.mark.parametrize( + "line", + [ + "2024/3/5 open Expenses:Vehicle:Gas", # slash date, single-digit M/D + "2020-1-1 open Expenses:Vehicle:Gas", # dash date, single-digit M/D + "2020-01-01 open Expenses:Vehicle:Gas", # multiple spaces + "2020-01-01\topen\tExpenses:Vehicle:Gas", # tab separators + "1970-01-01 open Expenses:Vehicle:Gas EUR", # currency-constrained + ], +) +def test_open_directive_exists_matches_beancount_date_and_whitespace_variants(line): + # All of these are valid Beancount Open directives per lexer.l's DATE token + # and ignored inter-token whitespace; each must be detected as existing. + assert fc._open_directive_exists(line + "\n", "Expenses:Vehicle:Gas") is True + + # --------------------------------------------------------------------------- # beancount_format.sanitize_link # ---------------------------------------------------------------------------