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
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
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
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
; 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):
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
- ASC 105-10-05: Substance Over Form
- Beancount Documentation:
http://furius.ca/beancount/doc/index
-- Libra Extension:
+
- Castle Extension:
docs/SATS-EQUIVALENT-METADATA.md
- BQL Analysis:
docs/BQL-BALANCE-QUERIES.md
@@ -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.