diff --git a/CLAUDE.md b/CLAUDE.md index 97e546f..3086441 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 -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. +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. ## Architecture @@ -12,9 +12,9 @@ Libra is a double-entry bookkeeping extension for LNbits that enables collective **Double-Entry Accounting**: Every transaction affects at least two accounts. Debits must equal credits. Five account types: Assets, Liabilities, Equity, Revenue (Income), Expenses. -**Fava/Beancount Backend**: Libra now uses [Fava](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. +**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. -**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. +**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. **Pure Functional Core**: The `core/` directory contains pure accounting logic independent of the database layer: - `core/validation.py` - Entry validation rules @@ -44,19 +44,23 @@ Libra is a double-entry bookkeeping extension for LNbits that enables collective - `tasks.py` - Background tasks (invoice payment monitoring) - `account_utils.py` - Hierarchical account naming utilities - `fava_client.py` - HTTP client for Fava REST API (add_entry, query, balance_sheet) -- `beancount_format.py` - Converts Libra entries to Beancount transaction format +- `beancount_format.py` - Converts Castle entries to Beancount transaction format - `core/validation.py` - Pure validation functions for accounting rules ### Database Schema -**Fava is the sole source of truth for journal entries.** Libra does NOT maintain a local mirror of transactions — the previous `journal_entries` and `entry_lines` tables were removed during the Fava migration. All transaction reads (history, balances, summaries) go through Fava's HTTP API; writes go through `PUT /api/add_entries` and are serialized via `FavaClient._write_lock`. When retrieving journal entries from Fava for UI display, results are enriched with a `username` field from LNbits user data. +**Note**: With Fava integration, Castle maintains a local cache of some data but delegates authoritative balance calculations to Beancount/Fava. -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. +**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) -**extension_settings**: Libra wallet configuration (admin-only) -- `libra_wallet_id` - The LNbits wallet used for Libra operations +**extension_settings**: Castle wallet configuration (admin-only) +- `castle_wallet_id` - The LNbits wallet used for Castle operations - `fava_url` - Fava service URL (default: http://localhost:3333) -- `fava_ledger_slug` - Ledger identifier in Fava (default: libra-accounting) +- `fava_ledger_slug` - Ledger identifier in Fava (default: castle-accounting) - `fava_timeout` - API request timeout in seconds **user_wallet_settings**: Per-user wallet configuration @@ -66,22 +70,22 @@ The SQLite tables below hold **operational state** that Fava doesn't (and should ## Transaction Flows ### User Adds Expense (Liability) -User pays cash for groceries, Libra owes them: +User pays cash for groceries, Castle owes them: ``` DR Expenses:Food 39,669 sats CR Liabilities:Payable:User-af983632 39,669 sats ``` Metadata preserves: `{"fiat_currency": "EUR", "fiat_amount": "36.93", "fiat_rate": "1074.192"}` -### Libra Adds Receivable -User owes Libra for accommodation: +### Castle Adds Receivable +User owes Castle for accommodation: ``` DR Assets:Receivable:User-af983632 268,548 sats CR Income:Accommodation 268,548 sats ``` ### User Pays with Lightning -Invoice generated on **Libra's wallet** (not user's). After payment: +Invoice generated on **Castle's wallet** (not user's). After payment: ``` DR Assets:Lightning:Balance 268,548 sats CR Assets:Receivable:User-af983632 268,548 sats @@ -97,14 +101,14 @@ DR Liabilities:Payable:User-af983632 39,669 sats ## Balance Calculation Logic **User Balance** (calculated by Beancount via Fava): -- Positive = Libra owes user (LIABILITY accounts have credit balance) -- Negative = User owes Libra (ASSET accounts have debit balance) +- Positive = Castle owes user (LIABILITY accounts have credit balance) +- Negative = User owes Castle (ASSET accounts have debit balance) - Calculated by querying Fava for sum of all postings across user's accounts - Fiat balances calculated by Beancount from cost basis annotations, NOT converted from current sats **Perspective-Based UI**: -- **User View**: Green = Libra owes them, Red = They owe Libra -- **Libra Admin View**: Green = User owes Libra, Red = Libra owes user +- **User View**: Green = Castle owes them, Red = They owe Castle +- **Castle Admin View**: Green = User owes Castle, Red = Castle owes user **Balance Retrieval**: Use `GET /api/v1/balance` which queries Fava's balance sheet or account reports for accurate, Beancount-calculated balances. @@ -123,12 +127,12 @@ DR Liabilities:Payable:User-af983632 39,669 sats - `POST /api/v1/entries` - Create raw journal entry (admin only) ### Payments & Balances -- `GET /api/v1/balance` - Get user balance (or Libra total if super user) +- `GET /api/v1/balance` - Get user balance (or Castle total if super user) - `GET /api/v1/balances/all` - Get all user balances (admin, enriched with usernames) -- `POST /api/v1/generate-payment-invoice` - Generate invoice for user to pay Libra -- `POST /api/v1/record-payment` - Record Lightning payment from user to Libra +- `POST /api/v1/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/settle-receivable` - Manually settle receivable (cash/bank) -- `POST /api/v1/pay-user` - Libra pays user (cash/bank/lightning) +- `POST /api/v1/pay-user` - Castle pays user (cash/bank/lightning) ### Manual Payment Requests - `POST /api/v1/manual-payment-requests` - User requests payment @@ -144,8 +148,8 @@ DR Liabilities:Payable:User-af983632 39,669 sats - `POST /api/v1/tasks/daily-reconciliation` - Run daily reconciliation (admin) ### Settings -- `GET /api/v1/settings` - Get Libra settings (super user) -- `PUT /api/v1/settings` - Update Libra settings (super user) +- `GET /api/v1/settings` - Get Castle settings (super user) +- `PUT /api/v1/settings` - Update Castle settings (super user) - `GET /api/v1/user/wallet` - Get user wallet settings - `PUT /api/v1/user/wallet` - Update user wallet settings @@ -209,8 +213,7 @@ entry = format_transaction( {"account": "Liabilities:Payable:User-abc123", "amount": "-50000 SATS"} ], tags=["groceries"], - links=["exp-a1b2c3d4e5f60708"], # typed settlement link; identity goes in entry-id metadata - meta={"entry-id": "a1b2c3d4e5f60708"} + links=["castle-entry-123"] ) # Submit to Fava @@ -218,8 +221,6 @@ 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 @@ -240,15 +241,15 @@ balance_result = await client.query( ### Extension as LNbits Module This extension follows LNbits extension structure: -- Registered via `libra_ext` router in `__init__.py` +- Registered via `castle_ext` router in `__init__.py` - Static files served from `static/` directory -- Templates in `templates/libra/` -- Database accessed via `db = Database("ext_libra")` +- Templates in `templates/castle/` +- Database accessed via `db = Database("ext_castle")` **Startup Requirements**: -- `libra_start()` initializes Fava client on extension load +- `castle_start()` initializes Fava client on extension load - Background task `wait_for_paid_invoices()` monitors Lightning invoice payments -- Fava service MUST be running before starting LNbits with Libra extension +- Fava service MUST be running before starting LNbits with Castle extension ## Common Tasks @@ -281,8 +282,7 @@ entry = format_transaction( {"account": "Assets:Lightning:Balance", "amount": "-50000 SATS"} ], tags=["utilities"], - links=["exp-0123456789abcdef"], - meta={"entry-id": "0123456789abcdef"} + links=["castle-tx-123"] ) client = get_fava_client() @@ -310,13 +310,6 @@ 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 @@ -344,24 +337,24 @@ result = await client.query(query) ### Prerequisites 1. **LNbits**: This extension must be installed in the `lnbits/extensions/` directory -2. **Fava Service**: Must be running before starting LNbits with Libra enabled +2. **Fava Service**: Must be running before starting LNbits with Castle enabled ```bash # Install Fava pip install fava # Create a basic Beancount file - touch libra-ledger.beancount + touch castle-ledger.beancount # Start Fava (default: http://localhost:3333) - fava libra-ledger.beancount + fava castle-ledger.beancount ``` -3. **Configure Libra Settings**: Set `fava_url` and `fava_ledger_slug` via settings API or UI +3. **Configure Castle Settings**: Set `fava_url` and `fava_ledger_slug` via settings API or UI -### Running Libra Extension +### Running Castle Extension -Libra is loaded as part of LNbits. No separate build or test commands are needed for the extension itself. Development workflow: +Castle is loaded as part of LNbits. No separate build or test commands are needed for the extension itself. Development workflow: -1. Modify code in `lnbits/extensions/libra/` +1. Modify code in `lnbits/extensions/castle/` 2. Restart LNbits 3. Extension hot-reloads are supported by LNbits in development mode @@ -370,13 +363,13 @@ Libra is loaded as part of LNbits. No separate build or test commands are needed Use the web UI or API endpoints to create test transactions. For API testing: ```bash -# Create expense (user owes Libra) -curl -X POST http://localhost:5000/libra/api/v1/entries/expense \ +# Create expense (user owes Castle) +curl -X POST http://localhost:5000/castle/api/v1/entries/expense \ -H "X-Api-Key: YOUR_INVOICE_KEY" \ -d '{"description": "Test expense", "amount": "100.00 EUR", "account_name": "Expenses:Test"}' # Check user balance -curl http://localhost:5000/libra/api/v1/balance \ +curl http://localhost:5000/castle/api/v1/balance \ -H "X-Api-Key: YOUR_INVOICE_KEY" ``` diff --git a/MIGRATION_SQUASH_SUMMARY.md b/MIGRATION_SQUASH_SUMMARY.md index 4b03ed0..8d86b9b 100644 --- a/MIGRATION_SQUASH_SUMMARY.md +++ b/MIGRATION_SQUASH_SUMMARY.md @@ -1,11 +1,11 @@ -# Libra Migration Squash Summary +# Castle Migration Squash Summary **Date:** November 10, 2025 **Action:** Squashed 16 incremental migrations into a single clean initial migration ## Overview -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. +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. ## Files Changed @@ -16,37 +16,37 @@ The Libra extension had accumulated 16 migrations (m001-m016) during development The squashed migration creates **7 tables**: -### 1. libra_accounts +### 1. castle_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. libra_extension_settings -- Libra-wide configuration -- Stores libra_wallet_id for Lightning payments +### 2. castle_extension_settings +- Castle-wide configuration +- Stores castle_wallet_id for Lightning payments -### 3. libra_user_wallet_settings +### 3. castle_user_wallet_settings - Per-user wallet configuration - Allows users to have separate wallet preferences -### 4. libra_manual_payment_requests -- User-submitted payment requests to Libra +### 4. castle_manual_payment_requests +- User-submitted payment requests to Castle - Reviewed by admins before processing - Includes notes field for additional context -### 5. libra_balance_assertions +### 5. castle_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. libra_user_equity_status +### 6. castle_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. libra_account_permissions +### 7. castle_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): -- **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_journal_entries** - Journal entries now managed by Fava/Beancount (external source of truth) +- **castle_entry_lines** - Entry lines now managed by Fava/Beancount -Libra now uses Fava as the single source of truth for accounting data. Journal operations: +Castle 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 -# Libra's migration system will run m001_initial automatically +# Castle'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 libra.sqlite3 libra.sqlite3.backup +cp castle.sqlite3 castle.sqlite3.backup # 2. Drop and recreate database to test fresh install -rm libra.sqlite3 +rm castle.sqlite3 # 3. Start LNbits - migration should run automatically poetry run lnbits # 4. Verify tables created -sqlite3 libra.sqlite3 ".tables" -# Should show: libra_accounts, libra_extension_settings, etc. +sqlite3 castle.sqlite3 ".tables" +# Should show: castle_accounts, castle_extension_settings, etc. # 5. Verify default accounts -sqlite3 libra.sqlite3 "SELECT COUNT(*) FROM libra_accounts;" +sqlite3 castle.sqlite3 "SELECT COUNT(*) FROM castle_accounts;" # Should show: 40 (default accounts) ``` @@ -200,12 +200,12 @@ If issues are discovered: cp migrations_old.py.bak migrations.py # Restore database -cp libra.sqlite3.backup libra.sqlite3 +cp castle.sqlite3.backup castle.sqlite3 ``` ## Notes -- This squash is safe because Libra has not been released yet +- This squash is safe because Castle 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 4f03cd3..6174bfb 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# Libra Extension for LNbits +# Castle Accounting Extension for LNbits A full-featured double-entry accounting system for collective projects, integrated with LNbits Lightning payments. ## Overview -Libra enables collectives like co-living spaces, makerspaces, and community projects to: +Castle Accounting 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 libra directory here +# Copy or clone the castle 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 collective owes you money or vice versa +2. **View Your Balance**: See if the Castle 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 organization owns (Cash, Bank, Accounts Receivable) -- **Liabilities**: What the organization owes (Accounts Payable to members) +- **Assets**: Things the Castle owns (Cash, Bank, Accounts Receivable) +- **Liabilities**: What the Castle 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: -- `libra.accounts` - Chart of accounts -- `libra.journal_entries` - Transaction headers -- `libra.entry_lines` - Debit/credit lines +- `castle.accounts` - Chart of accounts +- `castle.journal_entries` - Transaction headers +- `castle.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/libra/index.html` +5. Update UI in `templates/castle/index.html` ## Contributing diff --git a/__init__.py b/__init__.py index 614a8ba..6209e9d 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 libra_generic_router -from .views_api import libra_api_router +from .views import castle_generic_router +from .views_api import 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_ext: APIRouter = APIRouter(prefix="/castle", tags=["Castle"]) +castle_ext.include_router(castle_generic_router) +castle_ext.include_router(castle_api_router) -libra_static_files = [ +castle_static_files = [ { - "path": "/libra/static", - "name": "libra_static", + "path": "/castle/static", + "name": "castle_static", } ] scheduled_tasks: list[asyncio.Task] = [] -def libra_stop(): +def castle_stop(): """Clean up background tasks on extension shutdown""" for task in scheduled_tasks: try: @@ -31,54 +31,35 @@ def libra_stop(): logger.warning(ex) -def libra_start(): - """Initialize Libra extension background tasks""" +def castle_start(): + """Initialize Castle extension background tasks""" from lnbits.tasks import create_permanent_unique_task from .fava_client import init_fava_client - from .models import LibraSettings + from .models import CastleSettings 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 libra_db - - settings = None - try: - row = await libra_db.fetchone( - "SELECT * FROM extension_settings LIMIT 1", - model=LibraSettings, - ) - if row: - settings = row - 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 = LibraSettings() - logger.info(f"Using default Libra settings: {settings.fava_url}/{settings.fava_ledger_slug}") - - init_fava_client( - fava_url=settings.fava_url, - ledger_slug=settings.fava_ledger_slug, - timeout=settings.fava_timeout - ) - logger.info(f"Fava client initialized: {settings.fava_url}/{settings.fava_ledger_slug}") - + # Initialize Fava client with default settings + # (Will be re-initialized if admin updates settings) + defaults = CastleSettings() try: - asyncio.get_event_loop().create_task(_init_fava()) + init_fava_client( + fava_url=defaults.fava_url, + ledger_slug=defaults.fava_ledger_slug, + timeout=defaults.fava_timeout + ) + logger.info(f"Fava client initialized: {defaults.fava_url}/{defaults.fava_ledger_slug}") except Exception as e: logger.error(f"Failed to initialize Fava client: {e}") - logger.warning("Libra will not function without Fava. Please configure Fava settings.") + logger.warning("Castle will not function without Fava. Please configure Fava settings.") # Start background tasks - task = create_permanent_unique_task("ext_libra", wait_for_paid_invoices) + task = create_permanent_unique_task("ext_castle", wait_for_paid_invoices) scheduled_tasks.append(task) # Start account sync task (runs hourly) - sync_task = create_permanent_unique_task("ext_libra_account_sync", wait_for_account_sync) + sync_task = create_permanent_unique_task("ext_castle_account_sync", wait_for_account_sync) scheduled_tasks.append(sync_task) - logger.info("Libra account sync task started (runs hourly)") + logger.info("Castle account sync task started (runs hourly)") -__all__ = ["libra_ext", "libra_static_files", "db", "libra_start", "libra_stop"] +__all__ = ["castle_ext", "castle_static_files", "db", "castle_start", "castle_stop"] diff --git a/account_sync.py b/account_sync.py index 3d82381..95fe41c 100644 --- a/account_sync.py +++ b/account_sync.py @@ -1,11 +1,11 @@ """ Account Synchronization Module -Syncs accounts from Beancount (source of truth) to Libra DB (metadata store). +Syncs accounts from Beancount (source of truth) to Castle DB (metadata store). This implements the hybrid approach: - Beancount owns account existence (Open directives) -- Libra DB stores permissions and user associations +- Castle 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 Libra DB. + Sync accounts from Beancount to Castle DB. - This ensures Libra DB has metadata entries for all accounts that exist + This ensures Castle 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 Libra DB: Added as active - - Accounts in Libra DB but not in Beancount: Marked as inactive (soft delete) + - Accounts in Beancount but not in Castle DB: Added as active + - Accounts in Castle 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_libra_accounts": 148, + "total_castle_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 Libra DB") + logger.info("Starting account sync from Beancount to Castle 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_libra_accounts": 0, + "total_castle_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 Libra DB (including inactive ones for sync) - libra_accounts = await get_all_accounts(include_inactive=True) + # Get all accounts from Castle DB (including inactive ones for sync) + castle_accounts = await get_all_accounts(include_inactive=True) # Build lookup maps beancount_account_names = {acc["account"] for acc in beancount_accounts} - libra_accounts_by_name = {acc.name: acc for acc in libra_accounts} + castle_accounts_by_name = {acc.name: acc for acc in castle_accounts} stats = { "total_beancount_accounts": len(beancount_accounts), - "total_libra_accounts": len(libra_accounts), + "total_castle_accounts": len(castle_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 Libra DB + # Step 1: Sync accounts from Beancount to Castle DB for bc_account in beancount_accounts: account_name = bc_account["account"] try: - existing = libra_accounts_by_name.get(account_name) + existing = castle_accounts_by_name.get(account_name) if existing: - # Account exists in Libra DB + # Account exists in Castle 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 Libra DB + # Create new account in Castle 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 Libra DB but not in Beancount) as inactive + # Step 2: Mark orphaned accounts (in Castle DB but not in Beancount) as inactive # SKIP virtual accounts (they're intentionally metadata-only) - for libra_account in libra_accounts: - if libra_account.is_virtual: + for castle_account in castle_accounts: + if castle_account.is_virtual: # Virtual accounts are metadata-only, never deactivate them continue - if libra_account.name not in beancount_account_names: + if castle_account.name not in beancount_account_names: # Account no longer exists in Beancount - if libra_account.is_active: + if castle_account.is_active: try: - await update_account_is_active(libra_account.id, False) + await update_account_is_active(castle_account.id, False) stats["accounts_deactivated"] += 1 logger.info( - f"Deactivated orphaned account: {libra_account.name}" + f"Deactivated orphaned account: {castle_account.name}" ) except Exception as e: error_msg = ( - f"Failed to deactivate account {libra_account.name}: {e}" + f"Failed to deactivate account {castle_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_libra_accounts = await get_all_accounts(include_inactive=True) - all_account_names = {acc.name for acc in current_libra_accounts} + current_castle_accounts = await get_all_accounts(include_inactive=True) + all_account_names = {acc.name for acc in current_castle_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 Libra DB. + Sync a single account from Beancount to Castle DB. - Useful for ensuring a specific account exists in Libra DB before + Useful for ensuring a specific account exists in Castle DB before granting permissions on it. Args: @@ -318,22 +318,13 @@ 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 Libra DB + # Create in Castle 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( @@ -352,9 +343,9 @@ async def sync_single_account_from_beancount(account_name: str) -> bool: return False -async def ensure_account_exists_in_libra(account_name: str) -> bool: +async def ensure_account_exists_in_castle(account_name: str) -> bool: """ - Ensure account exists in Libra DB, creating from Beancount if needed. + Ensure account exists in Castle DB, creating from Beancount if needed. This is the recommended function to call before granting permissions. @@ -364,7 +355,7 @@ async def ensure_account_exists_in_libra(account_name: str) -> bool: Returns: True if account exists (or was created), False if failed """ - # Check Libra DB first + # Check Castle DB first existing = await get_account_by_name(account_name) if existing: return True @@ -376,9 +367,9 @@ async def ensure_account_exists_in_libra(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 Libra DB. + Scheduled task to sync accounts from Beancount to Castle DB. - Run this periodically (e.g., every hour) to keep Libra DB in sync with Beancount. + Run this periodically (e.g., every hour) to keep Castle 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 8520d59..46db327 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 organization"), + ("Assets:Receivable", AccountType.ASSET, "Money owed to the Castle"), ("Assets:Tools", AccountType.ASSET, "Tools and hand equipment"), # Liabilities - ("Liabilities:Payable", AccountType.LIABILITY, "Money owed by the organization"), + ("Liabilities:Payable", AccountType.LIABILITY, "Money owed by the Castle"), # 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 8cc9c17..b729347 100644 --- a/auth.py +++ b/auth.py @@ -1,5 +1,5 @@ """ -Centralized Authorization Module for Libra Extension. +Centralized Authorization Module for Castle 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 Libra admin (super user). + Check if user is a Castle admin (super user). - Note: In Libra, admin = super_user. There's no separate admin concept. + Note: In Castle, 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 Libra admin operations. + Use for Castle admin operations. """ auth = _build_auth_context(wallet) if not auth.is_super_user: diff --git a/beancount_format.py b/beancount_format.py index f233ee5..74ba53c 100644 --- a/beancount_format.py +++ b/beancount_format.py @@ -1,8 +1,8 @@ """ -Format Libra entries as Beancount transactions for Fava API. +Format Castle entries as Beancount transactions for Fava API. All entries submitted to Fava must follow Beancount syntax. -This module converts Libra data models to Fava API format. +This module converts Castle 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("libra-abc123") - 'libra-abc123' + >>> sanitize_link("castle-abc123") + 'castle-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., ["libra-abc123", "^invoice-xyz"]) + links: Optional links (e.g., ["castle-abc123", "^invoice-xyz"]) meta: Optional transaction metadata Returns: @@ -93,8 +93,8 @@ def format_transaction( ) ], tags=["expense-entry"], - links=["libra-abc123"], - meta={"user-id": "abc123", "source": "libra-expense-entry"} + links=["castle-abc123"], + meta={"user-id": "abc123", "source": "castle-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 Libra transactions. + This is the RECOMMENDED format for all Castle 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": "libra-api", + "source": "castle-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 libra). + Format a receivable entry (user owes castle). 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": "libra-api", + "source": "castle-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 libra paying user (payable), False if user paying libra (receivable) + is_payable: True if castle paying user (payable), False if user paying castle (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: - # Libra paying user: DR Payable, CR Lightning + # Castle 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 libra: DR Lightning, CR Receivable + # User paying castle: 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 libra paying user (payable), False if user paying libra (receivable) + is_payable: True if castle paying user (payable), False if user paying castle (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: - # Libra paying user: DR Payable, CR Cash/Bank + # Castle 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 libra: DR Cash/Bank, CR Receivable + # User paying castle: DR Cash/Bank, CR Receivable postings = [ { "account": payment_account, @@ -804,139 +804,6 @@ 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, @@ -945,11 +812,10 @@ def format_revenue_entry( entry_date: date, fiat_currency: Optional[str] = None, fiat_amount: Optional[Decimal] = None, - reference: Optional[str] = None, - entry_id: Optional[str] = None + reference: Optional[str] = None ) -> Dict[str, Any]: """ - Format a revenue entry (libra receives payment directly). + Format a revenue entry (castle receives payment directly). Creates a cleared transaction (flag="*") since payment was received. @@ -963,8 +829,7 @@ def format_revenue_entry( entry_date: Date of payment fiat_currency: Optional fiat currency fiat_amount: Optional fiat amount (unsigned) - reference: Optional reference (invoice ID, etc.) — stored as its own link - entry_id: Optional unique entry ID (generated if not provided) + reference: Optional reference Returns: Fava API entry dict @@ -980,9 +845,6 @@ 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 @@ -1007,13 +869,12 @@ def format_revenue_entry( # Note: created-via is redundant with #revenue-entry tag entry_meta = { - "source": "libra-api", - "entry-id": entry_id + "source": "castle-api" } links = [] if reference: - links.append(sanitize_link(reference)) + links.append(reference) return format_transaction( date_val=entry_date, @@ -1024,68 +885,3 @@ 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 00c8c50..b83f290 100644 --- a/config.json +++ b/config.json @@ -1,11 +1,11 @@ { - "name": "Libra", + "name": "Castle Accounting", "short_description": "Double-entry accounting system for collective projects", - "tile": "/libra/static/image/libra.png", + "tile": "/castle/static/image/castle.png", "contributors": [ "Your Name" ], "hidden": false, - "migration_module": "lnbits.extensions.libra.migrations", - "db_name": "ext_libra" + "migration_module": "lnbits.extensions.castle.migrations", + "db_name": "ext_castle" } diff --git a/core/__init__.py b/core/__init__.py index 10c362c..662bb20 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,5 +1,5 @@ """ -Libra Core Module - Pure accounting logic separated from database operations. +Castle 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 c29f069..d2372b8 100644 --- a/core/validation.py +++ b/core/validation.py @@ -1,5 +1,5 @@ """ -Validation rules for Libra accounting. +Validation rules for Castle 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 libra). + Validate a receivable entry (user owes castle). Args: user_id: User ID diff --git a/crud.py b/crud.py index 0692806..0976e72 100644 --- a/crud.py +++ b/crud.py @@ -14,7 +14,7 @@ from .models import ( AssertionStatus, AssignUserRole, BalanceAssertion, - LibraSettings, + CastleSettings, CreateAccount, CreateAccountPermission, CreateBalanceAssertion, @@ -32,7 +32,7 @@ from .models import ( StoredUserWalletSettings, UpdateRole, UserBalance, - UserLibraSettings, + UserCastleSettings, UserEquityStatus, UserRole, UserWalletSettings, @@ -49,7 +49,7 @@ from .core.validation import ( validate_payment_entry, ) -db = Database("ext_libra") +db = Database("ext_castle") # ===== 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 Libra's database for + if it doesn't exist. The account is also registered in Castle'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 Libra DB + # Try to find existing account with this hierarchical name in Castle 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 Libra DB: {account is not None}") + logger.info(f"[ACCOUNT CHECK] User {user_id[:8]}, Account: {account_name}, In Castle DB: {account is not None}") - # Always check/create in Fava, even if account exists in Libra DB + # Always check/create in Fava, even if account exists in Castle DB # This ensures Beancount has the Open directive fava_account_exists = False if True: # Always check Fava @@ -250,13 +250,9 @@ 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=None, + currencies=["EUR", "SATS", "USD"], # Support common currencies metadata={ "user_id": user_id, "description": f"User-specific {account_type.value} account" @@ -266,23 +262,23 @@ async def get_or_create_user_account( except Exception as e: logger.error(f"[FAVA ERROR] Could not check/create account in Fava: {e}", exc_info=True) - # Continue anyway - account creation in Libra DB is still useful for metadata + # Continue anyway - account creation in Castle DB is still useful for metadata - # Ensure account exists in Libra DB (sync from Beancount if needed) + # Ensure account exists in Castle DB (sync from Beancount if needed) # This uses the account sync module for consistency if not account: - logger.info(f"[LIBRA DB] Syncing account from Beancount to Libra DB: {account_name}") + logger.info(f"[CASTLE DB] Syncing account from Beancount to Castle DB: {account_name}") from .account_sync import sync_single_account_from_beancount - # Sync from Beancount to Libra DB + # Sync from Beancount to Castle DB created = await sync_single_account_from_beancount(account_name) if created: - logger.info(f"[LIBRA DB] Account synced from Beancount: {account_name}") + logger.info(f"[CASTLE DB] Account synced from Beancount: {account_name}") else: - logger.warning(f"[LIBRA DB] Failed to sync account from Beancount: {account_name}") + logger.warning(f"[CASTLE DB] Failed to sync account from Beancount: {account_name}") - # Fetch the account from Libra DB + # Fetch the account from Castle DB account = await db.fetchone( """ SELECT * FROM accounts @@ -293,9 +289,9 @@ async def get_or_create_user_account( ) if not account: - 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}") + 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}") try: account = await create_account( CreateAccount( @@ -308,7 +304,7 @@ async def get_or_create_user_account( except Exception as e: # Handle UNIQUE constraint error - account already exists if "UNIQUE constraint failed" in str(e) and "accounts.name" in str(e): - logger.warning(f"[LIBRA DB] Account already exists (UNIQUE constraint), fetching by name: {account_name}") + logger.warning(f"[CASTLE 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( """ @@ -319,10 +315,10 @@ async def get_or_create_user_account( Account, ) if account: - logger.info(f"[LIBRA DB] Found existing account: {account_name} (user_id: {account.user_id})") + logger.info(f"[CASTLE 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"[LIBRA DB] Updating account user_id from {account.user_id} to {user_id}") + logger.info(f"[CASTLE DB] Updating account user_id from {account.user_id} to {user_id}") await db.execute( """ UPDATE accounts @@ -344,7 +340,7 @@ async def get_or_create_user_account( # Re-raise if it's a different error raise else: - logger.info(f"[LIBRA DB] Account already exists in Libra DB: {account_name}") + logger.info(f"[CASTLE DB] Account already exists in Castle DB: {account_name}") return account @@ -355,7 +351,7 @@ async def get_or_create_user_account( # ===== JOURNAL ENTRY OPERATIONS (REMOVED) ===== # # All journal entry operations have been moved to Fava/Beancount. -# Libra no longer maintains its own journal_entries and entry_lines tables. +# Castle 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 @@ -379,29 +375,29 @@ async def get_or_create_user_account( # ===== SETTINGS ===== -async def create_libra_settings( - user_id: str, data: LibraSettings -) -> LibraSettings: - settings = UserLibraSettings(**data.dict(), id=user_id) +async def create_castle_settings( + user_id: str, data: CastleSettings +) -> CastleSettings: + settings = UserCastleSettings(**data.dict(), id=user_id) await db.insert("extension_settings", settings) return settings -async def get_libra_settings(user_id: str) -> Optional[LibraSettings]: +async def get_castle_settings(user_id: str) -> Optional[CastleSettings]: return await db.fetchone( """ SELECT * FROM extension_settings WHERE id = :user_id """, {"user_id": user_id}, - LibraSettings, + CastleSettings, ) -async def update_libra_settings( - user_id: str, data: LibraSettings -) -> LibraSettings: - settings = UserLibraSettings(**data.dict(), id=user_id) +async def update_castle_settings( + user_id: str, data: CastleSettings +) -> CastleSettings: + settings = UserCastleSettings(**data.dict(), id=user_id) await db.update("extension_settings", settings) return settings @@ -428,26 +424,6 @@ async def get_user_wallet_settings(user_id: str) -> Optional[UserWalletSettings] ) -async def get_user_wallet_settings_by_prefix( - user_id_prefix: str, -) -> Optional[StoredUserWalletSettings]: - """ - Get user wallet settings by user ID prefix (for truncated 8-char IDs from Beancount). - - Beancount accounts use truncated user IDs (first 8 chars), but the database - stores full UUIDs. This function looks up by prefix to bridge the gap. - """ - return await db.fetchone( - """ - SELECT * FROM user_wallet_settings - WHERE id LIKE :prefix || '%' - LIMIT 1 - """, - {"prefix": user_id_prefix}, - StoredUserWalletSettings, - ) - - async def update_user_wallet_settings( user_id: str, data: UserWalletSettings ) -> UserWalletSettings: diff --git a/description.md b/description.md index d609551..b3628a2 100644 --- a/description.md +++ b/description.md @@ -1,4 +1,4 @@ -# Libra +# Castle Accounting 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**: Libra owes them money (reimbursable) + - **Liabilities**: Castle owes them money (reimbursable) - **Equity**: Their contribution to the collective -- **Accounts Receivable**: Track what users owe the organization (e.g., accommodation fees) +- **Accounts Receivable**: Track what users owe the Castle (e.g., accommodation fees) - **Revenue Tracking**: Record revenue received by the collective -- **User Balance Dashboard**: Each user sees their balance with the organization +- **User Balance Dashboard**: Each user sees their balance with the Castle - **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 collective: +When a member buys supplies for the Castle: - They can choose to be reimbursed (Liability) - Or contribute it as equity (Equity) ### 2. Accounts Receivable -When someone stays with the collective and owes money: +When someone stays at the Castle 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 organization receives revenue: +When the Castle receives revenue: - Record revenue with the payment method (Cash, Lightning, Bank) - Properly categorized in the accounting system @@ -58,8 +58,8 @@ When the organization receives revenue: ## Getting Started -1. Enable the Libra extension in LNbits -2. Visit the Libra page to see your dashboard +1. Enable the Castle extension in LNbits +2. Visit the Castle 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 ea37aaa..f5528f5 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 Libra administration: +Implemented two major improvements for Castle administration: -1. **Account Synchronization** - Automatically sync accounts from Beancount → Libra DB +1. **Account Synchronization** - Automatically sync accounts from Beancount → Castle 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 Libra administration: ### Problem Solved -**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). +**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). ### Implementation -**New Module**: `libra/account_sync.py` +**New Module**: `castle/account_sync.py` **Core Functions**: ```python -# 1. Full sync from Beancount to Libra +# 1. Full sync from Beancount to Castle 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_libra("Expenses:Marketing") +exists = await ensure_account_exists_in_castle("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 libra.account_sync import sync_accounts_from_beancount +from castle.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 libra.account_sync import ensure_account_exists_in_libra -from libra.crud import create_account_permission +from castle.account_sync import ensure_account_exists_in_castle +from castle.crud import create_account_permission -# Ensure account exists in Libra DB first -account_exists = await ensure_account_exists_in_libra("Expenses:Marketing") +# Ensure account exists in Castle DB first +account_exists = await ensure_account_exists_in_castle("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 libra.account_sync import scheduled_account_sync +from castle.account_sync import scheduled_account_sync -# Run every hour to keep Libra DB in sync +# Run every hour to keep Castle DB in sync scheduler.add_job( scheduled_account_sync, 'interval', @@ -142,7 +142,7 @@ Authorization: Bearer {admin_key} ```json { "total_beancount_accounts": 150, - "total_libra_accounts": 150, + "total_castle_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**: Libra DB automatically reflects Beancount state -2. **Reduced Manual Work**: No more manual account creation in Libra +1. **Beancount as Source of Truth**: Castle DB automatically reflects Beancount state +2. **Reduced Manual Work**: No more manual account creation in Castle 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**: `libra/permission_management.py` +**New Module**: `castle/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 Libra DB? + account_id="acc123", # What if account doesn't exist in Castle DB? permission_type=PermissionType.SUBMIT_EXPENSE, granted_by="admin" ) # NEW: Safe permission creation with account sync -from libra.account_sync import ensure_account_exists_in_libra +from castle.account_sync import ensure_account_exists_in_castle # Ensure account exists first -account_exists = await ensure_account_exists_in_libra("Expenses:Marketing") +account_exists = await ensure_account_exists_in_castle("Expenses:Marketing") if account_exists: - # Now safe - account guaranteed to be in Libra DB + # Now safe - account guaranteed to be in Castle DB await create_account_permission( user_id="alice", account_id=account_id, @@ -497,10 +497,10 @@ else: ### Scheduler Integration ```python -# Add to your Libra extension startup +# Add to your Castle extension startup from apscheduler.schedulers.asyncio import AsyncIOScheduler -from libra.account_sync import scheduled_account_sync -from libra.permission_management import cleanup_expired_permissions +from castle.account_sync import scheduled_account_sync +from castle.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_libra("Expenses:Food") + await ensure_account_exists_in_castle("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 Libra Installations +### For Existing Castle Installations **Step 1: Deploy New Modules** ```bash -# Copy new files to Libra extension -cp account_sync.py /path/to/libra/ -cp permission_management.py /path/to/libra/ +# Copy new files to Castle extension +cp account_sync.py /path/to/castle/ +cp permission_management.py /path/to/castle/ ``` **Step 2: Initial Account Sync** ```python # Run once to sync existing accounts -from libra.account_sync import sync_accounts_from_beancount +from castle.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**: -- ✅ `libra/account_sync.py` (230 lines) -- ✅ `libra/permission_management.py` (400 lines) +- ✅ `castle/account_sync.py` (230 lines) +- ✅ `castle/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**: -- `libra/views_api.py` - Add new admin endpoints -- `libra/README.md` - Document new features +- `castle/views_api.py` - Add new admin endpoints +- `castle/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 → Libra DB + - Automatic sync from Beancount → Castle 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 6271865..d0e9bfe 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: Libra Extension - +Senior Accounting Review Subject: Castle Extension - Lightning Payment Settlement Entries Status: Technical Review


