diff --git a/CLAUDE.md b/CLAUDE.md index 3086441..97e546f 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,23 +44,19 @@ 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. +**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**: 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 +66,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 +97,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 +123,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 +144,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 +209,8 @@ entry = format_transaction( {"account": "Liabilities:Payable:User-abc123", "amount": "-50000 SATS"} ], tags=["groceries"], - links=["castle-entry-123"] + links=["exp-a1b2c3d4e5f60708"], # typed settlement link; identity goes in entry-id metadata + meta={"entry-id": "a1b2c3d4e5f60708"} ) # Submit to Fava @@ -221,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 @@ -241,15 +240,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 +281,8 @@ entry = format_transaction( {"account": "Assets:Lightning:Balance", "amount": "-50000 SATS"} ], tags=["utilities"], - links=["castle-tx-123"] + links=["exp-0123456789abcdef"], + meta={"entry-id": "0123456789abcdef"} ) client = get_fava_client() @@ -310,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 @@ -337,24 +344,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 +370,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..4f03cd3 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 collective 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 organization owns (Cash, Bank, Accounts Receivable) +- **Liabilities**: What the organization 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..3d82381 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,13 +318,22 @@ 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) + # 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( @@ -343,9 +352,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 +364,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 +376,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..8520d59 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 organization"), ("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 organization"), # 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..f233ee5 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, @@ -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, @@ -812,10 +945,11 @@ 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 (castle receives payment directly). + Format a revenue entry (libra receives payment directly). Creates a cleared transaction (flag="*") since payment was received. @@ -829,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 @@ -845,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 @@ -869,12 +1007,13 @@ def format_revenue_entry( # Note: created-via is redundant with #revenue-entry tag entry_meta = { - "source": "castle-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, @@ -885,3 +1024,68 @@ def format_revenue_entry( links=links, meta=entry_meta ) + + +def format_income_entry( + user_id: str, + user_account: str, + revenue_account: str, + amount_sats: int, + description: str, + entry_date: date, + fiat_currency: str, + fiat_amount: Decimal, + reference: Optional[str] = None, + entry_id: Optional[str] = None, +) -> Dict[str, Any]: + """ + Format a user-submitted income/revenue entry for Fava (pending approval). + + Mirrors format_expense_entry: pending flag (!) for super-user review, + fiat-first price notation (@@ SATS) for BQL queryability, unique link + (^inc-{entry_id}) for tracking through the approve/reject flow. + + Postings: DR user_account (Assets:Receivable:User-{id} — user owes + the entity until they hand the cash over), CR revenue_account. + """ + if not fiat_currency or not fiat_amount or fiat_amount <= 0: + raise ValueError("fiat_currency and a positive fiat_amount are required for income entries") + + if not entry_id: + entry_id = generate_entry_id() + + fiat_amount_abs = abs(fiat_amount) + sats_abs = abs(amount_sats) + + narration = f"{description} ({fiat_amount_abs:.2f} {fiat_currency})" + + postings = [ + { + "account": user_account, + "amount": f"{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS", + }, + { + "account": revenue_account, + "amount": f"-{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS", + }, + ] + + entry_meta = { + "user-id": user_id, + "source": "libra-api", + "entry-id": entry_id, + } + + links = [f"inc-{entry_id}"] + if reference: + links.append(sanitize_link(reference)) + + return format_transaction( + date_val=entry_date, + flag="!", # Pending - requires admin approval + narration=narration, + postings=postings, + tags=["income-entry"], + links=links, + meta=entry_meta, + ) 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..0692806 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 @@ -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" @@ -262,23 +266,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 +293,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 +308,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 +319,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 +344,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 +355,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 +379,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..d609551 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 organization (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 organization - **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 collective: - 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 with the collective 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 organization 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..58c9e14 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 collectives like co-living spaces, makerspaces, and community projects. It tracks financial relationships between a central organization and its members, 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 organization ### Liabilities -- `accounts_payable` - Money owed by the Castle +- `accounts_payable` - Money owed by the organization ### 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) @@ -1210,4 +1210,4 @@ The Castle Accounting extension provides a solid foundation for double-entry boo 5. Equity management features 6. External system integrations (accounting software, tax tools) -The refactoring path is clear: prioritize data integrity, then add reporting/export, then enhance with advanced features. The system is production-ready for basic use cases but needs the recommended enhancements for a full-featured cooperative accounting solution. +The refactoring path is clear: prioritize data integrity, then add reporting/export, then enhance with advanced features. The system is production-ready for basic use cases but needs the recommended enhancements for a full-featured collective accounting solution. diff --git a/docs/EXPENSE_APPROVAL.md b/docs/EXPENSE_APPROVAL.md index 3123b32..81305c1 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 an 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..eaed06b 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. @@ -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,68 @@ 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" + + +def _escape_beancount_string(value: str) -> str: + """Escape a value for safe inclusion in a Beancount string literal. + + Beancount's tokenizer unescapes \\", \\n, \\t, \\r, \\\\ etc. (tokens.c + cunescape). Unescaped quotes or newlines in free-text metadata written + straight into the ledger source would corrupt the file, so escape the + backslash first (to keep it round-tripping) then quotes and newlines. + """ + return ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + ) + + +# Beancount's DATE token (parser/lexer.l): (17|18|19|20)[0-9]{2}[-/][0-9]+[-/][0-9]+ +# — '-' OR '/' separators, 1+ digit month/day. Inter-token whitespace is any +# run of [ \t\r] (ignored by the lexer). The duplicate-detection regex must +# mirror this, or a validly-formatted existing Open (e.g. '2024/3/5 open X' or +# '2020-01-01 open X') escapes detection and a duplicate Open is appended, +# which bean-check then rejects — breaking every later write. +_OPEN_DATE = r"(?:17|18|19|20)\d\d[-/]\d+[-/]\d+" + + +def _open_directive_exists(source: str, account_name: str) -> bool: + """Return True if `source` already contains an Open directive for exactly + `account_name`. + + Anchored to a real ` 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 + account-continuation char, so: + - a prefix (Expenses:Gas) does not match a longer sibling + (Expenses:GasStation / Expenses:Gas:Vehicle), and + - a real directive with an inline comment and no space + (`open Expenses:Gas;legacy`) is still detected (`;` ends the name). + """ + return bool( + re.search( + rf"^{_OPEN_DATE}[ \t]+open[ \t]+{re.escape(account_name)}(?![\w:-])", + source, + re.MULTILINE, + ) + ) + + class FavaClient: """ Async client for Fava REST API. @@ -46,7 +109,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('/') @@ -66,6 +129,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. @@ -169,7 +272,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 +392,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 +779,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: @@ -766,10 +869,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 """ @@ -827,6 +935,66 @@ class FavaClient: "accounts": accounts } + async def get_user_lifetime_totals_bql(self, user_id: str) -> Dict[str, Any]: + """ + Get lifetime totals of expenses submitted and income recorded by this user. + + Sums original entries only (tag-filtered) — does not net against payments + or other reconciliation activity, so totals match "amounts ever entered". + + Args: + user_id: User ID + + Returns: + { + "total_expenses_sats": int, + "total_expenses_fiat": {"EUR": Decimal("...")}, + "total_income_sats": int, + "total_income_fiat": {"EUR": Decimal("...")}, + } + """ + from decimal import Decimal + + user_id_prefix = user_id[:8] + + async def _sum_for(account_pattern: str, tag: str): + query = f""" + SELECT currency, sum(number), sum(weight) + WHERE account ~ '{account_pattern}:User-{user_id_prefix}' + AND '{tag}' IN tags + AND flag = '*' + GROUP BY currency + """ + result = await self.query_bql(query) + sats_total = 0 + fiat_total: Dict[str, Decimal] = {} + for row in result["rows"]: + currency, number_sum, weight_sum = row + # Skip SATS-currency rows (payment/reconciliation legs) + if currency == "SATS": + continue + if isinstance(weight_sum, dict) and "SATS" in weight_sum: + sats_total += abs(int(Decimal(str(weight_sum["SATS"])))) + fiat_amount = abs(Decimal(str(number_sum))) if number_sum else Decimal(0) + if fiat_amount > 0: + fiat_total[currency] = fiat_total.get(currency, Decimal(0)) + fiat_amount + return sats_total, fiat_total + + exp_sats, exp_fiat = await _sum_for("Liabilities:Payable", "expense-entry") + inc_sats, inc_fiat = await _sum_for("Assets:Receivable", "income-entry") + + logger.info( + f"User {user_id[:8]} lifetime totals (BQL): " + f"expenses={exp_sats} sats {dict(exp_fiat)}, income={inc_sats} sats {dict(inc_fiat)}" + ) + + return { + "total_expenses_sats": exp_sats, + "total_expenses_fiat": exp_fiat, + "total_income_sats": inc_sats, + "total_income_fiat": inc_fiat, + } + async def get_all_user_balances_bql(self) -> List[Dict[str, Any]]: """ Get balances for all users using BQL with currency-grouped aggregation. @@ -856,10 +1024,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 """ @@ -1031,7 +1200,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: @@ -1424,16 +1593,23 @@ 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, 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 @@ -1446,6 +1622,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: @@ -1455,17 +1633,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 @@ -1473,6 +1652,12 @@ class FavaClient: if opening_date is None: opening_date = date_type.today() + 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): @@ -1480,18 +1665,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"] @@ -1499,47 +1676,56 @@ class FavaClient: sha256sum = source_data["sha256sum"] source = source_data["source"] - # Step 3: 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") - return {"data": sha256sum, "mtime": source_data.get("mtime", "")} + # Step 2: Check if account already exists (may have been + # created by a concurrent request). See + # _open_directive_exists for the anchoring rationale. + if _open_directive_exists(source, account_name): + logger.info(f"Account {account_name} already exists in {target_file}") + return { + "data": sha256sum, + "mtime": source_data.get("mtime", ""), + "already_existed": True, + } - # Step 4: Find insertion point (after last Open directive AND its metadata) + # 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 = 0 - 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 + insert_index = len(lines) - # Step 5: 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}') - # 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 } @@ -1552,8 +1738,8 @@ class FavaClient: response.raise_for_status() result = response.json() - logger.info(f"Added account {account_name} to Beancount file with currencies {currencies}") - return result + logger.info(f"Added account {account_name} to {target_file} with currencies {currencies}") + return {**result, "already_existed": False} except httpx.HTTPStatusError as e: # Check for checksum conflict (HTTP 412 Precondition Failed or similar) @@ -1601,8 +1787,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 +1928,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: @@ -1867,6 +2053,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): @@ -1880,9 +2070,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. @@ -1896,6 +2098,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..6c5c0e2 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..ff874c6 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..9c38c55 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""" @@ -240,7 +240,7 @@ async def m001_initial(db): # ACCOUNT PERMISSIONS TABLE # ========================================================================= # Granular access control for accounts - # Permission types: read, submit_expense, manage + # Permission types: read, submit_expense, submit_income, manage # Supports hierarchical inheritance (parent account permissions cascade) await db.execute( @@ -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..c8632d0 100644 --- a/models.py +++ b/models.py @@ -48,6 +48,17 @@ 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" + # 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 + + class EntryLine(BaseModel): id: str journal_entry_id: str @@ -87,9 +98,19 @@ 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] = [] + # 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 + total_expenses_fiat: dict[str, Decimal] = {} + total_income_sats: int = 0 + total_income_fiat: dict[str, Decimal] = {} class ExpenseEntry(BaseModel): @@ -98,7 +119,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 +132,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 +148,32 @@ 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 IncomeEntry(BaseModel): + """Helper model for user-facing income/revenue submission (pending approval). - castle_wallet_id: Optional[str] = None # The wallet ID that represents the Castle + The user records that they personally received money on the entity's + behalf — so the postings are DR Assets:Receivable:User-{id} / CR + revenue_account. The user now owes the entity until they settle via + the existing /settle-receivable flow. Symmetric with ExpenseEntry, + which credits Liabilities:Payable:User-{id} (entity owes user). + """ + + description: str + amount: Decimal # Fiat amount in the specified currency + revenue_account: str # Income/Revenue account name or ID + currency: str # Required: fiat currency code (EUR, USD, etc.) + reference: Optional[str] = None + entry_date: Optional[datetime] = None + + +class LibraSettings(BaseModel): + """Settings for the Libra extension""" + + 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 +183,7 @@ class CastleSettings(BaseModel): return True -class UserCastleSettings(CastleSettings): +class UserLibraSettings(LibraSettings): """User-specific settings (stored with user_id)""" id: str @@ -164,7 +203,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 +212,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 +237,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 +252,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) @@ -295,6 +334,7 @@ class PermissionType(str, Enum): """Types of permissions for account access""" READ = "read" # Can view account and its balance SUBMIT_EXPENSE = "submit_expense" # Can submit expenses to this account + SUBMIT_INCOME = "submit_income" # Can submit income/revenue to this account MANAGE = "manage" # Can modify account (admin level) 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..6f451f7 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 }, @@ -69,6 +69,13 @@ window.app = Vue.createApp({ userWalletId: '', loading: false }, + addAccountDialog: { + show: false, + rootType: 'Expenses', + subPath: '', + description: '', + loading: false + }, receivableDialog: { show: false, selectedUser: '', @@ -208,8 +215,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' } ] }, @@ -286,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 => { @@ -318,7 +335,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 +358,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 +406,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 +475,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 +489,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 +501,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 +513,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 +527,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 +548,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 +562,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 @@ -566,10 +583,59 @@ window.app = Vue.createApp({ this.syncingAccounts = false } }, + showAddAccountDialog() { + this.addAccountDialog.rootType = 'Expenses' + this.addAccountDialog.subPath = '' + this.addAccountDialog.description = '' + this.addAccountDialog.show = true + }, + async submitAddAccount() { + const name = this.addAccountFullName + if (!name) { + this.$q.notify({type: 'warning', message: 'Enter a sub-account name'}) + return + } + // Each segment under the root must be a valid Beancount account + // component (core/account.py ACC_COMP_NAME_RE): starts with an uppercase + // letter or digit, then letters/digits/hyphens (Unicode letters allowed). + const badSegment = name.split(':').slice(1).find( + seg => !/^[\p{Lu}\p{Nd}][\p{L}\p{Nd}-]*$/u.test(seg) + ) + if (badSegment !== undefined) { + this.$q.notify({ + type: 'warning', + message: `Invalid segment "${badSegment}" — letters, digits and hyphens only, starting with a capital letter or digit` + }) + return + } + this.addAccountDialog.loading = true + try { + const {data} = await LNbits.api.request( + 'POST', + '/libra/api/v1/admin/accounts', + this.g.user.wallets[0].adminkey, + { + name, + description: this.addAccountDialog.description || null + } + ) + this.$q.notify({ + type: 'positive', + message: `Account ${data.account_name} created` + + (data.synced_to_libra_db ? '' : ' (sync pending)') + }) + this.addAccountDialog.show = false + await this.loadAccounts() + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.addAccountDialog.loading = false + } + }, showSettingsDialog() { - this.settingsDialog.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 +644,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 +664,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 +679,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 +702,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 +725,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 +762,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 +811,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 +854,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 +872,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 +897,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 +921,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 +933,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 +951,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 +967,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 +986,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 +1005,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 +1031,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 +1080,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 +1099,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 +1128,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 +1142,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 +1155,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 +1209,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 +1252,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 +1268,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 +1320,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 +1450,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 +1474,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 +1482,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 +1514,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 +1546,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 +1564,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 +1579,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 +1645,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 +1672,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 @@ -1642,6 +1708,34 @@ window.app = Vue.createApp({ formatSats(amount) { return new Intl.NumberFormat().format(amount) }, + isIncomeEntry(entry) { + return Array.isArray(entry.tags) && entry.tags.includes('income-entry') + }, + // Per-currency split for multi-currency balances. Sign convention from the + // super-user perspective: positive fiat = user owes Libra (Receivable), + // negative fiat = Libra owes user (Payable). Distinct currencies can't be + // netted across each other (no spot rate), so we render them grouped by + // direction instead of one collapsed label. + owesYouFiat(fiatBalances) { + if (!fiatBalances) return {} + return Object.fromEntries( + Object.entries(fiatBalances).filter(([_, amount]) => Number(amount) > 0.005) + ) + }, + youOweFiat(fiatBalances) { + if (!fiatBalances) return {} + return Object.fromEntries( + Object.entries(fiatBalances) + .filter(([_, amount]) => Number(amount) < -0.005) + .map(([cur, amount]) => [cur, Math.abs(Number(amount))]) + ) + }, + hasOwesYouFiat(fiatBalances) { + return Object.keys(this.owesYouFiat(fiatBalances)).length > 0 + }, + hasYouOweFiat(fiatBalances) { + return Object.keys(this.youOweFiat(fiatBalances)).length > 0 + }, formatFiat(amount, currency) { return new Intl.NumberFormat('en-US', { style: 'currency', @@ -1663,13 +1757,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 @@ -1679,6 +1773,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/static/js/permissions.js b/static/js/permissions.js index 4cc54f0..b2a4f9d 100644 --- a/static/js/permissions.js +++ b/static/js/permissions.js @@ -53,6 +53,11 @@ window.app = Vue.createApp({ label: 'Submit Expense', description: 'Submit expenses to this account' }, + { + value: 'submit_income', + label: 'Submit Income', + description: 'Submit income/revenue entries to this account' + }, { value: 'manage', label: 'Manage', @@ -206,7 +211,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 +233,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 +256,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 +323,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 +362,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 +433,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 ) @@ -501,6 +506,8 @@ window.app = Vue.createApp({ return 'blue' case 'submit_expense': return 'green' + case 'submit_income': + return 'teal' case 'manage': return 'red' default: @@ -514,6 +521,8 @@ window.app = Vue.createApp({ return 'visibility' case 'submit_expense': return 'add_circle' + case 'submit_income': + return 'payments' case 'manage': return 'admin_panel_settings' default: @@ -535,7 +544,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 +582,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 +621,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 +664,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 +687,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 ) @@ -700,7 +709,7 @@ window.app = Vue.createApp({ } }, - editRole(role) { + async editRole(role) { this.editingRole = true this.selectedRole = role this.roleForm = { @@ -708,6 +717,28 @@ window.app = Vue.createApp({ description: role.description || '', is_default: role.is_default || false } + this.rolePermissionsForView = [] + this.roleUsersForView = [] + + try { + const response = await LNbits.api.request( + 'GET', + `/libra/api/v1/admin/roles/${role.id}`, + this.g.user.wallets[0].adminkey + ) + this.rolePermissionsForView = [...(response.data.permissions || [])] + this.roleUsersForView = [...(response.data.users || [])] + } catch (error) { + console.error('Failed to load role details:', error) + this.$q.notify({ + type: 'negative', + message: 'Failed to load role permissions', + caption: error.message || 'Unknown error', + timeout: 5000 + }) + } + + await this.$nextTick() this.showCreateRoleDialog = true }, @@ -733,7 +764,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 +778,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 +817,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 ) @@ -815,6 +846,8 @@ window.app = Vue.createApp({ this.showCreateRoleDialog = false this.editingRole = false this.selectedRole = null + this.roleUsersForView = [] + this.rolePermissionsForView = [] this.resetRoleForm() }, @@ -862,7 +895,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 +953,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 +1017,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 +1066,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 +1100,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..8ed5a33 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,31 @@ 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. + + 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". """ - logger.info("[CASTLE] Account sync background task started") + from .fava_client import wait_for_fava_client + + logger.info("[LIBRA] Account sync background task started") + await wait_for_fava_client() 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 +164,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 +180,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 +189,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 +201,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 +223,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 +253,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 +325,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 89% rename from templates/castle/index.html rename to templates/libra/index.html index 98b1625..5369f72 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 @@ - + @@ -69,18 +69,19 @@ - + -
    Pending Expense Approvals
    +
    Pending Approvals
    - - - Pending approval - - + + + {% raw %}{{ entry.description }}{% endraw %} {% raw %}{{ formatDate(entry.entry_date) }}{% endraw %} @@ -131,13 +132,13 @@ Add Expense - - Castle wallet must be configured first + + Libra wallet must be configured first - + You must configure your wallet first @@ -145,14 +146,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 organization
    @@ -186,22 +187,33 @@ @@ -257,7 +269,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 %}
    @@ -845,7 +857,20 @@ -
    Chart of Accounts
    +
    +
    Chart of Accounts
    + + +
    @@ -924,7 +949,7 @@ dense v-model="expenseDialog.isEquity" :options="[ - {label: 'Liability (Castle owes me)', value: false}, + {label: 'Liability (Libra owes me)', value: false}, {label: 'Equity (My contribution)', value: true} ]" option-label="label" @@ -932,7 +957,7 @@ emit-value map-options label="Type *" - hint="Choose whether this is a liability (Castle owes you) or an equity contribution" + hint="Choose whether this is a liability (Libra owes you) or an equity contribution" > @@ -941,9 +966,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)" >