diff --git a/CLAUDE.md b/CLAUDE.md index 3086441..97e546f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Castle Accounting is a double-entry bookkeeping extension for LNbits that enables collectives (co-living spaces, makerspaces, community projects) to track finances with proper accounting principles. It integrates Lightning Network payments with traditional accounting, supporting both cryptocurrency and fiat currency tracking. +Libra is a double-entry bookkeeping extension for LNbits that enables collectives (co-living spaces, makerspaces, community projects) to track finances with proper accounting principles. It integrates Lightning Network payments with traditional accounting, supporting both cryptocurrency and fiat currency tracking. ## Architecture @@ -12,9 +12,9 @@ Castle Accounting is a double-entry bookkeeping extension for LNbits that enable **Double-Entry Accounting**: Every transaction affects at least two accounts. Debits must equal credits. Five account types: Assets, Liabilities, Equity, Revenue (Income), Expenses. -**Fava/Beancount Backend**: Castle now uses [Fava](https://github.com/beancount/fava) as the primary accounting engine. Fava is a web interface for Beancount that provides a REST API for ledger operations. All accounting calculations (balance sheets, trial balances, account reports) are delegated to Fava/Beancount. Castle formats transactions as Beancount entries and submits them via Fava's API. +**Fava/Beancount Backend**: Libra now uses [Fava](https://github.com/beancount/fava) as the primary accounting engine. Fava is a web interface for Beancount that provides a REST API for ledger operations. All accounting calculations (balance sheets, trial balances, account reports) are delegated to Fava/Beancount. Libra formats transactions as Beancount entries and submits them via Fava's API. -**Required External Dependency**: Fava must be running as a separate service. Configure `fava_url` and `fava_ledger_slug` in Castle settings (default: `http://localhost:3333` with slug `castle-accounting`). Castle will not function without Fava. +**Required External Dependency**: Fava must be running as a separate service. Configure `fava_url` and `fava_ledger_slug` in Libra settings (default: `http://localhost:3333` with slug `libra-accounting`). Libra will not function without Fava. **Pure Functional Core**: The `core/` directory contains pure accounting logic independent of the database layer: - `core/validation.py` - Entry validation rules @@ -44,23 +44,19 @@ Castle Accounting is a double-entry bookkeeping extension for LNbits that enable - `tasks.py` - Background tasks (invoice payment monitoring) - `account_utils.py` - Hierarchical account naming utilities - `fava_client.py` - HTTP client for Fava REST API (add_entry, query, balance_sheet) -- `beancount_format.py` - Converts Castle entries to Beancount transaction format +- `beancount_format.py` - Converts Libra entries to Beancount transaction format - `core/validation.py` - Pure validation functions for accounting rules ### Database Schema -**Note**: With Fava integration, Castle maintains a local cache of some data but delegates authoritative balance calculations to Beancount/Fava. +**Fava is the sole source of truth for journal entries.** Libra does NOT maintain a local mirror of transactions — the previous `journal_entries` and `entry_lines` tables were removed during the Fava migration. All transaction reads (history, balances, summaries) go through Fava's HTTP API; writes go through `PUT /api/add_entries` and are serialized via `FavaClient._write_lock`. When retrieving journal entries from Fava for UI display, results are enriched with a `username` field from LNbits user data. -**journal_entries**: Transaction headers stored locally and synced to Fava -- `flag` field: `*` (cleared), `!` (pending), `#` (flagged), `x` (void) -- `meta` field: JSON storing source, tags, audit info -- `reference` field: Links to payment_hash, invoice numbers, etc. -- Enriched with `username` field when retrieved via API (added from LNbits user data) +The SQLite tables below hold **operational state** that Fava doesn't (and shouldn't) own — workflow, RBAC, settings, reconciliation assertions. None of it is derivable from the Beancount file; they are independent stores, not caches. -**extension_settings**: Castle wallet configuration (admin-only) -- `castle_wallet_id` - The LNbits wallet used for Castle operations +**extension_settings**: Libra wallet configuration (admin-only) +- `libra_wallet_id` - The LNbits wallet used for Libra operations - `fava_url` - Fava service URL (default: http://localhost:3333) -- `fava_ledger_slug` - Ledger identifier in Fava (default: castle-accounting) +- `fava_ledger_slug` - Ledger identifier in Fava (default: libra-accounting) - `fava_timeout` - API request timeout in seconds **user_wallet_settings**: Per-user wallet configuration @@ -70,22 +66,22 @@ Castle Accounting is a double-entry bookkeeping extension for LNbits that enable ## Transaction Flows ### User Adds Expense (Liability) -User pays cash for groceries, Castle owes them: +User pays cash for groceries, Libra owes them: ``` DR Expenses:Food 39,669 sats CR Liabilities:Payable:User-af983632 39,669 sats ``` Metadata preserves: `{"fiat_currency": "EUR", "fiat_amount": "36.93", "fiat_rate": "1074.192"}` -### Castle Adds Receivable -User owes Castle for accommodation: +### Libra Adds Receivable +User owes Libra for accommodation: ``` DR Assets:Receivable:User-af983632 268,548 sats CR Income:Accommodation 268,548 sats ``` ### User Pays with Lightning -Invoice generated on **Castle's wallet** (not user's). After payment: +Invoice generated on **Libra's wallet** (not user's). After payment: ``` DR Assets:Lightning:Balance 268,548 sats CR Assets:Receivable:User-af983632 268,548 sats @@ -101,14 +97,14 @@ DR Liabilities:Payable:User-af983632 39,669 sats ## Balance Calculation Logic **User Balance** (calculated by Beancount via Fava): -- Positive = Castle owes user (LIABILITY accounts have credit balance) -- Negative = User owes Castle (ASSET accounts have debit balance) +- Positive = Libra owes user (LIABILITY accounts have credit balance) +- Negative = User owes Libra (ASSET accounts have debit balance) - Calculated by querying Fava for sum of all postings across user's accounts - Fiat balances calculated by Beancount from cost basis annotations, NOT converted from current sats **Perspective-Based UI**: -- **User View**: Green = Castle owes them, Red = They owe Castle -- **Castle Admin View**: Green = User owes Castle, Red = Castle owes user +- **User View**: Green = Libra owes them, Red = They owe Libra +- **Libra Admin View**: Green = User owes Libra, Red = Libra owes user **Balance Retrieval**: Use `GET /api/v1/balance` which queries Fava's balance sheet or account reports for accurate, Beancount-calculated balances. @@ -127,12 +123,12 @@ DR Liabilities:Payable:User-af983632 39,669 sats - `POST /api/v1/entries` - Create raw journal entry (admin only) ### Payments & Balances -- `GET /api/v1/balance` - Get user balance (or Castle total if super user) +- `GET /api/v1/balance` - Get user balance (or Libra total if super user) - `GET /api/v1/balances/all` - Get all user balances (admin, enriched with usernames) -- `POST /api/v1/generate-payment-invoice` - Generate invoice for user to pay Castle -- `POST /api/v1/record-payment` - Record Lightning payment from user to Castle +- `POST /api/v1/generate-payment-invoice` - Generate invoice for user to pay Libra +- `POST /api/v1/record-payment` - Record Lightning payment from user to Libra - `POST /api/v1/settle-receivable` - Manually settle receivable (cash/bank) -- `POST /api/v1/pay-user` - Castle pays user (cash/bank/lightning) +- `POST /api/v1/pay-user` - Libra pays user (cash/bank/lightning) ### Manual Payment Requests - `POST /api/v1/manual-payment-requests` - User requests payment @@ -148,8 +144,8 @@ DR Liabilities:Payable:User-af983632 39,669 sats - `POST /api/v1/tasks/daily-reconciliation` - Run daily reconciliation (admin) ### Settings -- `GET /api/v1/settings` - Get Castle settings (super user) -- `PUT /api/v1/settings` - Update Castle settings (super user) +- `GET /api/v1/settings` - Get Libra settings (super user) +- `PUT /api/v1/settings` - Update Libra settings (super user) - `GET /api/v1/user/wallet` - Get user wallet settings - `PUT /api/v1/user/wallet` - Update user wallet settings @@ -213,7 +209,8 @@ entry = format_transaction( {"account": "Liabilities:Payable:User-abc123", "amount": "-50000 SATS"} ], tags=["groceries"], - links=["castle-entry-123"] + links=["exp-a1b2c3d4e5f60708"], # typed settlement link; identity goes in entry-id metadata + meta={"entry-id": "a1b2c3d4e5f60708"} ) # Submit to Fava @@ -221,6 +218,8 @@ client = get_fava_client() result = await client.add_entry(entry) ``` +Prefer the purpose-built formatters (`format_expense_entry`, `format_income_entry`, …) over raw `format_transaction` — they write the `entry-id` metadata and typed links for you (see Data Integrity → Entry Identity & Links). + **Querying Balances**: ```python # Query user balance from Fava @@ -241,15 +240,15 @@ balance_result = await client.query( ### Extension as LNbits Module This extension follows LNbits extension structure: -- Registered via `castle_ext` router in `__init__.py` +- Registered via `libra_ext` router in `__init__.py` - Static files served from `static/` directory -- Templates in `templates/castle/` -- Database accessed via `db = Database("ext_castle")` +- Templates in `templates/libra/` +- Database accessed via `db = Database("ext_libra")` **Startup Requirements**: -- `castle_start()` initializes Fava client on extension load +- `libra_start()` initializes Fava client on extension load - Background task `wait_for_paid_invoices()` monitors Lightning invoice payments -- Fava service MUST be running before starting LNbits with Castle extension +- Fava service MUST be running before starting LNbits with Libra extension ## Common Tasks @@ -282,7 +281,8 @@ entry = format_transaction( {"account": "Assets:Lightning:Balance", "amount": "-50000 SATS"} ], tags=["utilities"], - links=["castle-tx-123"] + links=["exp-0123456789abcdef"], + meta={"entry-id": "0123456789abcdef"} ) client = get_fava_client() @@ -310,6 +310,13 @@ result = await client.query(query) 3. User accounts use `user_id` (NOT `wallet_id`) for consistency 4. All accounting calculations delegated to Beancount/Fava +**Entry Identity & Links** (the contract `_extract_entry_id()` in views_api.py relies on): +- The `entry-id` **transaction metadata** is the single canonical entry identifier. Every libra-authored entry formatter (`format_expense_entry`, `format_receivable_entry`, `format_income_entry`, `format_revenue_entry`) writes it. All id resolution (pending list, user journal, approve, reject) reads it — never parse links to recover an id. +- Typed links `exp-{id}` / `rcv-{id}` / `inc-{id}` exist for **settlement tracking** (BQL queries match settlements to source entries by these links). They duplicate the id but are not the identity source. +- A user-supplied `reference` (invoice number, receipt id) becomes **its own sanitized link**, verbatim — never fused with the entry id, never displacing a system link. Two entries sharing a reference share the link (desired Beancount semantics). +- `ln-{payment_hash[:16]}` links mark Lightning payments. +- Legacy ledger history (pre-dfdcc44) carries a single `libra-{id}` link and no `entry-id` metadata — `_extract_entry_id()` falls back to parsing it. Do not write `libra-` links in new code. + **Validation** is performed in `core/validation.py`: - Pure validation functions for entry correctness before submitting to Fava @@ -337,24 +344,24 @@ result = await client.query(query) ### Prerequisites 1. **LNbits**: This extension must be installed in the `lnbits/extensions/` directory -2. **Fava Service**: Must be running before starting LNbits with Castle enabled +2. **Fava Service**: Must be running before starting LNbits with Libra enabled ```bash # Install Fava pip install fava # Create a basic Beancount file - touch castle-ledger.beancount + touch libra-ledger.beancount # Start Fava (default: http://localhost:3333) - fava castle-ledger.beancount + fava libra-ledger.beancount ``` -3. **Configure Castle Settings**: Set `fava_url` and `fava_ledger_slug` via settings API or UI +3. **Configure Libra Settings**: Set `fava_url` and `fava_ledger_slug` via settings API or UI -### Running Castle Extension +### Running Libra Extension -Castle is loaded as part of LNbits. No separate build or test commands are needed for the extension itself. Development workflow: +Libra is loaded as part of LNbits. No separate build or test commands are needed for the extension itself. Development workflow: -1. Modify code in `lnbits/extensions/castle/` +1. Modify code in `lnbits/extensions/libra/` 2. Restart LNbits 3. Extension hot-reloads are supported by LNbits in development mode @@ -363,13 +370,13 @@ Castle is loaded as part of LNbits. No separate build or test commands are neede Use the web UI or API endpoints to create test transactions. For API testing: ```bash -# Create expense (user owes Castle) -curl -X POST http://localhost:5000/castle/api/v1/entries/expense \ +# Create expense (user owes Libra) +curl -X POST http://localhost:5000/libra/api/v1/entries/expense \ -H "X-Api-Key: YOUR_INVOICE_KEY" \ -d '{"description": "Test expense", "amount": "100.00 EUR", "account_name": "Expenses:Test"}' # Check user balance -curl http://localhost:5000/castle/api/v1/balance \ +curl http://localhost:5000/libra/api/v1/balance \ -H "X-Api-Key: YOUR_INVOICE_KEY" ``` diff --git a/MIGRATION_SQUASH_SUMMARY.md b/MIGRATION_SQUASH_SUMMARY.md index 8d86b9b..4b03ed0 100644 --- a/MIGRATION_SQUASH_SUMMARY.md +++ b/MIGRATION_SQUASH_SUMMARY.md @@ -1,11 +1,11 @@ -# Castle Migration Squash Summary +# Libra Migration Squash Summary **Date:** November 10, 2025 **Action:** Squashed 16 incremental migrations into a single clean initial migration ## Overview -The Castle extension had accumulated 16 migrations (m001-m016) during development. Since the software has not been released yet, we safely squashed all migrations into a single clean `m001_initial` migration. +The Libra extension had accumulated 16 migrations (m001-m016) during development. Since the software has not been released yet, we safely squashed all migrations into a single clean `m001_initial` migration. ## Files Changed @@ -16,37 +16,37 @@ The Castle extension had accumulated 16 migrations (m001-m016) during developmen The squashed migration creates **7 tables**: -### 1. castle_accounts +### 1. libra_accounts - Core chart of accounts with hierarchical Beancount-style names - Examples: "Assets:Bitcoin:Lightning", "Expenses:Food:Groceries" - User-specific accounts: "Assets:Receivable:User-af983632" - Includes comprehensive default account set (40+ accounts) -### 2. castle_extension_settings -- Castle-wide configuration -- Stores castle_wallet_id for Lightning payments +### 2. libra_extension_settings +- Libra-wide configuration +- Stores libra_wallet_id for Lightning payments -### 3. castle_user_wallet_settings +### 3. libra_user_wallet_settings - Per-user wallet configuration - Allows users to have separate wallet preferences -### 4. castle_manual_payment_requests -- User-submitted payment requests to Castle +### 4. libra_manual_payment_requests +- User-submitted payment requests to Libra - Reviewed by admins before processing - Includes notes field for additional context -### 5. castle_balance_assertions +### 5. libra_balance_assertions - Reconciliation and balance checking at specific dates - Multi-currency support (satoshis + fiat) - Tolerance checking for small discrepancies - Includes notes field for reconciliation comments -### 6. castle_user_equity_status +### 6. libra_user_equity_status - Manages equity contribution eligibility - Equity-eligible users can convert expenses to equity - Creates dynamic user-specific equity accounts: Equity:User-{user_id} -### 7. castle_account_permissions +### 7. libra_account_permissions - Granular access control for accounts - Permission types: read, submit_expense, manage - Supports hierarchical inheritance (parent permissions cascade) @@ -56,10 +56,10 @@ The squashed migration creates **7 tables**: The following tables were **intentionally NOT included** in the final schema (they were dropped in m016): -- **castle_journal_entries** - Journal entries now managed by Fava/Beancount (external source of truth) -- **castle_entry_lines** - Entry lines now managed by Fava/Beancount +- **libra_journal_entries** - Journal entries now managed by Fava/Beancount (external source of truth) +- **libra_entry_lines** - Entry lines now managed by Fava/Beancount -Castle now uses Fava as the single source of truth for accounting data. Journal operations: +Libra now uses Fava as the single source of truth for accounting data. Journal operations: - **Write:** Submit to Fava via FavaClient.add_entry() - **Read:** Query Fava via FavaClient.get_entries() @@ -106,7 +106,7 @@ For reference, the original migration sequence (preserved in migrations_old.py.b For new installations: ```bash -# Castle's migration system will run m001_initial automatically +# Libra's migration system will run m001_initial automatically # No manual intervention needed ``` @@ -174,20 +174,20 @@ After squashing, verify the migration works: ```bash # 1. Backup existing database (if any) -cp castle.sqlite3 castle.sqlite3.backup +cp libra.sqlite3 libra.sqlite3.backup # 2. Drop and recreate database to test fresh install -rm castle.sqlite3 +rm libra.sqlite3 # 3. Start LNbits - migration should run automatically poetry run lnbits # 4. Verify tables created -sqlite3 castle.sqlite3 ".tables" -# Should show: castle_accounts, castle_extension_settings, etc. +sqlite3 libra.sqlite3 ".tables" +# Should show: libra_accounts, libra_extension_settings, etc. # 5. Verify default accounts -sqlite3 castle.sqlite3 "SELECT COUNT(*) FROM castle_accounts;" +sqlite3 libra.sqlite3 "SELECT COUNT(*) FROM libra_accounts;" # Should show: 40 (default accounts) ``` @@ -200,12 +200,12 @@ If issues are discovered: cp migrations_old.py.bak migrations.py # Restore database -cp castle.sqlite3.backup castle.sqlite3 +cp libra.sqlite3.backup libra.sqlite3 ``` ## Notes -- This squash is safe because Castle has not been released yet +- This squash is safe because Libra has not been released yet - No existing production databases need migration - Historical migrations preserved in migrations_old.py.bak - All functionality preserved in final schema diff --git a/README.md b/README.md index 6174bfb..4f03cd3 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# Castle Accounting Extension for LNbits +# Libra Extension for LNbits A full-featured double-entry accounting system for collective projects, integrated with LNbits Lightning payments. ## Overview -Castle Accounting enables collectives like co-living spaces, makerspaces, and community projects to: +Libra enables collectives like co-living spaces, makerspaces, and community projects to: - Track expenses and revenue with proper accounting - Manage individual member balances - Record contributions as equity or reimbursable expenses @@ -17,7 +17,7 @@ This extension is designed to be installed in the `lnbits/extensions/` directory ```bash cd lnbits/extensions/ -# Copy or clone the castle directory here +# Copy or clone the libra directory here ``` Enable the extension through the LNbits admin interface or by adding it to your configuration. @@ -30,7 +30,7 @@ Enable the extension through the LNbits admin interface or by adding it to your - Choose "Liability" if you want reimbursement - Choose "Equity" if it's a contribution -2. **View Your Balance**: See if the Castle owes you money or vice versa +2. **View Your Balance**: See if the collective owes you money or vice versa 3. **Pay Outstanding Balance**: Generate a Lightning invoice to settle what you owe @@ -54,8 +54,8 @@ Enable the extension through the LNbits admin interface or by adding it to your ### Account Types -- **Assets**: Things the Castle owns (Cash, Bank, Accounts Receivable) -- **Liabilities**: What the Castle owes (Accounts Payable to members) +- **Assets**: Things the organization owns (Cash, Bank, Accounts Receivable) +- **Liabilities**: What the organization owes (Accounts Payable to members) - **Equity**: Member contributions and retained earnings - **Revenue**: Income streams - **Expenses**: Operating costs @@ -63,9 +63,9 @@ Enable the extension through the LNbits admin interface or by adding it to your ### Database Schema The extension creates three tables: -- `castle.accounts` - Chart of accounts -- `castle.journal_entries` - Transaction headers -- `castle.entry_lines` - Debit/credit lines +- `libra.accounts` - Chart of accounts +- `libra.journal_entries` - Transaction headers +- `libra.entry_lines` - Debit/credit lines ## API Reference @@ -79,7 +79,7 @@ To modify this extension: 2. Add database migrations in `migrations.py` 3. Implement business logic in `crud.py` 4. Create API endpoints in `views_api.py` -5. Update UI in `templates/castle/index.html` +5. Update UI in `templates/libra/index.html` ## Contributing diff --git a/__init__.py b/__init__.py index 6209e9d..614a8ba 100644 --- a/__init__.py +++ b/__init__.py @@ -5,24 +5,24 @@ from loguru import logger from .crud import db from .tasks import wait_for_paid_invoices -from .views import castle_generic_router -from .views_api import castle_api_router +from .views import libra_generic_router +from .views_api import libra_api_router -castle_ext: APIRouter = APIRouter(prefix="/castle", tags=["Castle"]) -castle_ext.include_router(castle_generic_router) -castle_ext.include_router(castle_api_router) +libra_ext: APIRouter = APIRouter(prefix="/libra", tags=["Libra"]) +libra_ext.include_router(libra_generic_router) +libra_ext.include_router(libra_api_router) -castle_static_files = [ +libra_static_files = [ { - "path": "/castle/static", - "name": "castle_static", + "path": "/libra/static", + "name": "libra_static", } ] scheduled_tasks: list[asyncio.Task] = [] -def castle_stop(): +def libra_stop(): """Clean up background tasks on extension shutdown""" for task in scheduled_tasks: try: @@ -31,35 +31,54 @@ def castle_stop(): logger.warning(ex) -def castle_start(): - """Initialize Castle extension background tasks""" +def libra_start(): + """Initialize Libra extension background tasks""" from lnbits.tasks import create_permanent_unique_task from .fava_client import init_fava_client - from .models import CastleSettings + from .models import LibraSettings from .tasks import wait_for_account_sync - # Initialize Fava client with default settings - # (Will be re-initialized if admin updates settings) - defaults = CastleSettings() - try: + async def _init_fava(): + """Load saved settings from DB, fall back to defaults.""" + from .crud import db as 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=defaults.fava_url, - ledger_slug=defaults.fava_ledger_slug, - timeout=defaults.fava_timeout + fava_url=settings.fava_url, + ledger_slug=settings.fava_ledger_slug, + timeout=settings.fava_timeout ) - logger.info(f"Fava client initialized: {defaults.fava_url}/{defaults.fava_ledger_slug}") + logger.info(f"Fava client initialized: {settings.fava_url}/{settings.fava_ledger_slug}") + + try: + asyncio.get_event_loop().create_task(_init_fava()) except Exception as e: logger.error(f"Failed to initialize Fava client: {e}") - logger.warning("Castle will not function without Fava. Please configure Fava settings.") + logger.warning("Libra will not function without Fava. Please configure Fava settings.") # Start background tasks - task = create_permanent_unique_task("ext_castle", wait_for_paid_invoices) + task = create_permanent_unique_task("ext_libra", wait_for_paid_invoices) scheduled_tasks.append(task) # Start account sync task (runs hourly) - sync_task = create_permanent_unique_task("ext_castle_account_sync", wait_for_account_sync) + sync_task = create_permanent_unique_task("ext_libra_account_sync", wait_for_account_sync) scheduled_tasks.append(sync_task) - logger.info("Castle account sync task started (runs hourly)") + logger.info("Libra account sync task started (runs hourly)") -__all__ = ["castle_ext", "castle_static_files", "db", "castle_start", "castle_stop"] +__all__ = ["libra_ext", "libra_static_files", "db", "libra_start", "libra_stop"] diff --git a/account_sync.py b/account_sync.py index 95fe41c..3d82381 100644 --- a/account_sync.py +++ b/account_sync.py @@ -1,11 +1,11 @@ """ Account Synchronization Module -Syncs accounts from Beancount (source of truth) to Castle DB (metadata store). +Syncs accounts from Beancount (source of truth) to Libra DB (metadata store). This implements the hybrid approach: - Beancount owns account existence (Open directives) -- Castle DB stores permissions and user associations +- Libra DB stores permissions and user associations - Background sync keeps them in sync Related: ACCOUNTS-TABLE-REMOVAL-FEASIBILITY.md - Phase 2 implementation @@ -89,14 +89,14 @@ def extract_user_id_from_account_name(account_name: str) -> Optional[str]: async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: """ - Sync accounts from Beancount to Castle DB. + Sync accounts from Beancount to Libra DB. - This ensures Castle DB has metadata entries for all accounts that exist + This ensures Libra DB has metadata entries for all accounts that exist in Beancount, enabling permissions and user associations to work properly. New behavior (soft delete + virtual parents): - - Accounts in Beancount but not in Castle DB: Added as active - - Accounts in Castle DB but not in Beancount: Marked as inactive (soft delete) + - Accounts in Beancount but not in Libra DB: Added as active + - Accounts in Libra DB but not in Beancount: Marked as inactive (soft delete) - Inactive accounts that return to Beancount: Reactivated - Missing intermediate parents: Auto-created as virtual accounts @@ -113,7 +113,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: dict with sync statistics: { "total_beancount_accounts": 150, - "total_castle_accounts": 148, + "total_libra_accounts": 148, "accounts_added": 2, "accounts_updated": 0, "accounts_skipped": 148, @@ -123,7 +123,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: "errors": [] } """ - logger.info("Starting account sync from Beancount to Castle DB") + logger.info("Starting account sync from Beancount to Libra DB") fava = get_fava_client() @@ -134,7 +134,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: logger.error(f"Failed to fetch accounts from Beancount: {e}") return { "total_beancount_accounts": 0, - "total_castle_accounts": 0, + "total_libra_accounts": 0, "accounts_added": 0, "accounts_updated": 0, "accounts_skipped": 0, @@ -143,16 +143,16 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: "errors": [str(e)], } - # Get all accounts from Castle DB (including inactive ones for sync) - castle_accounts = await get_all_accounts(include_inactive=True) + # Get all accounts from Libra DB (including inactive ones for sync) + libra_accounts = await get_all_accounts(include_inactive=True) # Build lookup maps beancount_account_names = {acc["account"] for acc in beancount_accounts} - castle_accounts_by_name = {acc.name: acc for acc in castle_accounts} + libra_accounts_by_name = {acc.name: acc for acc in libra_accounts} stats = { "total_beancount_accounts": len(beancount_accounts), - "total_castle_accounts": len(castle_accounts), + "total_libra_accounts": len(libra_accounts), "accounts_added": 0, "accounts_updated": 0, "accounts_skipped": 0, @@ -162,15 +162,15 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: "errors": [], } - # Step 1: Sync accounts from Beancount to Castle DB + # Step 1: Sync accounts from Beancount to Libra DB for bc_account in beancount_accounts: account_name = bc_account["account"] try: - existing = castle_accounts_by_name.get(account_name) + existing = libra_accounts_by_name.get(account_name) if existing: - # Account exists in Castle DB + # Account exists in Libra DB # Check if it needs to be reactivated if not existing.is_active: await update_account_is_active(existing.id, True) @@ -181,7 +181,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: logger.debug(f"Account already active: {account_name}") continue - # Create new account in Castle DB + # Create new account in Libra DB account_type = infer_account_type_from_name(account_name) user_id = extract_user_id_from_account_name(account_name) @@ -207,25 +207,25 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: logger.error(error_msg) stats["errors"].append(error_msg) - # Step 2: Mark orphaned accounts (in Castle DB but not in Beancount) as inactive + # Step 2: Mark orphaned accounts (in Libra DB but not in Beancount) as inactive # SKIP virtual accounts (they're intentionally metadata-only) - for castle_account in castle_accounts: - if castle_account.is_virtual: + for libra_account in libra_accounts: + if libra_account.is_virtual: # Virtual accounts are metadata-only, never deactivate them continue - if castle_account.name not in beancount_account_names: + if libra_account.name not in beancount_account_names: # Account no longer exists in Beancount - if castle_account.is_active: + if libra_account.is_active: try: - await update_account_is_active(castle_account.id, False) + await update_account_is_active(libra_account.id, False) stats["accounts_deactivated"] += 1 logger.info( - f"Deactivated orphaned account: {castle_account.name}" + f"Deactivated orphaned account: {libra_account.name}" ) except Exception as e: error_msg = ( - f"Failed to deactivate account {castle_account.name}: {e}" + f"Failed to deactivate account {libra_account.name}: {e}" ) logger.error(error_msg) stats["errors"].append(error_msg) @@ -236,8 +236,8 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: # IMPORTANT: Re-fetch accounts from DB after Step 1 added new accounts # Otherwise we'll be checking against stale data and miss newly synced children - current_castle_accounts = await get_all_accounts(include_inactive=True) - all_account_names = {acc.name for acc in current_castle_accounts} + current_libra_accounts = await get_all_accounts(include_inactive=True) + all_account_names = {acc.name for acc in current_libra_accounts} for bc_account in beancount_accounts: account_name = bc_account["account"] @@ -287,9 +287,9 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: async def sync_single_account_from_beancount(account_name: str) -> bool: """ - Sync a single account from Beancount to Castle DB. + Sync a single account from Beancount to Libra DB. - Useful for ensuring a specific account exists in Castle DB before + Useful for ensuring a specific account exists in Libra DB before granting permissions on it. Args: @@ -318,13 +318,22 @@ async def sync_single_account_from_beancount(account_name: str) -> bool: logger.error(f"Account not found in Beancount: {account_name}") return False - # Create in Castle DB + # Create in Libra DB account_type = infer_account_type_from_name(account_name) - user_id = extract_user_id_from_account_name(account_name) + # Prefer the full user_id stored in Beancount metadata (libra writes it + # when crud.get_or_create_user_account calls fava.add_account). Fall + # back to the name-derived 8-char prefix for accounts imported without + # metadata. This keeps user_id consistent with what the caller will + # query for, avoiding a churn cycle through the UNIQUE-constraint + # recovery path in crud.py. description = None + meta_user_id = None if "meta" in bc_account and isinstance(bc_account["meta"], dict): description = bc_account["meta"].get("description") + meta_user_id = bc_account["meta"].get("user_id") + + user_id = meta_user_id or extract_user_id_from_account_name(account_name) await create_account( CreateAccount( @@ -343,9 +352,9 @@ async def sync_single_account_from_beancount(account_name: str) -> bool: return False -async def ensure_account_exists_in_castle(account_name: str) -> bool: +async def ensure_account_exists_in_libra(account_name: str) -> bool: """ - Ensure account exists in Castle DB, creating from Beancount if needed. + Ensure account exists in Libra DB, creating from Beancount if needed. This is the recommended function to call before granting permissions. @@ -355,7 +364,7 @@ async def ensure_account_exists_in_castle(account_name: str) -> bool: Returns: True if account exists (or was created), False if failed """ - # Check Castle DB first + # Check Libra DB first existing = await get_account_by_name(account_name) if existing: return True @@ -367,9 +376,9 @@ async def ensure_account_exists_in_castle(account_name: str) -> bool: # Background sync task (can be scheduled with cron or async scheduler) async def scheduled_account_sync(): """ - Scheduled task to sync accounts from Beancount to Castle DB. + Scheduled task to sync accounts from Beancount to Libra DB. - Run this periodically (e.g., every hour) to keep Castle DB in sync with Beancount. + Run this periodically (e.g., every hour) to keep Libra DB in sync with Beancount. Example with APScheduler: from apscheduler.schedulers.asyncio import AsyncIOScheduler diff --git a/account_utils.py b/account_utils.py index 46db327..8520d59 100644 --- a/account_utils.py +++ b/account_utils.py @@ -200,11 +200,11 @@ DEFAULT_HIERARCHICAL_ACCOUNTS = [ ("Assets:FixedAssets:ProductionFacility", AccountType.ASSET, "Production facilities"), ("Assets:Inventory", AccountType.ASSET, "Inventory and stock"), ("Assets:Livestock", AccountType.ASSET, "Livestock and animals"), - ("Assets:Receivable", AccountType.ASSET, "Money owed to the Castle"), + ("Assets:Receivable", AccountType.ASSET, "Money owed to the organization"), ("Assets:Tools", AccountType.ASSET, "Tools and hand equipment"), # Liabilities - ("Liabilities:Payable", AccountType.LIABILITY, "Money owed by the Castle"), + ("Liabilities:Payable", AccountType.LIABILITY, "Money owed by the organization"), # Equity - User equity accounts created dynamically as Equity:User-{user_id} # No parent "Equity" account needed - hierarchy is implicit in the name diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..8cc9c17 --- /dev/null +++ b/auth.py @@ -0,0 +1,310 @@ +""" +Centralized Authorization Module for Libra Extension. + +Provides consistent, secure authorization patterns across all endpoints. + +Key concepts: +- AuthContext: Captures all authorization state for a request +- Dependencies: FastAPI dependencies for endpoint protection +- Permission checks: Consistent resource-level access control + +Usage: + from .auth import require_super_user, require_authenticated, AuthContext + + @router.get("/api/v1/admin-endpoint") + async def admin_endpoint(auth: AuthContext = Depends(require_super_user)): + # Only super users can access + pass + + @router.get("/api/v1/user-data") + async def user_data(auth: AuthContext = Depends(require_authenticated)): + # Any authenticated user + user_id = auth.user_id + pass +""" + +from dataclasses import dataclass +from functools import wraps +from http import HTTPStatus +from typing import Optional + +from fastapi import Depends, HTTPException +from lnbits.core.models import WalletTypeInfo +from lnbits.decorators import require_admin_key, require_invoice_key +from lnbits.settings import settings as lnbits_settings +from loguru import logger + +from .crud import get_account, get_user_permissions +from .models import PermissionType + + +@dataclass +class AuthContext: + """ + Authorization context for a request. + + Contains all information needed to make authorization decisions. + Use this instead of directly accessing wallet/user properties scattered + throughout endpoint code. + """ + user_id: str + wallet_id: str + is_super_user: bool + wallet: WalletTypeInfo + + @property + def is_admin(self) -> bool: + """ + Check if user is a Libra admin (super user). + + Note: In Libra, admin = super_user. There's no separate admin concept. + """ + return self.is_super_user + + def require_super_user(self) -> None: + """Raise HTTPException if not super user.""" + if not self.is_super_user: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Super user access required" + ) + + def require_self_or_super_user(self, target_user_id: str) -> None: + """ + Require that user is accessing their own data or is super user. + + Args: + target_user_id: The user ID being accessed + + Raises: + HTTPException: If user is neither the target nor super user + """ + if not self.is_super_user and self.user_id != target_user_id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Access denied: you can only access your own data" + ) + + +def _build_auth_context(wallet: WalletTypeInfo) -> AuthContext: + """Build AuthContext from wallet info.""" + user_id = wallet.wallet.user + return AuthContext( + user_id=user_id, + wallet_id=wallet.wallet.id, + is_super_user=user_id == lnbits_settings.super_user, + wallet=wallet, + ) + + +# ===== FastAPI Dependencies ===== + +async def require_authenticated( + wallet: WalletTypeInfo = Depends(require_invoice_key), +) -> AuthContext: + """ + Require authentication (invoice key minimum). + + Returns AuthContext with user information. + Use for read-only access to user's own data. + """ + return _build_auth_context(wallet) + + +async def require_authenticated_write( + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> AuthContext: + """ + Require authentication with write permissions (admin key). + + Returns AuthContext with user information. + Use for write operations on user's own data. + """ + return _build_auth_context(wallet) + + +async def require_super_user( + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> AuthContext: + """ + Require super user access. + + Raises HTTPException 403 if not super user. + Use for Libra admin operations. + """ + auth = _build_auth_context(wallet) + if not auth.is_super_user: + logger.warning( + f"Super user access denied for user {auth.user_id[:8]} " + f"attempting admin operation" + ) + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Super user access required" + ) + return auth + + +# ===== Resource Access Checks ===== + +async def can_access_account( + auth: AuthContext, + account_id: str, + permission_type: PermissionType = PermissionType.READ, +) -> bool: + """ + Check if user can access an account. + + Access is granted if: + 1. User is super user (full access) + 2. User owns the account (user-specific accounts like Assets:Receivable:User-abc123) + 3. User has explicit permission for the account + + Args: + auth: The authorization context + account_id: The account ID to check + permission_type: The type of access needed (READ, SUBMIT_EXPENSE, MANAGE) + + Returns: + True if access is allowed, False otherwise + """ + # Super users have full access + if auth.is_super_user: + return True + + # Check if this is the user's own account + account = await get_account(account_id) + if account: + user_short = auth.user_id[:8] + if f"User-{user_short}" in account.name: + return True + + # Check explicit permissions + permissions = await get_user_permissions(auth.user_id) + for perm in permissions: + if perm.account_id == account_id: + # Check if permission type is sufficient + if perm.permission_type == PermissionType.MANAGE: + return True # MANAGE grants all access + if perm.permission_type == permission_type: + return True + if ( + permission_type == PermissionType.READ + and perm.permission_type in [PermissionType.SUBMIT_EXPENSE, PermissionType.MANAGE] + ): + return True # Higher permissions include READ + + return False + + +async def require_account_access( + auth: AuthContext, + account_id: str, + permission_type: PermissionType = PermissionType.READ, +) -> None: + """ + Require access to an account, raising HTTPException if denied. + + Args: + auth: The authorization context + account_id: The account ID to check + permission_type: The type of access needed + + Raises: + HTTPException: If access is denied + """ + if not await can_access_account(auth, account_id, permission_type): + logger.warning( + f"Account access denied: user {auth.user_id[:8]} " + f"attempted {permission_type.value} on account {account_id}" + ) + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail=f"Access denied to account {account_id}" + ) + + +async def can_access_user_data(auth: AuthContext, target_user_id: str) -> bool: + """ + Check if user can access another user's data. + + Access is granted if: + 1. User is super user + 2. User is accessing their own data + + Args: + auth: The authorization context + target_user_id: The user ID whose data is being accessed + + Returns: + True if access is allowed + """ + if auth.is_super_user: + return True + + # Users can access their own data - compare full ID or short ID + if auth.user_id == target_user_id: + return True + + # Also allow if short IDs match (8 char prefix) + if auth.user_id[:8] == target_user_id[:8]: + return True + + return False + + +async def require_user_data_access( + auth: AuthContext, + target_user_id: str, +) -> None: + """ + Require access to a user's data, raising HTTPException if denied. + + Args: + auth: The authorization context + target_user_id: The user ID whose data is being accessed + + Raises: + HTTPException: If access is denied + """ + if not await can_access_user_data(auth, target_user_id): + logger.warning( + f"User data access denied: user {auth.user_id[:8]} " + f"attempted to access data for user {target_user_id[:8]}" + ) + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Access denied: you can only access your own data" + ) + + +# ===== Utility Functions ===== + +def get_user_id_from_wallet(wallet: WalletTypeInfo) -> str: + """ + Get user ID from wallet info. + + IMPORTANT: Always use wallet.wallet.user (not wallet.wallet.id). + - wallet.wallet.user = the user's ID + - wallet.wallet.id = the wallet's ID (NOT the same!) + + Args: + wallet: The wallet type info from LNbits + + Returns: + The user ID + """ + return wallet.wallet.user + + +def is_super_user(user_id: str) -> bool: + """ + Check if a user ID is the super user. + + Args: + user_id: The user ID to check + + Returns: + True if this is the super user + """ + return user_id == lnbits_settings.super_user diff --git a/beancount_format.py b/beancount_format.py index 2ba5b96..f233ee5 100644 --- a/beancount_format.py +++ b/beancount_format.py @@ -1,8 +1,8 @@ """ -Format Castle entries as Beancount transactions for Fava API. +Format Libra entries as Beancount transactions for Fava API. All entries submitted to Fava must follow Beancount syntax. -This module converts Castle data models to Fava API format. +This module converts Libra data models to Fava API format. Key concepts: - Amounts are strings: "200000 SATS" or "100.00 EUR" @@ -35,8 +35,8 @@ def sanitize_link(text: str) -> str: 'Test-pending' >>> sanitize_link("Invoice #123") 'Invoice-123' - >>> sanitize_link("castle-abc123") - 'castle-abc123' + >>> sanitize_link("libra-abc123") + 'libra-abc123' """ # Replace any character that's not alphanumeric, dash, underscore, slash, or period with a hyphen sanitized = re.sub(r'[^A-Za-z0-9\-_/.]', '-', text) @@ -67,7 +67,7 @@ def format_transaction( postings: List of posting dicts (formatted by format_posting) payee: Optional payee tags: Optional tags (e.g., ["expense-entry", "approved"]) - links: Optional links (e.g., ["castle-abc123", "^invoice-xyz"]) + links: Optional links (e.g., ["libra-abc123", "^invoice-xyz"]) meta: Optional transaction metadata Returns: @@ -93,8 +93,8 @@ def format_transaction( ) ], tags=["expense-entry"], - links=["castle-abc123"], - meta={"user-id": "abc123", "source": "castle-expense-entry"} + links=["libra-abc123"], + meta={"user-id": "abc123", "source": "libra-expense-entry"} ) """ return { @@ -150,7 +150,7 @@ def format_posting_with_cost( """ Format a posting with cost basis for Fava API. - This is the RECOMMENDED format for all Castle transactions. + This is the RECOMMENDED format for all Libra transactions. Uses Beancount's cost basis syntax to preserve exchange rates. IMPORTANT: Beancount cost syntax uses PER-UNIT cost, not total cost. @@ -381,7 +381,7 @@ def format_expense_entry( # Build entry metadata entry_meta = { "user-id": user_id, - "source": "castle-api", + "source": "libra-api", "entry-id": entry_id } @@ -419,7 +419,7 @@ def format_receivable_entry( entry_id: Optional[str] = None ) -> Dict[str, Any]: """ - Format a receivable entry (user owes castle). + Format a receivable entry (user owes libra). Uses price notation (@@ SATS) for BQL-queryable SATS tracking. Generates unique receivable link (^rcv-{entry_id}) for settlement tracking. @@ -466,7 +466,7 @@ def format_receivable_entry( entry_meta = { "user-id": user_id, - "source": "castle-api", + "source": "libra-api", "entry-id": entry_id } @@ -497,7 +497,8 @@ def format_payment_entry( fiat_currency: Optional[str] = None, fiat_amount: Optional[Decimal] = None, payment_hash: Optional[str] = None, - reference: Optional[str] = None + reference: Optional[str] = None, + settled_entry_links: Optional[List[str]] = None ) -> Dict[str, Any]: """ Format a payment entry (Lightning payment recorded). @@ -511,11 +512,12 @@ def format_payment_entry( amount_sats: Amount in satoshis (unsigned) description: Payment description entry_date: Date of payment - is_payable: True if castle paying user (payable), False if user paying castle (receivable) + is_payable: True if libra paying user (payable), False if user paying libra (receivable) fiat_currency: Optional fiat currency fiat_amount: Optional fiat amount (unsigned) payment_hash: Lightning payment hash reference: Optional reference + settled_entry_links: List of expense/receivable links being settled (e.g., ["exp-abc123"]) Returns: Fava API entry dict @@ -529,7 +531,7 @@ def format_payment_entry( # Example: 908.44 EUR / 996896 SATS = 0.000911268 EUR/SAT (matches original receivable rate) if fiat_currency and fiat_amount_abs and amount_sats_abs > 0: if is_payable: - # Castle paying user: DR Payable, CR Lightning + # Libra paying user: DR Payable, CR Lightning postings = [ format_posting_with_cost( account=payable_or_receivable_account, @@ -544,7 +546,7 @@ def format_payment_entry( ) ] else: - # User paying castle: DR Lightning, CR Receivable + # User paying libra: DR Lightning, CR Receivable postings = [ format_posting_simple( account=payment_account, @@ -584,6 +586,8 @@ def format_payment_entry( entry_meta["payment-hash"] = payment_hash links = [] + if settled_entry_links: + links.extend(settled_entry_links) if reference: links.append(reference) if payment_hash: @@ -594,7 +598,7 @@ def format_payment_entry( flag="*", # Cleared (payment already happened) narration=description, postings=postings, - tags=["lightning-payment"], + tags=["lightning-payment", "settlement"], links=links, meta=entry_meta ) @@ -629,7 +633,7 @@ def format_fiat_settlement_entry( amount_sats: Equivalent amount in satoshis description: Payment description entry_date: Date of settlement - is_payable: True if castle paying user (payable), False if user paying castle (receivable) + is_payable: True if libra paying user (payable), False if user paying libra (receivable) payment_method: Payment method (cash, bank_transfer, check, etc.) reference: Optional reference settled_entry_links: List of expense/receivable links being settled (e.g., ["exp-abc123", "exp-def456"]) @@ -642,7 +646,7 @@ def format_fiat_settlement_entry( # Build postings using price notation (@@ SATS) for BQL queryability if is_payable: - # Castle paying user: DR Payable, CR Cash/Bank + # Libra paying user: DR Payable, CR Cash/Bank postings = [ { "account": payable_or_receivable_account, @@ -654,7 +658,7 @@ def format_fiat_settlement_entry( } ] else: - # User paying castle: DR Cash/Bank, CR Receivable + # User paying libra: DR Cash/Bank, CR Receivable postings = [ { "account": payment_account, @@ -713,7 +717,8 @@ def format_net_settlement_entry( description: str, entry_date: date, payment_hash: Optional[str] = None, - reference: Optional[str] = None + reference: Optional[str] = None, + settled_entry_links: Optional[List[str]] = None ) -> Dict[str, Any]: """ Format a net settlement payment entry (user paying net balance). @@ -743,6 +748,7 @@ def format_net_settlement_entry( entry_date: Date of payment payment_hash: Lightning payment hash reference: Optional reference + settled_entry_links: List of expense/receivable links being settled (e.g., ["exp-abc123", "rcv-def456"]) Returns: Fava API entry dict @@ -780,6 +786,8 @@ def format_net_settlement_entry( entry_meta["payment-hash"] = payment_hash links = [] + if settled_entry_links: + links.extend(settled_entry_links) if reference: links.append(reference) if payment_hash: @@ -796,6 +804,139 @@ def format_net_settlement_entry( ) +def format_fiat_net_settlement_entry( + user_id: str, + cash_account: str, + receivable_account: str, + payable_account: Optional[str], + credit_account: Optional[str], + cash_paid_fiat: Decimal, + total_receivable_fiat: Decimal, + total_payable_fiat: Decimal, + credit_overflow_fiat: Decimal, + fiat_currency: str, + description: str, + entry_date: date, + payment_method: str = "cash", + reference: Optional[str] = None, + settled_entry_links: Optional[List[str]] = None, +) -> Dict[str, Any]: + """Fiat cash settlement that nets receivable and payable for one user. + + Implements the contract from libra-#33 (settlement netting) and + libra-#41 (credit-balance overflow). Builds a 2- to 4-leg transaction + depending on what the user has open: + + - Cash + Receivable only (2-leg) — pure receivable, exact pay + - Cash + Receivable + Credit (3-leg) — overpay against pure receivable + - Cash + Receivable + Payable (3-leg) — nancy's #33 scenario, exact pay + - Cash + Receivable + Payable + Credit (4-leg) — net + overpay + + The receivable leg is always present (this endpoint is `/receivables/settle`). + The payable leg appears when the user has open expenses being netted against + the receivable. The credit leg appears when cash > settle target, absorbing + the overflow as a liability libra owes the user going forward. + + Constraint enforced inline: + cash_paid_fiat = total_receivable_fiat - total_payable_fiat + credit_overflow_fiat + + Args: + user_id: User ID + cash_account: Payment-method account name (e.g. "Assets:Cash") + receivable_account: User's receivable account being cleared + payable_account: User's payable account being cleared (omit when no payable) + credit_account: User's credit account receiving overflow (omit when no overflow) + cash_paid_fiat: What the user paid in cash, unsigned + total_receivable_fiat: Gross receivable being cleared (unsigned, 0 if none) + total_payable_fiat: Gross payable being cleared (unsigned, 0 if none) + credit_overflow_fiat: Excess cash going to credit (unsigned, 0 if none) + fiat_currency: Currency code (EUR, USD, etc.) + description: Entry narration + entry_date: Date of settlement + payment_method: cash / bank_transfer / check / other + reference: Optional caller-supplied reference (becomes an extra link) + settled_entry_links: Source entry links being cleared + (e.g. `["exp-abc", "rcv-def"]`). The audit trail for which + originals this settlement reconciles. + + Returns: + Fava API entry dict ready for `fava.add_entry`. + + Raises: + ValueError: if any amount is negative, or if the cash-balance + constraint above is not satisfied. + """ + for label, value in ( + ("cash_paid_fiat", cash_paid_fiat), + ("total_receivable_fiat", total_receivable_fiat), + ("total_payable_fiat", total_payable_fiat), + ("credit_overflow_fiat", credit_overflow_fiat), + ): + if value < 0: + raise ValueError(f"{label} must be non-negative; got {value}") + + expected_cash = total_receivable_fiat - total_payable_fiat + credit_overflow_fiat + if abs(cash_paid_fiat - expected_cash) > Decimal("0.01"): + raise ValueError( + f"cash_paid_fiat {cash_paid_fiat} does not match expected " + f"{expected_cash} (= receivable {total_receivable_fiat} " + f"- payable {total_payable_fiat} + credit {credit_overflow_fiat})" + ) + + if total_payable_fiat > 0 and not payable_account: + raise ValueError("payable_account required when total_payable_fiat > 0") + if credit_overflow_fiat > 0 and not credit_account: + raise ValueError("credit_account required when credit_overflow_fiat > 0") + + postings: List[Dict[str, Any]] = [ + {"account": cash_account, "amount": f"{cash_paid_fiat:.2f} {fiat_currency}"}, + {"account": receivable_account, "amount": f"-{total_receivable_fiat:.2f} {fiat_currency}"}, + ] + if total_payable_fiat > 0: + postings.append({ + "account": payable_account, + "amount": f"{total_payable_fiat:.2f} {fiat_currency}", + }) + if credit_overflow_fiat > 0: + postings.append({ + "account": credit_account, + "amount": f"-{credit_overflow_fiat:.2f} {fiat_currency}", + }) + + payment_method_map = { + "cash": ("cash_settlement", "cash-payment"), + "bank_transfer": ("bank_settlement", "bank-transfer"), + "check": ("check_settlement", "check-payment"), + "btc_onchain": ("onchain_settlement", "onchain-payment"), + "other": ("manual_settlement", "manual-payment"), + } + source, tag = payment_method_map.get( + payment_method.lower(), ("manual_settlement", "manual-payment"), + ) + + entry_meta: Dict[str, Any] = { + "user-id": user_id, + "source": source, + "payment-type": "net-settlement", + } + + links: List[str] = [] + if settled_entry_links: + links.extend(settled_entry_links) + if reference: + links.append(sanitize_link(reference)) + + return format_transaction( + date_val=entry_date, + flag="*", + narration=description, + postings=postings, + tags=[tag, "settlement", "net-settlement"], + links=links, + meta=entry_meta, + ) + + def format_revenue_entry( payment_account: str, revenue_account: str, @@ -804,10 +945,11 @@ def format_revenue_entry( entry_date: date, fiat_currency: Optional[str] = None, fiat_amount: Optional[Decimal] = None, - reference: Optional[str] = None + reference: Optional[str] = None, + entry_id: Optional[str] = None ) -> Dict[str, Any]: """ - Format a revenue entry (castle receives payment directly). + Format a revenue entry (libra receives payment directly). Creates a cleared transaction (flag="*") since payment was received. @@ -821,7 +963,8 @@ def format_revenue_entry( entry_date: Date of payment fiat_currency: Optional fiat currency fiat_amount: Optional fiat amount (unsigned) - reference: Optional reference + reference: Optional reference (invoice ID, etc.) — stored as its own link + entry_id: Optional unique entry ID (generated if not provided) Returns: Fava API entry dict @@ -837,6 +980,9 @@ def format_revenue_entry( fiat_amount=Decimal("50.00") ) """ + if not entry_id: + entry_id = generate_entry_id() + amount_sats_abs = abs(amount_sats) fiat_amount_abs = abs(fiat_amount) if fiat_amount else None @@ -861,12 +1007,13 @@ def format_revenue_entry( # Note: created-via is redundant with #revenue-entry tag entry_meta = { - "source": "castle-api" + "source": "libra-api", + "entry-id": entry_id } links = [] if reference: - links.append(reference) + links.append(sanitize_link(reference)) return format_transaction( date_val=entry_date, @@ -877,3 +1024,68 @@ def format_revenue_entry( links=links, meta=entry_meta ) + + +def format_income_entry( + user_id: str, + user_account: str, + revenue_account: str, + amount_sats: int, + description: str, + entry_date: date, + fiat_currency: str, + fiat_amount: Decimal, + reference: Optional[str] = None, + entry_id: Optional[str] = None, +) -> Dict[str, Any]: + """ + Format a user-submitted income/revenue entry for Fava (pending approval). + + Mirrors format_expense_entry: pending flag (!) for super-user review, + fiat-first price notation (@@ SATS) for BQL queryability, unique link + (^inc-{entry_id}) for tracking through the approve/reject flow. + + Postings: DR user_account (Assets:Receivable:User-{id} — user owes + the entity until they hand the cash over), CR revenue_account. + """ + if not fiat_currency or not fiat_amount or fiat_amount <= 0: + raise ValueError("fiat_currency and a positive fiat_amount are required for income entries") + + if not entry_id: + entry_id = generate_entry_id() + + fiat_amount_abs = abs(fiat_amount) + sats_abs = abs(amount_sats) + + narration = f"{description} ({fiat_amount_abs:.2f} {fiat_currency})" + + postings = [ + { + "account": user_account, + "amount": f"{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS", + }, + { + "account": revenue_account, + "amount": f"-{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS", + }, + ] + + entry_meta = { + "user-id": user_id, + "source": "libra-api", + "entry-id": entry_id, + } + + links = [f"inc-{entry_id}"] + if reference: + links.append(sanitize_link(reference)) + + return format_transaction( + date_val=entry_date, + flag="!", # Pending - requires admin approval + narration=narration, + postings=postings, + tags=["income-entry"], + links=links, + meta=entry_meta, + ) diff --git a/config.json b/config.json index b83f290..00c8c50 100644 --- a/config.json +++ b/config.json @@ -1,11 +1,11 @@ { - "name": "Castle Accounting", + "name": "Libra", "short_description": "Double-entry accounting system for collective projects", - "tile": "/castle/static/image/castle.png", + "tile": "/libra/static/image/libra.png", "contributors": [ "Your Name" ], "hidden": false, - "migration_module": "lnbits.extensions.castle.migrations", - "db_name": "ext_castle" + "migration_module": "lnbits.extensions.libra.migrations", + "db_name": "ext_libra" } diff --git a/core/__init__.py b/core/__init__.py index 662bb20..10c362c 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,5 +1,5 @@ """ -Castle Core Module - Pure accounting logic separated from database operations. +Libra Core Module - Pure accounting logic separated from database operations. This module contains the core business logic for double-entry accounting, following Beancount patterns for clean architecture: diff --git a/core/validation.py b/core/validation.py index d2372b8..c29f069 100644 --- a/core/validation.py +++ b/core/validation.py @@ -1,5 +1,5 @@ """ -Validation rules for Castle accounting. +Validation rules for Libra accounting. Comprehensive validation following Beancount's plugin system approach, but implemented as simple functions that can be called directly. @@ -159,7 +159,7 @@ def validate_receivable_entry( revenue_account_type: str ) -> None: """ - Validate a receivable entry (user owes castle). + Validate a receivable entry (user owes libra). Args: user_id: User ID diff --git a/crud.py b/crud.py index 0976e72..0692806 100644 --- a/crud.py +++ b/crud.py @@ -14,7 +14,7 @@ from .models import ( AssertionStatus, AssignUserRole, BalanceAssertion, - CastleSettings, + LibraSettings, CreateAccount, CreateAccountPermission, CreateBalanceAssertion, @@ -32,7 +32,7 @@ from .models import ( StoredUserWalletSettings, UpdateRole, UserBalance, - UserCastleSettings, + UserLibraSettings, UserEquityStatus, UserRole, UserWalletSettings, @@ -49,7 +49,7 @@ from .core.validation import ( validate_payment_entry, ) -db = Database("ext_castle") +db = Database("ext_libra") # ===== CACHING ===== # Cache for account and permission lookups to reduce DB queries @@ -197,7 +197,7 @@ async def get_or_create_user_account( Get or create a user-specific account with hierarchical naming. This function checks if the account exists in Fava/Beancount and creates it - if it doesn't exist. The account is also registered in Castle's database for + if it doesn't exist. The account is also registered in Libra's database for metadata tracking (permissions, descriptions, etc.). Examples: @@ -214,7 +214,7 @@ async def get_or_create_user_account( # Generate hierarchical account name account_name = format_hierarchical_account_name(account_type, base_name, user_id) - # Try to find existing account with this hierarchical name in Castle DB + # Try to find existing account with this hierarchical name in Libra DB account = await db.fetchone( """ SELECT * FROM accounts @@ -224,9 +224,9 @@ async def get_or_create_user_account( Account, ) - logger.info(f"[ACCOUNT CHECK] User {user_id[:8]}, Account: {account_name}, In Castle DB: {account is not None}") + logger.info(f"[ACCOUNT CHECK] User {user_id[:8]}, Account: {account_name}, In Libra DB: {account is not None}") - # Always check/create in Fava, even if account exists in Castle DB + # Always check/create in Fava, even if account exists in Libra DB # This ensures Beancount has the Open directive fava_account_exists = False if True: # Always check Fava @@ -250,9 +250,13 @@ async def get_or_create_user_account( if not fava_account_exists: # Create account in Fava/Beancount via Open directive logger.info(f"[FAVA CREATE] Creating account in Fava: {account_name}") + # Unconstrained Open: a per-user receivable/payable legitimately + # holds arbitrary fiat (CAD/GBP/JPY/…). Constraining it to + # EUR/SATS/USD made any posting in another currency fail + # bean-check (the errors this account path originally exhibited). await fava.add_account( account_name=account_name, - currencies=["EUR", "SATS", "USD"], # Support common currencies + currencies=None, metadata={ "user_id": user_id, "description": f"User-specific {account_type.value} account" @@ -262,23 +266,23 @@ async def get_or_create_user_account( except Exception as e: logger.error(f"[FAVA ERROR] Could not check/create account in Fava: {e}", exc_info=True) - # Continue anyway - account creation in Castle DB is still useful for metadata + # Continue anyway - account creation in Libra DB is still useful for metadata - # Ensure account exists in Castle DB (sync from Beancount if needed) + # Ensure account exists in Libra DB (sync from Beancount if needed) # This uses the account sync module for consistency if not account: - logger.info(f"[CASTLE DB] Syncing account from Beancount to Castle DB: {account_name}") + logger.info(f"[LIBRA DB] Syncing account from Beancount to Libra DB: {account_name}") from .account_sync import sync_single_account_from_beancount - # Sync from Beancount to Castle DB + # Sync from Beancount to Libra DB created = await sync_single_account_from_beancount(account_name) if created: - logger.info(f"[CASTLE DB] Account synced from Beancount: {account_name}") + logger.info(f"[LIBRA DB] Account synced from Beancount: {account_name}") else: - logger.warning(f"[CASTLE DB] Failed to sync account from Beancount: {account_name}") + logger.warning(f"[LIBRA DB] Failed to sync account from Beancount: {account_name}") - # Fetch the account from Castle DB + # Fetch the account from Libra DB account = await db.fetchone( """ SELECT * FROM accounts @@ -289,9 +293,9 @@ async def get_or_create_user_account( ) if not account: - logger.error(f"[CASTLE DB] Account still not found after sync: {account_name}") - # Fallback: create directly in Castle DB if sync failed - logger.info(f"[CASTLE DB] Creating account directly in Castle DB: {account_name}") + logger.error(f"[LIBRA DB] Account still not found after sync: {account_name}") + # Fallback: create directly in Libra DB if sync failed + logger.info(f"[LIBRA DB] Creating account directly in Libra DB: {account_name}") try: account = await create_account( CreateAccount( @@ -304,7 +308,7 @@ async def get_or_create_user_account( except Exception as e: # Handle UNIQUE constraint error - account already exists if "UNIQUE constraint failed" in str(e) and "accounts.name" in str(e): - logger.warning(f"[CASTLE DB] Account already exists (UNIQUE constraint), fetching by name: {account_name}") + logger.warning(f"[LIBRA DB] Account already exists (UNIQUE constraint), fetching by name: {account_name}") # Fetch existing account by name only (ignore user_id in query) account = await db.fetchone( """ @@ -315,10 +319,10 @@ async def get_or_create_user_account( Account, ) if account: - logger.info(f"[CASTLE DB] Found existing account: {account_name} (user_id: {account.user_id})") + logger.info(f"[LIBRA DB] Found existing account: {account_name} (user_id: {account.user_id})") # Update user_id if it's NULL or different if account.user_id != user_id: - logger.info(f"[CASTLE DB] Updating account user_id from {account.user_id} to {user_id}") + logger.info(f"[LIBRA DB] Updating account user_id from {account.user_id} to {user_id}") await db.execute( """ UPDATE accounts @@ -340,7 +344,7 @@ async def get_or_create_user_account( # Re-raise if it's a different error raise else: - logger.info(f"[CASTLE DB] Account already exists in Castle DB: {account_name}") + logger.info(f"[LIBRA DB] Account already exists in Libra DB: {account_name}") return account @@ -351,7 +355,7 @@ async def get_or_create_user_account( # ===== JOURNAL ENTRY OPERATIONS (REMOVED) ===== # # All journal entry operations have been moved to Fava/Beancount. -# Castle no longer maintains its own journal_entries and entry_lines tables. +# Libra no longer maintains its own journal_entries and entry_lines tables. # # For journal entry operations, see: # - views_api.py: api_create_journal_entry() - writes to Fava via FavaClient @@ -375,29 +379,29 @@ async def get_or_create_user_account( # ===== SETTINGS ===== -async def create_castle_settings( - user_id: str, data: CastleSettings -) -> CastleSettings: - settings = UserCastleSettings(**data.dict(), id=user_id) +async def create_libra_settings( + user_id: str, data: LibraSettings +) -> LibraSettings: + settings = UserLibraSettings(**data.dict(), id=user_id) await db.insert("extension_settings", settings) return settings -async def get_castle_settings(user_id: str) -> Optional[CastleSettings]: +async def get_libra_settings(user_id: str) -> Optional[LibraSettings]: return await db.fetchone( """ SELECT * FROM extension_settings WHERE id = :user_id """, {"user_id": user_id}, - CastleSettings, + LibraSettings, ) -async def update_castle_settings( - user_id: str, data: CastleSettings -) -> CastleSettings: - settings = UserCastleSettings(**data.dict(), id=user_id) +async def update_libra_settings( + user_id: str, data: LibraSettings +) -> LibraSettings: + settings = UserLibraSettings(**data.dict(), id=user_id) await db.update("extension_settings", settings) return settings @@ -424,6 +428,26 @@ 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 b3628a2..d609551 100644 --- a/description.md +++ b/description.md @@ -1,4 +1,4 @@ -# Castle Accounting +# Libra A comprehensive double-entry accounting system for collective projects, designed specifically for LNbits. @@ -7,29 +7,29 @@ A comprehensive double-entry accounting system for collective projects, designed - **Double-Entry Bookkeeping**: Full accounting system with debits and credits - **Chart of Accounts**: Pre-configured accounts for Assets, Liabilities, Equity, Revenue, and Expenses - **User Expense Tracking**: Members can record out-of-pocket expenses as either: - - **Liabilities**: Castle owes them money (reimbursable) + - **Liabilities**: Libra owes them money (reimbursable) - **Equity**: Their contribution to the collective -- **Accounts Receivable**: Track what users owe the Castle (e.g., accommodation fees) +- **Accounts Receivable**: Track what users owe the organization (e.g., accommodation fees) - **Revenue Tracking**: Record revenue received by the collective -- **User Balance Dashboard**: Each user sees their balance with the Castle +- **User Balance Dashboard**: Each user sees their balance with the organization - **Lightning Integration**: Generate invoices for outstanding balances - **Transaction History**: View all accounting entries and transactions ## Use Cases ### 1. User Pays Expense Out of Pocket -When a member buys supplies for the Castle: +When a member buys supplies for the collective: - They can choose to be reimbursed (Liability) - Or contribute it as equity (Equity) ### 2. Accounts Receivable -When someone stays at the Castle and owes money: +When someone stays with the collective and owes money: - Admin creates an AR entry (e.g., "5 nights @ 10€/night = 50€") - User sees they owe 50€ in their dashboard - They can generate an invoice to pay it off ### 3. Revenue Recording -When the Castle receives revenue: +When the organization receives revenue: - Record revenue with the payment method (Cash, Lightning, Bank) - Properly categorized in the accounting system @@ -58,8 +58,8 @@ When the Castle receives revenue: ## Getting Started -1. Enable the Castle extension in LNbits -2. Visit the Castle page to see your dashboard +1. Enable the Libra extension in LNbits +2. Visit the Libra page to see your dashboard 3. Start tracking expenses and balances! The extension automatically creates a default chart of accounts on first run. diff --git a/docs/ACCOUNT-SYNC-AND-PERMISSION-IMPROVEMENTS.md b/docs/ACCOUNT-SYNC-AND-PERMISSION-IMPROVEMENTS.md index f5528f5..ea37aaa 100644 --- a/docs/ACCOUNT-SYNC-AND-PERMISSION-IMPROVEMENTS.md +++ b/docs/ACCOUNT-SYNC-AND-PERMISSION-IMPROVEMENTS.md @@ -8,9 +8,9 @@ ## Summary -Implemented two major improvements for Castle administration: +Implemented two major improvements for Libra administration: -1. **Account Synchronization** - Automatically sync accounts from Beancount → Castle DB +1. **Account Synchronization** - Automatically sync accounts from Beancount → Libra DB 2. **Bulk Permission Management** - Tools for managing permissions at scale **Total Implementation Time**: ~4 hours @@ -23,24 +23,24 @@ Implemented two major improvements for Castle administration: ### Problem Solved -**Before**: Accounts existed in both Beancount and Castle DB, with manual sync required. -**After**: Automatic sync keeps Castle DB in sync with Beancount (source of truth). +**Before**: Accounts existed in both Beancount and Libra DB, with manual sync required. +**After**: Automatic sync keeps Libra DB in sync with Beancount (source of truth). ### Implementation -**New Module**: `castle/account_sync.py` +**New Module**: `libra/account_sync.py` **Core Functions**: ```python -# 1. Full sync from Beancount to Castle +# 1. Full sync from Beancount to Libra stats = await sync_accounts_from_beancount(force_full_sync=False) # 2. Sync single account success = await sync_single_account_from_beancount("Expenses:Food") # 3. Ensure account exists (recommended before granting permissions) -exists = await ensure_account_exists_in_castle("Expenses:Marketing") +exists = await ensure_account_exists_in_libra("Expenses:Marketing") # 4. Scheduled background sync (run hourly) stats = await scheduled_account_sync() @@ -77,7 +77,7 @@ stats = await scheduled_account_sync() ```python # Sync all accounts from Beancount -from castle.account_sync import sync_accounts_from_beancount +from libra.account_sync import sync_accounts_from_beancount stats = await sync_accounts_from_beancount() @@ -96,11 +96,11 @@ Errors: 0 #### Before Granting Permission (Best Practice) ```python -from castle.account_sync import ensure_account_exists_in_castle -from castle.crud import create_account_permission +from libra.account_sync import ensure_account_exists_in_libra +from libra.crud import create_account_permission -# Ensure account exists in Castle DB first -account_exists = await ensure_account_exists_in_castle("Expenses:Marketing") +# Ensure account exists in Libra DB first +account_exists = await ensure_account_exists_in_libra("Expenses:Marketing") if account_exists: # Now safe to grant permission @@ -116,9 +116,9 @@ if account_exists: ```python # Add to your scheduler (cron, APScheduler, etc.) -from castle.account_sync import scheduled_account_sync +from libra.account_sync import scheduled_account_sync -# Run every hour to keep Castle DB in sync +# Run every hour to keep Libra DB in sync scheduler.add_job( scheduled_account_sync, 'interval', @@ -142,7 +142,7 @@ Authorization: Bearer {admin_key} ```json { "total_beancount_accounts": 150, - "total_castle_accounts": 150, + "total_libra_accounts": 150, "accounts_added": 2, "accounts_updated": 0, "accounts_skipped": 148, @@ -152,8 +152,8 @@ Authorization: Bearer {admin_key} ### Benefits -1. **Beancount as Source of Truth**: Castle DB automatically reflects Beancount state -2. **Reduced Manual Work**: No more manual account creation in Castle +1. **Beancount as Source of Truth**: Libra DB automatically reflects Beancount state +2. **Reduced Manual Work**: No more manual account creation in Libra 3. **Prevents Permission Errors**: Cannot grant permission on non-existent account 4. **Audit Trail**: Tracks which accounts were synced and when 5. **Safe Operations**: Continues on errors, never deletes accounts @@ -169,7 +169,7 @@ Authorization: Bearer {admin_key} ### Implementation -**New Module**: `castle/permission_management.py` +**New Module**: `libra/permission_management.py` **Core Functions**: @@ -471,19 +471,19 @@ print(f"Permission types removed: {result['permission_types_removed']}") # OLD: Manual permission creation (risky) await create_account_permission( user_id="alice", - account_id="acc123", # What if account doesn't exist in Castle DB? + account_id="acc123", # What if account doesn't exist in Libra DB? permission_type=PermissionType.SUBMIT_EXPENSE, granted_by="admin" ) # NEW: Safe permission creation with account sync -from castle.account_sync import ensure_account_exists_in_castle +from libra.account_sync import ensure_account_exists_in_libra # Ensure account exists first -account_exists = await ensure_account_exists_in_castle("Expenses:Marketing") +account_exists = await ensure_account_exists_in_libra("Expenses:Marketing") if account_exists: - # Now safe - account guaranteed to be in Castle DB + # Now safe - account guaranteed to be in Libra DB await create_account_permission( user_id="alice", account_id=account_id, @@ -497,10 +497,10 @@ else: ### Scheduler Integration ```python -# Add to your Castle extension startup +# Add to your Libra extension startup from apscheduler.schedulers.asyncio import AsyncIOScheduler -from castle.account_sync import scheduled_account_sync -from castle.permission_management import cleanup_expired_permissions +from libra.account_sync import scheduled_account_sync +from libra.permission_management import cleanup_expired_permissions scheduler = AsyncIOScheduler() @@ -610,7 +610,7 @@ async def test_copy_permissions(): async def test_onboarding_workflow(): """Test complete onboarding workflow""" # 1. Sync account - await ensure_account_exists_in_castle("Expenses:Food") + await ensure_account_exists_in_libra("Expenses:Food") # 2. Copy permissions from template user result = await copy_permissions( @@ -745,19 +745,19 @@ logger.error(f"Account sync error: {error}") ## Migration Guide -### For Existing Castle Installations +### For Existing Libra Installations **Step 1: Deploy New Modules** ```bash -# Copy new files to Castle extension -cp account_sync.py /path/to/castle/ -cp permission_management.py /path/to/castle/ +# Copy new files to Libra extension +cp account_sync.py /path/to/libra/ +cp permission_management.py /path/to/libra/ ``` **Step 2: Initial Account Sync** ```python # Run once to sync existing accounts -from castle.account_sync import sync_accounts_from_beancount +from libra.account_sync import sync_accounts_from_beancount stats = await sync_accounts_from_beancount(force_full_sync=True) print(f"Synced {stats['accounts_added']} accounts") @@ -784,14 +784,14 @@ await bulk_grant_permission(...) ## Documentation Updates **New files created**: -- ✅ `castle/account_sync.py` (230 lines) -- ✅ `castle/permission_management.py` (400 lines) +- ✅ `libra/account_sync.py` (230 lines) +- ✅ `libra/permission_management.py` (400 lines) - ✅ `docs/PERMISSIONS-SYSTEM.md` (full permission system docs) - ✅ `docs/ACCOUNT-SYNC-AND-PERMISSION-IMPROVEMENTS.md` (this file) **Files to update**: -- `castle/views_api.py` - Add new admin endpoints -- `castle/README.md` - Document new features +- `libra/views_api.py` - Add new admin endpoints +- `libra/README.md` - Document new features - `tests/` - Add comprehensive tests --- @@ -801,7 +801,7 @@ await bulk_grant_permission(...) ### What Was Built 1. **Account Sync Module** (230 lines) - - Automatic sync from Beancount → Castle DB + - Automatic sync from Beancount → Libra DB - Type inference and user ID extraction - Background scheduling support diff --git a/docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.html b/docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.html index d0e9bfe..6271865 100644 --- a/docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.html +++ b/docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.html @@ -195,13 +195,13 @@ id="toc-professional-assessment">Professional Assessment
Date: 2025-01-12 Prepared By: -Senior Accounting Review Subject: Castle Extension - +Senior Accounting Review Subject: Libra Extension - Lightning Payment Settlement Entries Status: Technical Review
This document provides a professional accounting assessment of -Castle’s net settlement entry pattern used for recording Lightning +Libra’s net settlement entry pattern used for recording Lightning Network payments that settle fiat-denominated receivables. The analysis identifies areas where the implementation deviates from traditional accounting best practices and provides specific recommendations for @@ -214,7 +214,7 @@ hierarchy
Castle operates as a Lightning Network-integrated accounting system +
Libra operates as a Lightning Network-integrated accounting system for collectives (co-living spaces, makerspaces). It faces a unique accounting challenge:
Scenario: User creates a receivable in EUR (e.g., @@ -223,7 +223,7 @@ accounting challenge:
Challenge: Record the payment while: 1. Clearing the exact EUR receivable amount 2. Recording the exact satoshi amount received 3. Handling cases where users have both receivables (owe -Castle) and payables (Castle owes them) 4. Maintaining Beancount +Libra) and payables (Libra owes them) 4. Maintaining Beancount double-entry balance
; Step 1: Receivable Created
2025-11-12 * "room (200.00 EUR)" #receivable-entry
user-id: "375ec158"
- source: "castle-api"
+ source: "libra-api"
sats-amount: "225033"
Assets:Receivable:User-375ec158 200.00 EUR
sats-equivalent: "225033"
@@ -344,7 +344,7 @@ class="sourceCode sql">Assets:Bitcoin:Lightning 200.00 EUR
sats-received: "225033"
@@ -452,8 +452,8 @@ OR payable)
(receivable AND payable)
When Net Settlement is Appropriate:
-User owes Castle: 555.00 EUR (receivable)
-Castle owes User: 38.00 EUR (payable)
+User owes Libra: 555.00 EUR (receivable)
+Libra owes User: 38.00 EUR (payable)
Net amount due: 517.00 EUR (true settlement)
Proper three-posting entry:
Assets:Bitcoin:Lightning 565251 SATS @@ 517.00 EUR
@@ -461,8 +461,8 @@ Assets:Receivable:User -555.00 EUR
Liabilities:Payable:User 38.00 EUR
; Net: 517.00 = -555.00 + 38.00 ✓
When Two Postings Suffice:
-User owes Castle: 200.00 EUR (receivable)
-Castle owes User: 0.00 EUR (no payable)
+User owes Libra: 200.00 EUR (receivable)
+Libra owes User: 0.00 EUR (no payable)
Amount due: 200.00 EUR (simple payment)
Simpler two-posting entry:
Assets:Bitcoin:Lightning 225033 SATS @@ 200.00 EUR
@@ -515,7 +515,7 @@ positions - ❌ Requires metadata parsing for SATS balances
id="approach-3-true-net-settlement-when-both-obligations-exist">Approach
3: True Net Settlement (When Both Obligations Exist)
2025-11-12 * "Net settlement via Lightning"
- ; User owes 555 EUR, Castle owes 38 EUR, net: 517 EUR
+ ; User owes 555 EUR, Libra owes 38 EUR, net: 517 EUR
Assets:Bitcoin:Lightning 517.00 EUR
sats-received: "565251"
Assets:Receivable:User-375ec158 -555.00 EUR
@@ -570,7 +570,7 @@ Method
Decision Required: Select either position-based OR
metadata-based satoshi tracking.
Option A - Keep Metadata Approach (recommended for
-Castle):
+Libra):
Recommendation: Choose Option A (metadata) for
-consistency with Castle’s architecture.
+consistency with Libra’s architecture.
1.3 Rename Function for
Clarity
@@ -713,7 +713,7 @@ class="sourceCode python"> payment_hash=payment_hash
)
else:
- # PAYABLE PAYMENT: Castle paying user (different flow)
+ # PAYABLE PAYMENT: Libra paying user (different flow)
return await format_payable_payment_entry(...)
Priority 3:
@@ -742,7 +742,7 @@ architectures:
Recommendation: Architecture A (EUR primary)
because: 1. Most receivables created in EUR 2. Financial reporting
requirements typically in fiat 3. Tax obligations calculated in fiat 4.
-Aligns with current Castle metadata approach
+Aligns with current Libra metadata approach
3.2
Consider Separate Ledger for Cryptocurrency Holdings
@@ -754,7 +754,7 @@ from fiat accounting
Assets:Receivable:User-375ec158 -200.00 EUR
Cryptocurrency Sub-Ledger (SATS-denominated):
2025-11-12 * "Lightning payment received"
- Assets:Bitcoin:Lightning:Castle 225033 SATS
+ Assets:Bitcoin:Lightning:Libra 225033 SATS
Assets:Bitcoin:Custody:User-375ec 225033 SATS
Benefits: - ✅ Clean separation of concerns - ✅
Cryptocurrency movements tracked independently - ✅ Fiat accounting
@@ -902,7 +902,7 @@ Entry balances
Is this “best practice” accounting?
No, this implementation deviates from traditional
accounting standards in several ways.
-Is it acceptable for Castle’s use case? Yes,
+Is it acceptable for Libra’s use case? Yes,
with modifications, it’s a reasonable pragmatic solution for a
novel problem (cryptocurrency payments of fiat debts).
Critical improvements needed: 1. ✅ Remove
@@ -912,7 +912,7 @@ Separate payment vs. settlement logic (accuracy and clarity)
The fundamental challenge: Traditional accounting
wasn’t designed for this scenario. There is no established “standard”
for recording cryptocurrency payments of fiat-denominated receivables.
-Castle’s approach is functional, but should be refined to align better
+Libra’s approach is functional, but should be refined to align better
with accounting principles where possible.
Next Steps
@@ -935,7 +935,7 @@ Characteristics of Accounting Information
- ASC 105-10-05: Substance Over Form
- Beancount Documentation:
http://furius.ca/beancount/doc/index
-- Castle Extension:
+
- Libra 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
-Castle’s payment recording system.
+Libra’s payment recording system.