Executive Summary

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


Background: The Technical Challenge

-

Libra operates as a Lightning Network-integrated accounting system +

Castle 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 -Libra) and payables (Libra owes them) 4. Maintaining Beancount +Castle) and payables (Castle 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: "libra-api"
+  source: "castle-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 Libra:    555.00 EUR (receivable)
-Libra owes User:     38.00 EUR (payable)
+
User owes Castle:    555.00 EUR (receivable)
+Castle 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 Libra:    200.00 EUR (receivable)
-Libra owes User:      0.00 EUR (no payable)
+
User owes Castle:    200.00 EUR (receivable)
+Castle 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, Libra owes 38 EUR, net: 517 EUR
+  ; User owes 555 EUR, Castle 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 -Libra):

+Castle):

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

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

+consistency with Castle’s architecture.


1.3 Rename Function for Clarity

@@ -713,7 +713,7 @@ class="sourceCode python"> payment_hash=payment_hash ) else: - # PAYABLE PAYMENT: Libra paying user (different flow) + # PAYABLE PAYMENT: Castle 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 Libra metadata approach

+Aligns with current Castle 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:Libra    225033 SATS
+  Assets:Bitcoin:Lightning:Castle    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 Libra’s use case? Yes, +

Is it acceptable for Castle’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. -Libra’s approach is functional, but should be refined to align better +Castle’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. Libra Extension: +
  5. Castle 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 -Libra’s payment recording system.

    +Castle’s payment recording system.

    diff --git a/docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.md b/docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.md index 8725145..b145128 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**: Libra Extension - Lightning Payment Settlement Entries +**Subject**: Castle Extension - Lightning Payment Settlement Entries **Status**: Technical Review --- ## Executive Summary -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. +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. **Key Findings**: - ✅ Double-entry integrity maintained @@ -23,14 +23,14 @@ This document provides a professional accounting assessment of Libra's net settl ## Background: The Technical Challenge -Libra operates as a Lightning Network-integrated accounting system for collectives (co-living spaces, makerspaces). It faces a unique accounting challenge: +Castle operates as a Lightning Network-integrated accounting system for collectives (co-living spaces, makerspaces). It faces a unique accounting challenge: **Scenario**: User creates a receivable in EUR (e.g., €200 for room rent), then pays via Lightning Network in satoshis (225,033 sats). **Challenge**: Record the payment while: 1. Clearing the exact EUR receivable amount 2. Recording the exact satoshi amount received -3. Handling cases where users have both receivables (owe Libra) and payables (Libra owes them) +3. Handling cases where users have both receivables (owe Castle) and payables (Castle owes them) 4. Maintaining Beancount double-entry balance --- @@ -43,7 +43,7 @@ Libra operates as a Lightning Network-integrated accounting system for collectiv ; Step 1: Receivable Created 2025-11-12 * "room (200.00 EUR)" #receivable-entry user-id: "375ec158" - source: "libra-api" + source: "castle-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** (Libra's current approach): +**Option B - Use EUR positions with metadata** (Castle'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 Libra: 555.00 EUR (receivable) -Libra owes User: 38.00 EUR (payable) +User owes Castle: 555.00 EUR (receivable) +Castle 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 Libra: 200.00 EUR (receivable) -Libra owes User: 0.00 EUR (no payable) +User owes Castle: 200.00 EUR (receivable) +Castle 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, Libra owes 38 EUR, net: 517 EUR + ; User owes 555 EUR, Castle 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 Libra): +**Option A - Keep Metadata Approach** (recommended for Castle): ```python # In format_net_settlement_entry() postings = [ @@ -506,7 +506,7 @@ postings = [ ] ``` -**Recommendation**: Choose Option A (metadata) for consistency with Libra's architecture. +**Recommendation**: Choose Option A (metadata) for consistency with Castle's architecture. --- @@ -625,7 +625,7 @@ async def create_payment_entry( payment_hash=payment_hash ) else: - # PAYABLE PAYMENT: Libra paying user (different flow) + # PAYABLE PAYMENT: Castle 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 Libra metadata approach +4. Aligns with current Castle 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:Libra 225033 SATS + Assets:Bitcoin:Lightning:Castle 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 Libra's use case?** +**Is it acceptable for Castle'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. Libra'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. Castle'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 -- **Libra Extension**: `docs/SATS-EQUIVALENT-METADATA.md` +- **Castle 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 Libra'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 Castle's payment recording system.* diff --git a/docs/BEANCOUNT_PATTERNS.md b/docs/BEANCOUNT_PATTERNS.md index 29b65c9..2124c92 100644 --- a/docs/BEANCOUNT_PATTERNS.md +++ b/docs/BEANCOUNT_PATTERNS.md @@ -1,8 +1,8 @@ -# Beancount Patterns Analysis for Libra Extension +# Beancount Patterns Analysis for Castle 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 Libra 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 Castle Accounting extension. ## Key Patterns to Adopt @@ -38,7 +38,7 @@ class Posting(NamedTuple): - More memory efficient than regular classes - Thread-safe by design -**Libra Application:** +**Castle 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 ``` -**Libra Application:** +**Castle Application:** ```python # Create plugins/ directory -# lnbits/extensions/libra/plugins/__init__.py +# lnbits/extensions/castle/plugins/__init__.py from typing import Protocol, Tuple, List, Any -class LibraPlugin(Protocol): - """Protocol for Libra plugins""" +class CastlePlugin(Protocol): + """Protocol for Castle plugins""" def __call__( self, @@ -130,7 +130,7 @@ class LibraPlugin(Protocol): Args: entries: Journal entries to process - settings: Libra settings + settings: Castle settings config: Plugin-specific configuration Returns: @@ -212,7 +212,7 @@ class PluginManager: if plugin_file.name.startswith('_'): continue - module_name = f"libra.plugins.{plugin_file.stem}" + module_name = f"castle.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]): ) ``` -**Libra Application:** +**Castle 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 LibraPosition: - """A position in the Libra inventory""" +class CastlePosition: + """A position in the Castle inventory""" currency: str # "SATS", "EUR", "USD" amount: Decimal cost_currency: Optional[str] = None # Original currency if converted @@ -293,22 +293,22 @@ class LibraPosition: date: Optional[datetime] = None metadata: Dict[str, Any] = None -class LibraInventory: +class CastleInventory: """ Track user balances across multiple currencies with conversion tracking. - Similar to Beancount's Inventory but optimized for Libra's use case. + Similar to Beancount's Inventory but optimized for Castle's use case. """ def __init__(self): - self.positions: Dict[Tuple[str, Optional[str]], LibraPosition] = {} + self.positions: Dict[Tuple[str, Optional[str]], CastlePosition] = {} - def add_position(self, position: LibraPosition): + def add_position(self, position: CastlePosition): """Add or merge a position""" key = (position.currency, position.cost_currency) if key in self.positions: existing = self.positions[key] - self.positions[key] = LibraPosition( + self.positions[key] = CastlePosition( currency=position.currency, amount=existing.amount + position.amount, cost_currency=position.cost_currency, @@ -353,9 +353,9 @@ class LibraInventory: } # Usage in balance calculation: -async def get_user_inventory(user_id: str) -> LibraInventory: +async def get_user_inventory(user_id: str) -> CastleInventory: """Calculate user's inventory from journal entries""" - inventory = LibraInventory() + inventory = CastleInventory() 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) -> LibraInventory: # 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(LibraPosition( + inventory.add_position(CastlePosition( 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 -**Libra Application:** +**Castle 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. -**Libra Application:** +**Castle Application:** ```python # models.py class BalanceAssertion(BaseModel): @@ -464,7 +464,7 @@ class BalanceAssertion(BaseModel): created_at: datetime # API endpoint -@libra_api_router.post("/api/v1/assertions/balance") +@castle_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. -**Libra Application:** +**Castle 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 -**Libra Application:** +**Castle Application:** ```python # Add flag field to journal_entries class JournalEntryFlag(str, Enum): @@ -661,7 +661,7 @@ from decimal import Decimal amount = Decimal("19.99") ``` -**Libra Current Issue:** +**Castle 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; ``` -**Libra Application (Future):** +**Castle Application (Future):** ```python # Add query endpoint -@libra_api_router.post("/api/v1/query") +@castle_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 ``` -**Libra Should Adopt:** +**Castle Should Adopt:** ``` -libra/ +castle/ core/ # NEW: Pure accounting logic __init__.py - inventory.py # LibraInventory for position tracking + inventory.py # CastleInventory 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 ``` -**Libra Application:** +**Castle Application:** ```python from typing import NamedTuple, Optional -class LibraError(NamedTuple): +class CastleError(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[LibraError]: +async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError]: errors = [] # Beancount-style: sum of amounts must equal 0 @@ -870,7 +870,7 @@ async def validate_journal_entry(entry: CreateJournalEntry) -> list[LibraError]: ### Phase 3: Core Logic Refactoring (Medium Priority) ✅ COMPLETE 9. ✅ Create `core/` module with pure accounting logic -10. ✅ Implement `LibraInventory` for position tracking +10. ✅ Implement `CastleInventory` 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[LibraError]: 7. ✅ Separation of core logic from I/O 8. ✅ Comprehensive validation -**What Libra Should Adopt First:** +**What Castle 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[LibraError]: ## Conclusion -Beancount's architecture is battle-tested for serious accounting work. By adopting these patterns, Libra can: +Beancount's architecture is battle-tested for serious accounting work. By adopting these patterns, Castle 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 45cd738..d4997ab 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 Libra's Current Data Structure** +**Status**: ⚠️ **NOT FEASIBLE for Castle's Current Data Structure** ### Implementation Completed @@ -523,7 +523,7 @@ Improvement: 5-10x faster ### Root Cause: Architecture Limitation -**Current Libra Ledger Structure:** +**Current Castle 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 Libra's primary currency** for balance tracking +1. **SATS are Castle'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** Libra's ledger format changes to use SATS as position amounts: +**If** Castle'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 5a8df9b..24cd073 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 Libra:** +**Why `@@` is better for Castle:** - ✅ 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/libra-beancounter +cd /home/padreug/projects/castle-beancounter ./test_metadata_simple.sh ``` @@ -166,7 +166,7 @@ Add one test entry to your ledger: Then query: ```bash -curl -s "http://localhost:3333/libra-ledger/api/query" \ +curl -s "http://localhost:3333/castle-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 e284441..af38a8d 100644 --- a/docs/DAILY_RECONCILIATION.md +++ b/docs/DAILY_RECONCILIATION.md @@ -1,6 +1,6 @@ # Automated Daily Reconciliation -The Libra extension includes automated daily balance checking to ensure accounting accuracy. +The Castle 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/libra/api/v1/tasks/daily-reconciliation \ +curl -X POST https://your-lnbits-instance.com/castle/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/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY" >> /var/log/libra-reconciliation.log 2>&1 +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 ``` To edit crontab: @@ -38,22 +38,22 @@ crontab -e ### Option 2: Systemd Timer -Create `/etc/systemd/system/libra-reconciliation.service`: +Create `/etc/systemd/system/castle-reconciliation.service`: ```ini [Unit] -Description=Libra Daily Reconciliation Check +Description=Castle Daily Reconciliation Check After=network.target [Service] Type=oneshot User=lnbits -ExecStart=/usr/bin/curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY" +ExecStart=/usr/bin/curl -X POST http://localhost:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY" ``` -Create `/etc/systemd/system/libra-reconciliation.timer`: +Create `/etc/systemd/system/castle-reconciliation.timer`: ```ini [Unit] -Description=Run Libra reconciliation daily +Description=Run Castle reconciliation daily [Timer] OnCalendar=daily @@ -66,8 +66,8 @@ WantedBy=timers.target Enable and start: ```bash -sudo systemctl enable libra-reconciliation.timer -sudo systemctl start libra-reconciliation.timer +sudo systemctl enable castle-reconciliation.timer +sudo systemctl start castle-reconciliation.timer ``` ### Option 3: Docker/Kubernetes CronJob @@ -78,7 +78,7 @@ For containerized deployments: apiVersion: batch/v1 kind: CronJob metadata: - name: libra-reconciliation + name: castle-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/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: ${ADMIN_KEY}" + - curl -X POST http://lnbits:5000/castle/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/libra-reconciliation.log +tail -f /var/log/castle-reconciliation.log ``` ### Success Criteria @@ -142,7 +142,7 @@ tail -f /var/log/libra-reconciliation.log If `failed > 0`: 1. Check the `failed_assertions` array for details -2. Investigate discrepancies in the Libra UI +2. Investigate discrepancies in the Castle 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/libra/api/v1/reconciliation/summary -H "X-Api-Key: YOUR_KEY" + curl http://localhost:5000/castle/api/v1/reconciliation/summary -H "X-Api-Key: YOUR_KEY" ``` ### Permission Denied @@ -202,31 +202,31 @@ Planned features: ```bash #!/bin/bash -# setup-libra-reconciliation.sh +# setup-castle-reconciliation.sh # Configuration LNBITS_URL="http://localhost:5000" ADMIN_KEY="your_admin_key_here" -LOG_FILE="/var/log/libra-reconciliation.log" +LOG_FILE="/var/log/castle-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/libra/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/castle/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/libra/api/v1/tasks/daily-reconciliation" \ +curl -X POST "$LNBITS_URL/castle/api/v1/tasks/daily-reconciliation" \ -H "X-Api-Key: $ADMIN_KEY" ``` Make executable and run: ```bash -chmod +x setup-libra-reconciliation.sh -./setup-libra-reconciliation.sh +chmod +x setup-castle-reconciliation.sh +./setup-castle-reconciliation.sh ``` diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index 58c9e14..ac79f03 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -1,8 +1,8 @@ -# Libra Extension - Comprehensive Documentation +# Castle Accounting Extension - Comprehensive Documentation ## Overview -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. +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. ## 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 Libra owns or is owed | -| Liability | Credit | Credit | Debit | What Libra owes to others | +| Asset | Debit | Debit | Credit | What Castle owns or is owed | +| Liability | Credit | Credit | Debit | What Castle owes to others | | Equity | Credit | Credit | Debit | Member contributions, retained earnings | -| Revenue | Credit | Credit | Debit | Income earned by Libra | -| Expense | Debit | Debit | Credit | Costs incurred by Libra | +| Revenue | Credit | Credit | Debit | Income earned by Castle | +| Expense | Debit | Debit | Credit | Costs incurred by Castle | ### User-Specific Accounts The system creates **per-user accounts** for tracking individual balances: -- `Accounts Receivable - {user_id[:8]}` (Asset) - User owes Libra -- `Accounts Payable - {user_id[:8]}` (Liability) - Libra owes User +- `Accounts Receivable - {user_id[:8]}` (Asset) - User owes Castle +- `Accounts Payable - {user_id[:8]}` (Liability) - Castle owes User - `Member Equity - {user_id[:8]}` (Equity) - User's equity contributions **Balance Interpretation:** -- `balance > 0` and account is Liability → Libra owes user (user is creditor) -- `balance < 0` (or positive Asset balance) → User owes Libra (user is debtor) +- `balance > 0` and account is Liability → Castle owes user (user is creditor) +- `balance < 0` (or positive Asset balance) → User owes Castle (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" - libra_wallet_id TEXT, -- LNbits wallet ID for Libra operations + castle_wallet_id TEXT, -- LNbits wallet ID for Castle 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, Libra reimburses them +**Use Case:** User pays for groceries with cash, Castle reimburses them **User Action:** Add expense via UI ```javascript -POST /libra/api/v1/entries/expense +POST /castle/api/v1/entries/expense { "description": "Biocoop groceries", "amount": 36.93, @@ -162,15 +162,15 @@ Metadata on both lines: } ``` -**Effect:** Libra owes user €36.93 (39,669 sats) +**Effect:** Castle owes user €36.93 (39,669 sats) -### 2. Libra Adds Receivable +### 2. Castle Adds Receivable -**Use Case:** User stays in a room, owes Libra for accommodation +**Use Case:** User stays in a room, owes Castle for accommodation -**Libra Admin Action:** Add receivable via UI +**Castle Admin Action:** Add receivable via UI ```javascript -POST /libra/api/v1/entries/receivable +POST /castle/api/v1/entries/receivable { "description": "room 5 days", "amount": 250.0, @@ -198,7 +198,7 @@ Metadata: } ``` -**Effect:** User owes Libra €250.00 (268,548 sats) +**Effect:** User owes Castle €250.00 (268,548 sats) ### 3. User Pays with Lightning @@ -206,7 +206,7 @@ Metadata: **Step A: Generate Invoice** ```javascript -POST /libra/api/v1/generate-payment-invoice +POST /castle/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 Libra", - "check_wallet_key": "libra_wallet_inkey" + "memo": "Payment from user af983632 to Castle", + "check_wallet_key": "castle_wallet_inkey" } ``` -**Note:** Invoice is generated on **Libra's wallet**, not user's wallet. User polls using `check_wallet_key`. +**Note:** Invoice is generated on **Castle'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 /libra/api/v1/record-payment +POST /castle/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 Libra to pay them in cash instead of Lightning +**Use Case:** User wants Castle to pay them in cash instead of Lightning **Step A: User Requests Payment** ```javascript -POST /libra/api/v1/manual-payment-requests +POST /castle/api/v1/manual-payment-requests { "amount": 39669, "description": "Please pay me in cash for groceries" @@ -263,16 +263,16 @@ POST /libra/api/v1/manual-payment-requests Creates `manual_payment_request` with status='pending' -**Step B: Libra Admin Reviews** +**Step B: Castle 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: Libra Admin Approves** +**Step C: Castle Admin Approves** ```javascript -POST /libra/api/v1/manual-payment-requests/{id}/approve +POST /castle/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:** Libra's liability to user reduced by 39,669 sats +**Effect:** Castle's liability to user reduced by 39,669 sats -**Alternative: Libra Admin Rejects** +**Alternative: Castle Admin Rejects** ```javascript -POST /libra/api/v1/manual-payment-requests/{id}/reject +POST /castle/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 = Libra owes user + total_balance += account_balance # Positive = Castle owes user elif account.account_type == AccountType.ASSET: - total_balance -= account_balance # Positive asset = User owes Libra, so negative balance + total_balance -= account_balance # Positive asset = User owes Castle, 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 libra owes + # For liabilities, negative amounts (credits) increase what castle owes if line.amount < 0: - fiat_balances[currency] += fiat_amount # Libra owes more + fiat_balances[currency] += fiat_amount # Castle owes more else: - fiat_balances[currency] -= fiat_amount # Libra owes less + fiat_balances[currency] -= fiat_amount # Castle 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`: Libra owes user (LIABILITY side dominates) -- `balance < 0`: User owes Libra (ASSET side dominates) +- `balance > 0`: Castle owes user (LIABILITY side dominates) +- `balance < 0`: User owes Castle (ASSET side dominates) - `fiat_balances`: Net fiat position per currency -### Libra Balance Calculation +### Castle 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 Libra owes -total_receivables = sum(abs(b.balance) for b in all_balances if b.balance < 0) # What is owed to Libra +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 net_balance = total_liabilities - total_receivables # Aggregate all fiat balances @@ -354,34 +354,34 @@ for user_balance in all_balances: ``` **Result:** -- `net_balance > 0`: Libra owes users (net liability) -- `net_balance < 0`: Users owe Libra (net receivable) +- `net_balance > 0`: Castle owes users (net liability) +- `net_balance < 0`: Users owe Castle (net receivable) ## UI/UX Design ### Perspective-Based Display -The UI adapts based on whether the viewer is a regular user or Libra admin (super user): +The UI adapts based on whether the viewer is a regular user or Castle admin (super user): #### User View **Balance Display:** -- Green text: Libra owes them (positive balance, incoming money) -- Red text: They owe Libra (negative balance, outgoing money) +- Green text: Castle owes them (positive balance, incoming money) +- Red text: They owe Castle (negative balance, outgoing money) **Transaction Badges:** -- Green "Receivable": Libra owes them (Accounts Payable entry) -- Red "Payable": They owe Libra (Accounts Receivable entry) +- Green "Receivable": Castle owes them (Accounts Payable entry) +- Red "Payable": They owe Castle (Accounts Receivable entry) -#### Libra Admin View (Super User) +#### Castle Admin View (Super User) **Balance Display:** -- Red text: Libra owes users (positive balance, outgoing money) -- Green text: Users owe Libra (negative balance, incoming money) +- Red text: Castle owes users (positive balance, outgoing money) +- Green text: Users owe Castle (negative balance, incoming money) **Transaction Badges:** -- Green "Receivable": User owes Libra (Accounts Receivable entry) -- Red "Payable": Libra owes user (Accounts Payable entry) +- Green "Receivable": User owes Castle (Accounts Receivable entry) +- Red "Payable": Castle 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 organization +- `accounts_receivable` - Money owed to the Castle ### Liabilities -- `accounts_payable` - Money owed by the organization +- `accounts_payable` - Money owed by the Castle ### 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 Libra total if super user) +- `GET /api/v1/balance` - Get current user's balance (or Castle 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 Libra -- `POST /api/v1/record-payment` - Record Lightning payment to Libra +- `POST /api/v1/generate-payment-invoice` - Generate invoice for user to pay Castle +- `POST /api/v1/record-payment` - Record Lightning payment to Castle ### 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 Libra settings (super user only) -- `PUT /api/v1/settings` - Update Libra settings (super user only) +- `GET /api/v1/settings` - Get Castle settings (super user only) +- `PUT /api/v1/settings` - Update Castle 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 -@libra_api_router.get("/api/v1/export/beancount") +@castle_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 Libra admin UI: +Add export button to Castle admin UI: ```html Export to Beancount @@ -825,7 +825,7 @@ async exportBeancount() { try { const response = await LNbits.api.request( 'GET', - '/libra/api/v1/export/beancount', + '/castle/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 = `libra-accounting-${new Date().toISOString().split('T')[0]}.beancount` + link.download = `castle-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 libra-accounting-2025-10-22.beancount +bean-check castle-accounting-2025-10-22.beancount # Generate reports -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 +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 ``` ## Testing Strategy @@ -891,7 +891,7 @@ bean-web libra-accounting-2025-10-22.beancount 1. **End-to-End User Flow** - User adds expense - - Libra adds receivable + - Castle adds receivable - User pays via Lightning - Verify balances at each step @@ -904,7 +904,7 @@ bean-web libra-accounting-2025-10-22.beancount 3. **Multi-User Scenarios** - Multiple users with positive balances - Multiple users with negative balances - - Verify Libra net balance calculation + - Verify Castle net balance calculation ## Security Considerations @@ -916,12 +916,12 @@ bean-web libra-accounting-2025-10-22.beancount 2. **User Isolation** - Users can only see their own balances and transactions - - Users cannot create receivables (only Libra admin can) + - Users cannot create receivables (only Castle 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, Libra admin operations + - `require_admin_key`: Write access, Castle admin operations ### Potential Vulnerabilities @@ -959,7 +959,7 @@ bean-web libra-accounting-2025-10-22.beancount limiter = Limiter(key_func=get_remote_address) @limiter.limit("10/minute") - @libra_api_router.post("/api/v1/entries/expense") + @castle_api_router.post("/api/v1/entries/expense") async def api_create_expense_entry(...): ... ``` @@ -1020,7 +1020,7 @@ bean-web libra-accounting-2025-10-22.beancount 2. **Add Pagination** ```python - @libra_api_router.get("/api/v1/entries/user") + @castle_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 libra-accounting-2025-10-22.beancount ## Migration Path for Existing Data -If Libra is already in production with the old code: +If Castle 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 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. +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. ### Strengths ✅ Correct double-entry bookkeeping implementation @@ -1193,7 +1193,7 @@ The Libra extension provides a solid foundation for double-entry bookkeeping in ✅ Metadata preservation for fiat amounts ✅ Lightning payment integration ✅ Manual payment workflow -✅ Perspective-based UI (user vs Libra view) +✅ Perspective-based UI (user vs Castle view) ### Immediate Action Items 1. ✅ Fix user account creation bug (COMPLETED) @@ -1210,4 +1210,4 @@ The Libra extension provides a solid foundation for double-entry bookkeeping in 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 collective 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 cooperative accounting solution. diff --git a/docs/EXPENSE_APPROVAL.md b/docs/EXPENSE_APPROVAL.md index 81305c1..3123b32 100644 --- a/docs/EXPENSE_APPROVAL.md +++ b/docs/EXPENSE_APPROVAL.md @@ -2,7 +2,7 @@ ## Overview -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. +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. ## How It Works @@ -61,7 +61,7 @@ AND je.flag = '*' -- Only cleared entries ### Get Pending Entries (Admin Only) ``` -GET /libra/api/v1/entries/pending +GET /castle/api/v1/entries/pending Authorization: Admin Key Returns: list[JournalEntry] @@ -69,7 +69,7 @@ Returns: list[JournalEntry] ### Approve Expense (Admin Only) ``` -POST /libra/api/v1/entries/{entry_id}/approve +POST /castle/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 /libra/api/v1/entries/{entry_id}/reject +POST /castle/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 /libra/api/v1/entries/expense + POST /castle/api/v1/entries/expense { "description": "Test groceries", "amount": 50.00, diff --git a/docs/PERMISSIONS-SYSTEM.md b/docs/PERMISSIONS-SYSTEM.md index b3f86ea..c3c88b7 100644 --- a/docs/PERMISSIONS-SYSTEM.md +++ b/docs/PERMISSIONS-SYSTEM.md @@ -1,4 +1,4 @@ -# Libra Permissions System - Overview & Administration Guide +# Castle Permissions System - Overview & Administration Guide **Date**: November 10, 2025 **Status**: 📚 **Documentation** + 🔧 **Improvement Recommendations** @@ -7,7 +7,7 @@ ## Executive Summary -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. +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. **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 libra_accounts (id) + FOREIGN KEY (account_id) REFERENCES castle_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 Libra permissions system is **well-designed** with strong features: +The Castle 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 cc45614..a9574a0 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/libra/index.html:254-378`): +- **UI** (`templates/castle/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/libra/index.html:380-499`): +**Implementation** (`templates/castle/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/libra/index.html` - Added assertions and reconciliation UI +5. `templates/castle/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/libra/api/v1/assertions \ +curl -X POST http://localhost:5000/castle/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/libra/api/v1/assertions \ ### Get Reconciliation Summary ```bash -curl http://localhost:5000/libra/api/v1/reconciliation/summary \ +curl http://localhost:5000/castle/api/v1/reconciliation/summary \ -H "X-Api-Key: ADMIN_KEY" ``` ### Run Full Reconciliation ```bash -curl -X POST http://localhost:5000/libra/api/v1/reconciliation/check-all \ +curl -X POST http://localhost:5000/castle/api/v1/reconciliation/check-all \ -H "X-Api-Key: ADMIN_KEY" ``` ### Schedule Daily Reconciliation (Cron) ```bash # Add to crontab -0 2 * * * curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: ADMIN_KEY" +0 2 * * * curl -X POST http://localhost:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: ADMIN_KEY" ``` ## Testing Checklist @@ -238,7 +238,7 @@ curl -X POST http://localhost:5000/libra/api/v1/reconciliation/check-all \ **Phase 3: Core Logic Refactoring (Medium Priority)** - Create `core/` module with pure accounting logic -- Implement `LibraInventory` for position tracking +- Implement `CastleInventory` 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/libra/api/v1/reconciliation/check-all \ ## Conclusion -Phase 2 successfully implements Beancount's reconciliation philosophy in the Libra extension. With balance assertions, comprehensive reconciliation APIs, a visual dashboard, and automated daily checks, users can: +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: - **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 b53625a..1a3dbb6 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. LibraInventory for Position Tracking ✅ +### 2. CastleInventory for Position Tracking ✅ **Purpose**: Track balances across multiple currencies with cost basis information (following Beancount's Inventory pattern) **Implementation** (`core/inventory.py`): -**LibraPosition** (Lines 11-84): +**CastlePosition** (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 LibraPosition: +class CastlePosition: currency: str # "SATS", "EUR", "USD" amount: Decimal cost_currency: Optional[str] = None @@ -44,7 +44,7 @@ class LibraPosition: metadata: Dict[str, Any] = field(default_factory=dict) ``` -**LibraInventory** (Lines 87-201): +**CastleInventory** (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 LibraInventory from journal entry lines + - Build CastleInventory 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 libra) entries + - Validates receivable (user owes castle) entries - Ensures positive amount - Ensures revenue account type @@ -216,10 +216,10 @@ views_api.py → crud.py → core/ ## File Structure ``` -lnbits/extensions/libra/ +lnbits/extensions/castle/ ├── core/ │ ├── __init__.py # Module exports -│ ├── inventory.py # LibraInventory, LibraPosition +│ ├── inventory.py # CastleInventory, CastlePosition │ ├── balance.py # BalanceCalculator │ └── validation.py # Validation functions ├── crud.py # DB operations (refactored to use core/) @@ -230,22 +230,22 @@ lnbits/extensions/libra/ ## Usage Examples -### Using LibraInventory +### Using CastleInventory ```python from decimal import Decimal -from libra.core.inventory import LibraInventory, LibraPosition +from castle.core.inventory import CastleInventory, CastlePosition # Create inventory -inv = LibraInventory() +inv = CastleInventory() # Add positions -inv.add_position(LibraPosition( +inv.add_position(CastlePosition( currency="SATS", amount=Decimal("100000") )) -inv.add_position(LibraPosition( +inv.add_position(CastlePosition( currency="SATS", amount=Decimal("50000"), cost_currency="EUR", @@ -264,7 +264,7 @@ data = inv.to_dict() ### Using BalanceCalculator ```python -from libra.core.balance import BalanceCalculator, AccountType +from castle.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 libra.core.validation import validate_journal_entry, ValidationError +from castle.core.validation import validate_journal_entry, ValidationError entry = { "id": "abc123", @@ -320,8 +320,8 @@ except ValidationError as e: ## Testing Checklist -- [x] LibraInventory created and tested -- [x] LibraPosition addition works +- [x] CastleInventory created and tested +- [x] CastlePosition 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 Libra's accounting logic into a clean, testable core module. By following Beancount's architecture patterns, we've created: +Phase 3 successfully refactors Castle's accounting logic into a clean, testable core module. By following Beancount's architecture patterns, we've created: - **Pure accounting logic** separated from database concerns -- **LibraInventory** for position tracking across currencies +- **CastleInventory** 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 6a4e61f..48ab36c 100644 --- a/docs/SATS-EQUIVALENT-METADATA.md +++ b/docs/SATS-EQUIVALENT-METADATA.md @@ -8,21 +8,21 @@ ## Overview -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. +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. ### 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 (Libra's primary currency) +- **Primary Use**: Calculate user balances in satoshis (Castle's primary currency) - **Key Principle**: Satoshis are for reference; EUR is the actual transaction currency --- ## The Problem: Dual-Currency Tracking -Libra needs to track both: +Castle 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 @@ Libra needs to track both: - ❌ Complicate traditional accounting reconciliation - ❌ Make fiat-based reporting difficult -**Libra's Philosophy**: Record transactions in their **actual currency**, with Bitcoin as supplementary data. +**Castle'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 -Libra's core function is tracking **who owes whom** in satoshis. The `sats-equivalent` metadata enables this. +Castle'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 Libra Accepts This Trade-off +### Why Castle 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" -**Libra's Internal Representation**: +**Castle's Internal Representation**: ```python -# User provides or Libra calculates: +# User provides or Castle 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 = Libra owes user +# Result: negative balance = Castle owes user ``` **User Balance Response**: ```json { "user_id": "5987ae95", - "balance": -39669, // Libra owes user 39,669 sats + "balance": -39669, // Castle owes user 39,669 sats "fiat_balances": { - "EUR": "-36.93" // Libra owes user €36.93 + "EUR": "-36.93" // Castle 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 -Libra tracks TWO independent balances: +Castle 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 ca3d828..97bc9d3 100644 --- a/docs/UI-IMPROVEMENTS-PLAN.md +++ b/docs/UI-IMPROVEMENTS-PLAN.md @@ -1,4 +1,4 @@ -# Libra UI Improvements Plan +# Castle UI Improvements Plan **Date**: November 10, 2025 **Status**: 📋 **Planning Document** @@ -8,7 +8,7 @@ ## Overview -Enhance the Libra permissions UI to showcase new bulk permission management and account sync features, making admin tasks faster and more intuitive. +Enhance the Castle 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 Libra permissions UI to showcase new bulk permission management and │ │ │ ⚠️ Warning: This will revoke ALL │ │ permissions for this user. They will │ -│ immediately lose access to Libra. │ +│ immediately lose access to Castle. │ │ │ │ Reason for Offboarding │ │ [Employee departure - last day] │ @@ -257,13 +257,13 @@ Enhance the Libra permissions UI to showcase new bulk permission management and ├───────────────────────────────────────────┤ │ │ │ Sync accounts from your Beancount ledger │ -│ to Libra database for permission mgmt. │ +│ to Castle database for permission mgmt. │ │ │ │ Last Sync: 2 hours ago │ │ Status: ✅ Up to date │ │ │ │ Accounts in Beancount: 150 │ -│ Accounts in Libra DB: 150 │ +│ Accounts in Castle DB: 150 │ │ │ │ Options: │ │ ☐ Force full sync (re-check all) │ @@ -509,7 +509,7 @@ permissions.html syncStatus: { lastSync: null, beancountAccounts: 0, - libraAccounts: 0, + castleAccounts: 0, status: 'idle' } } diff --git a/fava_client.py b/fava_client.py index eaed06b..6880d11 100644 --- a/fava_client.py +++ b/fava_client.py @@ -1,5 +1,5 @@ """ -Fava API client for Libra. +Fava API client for Castle. This module provides an async HTTP client for interacting with Fava's JSON API. All accounting logic is delegated to Fava/Beancount. @@ -18,7 +18,6 @@ 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 @@ -31,68 +30,6 @@ 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. @@ -109,7 +46,7 @@ class FavaClient: Args: fava_url: Base URL of Fava server (e.g., http://localhost:3333) - ledger_slug: URL-safe ledger identifier (e.g., libra-accounting) + ledger_slug: URL-safe ledger identifier (e.g., castle-accounting) timeout: Request timeout in seconds """ self.fava_url = fava_url.rstrip('/') @@ -129,46 +66,6 @@ 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. @@ -272,7 +169,7 @@ class FavaClient: Args: entry: Beancount entry dict (same format as add_entry) - idempotency_key: Unique key for this operation (e.g., "libra-{uuid}" or "ln-{payment_hash}") + idempotency_key: Unique key for this operation (e.g., "castle-{uuid}" or "ln-{payment_hash}") Returns: Response from Fava if entry was created, or existing entry data if already exists @@ -392,18 +289,18 @@ class FavaClient: async def get_user_balance(self, user_id: str) -> Dict[str, Any]: """ - Get user's balance from libra's perspective. + Get user's balance from castle's perspective. Aggregates: - - Liabilities:Payable:User-{user_id} (negative = libra owes user) - - Assets:Receivable:User-{user_id} (positive = user owes libra) + - Liabilities:Payable:User-{user_id} (negative = castle owes user) + - Assets:Receivable:User-{user_id} (positive = user owes castle) Args: user_id: User ID Returns: { - "balance": int (sats, positive = user owes libra, negative = libra owes user), + "balance": int (sats, positive = user owes castle, negative = castle owes user), "fiat_balances": {"EUR": Decimal("100.50")}, "accounts": [list of account dicts with balances] } @@ -779,12 +676,12 @@ class FavaClient: Use this for efficient aggregations, filtering, and data retrieval. ⚠️ LIMITATION: BQL can only query position amounts and transaction-level data. - It CANNOT access posting metadata (like 'sats-equivalent'). For Libra's current + It CANNOT access posting metadata (like 'sats-equivalent'). For Castle'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 Libra's ledger format changes to use SATS as position + FUTURE CONSIDERATION: If Castle's ledger format changes to use SATS as position amounts (instead of metadata), BQL could provide significant performance benefits. Args: @@ -837,23 +734,17 @@ class FavaClient: async def get_user_balance_bql(self, user_id: str) -> Dict[str, Any]: """ - Get user balance using BQL with currency-grouped aggregation. + Get user balance using BQL with price notation (efficient server-side aggregation). - Groups by account AND currency to correctly handle mixed entry formats: - - Expense entries use EUR @@ SATS (position=EUR, weight=SATS) - - Payment entries use plain SATS (position=SATS, weight=SATS) - - Without currency grouping, sum(number) would mix EUR and SATS face values. - The net SATS balance is computed from sum(weight) which normalizes to SATS - across both formats. Fiat is taken only from EUR rows and scaled by the - fraction of SATS debt still outstanding. + Uses sum(weight) to aggregate SATS from @@ price notation. + This provides 5-10x performance improvement over manual aggregation. Args: user_id: User ID Returns: { - "balance": int (net sats owed), + "balance": int (sats from weight column), "fiat_balances": {"EUR": Decimal("100.50"), ...}, "accounts": [{"account": "...", "sats": 150000, "eur": Decimal("100.50")}, ...] } @@ -866,66 +757,48 @@ class FavaClient: user_id_prefix = user_id[:8] - # 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. + # BQL query using sum(weight) for SATS aggregation + # weight column returns the @@ price value (SATS) from price notation query = f""" - SELECT account, currency, sum(number), sum(weight) + SELECT account, sum(number), sum(weight) WHERE account ~ ':User-{user_id_prefix}' - AND (account ~ 'Payable' OR account ~ 'Receivable' OR account ~ 'Credit') + AND (account ~ 'Payable' OR account ~ 'Receivable') AND flag = '*' - GROUP BY account, currency + GROUP BY account """ result = await self.query_bql(query) - # First pass: collect EUR fiat totals and SATS weights per account - total_eur_sats = 0 # SATS equivalent of EUR entries (from weight) - total_sats_paid = 0 # SATS from payment entries + total_sats = 0 fiat_balances = {} accounts = [] for row in result["rows"]: - account_name, currency, number_sum, weight_sum = row + account_name, fiat_sum, weight_sum = row - # Parse SATS from weight column (always SATS for both entry formats) - sats_weight = 0 + # Parse fiat amount (sum of EUR/USD amounts) + fiat_amount = Decimal(str(fiat_sum)) if fiat_sum else Decimal(0) + + # Parse SATS from weight column + # weight_sum is an Inventory dict like {"SATS": -10442635.00} + sats_amount = 0 if isinstance(weight_sum, dict) and "SATS" in weight_sum: - sats_weight = int(Decimal(str(weight_sum["SATS"]))) + sats_value = weight_sum["SATS"] + sats_amount = int(Decimal(str(sats_value))) - if currency == "SATS": - # Payment entry: SATS position, track separately - total_sats_paid += int(Decimal(str(number_sum))) if number_sum else 0 - else: - # EUR (fiat) entry: number is the fiat amount, weight is SATS equivalent - fiat_amount = Decimal(str(number_sum)) if number_sum else Decimal(0) - total_eur_sats += sats_weight + total_sats += sats_amount - if fiat_amount != 0: - if currency not in fiat_balances: - fiat_balances[currency] = Decimal(0) - fiat_balances[currency] += fiat_amount + # Aggregate fiat (assume EUR for now, could be extended) + if fiat_amount != 0: + if "EUR" not in fiat_balances: + fiat_balances["EUR"] = Decimal(0) + fiat_balances["EUR"] += fiat_amount - accounts.append({ - "account": account_name, - "sats": sats_weight, - "eur": fiat_amount - }) - - # Net SATS balance: EUR-entry SATS (negative=owed) + payment SATS (positive=paid) - total_sats = total_eur_sats + total_sats_paid - - # Scale fiat proportionally if partially settled - # e.g., if 80% of SATS debt paid, reduce fiat owed by 80% - if total_eur_sats != 0 and total_sats_paid != 0: - remaining_fraction = Decimal(str(total_sats)) / Decimal(str(total_eur_sats)) - for currency in fiat_balances: - fiat_balances[currency] = (fiat_balances[currency] * remaining_fraction).quantize(Decimal("0.01")) + accounts.append({ + "account": account_name, + "sats": sats_amount, + "eur": fiat_amount + }) logger.info(f"User {user_id[:8]} balance (BQL): {total_sats} sats, fiat: {dict(fiat_balances)}") @@ -935,76 +808,12 @@ 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. + Get balances for all users using BQL with price notation (efficient admin view). - Groups by account AND currency to correctly handle mixed entry formats: - - Expense entries use EUR @@ SATS (position=EUR, weight=SATS) - - Payment entries use plain SATS (position=SATS, weight=SATS) - - Without currency grouping, sum(number) would mix EUR and SATS face values, - causing wildly inflated fiat amounts for users with payment entries. + Uses sum(weight) to aggregate SATS from @@ price notation in a single query. + This provides significant performance benefits for admin views. Returns: [ @@ -1024,23 +833,23 @@ class FavaClient: """ from decimal import Decimal - # 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. + # BQL query using sum(weight) for SATS aggregation query = """ - SELECT account, currency, sum(number), sum(weight) - WHERE (account ~ 'Payable:User-' OR account ~ 'Receivable:User-' OR account ~ 'Credit:User-') + SELECT account, sum(number), sum(weight) + WHERE (account ~ 'Payable:User-' OR account ~ 'Receivable:User-') AND flag = '*' - GROUP BY account, currency + GROUP BY account """ result = await self.query_bql(query) - # First pass: collect per-user EUR fiat totals and SATS amounts separately + # Group by user_id user_data = {} for row in result["rows"]: - account_name, currency, number_sum, weight_sum = row + account_name, fiat_sum, weight_sum = row + # Extract user_id from account name if ":User-" not in account_name: continue @@ -1052,48 +861,31 @@ class FavaClient: "user_id": user_id, "balance": 0, "fiat_balances": {}, - "accounts": [], - "_eur_sats": 0, # SATS equivalent of EUR entries (from weight) - "_sats_paid": 0, # SATS from payment entries + "accounts": [] } - # Parse SATS from weight column (always SATS for both entry formats) - sats_weight = 0 + # Parse fiat amount + fiat_amount = Decimal(str(fiat_sum)) if fiat_sum else Decimal(0) + + # Parse SATS from weight column + sats_amount = 0 if isinstance(weight_sum, dict) and "SATS" in weight_sum: - sats_weight = int(Decimal(str(weight_sum["SATS"]))) + sats_value = weight_sum["SATS"] + sats_amount = int(Decimal(str(sats_value))) - if currency == "SATS": - # Payment entry: SATS position, track separately - user_data[user_id]["_sats_paid"] += int(Decimal(str(number_sum))) if number_sum else 0 - else: - # EUR (fiat) entry: number is the fiat amount, weight is SATS equivalent - fiat_amount = Decimal(str(number_sum)) if number_sum else Decimal(0) - user_data[user_id]["_eur_sats"] += sats_weight + user_data[user_id]["balance"] += sats_amount - if fiat_amount != 0: - if currency not in user_data[user_id]["fiat_balances"]: - user_data[user_id]["fiat_balances"][currency] = Decimal(0) - user_data[user_id]["fiat_balances"][currency] += fiat_amount + # Aggregate fiat + if fiat_amount != 0: + if "EUR" not in user_data[user_id]["fiat_balances"]: + user_data[user_id]["fiat_balances"]["EUR"] = Decimal(0) + user_data[user_id]["fiat_balances"]["EUR"] += fiat_amount - user_data[user_id]["accounts"].append({ - "account": account_name, - "sats": sats_weight, - "eur": fiat_amount - }) - - # Second pass: compute net balances and scale fiat for partial settlements - for user_id, data in user_data.items(): - eur_sats = data.pop("_eur_sats") - sats_paid = data.pop("_sats_paid") - - # Net SATS balance: EUR-entry SATS (negative=owed) + payment SATS (positive=paid) - data["balance"] = eur_sats + sats_paid - - # Scale fiat proportionally if partially settled - if eur_sats != 0 and sats_paid != 0: - remaining_fraction = Decimal(str(data["balance"])) / Decimal(str(eur_sats)) - for currency in data["fiat_balances"]: - data["fiat_balances"][currency] = (data["fiat_balances"][currency] * remaining_fraction).quantize(Decimal("0.01")) + user_data[user_id]["accounts"].append({ + "account": account_name, + "sats": sats_amount, + "eur": fiat_amount + }) logger.info(f"Fetched balances for {len(user_data)} users (BQL)") @@ -1200,7 +992,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 (libra owes user). + that created liabilities (castle owes user). Returns: List of user contribution summaries: @@ -1301,27 +1093,9 @@ class FavaClient: limit=limit ) - def _extract_accounts_from_tree(self, tree: Any) -> List[str]: - """Recursively extract account names from a Fava tree structure.""" - accounts = [] - if isinstance(tree, dict): - for key, val in tree.items(): - if key == "account": - accounts.append(val) - elif isinstance(val, (dict, list)): - accounts.extend(self._extract_accounts_from_tree(val)) - elif isinstance(tree, list): - for item in tree: - accounts.extend(self._extract_accounts_from_tree(item)) - return accounts - async def get_all_accounts(self) -> List[Dict[str, Any]]: """ - Get all accounts from Beancount/Fava. - - Uses Fava's balance_sheet and income_statement API endpoints to - discover all opened accounts, including those with zero balances. - Falls back to BQL query if the tree endpoints fail. + Get all accounts from Beancount/Fava using BQL query. Returns: List of account dictionaries: @@ -1337,52 +1111,25 @@ class FavaClient: print(acc["account"]) # "Assets:Cash" """ try: - # Use balance_sheet + income_statement to get ALL opened accounts - # (BQL's SELECT DISTINCT account only returns accounts with postings) - account_names: set[str] = set() - - async with httpx.AsyncClient(timeout=self.timeout) as client: - for endpoint in ("balance_sheet", "income_statement"): - try: - response = await client.get(f"{self.base_url}/{endpoint}") - if response.status_code == 200: - data = response.json().get("data", {}) - trees = data.get("trees", {}) - names = self._extract_accounts_from_tree(trees) - account_names.update(names) - except Exception as e: - logger.warning(f"Failed to fetch {endpoint}: {e}") - - # Filter out synthetic entries like "Net Profit" - account_names = { - name for name in account_names - if ":" in name or name in ("Assets", "Liabilities", "Equity", "Income", "Expenses") - } - - if account_names: - accounts = [{"account": name, "meta": {}} for name in sorted(account_names)] - logger.debug(f"Fava returned {len(accounts)} accounts via tree endpoints") - return accounts - - # Fallback: BQL query (only finds accounts with postings) - logger.info("Tree endpoints returned no accounts, falling back to BQL") + # Use BQL to get all unique accounts query = "SELECT DISTINCT account" result = await self.query_bql(query) + # Convert BQL result to expected format accounts = [] for row in result["rows"]: account_name = row[0] if isinstance(row, list) else row.get("account") if account_name: accounts.append({ "account": account_name, - "meta": {} + "meta": {} # BQL doesn't return metadata easily }) logger.debug(f"Fava returned {len(accounts)} accounts via BQL") return accounts except Exception as e: - logger.error(f"Failed to fetch accounts: {e}") + logger.error(f"Failed to fetch accounts via BQL: {e}") raise async def get_journal_entries( @@ -1593,23 +1340,16 @@ class FavaClient: async def add_account( self, account_name: str, - currencies: Optional[list[str]] = None, + currencies: list[str], opening_date: Optional[date] = None, metadata: Optional[Dict[str, Any]] = None, - target_file: Optional[str] = None, max_retries: int = 3 ) -> Dict[str, Any]: """ Add an account to the Beancount ledger via an Open directive. NOTE: Fava's /api/add_entries endpoint does NOT support Open directives. - This method uses /api/source to directly edit 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 uses /api/source to directly edit the Beancount file. This method implements optimistic concurrency control with retry logic: - Acquires a global write lock before modifying the ledger @@ -1622,8 +1362,6 @@ 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: @@ -1633,18 +1371,17 @@ class FavaClient: ChecksumConflictError: If all retry attempts fail due to concurrent modifications Example: - # User-account names route to accounts/users.beancount automatically. + # Add a user's receivable account result = await fava.add_account( - account_name="Assets:Receivable:User-abc12345", + account_name="Assets:Receivable:User-abc123", currencies=["EUR", "SATS", "USD"], - metadata={"user_id": "abc12345", "description": "User receivables"} + metadata={"user_id": "abc123", "description": "User receivables"} ) - # Static / admin-added chart entries route to accounts/chart.beancount. + # Add a user's payable account result = await fava.add_account( - account_name="Expenses:NewCategory", - currencies=["EUR"], - target_file="accounts/chart.beancount", + account_name="Liabilities:Payable:User-abc123", + currencies=["EUR", "SATS"] ) """ from datetime import date as date_type @@ -1652,12 +1389,6 @@ 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): @@ -1665,10 +1396,18 @@ class FavaClient: async with self._write_lock: try: async with httpx.AsyncClient(timeout=self.timeout) as client: - # Step 1: Get current source file (fresh read on each attempt) + # 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) response = await client.get( f"{self.base_url}/source", - params={"filename": target_file} + params={"filename": file_path} ) response.raise_for_status() source_data = response.json()["data"] @@ -1676,56 +1415,47 @@ class FavaClient: sha256sum = source_data["sha256sum"] source = source_data["source"] - # 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 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 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. + # Step 4: Find insertion point (after last Open directive AND its metadata) lines = source.split('\n') - insert_index = len(lines) + 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 - # 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] + # Step 5: Format Open directive as Beancount text + currencies_str = ", ".join(currencies) + open_lines = [ + "", + f"{opening_date.isoformat()} open {account_name} {currencies_str}" + ] # 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}: "{_escape_beancount_string(value)}"' - ) + open_lines.append(f' {key}: "{value}"') else: open_lines.append(f' {key}: {value}') - # Step 5: Insert into source + # Step 6: Insert into source for i, line in enumerate(open_lines): lines.insert(insert_index + i, line) new_source = '\n'.join(lines) - # Step 6: Update source file via PUT /api/source + # Step 7: Update source file via PUT /api/source update_payload = { - "file_path": target_file, + "file_path": file_path, "source": new_source, "sha256sum": sha256sum } @@ -1738,8 +1468,8 @@ class FavaClient: response.raise_for_status() result = response.json() - logger.info(f"Added account {account_name} to {target_file} with currencies {currencies}") - return {**result, "already_existed": False} + logger.info(f"Added account {account_name} to Beancount file with currencies {currencies}") + return result except httpx.HTTPStatusError as e: # Check for checksum conflict (HTTP 412 Precondition Failed or similar) @@ -1787,8 +1517,8 @@ class FavaClient: Args: user_id: User ID (first 8 characters used for account matching) - entry_type: "expense" (payables - libra owes user) or - "receivable" (user owes libra) + entry_type: "expense" (payables - castle owes user) or + "receivable" (user owes castle) Returns: List of unsettled entries with: @@ -1928,8 +1658,8 @@ class FavaClient: Args: user_id: User ID (first 8 characters used for account matching) - entry_type: "expense" (payables - libra owes user) or - "receivable" (user owes libra) + entry_type: "expense" (payables - castle owes user) or + "receivable" (user owes castle) Returns: List of unsettled entries with: @@ -2053,10 +1783,6 @@ 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): @@ -2070,21 +1796,9 @@ 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. @@ -2098,6 +1812,6 @@ def get_fava_client() -> FavaClient: if _fava_client is None: raise RuntimeError( "Fava client not initialized. Call init_fava_client() first. " - "Libra requires Fava for all accounting operations." + "Castle requires Fava for all accounting operations." ) return _fava_client diff --git a/helper/README.md b/helper/README.md index 6c5c0e2..648b987 100644 --- a/helper/README.md +++ b/helper/README.md @@ -1,6 +1,6 @@ -# Libra Beancount Import Helper +# Castle Beancount Import Helper -Import Beancount ledger transactions into Libra accounting extension. +Import Beancount ledger transactions into Castle accounting extension. ## 📁 Files @@ -40,14 +40,14 @@ USER_MAPPINGS = { ### 3. Set API Key ```bash -export LIBRA_ADMIN_KEY="your_lnbits_admin_invoice_key" +export CASTLE_ADMIN_KEY="your_lnbits_admin_invoice_key" export LNBITS_URL="http://localhost:5000" # Optional ``` ## 📖 Usage ```bash -cd /path/to/libra/helper +cd /path/to/castle/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 Libra +- Account names must match exactly what's in Castle - The name after `Equity:` must be in `USER_MAPPINGS` ## 🔄 How It Works 1. **Loads rates** from `btc_eur_rates.csv` -2. **Loads accounts** from Libra API automatically +2. **Loads accounts** from Castle 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 Libra with metadata +6. **Uploads** to Castle with metadata ## 📊 Example Output ```bash $ python import_beancount.py ledger.beancount ====================================================================== -⚖️ Beancount to Libra Import Script +🏰 Beancount to Castle Import Script ====================================================================== 📊 Loaded 15 daily rates from btc_eur_rates.csv Date range: 2025-07-01 to 2025-07-15 -🏦 Loaded 28 accounts from Libra +🏦 Loaded 28 accounts from Castle 👥 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 Libra! +✅ Successfully imported 25 transactions to Castle! ``` ## ❓ Troubleshooting -### "No account found in Libra" -**Error:** `No account found in Libra with name 'Expenses:XYZ'` +### "No account found in Castle" +**Error:** `No account found in Castle with name 'Expenses:XYZ'` -**Solution:** Create the account in Libra first with that exact name. +**Solution:** Create the account in Castle 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 ff874c6..417d0fd 100755 --- a/helper/import_beancount.py +++ b/helper/import_beancount.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 """ -Beancount to Libra Import Script +Beancount to Castle Import Script ⚠️ NOTE: This script is for ONE-OFF MIGRATION purposes only. - Now that Libra uses Fava/Beancount as the single source of truth, - the data flow is: Libra → Fava/Beancount (not the reverse). + Now that Castle uses Fava/Beancount as the single source of truth, + the data flow is: Castle → Fava/Beancount (not the reverse). This script was used for initial data import from existing Beancount files. @@ -14,7 +14,7 @@ Beancount to Libra 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 Libra accounting extension. +Imports Beancount ledger transactions into Castle 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("LIBRA_ADMIN_KEY", "48d787d862484a6c89d6a557b4d5be9d") +ADMIN_API_KEY = os.environ.get("CASTLE_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 -> Libra user ID (wallet ID) -# TODO: Update these with your actual Libra user/wallet IDs +# User ID mappings: Equity account name -> Castle user ID (wallet ID) +# TODO: Update these with your actual Castle user/wallet IDs USER_MAPPINGS = { "Pat": "75be145a42884b22b60bf97510ed46e3", "Coco": "375ec158ceca46de86cf6561ca20f881", @@ -116,7 +116,7 @@ class RateLookup: # ===== ACCOUNT LOOKUP ===== class AccountLookup: - """Fetch and lookup Libra accounts from API""" + """Fetch and lookup Castle 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 Libra API""" - url = f"{lnbits_url}/libra/api/v1/accounts" + """Fetch all accounts from Castle API""" + url = f"{lnbits_url}/castle/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 Libra") + print(f"🏦 Loaded {len(self.accounts)} accounts from Castle") except requests.RequestException as e: - raise ConnectionError(f"Failed to fetch accounts from Libra API: {e}") + raise ConnectionError(f"Failed to fetch accounts from Castle API: {e}") def get_account_id(self, account_name: str) -> Optional[str]: """ - Get Libra account ID for a Beancount account name. + Get Castle 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 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 + - "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 Args: account_name: Beancount account name (e.g., "Expenses:Food:Supplies", "Liabilities:Payable:Pat", "Assets:Receivable:Pat", "Equity:Pat") Returns: - Libra account UUID or None if not found + Castle account UUID or None if not found """ # Check if this is a Liabilities:Payable: account - # Map Beancount Liabilities:Payable:Pat to Libra Liabilities:Payable:User- + # Map Beancount Liabilities:Payable:Pat to Castle 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 Libra + # This is the Liabilities:Payable:User- account in Castle 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 Libra Assets:Receivable:User- + # Map Beancount Assets:Receivable:Pat to Castle 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 Libra + # This is the Assets:Receivable:User- account in Castle 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 Libra Equity:User- + # Map Beancount Equity:Pat to Castle 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 Libra + # This is the Equity:User- account in Castle 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 Libra.\n" + f"Equity eligibility must be enabled for this user in Castle.\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 Libra entry line. + Build metadata dict for Castle 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 -# ===== LIBRA CONVERTER ===== +# ===== CASTLE CONVERTER ===== -def convert_to_libra_entry(parsed: dict, btc_eur_rate: float, account_lookup: AccountLookup) -> dict: +def convert_to_castle_entry(parsed: dict, btc_eur_rate: float, account_lookup: AccountLookup) -> dict: """ - Convert parsed Beancount transaction to Libra format. + Convert parsed Beancount transaction to Castle format. - Sends SATS amounts with fiat metadata. The Libra API will automatically + Sends SATS amounts with fiat metadata. The Castle API will automatically convert to EUR-based postings with SATS stored in metadata. """ @@ -469,8 +469,8 @@ def convert_to_libra_entry(parsed: dict, btc_eur_rate: float, account_lookup: Ac account_id = account_lookup.get_account_id(posting['account']) if not account_id: raise ValueError( - f"No account found in Libra with name '{posting['account']}'.\n" - f"Please create this account in Libra first." + f"No account found in Castle with name '{posting['account']}'.\n" + f"Please create this account in Castle first." ) eur_amount = posting['eur_amount'] @@ -510,7 +510,7 @@ def convert_to_libra_entry(parsed: dict, btc_eur_rate: float, account_lookup: Ac # ===== API UPLOAD ===== def upload_entry(entry: dict, api_key: str, dry_run: bool = False) -> dict: - """Upload journal entry to Libra API""" + """Upload journal entry to Castle 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}/libra/api/v1/entries" + url = f"{LNBITS_URL}/castle/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: LIBRA_ADMIN_KEY not set!") + print("❌ Error: CASTLE_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 Libra + # Load accounts from Castle 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 Libra!)'}") + print(f" {status} {name} → {user_id} {'(has equity account)' if has_equity else '(NO EQUITY ACCOUNT - create in Castle!)'}") # 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()}") - libra_entry = convert_to_libra_entry(parsed, btc_eur_rate, account_lookup) - result = upload_entry(libra_entry, ADMIN_API_KEY, dry_run) + castle_entry = convert_to_castle_entry(parsed, btc_eur_rate, account_lookup) + result = upload_entry(castle_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 Libra!") + print(f"\n✅ Successfully imported {success_count} transactions to Castle!") 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 Libra Import Script") + print("🏰 Beancount to Castle 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 LIBRA_ADMIN_KEY env var)'}") + print(f" API Key set: {'Yes' if ADMIN_API_KEY else 'No (set CASTLE_ADMIN_KEY env var)'}") sys.exit(1) beancount_file = sys.argv[1] diff --git a/manifest.json b/manifest.json index d7e25d2..1a8c320 100644 --- a/manifest.json +++ b/manifest.json @@ -1,9 +1,9 @@ { "repos": [ { - "id": "libra", + "id": "castle", "organisation": "lnbits", - "repository": "libra" + "repository": "castle" } ] } diff --git a/migrations.py b/migrations.py index 9c38c55..c9a7e30 100644 --- a/migrations.py +++ b/migrations.py @@ -1,8 +1,8 @@ """ -Libra Extension Database Migrations +Castle Extension Database Migrations This file contains a single squashed migration that creates the complete -database schema for the Libra extension. +database schema for the Castle 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 Libra database schema (squashed from m001-m016). + Initial Castle database schema (squashed from m001-m016). - Creates complete database structure for Libra accounting extension: + Creates complete database structure for Castle accounting extension: - Accounts: Chart of accounts with hierarchical Beancount-style names - - Extension settings: Libra-wide configuration + - Extension settings: Castle-wide configuration - User wallet settings: Per-user wallet configuration - - Manual payment requests: User-submitted payment requests to Libra + - Manual payment requests: User-submitted payment requests to Castle - 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). - Libra submits entries to Fava and queries Fava for journal data. + Castle submits entries to Fava and queries Fava for journal data. """ # ========================================================================= @@ -89,15 +89,15 @@ async def m001_initial(db): # ========================================================================= # EXTENSION SETTINGS TABLE # ========================================================================= - # Libra-wide configuration settings + # Castle-wide configuration settings await db.execute( f""" CREATE TABLE extension_settings ( id TEXT NOT NULL PRIMARY KEY, - libra_wallet_id TEXT, + castle_wallet_id TEXT, fava_url TEXT NOT NULL DEFAULT 'http://localhost:3333', - fava_ledger_slug TEXT NOT NULL DEFAULT 'libra-ledger', + fava_ledger_slug TEXT NOT NULL DEFAULT 'castle-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 Libra (reviewed by admins) + # User-submitted payment requests to Castle (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, submit_income, manage + # Permission types: read, submit_expense, 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 Libra DB (metadata-only, not in Beancount) + - Exist only in Castle 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 c8632d0..3b47210 100644 --- a/models.py +++ b/models.py @@ -48,17 +48,6 @@ 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 @@ -98,19 +87,9 @@ class CreateJournalEntry(BaseModel): class UserBalance(BaseModel): user_id: str - balance: int # positive = libra owes user, negative = user owes libra + balance: int # positive = castle owes user, negative = user owes castle 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): @@ -119,7 +98,7 @@ class ExpenseEntry(BaseModel): description: str amount: Decimal # Amount in the specified currency (or satoshis if currency is None) expense_account: str # account name or ID - is_equity: bool = False # True = equity contribution, False = liability (libra owes user) + is_equity: bool = False # True = equity contribution, False = liability (castle 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.) @@ -132,7 +111,7 @@ class ReceivableEntry(BaseModel): description: str amount: Decimal # Amount in the specified currency (or satoshis if currency is None) revenue_account: str # account name or ID - user_id: str # The user_id (not wallet_id) of the user who owes the libra + user_id: str # The user_id (not wallet_id) of the user who owes the castle reference: Optional[str] = None currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code @@ -148,32 +127,14 @@ class RevenueEntry(BaseModel): currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code -class IncomeEntry(BaseModel): - """Helper model for user-facing income/revenue submission (pending approval). +class CastleSettings(BaseModel): + """Settings for the Castle extension""" - 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 + castle_wallet_id: Optional[str] = None # The wallet ID that represents the Castle # Fava/Beancount integration - ALL accounting is done via Fava fava_url: str = "http://localhost:3333" # Base URL of Fava server - fava_ledger_slug: str = "libra-ledger" # Ledger identifier in Fava URL + fava_ledger_slug: str = "castle-ledger" # Ledger identifier in Fava URL fava_timeout: float = 10.0 # Request timeout in seconds updated_at: datetime = Field(default_factory=lambda: datetime.now()) @@ -183,7 +144,7 @@ class LibraSettings(BaseModel): return True -class UserLibraSettings(LibraSettings): +class UserCastleSettings(CastleSettings): """User-specific settings (stored with user_id)""" id: str @@ -203,7 +164,7 @@ class StoredUserWalletSettings(UserWalletSettings): class ManualPaymentRequest(BaseModel): - """Manual payment request from user to libra""" + """Manual payment request from user to castle""" id: str user_id: str @@ -212,7 +173,7 @@ class ManualPaymentRequest(BaseModel): status: str = "pending" # pending, approved, rejected created_at: datetime reviewed_at: Optional[datetime] = None - reviewed_by: Optional[str] = None # user_id of libra admin who reviewed + reviewed_by: Optional[str] = None # user_id of castle admin who reviewed journal_entry_id: Optional[str] = None # set when approved @@ -237,7 +198,7 @@ class RecordPayment(BaseModel): class SettleReceivable(BaseModel): - """Manually settle a receivable (user pays libra in person)""" + """Manually settle a receivable (user pays castle in person)""" user_id: str amount: Decimal # Amount in the specified currency (or satoshis if currency is None) @@ -252,7 +213,7 @@ class SettleReceivable(BaseModel): class PayUser(BaseModel): - """Pay a user (libra pays user for expense/liability)""" + """Pay a user (castle pays user for expense/liability)""" user_id: str amount: Decimal # Amount in the specified currency (or satoshis if currency is None) @@ -334,7 +295,6 @@ 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 8965642..f479115 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "libra", + "name": "castle", "version": "0.0.2", "description": "Accounting for a collective entity", "main": "index.js", diff --git a/permission_management.py b/permission_management.py index 80f63ff..7dea217 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 libra_accounts a ON ap.account_id = a.id + JOIN castle_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 libra_accounts a + FROM castle_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 6776c05..51c4bd8 100644 --- a/services.py +++ b/services.py @@ -1,32 +1,32 @@ from .crud import ( - create_libra_settings, + create_castle_settings, create_user_wallet_settings, - get_libra_settings, + get_castle_settings, get_or_create_user_account, get_user_wallet_settings, - update_libra_settings, + update_castle_settings, update_user_wallet_settings, ) -from .models import AccountType, LibraSettings, UserWalletSettings +from .models import AccountType, CastleSettings, UserWalletSettings -async def get_settings(user_id: str) -> LibraSettings: - settings = await get_libra_settings(user_id) +async def get_settings(user_id: str) -> CastleSettings: + settings = await get_castle_settings(user_id) if not settings: - settings = await create_libra_settings(user_id, LibraSettings()) + settings = await create_castle_settings(user_id, CastleSettings()) return settings -async def update_settings(user_id: str, data: LibraSettings) -> LibraSettings: +async def update_settings(user_id: str, data: CastleSettings) -> CastleSettings: from loguru import logger from .fava_client import init_fava_client - settings = await get_libra_settings(user_id) + settings = await get_castle_settings(user_id) if not settings: - settings = await create_libra_settings(user_id, data) + settings = await create_castle_settings(user_id, data) else: - settings = await update_libra_settings(user_id, data) + settings = await update_castle_settings(user_id, data) # Reinitialize Fava client with new settings try: diff --git a/static/image/libra.png b/static/image/castle.png similarity index 100% rename from static/image/libra.png rename to static/image/castle.png diff --git a/static/js/index.js b/static/js/index.js index 6f451f7..3c8d9b7 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -32,9 +32,8 @@ window.app = Vue.createApp({ isAdmin: false, isSuperUser: false, settingsLoaded: false, // Flag to prevent race conditions on toolbar buttons - libraWalletConfigured: false, + castleWalletConfigured: false, userWalletConfigured: false, - syncingAccounts: false, currentExchangeRate: null, // BTC/EUR rate (sats per EUR) expenseDialog: { show: false, @@ -58,9 +57,9 @@ window.app = Vue.createApp({ }, settingsDialog: { show: false, - libraWalletId: '', + castleWalletId: '', favaUrl: 'http://localhost:3333', - favaLedgerSlug: 'libra-ledger', + favaLedgerSlug: 'castle-ledger', favaTimeout: 10.0, loading: false }, @@ -69,13 +68,6 @@ window.app = Vue.createApp({ userWalletId: '', loading: false }, - addAccountDialog: { - show: false, - rootType: 'Expenses', - subPath: '', - description: '', - loading: false - }, receivableDialog: { show: false, selectedUser: '', @@ -215,8 +207,8 @@ window.app = Vue.createApp({ accountTypeOptions() { return [ { label: 'All Types', value: null }, - { label: 'Receivable (User owes Libra)', value: 'asset' }, - { label: 'Payable (Libra owes User)', value: 'liability' }, + { label: 'Receivable (User owes Castle)', value: 'asset' }, + { label: 'Payable (Castle owes User)', value: 'liability' }, { label: 'Equity (User Balance)', value: 'equity' } ] }, @@ -293,16 +285,6 @@ 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 => { @@ -335,7 +317,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/libra/api/v1/balance', + '/castle/api/v1/balance', this.g.user.wallets[0].inkey ) this.balance = response.data @@ -358,7 +340,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/libra/api/v1/balances/all', + '/castle/api/v1/balances/all', this.g.user.wallets[0].adminkey ) this.allUserBalances = response.data @@ -406,7 +388,7 @@ window.app = Vue.createApp({ const response = await LNbits.api.request( 'GET', - `/libra/api/v1/entries/user?${queryParams}`, + `/castle/api/v1/entries/user?${queryParams}`, this.g.user.wallets[0].inkey ) @@ -475,7 +457,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/libra/api/v1/accounts?filter_by_user=true&exclude_virtual=true', + '/castle/api/v1/accounts?filter_by_user=true&exclude_virtual=true', this.g.user.wallets[0].inkey ) this.accounts = response.data @@ -489,7 +471,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/libra/api/v1/currencies', + '/castle/api/v1/currencies', this.g.user.wallets[0].inkey ) this.currencies = response.data @@ -501,7 +483,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/libra/api/v1/users', + '/castle/api/v1/users', this.g.user.wallets[0].adminkey ) this.users = response.data @@ -513,7 +495,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/libra/api/v1/user/info', + '/castle/api/v1/user/info', this.g.user.wallets[0].inkey ) this.userInfo = response.data @@ -527,18 +509,18 @@ window.app = Vue.createApp({ // Try with admin key first to check settings const response = await LNbits.api.request( 'GET', - '/libra/api/v1/settings', + '/castle/api/v1/settings', this.g.user.wallets[0].inkey ) this.settings = response.data - this.libraWalletConfigured = !!(this.settings && this.settings.libra_wallet_id) + this.castleWalletConfigured = !!(this.settings && this.settings.castle_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.libraWalletConfigured = false + this.castleWalletConfigured = false } finally { // Mark settings as loaded to enable toolbar buttons this.settingsLoaded = true @@ -548,7 +530,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/libra/api/v1/user/wallet', + '/castle/api/v1/user/wallet', this.g.user.wallets[0].inkey ) this.userWalletSettings = response.data @@ -557,85 +539,10 @@ window.app = Vue.createApp({ this.userWalletConfigured = false } }, - async syncAccounts() { - this.syncingAccounts = true - try { - const {data} = await LNbits.api.request( - 'POST', - '/libra/api/v1/admin/accounts/sync', - this.g.user.wallets[0].adminkey - ) - const errors = (data?.errors || []).length - const message = `Synced: ${data?.accounts_added ?? 0} added, ` + - `${data?.accounts_reactivated ?? 0} reactivated, ` + - `${data?.accounts_deactivated ?? 0} deactivated, ` + - `${data?.virtual_parents_created ?? 0} virtual parents` + - (errors ? `, ${errors} errors` : '') - this.$q.notify({ - type: errors ? 'warning' : 'positive', - message, - timeout: errors ? 8000 : 4000 - }) - await this.loadAccounts() - } catch (error) { - LNbits.utils.notifyApiError(error) - } finally { - this.syncingAccounts = false - } - }, - 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.libraWalletId = this.settings?.libra_wallet_id || '' + this.settingsDialog.castleWalletId = this.settings?.castle_wallet_id || '' this.settingsDialog.favaUrl = this.settings?.fava_url || 'http://localhost:3333' - this.settingsDialog.favaLedgerSlug = this.settings?.fava_ledger_slug || 'libra-ledger' + this.settingsDialog.favaLedgerSlug = this.settings?.fava_ledger_slug || 'castle-ledger' this.settingsDialog.favaTimeout = this.settings?.fava_timeout || 10.0 this.settingsDialog.show = true }, @@ -644,10 +551,10 @@ window.app = Vue.createApp({ this.userWalletDialog.show = true }, async submitSettings() { - if (!this.settingsDialog.libraWalletId) { + if (!this.settingsDialog.castleWalletId) { this.$q.notify({ type: 'warning', - message: 'Libra Wallet ID is required' + message: 'Castle Wallet ID is required' }) return } @@ -664,12 +571,12 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'PUT', - '/libra/api/v1/settings', + '/castle/api/v1/settings', this.g.user.wallets[0].adminkey, { - libra_wallet_id: this.settingsDialog.libraWalletId, + castle_wallet_id: this.settingsDialog.castleWalletId, fava_url: this.settingsDialog.favaUrl, - fava_ledger_slug: this.settingsDialog.favaLedgerSlug || 'libra-ledger', + fava_ledger_slug: this.settingsDialog.favaLedgerSlug || 'castle-ledger', fava_timeout: parseFloat(this.settingsDialog.favaTimeout) || 10.0 } ) @@ -679,7 +586,7 @@ window.app = Vue.createApp({ }) this.settingsDialog.show = false await this.loadSettings() - // Reload user wallet to reflect libra wallet for super user + // Reload user wallet to reflect castle wallet for super user if (this.isSuperUser) { await this.loadUserWallet() } @@ -702,7 +609,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'PUT', - '/libra/api/v1/user/wallet', + '/castle/api/v1/user/wallet', this.g.user.wallets[0].inkey, { user_wallet_id: this.userWalletDialog.userWalletId @@ -725,7 +632,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'POST', - '/libra/api/v1/entries/expense', + '/castle/api/v1/entries/expense', this.g.user.wallets[0].inkey, { description: this.expenseDialog.description, @@ -762,10 +669,10 @@ window.app = Vue.createApp({ } try { - // Generate an invoice on the Libra wallet + // Generate an invoice on the Castle wallet const response = await LNbits.api.request( 'POST', - '/libra/api/v1/generate-payment-invoice', + '/castle/api/v1/generate-payment-invoice', this.g.user.wallets[0].inkey, { amount: this.payDialog.amount @@ -811,7 +718,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'POST', - '/libra/api/v1/record-payment', + '/castle/api/v1/record-payment', this.g.user.wallets[0].inkey, { payment_hash: paymentHash @@ -854,15 +761,15 @@ window.app = Vue.createApp({ }, showManualPaymentOption() { // This is for when user wants to pay their debt manually - // For now, just notify them to contact libra + // For now, just notify them to contact castle this.$q.notify({ type: 'info', - message: 'Please contact Libra directly to arrange manual payment.', + message: 'Please contact Castle directly to arrange manual payment.', timeout: 3000 }) }, showManualPaymentDialog() { - // This is for when Libra owes user and they want to request manual payment + // This is for when Castle owes user and they want to request manual payment this.manualPaymentDialog.amount = Math.abs(this.balance.balance) this.manualPaymentDialog.description = '' this.manualPaymentDialog.show = true @@ -872,7 +779,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'POST', - '/libra/api/v1/manual-payment-request', + '/castle/api/v1/manual-payment-request', this.g.user.wallets[0].inkey, { amount: this.manualPaymentDialog.amount, @@ -897,8 +804,8 @@ window.app = Vue.createApp({ try { // If super user, load all requests; otherwise load user's own requests const endpoint = this.isSuperUser - ? '/libra/api/v1/manual-payment-requests/all' - : '/libra/api/v1/manual-payment-requests' + ? '/castle/api/v1/manual-payment-requests/all' + : '/castle/api/v1/manual-payment-requests' const key = this.isSuperUser ? this.g.user.wallets[0].adminkey : this.g.user.wallets[0].inkey @@ -921,7 +828,7 @@ window.app = Vue.createApp({ const response = await LNbits.api.request( 'GET', - '/libra/api/v1/entries/pending', + '/castle/api/v1/entries/pending', this.g.user.wallets[0].adminkey ) this.pendingExpenses = response.data @@ -933,7 +840,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'POST', - `/libra/api/v1/manual-payment-requests/${requestId}/approve`, + `/castle/api/v1/manual-payment-requests/${requestId}/approve`, this.g.user.wallets[0].adminkey ) this.$q.notify({ @@ -951,7 +858,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'POST', - `/libra/api/v1/manual-payment-requests/${requestId}/reject`, + `/castle/api/v1/manual-payment-requests/${requestId}/reject`, this.g.user.wallets[0].adminkey ) this.$q.notify({ @@ -967,7 +874,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'POST', - `/libra/api/v1/entries/${entryId}/approve`, + `/castle/api/v1/entries/${entryId}/approve`, this.g.user.wallets[0].adminkey ) this.$q.notify({ @@ -986,7 +893,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'POST', - `/libra/api/v1/entries/${entryId}/reject`, + `/castle/api/v1/entries/${entryId}/reject`, this.g.user.wallets[0].adminkey ) this.$q.notify({ @@ -1005,7 +912,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/libra/api/v1/assertions', + '/castle/api/v1/assertions', this.g.user.wallets[0].adminkey ) this.balanceAssertions = response.data @@ -1031,7 +938,7 @@ window.app = Vue.createApp({ await LNbits.api.request( 'POST', - '/libra/api/v1/assertions', + '/castle/api/v1/assertions', this.g.user.wallets[0].adminkey, payload ) @@ -1080,7 +987,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'POST', - `/libra/api/v1/assertions/${assertionId}/check`, + `/castle/api/v1/assertions/${assertionId}/check`, this.g.user.wallets[0].adminkey ) @@ -1099,7 +1006,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'DELETE', - `/libra/api/v1/assertions/${assertionId}`, + `/castle/api/v1/assertions/${assertionId}`, this.g.user.wallets[0].adminkey ) @@ -1128,7 +1035,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/libra/api/v1/reconciliation/summary', + '/castle/api/v1/reconciliation/summary', this.g.user.wallets[0].adminkey ) this.reconciliation.summary = response.data @@ -1142,7 +1049,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/libra/api/v1/reconciliation/discrepancies', + '/castle/api/v1/reconciliation/discrepancies', this.g.user.wallets[0].adminkey ) this.reconciliation.discrepancies = response.data @@ -1155,7 +1062,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'POST', - '/libra/api/v1/reconciliation/check-all', + '/castle/api/v1/reconciliation/check-all', this.g.user.wallets[0].adminkey ) @@ -1209,7 +1116,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'POST', - '/libra/api/v1/entries/receivable', + '/castle/api/v1/entries/receivable', this.g.user.wallets[0].adminkey, { description: this.receivableDialog.description, @@ -1252,7 +1159,7 @@ window.app = Vue.createApp({ this.receivableDialog.currency = null }, async showSettleReceivableDialog(userBalance) { - // Only show for users who owe libra (positive balance = receivable) + // Only show for users who owe castle (positive balance = receivable) if (userBalance.balance <= 0) return // Clear any existing polling @@ -1268,19 +1175,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 libra) + // Fetch receivable entries (user owes castle) const receivableResponse = await LNbits.api.request( 'GET', - `/libra/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`, + `/castle/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 (libra owes user) - these are netted in the settlement + // Also fetch expense entries (castle owes user) - these are netted in the settlement const expenseResponse = await LNbits.api.request( 'GET', - `/libra/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`, + `/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`, this.g.user.wallets[0].adminkey ) const expenseEntries = expenseResponse.data.unsettled_entries || [] @@ -1320,10 +1227,10 @@ window.app = Vue.createApp({ } try { - // Generate an invoice on the Libra wallet for the user to pay + // Generate an invoice on the Castle wallet for the user to pay const response = await LNbits.api.request( 'POST', - '/libra/api/v1/generate-payment-invoice', + '/castle/api/v1/generate-payment-invoice', this.g.user.wallets[0].adminkey, { amount: this.settleReceivableDialog.amount, @@ -1450,7 +1357,7 @@ window.app = Vue.createApp({ const response = await LNbits.api.request( 'POST', - '/libra/api/v1/receivables/settle', + '/castle/api/v1/receivables/settle', this.g.user.wallets[0].adminkey, payload ) @@ -1474,7 +1381,7 @@ window.app = Vue.createApp({ } }, async showPayUserDialog(userBalance) { - // Only show for users libra owes (negative balance = payable) + // Only show for users castle owes (negative balance = payable) if (userBalance.balance >= 0) return // Extract fiat balances (e.g., EUR) @@ -1482,26 +1389,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 = libra owes user) + // Use absolute values since balance is negative (liability = castle 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 (libra owes user) + // Fetch expense entries (castle owes user) const expenseResponse = await LNbits.api.request( 'GET', - `/libra/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`, + `/castle/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 libra) - these are netted in the settlement + // Also fetch receivable entries (user owes castle) - these are netted in the settlement const receivableResponse = await LNbits.api.request( 'GET', - `/libra/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`, + `/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`, this.g.user.wallets[0].adminkey ) const receivableEntries = receivableResponse.data.unsettled_entries || [] @@ -1514,10 +1421,10 @@ window.app = Vue.createApp({ show: true, user_id: userBalance.user_id, username: userBalance.username, - maxAmount: maxAmountSats, // Positive sats amount libra owes + maxAmount: maxAmountSats, // Positive sats amount castle owes maxAmountFiat: maxAmountFiat, // EUR or other fiat amount (positive) fiatCurrency: fiatCurrency, - amount: maxAmountSats, // Default to sats since lightning is the default payment method + amount: fiatCurrency ? maxAmountFiat : maxAmountSats, // Default to fiat if available payment_method: 'lightning', // Default to lightning for paying description: '', reference: '', @@ -1546,14 +1453,13 @@ window.app = Vue.createApp({ { out: false, amount: this.payUserDialog.amount, - memo: `Payment from Libra to ${this.payUserDialog.username}` + memo: `Payment from Castle to ${this.payUserDialog.username}` } ) - console.log(invoiceResponse) - const paymentRequest = invoiceResponse.data.bolt11 + const paymentRequest = invoiceResponse.data.payment_request - // Pay the invoice from Libra's wallet + // Pay the invoice from Castle's wallet const paymentResponse = await LNbits.api.request( 'POST', `/api/v1/payments`, @@ -1564,7 +1470,7 @@ window.app = Vue.createApp({ } ) - // Record the payment in Libra accounting + // Record the payment in Castle accounting const payPayload = { user_id: this.payUserDialog.user_id, amount: this.payUserDialog.amount, @@ -1579,7 +1485,7 @@ window.app = Vue.createApp({ await LNbits.api.request( 'POST', - '/libra/api/v1/payables/pay', + '/castle/api/v1/payables/pay', this.g.user.wallets[0].adminkey, payPayload ) @@ -1645,7 +1551,7 @@ window.app = Vue.createApp({ const response = await LNbits.api.request( 'POST', - '/libra/api/v1/payables/pay', + '/castle/api/v1/payables/pay', this.g.user.wallets[0].adminkey, payload ) @@ -1672,7 +1578,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - `/libra/api/v1/user-wallet/${userId}`, + `/castle/api/v1/user-wallet/${userId}`, this.g.user.wallets[0].adminkey ) return response.data @@ -1708,34 +1614,6 @@ 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', @@ -1757,13 +1635,13 @@ window.app = Vue.createApp({ return null }, isReceivable(entry) { - // Check if this is a receivable entry (user owes libra) + // Check if this is a receivable entry (user owes castle) 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 (libra owes user) + // Check if this is a payable entry (castle owes user) if (entry.tags && entry.tags.includes('expense-entry')) return true if (entry.account && entry.account.includes('Payable')) return true return false @@ -1773,10 +1651,6 @@ 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 b2a4f9d..4cc54f0 100644 --- a/static/js/permissions.js +++ b/static/js/permissions.js @@ -53,11 +53,6 @@ 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', @@ -211,7 +206,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/libra/api/v1/admin/permissions', + '/castle/api/v1/admin/permissions', this.g.user.wallets[0].adminkey ) this.permissions = response.data @@ -233,7 +228,7 @@ window.app = Vue.createApp({ // Admin permissions UI needs to see virtual accounts to grant permissions on them const response = await LNbits.api.request( 'GET', - '/libra/api/v1/accounts?exclude_virtual=false', + '/castle/api/v1/accounts?exclude_virtual=false', this.g.user.wallets[0].inkey ) this.accounts = response.data @@ -256,7 +251,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/libra/api/v1/admin/libra-users', + '/castle/api/v1/admin/castle-users', this.g.user.wallets[0].adminkey ) this.users = response.data || [] @@ -323,7 +318,7 @@ window.app = Vue.createApp({ await LNbits.api.request( 'POST', - '/libra/api/v1/admin/permissions', + '/castle/api/v1/admin/permissions', this.g.user.wallets[0].adminkey, payload ) @@ -362,7 +357,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'DELETE', - `/libra/api/v1/admin/permissions/${this.permissionToRevoke.id}`, + `/castle/api/v1/admin/permissions/${this.permissionToRevoke.id}`, this.g.user.wallets[0].adminkey ) @@ -433,7 +428,7 @@ window.app = Vue.createApp({ const response = await LNbits.api.request( 'POST', - '/libra/api/v1/admin/permissions/bulk-grant', + '/castle/api/v1/admin/permissions/bulk-grant', this.g.user.wallets[0].adminkey, payload ) @@ -506,8 +501,6 @@ window.app = Vue.createApp({ return 'blue' case 'submit_expense': return 'green' - case 'submit_income': - return 'teal' case 'manage': return 'red' default: @@ -521,8 +514,6 @@ window.app = Vue.createApp({ return 'visibility' case 'submit_expense': return 'add_circle' - case 'submit_income': - return 'payments' case 'manage': return 'admin_panel_settings' default: @@ -544,7 +535,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/libra/api/v1/admin/equity-eligibility', + '/castle/api/v1/admin/equity-eligibility', this.g.user.wallets[0].adminkey ) this.equityEligibleUsers = response.data || [] @@ -582,7 +573,7 @@ window.app = Vue.createApp({ await LNbits.api.request( 'POST', - '/libra/api/v1/admin/equity-eligibility', + '/castle/api/v1/admin/equity-eligibility', this.g.user.wallets[0].adminkey, payload ) @@ -621,7 +612,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'DELETE', - `/libra/api/v1/admin/equity-eligibility/${this.equityToRevoke.user_id}`, + `/castle/api/v1/admin/equity-eligibility/${this.equityToRevoke.user_id}`, this.g.user.wallets[0].adminkey ) @@ -664,7 +655,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/libra/api/v1/admin/roles', + '/castle/api/v1/admin/roles', this.g.user.wallets[0].adminkey ) this.roles = response.data || [] @@ -687,7 +678,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - `/libra/api/v1/admin/roles/${role.id}`, + `/castle/api/v1/admin/roles/${role.id}`, this.g.user.wallets[0].adminkey ) @@ -709,7 +700,7 @@ window.app = Vue.createApp({ } }, - async editRole(role) { + editRole(role) { this.editingRole = true this.selectedRole = role this.roleForm = { @@ -717,28 +708,6 @@ 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 }, @@ -764,7 +733,7 @@ window.app = Vue.createApp({ // Update existing role await LNbits.api.request( 'PUT', - `/libra/api/v1/admin/roles/${this.selectedRole.id}`, + `/castle/api/v1/admin/roles/${this.selectedRole.id}`, this.g.user.wallets[0].adminkey, payload ) @@ -778,7 +747,7 @@ window.app = Vue.createApp({ // Create new role await LNbits.api.request( 'POST', - '/libra/api/v1/admin/roles', + '/castle/api/v1/admin/roles', this.g.user.wallets[0].adminkey, payload ) @@ -817,7 +786,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'DELETE', - `/libra/api/v1/admin/roles/${this.roleToDelete.id}`, + `/castle/api/v1/admin/roles/${this.roleToDelete.id}`, this.g.user.wallets[0].adminkey ) @@ -846,8 +815,6 @@ window.app = Vue.createApp({ this.showCreateRoleDialog = false this.editingRole = false this.selectedRole = null - this.roleUsersForView = [] - this.rolePermissionsForView = [] this.resetRoleForm() }, @@ -895,7 +862,7 @@ window.app = Vue.createApp({ await LNbits.api.request( 'POST', - '/libra/api/v1/admin/user-roles', + '/castle/api/v1/admin/user-roles', this.g.user.wallets[0].adminkey, payload ) @@ -953,7 +920,7 @@ window.app = Vue.createApp({ try { const response = await LNbits.api.request( 'GET', - '/libra/api/v1/admin/users/roles', + '/castle/api/v1/admin/users/roles', this.g.user.wallets[0].adminkey ) @@ -1017,7 +984,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'DELETE', - `/libra/api/v1/admin/user-roles/${this.userRoleToRevoke.id}`, + `/castle/api/v1/admin/user-roles/${this.userRoleToRevoke.id}`, this.g.user.wallets[0].adminkey ) @@ -1066,7 +1033,7 @@ window.app = Vue.createApp({ } await LNbits.api.request( 'POST', - `/libra/api/v1/admin/roles/${this.selectedRole.id}/permissions`, + `/castle/api/v1/admin/roles/${this.selectedRole.id}/permissions`, this.g.user.wallets[0].adminkey, payload ) @@ -1100,7 +1067,7 @@ window.app = Vue.createApp({ try { await LNbits.api.request( 'DELETE', - `/libra/api/v1/admin/roles/${this.selectedRole.id}/permissions/${permissionId}`, + `/castle/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 8ed5a33..8ec83b9 100644 --- a/tasks.py +++ b/tasks.py @@ -1,5 +1,5 @@ """ -Background tasks for Libra accounting extension. +Background tasks for Castle 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"[LIBRA] Daily reconciliation check: {results['failed']} FAILED assertions!") + print(f"[CASTLE] 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"[LIBRA] Daily reconciliation check: All {results['passed']} assertions passed ✓") + print(f"[CASTLE] Daily reconciliation check: All {results['passed']} assertions passed ✓") return results @@ -78,7 +78,7 @@ async def scheduled_daily_reconciliation(): This function is meant to be called by a scheduler (cron, systemd timer, etc.) or by LNbits background task system. """ - print(f"[LIBRA] Running scheduled daily reconciliation check at {datetime.now()}") + print(f"[CASTLE] 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"[LIBRA] WARNING: {results['failed']} balance assertions failed!") + print(f"[CASTLE] WARNING: {results['failed']} balance assertions failed!") # Future: Send alert notification return results except Exception as e: - print(f"[LIBRA] Error in scheduled reconciliation: {e}") + print(f"[CASTLE] Error in scheduled reconciliation: {e}") raise async def scheduled_account_sync(): """ - Scheduled task that runs hourly to sync accounts from Beancount to Libra DB. + Scheduled task that runs hourly to sync accounts from Beancount to Castle DB. - This ensures Libra DB stays in sync with Beancount (source of truth) by - automatically adding any new accounts created in Beancount to Libra's + This ensures Castle DB stays in sync with Beancount (source of truth) by + automatically adding any new accounts created in Beancount to Castle's metadata database for permission tracking. """ from .account_sync import sync_accounts_from_beancount - logger.info(f"[LIBRA] Running scheduled account sync at {datetime.now()}") + logger.info(f"[CASTLE] 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"[LIBRA] Account sync: Added {stats['accounts_added']} new accounts" + f"[CASTLE] Account sync: Added {stats['accounts_added']} new accounts" ) if stats["errors"]: logger.warning( - f"[LIBRA] Account sync: {len(stats['errors'])} errors encountered" + f"[CASTLE] Account sync: {len(stats['errors'])} errors encountered" ) for error in stats["errors"][:5]: # Log first 5 errors logger.error(f" - {error}") @@ -125,31 +125,24 @@ async def scheduled_account_sync(): return stats except Exception as e: - logger.error(f"[LIBRA] Error in scheduled account sync: {e}") + logger.error(f"[CASTLE] Error in scheduled account sync: {e}") raise async def wait_for_account_sync(): """ - Background task that periodically syncs accounts from Beancount to Libra DB. + Background task that periodically syncs accounts from Beancount to Castle DB. - Runs hourly to ensure Libra DB stays in sync with Beancount. - - Blocks on `wait_for_fava_client()` before the first iteration so we don't - race the fire-and-forget `_init_fava()` started in `libra_start()` and - fail the first sync with "Fava client not initialized". + Runs hourly to ensure Castle DB stays in sync with Beancount. """ - from .fava_client import wait_for_fava_client - - logger.info("[LIBRA] Account sync background task started") - await wait_for_fava_client() + logger.info("[CASTLE] Account sync background task started") while True: try: # Run sync await scheduled_account_sync() except Exception as e: - logger.error(f"[LIBRA] Account sync error: {e}") + logger.error(f"[CASTLE] Account sync error: {e}") # Wait 1 hour before next sync await asyncio.sleep(3600) # 3600 seconds = 1 hour @@ -164,9 +157,9 @@ def start_daily_reconciliation_task(): For cron setup: # Run daily at 2 AM - 0 2 * * * curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY" + 0 2 * * * curl -X POST http://localhost:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY" """ - print("[LIBRA] Daily reconciliation task registered") + print("[CASTLE] 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 @@ -180,7 +173,7 @@ async def wait_for_paid_invoices(): before the payment is detected by client-side polling. """ invoice_queue = Queue() - register_invoice_listener(invoice_queue, "ext_libra") + register_invoice_listener(invoice_queue, "ext_castle") while True: payment = await invoice_queue.get() @@ -189,10 +182,10 @@ async def wait_for_paid_invoices(): async def on_invoice_paid(payment: Payment) -> None: """ - Handle a paid Libra invoice by automatically submitting to Fava. + Handle a paid Castle invoice by automatically submitting to Fava. - 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 + 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 Beancount via Fava. Concurrency Protection: @@ -201,13 +194,13 @@ async def on_invoice_paid(payment: Payment) -> None: - Uses idempotent entry creation to prevent duplicate entries even if the same payment is processed multiple times """ - # Only process Libra-specific payments - if not payment.extra or payment.extra.get("tag") != "libra": + # Only process Castle-specific payments + if not payment.extra or payment.extra.get("tag") != "castle": return user_id = payment.extra.get("user_id") if not user_id: - logger.warning(f"Libra invoice {payment.payment_hash} missing user_id in metadata") + logger.warning(f"Castle invoice {payment.payment_hash} missing user_id in metadata") return from .fava_client import get_fava_client @@ -223,7 +216,7 @@ async def on_invoice_paid(payment: Payment) -> None: user_lock = fava.get_user_lock(user_id) async with user_lock: - logger.info(f"Recording Libra payment {payment.payment_hash} for user {user_id[:8]} to Fava") + logger.info(f"Recording Castle payment {payment.payment_hash} for user {user_id[:8]} to Fava") try: from decimal import Decimal @@ -253,14 +246,14 @@ async def on_invoice_paid(payment: Payment) -> None: total_fiat_balance = fiat_balances.get(fiat_currency, Decimal(0)) # Determine receivables and payables based on balance - # Positive balance = user owes libra (receivable) - # Negative balance = libra owes user (payable) + # Positive balance = user owes castle (receivable) + # Negative balance = castle owes user (payable) if total_fiat_balance > 0: - # User owes libra + # User owes castle total_receivable = total_fiat_balance total_payable = Decimal(0) else: - # Libra owes user + # Castle owes user total_receivable = Decimal(0) total_payable = abs(total_fiat_balance) @@ -325,5 +318,5 @@ async def on_invoice_paid(payment: Payment) -> None: ) except Exception as e: - logger.error(f"Error recording Libra payment {payment.payment_hash}: {e}") + logger.error(f"Error recording Castle payment {payment.payment_hash}: {e}") raise diff --git a/templates/libra/index.html b/templates/castle/index.html similarity index 89% rename from templates/libra/index.html rename to templates/castle/index.html index 5369f72..2a1e665 100644 --- a/templates/libra/index.html +++ b/templates/castle/index.html @@ -3,7 +3,7 @@ {% block scripts %} {{ window_vars(user) }} - + {% endblock %} {% block page %} @@ -13,7 +13,7 @@
    -
    Libra
    +
    🏰 Castle Accounting

    Track expenses, receivables, and balances for the collective

    @@ -21,14 +21,11 @@ Configure Your Wallet - + Manage Permissions (Admin) - - Sync Accounts from Beancount - - Libra Settings (Super User Only) + Castle Settings (Super User Only)
    @@ -36,19 +33,19 @@ - +
    - Setup Required: Libra Wallet ID must be configured before the extension can function. + Setup Required: Castle Wallet ID must be configured before the extension can function.
    - + @@ -57,7 +54,7 @@ - + @@ -69,19 +66,18 @@ - + -
    Pending Approvals
    +
    Pending Expense Approvals
    + + + Pending approval + + - - - {% raw %}{{ entry.description }}{% endraw %} {% raw %}{{ formatDate(entry.entry_date) }}{% endraw %} @@ -132,13 +128,13 @@ Add Expense - - Libra wallet must be configured first + + Castle wallet must be configured first - + You must configure your wallet first @@ -146,14 +142,14 @@ v-if="isSuperUser" color="orange" @click="showReceivableDialog" - :disable="!libraWalletConfigured" + :disable="!castleWalletConfigured" > Add Receivable - - Libra wallet must be configured first + + Castle wallet must be configured first - Record when a user owes the organization + Record when a user owes the Castle
    @@ -187,33 +183,22 @@ @@ -269,7 +254,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 Libra' : 'Libra owes you' }}{% endraw %} + {% raw %}{{ balance.balance >= 0 ? 'You owe Castle' : 'Castle owes you' }}{% endraw %}
    @@ -857,20 +842,7 @@ -
    -
    Chart of Accounts
    - - -
    +
    Chart of Accounts
    @@ -949,7 +921,7 @@ dense v-model="expenseDialog.isEquity" :options="[ - {label: 'Liability (Libra owes me)', value: false}, + {label: 'Liability (Castle owes me)', value: false}, {label: 'Equity (My contribution)', value: true} ]" option-label="label" @@ -957,7 +929,7 @@ emit-value map-options label="Type *" - hint="Choose whether this is a liability (Libra owes you) or an equity contribution" + hint="Choose whether this is a liability (Castle owes you) or an equity contribution" > @@ -966,9 +938,9 @@ filled dense readonly - :model-value="'Liability (Libra owes me)'" + :model-value="'Liability (Castle owes me)'" label="Type" - hint="This expense will be recorded as a liability (Libra owes you)" + hint="This expense will be recorded as a liability (Castle owes you)" >