1
0
Fork 0
forked from aiolabs/libra

Rename Castle Accounting extension to Libra

Full identifier rename: module path lnbits.extensions.castle →
lnbits.extensions.libra, DB ext_castle → ext_libra, URL prefix
/castle/ → /libra/, manifest id castle → libra, fava ledger slug
default castle-ledger → libra-ledger, Beancount source metadata
castle-api → libra-api and link prefixes castle-{entry,tx}- →
libra-{entry,tx}-, column castle_wallet_id → libra_wallet_id, all
Python/JS/HTML identifiers (castle_ext, CastleSettings,
castle_reference, castleWalletConfigured, etc.).

Display name "Castle Accounting" → "Libra" (the scales/balance
metaphor — fits double-entry bookkeeping).

No backward compat: production hosts will be force-updated. Old
castle-prefixed Beancount metadata in existing Fava ledgers is
historical; new entries use libra-* prefixes going forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-05 10:24:46 +02:00
commit c174cda48d
44 changed files with 953 additions and 953 deletions

View file

@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## 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 ## 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. **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: **Pure Functional Core**: The `core/` directory contains pure accounting logic independent of the database layer:
- `core/validation.py` - Entry validation rules - `core/validation.py` - Entry validation rules
@ -44,12 +44,12 @@ Castle Accounting is a double-entry bookkeeping extension for LNbits that enable
- `tasks.py` - Background tasks (invoice payment monitoring) - `tasks.py` - Background tasks (invoice payment monitoring)
- `account_utils.py` - Hierarchical account naming utilities - `account_utils.py` - Hierarchical account naming utilities
- `fava_client.py` - HTTP client for Fava REST API (add_entry, query, balance_sheet) - `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 - `core/validation.py` - Pure validation functions for accounting rules
### Database Schema ### Database Schema
**Note**: With Fava integration, Castle maintains a local cache of some data but delegates authoritative balance calculations to Beancount/Fava. **Note**: With Fava integration, Libra maintains a local cache of some data but delegates authoritative balance calculations to Beancount/Fava.
**journal_entries**: Transaction headers stored locally and synced to Fava **journal_entries**: Transaction headers stored locally and synced to Fava
- `flag` field: `*` (cleared), `!` (pending), `#` (flagged), `x` (void) - `flag` field: `*` (cleared), `!` (pending), `#` (flagged), `x` (void)
@ -57,10 +57,10 @@ Castle Accounting is a double-entry bookkeeping extension for LNbits that enable
- `reference` field: Links to payment_hash, invoice numbers, etc. - `reference` field: Links to payment_hash, invoice numbers, etc.
- Enriched with `username` field when retrieved via API (added from LNbits user data) - Enriched with `username` field when retrieved via API (added from LNbits user data)
**extension_settings**: Castle wallet configuration (admin-only) **extension_settings**: Libra wallet configuration (admin-only)
- `castle_wallet_id` - The LNbits wallet used for Castle operations - `libra_wallet_id` - The LNbits wallet used for Libra operations
- `fava_url` - Fava service URL (default: http://localhost:3333) - `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 - `fava_timeout` - API request timeout in seconds
**user_wallet_settings**: Per-user wallet configuration **user_wallet_settings**: Per-user wallet configuration
@ -70,22 +70,22 @@ Castle Accounting is a double-entry bookkeeping extension for LNbits that enable
## Transaction Flows ## Transaction Flows
### User Adds Expense (Liability) ### 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 DR Expenses:Food 39,669 sats
CR Liabilities:Payable:User-af983632 39,669 sats CR Liabilities:Payable:User-af983632 39,669 sats
``` ```
Metadata preserves: `{"fiat_currency": "EUR", "fiat_amount": "36.93", "fiat_rate": "1074.192"}` Metadata preserves: `{"fiat_currency": "EUR", "fiat_amount": "36.93", "fiat_rate": "1074.192"}`
### Castle Adds Receivable ### Libra Adds Receivable
User owes Castle for accommodation: User owes Libra for accommodation:
``` ```
DR Assets:Receivable:User-af983632 268,548 sats DR Assets:Receivable:User-af983632 268,548 sats
CR Income:Accommodation 268,548 sats CR Income:Accommodation 268,548 sats
``` ```
### User Pays with Lightning ### 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 DR Assets:Lightning:Balance 268,548 sats
CR Assets:Receivable:User-af983632 268,548 sats CR Assets:Receivable:User-af983632 268,548 sats
@ -101,14 +101,14 @@ DR Liabilities:Payable:User-af983632 39,669 sats
## Balance Calculation Logic ## Balance Calculation Logic
**User Balance** (calculated by Beancount via Fava): **User Balance** (calculated by Beancount via Fava):
- Positive = Castle owes user (LIABILITY accounts have credit balance) - Positive = Libra owes user (LIABILITY accounts have credit balance)
- Negative = User owes Castle (ASSET accounts have debit balance) - Negative = User owes Libra (ASSET accounts have debit balance)
- Calculated by querying Fava for sum of all postings across user's accounts - 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 - Fiat balances calculated by Beancount from cost basis annotations, NOT converted from current sats
**Perspective-Based UI**: **Perspective-Based UI**:
- **User View**: Green = Castle owes them, Red = They owe Castle - **User View**: Green = Libra owes them, Red = They owe Libra
- **Castle Admin View**: Green = User owes Castle, Red = Castle owes user - **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. **Balance Retrieval**: Use `GET /api/v1/balance` which queries Fava's balance sheet or account reports for accurate, Beancount-calculated balances.
@ -127,12 +127,12 @@ DR Liabilities:Payable:User-af983632 39,669 sats
- `POST /api/v1/entries` - Create raw journal entry (admin only) - `POST /api/v1/entries` - Create raw journal entry (admin only)
### Payments & Balances ### 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) - `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/generate-payment-invoice` - Generate invoice for user to pay Libra
- `POST /api/v1/record-payment` - Record Lightning payment from user to Castle - `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/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 ### Manual Payment Requests
- `POST /api/v1/manual-payment-requests` - User requests payment - `POST /api/v1/manual-payment-requests` - User requests payment
@ -148,8 +148,8 @@ DR Liabilities:Payable:User-af983632 39,669 sats
- `POST /api/v1/tasks/daily-reconciliation` - Run daily reconciliation (admin) - `POST /api/v1/tasks/daily-reconciliation` - Run daily reconciliation (admin)
### Settings ### Settings
- `GET /api/v1/settings` - Get Castle settings (super user) - `GET /api/v1/settings` - Get Libra settings (super user)
- `PUT /api/v1/settings` - Update Castle settings (super user) - `PUT /api/v1/settings` - Update Libra settings (super user)
- `GET /api/v1/user/wallet` - Get user wallet settings - `GET /api/v1/user/wallet` - Get user wallet settings
- `PUT /api/v1/user/wallet` - Update user wallet settings - `PUT /api/v1/user/wallet` - Update user wallet settings
@ -213,7 +213,7 @@ entry = format_transaction(
{"account": "Liabilities:Payable:User-abc123", "amount": "-50000 SATS"} {"account": "Liabilities:Payable:User-abc123", "amount": "-50000 SATS"}
], ],
tags=["groceries"], tags=["groceries"],
links=["castle-entry-123"] links=["libra-entry-123"]
) )
# Submit to Fava # Submit to Fava
@ -241,15 +241,15 @@ balance_result = await client.query(
### Extension as LNbits Module ### Extension as LNbits Module
This extension follows LNbits extension structure: 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 - Static files served from `static/` directory
- Templates in `templates/castle/` - Templates in `templates/libra/`
- Database accessed via `db = Database("ext_castle")` - Database accessed via `db = Database("ext_libra")`
**Startup Requirements**: **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 - 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 ## Common Tasks
@ -282,7 +282,7 @@ entry = format_transaction(
{"account": "Assets:Lightning:Balance", "amount": "-50000 SATS"} {"account": "Assets:Lightning:Balance", "amount": "-50000 SATS"}
], ],
tags=["utilities"], tags=["utilities"],
links=["castle-tx-123"] links=["libra-tx-123"]
) )
client = get_fava_client() client = get_fava_client()
@ -337,24 +337,24 @@ result = await client.query(query)
### Prerequisites ### Prerequisites
1. **LNbits**: This extension must be installed in the `lnbits/extensions/` directory 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 ```bash
# Install Fava # Install Fava
pip install fava pip install fava
# Create a basic Beancount file # Create a basic Beancount file
touch castle-ledger.beancount touch libra-ledger.beancount
# Start Fava (default: http://localhost:3333) # 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 2. Restart LNbits
3. Extension hot-reloads are supported by LNbits in development mode 3. Extension hot-reloads are supported by LNbits in development mode
@ -363,13 +363,13 @@ Castle is loaded as part of LNbits. No separate build or test commands are neede
Use the web UI or API endpoints to create test transactions. For API testing: Use the web UI or API endpoints to create test transactions. For API testing:
```bash ```bash
# Create expense (user owes Castle) # Create expense (user owes Libra)
curl -X POST http://localhost:5000/castle/api/v1/entries/expense \ curl -X POST http://localhost:5000/libra/api/v1/entries/expense \
-H "X-Api-Key: YOUR_INVOICE_KEY" \ -H "X-Api-Key: YOUR_INVOICE_KEY" \
-d '{"description": "Test expense", "amount": "100.00 EUR", "account_name": "Expenses:Test"}' -d '{"description": "Test expense", "amount": "100.00 EUR", "account_name": "Expenses:Test"}'
# Check user balance # 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" -H "X-Api-Key: YOUR_INVOICE_KEY"
``` ```

View file

@ -1,11 +1,11 @@
# Castle Migration Squash Summary # Libra Migration Squash Summary
**Date:** November 10, 2025 **Date:** November 10, 2025
**Action:** Squashed 16 incremental migrations into a single clean initial migration **Action:** Squashed 16 incremental migrations into a single clean initial migration
## Overview ## 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 ## Files Changed
@ -16,37 +16,37 @@ The Castle extension had accumulated 16 migrations (m001-m016) during developmen
The squashed migration creates **7 tables**: The squashed migration creates **7 tables**:
### 1. castle_accounts ### 1. libra_accounts
- Core chart of accounts with hierarchical Beancount-style names - Core chart of accounts with hierarchical Beancount-style names
- Examples: "Assets:Bitcoin:Lightning", "Expenses:Food:Groceries" - Examples: "Assets:Bitcoin:Lightning", "Expenses:Food:Groceries"
- User-specific accounts: "Assets:Receivable:User-af983632" - User-specific accounts: "Assets:Receivable:User-af983632"
- Includes comprehensive default account set (40+ accounts) - Includes comprehensive default account set (40+ accounts)
### 2. castle_extension_settings ### 2. libra_extension_settings
- Castle-wide configuration - Libra-wide configuration
- Stores castle_wallet_id for Lightning payments - Stores libra_wallet_id for Lightning payments
### 3. castle_user_wallet_settings ### 3. libra_user_wallet_settings
- Per-user wallet configuration - Per-user wallet configuration
- Allows users to have separate wallet preferences - Allows users to have separate wallet preferences
### 4. castle_manual_payment_requests ### 4. libra_manual_payment_requests
- User-submitted payment requests to Castle - User-submitted payment requests to Libra
- Reviewed by admins before processing - Reviewed by admins before processing
- Includes notes field for additional context - Includes notes field for additional context
### 5. castle_balance_assertions ### 5. libra_balance_assertions
- Reconciliation and balance checking at specific dates - Reconciliation and balance checking at specific dates
- Multi-currency support (satoshis + fiat) - Multi-currency support (satoshis + fiat)
- Tolerance checking for small discrepancies - Tolerance checking for small discrepancies
- Includes notes field for reconciliation comments - Includes notes field for reconciliation comments
### 6. castle_user_equity_status ### 6. libra_user_equity_status
- Manages equity contribution eligibility - Manages equity contribution eligibility
- Equity-eligible users can convert expenses to equity - Equity-eligible users can convert expenses to equity
- Creates dynamic user-specific equity accounts: Equity:User-{user_id} - Creates dynamic user-specific equity accounts: Equity:User-{user_id}
### 7. castle_account_permissions ### 7. libra_account_permissions
- Granular access control for accounts - Granular access control for accounts
- Permission types: read, submit_expense, manage - Permission types: read, submit_expense, manage
- Supports hierarchical inheritance (parent permissions cascade) - 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): 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) - **libra_journal_entries** - Journal entries now managed by Fava/Beancount (external source of truth)
- **castle_entry_lines** - Entry lines now managed by Fava/Beancount - **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() - **Write:** Submit to Fava via FavaClient.add_entry()
- **Read:** Query Fava via FavaClient.get_entries() - **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: For new installations:
```bash ```bash
# Castle's migration system will run m001_initial automatically # Libra's migration system will run m001_initial automatically
# No manual intervention needed # No manual intervention needed
``` ```
@ -174,20 +174,20 @@ After squashing, verify the migration works:
```bash ```bash
# 1. Backup existing database (if any) # 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 # 2. Drop and recreate database to test fresh install
rm castle.sqlite3 rm libra.sqlite3
# 3. Start LNbits - migration should run automatically # 3. Start LNbits - migration should run automatically
poetry run lnbits poetry run lnbits
# 4. Verify tables created # 4. Verify tables created
sqlite3 castle.sqlite3 ".tables" sqlite3 libra.sqlite3 ".tables"
# Should show: castle_accounts, castle_extension_settings, etc. # Should show: libra_accounts, libra_extension_settings, etc.
# 5. Verify default accounts # 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) # Should show: 40 (default accounts)
``` ```
@ -200,12 +200,12 @@ If issues are discovered:
cp migrations_old.py.bak migrations.py cp migrations_old.py.bak migrations.py
# Restore database # Restore database
cp castle.sqlite3.backup castle.sqlite3 cp libra.sqlite3.backup libra.sqlite3
``` ```
## Notes ## 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 - No existing production databases need migration
- Historical migrations preserved in migrations_old.py.bak - Historical migrations preserved in migrations_old.py.bak
- All functionality preserved in final schema - All functionality preserved in final schema

View file

@ -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. A full-featured double-entry accounting system for collective projects, integrated with LNbits Lightning payments.
## Overview ## 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 - Track expenses and revenue with proper accounting
- Manage individual member balances - Manage individual member balances
- Record contributions as equity or reimbursable expenses - Record contributions as equity or reimbursable expenses
@ -17,7 +17,7 @@ This extension is designed to be installed in the `lnbits/extensions/` directory
```bash ```bash
cd lnbits/extensions/ 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. 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 "Liability" if you want reimbursement
- Choose "Equity" if it's a contribution - Choose "Equity" if it's a contribution
2. **View Your Balance**: See if the Castle owes you money or vice versa 2. **View Your Balance**: See if the Libra owes you money or vice versa
3. **Pay Outstanding Balance**: Generate a Lightning invoice to settle what you owe 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 ### Account Types
- **Assets**: Things the Castle owns (Cash, Bank, Accounts Receivable) - **Assets**: Things the Libra owns (Cash, Bank, Accounts Receivable)
- **Liabilities**: What the Castle owes (Accounts Payable to members) - **Liabilities**: What the Libra owes (Accounts Payable to members)
- **Equity**: Member contributions and retained earnings - **Equity**: Member contributions and retained earnings
- **Revenue**: Income streams - **Revenue**: Income streams
- **Expenses**: Operating costs - **Expenses**: Operating costs
@ -63,9 +63,9 @@ Enable the extension through the LNbits admin interface or by adding it to your
### Database Schema ### Database Schema
The extension creates three tables: The extension creates three tables:
- `castle.accounts` - Chart of accounts - `libra.accounts` - Chart of accounts
- `castle.journal_entries` - Transaction headers - `libra.journal_entries` - Transaction headers
- `castle.entry_lines` - Debit/credit lines - `libra.entry_lines` - Debit/credit lines
## API Reference ## API Reference
@ -79,7 +79,7 @@ To modify this extension:
2. Add database migrations in `migrations.py` 2. Add database migrations in `migrations.py`
3. Implement business logic in `crud.py` 3. Implement business logic in `crud.py`
4. Create API endpoints in `views_api.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 ## Contributing

View file

@ -5,24 +5,24 @@ from loguru import logger
from .crud import db from .crud import db
from .tasks import wait_for_paid_invoices from .tasks import wait_for_paid_invoices
from .views import castle_generic_router from .views import libra_generic_router
from .views_api import castle_api_router from .views_api import libra_api_router
castle_ext: APIRouter = APIRouter(prefix="/castle", tags=["Castle"]) libra_ext: APIRouter = APIRouter(prefix="/libra", tags=["Libra"])
castle_ext.include_router(castle_generic_router) libra_ext.include_router(libra_generic_router)
castle_ext.include_router(castle_api_router) libra_ext.include_router(libra_api_router)
castle_static_files = [ libra_static_files = [
{ {
"path": "/castle/static", "path": "/libra/static",
"name": "castle_static", "name": "libra_static",
} }
] ]
scheduled_tasks: list[asyncio.Task] = [] scheduled_tasks: list[asyncio.Task] = []
def castle_stop(): def libra_stop():
"""Clean up background tasks on extension shutdown""" """Clean up background tasks on extension shutdown"""
for task in scheduled_tasks: for task in scheduled_tasks:
try: try:
@ -31,32 +31,32 @@ def castle_stop():
logger.warning(ex) logger.warning(ex)
def castle_start(): def libra_start():
"""Initialize Castle extension background tasks""" """Initialize Libra extension background tasks"""
from lnbits.tasks import create_permanent_unique_task from lnbits.tasks import create_permanent_unique_task
from .fava_client import init_fava_client from .fava_client import init_fava_client
from .models import CastleSettings from .models import LibraSettings
from .tasks import wait_for_account_sync from .tasks import wait_for_account_sync
async def _init_fava(): async def _init_fava():
"""Load saved settings from DB, fall back to defaults.""" """Load saved settings from DB, fall back to defaults."""
from .crud import db as castle_db from .crud import db as libra_db
settings = None settings = None
try: try:
row = await castle_db.fetchone( row = await libra_db.fetchone(
"SELECT * FROM extension_settings LIMIT 1", "SELECT * FROM extension_settings LIMIT 1",
model=CastleSettings, model=LibraSettings,
) )
if row: if row:
settings = row settings = row
logger.info(f"Loaded Castle settings from DB: {settings.fava_url}/{settings.fava_ledger_slug}") logger.info(f"Loaded Libra settings from DB: {settings.fava_url}/{settings.fava_ledger_slug}")
except Exception as e: except Exception as e:
logger.warning(f"Could not load settings from DB: {e}") logger.warning(f"Could not load settings from DB: {e}")
if not settings: if not settings:
settings = CastleSettings() settings = LibraSettings()
logger.info(f"Using default Castle settings: {settings.fava_url}/{settings.fava_ledger_slug}") logger.info(f"Using default Libra settings: {settings.fava_url}/{settings.fava_ledger_slug}")
init_fava_client( init_fava_client(
fava_url=settings.fava_url, fava_url=settings.fava_url,
@ -69,16 +69,16 @@ def castle_start():
asyncio.get_event_loop().create_task(_init_fava()) asyncio.get_event_loop().create_task(_init_fava())
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize Fava client: {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 # 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) scheduled_tasks.append(task)
# Start account sync task (runs hourly) # 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) 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"]

View file

@ -1,11 +1,11 @@
""" """
Account Synchronization Module 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: This implements the hybrid approach:
- Beancount owns account existence (Open directives) - 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 - Background sync keeps them in sync
Related: ACCOUNTS-TABLE-REMOVAL-FEASIBILITY.md - Phase 2 implementation 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: 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. in Beancount, enabling permissions and user associations to work properly.
New behavior (soft delete + virtual parents): New behavior (soft delete + virtual parents):
- Accounts in Beancount but not in Castle DB: Added as active - Accounts in Beancount but not in Libra DB: Added as active
- Accounts in Castle DB but not in Beancount: Marked as inactive (soft delete) - Accounts in Libra DB but not in Beancount: Marked as inactive (soft delete)
- Inactive accounts that return to Beancount: Reactivated - Inactive accounts that return to Beancount: Reactivated
- Missing intermediate parents: Auto-created as virtual accounts - 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: dict with sync statistics:
{ {
"total_beancount_accounts": 150, "total_beancount_accounts": 150,
"total_castle_accounts": 148, "total_libra_accounts": 148,
"accounts_added": 2, "accounts_added": 2,
"accounts_updated": 0, "accounts_updated": 0,
"accounts_skipped": 148, "accounts_skipped": 148,
@ -123,7 +123,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
"errors": [] "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() 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}") logger.error(f"Failed to fetch accounts from Beancount: {e}")
return { return {
"total_beancount_accounts": 0, "total_beancount_accounts": 0,
"total_castle_accounts": 0, "total_libra_accounts": 0,
"accounts_added": 0, "accounts_added": 0,
"accounts_updated": 0, "accounts_updated": 0,
"accounts_skipped": 0, "accounts_skipped": 0,
@ -143,16 +143,16 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
"errors": [str(e)], "errors": [str(e)],
} }
# Get all accounts from Castle DB (including inactive ones for sync) # Get all accounts from Libra DB (including inactive ones for sync)
castle_accounts = await get_all_accounts(include_inactive=True) libra_accounts = await get_all_accounts(include_inactive=True)
# Build lookup maps # Build lookup maps
beancount_account_names = {acc["account"] for acc in beancount_accounts} 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 = { stats = {
"total_beancount_accounts": len(beancount_accounts), "total_beancount_accounts": len(beancount_accounts),
"total_castle_accounts": len(castle_accounts), "total_libra_accounts": len(libra_accounts),
"accounts_added": 0, "accounts_added": 0,
"accounts_updated": 0, "accounts_updated": 0,
"accounts_skipped": 0, "accounts_skipped": 0,
@ -162,15 +162,15 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
"errors": [], "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: for bc_account in beancount_accounts:
account_name = bc_account["account"] account_name = bc_account["account"]
try: try:
existing = castle_accounts_by_name.get(account_name) existing = libra_accounts_by_name.get(account_name)
if existing: if existing:
# Account exists in Castle DB # Account exists in Libra DB
# Check if it needs to be reactivated # Check if it needs to be reactivated
if not existing.is_active: if not existing.is_active:
await update_account_is_active(existing.id, True) 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}") logger.debug(f"Account already active: {account_name}")
continue continue
# Create new account in Castle DB # Create new account in Libra DB
account_type = infer_account_type_from_name(account_name) account_type = infer_account_type_from_name(account_name)
user_id = extract_user_id_from_account_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) logger.error(error_msg)
stats["errors"].append(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) # SKIP virtual accounts (they're intentionally metadata-only)
for castle_account in castle_accounts: for libra_account in libra_accounts:
if castle_account.is_virtual: if libra_account.is_virtual:
# Virtual accounts are metadata-only, never deactivate them # Virtual accounts are metadata-only, never deactivate them
continue 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 # Account no longer exists in Beancount
if castle_account.is_active: if libra_account.is_active:
try: try:
await update_account_is_active(castle_account.id, False) await update_account_is_active(libra_account.id, False)
stats["accounts_deactivated"] += 1 stats["accounts_deactivated"] += 1
logger.info( logger.info(
f"Deactivated orphaned account: {castle_account.name}" f"Deactivated orphaned account: {libra_account.name}"
) )
except Exception as e: except Exception as e:
error_msg = ( error_msg = (
f"Failed to deactivate account {castle_account.name}: {e}" f"Failed to deactivate account {libra_account.name}: {e}"
) )
logger.error(error_msg) logger.error(error_msg)
stats["errors"].append(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 # 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 # Otherwise we'll be checking against stale data and miss newly synced children
current_castle_accounts = await get_all_accounts(include_inactive=True) current_libra_accounts = await get_all_accounts(include_inactive=True)
all_account_names = {acc.name for acc in current_castle_accounts} all_account_names = {acc.name for acc in current_libra_accounts}
for bc_account in beancount_accounts: for bc_account in beancount_accounts:
account_name = bc_account["account"] 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: 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. granting permissions on it.
Args: Args:
@ -318,7 +318,7 @@ async def sync_single_account_from_beancount(account_name: str) -> bool:
logger.error(f"Account not found in Beancount: {account_name}") logger.error(f"Account not found in Beancount: {account_name}")
return False return False
# Create in Castle DB # Create in Libra DB
account_type = infer_account_type_from_name(account_name) account_type = infer_account_type_from_name(account_name)
user_id = extract_user_id_from_account_name(account_name) user_id = extract_user_id_from_account_name(account_name)
@ -343,9 +343,9 @@ async def sync_single_account_from_beancount(account_name: str) -> bool:
return False 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. This is the recommended function to call before granting permissions.
@ -355,7 +355,7 @@ async def ensure_account_exists_in_castle(account_name: str) -> bool:
Returns: Returns:
True if account exists (or was created), False if failed 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) existing = await get_account_by_name(account_name)
if existing: if existing:
return True return True
@ -367,9 +367,9 @@ async def ensure_account_exists_in_castle(account_name: str) -> bool:
# Background sync task (can be scheduled with cron or async scheduler) # Background sync task (can be scheduled with cron or async scheduler)
async def scheduled_account_sync(): 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: Example with APScheduler:
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler

View file

@ -200,11 +200,11 @@ DEFAULT_HIERARCHICAL_ACCOUNTS = [
("Assets:FixedAssets:ProductionFacility", AccountType.ASSET, "Production facilities"), ("Assets:FixedAssets:ProductionFacility", AccountType.ASSET, "Production facilities"),
("Assets:Inventory", AccountType.ASSET, "Inventory and stock"), ("Assets:Inventory", AccountType.ASSET, "Inventory and stock"),
("Assets:Livestock", AccountType.ASSET, "Livestock and animals"), ("Assets:Livestock", AccountType.ASSET, "Livestock and animals"),
("Assets:Receivable", AccountType.ASSET, "Money owed to the Castle"), ("Assets:Receivable", AccountType.ASSET, "Money owed to the Libra"),
("Assets:Tools", AccountType.ASSET, "Tools and hand equipment"), ("Assets:Tools", AccountType.ASSET, "Tools and hand equipment"),
# Liabilities # Liabilities
("Liabilities:Payable", AccountType.LIABILITY, "Money owed by the Castle"), ("Liabilities:Payable", AccountType.LIABILITY, "Money owed by the Libra"),
# Equity - User equity accounts created dynamically as Equity:User-{user_id} # Equity - User equity accounts created dynamically as Equity:User-{user_id}
# No parent "Equity" account needed - hierarchy is implicit in the name # No parent "Equity" account needed - hierarchy is implicit in the name

View file

@ -1,5 +1,5 @@
""" """
Centralized Authorization Module for Castle Extension. Centralized Authorization Module for Libra Extension.
Provides consistent, secure authorization patterns across all endpoints. Provides consistent, secure authorization patterns across all endpoints.
@ -55,9 +55,9 @@ class AuthContext:
@property @property
def is_admin(self) -> bool: def is_admin(self) -> bool:
""" """
Check if user is a Castle admin (super user). Check if user is a Libra admin (super user).
Note: In Castle, admin = super_user. There's no separate admin concept. Note: In Libra, admin = super_user. There's no separate admin concept.
""" """
return self.is_super_user return self.is_super_user
@ -130,7 +130,7 @@ async def require_super_user(
Require super user access. Require super user access.
Raises HTTPException 403 if not super user. Raises HTTPException 403 if not super user.
Use for Castle admin operations. Use for Libra admin operations.
""" """
auth = _build_auth_context(wallet) auth = _build_auth_context(wallet)
if not auth.is_super_user: if not auth.is_super_user:

View file

@ -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. 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: Key concepts:
- Amounts are strings: "200000 SATS" or "100.00 EUR" - Amounts are strings: "200000 SATS" or "100.00 EUR"
@ -35,8 +35,8 @@ def sanitize_link(text: str) -> str:
'Test-pending' 'Test-pending'
>>> sanitize_link("Invoice #123") >>> sanitize_link("Invoice #123")
'Invoice-123' 'Invoice-123'
>>> sanitize_link("castle-abc123") >>> sanitize_link("libra-abc123")
'castle-abc123' 'libra-abc123'
""" """
# Replace any character that's not alphanumeric, dash, underscore, slash, or period with a hyphen # Replace any character that's not alphanumeric, dash, underscore, slash, or period with a hyphen
sanitized = re.sub(r'[^A-Za-z0-9\-_/.]', '-', text) 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) postings: List of posting dicts (formatted by format_posting)
payee: Optional payee payee: Optional payee
tags: Optional tags (e.g., ["expense-entry", "approved"]) 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 meta: Optional transaction metadata
Returns: Returns:
@ -93,8 +93,8 @@ def format_transaction(
) )
], ],
tags=["expense-entry"], tags=["expense-entry"],
links=["castle-abc123"], links=["libra-abc123"],
meta={"user-id": "abc123", "source": "castle-expense-entry"} meta={"user-id": "abc123", "source": "libra-expense-entry"}
) )
""" """
return { return {
@ -150,7 +150,7 @@ def format_posting_with_cost(
""" """
Format a posting with cost basis for Fava API. 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. Uses Beancount's cost basis syntax to preserve exchange rates.
IMPORTANT: Beancount cost syntax uses PER-UNIT cost, not total cost. IMPORTANT: Beancount cost syntax uses PER-UNIT cost, not total cost.
@ -381,7 +381,7 @@ def format_expense_entry(
# Build entry metadata # Build entry metadata
entry_meta = { entry_meta = {
"user-id": user_id, "user-id": user_id,
"source": "castle-api", "source": "libra-api",
"entry-id": entry_id "entry-id": entry_id
} }
@ -419,7 +419,7 @@ def format_receivable_entry(
entry_id: Optional[str] = None entry_id: Optional[str] = None
) -> Dict[str, Any]: ) -> 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. Uses price notation (@@ SATS) for BQL-queryable SATS tracking.
Generates unique receivable link (^rcv-{entry_id}) for settlement tracking. Generates unique receivable link (^rcv-{entry_id}) for settlement tracking.
@ -466,7 +466,7 @@ def format_receivable_entry(
entry_meta = { entry_meta = {
"user-id": user_id, "user-id": user_id,
"source": "castle-api", "source": "libra-api",
"entry-id": entry_id "entry-id": entry_id
} }
@ -512,7 +512,7 @@ def format_payment_entry(
amount_sats: Amount in satoshis (unsigned) amount_sats: Amount in satoshis (unsigned)
description: Payment description description: Payment description
entry_date: Date of payment 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_currency: Optional fiat currency
fiat_amount: Optional fiat amount (unsigned) fiat_amount: Optional fiat amount (unsigned)
payment_hash: Lightning payment hash 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) # 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 fiat_currency and fiat_amount_abs and amount_sats_abs > 0:
if is_payable: if is_payable:
# Castle paying user: DR Payable, CR Lightning # Libra paying user: DR Payable, CR Lightning
postings = [ postings = [
format_posting_with_cost( format_posting_with_cost(
account=payable_or_receivable_account, account=payable_or_receivable_account,
@ -546,7 +546,7 @@ def format_payment_entry(
) )
] ]
else: else:
# User paying castle: DR Lightning, CR Receivable # User paying libra: DR Lightning, CR Receivable
postings = [ postings = [
format_posting_simple( format_posting_simple(
account=payment_account, account=payment_account,
@ -633,7 +633,7 @@ def format_fiat_settlement_entry(
amount_sats: Equivalent amount in satoshis amount_sats: Equivalent amount in satoshis
description: Payment description description: Payment description
entry_date: Date of settlement 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.) payment_method: Payment method (cash, bank_transfer, check, etc.)
reference: Optional reference reference: Optional reference
settled_entry_links: List of expense/receivable links being settled (e.g., ["exp-abc123", "exp-def456"]) 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 # Build postings using price notation (@@ SATS) for BQL queryability
if is_payable: if is_payable:
# Castle paying user: DR Payable, CR Cash/Bank # Libra paying user: DR Payable, CR Cash/Bank
postings = [ postings = [
{ {
"account": payable_or_receivable_account, "account": payable_or_receivable_account,
@ -658,7 +658,7 @@ def format_fiat_settlement_entry(
} }
] ]
else: else:
# User paying castle: DR Cash/Bank, CR Receivable # User paying libra: DR Cash/Bank, CR Receivable
postings = [ postings = [
{ {
"account": payment_account, "account": payment_account,
@ -815,7 +815,7 @@ def format_revenue_entry(
reference: Optional[str] = None reference: Optional[str] = None
) -> Dict[str, Any]: ) -> 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. Creates a cleared transaction (flag="*") since payment was received.
@ -869,7 +869,7 @@ def format_revenue_entry(
# Note: created-via is redundant with #revenue-entry tag # Note: created-via is redundant with #revenue-entry tag
entry_meta = { entry_meta = {
"source": "castle-api" "source": "libra-api"
} }
links = [] links = []

View file

@ -1,11 +1,11 @@
{ {
"name": "Castle Accounting", "name": "Libra",
"short_description": "Double-entry accounting system for collective projects", "short_description": "Double-entry accounting system for collective projects",
"tile": "/castle/static/image/castle.png", "tile": "/libra/static/image/libra.png",
"contributors": [ "contributors": [
"Your Name" "Your Name"
], ],
"hidden": false, "hidden": false,
"migration_module": "lnbits.extensions.castle.migrations", "migration_module": "lnbits.extensions.libra.migrations",
"db_name": "ext_castle" "db_name": "ext_libra"
} }

View file

@ -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, This module contains the core business logic for double-entry accounting,
following Beancount patterns for clean architecture: following Beancount patterns for clean architecture:

View file

@ -1,5 +1,5 @@
""" """
Validation rules for Castle accounting. Validation rules for Libra accounting.
Comprehensive validation following Beancount's plugin system approach, Comprehensive validation following Beancount's plugin system approach,
but implemented as simple functions that can be called directly. but implemented as simple functions that can be called directly.
@ -159,7 +159,7 @@ def validate_receivable_entry(
revenue_account_type: str revenue_account_type: str
) -> None: ) -> None:
""" """
Validate a receivable entry (user owes castle). Validate a receivable entry (user owes libra).
Args: Args:
user_id: User ID user_id: User ID

64
crud.py
View file

@ -14,7 +14,7 @@ from .models import (
AssertionStatus, AssertionStatus,
AssignUserRole, AssignUserRole,
BalanceAssertion, BalanceAssertion,
CastleSettings, LibraSettings,
CreateAccount, CreateAccount,
CreateAccountPermission, CreateAccountPermission,
CreateBalanceAssertion, CreateBalanceAssertion,
@ -32,7 +32,7 @@ from .models import (
StoredUserWalletSettings, StoredUserWalletSettings,
UpdateRole, UpdateRole,
UserBalance, UserBalance,
UserCastleSettings, UserLibraSettings,
UserEquityStatus, UserEquityStatus,
UserRole, UserRole,
UserWalletSettings, UserWalletSettings,
@ -49,7 +49,7 @@ from .core.validation import (
validate_payment_entry, validate_payment_entry,
) )
db = Database("ext_castle") db = Database("ext_libra")
# ===== CACHING ===== # ===== CACHING =====
# Cache for account and permission lookups to reduce DB queries # 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. Get or create a user-specific account with hierarchical naming.
This function checks if the account exists in Fava/Beancount and creates it 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.). metadata tracking (permissions, descriptions, etc.).
Examples: Examples:
@ -214,7 +214,7 @@ async def get_or_create_user_account(
# Generate hierarchical account name # Generate hierarchical account name
account_name = format_hierarchical_account_name(account_type, base_name, user_id) 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( account = await db.fetchone(
""" """
SELECT * FROM accounts SELECT * FROM accounts
@ -224,9 +224,9 @@ async def get_or_create_user_account(
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 # This ensures Beancount has the Open directive
fava_account_exists = False fava_account_exists = False
if True: # Always check Fava if True: # Always check Fava
@ -262,23 +262,23 @@ async def get_or_create_user_account(
except Exception as e: except Exception as e:
logger.error(f"[FAVA ERROR] Could not check/create account in Fava: {e}", exc_info=True) 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 # This uses the account sync module for consistency
if not account: 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 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) created = await sync_single_account_from_beancount(account_name)
if created: 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: 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( account = await db.fetchone(
""" """
SELECT * FROM accounts SELECT * FROM accounts
@ -289,9 +289,9 @@ async def get_or_create_user_account(
) )
if not account: if not account:
logger.error(f"[CASTLE DB] Account still not found after sync: {account_name}") logger.error(f"[LIBRA DB] Account still not found after sync: {account_name}")
# Fallback: create directly in Castle DB if sync failed # Fallback: create directly in Libra DB if sync failed
logger.info(f"[CASTLE DB] Creating account directly in Castle DB: {account_name}") logger.info(f"[LIBRA DB] Creating account directly in Libra DB: {account_name}")
try: try:
account = await create_account( account = await create_account(
CreateAccount( CreateAccount(
@ -304,7 +304,7 @@ async def get_or_create_user_account(
except Exception as e: except Exception as e:
# Handle UNIQUE constraint error - account already exists # Handle UNIQUE constraint error - account already exists
if "UNIQUE constraint failed" in str(e) and "accounts.name" in str(e): 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) # Fetch existing account by name only (ignore user_id in query)
account = await db.fetchone( account = await db.fetchone(
""" """
@ -315,10 +315,10 @@ async def get_or_create_user_account(
Account, Account,
) )
if 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 # Update user_id if it's NULL or different
if account.user_id != user_id: 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( await db.execute(
""" """
UPDATE accounts UPDATE accounts
@ -340,7 +340,7 @@ async def get_or_create_user_account(
# Re-raise if it's a different error # Re-raise if it's a different error
raise raise
else: 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 return account
@ -351,7 +351,7 @@ async def get_or_create_user_account(
# ===== JOURNAL ENTRY OPERATIONS (REMOVED) ===== # ===== JOURNAL ENTRY OPERATIONS (REMOVED) =====
# #
# All journal entry operations have been moved to Fava/Beancount. # 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: # For journal entry operations, see:
# - views_api.py: api_create_journal_entry() - writes to Fava via FavaClient # - views_api.py: api_create_journal_entry() - writes to Fava via FavaClient
@ -375,29 +375,29 @@ async def get_or_create_user_account(
# ===== SETTINGS ===== # ===== SETTINGS =====
async def create_castle_settings( async def create_libra_settings(
user_id: str, data: CastleSettings user_id: str, data: LibraSettings
) -> CastleSettings: ) -> LibraSettings:
settings = UserCastleSettings(**data.dict(), id=user_id) settings = UserLibraSettings(**data.dict(), id=user_id)
await db.insert("extension_settings", settings) await db.insert("extension_settings", settings)
return 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( return await db.fetchone(
""" """
SELECT * FROM extension_settings SELECT * FROM extension_settings
WHERE id = :user_id WHERE id = :user_id
""", """,
{"user_id": user_id}, {"user_id": user_id},
CastleSettings, LibraSettings,
) )
async def update_castle_settings( async def update_libra_settings(
user_id: str, data: CastleSettings user_id: str, data: LibraSettings
) -> CastleSettings: ) -> LibraSettings:
settings = UserCastleSettings(**data.dict(), id=user_id) settings = UserLibraSettings(**data.dict(), id=user_id)
await db.update("extension_settings", settings) await db.update("extension_settings", settings)
return settings return settings

View file

@ -1,4 +1,4 @@
# Castle Accounting # Libra
A comprehensive double-entry accounting system for collective projects, designed specifically for LNbits. 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 - **Double-Entry Bookkeeping**: Full accounting system with debits and credits
- **Chart of Accounts**: Pre-configured accounts for Assets, Liabilities, Equity, Revenue, and Expenses - **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: - **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 - **Equity**: Their contribution to the collective
- **Accounts Receivable**: Track what users owe the Castle (e.g., accommodation fees) - **Accounts Receivable**: Track what users owe the Libra (e.g., accommodation fees)
- **Revenue Tracking**: Record revenue received by the collective - **Revenue Tracking**: Record revenue received by the collective
- **User Balance Dashboard**: Each user sees their balance with the Castle - **User Balance Dashboard**: Each user sees their balance with the Libra
- **Lightning Integration**: Generate invoices for outstanding balances - **Lightning Integration**: Generate invoices for outstanding balances
- **Transaction History**: View all accounting entries and transactions - **Transaction History**: View all accounting entries and transactions
## Use Cases ## Use Cases
### 1. User Pays Expense Out of Pocket ### 1. User Pays Expense Out of Pocket
When a member buys supplies for the Castle: When a member buys supplies for the Libra:
- They can choose to be reimbursed (Liability) - They can choose to be reimbursed (Liability)
- Or contribute it as equity (Equity) - Or contribute it as equity (Equity)
### 2. Accounts Receivable ### 2. Accounts Receivable
When someone stays at the Castle and owes money: When someone stays at the Libra and owes money:
- Admin creates an AR entry (e.g., "5 nights @ 10€/night = 50€") - Admin creates an AR entry (e.g., "5 nights @ 10€/night = 50€")
- User sees they owe 50€ in their dashboard - User sees they owe 50€ in their dashboard
- They can generate an invoice to pay it off - They can generate an invoice to pay it off
### 3. Revenue Recording ### 3. Revenue Recording
When the Castle receives revenue: When the Libra receives revenue:
- Record revenue with the payment method (Cash, Lightning, Bank) - Record revenue with the payment method (Cash, Lightning, Bank)
- Properly categorized in the accounting system - Properly categorized in the accounting system
@ -58,8 +58,8 @@ When the Castle receives revenue:
## Getting Started ## Getting Started
1. Enable the Castle extension in LNbits 1. Enable the Libra extension in LNbits
2. Visit the Castle page to see your dashboard 2. Visit the Libra page to see your dashboard
3. Start tracking expenses and balances! 3. Start tracking expenses and balances!
The extension automatically creates a default chart of accounts on first run. The extension automatically creates a default chart of accounts on first run.

View file

@ -8,9 +8,9 @@
## Summary ## 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 2. **Bulk Permission Management** - Tools for managing permissions at scale
**Total Implementation Time**: ~4 hours **Total Implementation Time**: ~4 hours
@ -23,24 +23,24 @@ Implemented two major improvements for Castle administration:
### Problem Solved ### Problem Solved
**Before**: Accounts existed in both Beancount and Castle DB, with manual sync required. **Before**: Accounts existed in both Beancount and Libra DB, with manual sync required.
**After**: Automatic sync keeps Castle DB in sync with Beancount (source of truth). **After**: Automatic sync keeps Libra DB in sync with Beancount (source of truth).
### Implementation ### Implementation
**New Module**: `castle/account_sync.py` **New Module**: `libra/account_sync.py`
**Core Functions**: **Core Functions**:
```python ```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) stats = await sync_accounts_from_beancount(force_full_sync=False)
# 2. Sync single account # 2. Sync single account
success = await sync_single_account_from_beancount("Expenses:Food") success = await sync_single_account_from_beancount("Expenses:Food")
# 3. Ensure account exists (recommended before granting permissions) # 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) # 4. Scheduled background sync (run hourly)
stats = await scheduled_account_sync() stats = await scheduled_account_sync()
@ -77,7 +77,7 @@ stats = await scheduled_account_sync()
```python ```python
# Sync all accounts from Beancount # 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() stats = await sync_accounts_from_beancount()
@ -96,11 +96,11 @@ Errors: 0
#### Before Granting Permission (Best Practice) #### Before Granting Permission (Best Practice)
```python ```python
from castle.account_sync import ensure_account_exists_in_castle from libra.account_sync import ensure_account_exists_in_libra
from castle.crud import create_account_permission from libra.crud import create_account_permission
# Ensure account exists in Castle DB first # Ensure account exists in Libra DB first
account_exists = await ensure_account_exists_in_castle("Expenses:Marketing") account_exists = await ensure_account_exists_in_libra("Expenses:Marketing")
if account_exists: if account_exists:
# Now safe to grant permission # Now safe to grant permission
@ -116,9 +116,9 @@ if account_exists:
```python ```python
# Add to your scheduler (cron, APScheduler, etc.) # 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( scheduler.add_job(
scheduled_account_sync, scheduled_account_sync,
'interval', 'interval',
@ -142,7 +142,7 @@ Authorization: Bearer {admin_key}
```json ```json
{ {
"total_beancount_accounts": 150, "total_beancount_accounts": 150,
"total_castle_accounts": 150, "total_libra_accounts": 150,
"accounts_added": 2, "accounts_added": 2,
"accounts_updated": 0, "accounts_updated": 0,
"accounts_skipped": 148, "accounts_skipped": 148,
@ -152,8 +152,8 @@ Authorization: Bearer {admin_key}
### Benefits ### Benefits
1. **Beancount as Source of Truth**: Castle DB automatically reflects Beancount state 1. **Beancount as Source of Truth**: Libra DB automatically reflects Beancount state
2. **Reduced Manual Work**: No more manual account creation in Castle 2. **Reduced Manual Work**: No more manual account creation in Libra
3. **Prevents Permission Errors**: Cannot grant permission on non-existent account 3. **Prevents Permission Errors**: Cannot grant permission on non-existent account
4. **Audit Trail**: Tracks which accounts were synced and when 4. **Audit Trail**: Tracks which accounts were synced and when
5. **Safe Operations**: Continues on errors, never deletes accounts 5. **Safe Operations**: Continues on errors, never deletes accounts
@ -169,7 +169,7 @@ Authorization: Bearer {admin_key}
### Implementation ### Implementation
**New Module**: `castle/permission_management.py` **New Module**: `libra/permission_management.py`
**Core Functions**: **Core Functions**:
@ -471,19 +471,19 @@ print(f"Permission types removed: {result['permission_types_removed']}")
# OLD: Manual permission creation (risky) # OLD: Manual permission creation (risky)
await create_account_permission( await create_account_permission(
user_id="alice", 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, permission_type=PermissionType.SUBMIT_EXPENSE,
granted_by="admin" granted_by="admin"
) )
# NEW: Safe permission creation with account sync # 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 # 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: 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( await create_account_permission(
user_id="alice", user_id="alice",
account_id=account_id, account_id=account_id,
@ -497,10 +497,10 @@ else:
### Scheduler Integration ### Scheduler Integration
```python ```python
# Add to your Castle extension startup # Add to your Libra extension startup
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from castle.account_sync import scheduled_account_sync from libra.account_sync import scheduled_account_sync
from castle.permission_management import cleanup_expired_permissions from libra.permission_management import cleanup_expired_permissions
scheduler = AsyncIOScheduler() scheduler = AsyncIOScheduler()
@ -610,7 +610,7 @@ async def test_copy_permissions():
async def test_onboarding_workflow(): async def test_onboarding_workflow():
"""Test complete onboarding workflow""" """Test complete onboarding workflow"""
# 1. Sync account # 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 # 2. Copy permissions from template user
result = await copy_permissions( result = await copy_permissions(
@ -745,19 +745,19 @@ logger.error(f"Account sync error: {error}")
## Migration Guide ## Migration Guide
### For Existing Castle Installations ### For Existing Libra Installations
**Step 1: Deploy New Modules** **Step 1: Deploy New Modules**
```bash ```bash
# Copy new files to Castle extension # Copy new files to Libra extension
cp account_sync.py /path/to/castle/ cp account_sync.py /path/to/libra/
cp permission_management.py /path/to/castle/ cp permission_management.py /path/to/libra/
``` ```
**Step 2: Initial Account Sync** **Step 2: Initial Account Sync**
```python ```python
# Run once to sync existing accounts # 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) stats = await sync_accounts_from_beancount(force_full_sync=True)
print(f"Synced {stats['accounts_added']} accounts") print(f"Synced {stats['accounts_added']} accounts")
@ -784,14 +784,14 @@ await bulk_grant_permission(...)
## Documentation Updates ## Documentation Updates
**New files created**: **New files created**:
- ✅ `castle/account_sync.py` (230 lines) - ✅ `libra/account_sync.py` (230 lines)
- ✅ `castle/permission_management.py` (400 lines) - ✅ `libra/permission_management.py` (400 lines)
- ✅ `docs/PERMISSIONS-SYSTEM.md` (full permission system docs) - ✅ `docs/PERMISSIONS-SYSTEM.md` (full permission system docs)
- ✅ `docs/ACCOUNT-SYNC-AND-PERMISSION-IMPROVEMENTS.md` (this file) - ✅ `docs/ACCOUNT-SYNC-AND-PERMISSION-IMPROVEMENTS.md` (this file)
**Files to update**: **Files to update**:
- `castle/views_api.py` - Add new admin endpoints - `libra/views_api.py` - Add new admin endpoints
- `castle/README.md` - Document new features - `libra/README.md` - Document new features
- `tests/` - Add comprehensive tests - `tests/` - Add comprehensive tests
--- ---
@ -801,7 +801,7 @@ await bulk_grant_permission(...)
### What Was Built ### What Was Built
1. **Account Sync Module** (230 lines) 1. **Account Sync Module** (230 lines)
- Automatic sync from Beancount → Castle DB - Automatic sync from Beancount → Libra DB
- Type inference and user ID extraction - Type inference and user ID extraction
- Background scheduling support - Background scheduling support

View file

@ -195,13 +195,13 @@ id="toc-professional-assessment">Professional Assessment</a></li>
<h1 id="accounting-analysis-net-settlement-entry-pattern">Accounting <h1 id="accounting-analysis-net-settlement-entry-pattern">Accounting
Analysis: Net Settlement Entry Pattern</h1> Analysis: Net Settlement Entry Pattern</h1>
<p><strong>Date</strong>: 2025-01-12 <strong>Prepared By</strong>: <p><strong>Date</strong>: 2025-01-12 <strong>Prepared By</strong>:
Senior Accounting Review <strong>Subject</strong>: Castle Extension - Senior Accounting Review <strong>Subject</strong>: Libra Extension -
Lightning Payment Settlement Entries <strong>Status</strong>: Technical Lightning Payment Settlement Entries <strong>Status</strong>: Technical
Review</p> Review</p>
<hr /> <hr />
<h2 id="executive-summary">Executive Summary</h2> <h2 id="executive-summary">Executive Summary</h2>
<p>This document provides a professional accounting assessment of <p>This document provides a professional accounting assessment of
Castles net settlement entry pattern used for recording Lightning Libras net settlement entry pattern used for recording Lightning
Network payments that settle fiat-denominated receivables. The analysis Network payments that settle fiat-denominated receivables. The analysis
identifies areas where the implementation deviates from traditional identifies areas where the implementation deviates from traditional
accounting best practices and provides specific recommendations for accounting best practices and provides specific recommendations for
@ -214,7 +214,7 @@ hierarchy</p>
<hr /> <hr />
<h2 id="background-the-technical-challenge">Background: The Technical <h2 id="background-the-technical-challenge">Background: The Technical
Challenge</h2> Challenge</h2>
<p>Castle operates as a Lightning Network-integrated accounting system <p>Libra operates as a Lightning Network-integrated accounting system
for collectives (co-living spaces, makerspaces). It faces a unique for collectives (co-living spaces, makerspaces). It faces a unique
accounting challenge:</p> accounting challenge:</p>
<p><strong>Scenario</strong>: User creates a receivable in EUR (e.g., <p><strong>Scenario</strong>: User creates a receivable in EUR (e.g.,
@ -223,7 +223,7 @@ accounting challenge:</p>
<p><strong>Challenge</strong>: Record the payment while: 1. Clearing the <p><strong>Challenge</strong>: Record the payment while: 1. Clearing the
exact EUR receivable amount 2. Recording the exact satoshi amount exact EUR receivable amount 2. Recording the exact satoshi amount
received 3. Handling cases where users have both receivables (owe 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</p> double-entry balance</p>
<hr /> <hr />
<h2 id="current-implementation">Current Implementation</h2> <h2 id="current-implementation">Current Implementation</h2>
@ -231,7 +231,7 @@ double-entry balance</p>
<pre class="beancount"><code>; Step 1: Receivable Created <pre class="beancount"><code>; Step 1: Receivable Created
2025-11-12 * &quot;room (200.00 EUR)&quot; #receivable-entry 2025-11-12 * &quot;room (200.00 EUR)&quot; #receivable-entry
user-id: &quot;375ec158&quot; user-id: &quot;375ec158&quot;
source: &quot;castle-api&quot; source: &quot;libra-api&quot;
sats-amount: &quot;225033&quot; sats-amount: &quot;225033&quot;
Assets:Receivable:User-375ec158 200.00 EUR Assets:Receivable:User-375ec158 200.00 EUR
sats-equivalent: &quot;225033&quot; sats-equivalent: &quot;225033&quot;
@ -344,7 +344,7 @@ class="sourceCode sql"><code class="sourceCode sql"><span id="cb8-1"><a href="#c
payment-hash: &quot;8d080ec4...&quot; payment-hash: &quot;8d080ec4...&quot;
Assets:Receivable:User-375ec158 -200.00 EUR Assets:Receivable:User-375ec158 -200.00 EUR
; No sats-equivalent needed here</code></pre> ; No sats-equivalent needed here</code></pre>
<p><strong>Option B - Use EUR positions with metadata</strong> (Castles <p><strong>Option B - Use EUR positions with metadata</strong> (Libras
current approach):</p> current approach):</p>
<pre class="beancount"><code>Assets:Bitcoin:Lightning 200.00 EUR <pre class="beancount"><code>Assets:Bitcoin:Lightning 200.00 EUR
sats-received: &quot;225033&quot; sats-received: &quot;225033&quot;
@ -452,8 +452,8 @@ OR payable)</li>
(receivable AND payable)</li> (receivable AND payable)</li>
</ul> </ul>
<p><strong>When Net Settlement is Appropriate</strong>:</p> <p><strong>When Net Settlement is Appropriate</strong>:</p>
<pre><code>User owes Castle: 555.00 EUR (receivable) <pre><code>User owes Libra: 555.00 EUR (receivable)
Castle owes User: 38.00 EUR (payable) Libra owes User: 38.00 EUR (payable)
Net amount due: 517.00 EUR (true settlement)</code></pre> Net amount due: 517.00 EUR (true settlement)</code></pre>
<p>Proper three-posting entry:</p> <p>Proper three-posting entry:</p>
<pre class="beancount"><code>Assets:Bitcoin:Lightning 565251 SATS @@ 517.00 EUR <pre class="beancount"><code>Assets:Bitcoin:Lightning 565251 SATS @@ 517.00 EUR
@ -461,8 +461,8 @@ Assets:Receivable:User -555.00 EUR
Liabilities:Payable:User 38.00 EUR Liabilities:Payable:User 38.00 EUR
; Net: 517.00 = -555.00 + 38.00 ✓</code></pre> ; Net: 517.00 = -555.00 + 38.00 ✓</code></pre>
<p><strong>When Two Postings Suffice</strong>:</p> <p><strong>When Two Postings Suffice</strong>:</p>
<pre><code>User owes Castle: 200.00 EUR (receivable) <pre><code>User owes Libra: 200.00 EUR (receivable)
Castle owes User: 0.00 EUR (no payable) Libra owes User: 0.00 EUR (no payable)
Amount due: 200.00 EUR (simple payment)</code></pre> Amount due: 200.00 EUR (simple payment)</code></pre>
<p>Simpler two-posting entry:</p> <p>Simpler two-posting entry:</p>
<pre class="beancount"><code>Assets:Bitcoin:Lightning 225033 SATS @@ 200.00 EUR <pre class="beancount"><code>Assets:Bitcoin:Lightning 225033 SATS @@ 200.00 EUR
@ -515,7 +515,7 @@ positions - ❌ Requires metadata parsing for SATS balances</p>
id="approach-3-true-net-settlement-when-both-obligations-exist">Approach id="approach-3-true-net-settlement-when-both-obligations-exist">Approach
3: True Net Settlement (When Both Obligations Exist)</h3> 3: True Net Settlement (When Both Obligations Exist)</h3>
<pre class="beancount"><code>2025-11-12 * &quot;Net settlement via Lightning&quot; <pre class="beancount"><code>2025-11-12 * &quot;Net settlement via Lightning&quot;
; 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 Assets:Bitcoin:Lightning 517.00 EUR
sats-received: &quot;565251&quot; sats-received: &quot;565251&quot;
Assets:Receivable:User-375ec158 -555.00 EUR Assets:Receivable:User-375ec158 -555.00 EUR
@ -570,7 +570,7 @@ Method</h4>
<p><strong>Decision Required</strong>: Select either position-based OR <p><strong>Decision Required</strong>: Select either position-based OR
metadata-based satoshi tracking.</p> metadata-based satoshi tracking.</p>
<p><strong>Option A - Keep Metadata Approach</strong> (recommended for <p><strong>Option A - Keep Metadata Approach</strong> (recommended for
Castle):</p> Libra):</p>
<div class="sourceCode" id="cb25"><pre <div class="sourceCode" id="cb25"><pre
class="sourceCode python"><code class="sourceCode python"><span id="cb25-1"><a href="#cb25-1" aria-hidden="true" tabindex="-1"></a><span class="co"># In format_net_settlement_entry()</span></span> class="sourceCode python"><code class="sourceCode python"><span id="cb25-1"><a href="#cb25-1" aria-hidden="true" tabindex="-1"></a><span class="co"># In format_net_settlement_entry()</span></span>
<span id="cb25-2"><a href="#cb25-2" aria-hidden="true" tabindex="-1"></a>postings <span class="op">=</span> [</span> <span id="cb25-2"><a href="#cb25-2" aria-hidden="true" tabindex="-1"></a>postings <span class="op">=</span> [</span>
@ -604,7 +604,7 @@ class="sourceCode python"><code class="sourceCode python"><span id="cb26-1"><a h
<span id="cb26-12"><a href="#cb26-12" aria-hidden="true" tabindex="-1"></a> }</span> <span id="cb26-12"><a href="#cb26-12" aria-hidden="true" tabindex="-1"></a> }</span>
<span id="cb26-13"><a href="#cb26-13" aria-hidden="true" tabindex="-1"></a>]</span></code></pre></div> <span id="cb26-13"><a href="#cb26-13" aria-hidden="true" tabindex="-1"></a>]</span></code></pre></div>
<p><strong>Recommendation</strong>: Choose Option A (metadata) for <p><strong>Recommendation</strong>: Choose Option A (metadata) for
consistency with Castles architecture.</p> consistency with Libras architecture.</p>
<hr /> <hr />
<h4 id="rename-function-for-clarity">1.3 Rename Function for <h4 id="rename-function-for-clarity">1.3 Rename Function for
Clarity</h4> Clarity</h4>
@ -713,7 +713,7 @@ class="sourceCode python"><code class="sourceCode python"><span id="cb28-1"><a h
<span id="cb28-45"><a href="#cb28-45" aria-hidden="true" tabindex="-1"></a> payment_hash<span class="op">=</span>payment_hash</span> <span id="cb28-45"><a href="#cb28-45" aria-hidden="true" tabindex="-1"></a> payment_hash<span class="op">=</span>payment_hash</span>
<span id="cb28-46"><a href="#cb28-46" aria-hidden="true" tabindex="-1"></a> )</span> <span id="cb28-46"><a href="#cb28-46" aria-hidden="true" tabindex="-1"></a> )</span>
<span id="cb28-47"><a href="#cb28-47" aria-hidden="true" tabindex="-1"></a> <span class="cf">else</span>:</span> <span id="cb28-47"><a href="#cb28-47" aria-hidden="true" tabindex="-1"></a> <span class="cf">else</span>:</span>
<span id="cb28-48"><a href="#cb28-48" aria-hidden="true" tabindex="-1"></a> <span class="co"># PAYABLE PAYMENT: Castle paying user (different flow)</span></span> <span id="cb28-48"><a href="#cb28-48" aria-hidden="true" tabindex="-1"></a> <span class="co"># PAYABLE PAYMENT: Libra paying user (different flow)</span></span>
<span id="cb28-49"><a href="#cb28-49" aria-hidden="true" tabindex="-1"></a> <span class="cf">return</span> <span class="cf">await</span> format_payable_payment_entry(...)</span></code></pre></div> <span id="cb28-49"><a href="#cb28-49" aria-hidden="true" tabindex="-1"></a> <span class="cf">return</span> <span class="cf">await</span> format_payable_payment_entry(...)</span></code></pre></div>
<hr /> <hr />
<h3 id="priority-3-long-term-architectural-decisions">Priority 3: <h3 id="priority-3-long-term-architectural-decisions">Priority 3:
@ -742,7 +742,7 @@ architectures:</p>
<p><strong>Recommendation</strong>: Architecture A (EUR primary) <p><strong>Recommendation</strong>: Architecture A (EUR primary)
because: 1. Most receivables created in EUR 2. Financial reporting because: 1. Most receivables created in EUR 2. Financial reporting
requirements typically in fiat 3. Tax obligations calculated in fiat 4. requirements typically in fiat 3. Tax obligations calculated in fiat 4.
Aligns with current Castle metadata approach</p> Aligns with current Libra metadata approach</p>
<hr /> <hr />
<h4 id="consider-separate-ledger-for-cryptocurrency-holdings">3.2 <h4 id="consider-separate-ledger-for-cryptocurrency-holdings">3.2
Consider Separate Ledger for Cryptocurrency Holdings</h4> Consider Separate Ledger for Cryptocurrency Holdings</h4>
@ -754,7 +754,7 @@ from fiat accounting</p>
Assets:Receivable:User-375ec158 -200.00 EUR</code></pre> Assets:Receivable:User-375ec158 -200.00 EUR</code></pre>
<p><strong>Cryptocurrency Sub-Ledger</strong> (SATS-denominated):</p> <p><strong>Cryptocurrency Sub-Ledger</strong> (SATS-denominated):</p>
<pre class="beancount"><code>2025-11-12 * &quot;Lightning payment received&quot; <pre class="beancount"><code>2025-11-12 * &quot;Lightning payment received&quot;
Assets:Bitcoin:Lightning:Castle 225033 SATS Assets:Bitcoin:Lightning:Libra 225033 SATS
Assets:Bitcoin:Custody:User-375ec 225033 SATS</code></pre> Assets:Bitcoin:Custody:User-375ec 225033 SATS</code></pre>
<p><strong>Benefits</strong>: - ✅ Clean separation of concerns - ✅ <p><strong>Benefits</strong>: - ✅ Clean separation of concerns - ✅
Cryptocurrency movements tracked independently - ✅ Fiat accounting Cryptocurrency movements tracked independently - ✅ Fiat accounting
@ -902,7 +902,7 @@ Entry balances</p>
<p><strong>Is this “best practice” accounting?</strong> <p><strong>Is this “best practice” accounting?</strong>
<strong>No</strong>, this implementation deviates from traditional <strong>No</strong>, this implementation deviates from traditional
accounting standards in several ways.</p> accounting standards in several ways.</p>
<p><strong>Is it acceptable for Castles use case?</strong> <strong>Yes, <p><strong>Is it acceptable for Libras use case?</strong> <strong>Yes,
with modifications</strong>, its a reasonable pragmatic solution for a with modifications</strong>, its a reasonable pragmatic solution for a
novel problem (cryptocurrency payments of fiat debts).</p> novel problem (cryptocurrency payments of fiat debts).</p>
<p><strong>Critical improvements needed</strong>: 1. ✅ Remove <p><strong>Critical improvements needed</strong>: 1. ✅ Remove
@ -912,7 +912,7 @@ Separate payment vs. settlement logic (accuracy and clarity)</p>
<p><strong>The fundamental challenge</strong>: Traditional accounting <p><strong>The fundamental challenge</strong>: Traditional accounting
wasnt designed for this scenario. There is no established “standard” wasnt designed for this scenario. There is no established “standard”
for recording cryptocurrency payments of fiat-denominated receivables. for recording cryptocurrency payments of fiat-denominated receivables.
Castles approach is functional, but should be refined to align better Libras approach is functional, but should be refined to align better
with accounting principles where possible.</p> with accounting principles where possible.</p>
<h3 id="next-steps">Next Steps</h3> <h3 id="next-steps">Next Steps</h3>
<ol type="1"> <ol type="1">
@ -935,7 +935,7 @@ Characteristics of Accounting Information</li>
<li><strong>ASC 105-10-05</strong>: Substance Over Form</li> <li><strong>ASC 105-10-05</strong>: Substance Over Form</li>
<li><strong>Beancount Documentation</strong>: <li><strong>Beancount Documentation</strong>:
http://furius.ca/beancount/doc/index</li> http://furius.ca/beancount/doc/index</li>
<li><strong>Castle Extension</strong>: <li><strong>Libra Extension</strong>:
<code>docs/SATS-EQUIVALENT-METADATA.md</code></li> <code>docs/SATS-EQUIVALENT-METADATA.md</code></li>
<li><strong>BQL Analysis</strong>: <li><strong>BQL Analysis</strong>:
<code>docs/BQL-BALANCE-QUERIES.md</code></li> <code>docs/BQL-BALANCE-QUERIES.md</code></li>
@ -948,6 +948,6 @@ implemented</p>
<p><em>This analysis was prepared for internal review and development <p><em>This analysis was prepared for internal review and development
planning. It represents a professional accounting assessment of the planning. It represents a professional accounting assessment of the
current implementation and should be used to guide improvements to current implementation and should be used to guide improvements to
Castles payment recording system.</em></p> Libras payment recording system.</em></p>
</body> </body>
</html> </html>

View file

@ -2,14 +2,14 @@
**Date**: 2025-01-12 **Date**: 2025-01-12
**Prepared By**: Senior Accounting Review **Prepared By**: Senior Accounting Review
**Subject**: Castle Extension - Lightning Payment Settlement Entries **Subject**: Libra Extension - Lightning Payment Settlement Entries
**Status**: Technical Review **Status**: Technical Review
--- ---
## Executive Summary ## Executive Summary
This document provides a professional accounting assessment of Castle's net settlement entry pattern used for recording Lightning Network payments that settle fiat-denominated receivables. The analysis identifies areas where the implementation deviates from traditional accounting best practices and provides specific recommendations for improvement. This document provides a professional accounting assessment of Libra's net settlement entry pattern used for recording Lightning Network payments that settle fiat-denominated receivables. The analysis identifies areas where the implementation deviates from traditional accounting best practices and provides specific recommendations for improvement.
**Key Findings**: **Key Findings**:
- ✅ Double-entry integrity maintained - ✅ Double-entry integrity maintained
@ -23,14 +23,14 @@ This document provides a professional accounting assessment of Castle's net sett
## Background: The Technical Challenge ## Background: The Technical Challenge
Castle operates as a Lightning Network-integrated accounting system for collectives (co-living spaces, makerspaces). It faces a unique accounting challenge: Libra operates as a Lightning Network-integrated accounting system for collectives (co-living spaces, makerspaces). It faces a unique accounting challenge:
**Scenario**: User creates a receivable in EUR (e.g., €200 for room rent), then pays via Lightning Network in satoshis (225,033 sats). **Scenario**: User creates a receivable in EUR (e.g., €200 for room rent), then pays via Lightning Network in satoshis (225,033 sats).
**Challenge**: Record the payment while: **Challenge**: Record the payment while:
1. Clearing the exact EUR receivable amount 1. Clearing the exact EUR receivable amount
2. Recording the exact satoshi amount received 2. Recording the exact satoshi amount received
3. Handling cases where users have both receivables (owe Castle) and payables (Castle owes them) 3. Handling cases where users have both receivables (owe Libra) and payables (Libra owes them)
4. Maintaining Beancount double-entry balance 4. Maintaining Beancount double-entry balance
--- ---
@ -43,7 +43,7 @@ Castle operates as a Lightning Network-integrated accounting system for collecti
; Step 1: Receivable Created ; Step 1: Receivable Created
2025-11-12 * "room (200.00 EUR)" #receivable-entry 2025-11-12 * "room (200.00 EUR)" #receivable-entry
user-id: "375ec158" user-id: "375ec158"
source: "castle-api" source: "libra-api"
sats-amount: "225033" sats-amount: "225033"
Assets:Receivable:User-375ec158 200.00 EUR Assets:Receivable:User-375ec158 200.00 EUR
sats-equivalent: "225033" sats-equivalent: "225033"
@ -187,7 +187,7 @@ Assets:Receivable:User-375ec158 -200.00 EUR
; No sats-equivalent needed here ; No sats-equivalent needed here
``` ```
**Option B - Use EUR positions with metadata** (Castle's current approach): **Option B - Use EUR positions with metadata** (Libra's current approach):
```beancount ```beancount
Assets:Bitcoin:Lightning 200.00 EUR Assets:Bitcoin:Lightning 200.00 EUR
sats-received: "225033" sats-received: "225033"
@ -314,8 +314,8 @@ Assets:Receivable:User-375ec158 -200.00 EUR
**When Net Settlement is Appropriate**: **When Net Settlement is Appropriate**:
``` ```
User owes Castle: 555.00 EUR (receivable) User owes Libra: 555.00 EUR (receivable)
Castle owes User: 38.00 EUR (payable) Libra owes User: 38.00 EUR (payable)
Net amount due: 517.00 EUR (true settlement) Net amount due: 517.00 EUR (true settlement)
``` ```
@ -330,8 +330,8 @@ Liabilities:Payable:User 38.00 EUR
**When Two Postings Suffice**: **When Two Postings Suffice**:
``` ```
User owes Castle: 200.00 EUR (receivable) User owes Libra: 200.00 EUR (receivable)
Castle owes User: 0.00 EUR (no payable) Libra owes User: 0.00 EUR (no payable)
Amount due: 200.00 EUR (simple payment) Amount due: 200.00 EUR (simple payment)
``` ```
@ -405,7 +405,7 @@ Assets:Receivable:User -200.00 EUR
```beancount ```beancount
2025-11-12 * "Net settlement via Lightning" 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 Assets:Bitcoin:Lightning 517.00 EUR
sats-received: "565251" sats-received: "565251"
Assets:Receivable:User-375ec158 -555.00 EUR Assets:Receivable:User-375ec158 -555.00 EUR
@ -469,7 +469,7 @@ if total_payable_fiat > 0:
**Decision Required**: Select either position-based OR metadata-based satoshi tracking. **Decision Required**: Select either position-based OR metadata-based satoshi tracking.
**Option A - Keep Metadata Approach** (recommended for Castle): **Option A - Keep Metadata Approach** (recommended for Libra):
```python ```python
# In format_net_settlement_entry() # In format_net_settlement_entry()
postings = [ postings = [
@ -506,7 +506,7 @@ postings = [
] ]
``` ```
**Recommendation**: Choose Option A (metadata) for consistency with Castle's architecture. **Recommendation**: Choose Option A (metadata) for consistency with Libra's architecture.
--- ---
@ -625,7 +625,7 @@ async def create_payment_entry(
payment_hash=payment_hash payment_hash=payment_hash
) )
else: else:
# PAYABLE PAYMENT: Castle paying user (different flow) # PAYABLE PAYMENT: Libra paying user (different flow)
return await format_payable_payment_entry(...) return await format_payable_payment_entry(...)
``` ```
@ -663,7 +663,7 @@ async def create_payment_entry(
1. Most receivables created in EUR 1. Most receivables created in EUR
2. Financial reporting requirements typically in fiat 2. Financial reporting requirements typically in fiat
3. Tax obligations calculated in fiat 3. Tax obligations calculated in fiat
4. Aligns with current Castle metadata approach 4. Aligns with current Libra metadata approach
--- ---
@ -681,7 +681,7 @@ async def create_payment_entry(
**Cryptocurrency Sub-Ledger** (SATS-denominated): **Cryptocurrency Sub-Ledger** (SATS-denominated):
```beancount ```beancount
2025-11-12 * "Lightning payment received" 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 Assets:Bitcoin:Custody:User-375ec 225033 SATS
``` ```
@ -821,7 +821,7 @@ async def create_payment_entry(
**Is this "best practice" accounting?** **Is this "best practice" accounting?**
**No**, this implementation deviates from traditional accounting standards in several ways. **No**, this implementation deviates from traditional accounting standards in several ways.
**Is it acceptable for Castle's use case?** **Is it acceptable for Libra's use case?**
**Yes, with modifications**, it's a reasonable pragmatic solution for a novel problem (cryptocurrency payments of fiat debts). **Yes, with modifications**, it's a reasonable pragmatic solution for a novel problem (cryptocurrency payments of fiat debts).
**Critical improvements needed**: **Critical improvements needed**:
@ -829,7 +829,7 @@ async def create_payment_entry(
2. ✅ Implement exchange gain/loss tracking (required for compliance) 2. ✅ Implement exchange gain/loss tracking (required for compliance)
3. ✅ Separate payment vs. settlement logic (accuracy and clarity) 3. ✅ Separate payment vs. settlement logic (accuracy and clarity)
**The fundamental challenge**: Traditional accounting wasn't designed for this scenario. There is no established "standard" for recording cryptocurrency payments of fiat-denominated receivables. Castle's approach is functional, but should be refined to align better with accounting principles where possible. **The fundamental challenge**: Traditional accounting wasn't designed for this scenario. There is no established "standard" for recording cryptocurrency payments of fiat-denominated receivables. Libra's approach is functional, but should be refined to align better with accounting principles where possible.
### Next Steps ### Next Steps
@ -847,7 +847,7 @@ async def create_payment_entry(
- **FASB Concept Statement No. 2**: Qualitative Characteristics of Accounting Information - **FASB Concept Statement No. 2**: Qualitative Characteristics of Accounting Information
- **ASC 105-10-05**: Substance Over Form - **ASC 105-10-05**: Substance Over Form
- **Beancount Documentation**: http://furius.ca/beancount/doc/index - **Beancount Documentation**: http://furius.ca/beancount/doc/index
- **Castle Extension**: `docs/SATS-EQUIVALENT-METADATA.md` - **Libra Extension**: `docs/SATS-EQUIVALENT-METADATA.md`
- **BQL Analysis**: `docs/BQL-BALANCE-QUERIES.md` - **BQL Analysis**: `docs/BQL-BALANCE-QUERIES.md`
--- ---
@ -858,4 +858,4 @@ async def create_payment_entry(
--- ---
*This analysis was prepared for internal review and development planning. It represents a professional accounting assessment of the current implementation and should be used to guide improvements to Castle's payment recording system.* *This analysis was prepared for internal review and development planning. It represents a professional accounting assessment of the current implementation and should be used to guide improvements to Libra's payment recording system.*

View file

@ -1,8 +1,8 @@
# Beancount Patterns Analysis for Castle Extension # Beancount Patterns Analysis for Libra Extension
## Overview ## Overview
After analyzing the [Beancount repository](https://github.com/beancount/beancount), I've identified several excellent design patterns and architectural decisions that we should adopt or consider for the Castle Accounting extension. After analyzing the [Beancount repository](https://github.com/beancount/beancount), I've identified several excellent design patterns and architectural decisions that we should adopt or consider for the Libra extension.
## Key Patterns to Adopt ## Key Patterns to Adopt
@ -38,7 +38,7 @@ class Posting(NamedTuple):
- More memory efficient than regular classes - More memory efficient than regular classes
- Thread-safe by design - Thread-safe by design
**Castle Application:** **Libra Application:**
```python ```python
# In models.py # In models.py
from typing import NamedTuple, Optional from typing import NamedTuple, Optional
@ -109,15 +109,15 @@ def validate_commodity_directives(entries, options_map, config):
return entries, errors return entries, errors
``` ```
**Castle Application:** **Libra Application:**
```python ```python
# Create plugins/ directory # Create plugins/ directory
# lnbits/extensions/castle/plugins/__init__.py # lnbits/extensions/libra/plugins/__init__.py
from typing import Protocol, Tuple, List, Any from typing import Protocol, Tuple, List, Any
class CastlePlugin(Protocol): class LibraPlugin(Protocol):
"""Protocol for Castle plugins""" """Protocol for Libra plugins"""
def __call__( def __call__(
self, self,
@ -130,7 +130,7 @@ class CastlePlugin(Protocol):
Args: Args:
entries: Journal entries to process entries: Journal entries to process
settings: Castle settings settings: Libra settings
config: Plugin-specific configuration config: Plugin-specific configuration
Returns: Returns:
@ -212,7 +212,7 @@ class PluginManager:
if plugin_file.name.startswith('_'): if plugin_file.name.startswith('_'):
continue continue
module_name = f"castle.plugins.{plugin_file.stem}" module_name = f"libra.plugins.{plugin_file.stem}"
module = importlib.import_module(module_name) module = importlib.import_module(module_name)
if hasattr(module, '__plugins__'): if hasattr(module, '__plugins__'):
@ -276,7 +276,7 @@ class Inventory(dict[tuple[str, Optional[Cost]], Position]):
) )
``` ```
**Castle Application:** **Libra Application:**
```python ```python
# core/inventory.py # core/inventory.py
from decimal import Decimal from decimal import Decimal
@ -284,8 +284,8 @@ from typing import Optional, Dict, Tuple
from dataclasses import dataclass from dataclasses import dataclass
@dataclass(frozen=True) @dataclass(frozen=True)
class CastlePosition: class LibraPosition:
"""A position in the Castle inventory""" """A position in the Libra inventory"""
currency: str # "SATS", "EUR", "USD" currency: str # "SATS", "EUR", "USD"
amount: Decimal amount: Decimal
cost_currency: Optional[str] = None # Original currency if converted cost_currency: Optional[str] = None # Original currency if converted
@ -293,22 +293,22 @@ class CastlePosition:
date: Optional[datetime] = None date: Optional[datetime] = None
metadata: Dict[str, Any] = None metadata: Dict[str, Any] = None
class CastleInventory: class LibraInventory:
""" """
Track user balances across multiple currencies with conversion tracking. Track user balances across multiple currencies with conversion tracking.
Similar to Beancount's Inventory but optimized for Castle's use case. Similar to Beancount's Inventory but optimized for Libra's use case.
""" """
def __init__(self): def __init__(self):
self.positions: Dict[Tuple[str, Optional[str]], CastlePosition] = {} self.positions: Dict[Tuple[str, Optional[str]], LibraPosition] = {}
def add_position(self, position: CastlePosition): def add_position(self, position: LibraPosition):
"""Add or merge a position""" """Add or merge a position"""
key = (position.currency, position.cost_currency) key = (position.currency, position.cost_currency)
if key in self.positions: if key in self.positions:
existing = self.positions[key] existing = self.positions[key]
self.positions[key] = CastlePosition( self.positions[key] = LibraPosition(
currency=position.currency, currency=position.currency,
amount=existing.amount + position.amount, amount=existing.amount + position.amount,
cost_currency=position.cost_currency, cost_currency=position.cost_currency,
@ -353,9 +353,9 @@ class CastleInventory:
} }
# Usage in balance calculation: # Usage in balance calculation:
async def get_user_inventory(user_id: str) -> CastleInventory: async def get_user_inventory(user_id: str) -> LibraInventory:
"""Calculate user's inventory from journal entries""" """Calculate user's inventory from journal entries"""
inventory = CastleInventory() inventory = LibraInventory()
user_accounts = await get_user_accounts(user_id) user_accounts = await get_user_accounts(user_id)
for account in user_accounts: for account in user_accounts:
@ -369,7 +369,7 @@ async def get_user_inventory(user_id: str) -> CastleInventory:
# Beancount-style: positive = debit, negative = credit # Beancount-style: positive = debit, negative = credit
# Adjust sign for cost amount based on amount direction # Adjust sign for cost amount based on amount direction
cost_sign = 1 if line.amount > 0 else -1 cost_sign = 1 if line.amount > 0 else -1
inventory.add_position(CastlePosition( inventory.add_position(LibraPosition(
currency="SATS", currency="SATS",
amount=Decimal(line.amount), amount=Decimal(line.amount),
cost_currency=metadata.get("fiat_currency"), cost_currency=metadata.get("fiat_currency"),
@ -397,7 +397,7 @@ Every directive has a `meta: dict[str, Any]` attribute that stores:
- `lineno`: Line number - `lineno`: Line number
- Custom metadata like tags, links, notes - Custom metadata like tags, links, notes
**Castle Application:** **Libra Application:**
```python ```python
class JournalEntryMeta(BaseModel): class JournalEntryMeta(BaseModel):
"""Metadata for journal entries""" """Metadata for journal entries"""
@ -447,7 +447,7 @@ entry = await create_journal_entry(
This asserts that the account balance should be exactly 268,548 sats on that date. If it's not, Beancount throws an error. This asserts that the account balance should be exactly 268,548 sats on that date. If it's not, Beancount throws an error.
**Castle Application:** **Libra Application:**
```python ```python
# models.py # models.py
class BalanceAssertion(BaseModel): class BalanceAssertion(BaseModel):
@ -464,7 +464,7 @@ class BalanceAssertion(BaseModel):
created_at: datetime created_at: datetime
# API endpoint # API endpoint
@castle_api_router.post("/api/v1/assertions/balance") @libra_api_router.post("/api/v1/assertions/balance")
async def create_balance_assertion( async def create_balance_assertion(
data: CreateBalanceAssertion, data: CreateBalanceAssertion,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
@ -554,7 +554,7 @@ Liabilities:US:CreditCard:Amex
Accounts are organized hierarchically with `:` separator. Accounts are organized hierarchically with `:` separator.
**Castle Application:** **Libra Application:**
```python ```python
# Currently: "Accounts Receivable - af983632" # Currently: "Accounts Receivable - af983632"
# Better: "Assets:Receivable:User-af983632" # Better: "Assets:Receivable:User-af983632"
@ -617,7 +617,7 @@ def format_account_name(
Flags: `*` = cleared, `!` = pending, `#` = flagged for review Flags: `*` = cleared, `!` = pending, `#` = flagged for review
**Castle Application:** **Libra Application:**
```python ```python
# Add flag field to journal_entries # Add flag field to journal_entries
class JournalEntryFlag(str, Enum): class JournalEntryFlag(str, Enum):
@ -661,7 +661,7 @@ from decimal import Decimal
amount = Decimal("19.99") amount = Decimal("19.99")
``` ```
**Castle Current Issue:** **Libra Current Issue:**
We're using `int` for satoshis (good!) but `float` for fiat amounts (bad!). We're using `int` for satoshis (good!) but `float` for fiat amounts (bad!).
**Fix:** **Fix:**
@ -709,10 +709,10 @@ WHERE account = 'Assets:Checking'
AND date >= 2025-01-01; AND date >= 2025-01-01;
``` ```
**Castle Application (Future):** **Libra Application (Future):**
```python ```python
# Add query endpoint # Add query endpoint
@castle_api_router.post("/api/v1/query") @libra_api_router.post("/api/v1/query")
async def execute_query( async def execute_query(
query: str, query: str,
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
@ -756,12 +756,12 @@ beancount/
tools/ # Reporting and analysis tools/ # Reporting and analysis
``` ```
**Castle Should Adopt:** **Libra Should Adopt:**
``` ```
castle/ libra/
core/ # NEW: Pure accounting logic core/ # NEW: Pure accounting logic
__init__.py __init__.py
inventory.py # CastleInventory for position tracking inventory.py # LibraInventory for position tracking
balance.py # Balance calculation logic balance.py # Balance calculation logic
validation.py # Entry validation (debits=credits, etc) validation.py # Entry validation (debits=credits, etc)
account.py # Account hierarchy and naming account.py # Account hierarchy and naming
@ -805,11 +805,11 @@ def validate_entries(entries):
return errors return errors
``` ```
**Castle Application:** **Libra Application:**
```python ```python
from typing import NamedTuple, Optional from typing import NamedTuple, Optional
class CastleError(NamedTuple): class LibraError(NamedTuple):
"""Base error type""" """Base error type"""
source: dict # {'endpoint': '...', 'user_id': '...'} source: dict # {'endpoint': '...', 'user_id': '...'}
message: str message: str
@ -828,7 +828,7 @@ class UnbalancedEntryError(NamedTuple):
difference: int difference: int
# Return errors from validation # Return errors from validation
async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError]: async def validate_journal_entry(entry: CreateJournalEntry) -> list[LibraError]:
errors = [] errors = []
# Beancount-style: sum of amounts must equal 0 # Beancount-style: sum of amounts must equal 0
@ -870,7 +870,7 @@ async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError]
### Phase 3: Core Logic Refactoring (Medium Priority) ✅ COMPLETE ### Phase 3: Core Logic Refactoring (Medium Priority) ✅ COMPLETE
9. ✅ Create `core/` module with pure accounting logic 9. ✅ Create `core/` module with pure accounting logic
10. ✅ Implement `CastleInventory` for position tracking 10. ✅ Implement `LibraInventory` for position tracking
11. ✅ Move balance calculation to `core/balance.py` 11. ✅ Move balance calculation to `core/balance.py`
12. ✅ Add comprehensive validation in `core/validation.py` 12. ✅ Add comprehensive validation in `core/validation.py`
@ -900,7 +900,7 @@ async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError]
7. ✅ Separation of core logic from I/O 7. ✅ Separation of core logic from I/O
8. ✅ Comprehensive validation 8. ✅ Comprehensive validation
**What Castle Should Adopt First:** **What Libra Should Adopt First:**
1. **Decimal for fiat amounts** (prevent rounding errors) 1. **Decimal for fiat amounts** (prevent rounding errors)
2. **Meta field** (audit trail, source tracking) 2. **Meta field** (audit trail, source tracking)
3. **Flag field** (transaction status) 3. **Flag field** (transaction status)
@ -916,7 +916,7 @@ async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError]
## Conclusion ## Conclusion
Beancount's architecture is battle-tested for serious accounting work. By adopting these patterns, Castle can: Beancount's architecture is battle-tested for serious accounting work. By adopting these patterns, Libra can:
- Prevent financial calculation errors (Decimal) - Prevent financial calculation errors (Decimal)
- Support complex workflows (plugins) - Support complex workflows (plugins)
- Build user trust (balance assertions, audit trail) - Build user trust (balance assertions, audit trail)

View file

@ -496,7 +496,7 @@ Improvement: 5-10x faster
## Test Results and Findings ## Test Results and Findings
**Date**: November 10, 2025 **Date**: November 10, 2025
**Status**: ⚠️ **NOT FEASIBLE for Castle's Current Data Structure** **Status**: ⚠️ **NOT FEASIBLE for Libra's Current Data Structure**
### Implementation Completed ### Implementation Completed
@ -523,7 +523,7 @@ Improvement: 5-10x faster
### Root Cause: Architecture Limitation ### Root Cause: Architecture Limitation
**Current Castle Ledger Structure:** **Current Libra Ledger Structure:**
``` ```
Posting format: Posting format:
Amount: -360.00 EUR ← Position (BQL can query this) Amount: -360.00 EUR ← Position (BQL can query this)
@ -549,7 +549,7 @@ SELECT account, sum(meta["sats-equivalent"]) WHERE account ~ 'User-'
### Why Manual Aggregation is Necessary ### Why Manual Aggregation is Necessary
1. **SATS are Castle's primary currency** for balance tracking 1. **SATS are Libra's primary currency** for balance tracking
2. **SATS values are in metadata**, not positions 2. **SATS values are in metadata**, not positions
3. **BQL has no metadata query capability** 3. **BQL has no metadata query capability**
4. **Must iterate through postings** to read `meta["sats-equivalent"]` 4. **Must iterate through postings** to read `meta["sats-equivalent"]`
@ -590,7 +590,7 @@ SELECT account, sum(meta["sats-equivalent"]) WHERE account ~ 'User-'
## Future Consideration: Ledger Format Change ## Future Consideration: Ledger Format Change
**If** Castle's ledger format changes to use SATS as position amounts: **If** Libra's ledger format changes to use SATS as position amounts:
```beancount ```beancount
; Current format (EUR position, SATS in metadata): ; Current format (EUR position, SATS in metadata):

View file

@ -63,7 +63,7 @@ Expenses:Food -360.00 EUR @@ 337096 SATS
**Total calculation**: Exact 337,096 SATS (no rounding) **Total calculation**: Exact 337,096 SATS (no rounding)
**Precision**: Preserves exact SATS amount from original calculation **Precision**: Preserves exact SATS amount from original calculation
**Why `@@` is better for Castle:** **Why `@@` is better for Libra:**
- ✅ Preserves exact SATS amount (no rounding errors) - ✅ Preserves exact SATS amount (no rounding errors)
- ✅ Matches current metadata storage exactly - ✅ Matches current metadata storage exactly
- ✅ Clearer intent: "this transaction equals X SATS total" - ✅ Clearer intent: "this transaction equals X SATS total"
@ -124,7 +124,7 @@ GROUP BY account;
### Step 1: Run Metadata Test ### Step 1: Run Metadata Test
```bash ```bash
cd /home/padreug/projects/castle-beancounter cd /home/padreug/projects/libra-beancounter
./test_metadata_simple.sh ./test_metadata_simple.sh
``` ```
@ -166,7 +166,7 @@ Add one test entry to your ledger:
Then query: Then query:
```bash ```bash
curl -s "http://localhost:3333/castle-ledger/api/query" \ curl -s "http://localhost:3333/libra-ledger/api/query" \
-G \ -G \
--data-urlencode "query_string=SELECT account, position, price_number, price_currency WHERE account ~ 'TEST'" \ --data-urlencode "query_string=SELECT account, position, price_number, price_currency WHERE account ~ 'TEST'" \
| jq '.' | jq '.'

View file

@ -1,6 +1,6 @@
# Automated Daily Reconciliation # Automated Daily Reconciliation
The Castle extension includes automated daily balance checking to ensure accounting accuracy. The Libra extension includes automated daily balance checking to ensure accounting accuracy.
## Overview ## Overview
@ -16,7 +16,7 @@ You can manually trigger the reconciliation check from the UI or via API:
### Via API ### Via API
```bash ```bash
curl -X POST https://your-lnbits-instance.com/castle/api/v1/tasks/daily-reconciliation \ curl -X POST https://your-lnbits-instance.com/libra/api/v1/tasks/daily-reconciliation \
-H "X-Api-Key: YOUR_ADMIN_KEY" -H "X-Api-Key: YOUR_ADMIN_KEY"
``` ```
@ -28,7 +28,7 @@ Add to your crontab:
```bash ```bash
# Run daily at 2 AM # Run daily at 2 AM
0 2 * * * curl -X POST http://localhost:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY" >> /var/log/castle-reconciliation.log 2>&1 0 2 * * * curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY" >> /var/log/libra-reconciliation.log 2>&1
``` ```
To edit crontab: To edit crontab:
@ -38,22 +38,22 @@ crontab -e
### Option 2: Systemd Timer ### Option 2: Systemd Timer
Create `/etc/systemd/system/castle-reconciliation.service`: Create `/etc/systemd/system/libra-reconciliation.service`:
```ini ```ini
[Unit] [Unit]
Description=Castle Daily Reconciliation Check Description=Libra Daily Reconciliation Check
After=network.target After=network.target
[Service] [Service]
Type=oneshot Type=oneshot
User=lnbits User=lnbits
ExecStart=/usr/bin/curl -X POST http://localhost:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY" ExecStart=/usr/bin/curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY"
``` ```
Create `/etc/systemd/system/castle-reconciliation.timer`: Create `/etc/systemd/system/libra-reconciliation.timer`:
```ini ```ini
[Unit] [Unit]
Description=Run Castle reconciliation daily Description=Run Libra reconciliation daily
[Timer] [Timer]
OnCalendar=daily OnCalendar=daily
@ -66,8 +66,8 @@ WantedBy=timers.target
Enable and start: Enable and start:
```bash ```bash
sudo systemctl enable castle-reconciliation.timer sudo systemctl enable libra-reconciliation.timer
sudo systemctl start castle-reconciliation.timer sudo systemctl start libra-reconciliation.timer
``` ```
### Option 3: Docker/Kubernetes CronJob ### Option 3: Docker/Kubernetes CronJob
@ -78,7 +78,7 @@ For containerized deployments:
apiVersion: batch/v1 apiVersion: batch/v1
kind: CronJob kind: CronJob
metadata: metadata:
name: castle-reconciliation name: libra-reconciliation
spec: spec:
schedule: "0 2 * * *" # Daily at 2 AM schedule: "0 2 * * *" # Daily at 2 AM
jobTemplate: jobTemplate:
@ -91,7 +91,7 @@ spec:
args: args:
- /bin/sh - /bin/sh
- -c - -c
- curl -X POST http://lnbits:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: ${ADMIN_KEY}" - curl -X POST http://lnbits:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: ${ADMIN_KEY}"
restartPolicy: OnFailure restartPolicy: OnFailure
``` ```
@ -129,7 +129,7 @@ The endpoint returns:
grep CRON /var/log/syslog grep CRON /var/log/syslog
# View custom log (if using cron with redirect) # View custom log (if using cron with redirect)
tail -f /var/log/castle-reconciliation.log tail -f /var/log/libra-reconciliation.log
``` ```
### Success Criteria ### Success Criteria
@ -142,7 +142,7 @@ tail -f /var/log/castle-reconciliation.log
If `failed > 0`: If `failed > 0`:
1. Check the `failed_assertions` array for details 1. Check the `failed_assertions` array for details
2. Investigate discrepancies in the Castle UI 2. Investigate discrepancies in the Libra UI
3. Review recent transactions 3. Review recent transactions
4. Check for data entry errors 4. Check for data entry errors
5. Verify exchange rate conversions (for fiat) 5. Verify exchange rate conversions (for fiat)
@ -172,7 +172,7 @@ Planned features:
3. **Check network connectivity**: 3. **Check network connectivity**:
```bash ```bash
curl http://localhost:5000/castle/api/v1/reconciliation/summary -H "X-Api-Key: YOUR_KEY" curl http://localhost:5000/libra/api/v1/reconciliation/summary -H "X-Api-Key: YOUR_KEY"
``` ```
### Permission Denied ### Permission Denied
@ -202,31 +202,31 @@ Planned features:
```bash ```bash
#!/bin/bash #!/bin/bash
# setup-castle-reconciliation.sh # setup-libra-reconciliation.sh
# Configuration # Configuration
LNBITS_URL="http://localhost:5000" LNBITS_URL="http://localhost:5000"
ADMIN_KEY="your_admin_key_here" ADMIN_KEY="your_admin_key_here"
LOG_FILE="/var/log/castle-reconciliation.log" LOG_FILE="/var/log/libra-reconciliation.log"
# Create log file # Create log file
touch "$LOG_FILE" touch "$LOG_FILE"
chmod 644 "$LOG_FILE" chmod 644 "$LOG_FILE"
# Add cron job # Add cron job
(crontab -l 2>/dev/null; echo "0 2 * * * curl -X POST $LNBITS_URL/castle/api/v1/tasks/daily-reconciliation -H 'X-Api-Key: $ADMIN_KEY' >> $LOG_FILE 2>&1") | crontab - (crontab -l 2>/dev/null; echo "0 2 * * * curl -X POST $LNBITS_URL/libra/api/v1/tasks/daily-reconciliation -H 'X-Api-Key: $ADMIN_KEY' >> $LOG_FILE 2>&1") | crontab -
echo "Daily reconciliation scheduled for 2 AM" echo "Daily reconciliation scheduled for 2 AM"
echo "Logs will be written to: $LOG_FILE" echo "Logs will be written to: $LOG_FILE"
# Test the endpoint # Test the endpoint
echo "Running test reconciliation..." echo "Running test reconciliation..."
curl -X POST "$LNBITS_URL/castle/api/v1/tasks/daily-reconciliation" \ curl -X POST "$LNBITS_URL/libra/api/v1/tasks/daily-reconciliation" \
-H "X-Api-Key: $ADMIN_KEY" -H "X-Api-Key: $ADMIN_KEY"
``` ```
Make executable and run: Make executable and run:
```bash ```bash
chmod +x setup-castle-reconciliation.sh chmod +x setup-libra-reconciliation.sh
./setup-castle-reconciliation.sh ./setup-libra-reconciliation.sh
``` ```

View file

@ -1,8 +1,8 @@
# Castle Accounting Extension - Comprehensive Documentation # Libra Extension - Comprehensive Documentation
## Overview ## Overview
The Castle Accounting extension for LNbits implements a double-entry bookkeeping system designed for cooperative/communal living spaces (like "castles"). It tracks financial relationships between a central entity (the Castle) and multiple users, handling both Lightning Network payments and manual/cash transactions. The Libra extension for LNbits implements a double-entry bookkeeping system designed for cooperative/communal living spaces (like cooperatives). It tracks financial relationships between a central entity (the Libra) and multiple users, handling both Lightning Network payments and manual/cash transactions.
## Architecture ## Architecture
@ -19,23 +19,23 @@ The system implements traditional **double-entry bookkeeping** principles:
| Account Type | Normal Balance | Increases With | Decreases With | Purpose | | Account Type | Normal Balance | Increases With | Decreases With | Purpose |
|--------------|----------------|----------------|----------------|---------| |--------------|----------------|----------------|----------------|---------|
| Asset | Debit | Debit | Credit | What Castle owns or is owed | | Asset | Debit | Debit | Credit | What Libra owns or is owed |
| Liability | Credit | Credit | Debit | What Castle owes to others | | Liability | Credit | Credit | Debit | What Libra owes to others |
| Equity | Credit | Credit | Debit | Member contributions, retained earnings | | Equity | Credit | Credit | Debit | Member contributions, retained earnings |
| Revenue | Credit | Credit | Debit | Income earned by Castle | | Revenue | Credit | Credit | Debit | Income earned by Libra |
| Expense | Debit | Debit | Credit | Costs incurred by Castle | | Expense | Debit | Debit | Credit | Costs incurred by Libra |
### User-Specific Accounts ### User-Specific Accounts
The system creates **per-user accounts** for tracking individual balances: The system creates **per-user accounts** for tracking individual balances:
- `Accounts Receivable - {user_id[:8]}` (Asset) - User owes Castle - `Accounts Receivable - {user_id[:8]}` (Asset) - User owes Libra
- `Accounts Payable - {user_id[:8]}` (Liability) - Castle owes User - `Accounts Payable - {user_id[:8]}` (Liability) - Libra owes User
- `Member Equity - {user_id[:8]}` (Equity) - User's equity contributions - `Member Equity - {user_id[:8]}` (Equity) - User's equity contributions
**Balance Interpretation:** **Balance Interpretation:**
- `balance > 0` and account is Liability → Castle owes user (user is creditor) - `balance > 0` and account is Liability → Libra owes user (user is creditor)
- `balance < 0` (or positive Asset balance) → User owes Castle (user is debtor) - `balance < 0` (or positive Asset balance) → User owes Libra (user is debtor)
### Database Schema ### Database Schema
@ -81,7 +81,7 @@ CREATE TABLE entry_lines (
```sql ```sql
CREATE TABLE extension_settings ( CREATE TABLE extension_settings (
id TEXT NOT NULL PRIMARY KEY, -- Always "admin" id TEXT NOT NULL PRIMARY KEY, -- Always "admin"
castle_wallet_id TEXT, -- LNbits wallet ID for Castle operations libra_wallet_id TEXT, -- LNbits wallet ID for Libra operations
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
``` ```
@ -129,11 +129,11 @@ Each `entry_line` can store metadata as JSON to preserve original fiat amounts:
### 1. User Adds Expense (Liability Model) ### 1. User Adds Expense (Liability Model)
**Use Case:** User pays for groceries with cash, Castle reimburses them **Use Case:** User pays for groceries with cash, Libra reimburses them
**User Action:** Add expense via UI **User Action:** Add expense via UI
```javascript ```javascript
POST /castle/api/v1/entries/expense POST /libra/api/v1/entries/expense
{ {
"description": "Biocoop groceries", "description": "Biocoop groceries",
"amount": 36.93, "amount": 36.93,
@ -162,15 +162,15 @@ Metadata on both lines:
} }
``` ```
**Effect:** Castle owes user €36.93 (39,669 sats) **Effect:** Libra owes user €36.93 (39,669 sats)
### 2. Castle Adds Receivable ### 2. Libra Adds Receivable
**Use Case:** User stays in a room, owes Castle for accommodation **Use Case:** User stays in a room, owes Libra for accommodation
**Castle Admin Action:** Add receivable via UI **Libra Admin Action:** Add receivable via UI
```javascript ```javascript
POST /castle/api/v1/entries/receivable POST /libra/api/v1/entries/receivable
{ {
"description": "room 5 days", "description": "room 5 days",
"amount": 250.0, "amount": 250.0,
@ -198,7 +198,7 @@ Metadata:
} }
``` ```
**Effect:** User owes Castle €250.00 (268,548 sats) **Effect:** User owes Libra €250.00 (268,548 sats)
### 3. User Pays with Lightning ### 3. User Pays with Lightning
@ -206,7 +206,7 @@ Metadata:
**Step A: Generate Invoice** **Step A: Generate Invoice**
```javascript ```javascript
POST /castle/api/v1/generate-payment-invoice POST /libra/api/v1/generate-payment-invoice
{ {
"amount": 268548 "amount": 268548
} }
@ -218,19 +218,19 @@ Returns:
"payment_hash": "...", "payment_hash": "...",
"payment_request": "lnbc...", "payment_request": "lnbc...",
"amount": 268548, "amount": 268548,
"memo": "Payment from user af983632 to Castle", "memo": "Payment from user af983632 to Libra",
"check_wallet_key": "castle_wallet_inkey" "check_wallet_key": "libra_wallet_inkey"
} }
``` ```
**Note:** Invoice is generated on **Castle's wallet**, not user's wallet. User polls using `check_wallet_key`. **Note:** Invoice is generated on **Libra's wallet**, not user's wallet. User polls using `check_wallet_key`.
**Step B: User Pays Invoice** **Step B: User Pays Invoice**
(External Lightning wallet or LNbits wallet) (External Lightning wallet or LNbits wallet)
**Step C: Record Payment** **Step C: Record Payment**
```javascript ```javascript
POST /castle/api/v1/record-payment POST /libra/api/v1/record-payment
{ {
"payment_hash": "..." "payment_hash": "..."
} }
@ -250,11 +250,11 @@ DR Lightning Balance 268,548 sats
### 4. Manual Payment Request Flow ### 4. Manual Payment Request Flow
**Use Case:** User wants Castle to pay them in cash instead of Lightning **Use Case:** User wants Libra to pay them in cash instead of Lightning
**Step A: User Requests Payment** **Step A: User Requests Payment**
```javascript ```javascript
POST /castle/api/v1/manual-payment-requests POST /libra/api/v1/manual-payment-requests
{ {
"amount": 39669, "amount": 39669,
"description": "Please pay me in cash for groceries" "description": "Please pay me in cash for groceries"
@ -263,16 +263,16 @@ POST /castle/api/v1/manual-payment-requests
Creates `manual_payment_request` with status='pending' Creates `manual_payment_request` with status='pending'
**Step B: Castle Admin Reviews** **Step B: Libra Admin Reviews**
Admin sees pending request in UI: Admin sees pending request in UI:
- User: af983632 - User: af983632
- Amount: 39,669 sats (€36.93) - Amount: 39,669 sats (€36.93)
- Description: "Please pay me in cash for groceries" - Description: "Please pay me in cash for groceries"
**Step C: Castle Admin Approves** **Step C: Libra Admin Approves**
```javascript ```javascript
POST /castle/api/v1/manual-payment-requests/{id}/approve POST /libra/api/v1/manual-payment-requests/{id}/approve
``` ```
**Journal Entry Created:** **Journal Entry Created:**
@ -285,11 +285,11 @@ DR Accounts Payable - af983632 39,669 sats
CR Lightning Balance 39,669 sats CR Lightning Balance 39,669 sats
``` ```
**Effect:** Castle's liability to user reduced by 39,669 sats **Effect:** Libra's liability to user reduced by 39,669 sats
**Alternative: Castle Admin Rejects** **Alternative: Libra Admin Rejects**
```javascript ```javascript
POST /castle/api/v1/manual-payment-requests/{id}/reject POST /libra/api/v1/manual-payment-requests/{id}/reject
``` ```
No journal entry created, request marked as 'rejected'. No journal entry created, request marked as 'rejected'.
@ -308,20 +308,20 @@ for account in user_accounts:
# Calculate satoshi balance # Calculate satoshi balance
if account.account_type == AccountType.LIABILITY: if account.account_type == AccountType.LIABILITY:
total_balance += account_balance # Positive = Castle owes user total_balance += account_balance # Positive = Libra owes user
elif account.account_type == AccountType.ASSET: elif account.account_type == AccountType.ASSET:
total_balance -= account_balance # Positive asset = User owes Castle, so negative balance total_balance -= account_balance # Positive asset = User owes Libra, so negative balance
# Calculate fiat balance from metadata # Calculate fiat balance from metadata
# Beancount-style: positive amount = debit, negative amount = credit # Beancount-style: positive amount = debit, negative amount = credit
for line in account_entry_lines: for line in account_entry_lines:
if line.metadata.fiat_currency and line.metadata.fiat_amount: if line.metadata.fiat_currency and line.metadata.fiat_amount:
if account.account_type == AccountType.LIABILITY: if account.account_type == AccountType.LIABILITY:
# For liabilities, negative amounts (credits) increase what castle owes # For liabilities, negative amounts (credits) increase what libra owes
if line.amount < 0: if line.amount < 0:
fiat_balances[currency] += fiat_amount # Castle owes more fiat_balances[currency] += fiat_amount # Libra owes more
else: else:
fiat_balances[currency] -= fiat_amount # Castle owes less fiat_balances[currency] -= fiat_amount # Libra owes less
elif account.account_type == AccountType.ASSET: elif account.account_type == AccountType.ASSET:
# For assets, positive amounts (debits) increase what user owes # For assets, positive amounts (debits) increase what user owes
if line.amount > 0: if line.amount > 0:
@ -331,19 +331,19 @@ for account in user_accounts:
``` ```
**Result:** **Result:**
- `balance > 0`: Castle owes user (LIABILITY side dominates) - `balance > 0`: Libra owes user (LIABILITY side dominates)
- `balance < 0`: User owes Castle (ASSET side dominates) - `balance < 0`: User owes Libra (ASSET side dominates)
- `fiat_balances`: Net fiat position per currency - `fiat_balances`: Net fiat position per currency
### Castle Balance Calculation ### Libra Balance Calculation
From `views_api.py:api_get_my_balance()` (super user): From `views_api.py:api_get_my_balance()` (super user):
```python ```python
all_balances = get_all_user_balances() all_balances = get_all_user_balances()
total_liabilities = sum(b.balance for b in all_balances if b.balance > 0) # What Castle owes total_liabilities = sum(b.balance for b in all_balances if b.balance > 0) # What Libra owes
total_receivables = sum(abs(b.balance) for b in all_balances if b.balance < 0) # What is owed to Castle total_receivables = sum(abs(b.balance) for b in all_balances if b.balance < 0) # What is owed to Libra
net_balance = total_liabilities - total_receivables net_balance = total_liabilities - total_receivables
# Aggregate all fiat balances # Aggregate all fiat balances
@ -354,34 +354,34 @@ for user_balance in all_balances:
``` ```
**Result:** **Result:**
- `net_balance > 0`: Castle owes users (net liability) - `net_balance > 0`: Libra owes users (net liability)
- `net_balance < 0`: Users owe Castle (net receivable) - `net_balance < 0`: Users owe Libra (net receivable)
## UI/UX Design ## UI/UX Design
### Perspective-Based Display ### Perspective-Based Display
The UI adapts based on whether the viewer is a regular user or Castle admin (super user): The UI adapts based on whether the viewer is a regular user or Libra admin (super user):
#### User View #### User View
**Balance Display:** **Balance Display:**
- Green text: Castle owes them (positive balance, incoming money) - Green text: Libra owes them (positive balance, incoming money)
- Red text: They owe Castle (negative balance, outgoing money) - Red text: They owe Libra (negative balance, outgoing money)
**Transaction Badges:** **Transaction Badges:**
- Green "Receivable": Castle owes them (Accounts Payable entry) - Green "Receivable": Libra owes them (Accounts Payable entry)
- Red "Payable": They owe Castle (Accounts Receivable entry) - Red "Payable": They owe Libra (Accounts Receivable entry)
#### Castle Admin View (Super User) #### Libra Admin View (Super User)
**Balance Display:** **Balance Display:**
- Red text: Castle owes users (positive balance, outgoing money) - Red text: Libra owes users (positive balance, outgoing money)
- Green text: Users owe Castle (negative balance, incoming money) - Green text: Users owe Libra (negative balance, incoming money)
**Transaction Badges:** **Transaction Badges:**
- Green "Receivable": User owes Castle (Accounts Receivable entry) - Green "Receivable": User owes Libra (Accounts Receivable entry)
- Red "Payable": Castle owes user (Accounts Payable entry) - Red "Payable": Libra owes user (Accounts Payable entry)
**Outstanding Balances Table:** **Outstanding Balances Table:**
Shows all users with non-zero balances: Shows all users with non-zero balances:
@ -411,10 +411,10 @@ Created by `m001_initial` migration:
- `cash` - Cash on hand - `cash` - Cash on hand
- `bank` - Bank Account - `bank` - Bank Account
- `lightning` - Lightning Balance - `lightning` - Lightning Balance
- `accounts_receivable` - Money owed to the Castle - `accounts_receivable` - Money owed to the Libra
### Liabilities ### Liabilities
- `accounts_payable` - Money owed by the Castle - `accounts_payable` - Money owed by the Libra
### Equity ### Equity
- `member_equity` - Member contributions - `member_equity` - Member contributions
@ -449,11 +449,11 @@ Created by `m001_initial` migration:
- `POST /api/v1/entries/revenue` - Create direct revenue entry (admin only) - `POST /api/v1/entries/revenue` - Create direct revenue entry (admin only)
### Balance & Payments ### Balance & Payments
- `GET /api/v1/balance` - Get current user's balance (or Castle total if super user) - `GET /api/v1/balance` - Get current user's balance (or Libra total if super user)
- `GET /api/v1/balance/{user_id}` - Get specific user's balance - `GET /api/v1/balance/{user_id}` - Get specific user's balance
- `GET /api/v1/balances/all` - Get all user balances (admin only, enriched with usernames) - `GET /api/v1/balances/all` - Get all user balances (admin only, enriched with usernames)
- `POST /api/v1/generate-payment-invoice` - Generate invoice for user to pay Castle - `POST /api/v1/generate-payment-invoice` - Generate invoice for user to pay Libra
- `POST /api/v1/record-payment` - Record Lightning payment to Castle - `POST /api/v1/record-payment` - Record Lightning payment to Libra
### Manual Payments ### Manual Payments
- `POST /api/v1/manual-payment-requests` - User creates manual payment request - `POST /api/v1/manual-payment-requests` - User creates manual payment request
@ -463,8 +463,8 @@ Created by `m001_initial` migration:
- `POST /api/v1/manual-payment-requests/{id}/reject` - Admin rejects request - `POST /api/v1/manual-payment-requests/{id}/reject` - Admin rejects request
### Settings ### Settings
- `GET /api/v1/settings` - Get Castle settings (super user only) - `GET /api/v1/settings` - Get Libra settings (super user only)
- `PUT /api/v1/settings` - Update Castle settings (super user only) - `PUT /api/v1/settings` - Update Libra settings (super user only)
- `GET /api/v1/user/wallet` - Get user's wallet settings - `GET /api/v1/user/wallet` - Get user's wallet settings
- `PUT /api/v1/user/wallet` - Update user's wallet settings - `PUT /api/v1/user/wallet` - Update user's wallet settings
- `GET /api/v1/users` - Get all users with configured wallets (admin only) - `GET /api/v1/users` - Get all users with configured wallets (admin only)
@ -712,7 +712,7 @@ GET /api/v1/entries/user?start_date=2025-01-01&end_date=2025-03-31
**Add Endpoint:** **Add Endpoint:**
```python ```python
@castle_api_router.get("/api/v1/export/beancount") @libra_api_router.get("/api/v1/export/beancount")
async def export_beancount( async def export_beancount(
start_date: Optional[str] = None, start_date: Optional[str] = None,
end_date: Optional[str] = None, end_date: Optional[str] = None,
@ -812,7 +812,7 @@ async def export_beancount(
**UI Addition:** **UI Addition:**
Add export button to Castle admin UI: Add export button to Libra admin UI:
```html ```html
<q-btn color="primary" @click="exportBeancount"> <q-btn color="primary" @click="exportBeancount">
Export to Beancount Export to Beancount
@ -825,7 +825,7 @@ async exportBeancount() {
try { try {
const response = await LNbits.api.request( const response = await LNbits.api.request(
'GET', 'GET',
'/castle/api/v1/export/beancount', '/libra/api/v1/export/beancount',
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
@ -834,7 +834,7 @@ async exportBeancount() {
const url = window.URL.createObjectURL(blob) const url = window.URL.createObjectURL(blob)
const link = document.createElement('a') const link = document.createElement('a')
link.href = url link.href = url
link.download = `castle-accounting-${new Date().toISOString().split('T')[0]}.beancount` link.download = `libra-accounting-${new Date().toISOString().split('T')[0]}.beancount`
link.click() link.click()
window.URL.revokeObjectURL(url) window.URL.revokeObjectURL(url)
@ -854,12 +854,12 @@ After export, users can verify with Beancount:
```bash ```bash
# Check file is valid # Check file is valid
bean-check castle-accounting-2025-10-22.beancount bean-check libra-accounting-2025-10-22.beancount
# Generate reports # Generate reports
bean-report castle-accounting-2025-10-22.beancount balances bean-report libra-accounting-2025-10-22.beancount balances
bean-report castle-accounting-2025-10-22.beancount income bean-report libra-accounting-2025-10-22.beancount income
bean-web castle-accounting-2025-10-22.beancount bean-web libra-accounting-2025-10-22.beancount
``` ```
## Testing Strategy ## Testing Strategy
@ -891,7 +891,7 @@ bean-web castle-accounting-2025-10-22.beancount
1. **End-to-End User Flow** 1. **End-to-End User Flow**
- User adds expense - User adds expense
- Castle adds receivable - Libra adds receivable
- User pays via Lightning - User pays via Lightning
- Verify balances at each step - Verify balances at each step
@ -904,7 +904,7 @@ bean-web castle-accounting-2025-10-22.beancount
3. **Multi-User Scenarios** 3. **Multi-User Scenarios**
- Multiple users with positive balances - Multiple users with positive balances
- Multiple users with negative balances - Multiple users with negative balances
- Verify Castle net balance calculation - Verify Libra net balance calculation
## Security Considerations ## Security Considerations
@ -916,12 +916,12 @@ bean-web castle-accounting-2025-10-22.beancount
2. **User Isolation** 2. **User Isolation**
- Users can only see their own balances and transactions - Users can only see their own balances and transactions
- Users cannot create receivables (only Castle admin can) - Users cannot create receivables (only Libra admin can)
- Users cannot approve their own manual payment requests - Users cannot approve their own manual payment requests
3. **Wallet Key Requirements** 3. **Wallet Key Requirements**
- `require_invoice_key`: Read access to user's data - `require_invoice_key`: Read access to user's data
- `require_admin_key`: Write access, Castle admin operations - `require_admin_key`: Write access, Libra admin operations
### Potential Vulnerabilities ### Potential Vulnerabilities
@ -959,7 +959,7 @@ bean-web castle-accounting-2025-10-22.beancount
limiter = Limiter(key_func=get_remote_address) limiter = Limiter(key_func=get_remote_address)
@limiter.limit("10/minute") @limiter.limit("10/minute")
@castle_api_router.post("/api/v1/entries/expense") @libra_api_router.post("/api/v1/entries/expense")
async def api_create_expense_entry(...): async def api_create_expense_entry(...):
... ...
``` ```
@ -1020,7 +1020,7 @@ bean-web castle-accounting-2025-10-22.beancount
2. **Add Pagination** 2. **Add Pagination**
```python ```python
@castle_api_router.get("/api/v1/entries/user") @libra_api_router.get("/api/v1/entries/user")
async def api_get_user_entries( async def api_get_user_entries(
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
limit: int = 100, limit: int = 100,
@ -1092,7 +1092,7 @@ bean-web castle-accounting-2025-10-22.beancount
## Migration Path for Existing Data ## Migration Path for Existing Data
If Castle is already in production with the old code: If Libra is already in production with the old code:
### Migration Script: `m005_fix_user_accounts.py` ### Migration Script: `m005_fix_user_accounts.py`
@ -1185,7 +1185,7 @@ async def m005_fix_user_accounts(db):
## Conclusion ## Conclusion
The Castle Accounting extension provides a solid foundation for double-entry bookkeeping in LNbits. The core accounting logic is sound, with proper debit/credit handling and user-specific account isolation. The Libra extension provides a solid foundation for double-entry bookkeeping in LNbits. The core accounting logic is sound, with proper debit/credit handling and user-specific account isolation.
### Strengths ### Strengths
✅ Correct double-entry bookkeeping implementation ✅ Correct double-entry bookkeeping implementation
@ -1193,7 +1193,7 @@ The Castle Accounting extension provides a solid foundation for double-entry boo
✅ Metadata preservation for fiat amounts ✅ Metadata preservation for fiat amounts
✅ Lightning payment integration ✅ Lightning payment integration
✅ Manual payment workflow ✅ Manual payment workflow
✅ Perspective-based UI (user vs Castle view) ✅ Perspective-based UI (user vs Libra view)
### Immediate Action Items ### Immediate Action Items
1. ✅ Fix user account creation bug (COMPLETED) 1. ✅ Fix user account creation bug (COMPLETED)

View file

@ -2,7 +2,7 @@
## Overview ## Overview
The Castle extension now requires admin approval for all user-submitted expenses. This prevents invalid or incorrect expenses from affecting balances until they are verified by the Castle admin. The Libra extension now requires admin approval for all user-submitted expenses. This prevents invalid or incorrect expenses from affecting balances until they are verified by the Libra admin.
## How It Works ## How It Works
@ -61,7 +61,7 @@ AND je.flag = '*' -- Only cleared entries
### Get Pending Entries (Admin Only) ### Get Pending Entries (Admin Only)
``` ```
GET /castle/api/v1/entries/pending GET /libra/api/v1/entries/pending
Authorization: Admin Key Authorization: Admin Key
Returns: list[JournalEntry] Returns: list[JournalEntry]
@ -69,7 +69,7 @@ Returns: list[JournalEntry]
### Approve Expense (Admin Only) ### Approve Expense (Admin Only)
``` ```
POST /castle/api/v1/entries/{entry_id}/approve POST /libra/api/v1/entries/{entry_id}/approve
Authorization: Admin Key Authorization: Admin Key
Returns: JournalEntry (with flag='*') Returns: JournalEntry (with flag='*')
@ -77,7 +77,7 @@ Returns: JournalEntry (with flag='*')
### Reject Expense (Admin Only) ### Reject Expense (Admin Only)
``` ```
POST /castle/api/v1/entries/{entry_id}/reject POST /libra/api/v1/entries/{entry_id}/reject
Authorization: Admin Key Authorization: Admin Key
Returns: JournalEntry (with flag='x') Returns: JournalEntry (with flag='x')
@ -133,7 +133,7 @@ Returns: JournalEntry (with flag='x')
1. **Submit test expense as regular user** 1. **Submit test expense as regular user**
``` ```
POST /castle/api/v1/entries/expense POST /libra/api/v1/entries/expense
{ {
"description": "Test groceries", "description": "Test groceries",
"amount": 50.00, "amount": 50.00,

View file

@ -1,4 +1,4 @@
# Castle Permissions System - Overview & Administration Guide # Libra Permissions System - Overview & Administration Guide
**Date**: November 10, 2025 **Date**: November 10, 2025
**Status**: 📚 **Documentation** + 🔧 **Improvement Recommendations** **Status**: 📚 **Documentation** + 🔧 **Improvement Recommendations**
@ -7,7 +7,7 @@
## Executive Summary ## Executive Summary
Castle implements a **granular, hierarchical permission system** that controls who can access which accounts and perform what actions. The system supports permission inheritance, making it easy to grant access to entire account hierarchies with a single permission. Libra implements a **granular, hierarchical permission system** that controls who can access which accounts and perform what actions. The system supports permission inheritance, making it easy to grant access to entire account hierarchies with a single permission.
**Key Features:** **Key Features:**
- ✅ **Three permission levels**: READ, SUBMIT_EXPENSE, MANAGE - ✅ **Three permission levels**: READ, SUBMIT_EXPENSE, MANAGE
@ -680,7 +680,7 @@ CREATE TABLE account_permissions (
expires_at TIMESTAMP, expires_at TIMESTAMP,
notes TEXT, notes TEXT,
FOREIGN KEY (account_id) REFERENCES castle_accounts (id) FOREIGN KEY (account_id) REFERENCES libra_accounts (id)
); );
CREATE INDEX idx_account_permissions_user_id ON account_permissions (user_id); CREATE INDEX idx_account_permissions_user_id ON account_permissions (user_id);
@ -840,7 +840,7 @@ async def test_expense_submission_without_permission():
## Summary ## Summary
The Castle permissions system is **well-designed** with strong features: The Libra permissions system is **well-designed** with strong features:
- Hierarchical inheritance reduces admin burden - Hierarchical inheritance reduces admin burden
- Caching provides good performance - Caching provides good performance
- Expiration and audit trail support compliance - Expiration and audit trail support compliance

View file

@ -36,7 +36,7 @@ Phase 2 of the Beancount-inspired refactor focused on **reconciliation and autom
- `POST /api/v1/assertions/{id}/check` - Re-check assertion - `POST /api/v1/assertions/{id}/check` - Re-check assertion
- `DELETE /api/v1/assertions/{id}` - Delete assertion - `DELETE /api/v1/assertions/{id}` - Delete assertion
- **UI** (`templates/castle/index.html:254-378`): - **UI** (`templates/libra/index.html:254-378`):
- Balance Assertions card (super user only) - Balance Assertions card (super user only)
- Failed assertions prominently displayed with red banner - Failed assertions prominently displayed with red banner
- Passed assertions in collapsible panel - Passed assertions in collapsible panel
@ -77,7 +77,7 @@ Phase 2 of the Beancount-inspired refactor focused on **reconciliation and autom
**Purpose**: Visual dashboard for reconciliation status and quick access to reconciliation tools **Purpose**: Visual dashboard for reconciliation status and quick access to reconciliation tools
**Implementation** (`templates/castle/index.html:380-499`): **Implementation** (`templates/libra/index.html:380-499`):
- **Summary Cards**: - **Summary Cards**:
- Balance Assertions stats (total, passed, failed, pending) - Balance Assertions stats (total, passed, failed, pending)
- Journal Entries stats (total, cleared, pending, flagged) - Journal Entries stats (total, cleared, pending, flagged)
@ -161,7 +161,7 @@ Phase 2 of the Beancount-inspired refactor focused on **reconciliation and autom
2. `migrations.py` - Added `m007_balance_assertions` migration 2. `migrations.py` - Added `m007_balance_assertions` migration
3. `crud.py` - Added balance assertion CRUD operations 3. `crud.py` - Added balance assertion CRUD operations
4. `views_api.py` - Added assertion, reconciliation, and task endpoints 4. `views_api.py` - Added assertion, reconciliation, and task endpoints
5. `templates/castle/index.html` - Added assertions and reconciliation UI 5. `templates/libra/index.html` - Added assertions and reconciliation UI
6. `static/js/index.js` - Added assertion and reconciliation functionality 6. `static/js/index.js` - Added assertion and reconciliation functionality
7. `BEANCOUNT_PATTERNS.md` - Updated roadmap to mark Phase 2 complete 7. `BEANCOUNT_PATTERNS.md` - Updated roadmap to mark Phase 2 complete
@ -186,7 +186,7 @@ Phase 2 of the Beancount-inspired refactor focused on **reconciliation and autom
### Create a Balance Assertion ### Create a Balance Assertion
```bash ```bash
curl -X POST http://localhost:5000/castle/api/v1/assertions \ curl -X POST http://localhost:5000/libra/api/v1/assertions \
-H "X-Api-Key: ADMIN_KEY" \ -H "X-Api-Key: ADMIN_KEY" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
@ -198,20 +198,20 @@ curl -X POST http://localhost:5000/castle/api/v1/assertions \
### Get Reconciliation Summary ### Get Reconciliation Summary
```bash ```bash
curl http://localhost:5000/castle/api/v1/reconciliation/summary \ curl http://localhost:5000/libra/api/v1/reconciliation/summary \
-H "X-Api-Key: ADMIN_KEY" -H "X-Api-Key: ADMIN_KEY"
``` ```
### Run Full Reconciliation ### Run Full Reconciliation
```bash ```bash
curl -X POST http://localhost:5000/castle/api/v1/reconciliation/check-all \ curl -X POST http://localhost:5000/libra/api/v1/reconciliation/check-all \
-H "X-Api-Key: ADMIN_KEY" -H "X-Api-Key: ADMIN_KEY"
``` ```
### Schedule Daily Reconciliation (Cron) ### Schedule Daily Reconciliation (Cron)
```bash ```bash
# Add to crontab # Add to crontab
0 2 * * * curl -X POST http://localhost:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: ADMIN_KEY" 0 2 * * * curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: ADMIN_KEY"
``` ```
## Testing Checklist ## Testing Checklist
@ -238,7 +238,7 @@ curl -X POST http://localhost:5000/castle/api/v1/reconciliation/check-all \
**Phase 3: Core Logic Refactoring (Medium Priority)** **Phase 3: Core Logic Refactoring (Medium Priority)**
- Create `core/` module with pure accounting logic - Create `core/` module with pure accounting logic
- Implement `CastleInventory` for position tracking - Implement `LibraInventory` for position tracking
- Move balance calculation to `core/balance.py` - Move balance calculation to `core/balance.py`
- Add comprehensive validation in `core/validation.py` - Add comprehensive validation in `core/validation.py`
@ -256,7 +256,7 @@ curl -X POST http://localhost:5000/castle/api/v1/reconciliation/check-all \
## Conclusion ## Conclusion
Phase 2 successfully implements Beancount's reconciliation philosophy in the Castle extension. With balance assertions, comprehensive reconciliation APIs, a visual dashboard, and automated daily checks, users can: Phase 2 successfully implements Beancount's reconciliation philosophy in the Libra extension. With balance assertions, comprehensive reconciliation APIs, a visual dashboard, and automated daily checks, users can:
- **Trust their data** with automated verification - **Trust their data** with automated verification
- **Catch errors early** through regular reconciliation - **Catch errors early** through regular reconciliation

View file

@ -21,13 +21,13 @@ Phase 3 of the Beancount-inspired refactor focused on **separating business logi
- Easier to audit and verify - Easier to audit and verify
- Clear architecture - Clear architecture
### 2. CastleInventory for Position Tracking ✅ ### 2. LibraInventory for Position Tracking ✅
**Purpose**: Track balances across multiple currencies with cost basis information (following Beancount's Inventory pattern) **Purpose**: Track balances across multiple currencies with cost basis information (following Beancount's Inventory pattern)
**Implementation** (`core/inventory.py`): **Implementation** (`core/inventory.py`):
**CastlePosition** (Lines 11-84): **LibraPosition** (Lines 11-84):
- Immutable dataclass representing a single position - Immutable dataclass representing a single position
- Tracks currency, amount, cost basis, and metadata - Tracks currency, amount, cost basis, and metadata
- Supports addition and negation operations - Supports addition and negation operations
@ -35,7 +35,7 @@ Phase 3 of the Beancount-inspired refactor focused on **separating business logi
```python ```python
@dataclass(frozen=True) @dataclass(frozen=True)
class CastlePosition: class LibraPosition:
currency: str # "SATS", "EUR", "USD" currency: str # "SATS", "EUR", "USD"
amount: Decimal amount: Decimal
cost_currency: Optional[str] = None cost_currency: Optional[str] = None
@ -44,7 +44,7 @@ class CastlePosition:
metadata: Dict[str, Any] = field(default_factory=dict) metadata: Dict[str, Any] = field(default_factory=dict)
``` ```
**CastleInventory** (Lines 87-201): **LibraInventory** (Lines 87-201):
- Container for multiple positions - Container for multiple positions
- Positions keyed by `(currency, cost_currency)` tuple - Positions keyed by `(currency, cost_currency)` tuple
- Methods for querying balances: - Methods for querying balances:
@ -83,7 +83,7 @@ class AccountType(str, Enum):
- Liabilities/Equity/Revenue: Credit balance (credit - debit) - Liabilities/Equity/Revenue: Credit balance (credit - debit)
2. **`build_inventory_from_entry_lines()`** (Lines 56-117): 2. **`build_inventory_from_entry_lines()`** (Lines 56-117):
- Build CastleInventory from journal entry lines - Build LibraInventory from journal entry lines
- Handles both sats and fiat currency tracking - Handles both sats and fiat currency tracking
- Accounts for account type when determining sign - Accounts for account type when determining sign
@ -123,7 +123,7 @@ class AccountType(str, Enum):
- Checks both sats and fiat within tolerance - Checks both sats and fiat within tolerance
3. **`validate_receivable_entry()`** (Lines 180-199): 3. **`validate_receivable_entry()`** (Lines 180-199):
- Validates receivable (user owes castle) entries - Validates receivable (user owes libra) entries
- Ensures positive amount - Ensures positive amount
- Ensures revenue account type - Ensures revenue account type
@ -216,10 +216,10 @@ views_api.py → crud.py → core/
## File Structure ## File Structure
``` ```
lnbits/extensions/castle/ lnbits/extensions/libra/
├── core/ ├── core/
│ ├── __init__.py # Module exports │ ├── __init__.py # Module exports
│ ├── inventory.py # CastleInventory, CastlePosition │ ├── inventory.py # LibraInventory, LibraPosition
│ ├── balance.py # BalanceCalculator │ ├── balance.py # BalanceCalculator
│ └── validation.py # Validation functions │ └── validation.py # Validation functions
├── crud.py # DB operations (refactored to use core/) ├── crud.py # DB operations (refactored to use core/)
@ -230,22 +230,22 @@ lnbits/extensions/castle/
## Usage Examples ## Usage Examples
### Using CastleInventory ### Using LibraInventory
```python ```python
from decimal import Decimal from decimal import Decimal
from castle.core.inventory import CastleInventory, CastlePosition from libra.core.inventory import LibraInventory, LibraPosition
# Create inventory # Create inventory
inv = CastleInventory() inv = LibraInventory()
# Add positions # Add positions
inv.add_position(CastlePosition( inv.add_position(LibraPosition(
currency="SATS", currency="SATS",
amount=Decimal("100000") amount=Decimal("100000")
)) ))
inv.add_position(CastlePosition( inv.add_position(LibraPosition(
currency="SATS", currency="SATS",
amount=Decimal("50000"), amount=Decimal("50000"),
cost_currency="EUR", cost_currency="EUR",
@ -264,7 +264,7 @@ data = inv.to_dict()
### Using BalanceCalculator ### Using BalanceCalculator
```python ```python
from castle.core.balance import BalanceCalculator, AccountType from libra.core.balance import BalanceCalculator, AccountType
# Calculate account balance # Calculate account balance
balance = BalanceCalculator.calculate_account_balance( balance = BalanceCalculator.calculate_account_balance(
@ -297,7 +297,7 @@ is_valid = BalanceCalculator.check_balance_matches(
### Using Validation ### Using Validation
```python ```python
from castle.core.validation import validate_journal_entry, ValidationError from libra.core.validation import validate_journal_entry, ValidationError
entry = { entry = {
"id": "abc123", "id": "abc123",
@ -320,8 +320,8 @@ except ValidationError as e:
## Testing Checklist ## Testing Checklist
- [x] CastleInventory created and tested - [x] LibraInventory created and tested
- [x] CastlePosition addition works - [x] LibraPosition addition works
- [x] Inventory balance calculations work - [x] Inventory balance calculations work
- [x] BalanceCalculator account balance calculation works - [x] BalanceCalculator account balance calculation works
- [x] BalanceCalculator inventory building works - [x] BalanceCalculator inventory building works
@ -348,10 +348,10 @@ except ValidationError as e:
## Conclusion ## Conclusion
Phase 3 successfully refactors Castle's accounting logic into a clean, testable core module. By following Beancount's architecture patterns, we've created: Phase 3 successfully refactors Libra's accounting logic into a clean, testable core module. By following Beancount's architecture patterns, we've created:
- **Pure accounting logic** separated from database concerns - **Pure accounting logic** separated from database concerns
- **CastleInventory** for position tracking across currencies - **LibraInventory** for position tracking across currencies
- **BalanceCalculator** for consistent balance calculations - **BalanceCalculator** for consistent balance calculations
- **Comprehensive validation** for data integrity - **Comprehensive validation** for data integrity

View file

@ -8,21 +8,21 @@
## Overview ## Overview
The `sats-equivalent` metadata field is Castle's solution for **dual-currency tracking** in a fiat-denominated ledger. It preserves Bitcoin (satoshi) amounts alongside fiat currency amounts without violating accounting principles or creating multi-currency complexity in position balances. The `sats-equivalent` metadata field is Libra's solution for **dual-currency tracking** in a fiat-denominated ledger. It preserves Bitcoin (satoshi) amounts alongside fiat currency amounts without violating accounting principles or creating multi-currency complexity in position balances.
### Quick Summary ### Quick Summary
- **Purpose**: Track Bitcoin/Lightning amounts in a EUR-denominated ledger - **Purpose**: Track Bitcoin/Lightning amounts in a EUR-denominated ledger
- **Location**: Beancount posting metadata (not position amounts) - **Location**: Beancount posting metadata (not position amounts)
- **Format**: String containing absolute satoshi amount (e.g., `"337096"`) - **Format**: String containing absolute satoshi amount (e.g., `"337096"`)
- **Primary Use**: Calculate user balances in satoshis (Castle's primary currency) - **Primary Use**: Calculate user balances in satoshis (Libra's primary currency)
- **Key Principle**: Satoshis are for reference; EUR is the actual transaction currency - **Key Principle**: Satoshis are for reference; EUR is the actual transaction currency
--- ---
## The Problem: Dual-Currency Tracking ## The Problem: Dual-Currency Tracking
Castle needs to track both: Libra needs to track both:
1. **Fiat amounts** (EUR, USD) - The actual transaction currency 1. **Fiat amounts** (EUR, USD) - The actual transaction currency
2. **Bitcoin amounts** (satoshis) - The Lightning Network settlement currency 2. **Bitcoin amounts** (satoshis) - The Lightning Network settlement currency
@ -34,7 +34,7 @@ Castle needs to track both:
- ❌ Complicate traditional accounting reconciliation - ❌ Complicate traditional accounting reconciliation
- ❌ Make fiat-based reporting difficult - ❌ Make fiat-based reporting difficult
**Castle's Philosophy**: Record transactions in their **actual currency**, with Bitcoin as supplementary data. **Libra's Philosophy**: Record transactions in their **actual currency**, with Bitcoin as supplementary data.
--- ---
@ -88,7 +88,7 @@ if fiat_currency and fiat_amount:
### Primary Use Case: User Balances ### Primary Use Case: User Balances
Castle's core function is tracking **who owes whom** in satoshis. The `sats-equivalent` metadata enables this. Libra's core function is tracking **who owes whom** in satoshis. The `sats-equivalent` metadata enables this.
**Flow** (`fava_client.py:220-248`): **Flow** (`fava_client.py:220-248`):
@ -147,7 +147,7 @@ SELECT account, sum(meta["sats-equivalent"]) WHERE account ~ 'User-5987ae95'
-- Error: BQL cannot access metadata -- Error: BQL cannot access metadata
``` ```
### Why Castle Accepts This Trade-off ### Why Libra Accepts This Trade-off
**Performance Analysis** (see `docs/BQL-BALANCE-QUERIES.md`): **Performance Analysis** (see `docs/BQL-BALANCE-QUERIES.md`):
1. **Caching solves the bottleneck**: 60-80% performance improvement from caching account/permission lookups 1. **Caching solves the bottleneck**: 60-80% performance improvement from caching account/permission lookups
@ -196,9 +196,9 @@ See `docs/BQL-PRICE-NOTATION-SOLUTION.md` for full analysis.
**User Action**: "I paid €36.93 cash for groceries" **User Action**: "I paid €36.93 cash for groceries"
**Castle's Internal Representation**: **Libra's Internal Representation**:
```python ```python
# User provides or Castle calculates: # User provides or Libra calculates:
fiat_amount = Decimal("36.93") # EUR fiat_amount = Decimal("36.93") # EUR
fiat_currency = "EUR" fiat_currency = "EUR"
amount_sats = 39669 # Calculated from exchange rate amount_sats = 39669 # Calculated from exchange rate
@ -232,16 +232,16 @@ line = CreateEntryLine(
# - Apply sign: -36.93 is negative → sats = -39669 # - Apply sign: -36.93 is negative → sats = -39669
# - Accumulate: user_balance_sats += -39669 # - Accumulate: user_balance_sats += -39669
# Result: negative balance = Castle owes user # Result: negative balance = Libra owes user
``` ```
**User Balance Response**: **User Balance Response**:
```json ```json
{ {
"user_id": "5987ae95", "user_id": "5987ae95",
"balance": -39669, // Castle owes user 39,669 sats "balance": -39669, // Libra owes user 39,669 sats
"fiat_balances": { "fiat_balances": {
"EUR": "-36.93" // Castle owes user €36.93 "EUR": "-36.93" // Libra owes user €36.93
} }
} }
``` ```
@ -306,7 +306,7 @@ The `sats-equivalent` is the **exact satoshi amount at transaction time**. It do
### 3. Separate Fiat and Sats Balances ### 3. Separate Fiat and Sats Balances
Castle tracks TWO independent balances: Libra tracks TWO independent balances:
- **Satoshi balance**: Sum of `sats-equivalent` metadata (primary) - **Satoshi balance**: Sum of `sats-equivalent` metadata (primary)
- **Fiat balances**: Sum of EUR/USD position amounts (secondary) - **Fiat balances**: Sum of EUR/USD position amounts (secondary)

View file

@ -1,4 +1,4 @@
# Castle UI Improvements Plan # Libra UI Improvements Plan
**Date**: November 10, 2025 **Date**: November 10, 2025
**Status**: 📋 **Planning Document** **Status**: 📋 **Planning Document**
@ -8,7 +8,7 @@
## Overview ## Overview
Enhance the Castle permissions UI to showcase new bulk permission management and account sync features, making admin tasks faster and more intuitive. Enhance the Libra permissions UI to showcase new bulk permission management and account sync features, making admin tasks faster and more intuitive.
--- ---
@ -230,7 +230,7 @@ Enhance the Castle permissions UI to showcase new bulk permission management and
│ │ │ │
│ ⚠️ Warning: This will revoke ALL │ │ ⚠️ Warning: This will revoke ALL │
│ permissions for this user. They will │ │ permissions for this user. They will │
│ immediately lose access to Castle. │ │ immediately lose access to Libra. │
│ │ │ │
│ Reason for Offboarding │ │ Reason for Offboarding │
│ [Employee departure - last day] │ │ [Employee departure - last day] │
@ -257,13 +257,13 @@ Enhance the Castle permissions UI to showcase new bulk permission management and
├───────────────────────────────────────────┤ ├───────────────────────────────────────────┤
│ │ │ │
│ Sync accounts from your Beancount ledger │ │ Sync accounts from your Beancount ledger │
│ to Castle database for permission mgmt. │ │ to Libra database for permission mgmt. │
│ │ │ │
│ Last Sync: 2 hours ago │ │ Last Sync: 2 hours ago │
│ Status: ✅ Up to date │ │ Status: ✅ Up to date │
│ │ │ │
│ Accounts in Beancount: 150 │ │ Accounts in Beancount: 150 │
│ Accounts in Castle DB: 150 │ │ Accounts in Libra DB: 150 │
│ │ │ │
│ Options: │ │ Options: │
│ ☐ Force full sync (re-check all) │ │ ☐ Force full sync (re-check all) │
@ -509,7 +509,7 @@ permissions.html
syncStatus: { syncStatus: {
lastSync: null, lastSync: null,
beancountAccounts: 0, beancountAccounts: 0,
castleAccounts: 0, libraAccounts: 0,
status: 'idle' status: 'idle'
} }
} }

View file

@ -1,5 +1,5 @@
""" """
Fava API client for Castle. Fava API client for Libra.
This module provides an async HTTP client for interacting with Fava's JSON API. This module provides an async HTTP client for interacting with Fava's JSON API.
All accounting logic is delegated to Fava/Beancount. All accounting logic is delegated to Fava/Beancount.
@ -46,7 +46,7 @@ class FavaClient:
Args: Args:
fava_url: Base URL of Fava server (e.g., http://localhost:3333) fava_url: Base URL of Fava server (e.g., http://localhost:3333)
ledger_slug: URL-safe ledger identifier (e.g., castle-accounting) ledger_slug: URL-safe ledger identifier (e.g., libra-accounting)
timeout: Request timeout in seconds timeout: Request timeout in seconds
""" """
self.fava_url = fava_url.rstrip('/') self.fava_url = fava_url.rstrip('/')
@ -169,7 +169,7 @@ class FavaClient:
Args: Args:
entry: Beancount entry dict (same format as add_entry) entry: Beancount entry dict (same format as add_entry)
idempotency_key: Unique key for this operation (e.g., "castle-{uuid}" or "ln-{payment_hash}") idempotency_key: Unique key for this operation (e.g., "libra-{uuid}" or "ln-{payment_hash}")
Returns: Returns:
Response from Fava if entry was created, or existing entry data if already exists Response from Fava if entry was created, or existing entry data if already exists
@ -289,18 +289,18 @@ class FavaClient:
async def get_user_balance(self, user_id: str) -> Dict[str, Any]: async def get_user_balance(self, user_id: str) -> Dict[str, Any]:
""" """
Get user's balance from castle's perspective. Get user's balance from libra's perspective.
Aggregates: Aggregates:
- Liabilities:Payable:User-{user_id} (negative = castle owes user) - Liabilities:Payable:User-{user_id} (negative = libra owes user)
- Assets:Receivable:User-{user_id} (positive = user owes castle) - Assets:Receivable:User-{user_id} (positive = user owes libra)
Args: Args:
user_id: User ID user_id: User ID
Returns: Returns:
{ {
"balance": int (sats, positive = user owes castle, negative = castle owes user), "balance": int (sats, positive = user owes libra, negative = libra owes user),
"fiat_balances": {"EUR": Decimal("100.50")}, "fiat_balances": {"EUR": Decimal("100.50")},
"accounts": [list of account dicts with balances] "accounts": [list of account dicts with balances]
} }
@ -676,12 +676,12 @@ class FavaClient:
Use this for efficient aggregations, filtering, and data retrieval. Use this for efficient aggregations, filtering, and data retrieval.
LIMITATION: BQL can only query position amounts and transaction-level data. LIMITATION: BQL can only query position amounts and transaction-level data.
It CANNOT access posting metadata (like 'sats-equivalent'). For Castle's current It CANNOT access posting metadata (like 'sats-equivalent'). For Libra's current
ledger format where SATS are stored in metadata, manual aggregation is required. ledger format where SATS are stored in metadata, manual aggregation is required.
See: docs/BQL-BALANCE-QUERIES.md for detailed analysis and test results. See: docs/BQL-BALANCE-QUERIES.md for detailed analysis and test results.
FUTURE CONSIDERATION: If Castle's ledger format changes to use SATS as position FUTURE CONSIDERATION: If Libra's ledger format changes to use SATS as position
amounts (instead of metadata), BQL could provide significant performance benefits. amounts (instead of metadata), BQL could provide significant performance benefits.
Args: Args:
@ -1031,7 +1031,7 @@ class FavaClient:
Get total expense contributions per user using BQL. Get total expense contributions per user using BQL.
Uses sum(weight) to aggregate all expenses each user has submitted Uses sum(weight) to aggregate all expenses each user has submitted
that created liabilities (castle owes user). that created liabilities (libra owes user).
Returns: Returns:
List of user contribution summaries: List of user contribution summaries:
@ -1601,8 +1601,8 @@ class FavaClient:
Args: Args:
user_id: User ID (first 8 characters used for account matching) user_id: User ID (first 8 characters used for account matching)
entry_type: "expense" (payables - castle owes user) or entry_type: "expense" (payables - libra owes user) or
"receivable" (user owes castle) "receivable" (user owes libra)
Returns: Returns:
List of unsettled entries with: List of unsettled entries with:
@ -1742,8 +1742,8 @@ class FavaClient:
Args: Args:
user_id: User ID (first 8 characters used for account matching) user_id: User ID (first 8 characters used for account matching)
entry_type: "expense" (payables - castle owes user) or entry_type: "expense" (payables - libra owes user) or
"receivable" (user owes castle) "receivable" (user owes libra)
Returns: Returns:
List of unsettled entries with: List of unsettled entries with:
@ -1896,6 +1896,6 @@ def get_fava_client() -> FavaClient:
if _fava_client is None: if _fava_client is None:
raise RuntimeError( raise RuntimeError(
"Fava client not initialized. Call init_fava_client() first. " "Fava client not initialized. Call init_fava_client() first. "
"Castle requires Fava for all accounting operations." "Libra requires Fava for all accounting operations."
) )
return _fava_client return _fava_client

View file

@ -1,6 +1,6 @@
# Castle Beancount Import Helper # Libra Beancount Import Helper
Import Beancount ledger transactions into Castle accounting extension. Import Beancount ledger transactions into Libra accounting extension.
## 📁 Files ## 📁 Files
@ -40,14 +40,14 @@ USER_MAPPINGS = {
### 3. Set API Key ### 3. Set API Key
```bash ```bash
export CASTLE_ADMIN_KEY="your_lnbits_admin_invoice_key" export LIBRA_ADMIN_KEY="your_lnbits_admin_invoice_key"
export LNBITS_URL="http://localhost:5000" # Optional export LNBITS_URL="http://localhost:5000" # Optional
``` ```
## 📖 Usage ## 📖 Usage
```bash ```bash
cd /path/to/castle/helper cd /path/to/libra/helper
# Test with dry run # Test with dry run
python import_beancount.py ledger.beancount --dry-run python import_beancount.py ledger.beancount --dry-run
@ -72,30 +72,30 @@ Your Beancount transactions must have an `Equity:<name>` account:
**Requirements:** **Requirements:**
- Every transaction must have an `Equity:<name>` account - Every transaction must have an `Equity:<name>` account
- Account names must match exactly what's in Castle - Account names must match exactly what's in Libra
- The name after `Equity:` must be in `USER_MAPPINGS` - The name after `Equity:` must be in `USER_MAPPINGS`
## 🔄 How It Works ## 🔄 How It Works
1. **Loads rates** from `btc_eur_rates.csv` 1. **Loads rates** from `btc_eur_rates.csv`
2. **Loads accounts** from Castle API automatically 2. **Loads accounts** from Libra API automatically
3. **Maps users** - Extracts user name from `Equity:Name` accounts 3. **Maps users** - Extracts user name from `Equity:Name` accounts
4. **Parses** Beancount transactions 4. **Parses** Beancount transactions
5. **Converts** EUR → sats using daily rate 5. **Converts** EUR → sats using daily rate
6. **Uploads** to Castle with metadata 6. **Uploads** to Libra with metadata
## 📊 Example Output ## 📊 Example Output
```bash ```bash
$ python import_beancount.py ledger.beancount $ python import_beancount.py ledger.beancount
====================================================================== ======================================================================
🏰 Beancount to Castle Import Script 🏰 Beancount to Libra Import Script
====================================================================== ======================================================================
📊 Loaded 15 daily rates from btc_eur_rates.csv 📊 Loaded 15 daily rates from btc_eur_rates.csv
Date range: 2025-07-01 to 2025-07-15 Date range: 2025-07-01 to 2025-07-15
🏦 Loaded 28 accounts from Castle 🏦 Loaded 28 accounts from Libra
👥 User ID mappings: 👥 User ID mappings:
- Pat → wallet_abc123 - Pat → wallet_abc123
@ -112,15 +112,15 @@ $ python import_beancount.py ledger.beancount
📊 Summary: 25 succeeded, 0 failed, 0 skipped 📊 Summary: 25 succeeded, 0 failed, 0 skipped
====================================================================== ======================================================================
✅ Successfully imported 25 transactions to Castle! ✅ Successfully imported 25 transactions to Libra!
``` ```
## ❓ Troubleshooting ## ❓ Troubleshooting
### "No account found in Castle" ### "No account found in Libra"
**Error:** `No account found in Castle with name 'Expenses:XYZ'` **Error:** `No account found in Libra with name 'Expenses:XYZ'`
**Solution:** Create the account in Castle first with that exact name. **Solution:** Create the account in Libra first with that exact name.
### "No user ID mapping found" ### "No user ID mapping found"
**Error:** `No user ID mapping found for 'Pat'` **Error:** `No user ID mapping found for 'Pat'`

View file

@ -1,11 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Beancount to Castle Import Script Beancount to Libra Import Script
NOTE: This script is for ONE-OFF MIGRATION purposes only. NOTE: This script is for ONE-OFF MIGRATION purposes only.
Now that Castle uses Fava/Beancount as the single source of truth, Now that Libra uses Fava/Beancount as the single source of truth,
the data flow is: Castle Fava/Beancount (not the reverse). the data flow is: Libra Fava/Beancount (not the reverse).
This script was used for initial data import from existing Beancount files. This script was used for initial data import from existing Beancount files.
@ -14,7 +14,7 @@ Beancount to Castle Import Script
- REPURPOSE for bidirectional sync if that becomes a requirement - REPURPOSE for bidirectional sync if that becomes a requirement
- ARCHIVE to misc-docs/old-helpers/ if keeping for reference - ARCHIVE to misc-docs/old-helpers/ if keeping for reference
Imports Beancount ledger transactions into Castle accounting extension. Imports Beancount ledger transactions into Libra accounting extension.
Reads daily BTC/EUR rates from btc_eur_rates.csv in the same directory. Reads daily BTC/EUR rates from btc_eur_rates.csv in the same directory.
Usage: Usage:
@ -35,14 +35,14 @@ from typing import Dict, Optional
# LNbits URL and API Key # LNbits URL and API Key
LNBITS_URL = os.environ.get("LNBITS_URL", "http://localhost:5000") LNBITS_URL = os.environ.get("LNBITS_URL", "http://localhost:5000")
ADMIN_API_KEY = os.environ.get("CASTLE_ADMIN_KEY", "48d787d862484a6c89d6a557b4d5be9d") ADMIN_API_KEY = os.environ.get("LIBRA_ADMIN_KEY", "48d787d862484a6c89d6a557b4d5be9d")
# Rates CSV file (looks in same directory as this script) # Rates CSV file (looks in same directory as this script)
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
RATES_CSV_FILE = os.path.join(SCRIPT_DIR, "btc_eur_rates.csv") RATES_CSV_FILE = os.path.join(SCRIPT_DIR, "btc_eur_rates.csv")
# User ID mappings: Equity account name -> Castle user ID (wallet ID) # User ID mappings: Equity account name -> Libra user ID (wallet ID)
# TODO: Update these with your actual Castle user/wallet IDs # TODO: Update these with your actual Libra user/wallet IDs
USER_MAPPINGS = { USER_MAPPINGS = {
"Pat": "75be145a42884b22b60bf97510ed46e3", "Pat": "75be145a42884b22b60bf97510ed46e3",
"Coco": "375ec158ceca46de86cf6561ca20f881", "Coco": "375ec158ceca46de86cf6561ca20f881",
@ -116,7 +116,7 @@ class RateLookup:
# ===== ACCOUNT LOOKUP ===== # ===== ACCOUNT LOOKUP =====
class AccountLookup: class AccountLookup:
"""Fetch and lookup Castle accounts from API""" """Fetch and lookup Libra accounts from API"""
def __init__(self, lnbits_url: str, api_key: str): def __init__(self, lnbits_url: str, api_key: str):
self.accounts = {} # name -> account_id self.accounts = {} # name -> account_id
@ -125,8 +125,8 @@ class AccountLookup:
self._fetch_accounts(lnbits_url, api_key) self._fetch_accounts(lnbits_url, api_key)
def _fetch_accounts(self, lnbits_url: str, api_key: str): def _fetch_accounts(self, lnbits_url: str, api_key: str):
"""Fetch all accounts from Castle API""" """Fetch all accounts from Libra API"""
url = f"{lnbits_url}/castle/api/v1/accounts" url = f"{lnbits_url}/libra/api/v1/accounts"
headers = {"X-Api-Key": api_key} headers = {"X-Api-Key": api_key}
try: try:
@ -153,28 +153,28 @@ class AccountLookup:
self.accounts_by_user[user_id] = {} self.accounts_by_user[user_id] = {}
self.accounts_by_user[user_id][account_type] = account_id self.accounts_by_user[user_id][account_type] = account_id
print(f"🏦 Loaded {len(self.accounts)} accounts from Castle") print(f"🏦 Loaded {len(self.accounts)} accounts from Libra")
except requests.RequestException as e: except requests.RequestException as e:
raise ConnectionError(f"Failed to fetch accounts from Castle API: {e}") raise ConnectionError(f"Failed to fetch accounts from Libra API: {e}")
def get_account_id(self, account_name: str) -> Optional[str]: def get_account_id(self, account_name: str) -> Optional[str]:
""" """
Get Castle account ID for a Beancount account name. Get Libra account ID for a Beancount account name.
Special handling for user-specific accounts: Special handling for user-specific accounts:
- "Liabilities:Payable:Pat" -> looks up Pat's user_id and finds their Castle payable account - "Liabilities:Payable:Pat" -> looks up Pat's user_id and finds their Libra payable account
- "Assets:Receivable:Pat" -> looks up Pat's user_id and finds their Castle receivable account - "Assets:Receivable:Pat" -> looks up Pat's user_id and finds their Libra receivable account
- "Equity:Pat" -> looks up Pat's user_id and finds their Castle equity account - "Equity:Pat" -> looks up Pat's user_id and finds their Libra equity account
Args: Args:
account_name: Beancount account name (e.g., "Expenses:Food:Supplies", "Liabilities:Payable:Pat", "Assets:Receivable:Pat", "Equity:Pat") account_name: Beancount account name (e.g., "Expenses:Food:Supplies", "Liabilities:Payable:Pat", "Assets:Receivable:Pat", "Equity:Pat")
Returns: Returns:
Castle account UUID or None if not found Libra account UUID or None if not found
""" """
# Check if this is a Liabilities:Payable:<name> account # Check if this is a Liabilities:Payable:<name> account
# Map Beancount Liabilities:Payable:Pat to Castle Liabilities:Payable:User-<id> # Map Beancount Liabilities:Payable:Pat to Libra Liabilities:Payable:User-<id>
if account_name.startswith("Liabilities:Payable:"): if account_name.startswith("Liabilities:Payable:"):
user_name = extract_user_from_user_account(account_name) user_name = extract_user_from_user_account(account_name)
if user_name: if user_name:
@ -182,7 +182,7 @@ class AccountLookup:
user_id = USER_MAPPINGS.get(user_name) user_id = USER_MAPPINGS.get(user_name)
if user_id: if user_id:
# Find this user's liability (payable) account # Find this user's liability (payable) account
# This is the Liabilities:Payable:User-<id> account in Castle # This is the Liabilities:Payable:User-<id> account in Libra
if user_id in self.accounts_by_user: if user_id in self.accounts_by_user:
liability_account_id = self.accounts_by_user[user_id].get('liability') liability_account_id = self.accounts_by_user[user_id].get('liability')
if liability_account_id: if liability_account_id:
@ -196,7 +196,7 @@ class AccountLookup:
) )
# Check if this is an Assets:Receivable:<name> account # Check if this is an Assets:Receivable:<name> account
# Map Beancount Assets:Receivable:Pat to Castle Assets:Receivable:User-<id> # Map Beancount Assets:Receivable:Pat to Libra Assets:Receivable:User-<id>
elif account_name.startswith("Assets:Receivable:"): elif account_name.startswith("Assets:Receivable:"):
user_name = extract_user_from_user_account(account_name) user_name = extract_user_from_user_account(account_name)
if user_name: if user_name:
@ -204,7 +204,7 @@ class AccountLookup:
user_id = USER_MAPPINGS.get(user_name) user_id = USER_MAPPINGS.get(user_name)
if user_id: if user_id:
# Find this user's asset (receivable) account # Find this user's asset (receivable) account
# This is the Assets:Receivable:User-<id> account in Castle # This is the Assets:Receivable:User-<id> account in Libra
if user_id in self.accounts_by_user: if user_id in self.accounts_by_user:
asset_account_id = self.accounts_by_user[user_id].get('asset') asset_account_id = self.accounts_by_user[user_id].get('asset')
if asset_account_id: if asset_account_id:
@ -218,7 +218,7 @@ class AccountLookup:
) )
# Check if this is an Equity:<name> account # Check if this is an Equity:<name> account
# Map Beancount Equity:Pat to Castle Equity:User-<id> # Map Beancount Equity:Pat to Libra Equity:User-<id>
elif account_name.startswith("Equity:"): elif account_name.startswith("Equity:"):
user_name = extract_user_from_user_account(account_name) user_name = extract_user_from_user_account(account_name)
if user_name: if user_name:
@ -226,7 +226,7 @@ class AccountLookup:
user_id = USER_MAPPINGS.get(user_name) user_id = USER_MAPPINGS.get(user_name)
if user_id: if user_id:
# Find this user's equity account # Find this user's equity account
# This is the Equity:User-<id> account in Castle # This is the Equity:User-<id> account in Libra
if user_id in self.accounts_by_user: if user_id in self.accounts_by_user:
equity_account_id = self.accounts_by_user[user_id].get('equity') equity_account_id = self.accounts_by_user[user_id].get('equity')
if equity_account_id: if equity_account_id:
@ -235,7 +235,7 @@ class AccountLookup:
# If not found, provide helpful error # If not found, provide helpful error
raise ValueError( raise ValueError(
f"User '{user_name}' (ID: {user_id}) does not have an equity account.\n" f"User '{user_name}' (ID: {user_id}) does not have an equity account.\n"
f"Equity eligibility must be enabled for this user in Castle.\n" f"Equity eligibility must be enabled for this user in Libra.\n"
f"Please enable equity for user ID: {user_id}" f"Please enable equity for user ID: {user_id}"
) )
@ -282,7 +282,7 @@ def eur_to_sats(eur_amount: Decimal, btc_eur_rate: float) -> int:
def build_metadata(eur_amount: Decimal, btc_eur_rate: float) -> dict: def build_metadata(eur_amount: Decimal, btc_eur_rate: float) -> dict:
""" """
Build metadata dict for Castle entry line. Build metadata dict for Libra entry line.
The API will extract fiat_currency and fiat_amount and use them The API will extract fiat_currency and fiat_amount and use them
to create proper EUR-based postings with SATS in metadata. to create proper EUR-based postings with SATS in metadata.
@ -441,13 +441,13 @@ def determine_user_id(postings: list) -> Optional[str]:
# No user-specific account found - this shouldn't happen for typical transactions # No user-specific account found - this shouldn't happen for typical transactions
return None return None
# ===== CASTLE CONVERTER ===== # ===== LIBRA CONVERTER =====
def convert_to_castle_entry(parsed: dict, btc_eur_rate: float, account_lookup: AccountLookup) -> dict: def convert_to_libra_entry(parsed: dict, btc_eur_rate: float, account_lookup: AccountLookup) -> dict:
""" """
Convert parsed Beancount transaction to Castle format. Convert parsed Beancount transaction to Libra format.
Sends SATS amounts with fiat metadata. The Castle API will automatically Sends SATS amounts with fiat metadata. The Libra API will automatically
convert to EUR-based postings with SATS stored in metadata. convert to EUR-based postings with SATS stored in metadata.
""" """
@ -469,8 +469,8 @@ def convert_to_castle_entry(parsed: dict, btc_eur_rate: float, account_lookup: A
account_id = account_lookup.get_account_id(posting['account']) account_id = account_lookup.get_account_id(posting['account'])
if not account_id: if not account_id:
raise ValueError( raise ValueError(
f"No account found in Castle with name '{posting['account']}'.\n" f"No account found in Libra with name '{posting['account']}'.\n"
f"Please create this account in Castle first." f"Please create this account in Libra first."
) )
eur_amount = posting['eur_amount'] eur_amount = posting['eur_amount']
@ -510,7 +510,7 @@ def convert_to_castle_entry(parsed: dict, btc_eur_rate: float, account_lookup: A
# ===== API UPLOAD ===== # ===== API UPLOAD =====
def upload_entry(entry: dict, api_key: str, dry_run: bool = False) -> dict: def upload_entry(entry: dict, api_key: str, dry_run: bool = False) -> dict:
"""Upload journal entry to Castle API""" """Upload journal entry to Libra API"""
if dry_run: if dry_run:
print(f"\n[DRY RUN] Entry preview:") print(f"\n[DRY RUN] Entry preview:")
print(f" Description: {entry['description']}") print(f" Description: {entry['description']}")
@ -525,7 +525,7 @@ def upload_entry(entry: dict, api_key: str, dry_run: bool = False) -> dict:
print(f" Balance check: {total_sats} (should be 0)") print(f" Balance check: {total_sats} (should be 0)")
return {"id": "dry-run"} return {"id": "dry-run"}
url = f"{LNBITS_URL}/castle/api/v1/entries" url = f"{LNBITS_URL}/libra/api/v1/entries"
headers = { headers = {
"X-Api-Key": api_key, "X-Api-Key": api_key,
"Content-Type": "application/json" "Content-Type": "application/json"
@ -551,7 +551,7 @@ def import_beancount_file(beancount_file: str, dry_run: bool = False):
# Validate configuration # Validate configuration
if not ADMIN_API_KEY: if not ADMIN_API_KEY:
print("❌ Error: CASTLE_ADMIN_KEY not set!") print("❌ Error: LIBRA_ADMIN_KEY not set!")
print(" Set it as environment variable or update ADMIN_API_KEY in the script.") print(" Set it as environment variable or update ADMIN_API_KEY in the script.")
return return
@ -562,7 +562,7 @@ def import_beancount_file(beancount_file: str, dry_run: bool = False):
print(f"❌ Error loading rates: {e}") print(f"❌ Error loading rates: {e}")
return return
# Load accounts from Castle # Load accounts from Libra
try: try:
account_lookup = AccountLookup(LNBITS_URL, ADMIN_API_KEY) account_lookup = AccountLookup(LNBITS_URL, ADMIN_API_KEY)
except (ConnectionError, ValueError) as e: except (ConnectionError, ValueError) as e:
@ -574,7 +574,7 @@ def import_beancount_file(beancount_file: str, dry_run: bool = False):
for name, user_id in USER_MAPPINGS.items(): for name, user_id in USER_MAPPINGS.items():
has_equity = user_id in account_lookup.accounts_by_user and 'equity' in account_lookup.accounts_by_user[user_id] has_equity = user_id in account_lookup.accounts_by_user and 'equity' in account_lookup.accounts_by_user[user_id]
status = "" if has_equity else "" status = "" if has_equity else ""
print(f" {status} {name}{user_id} {'(has equity account)' if has_equity else '(NO EQUITY ACCOUNT - create in Castle!)'}") print(f" {status} {name}{user_id} {'(has equity account)' if has_equity else '(NO EQUITY ACCOUNT - create in Libra!)'}")
# Read beancount file # Read beancount file
if not os.path.exists(beancount_file): if not os.path.exists(beancount_file):
@ -612,8 +612,8 @@ def import_beancount_file(beancount_file: str, dry_run: bool = False):
if not btc_eur_rate: if not btc_eur_rate:
raise ValueError(f"No BTC/EUR rate found for {parsed['date'].date()}") raise ValueError(f"No BTC/EUR rate found for {parsed['date'].date()}")
castle_entry = convert_to_castle_entry(parsed, btc_eur_rate, account_lookup) libra_entry = convert_to_libra_entry(parsed, btc_eur_rate, account_lookup)
result = upload_entry(castle_entry, ADMIN_API_KEY, dry_run) result = upload_entry(libra_entry, ADMIN_API_KEY, dry_run)
# Get user name for display # Get user name for display
user_name = None user_name = None
@ -643,7 +643,7 @@ def import_beancount_file(beancount_file: str, dry_run: bool = False):
print(f" {item}") print(f" {item}")
if success_count > 0 and not dry_run: if success_count > 0 and not dry_run:
print(f"\n✅ Successfully imported {success_count} transactions to Castle!") print(f"\n✅ Successfully imported {success_count} transactions to Libra!")
print(f"\n💡 Note: Transactions are stored in EUR with SATS in metadata.") print(f"\n💡 Note: Transactions are stored in EUR with SATS in metadata.")
print(f" Check Fava to see the imported entries.") print(f" Check Fava to see the imported entries.")
@ -653,7 +653,7 @@ if __name__ == "__main__":
import sys import sys
print("=" * 70) print("=" * 70)
print("🏰 Beancount to Castle Import Script") print("🏰 Beancount to Libra Import Script")
print("=" * 70) print("=" * 70)
if len(sys.argv) < 2: if len(sys.argv) < 2:
@ -664,7 +664,7 @@ if __name__ == "__main__":
print("\nConfiguration:") print("\nConfiguration:")
print(f" LNBITS_URL: {LNBITS_URL}") print(f" LNBITS_URL: {LNBITS_URL}")
print(f" RATES_CSV: {RATES_CSV_FILE}") print(f" RATES_CSV: {RATES_CSV_FILE}")
print(f" API Key set: {'Yes' if ADMIN_API_KEY else 'No (set CASTLE_ADMIN_KEY env var)'}") print(f" API Key set: {'Yes' if ADMIN_API_KEY else 'No (set LIBRA_ADMIN_KEY env var)'}")
sys.exit(1) sys.exit(1)
beancount_file = sys.argv[1] beancount_file = sys.argv[1]

View file

@ -1,9 +1,9 @@
{ {
"repos": [ "repos": [
{ {
"id": "castle", "id": "libra",
"organisation": "lnbits", "organisation": "lnbits",
"repository": "castle" "repository": "libra"
} }
] ]
} }

View file

@ -1,8 +1,8 @@
""" """
Castle Extension Database Migrations Libra Extension Database Migrations
This file contains a single squashed migration that creates the complete This file contains a single squashed migration that creates the complete
database schema for the Castle extension. database schema for the Libra extension.
MIGRATION HISTORY: MIGRATION HISTORY:
This is a squashed migration that combines m001-m016 from the original This is a squashed migration that combines m001-m016 from the original
@ -39,19 +39,19 @@ Original migration sequence (Nov 2025):
async def m001_initial(db): async def m001_initial(db):
""" """
Initial Castle database schema (squashed from m001-m016). Initial Libra database schema (squashed from m001-m016).
Creates complete database structure for Castle accounting extension: Creates complete database structure for Libra accounting extension:
- Accounts: Chart of accounts with hierarchical Beancount-style names - Accounts: Chart of accounts with hierarchical Beancount-style names
- Extension settings: Castle-wide configuration - Extension settings: Libra-wide configuration
- User wallet settings: Per-user wallet configuration - User wallet settings: Per-user wallet configuration
- Manual payment requests: User-submitted payment requests to Castle - Manual payment requests: User-submitted payment requests to Libra
- Balance assertions: Reconciliation and balance checking - Balance assertions: Reconciliation and balance checking
- User equity status: Equity contribution eligibility - User equity status: Equity contribution eligibility
- Account permissions: Granular access control - Account permissions: Granular access control
Note: Journal entries are managed by Fava/Beancount (external source of truth). Note: Journal entries are managed by Fava/Beancount (external source of truth).
Castle submits entries to Fava and queries Fava for journal data. Libra submits entries to Fava and queries Fava for journal data.
""" """
# ========================================================================= # =========================================================================
@ -89,15 +89,15 @@ async def m001_initial(db):
# ========================================================================= # =========================================================================
# EXTENSION SETTINGS TABLE # EXTENSION SETTINGS TABLE
# ========================================================================= # =========================================================================
# Castle-wide configuration settings # Libra-wide configuration settings
await db.execute( await db.execute(
f""" f"""
CREATE TABLE extension_settings ( CREATE TABLE extension_settings (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
castle_wallet_id TEXT, libra_wallet_id TEXT,
fava_url TEXT NOT NULL DEFAULT 'http://localhost:3333', fava_url TEXT NOT NULL DEFAULT 'http://localhost:3333',
fava_ledger_slug TEXT NOT NULL DEFAULT 'castle-ledger', fava_ledger_slug TEXT NOT NULL DEFAULT 'libra-ledger',
fava_timeout REAL NOT NULL DEFAULT 10.0, fava_timeout REAL NOT NULL DEFAULT 10.0,
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
); );
@ -122,7 +122,7 @@ async def m001_initial(db):
# ========================================================================= # =========================================================================
# MANUAL PAYMENT REQUESTS TABLE # MANUAL PAYMENT REQUESTS TABLE
# ========================================================================= # =========================================================================
# User-submitted payment requests to Castle (reviewed by admins) # User-submitted payment requests to Libra (reviewed by admins)
await db.execute( await db.execute(
f""" f"""
@ -362,7 +362,7 @@ async def m003_add_account_is_virtual(db):
Add is_virtual field to accounts table for virtual parent accounts. Add is_virtual field to accounts table for virtual parent accounts.
Virtual parent accounts: Virtual parent accounts:
- Exist only in Castle DB (metadata-only, not in Beancount) - Exist only in Libra DB (metadata-only, not in Beancount)
- Used solely for permission inheritance - Used solely for permission inheritance
- Allow granting permissions on top-level accounts like "Expenses", "Assets" - Allow granting permissions on top-level accounts like "Expenses", "Assets"
- Are not synced to/from Beancount - Are not synced to/from Beancount

View file

@ -87,7 +87,7 @@ class CreateJournalEntry(BaseModel):
class UserBalance(BaseModel): class UserBalance(BaseModel):
user_id: str user_id: str
balance: int # positive = castle owes user, negative = user owes castle balance: int # positive = libra owes user, negative = user owes libra
accounts: list[Account] = [] accounts: list[Account] = []
fiat_balances: dict[str, Decimal] = {} # e.g. {"EUR": Decimal("250.0"), "USD": Decimal("100.0")} fiat_balances: dict[str, Decimal] = {} # e.g. {"EUR": Decimal("250.0"), "USD": Decimal("100.0")}
@ -98,7 +98,7 @@ class ExpenseEntry(BaseModel):
description: str description: str
amount: Decimal # Amount in the specified currency (or satoshis if currency is None) amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
expense_account: str # account name or ID expense_account: str # account name or ID
is_equity: bool = False # True = equity contribution, False = liability (castle owes user) is_equity: bool = False # True = equity contribution, False = liability (libra owes user)
user_wallet: str user_wallet: str
reference: Optional[str] = None reference: Optional[str] = None
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code (EUR, USD, etc.) currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code (EUR, USD, etc.)
@ -111,7 +111,7 @@ class ReceivableEntry(BaseModel):
description: str description: str
amount: Decimal # Amount in the specified currency (or satoshis if currency is None) amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
revenue_account: str # account name or ID revenue_account: str # account name or ID
user_id: str # The user_id (not wallet_id) of the user who owes the castle user_id: str # The user_id (not wallet_id) of the user who owes the libra
reference: Optional[str] = None reference: Optional[str] = None
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code
@ -127,14 +127,14 @@ class RevenueEntry(BaseModel):
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code
class CastleSettings(BaseModel): class LibraSettings(BaseModel):
"""Settings for the Castle extension""" """Settings for the Libra extension"""
castle_wallet_id: Optional[str] = None # The wallet ID that represents the Castle libra_wallet_id: Optional[str] = None # The wallet ID that represents the Libra
# Fava/Beancount integration - ALL accounting is done via Fava # Fava/Beancount integration - ALL accounting is done via Fava
fava_url: str = "http://localhost:3333" # Base URL of Fava server fava_url: str = "http://localhost:3333" # Base URL of Fava server
fava_ledger_slug: str = "castle-ledger" # Ledger identifier in Fava URL fava_ledger_slug: str = "libra-ledger" # Ledger identifier in Fava URL
fava_timeout: float = 10.0 # Request timeout in seconds fava_timeout: float = 10.0 # Request timeout in seconds
updated_at: datetime = Field(default_factory=lambda: datetime.now()) updated_at: datetime = Field(default_factory=lambda: datetime.now())
@ -144,7 +144,7 @@ class CastleSettings(BaseModel):
return True return True
class UserCastleSettings(CastleSettings): class UserLibraSettings(LibraSettings):
"""User-specific settings (stored with user_id)""" """User-specific settings (stored with user_id)"""
id: str id: str
@ -164,7 +164,7 @@ class StoredUserWalletSettings(UserWalletSettings):
class ManualPaymentRequest(BaseModel): class ManualPaymentRequest(BaseModel):
"""Manual payment request from user to castle""" """Manual payment request from user to libra"""
id: str id: str
user_id: str user_id: str
@ -173,7 +173,7 @@ class ManualPaymentRequest(BaseModel):
status: str = "pending" # pending, approved, rejected status: str = "pending" # pending, approved, rejected
created_at: datetime created_at: datetime
reviewed_at: Optional[datetime] = None reviewed_at: Optional[datetime] = None
reviewed_by: Optional[str] = None # user_id of castle admin who reviewed reviewed_by: Optional[str] = None # user_id of libra admin who reviewed
journal_entry_id: Optional[str] = None # set when approved journal_entry_id: Optional[str] = None # set when approved
@ -198,7 +198,7 @@ class RecordPayment(BaseModel):
class SettleReceivable(BaseModel): class SettleReceivable(BaseModel):
"""Manually settle a receivable (user pays castle in person)""" """Manually settle a receivable (user pays libra in person)"""
user_id: str user_id: str
amount: Decimal # Amount in the specified currency (or satoshis if currency is None) amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
@ -213,7 +213,7 @@ class SettleReceivable(BaseModel):
class PayUser(BaseModel): class PayUser(BaseModel):
"""Pay a user (castle pays user for expense/liability)""" """Pay a user (libra pays user for expense/liability)"""
user_id: str user_id: str
amount: Decimal # Amount in the specified currency (or satoshis if currency is None) amount: Decimal # Amount in the specified currency (or satoshis if currency is None)

View file

@ -1,5 +1,5 @@
{ {
"name": "castle", "name": "libra",
"version": "0.0.2", "version": "0.0.2",
"description": "Accounting for a collective entity", "description": "Accounting for a collective entity",
"main": "index.js", "main": "index.js",

View file

@ -361,7 +361,7 @@ async def get_permission_analytics() -> dict:
""" """
SELECT ap.*, a.name as account_name SELECT ap.*, a.name as account_name
FROM account_permissions ap FROM account_permissions ap
JOIN castle_accounts a ON ap.account_id = a.id JOIN libra_accounts a ON ap.account_id = a.id
WHERE ap.expires_at IS NOT NULL WHERE ap.expires_at IS NOT NULL
AND ap.expires_at > :now AND ap.expires_at > :now
AND ap.expires_at <= :seven_days AND ap.expires_at <= :seven_days
@ -385,7 +385,7 @@ async def get_permission_analytics() -> dict:
top_accounts_result = await db.fetchall( top_accounts_result = await db.fetchall(
""" """
SELECT a.name, COUNT(ap.id) as permission_count SELECT a.name, COUNT(ap.id) as permission_count
FROM castle_accounts a FROM libra_accounts a
LEFT JOIN account_permissions ap ON a.id = ap.account_id LEFT JOIN account_permissions ap ON a.id = ap.account_id
GROUP BY a.id, a.name GROUP BY a.id, a.name
HAVING COUNT(ap.id) > 0 HAVING COUNT(ap.id) > 0

View file

@ -1,32 +1,32 @@
from .crud import ( from .crud import (
create_castle_settings, create_libra_settings,
create_user_wallet_settings, create_user_wallet_settings,
get_castle_settings, get_libra_settings,
get_or_create_user_account, get_or_create_user_account,
get_user_wallet_settings, get_user_wallet_settings,
update_castle_settings, update_libra_settings,
update_user_wallet_settings, update_user_wallet_settings,
) )
from .models import AccountType, CastleSettings, UserWalletSettings from .models import AccountType, LibraSettings, UserWalletSettings
async def get_settings(user_id: str) -> CastleSettings: async def get_settings(user_id: str) -> LibraSettings:
settings = await get_castle_settings(user_id) settings = await get_libra_settings(user_id)
if not settings: if not settings:
settings = await create_castle_settings(user_id, CastleSettings()) settings = await create_libra_settings(user_id, LibraSettings())
return settings return settings
async def update_settings(user_id: str, data: CastleSettings) -> CastleSettings: async def update_settings(user_id: str, data: LibraSettings) -> LibraSettings:
from loguru import logger from loguru import logger
from .fava_client import init_fava_client from .fava_client import init_fava_client
settings = await get_castle_settings(user_id) settings = await get_libra_settings(user_id)
if not settings: if not settings:
settings = await create_castle_settings(user_id, data) settings = await create_libra_settings(user_id, data)
else: else:
settings = await update_castle_settings(user_id, data) settings = await update_libra_settings(user_id, data)
# Reinitialize Fava client with new settings # Reinitialize Fava client with new settings
try: try:

View file

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Before After
Before After

View file

@ -32,7 +32,7 @@ window.app = Vue.createApp({
isAdmin: false, isAdmin: false,
isSuperUser: false, isSuperUser: false,
settingsLoaded: false, // Flag to prevent race conditions on toolbar buttons settingsLoaded: false, // Flag to prevent race conditions on toolbar buttons
castleWalletConfigured: false, libraWalletConfigured: false,
userWalletConfigured: false, userWalletConfigured: false,
syncingAccounts: false, syncingAccounts: false,
currentExchangeRate: null, // BTC/EUR rate (sats per EUR) currentExchangeRate: null, // BTC/EUR rate (sats per EUR)
@ -58,9 +58,9 @@ window.app = Vue.createApp({
}, },
settingsDialog: { settingsDialog: {
show: false, show: false,
castleWalletId: '', libraWalletId: '',
favaUrl: 'http://localhost:3333', favaUrl: 'http://localhost:3333',
favaLedgerSlug: 'castle-ledger', favaLedgerSlug: 'libra-ledger',
favaTimeout: 10.0, favaTimeout: 10.0,
loading: false loading: false
}, },
@ -208,8 +208,8 @@ window.app = Vue.createApp({
accountTypeOptions() { accountTypeOptions() {
return [ return [
{ label: 'All Types', value: null }, { label: 'All Types', value: null },
{ label: 'Receivable (User owes Castle)', value: 'asset' }, { label: 'Receivable (User owes Libra)', value: 'asset' },
{ label: 'Payable (Castle owes User)', value: 'liability' }, { label: 'Payable (Libra owes User)', value: 'liability' },
{ label: 'Equity (User Balance)', value: 'equity' } { label: 'Equity (User Balance)', value: 'equity' }
] ]
}, },
@ -318,7 +318,7 @@ window.app = Vue.createApp({
try { try {
const response = await LNbits.api.request( const response = await LNbits.api.request(
'GET', 'GET',
'/castle/api/v1/balance', '/libra/api/v1/balance',
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
this.balance = response.data this.balance = response.data
@ -341,7 +341,7 @@ window.app = Vue.createApp({
try { try {
const response = await LNbits.api.request( const response = await LNbits.api.request(
'GET', 'GET',
'/castle/api/v1/balances/all', '/libra/api/v1/balances/all',
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
this.allUserBalances = response.data this.allUserBalances = response.data
@ -389,7 +389,7 @@ window.app = Vue.createApp({
const response = await LNbits.api.request( const response = await LNbits.api.request(
'GET', 'GET',
`/castle/api/v1/entries/user?${queryParams}`, `/libra/api/v1/entries/user?${queryParams}`,
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
@ -458,7 +458,7 @@ window.app = Vue.createApp({
try { try {
const response = await LNbits.api.request( const response = await LNbits.api.request(
'GET', 'GET',
'/castle/api/v1/accounts?filter_by_user=true&exclude_virtual=true', '/libra/api/v1/accounts?filter_by_user=true&exclude_virtual=true',
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
this.accounts = response.data this.accounts = response.data
@ -472,7 +472,7 @@ window.app = Vue.createApp({
try { try {
const response = await LNbits.api.request( const response = await LNbits.api.request(
'GET', 'GET',
'/castle/api/v1/currencies', '/libra/api/v1/currencies',
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
this.currencies = response.data this.currencies = response.data
@ -484,7 +484,7 @@ window.app = Vue.createApp({
try { try {
const response = await LNbits.api.request( const response = await LNbits.api.request(
'GET', 'GET',
'/castle/api/v1/users', '/libra/api/v1/users',
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
this.users = response.data this.users = response.data
@ -496,7 +496,7 @@ window.app = Vue.createApp({
try { try {
const response = await LNbits.api.request( const response = await LNbits.api.request(
'GET', 'GET',
'/castle/api/v1/user/info', '/libra/api/v1/user/info',
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
this.userInfo = response.data this.userInfo = response.data
@ -510,18 +510,18 @@ window.app = Vue.createApp({
// Try with admin key first to check settings // Try with admin key first to check settings
const response = await LNbits.api.request( const response = await LNbits.api.request(
'GET', 'GET',
'/castle/api/v1/settings', '/libra/api/v1/settings',
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
this.settings = response.data this.settings = response.data
this.castleWalletConfigured = !!(this.settings && this.settings.castle_wallet_id) this.libraWalletConfigured = !!(this.settings && this.settings.libra_wallet_id)
// Check if user is super user by seeing if they can access admin features // Check if user is super user by seeing if they can access admin features
this.isSuperUser = this.g.user.super_user || false this.isSuperUser = this.g.user.super_user || false
this.isAdmin = this.g.user.admin || this.isSuperUser this.isAdmin = this.g.user.admin || this.isSuperUser
} catch (error) { } catch (error) {
// Settings not available // Settings not available
this.castleWalletConfigured = false this.libraWalletConfigured = false
} finally { } finally {
// Mark settings as loaded to enable toolbar buttons // Mark settings as loaded to enable toolbar buttons
this.settingsLoaded = true this.settingsLoaded = true
@ -531,7 +531,7 @@ window.app = Vue.createApp({
try { try {
const response = await LNbits.api.request( const response = await LNbits.api.request(
'GET', 'GET',
'/castle/api/v1/user/wallet', '/libra/api/v1/user/wallet',
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
this.userWalletSettings = response.data this.userWalletSettings = response.data
@ -545,7 +545,7 @@ window.app = Vue.createApp({
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'POST', 'POST',
'/castle/api/v1/admin/accounts/sync', '/libra/api/v1/admin/accounts/sync',
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
const errors = (data?.errors || []).length const errors = (data?.errors || []).length
@ -567,9 +567,9 @@ window.app = Vue.createApp({
} }
}, },
showSettingsDialog() { showSettingsDialog() {
this.settingsDialog.castleWalletId = this.settings?.castle_wallet_id || '' this.settingsDialog.libraWalletId = this.settings?.libra_wallet_id || ''
this.settingsDialog.favaUrl = this.settings?.fava_url || 'http://localhost:3333' this.settingsDialog.favaUrl = this.settings?.fava_url || 'http://localhost:3333'
this.settingsDialog.favaLedgerSlug = this.settings?.fava_ledger_slug || 'castle-ledger' this.settingsDialog.favaLedgerSlug = this.settings?.fava_ledger_slug || 'libra-ledger'
this.settingsDialog.favaTimeout = this.settings?.fava_timeout || 10.0 this.settingsDialog.favaTimeout = this.settings?.fava_timeout || 10.0
this.settingsDialog.show = true this.settingsDialog.show = true
}, },
@ -578,10 +578,10 @@ window.app = Vue.createApp({
this.userWalletDialog.show = true this.userWalletDialog.show = true
}, },
async submitSettings() { async submitSettings() {
if (!this.settingsDialog.castleWalletId) { if (!this.settingsDialog.libraWalletId) {
this.$q.notify({ this.$q.notify({
type: 'warning', type: 'warning',
message: 'Castle Wallet ID is required' message: 'Libra Wallet ID is required'
}) })
return return
} }
@ -598,12 +598,12 @@ window.app = Vue.createApp({
try { try {
await LNbits.api.request( await LNbits.api.request(
'PUT', 'PUT',
'/castle/api/v1/settings', '/libra/api/v1/settings',
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
{ {
castle_wallet_id: this.settingsDialog.castleWalletId, libra_wallet_id: this.settingsDialog.libraWalletId,
fava_url: this.settingsDialog.favaUrl, fava_url: this.settingsDialog.favaUrl,
fava_ledger_slug: this.settingsDialog.favaLedgerSlug || 'castle-ledger', fava_ledger_slug: this.settingsDialog.favaLedgerSlug || 'libra-ledger',
fava_timeout: parseFloat(this.settingsDialog.favaTimeout) || 10.0 fava_timeout: parseFloat(this.settingsDialog.favaTimeout) || 10.0
} }
) )
@ -613,7 +613,7 @@ window.app = Vue.createApp({
}) })
this.settingsDialog.show = false this.settingsDialog.show = false
await this.loadSettings() await this.loadSettings()
// Reload user wallet to reflect castle wallet for super user // Reload user wallet to reflect libra wallet for super user
if (this.isSuperUser) { if (this.isSuperUser) {
await this.loadUserWallet() await this.loadUserWallet()
} }
@ -636,7 +636,7 @@ window.app = Vue.createApp({
try { try {
await LNbits.api.request( await LNbits.api.request(
'PUT', 'PUT',
'/castle/api/v1/user/wallet', '/libra/api/v1/user/wallet',
this.g.user.wallets[0].inkey, this.g.user.wallets[0].inkey,
{ {
user_wallet_id: this.userWalletDialog.userWalletId user_wallet_id: this.userWalletDialog.userWalletId
@ -659,7 +659,7 @@ window.app = Vue.createApp({
try { try {
await LNbits.api.request( await LNbits.api.request(
'POST', 'POST',
'/castle/api/v1/entries/expense', '/libra/api/v1/entries/expense',
this.g.user.wallets[0].inkey, this.g.user.wallets[0].inkey,
{ {
description: this.expenseDialog.description, description: this.expenseDialog.description,
@ -696,10 +696,10 @@ window.app = Vue.createApp({
} }
try { try {
// Generate an invoice on the Castle wallet // Generate an invoice on the Libra wallet
const response = await LNbits.api.request( const response = await LNbits.api.request(
'POST', 'POST',
'/castle/api/v1/generate-payment-invoice', '/libra/api/v1/generate-payment-invoice',
this.g.user.wallets[0].inkey, this.g.user.wallets[0].inkey,
{ {
amount: this.payDialog.amount amount: this.payDialog.amount
@ -745,7 +745,7 @@ window.app = Vue.createApp({
try { try {
await LNbits.api.request( await LNbits.api.request(
'POST', 'POST',
'/castle/api/v1/record-payment', '/libra/api/v1/record-payment',
this.g.user.wallets[0].inkey, this.g.user.wallets[0].inkey,
{ {
payment_hash: paymentHash payment_hash: paymentHash
@ -788,15 +788,15 @@ window.app = Vue.createApp({
}, },
showManualPaymentOption() { showManualPaymentOption() {
// This is for when user wants to pay their debt manually // This is for when user wants to pay their debt manually
// For now, just notify them to contact castle // For now, just notify them to contact libra
this.$q.notify({ this.$q.notify({
type: 'info', type: 'info',
message: 'Please contact Castle directly to arrange manual payment.', message: 'Please contact Libra directly to arrange manual payment.',
timeout: 3000 timeout: 3000
}) })
}, },
showManualPaymentDialog() { showManualPaymentDialog() {
// This is for when Castle owes user and they want to request manual payment // This is for when Libra owes user and they want to request manual payment
this.manualPaymentDialog.amount = Math.abs(this.balance.balance) this.manualPaymentDialog.amount = Math.abs(this.balance.balance)
this.manualPaymentDialog.description = '' this.manualPaymentDialog.description = ''
this.manualPaymentDialog.show = true this.manualPaymentDialog.show = true
@ -806,7 +806,7 @@ window.app = Vue.createApp({
try { try {
await LNbits.api.request( await LNbits.api.request(
'POST', 'POST',
'/castle/api/v1/manual-payment-request', '/libra/api/v1/manual-payment-request',
this.g.user.wallets[0].inkey, this.g.user.wallets[0].inkey,
{ {
amount: this.manualPaymentDialog.amount, amount: this.manualPaymentDialog.amount,
@ -831,8 +831,8 @@ window.app = Vue.createApp({
try { try {
// If super user, load all requests; otherwise load user's own requests // If super user, load all requests; otherwise load user's own requests
const endpoint = this.isSuperUser const endpoint = this.isSuperUser
? '/castle/api/v1/manual-payment-requests/all' ? '/libra/api/v1/manual-payment-requests/all'
: '/castle/api/v1/manual-payment-requests' : '/libra/api/v1/manual-payment-requests'
const key = this.isSuperUser const key = this.isSuperUser
? this.g.user.wallets[0].adminkey ? this.g.user.wallets[0].adminkey
: this.g.user.wallets[0].inkey : this.g.user.wallets[0].inkey
@ -855,7 +855,7 @@ window.app = Vue.createApp({
const response = await LNbits.api.request( const response = await LNbits.api.request(
'GET', 'GET',
'/castle/api/v1/entries/pending', '/libra/api/v1/entries/pending',
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
this.pendingExpenses = response.data this.pendingExpenses = response.data
@ -867,7 +867,7 @@ window.app = Vue.createApp({
try { try {
await LNbits.api.request( await LNbits.api.request(
'POST', 'POST',
`/castle/api/v1/manual-payment-requests/${requestId}/approve`, `/libra/api/v1/manual-payment-requests/${requestId}/approve`,
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
this.$q.notify({ this.$q.notify({
@ -885,7 +885,7 @@ window.app = Vue.createApp({
try { try {
await LNbits.api.request( await LNbits.api.request(
'POST', 'POST',
`/castle/api/v1/manual-payment-requests/${requestId}/reject`, `/libra/api/v1/manual-payment-requests/${requestId}/reject`,
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
this.$q.notify({ this.$q.notify({
@ -901,7 +901,7 @@ window.app = Vue.createApp({
try { try {
await LNbits.api.request( await LNbits.api.request(
'POST', 'POST',
`/castle/api/v1/entries/${entryId}/approve`, `/libra/api/v1/entries/${entryId}/approve`,
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
this.$q.notify({ this.$q.notify({
@ -920,7 +920,7 @@ window.app = Vue.createApp({
try { try {
await LNbits.api.request( await LNbits.api.request(
'POST', 'POST',
`/castle/api/v1/entries/${entryId}/reject`, `/libra/api/v1/entries/${entryId}/reject`,
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
this.$q.notify({ this.$q.notify({
@ -939,7 +939,7 @@ window.app = Vue.createApp({
try { try {
const response = await LNbits.api.request( const response = await LNbits.api.request(
'GET', 'GET',
'/castle/api/v1/assertions', '/libra/api/v1/assertions',
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
this.balanceAssertions = response.data this.balanceAssertions = response.data
@ -965,7 +965,7 @@ window.app = Vue.createApp({
await LNbits.api.request( await LNbits.api.request(
'POST', 'POST',
'/castle/api/v1/assertions', '/libra/api/v1/assertions',
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
payload payload
) )
@ -1014,7 +1014,7 @@ window.app = Vue.createApp({
try { try {
await LNbits.api.request( await LNbits.api.request(
'POST', 'POST',
`/castle/api/v1/assertions/${assertionId}/check`, `/libra/api/v1/assertions/${assertionId}/check`,
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
@ -1033,7 +1033,7 @@ window.app = Vue.createApp({
try { try {
await LNbits.api.request( await LNbits.api.request(
'DELETE', 'DELETE',
`/castle/api/v1/assertions/${assertionId}`, `/libra/api/v1/assertions/${assertionId}`,
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
@ -1062,7 +1062,7 @@ window.app = Vue.createApp({
try { try {
const response = await LNbits.api.request( const response = await LNbits.api.request(
'GET', 'GET',
'/castle/api/v1/reconciliation/summary', '/libra/api/v1/reconciliation/summary',
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
this.reconciliation.summary = response.data this.reconciliation.summary = response.data
@ -1076,7 +1076,7 @@ window.app = Vue.createApp({
try { try {
const response = await LNbits.api.request( const response = await LNbits.api.request(
'GET', 'GET',
'/castle/api/v1/reconciliation/discrepancies', '/libra/api/v1/reconciliation/discrepancies',
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
this.reconciliation.discrepancies = response.data this.reconciliation.discrepancies = response.data
@ -1089,7 +1089,7 @@ window.app = Vue.createApp({
try { try {
const response = await LNbits.api.request( const response = await LNbits.api.request(
'POST', 'POST',
'/castle/api/v1/reconciliation/check-all', '/libra/api/v1/reconciliation/check-all',
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
@ -1143,7 +1143,7 @@ window.app = Vue.createApp({
try { try {
await LNbits.api.request( await LNbits.api.request(
'POST', 'POST',
'/castle/api/v1/entries/receivable', '/libra/api/v1/entries/receivable',
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
{ {
description: this.receivableDialog.description, description: this.receivableDialog.description,
@ -1186,7 +1186,7 @@ window.app = Vue.createApp({
this.receivableDialog.currency = null this.receivableDialog.currency = null
}, },
async showSettleReceivableDialog(userBalance) { async showSettleReceivableDialog(userBalance) {
// Only show for users who owe castle (positive balance = receivable) // Only show for users who owe libra (positive balance = receivable)
if (userBalance.balance <= 0) return if (userBalance.balance <= 0) return
// Clear any existing polling // Clear any existing polling
@ -1202,19 +1202,19 @@ window.app = Vue.createApp({
// Fetch unsettled entries for this user (BOTH receivables AND expenses for net settlement) // Fetch unsettled entries for this user (BOTH receivables AND expenses for net settlement)
let allEntryLinks = [] let allEntryLinks = []
try { try {
// Fetch receivable entries (user owes castle) // Fetch receivable entries (user owes libra)
const receivableResponse = await LNbits.api.request( const receivableResponse = await LNbits.api.request(
'GET', 'GET',
`/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`, `/libra/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`,
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
const receivableEntries = receivableResponse.data.unsettled_entries || [] const receivableEntries = receivableResponse.data.unsettled_entries || []
allEntryLinks.push(...receivableEntries.map(e => e.link).filter(l => l)) allEntryLinks.push(...receivableEntries.map(e => e.link).filter(l => l))
// Also fetch expense entries (castle owes user) - these are netted in the settlement // Also fetch expense entries (libra owes user) - these are netted in the settlement
const expenseResponse = await LNbits.api.request( const expenseResponse = await LNbits.api.request(
'GET', 'GET',
`/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`, `/libra/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`,
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
const expenseEntries = expenseResponse.data.unsettled_entries || [] const expenseEntries = expenseResponse.data.unsettled_entries || []
@ -1254,10 +1254,10 @@ window.app = Vue.createApp({
} }
try { try {
// Generate an invoice on the Castle wallet for the user to pay // Generate an invoice on the Libra wallet for the user to pay
const response = await LNbits.api.request( const response = await LNbits.api.request(
'POST', 'POST',
'/castle/api/v1/generate-payment-invoice', '/libra/api/v1/generate-payment-invoice',
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
{ {
amount: this.settleReceivableDialog.amount, amount: this.settleReceivableDialog.amount,
@ -1384,7 +1384,7 @@ window.app = Vue.createApp({
const response = await LNbits.api.request( const response = await LNbits.api.request(
'POST', 'POST',
'/castle/api/v1/receivables/settle', '/libra/api/v1/receivables/settle',
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
payload payload
) )
@ -1408,7 +1408,7 @@ window.app = Vue.createApp({
} }
}, },
async showPayUserDialog(userBalance) { async showPayUserDialog(userBalance) {
// Only show for users castle owes (negative balance = payable) // Only show for users libra owes (negative balance = payable)
if (userBalance.balance >= 0) return if (userBalance.balance >= 0) return
// Extract fiat balances (e.g., EUR) // Extract fiat balances (e.g., EUR)
@ -1416,26 +1416,26 @@ window.app = Vue.createApp({
const fiatCurrency = Object.keys(fiatBalances)[0] || null const fiatCurrency = Object.keys(fiatBalances)[0] || null
const fiatAmount = fiatCurrency ? fiatBalances[fiatCurrency] : 0 const fiatAmount = fiatCurrency ? fiatBalances[fiatCurrency] : 0
// Use absolute values since balance is negative (liability = castle owes user) // Use absolute values since balance is negative (liability = libra owes user)
const maxAmountSats = Math.abs(userBalance.balance) const maxAmountSats = Math.abs(userBalance.balance)
const maxAmountFiat = Math.abs(fiatAmount) const maxAmountFiat = Math.abs(fiatAmount)
// Fetch unsettled entries for this user (BOTH expenses AND receivables for net settlement) // Fetch unsettled entries for this user (BOTH expenses AND receivables for net settlement)
let allEntryLinks = [] let allEntryLinks = []
try { try {
// Fetch expense entries (castle owes user) // Fetch expense entries (libra owes user)
const expenseResponse = await LNbits.api.request( const expenseResponse = await LNbits.api.request(
'GET', 'GET',
`/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`, `/libra/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`,
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
const expenseEntries = expenseResponse.data.unsettled_entries || [] const expenseEntries = expenseResponse.data.unsettled_entries || []
allEntryLinks.push(...expenseEntries.map(e => e.link).filter(l => l)) allEntryLinks.push(...expenseEntries.map(e => e.link).filter(l => l))
// Also fetch receivable entries (user owes castle) - these are netted in the settlement // Also fetch receivable entries (user owes libra) - these are netted in the settlement
const receivableResponse = await LNbits.api.request( const receivableResponse = await LNbits.api.request(
'GET', 'GET',
`/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`, `/libra/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`,
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
const receivableEntries = receivableResponse.data.unsettled_entries || [] const receivableEntries = receivableResponse.data.unsettled_entries || []
@ -1448,7 +1448,7 @@ window.app = Vue.createApp({
show: true, show: true,
user_id: userBalance.user_id, user_id: userBalance.user_id,
username: userBalance.username, username: userBalance.username,
maxAmount: maxAmountSats, // Positive sats amount castle owes maxAmount: maxAmountSats, // Positive sats amount libra owes
maxAmountFiat: maxAmountFiat, // EUR or other fiat amount (positive) maxAmountFiat: maxAmountFiat, // EUR or other fiat amount (positive)
fiatCurrency: fiatCurrency, fiatCurrency: fiatCurrency,
amount: maxAmountSats, // Default to sats since lightning is the default payment method amount: maxAmountSats, // Default to sats since lightning is the default payment method
@ -1480,14 +1480,14 @@ window.app = Vue.createApp({
{ {
out: false, out: false,
amount: this.payUserDialog.amount, amount: this.payUserDialog.amount,
memo: `Payment from Castle to ${this.payUserDialog.username}` memo: `Payment from Libra to ${this.payUserDialog.username}`
} }
) )
console.log(invoiceResponse) console.log(invoiceResponse)
const paymentRequest = invoiceResponse.data.bolt11 const paymentRequest = invoiceResponse.data.bolt11
// Pay the invoice from Castle's wallet // Pay the invoice from Libra's wallet
const paymentResponse = await LNbits.api.request( const paymentResponse = await LNbits.api.request(
'POST', 'POST',
`/api/v1/payments`, `/api/v1/payments`,
@ -1498,7 +1498,7 @@ window.app = Vue.createApp({
} }
) )
// Record the payment in Castle accounting // Record the payment in Libra accounting
const payPayload = { const payPayload = {
user_id: this.payUserDialog.user_id, user_id: this.payUserDialog.user_id,
amount: this.payUserDialog.amount, amount: this.payUserDialog.amount,
@ -1513,7 +1513,7 @@ window.app = Vue.createApp({
await LNbits.api.request( await LNbits.api.request(
'POST', 'POST',
'/castle/api/v1/payables/pay', '/libra/api/v1/payables/pay',
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
payPayload payPayload
) )
@ -1579,7 +1579,7 @@ window.app = Vue.createApp({
const response = await LNbits.api.request( const response = await LNbits.api.request(
'POST', 'POST',
'/castle/api/v1/payables/pay', '/libra/api/v1/payables/pay',
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
payload payload
) )
@ -1606,7 +1606,7 @@ window.app = Vue.createApp({
try { try {
const response = await LNbits.api.request( const response = await LNbits.api.request(
'GET', 'GET',
`/castle/api/v1/user-wallet/${userId}`, `/libra/api/v1/user-wallet/${userId}`,
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
return response.data return response.data
@ -1663,13 +1663,13 @@ window.app = Vue.createApp({
return null return null
}, },
isReceivable(entry) { isReceivable(entry) {
// Check if this is a receivable entry (user owes castle) // Check if this is a receivable entry (user owes libra)
if (entry.tags && entry.tags.includes('receivable-entry')) return true if (entry.tags && entry.tags.includes('receivable-entry')) return true
if (entry.account && entry.account.includes('Receivable')) return true if (entry.account && entry.account.includes('Receivable')) return true
return false return false
}, },
isPayable(entry) { isPayable(entry) {
// Check if this is a payable entry (castle owes user) // Check if this is a payable entry (libra owes user)
if (entry.tags && entry.tags.includes('expense-entry')) return true if (entry.tags && entry.tags.includes('expense-entry')) return true
if (entry.account && entry.account.includes('Payable')) return true if (entry.account && entry.account.includes('Payable')) return true
return false return false

View file

@ -206,7 +206,7 @@ window.app = Vue.createApp({
try { try {
const response = await LNbits.api.request( const response = await LNbits.api.request(
'GET', 'GET',
'/castle/api/v1/admin/permissions', '/libra/api/v1/admin/permissions',
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
this.permissions = response.data this.permissions = response.data
@ -228,7 +228,7 @@ window.app = Vue.createApp({
// Admin permissions UI needs to see virtual accounts to grant permissions on them // Admin permissions UI needs to see virtual accounts to grant permissions on them
const response = await LNbits.api.request( const response = await LNbits.api.request(
'GET', 'GET',
'/castle/api/v1/accounts?exclude_virtual=false', '/libra/api/v1/accounts?exclude_virtual=false',
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
this.accounts = response.data this.accounts = response.data
@ -251,7 +251,7 @@ window.app = Vue.createApp({
try { try {
const response = await LNbits.api.request( const response = await LNbits.api.request(
'GET', 'GET',
'/castle/api/v1/admin/castle-users', '/libra/api/v1/admin/libra-users',
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
this.users = response.data || [] this.users = response.data || []
@ -318,7 +318,7 @@ window.app = Vue.createApp({
await LNbits.api.request( await LNbits.api.request(
'POST', 'POST',
'/castle/api/v1/admin/permissions', '/libra/api/v1/admin/permissions',
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
payload payload
) )
@ -357,7 +357,7 @@ window.app = Vue.createApp({
try { try {
await LNbits.api.request( await LNbits.api.request(
'DELETE', 'DELETE',
`/castle/api/v1/admin/permissions/${this.permissionToRevoke.id}`, `/libra/api/v1/admin/permissions/${this.permissionToRevoke.id}`,
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
@ -428,7 +428,7 @@ window.app = Vue.createApp({
const response = await LNbits.api.request( const response = await LNbits.api.request(
'POST', 'POST',
'/castle/api/v1/admin/permissions/bulk-grant', '/libra/api/v1/admin/permissions/bulk-grant',
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
payload payload
) )
@ -535,7 +535,7 @@ window.app = Vue.createApp({
try { try {
const response = await LNbits.api.request( const response = await LNbits.api.request(
'GET', 'GET',
'/castle/api/v1/admin/equity-eligibility', '/libra/api/v1/admin/equity-eligibility',
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
this.equityEligibleUsers = response.data || [] this.equityEligibleUsers = response.data || []
@ -573,7 +573,7 @@ window.app = Vue.createApp({
await LNbits.api.request( await LNbits.api.request(
'POST', 'POST',
'/castle/api/v1/admin/equity-eligibility', '/libra/api/v1/admin/equity-eligibility',
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
payload payload
) )
@ -612,7 +612,7 @@ window.app = Vue.createApp({
try { try {
await LNbits.api.request( await LNbits.api.request(
'DELETE', 'DELETE',
`/castle/api/v1/admin/equity-eligibility/${this.equityToRevoke.user_id}`, `/libra/api/v1/admin/equity-eligibility/${this.equityToRevoke.user_id}`,
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
@ -655,7 +655,7 @@ window.app = Vue.createApp({
try { try {
const response = await LNbits.api.request( const response = await LNbits.api.request(
'GET', 'GET',
'/castle/api/v1/admin/roles', '/libra/api/v1/admin/roles',
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
this.roles = response.data || [] this.roles = response.data || []
@ -678,7 +678,7 @@ window.app = Vue.createApp({
try { try {
const response = await LNbits.api.request( const response = await LNbits.api.request(
'GET', 'GET',
`/castle/api/v1/admin/roles/${role.id}`, `/libra/api/v1/admin/roles/${role.id}`,
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
@ -733,7 +733,7 @@ window.app = Vue.createApp({
// Update existing role // Update existing role
await LNbits.api.request( await LNbits.api.request(
'PUT', 'PUT',
`/castle/api/v1/admin/roles/${this.selectedRole.id}`, `/libra/api/v1/admin/roles/${this.selectedRole.id}`,
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
payload payload
) )
@ -747,7 +747,7 @@ window.app = Vue.createApp({
// Create new role // Create new role
await LNbits.api.request( await LNbits.api.request(
'POST', 'POST',
'/castle/api/v1/admin/roles', '/libra/api/v1/admin/roles',
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
payload payload
) )
@ -786,7 +786,7 @@ window.app = Vue.createApp({
try { try {
await LNbits.api.request( await LNbits.api.request(
'DELETE', 'DELETE',
`/castle/api/v1/admin/roles/${this.roleToDelete.id}`, `/libra/api/v1/admin/roles/${this.roleToDelete.id}`,
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
@ -862,7 +862,7 @@ window.app = Vue.createApp({
await LNbits.api.request( await LNbits.api.request(
'POST', 'POST',
'/castle/api/v1/admin/user-roles', '/libra/api/v1/admin/user-roles',
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
payload payload
) )
@ -920,7 +920,7 @@ window.app = Vue.createApp({
try { try {
const response = await LNbits.api.request( const response = await LNbits.api.request(
'GET', 'GET',
'/castle/api/v1/admin/users/roles', '/libra/api/v1/admin/users/roles',
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
@ -984,7 +984,7 @@ window.app = Vue.createApp({
try { try {
await LNbits.api.request( await LNbits.api.request(
'DELETE', 'DELETE',
`/castle/api/v1/admin/user-roles/${this.userRoleToRevoke.id}`, `/libra/api/v1/admin/user-roles/${this.userRoleToRevoke.id}`,
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
@ -1033,7 +1033,7 @@ window.app = Vue.createApp({
} }
await LNbits.api.request( await LNbits.api.request(
'POST', 'POST',
`/castle/api/v1/admin/roles/${this.selectedRole.id}/permissions`, `/libra/api/v1/admin/roles/${this.selectedRole.id}/permissions`,
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
payload payload
) )
@ -1067,7 +1067,7 @@ window.app = Vue.createApp({
try { try {
await LNbits.api.request( await LNbits.api.request(
'DELETE', 'DELETE',
`/castle/api/v1/admin/roles/${this.selectedRole.id}/permissions/${permissionId}`, `/libra/api/v1/admin/roles/${this.selectedRole.id}/permissions/${permissionId}`,
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey
) )
// Reload role permissions // Reload role permissions

View file

@ -1,5 +1,5 @@
""" """
Background tasks for Castle accounting extension. Background tasks for Libra accounting extension.
These tasks handle automated reconciliation checks and maintenance. These tasks handle automated reconciliation checks and maintenance.
""" """
@ -62,11 +62,11 @@ async def check_all_balance_assertions() -> dict:
# Log results # Log results
if results["failed"] > 0: if results["failed"] > 0:
print(f"[CASTLE] Daily reconciliation check: {results['failed']} FAILED assertions!") print(f"[LIBRA] Daily reconciliation check: {results['failed']} FAILED assertions!")
for failed in results["failed_assertions"]: for failed in results["failed_assertions"]:
print(f" - Account {failed['account_id']}: expected {failed['expected_sats']}, got {failed['actual_sats']}") print(f" - Account {failed['account_id']}: expected {failed['expected_sats']}, got {failed['actual_sats']}")
else: else:
print(f"[CASTLE] Daily reconciliation check: All {results['passed']} assertions passed ✓") print(f"[LIBRA] Daily reconciliation check: All {results['passed']} assertions passed ✓")
return results return results
@ -78,7 +78,7 @@ async def scheduled_daily_reconciliation():
This function is meant to be called by a scheduler (cron, systemd timer, etc.) This function is meant to be called by a scheduler (cron, systemd timer, etc.)
or by LNbits background task system. or by LNbits background task system.
""" """
print(f"[CASTLE] Running scheduled daily reconciliation check at {datetime.now()}") print(f"[LIBRA] Running scheduled daily reconciliation check at {datetime.now()}")
try: try:
results = await check_all_balance_assertions() results = await check_all_balance_assertions()
@ -86,38 +86,38 @@ async def scheduled_daily_reconciliation():
# TODO: Send notifications if there are failures # TODO: Send notifications if there are failures
# This could send email, webhook, or in-app notification # This could send email, webhook, or in-app notification
if results["failed"] > 0: if results["failed"] > 0:
print(f"[CASTLE] WARNING: {results['failed']} balance assertions failed!") print(f"[LIBRA] WARNING: {results['failed']} balance assertions failed!")
# Future: Send alert notification # Future: Send alert notification
return results return results
except Exception as e: except Exception as e:
print(f"[CASTLE] Error in scheduled reconciliation: {e}") print(f"[LIBRA] Error in scheduled reconciliation: {e}")
raise raise
async def scheduled_account_sync(): async def scheduled_account_sync():
""" """
Scheduled task that runs hourly to sync accounts from Beancount to Castle DB. Scheduled task that runs hourly to sync accounts from Beancount to Libra DB.
This ensures Castle DB stays in sync with Beancount (source of truth) by This ensures Libra DB stays in sync with Beancount (source of truth) by
automatically adding any new accounts created in Beancount to Castle's automatically adding any new accounts created in Beancount to Libra's
metadata database for permission tracking. metadata database for permission tracking.
""" """
from .account_sync import sync_accounts_from_beancount from .account_sync import sync_accounts_from_beancount
logger.info(f"[CASTLE] Running scheduled account sync at {datetime.now()}") logger.info(f"[LIBRA] Running scheduled account sync at {datetime.now()}")
try: try:
stats = await sync_accounts_from_beancount(force_full_sync=False) stats = await sync_accounts_from_beancount(force_full_sync=False)
if stats["accounts_added"] > 0: if stats["accounts_added"] > 0:
logger.info( logger.info(
f"[CASTLE] Account sync: Added {stats['accounts_added']} new accounts" f"[LIBRA] Account sync: Added {stats['accounts_added']} new accounts"
) )
if stats["errors"]: if stats["errors"]:
logger.warning( logger.warning(
f"[CASTLE] Account sync: {len(stats['errors'])} errors encountered" f"[LIBRA] Account sync: {len(stats['errors'])} errors encountered"
) )
for error in stats["errors"][:5]: # Log first 5 errors for error in stats["errors"][:5]: # Log first 5 errors
logger.error(f" - {error}") logger.error(f" - {error}")
@ -125,24 +125,24 @@ async def scheduled_account_sync():
return stats return stats
except Exception as e: except Exception as e:
logger.error(f"[CASTLE] Error in scheduled account sync: {e}") logger.error(f"[LIBRA] Error in scheduled account sync: {e}")
raise raise
async def wait_for_account_sync(): async def wait_for_account_sync():
""" """
Background task that periodically syncs accounts from Beancount to Castle DB. Background task that periodically syncs accounts from Beancount to Libra DB.
Runs hourly to ensure Castle DB stays in sync with Beancount. Runs hourly to ensure Libra DB stays in sync with Beancount.
""" """
logger.info("[CASTLE] Account sync background task started") logger.info("[LIBRA] Account sync background task started")
while True: while True:
try: try:
# Run sync # Run sync
await scheduled_account_sync() await scheduled_account_sync()
except Exception as e: except Exception as e:
logger.error(f"[CASTLE] Account sync error: {e}") logger.error(f"[LIBRA] Account sync error: {e}")
# Wait 1 hour before next sync # Wait 1 hour before next sync
await asyncio.sleep(3600) # 3600 seconds = 1 hour await asyncio.sleep(3600) # 3600 seconds = 1 hour
@ -157,9 +157,9 @@ def start_daily_reconciliation_task():
For cron setup: For cron setup:
# Run daily at 2 AM # Run daily at 2 AM
0 2 * * * curl -X POST http://localhost:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY" 0 2 * * * curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY"
""" """
print("[CASTLE] Daily reconciliation task registered") print("[LIBRA] Daily reconciliation task registered")
# In a production system, you would register this with LNbits task scheduler # In a production system, you would register this with LNbits task scheduler
# For now, it can be triggered manually via API endpoint # For now, it can be triggered manually via API endpoint
@ -173,7 +173,7 @@ async def wait_for_paid_invoices():
before the payment is detected by client-side polling. before the payment is detected by client-side polling.
""" """
invoice_queue = Queue() invoice_queue = Queue()
register_invoice_listener(invoice_queue, "ext_castle") register_invoice_listener(invoice_queue, "ext_libra")
while True: while True:
payment = await invoice_queue.get() payment = await invoice_queue.get()
@ -182,10 +182,10 @@ async def wait_for_paid_invoices():
async def on_invoice_paid(payment: Payment) -> None: async def on_invoice_paid(payment: Payment) -> None:
""" """
Handle a paid Castle invoice by automatically submitting to Fava. Handle a paid Libra invoice by automatically submitting to Fava.
This function is called automatically when any invoice on the Castle wallet This function is called automatically when any invoice on the Libra wallet
is paid. It checks if the invoice is a Castle payment and records it in is paid. It checks if the invoice is a Libra payment and records it in
Beancount via Fava. Beancount via Fava.
Concurrency Protection: Concurrency Protection:
@ -194,13 +194,13 @@ async def on_invoice_paid(payment: Payment) -> None:
- Uses idempotent entry creation to prevent duplicate entries even if - Uses idempotent entry creation to prevent duplicate entries even if
the same payment is processed multiple times the same payment is processed multiple times
""" """
# Only process Castle-specific payments # Only process Libra-specific payments
if not payment.extra or payment.extra.get("tag") != "castle": if not payment.extra or payment.extra.get("tag") != "libra":
return return
user_id = payment.extra.get("user_id") user_id = payment.extra.get("user_id")
if not user_id: if not user_id:
logger.warning(f"Castle invoice {payment.payment_hash} missing user_id in metadata") logger.warning(f"Libra invoice {payment.payment_hash} missing user_id in metadata")
return return
from .fava_client import get_fava_client from .fava_client import get_fava_client
@ -216,7 +216,7 @@ async def on_invoice_paid(payment: Payment) -> None:
user_lock = fava.get_user_lock(user_id) user_lock = fava.get_user_lock(user_id)
async with user_lock: async with user_lock:
logger.info(f"Recording Castle payment {payment.payment_hash} for user {user_id[:8]} to Fava") logger.info(f"Recording Libra payment {payment.payment_hash} for user {user_id[:8]} to Fava")
try: try:
from decimal import Decimal from decimal import Decimal
@ -246,14 +246,14 @@ async def on_invoice_paid(payment: Payment) -> None:
total_fiat_balance = fiat_balances.get(fiat_currency, Decimal(0)) total_fiat_balance = fiat_balances.get(fiat_currency, Decimal(0))
# Determine receivables and payables based on balance # Determine receivables and payables based on balance
# Positive balance = user owes castle (receivable) # Positive balance = user owes libra (receivable)
# Negative balance = castle owes user (payable) # Negative balance = libra owes user (payable)
if total_fiat_balance > 0: if total_fiat_balance > 0:
# User owes castle # User owes libra
total_receivable = total_fiat_balance total_receivable = total_fiat_balance
total_payable = Decimal(0) total_payable = Decimal(0)
else: else:
# Castle owes user # Libra owes user
total_receivable = Decimal(0) total_receivable = Decimal(0)
total_payable = abs(total_fiat_balance) total_payable = abs(total_fiat_balance)
@ -318,5 +318,5 @@ async def on_invoice_paid(payment: Payment) -> None:
) )
except Exception as e: except Exception as e:
logger.error(f"Error recording Castle payment {payment.payment_hash}: {e}") logger.error(f"Error recording Libra payment {payment.payment_hash}: {e}")
raise raise

View file

@ -3,7 +3,7 @@
{% block scripts %} {% block scripts %}
{{ window_vars(user) }} {{ window_vars(user) }}
<script src="{{ static_url_for('castle/static', path='js/index.js') }}"></script> <script src="{{ static_url_for('libra/static', path='js/index.js') }}"></script>
{% endblock %} {% endblock %}
{% block page %} {% block page %}
@ -13,7 +13,7 @@
<q-card-section> <q-card-section>
<div class="row items-center no-wrap"> <div class="row items-center no-wrap">
<div class="col"> <div class="col">
<h5 class="q-my-none">🏰 Castle Accounting</h5> <h5 class="q-my-none">Libra</h5>
<p class="q-mb-none">Track expenses, receivables, and balances for the collective</p> <p class="q-mb-none">Track expenses, receivables, and balances for the collective</p>
</div> </div>
<div class="col-auto q-gutter-xs"> <div class="col-auto q-gutter-xs">
@ -21,14 +21,14 @@
<q-btn v-if="settingsLoaded && !isSuperUser" flat round icon="account_balance_wallet" @click="showUserWalletDialog"> <q-btn v-if="settingsLoaded && !isSuperUser" flat round icon="account_balance_wallet" @click="showUserWalletDialog">
<q-tooltip>Configure Your Wallet</q-tooltip> <q-tooltip>Configure Your Wallet</q-tooltip>
</q-btn> </q-btn>
<q-btn v-if="settingsLoaded && isSuperUser" flat round icon="admin_panel_settings" :href="'/castle/permissions'"> <q-btn v-if="settingsLoaded && isSuperUser" flat round icon="admin_panel_settings" :href="'/libra/permissions'">
<q-tooltip>Manage Permissions (Admin)</q-tooltip> <q-tooltip>Manage Permissions (Admin)</q-tooltip>
</q-btn> </q-btn>
<q-btn v-if="settingsLoaded && isSuperUser" flat round icon="sync" :loading="syncingAccounts" @click="syncAccounts"> <q-btn v-if="settingsLoaded && isSuperUser" flat round icon="sync" :loading="syncingAccounts" @click="syncAccounts">
<q-tooltip>Sync Accounts from Beancount</q-tooltip> <q-tooltip>Sync Accounts from Beancount</q-tooltip>
</q-btn> </q-btn>
<q-btn v-if="settingsLoaded && isSuperUser" flat round icon="settings" @click="showSettingsDialog"> <q-btn v-if="settingsLoaded && isSuperUser" flat round icon="settings" @click="showSettingsDialog">
<q-tooltip>Castle Settings (Super User Only)</q-tooltip> <q-tooltip>Libra Settings (Super User Only)</q-tooltip>
</q-btn> </q-btn>
</div> </div>
</div> </div>
@ -36,19 +36,19 @@
</q-card> </q-card>
<!-- Setup Warning --> <!-- Setup Warning -->
<q-banner v-if="settingsLoaded && !castleWalletConfigured && isSuperUser" class="bg-warning text-white" rounded> <q-banner v-if="settingsLoaded && !libraWalletConfigured && isSuperUser" class="bg-warning text-white" rounded>
<template v-slot:avatar> <template v-slot:avatar>
<q-icon name="warning" color="white"></q-icon> <q-icon name="warning" color="white"></q-icon>
</template> </template>
<div> <div>
<strong>Setup Required:</strong> Castle Wallet ID must be configured before the extension can function. <strong>Setup Required:</strong> Libra Wallet ID must be configured before the extension can function.
</div> </div>
<template v-slot:action> <template v-slot:action>
<q-btn flat color="white" label="Configure Now" @click="showSettingsDialog"></q-btn> <q-btn flat color="white" label="Configure Now" @click="showSettingsDialog"></q-btn>
</template> </template>
</q-banner> </q-banner>
<q-banner v-if="settingsLoaded && !castleWalletConfigured && !isSuperUser" class="bg-info text-white" rounded> <q-banner v-if="settingsLoaded && !libraWalletConfigured && !isSuperUser" class="bg-info text-white" rounded>
<template v-slot:avatar> <template v-slot:avatar>
<q-icon name="info" color="white"></q-icon> <q-icon name="info" color="white"></q-icon>
</template> </template>
@ -57,7 +57,7 @@
</div> </div>
</q-banner> </q-banner>
<q-banner v-if="settingsLoaded && castleWalletConfigured && !userWalletConfigured && !isSuperUser" class="bg-orange text-white" rounded> <q-banner v-if="settingsLoaded && libraWalletConfigured && !userWalletConfigured && !isSuperUser" class="bg-orange text-white" rounded>
<template v-slot:avatar> <template v-slot:avatar>
<q-icon name="account_balance_wallet" color="white"></q-icon> <q-icon name="account_balance_wallet" color="white"></q-icon>
</template> </template>
@ -131,13 +131,13 @@
<q-btn <q-btn
color="primary" color="primary"
@click="expenseDialog.show = true" @click="expenseDialog.show = true"
:disable="!castleWalletConfigured || (!userWalletConfigured && !isSuperUser)" :disable="!libraWalletConfigured || (!userWalletConfigured && !isSuperUser)"
> >
Add Expense Add Expense
<q-tooltip v-if="!castleWalletConfigured"> <q-tooltip v-if="!libraWalletConfigured">
Castle wallet must be configured first Libra wallet must be configured first
</q-tooltip> </q-tooltip>
<q-tooltip v-if="castleWalletConfigured && !userWalletConfigured && !isSuperUser"> <q-tooltip v-if="libraWalletConfigured && !userWalletConfigured && !isSuperUser">
You must configure your wallet first You must configure your wallet first
</q-tooltip> </q-tooltip>
</q-btn> </q-btn>
@ -145,14 +145,14 @@
v-if="isSuperUser" v-if="isSuperUser"
color="orange" color="orange"
@click="showReceivableDialog" @click="showReceivableDialog"
:disable="!castleWalletConfigured" :disable="!libraWalletConfigured"
> >
Add Receivable Add Receivable
<q-tooltip v-if="!castleWalletConfigured"> <q-tooltip v-if="!libraWalletConfigured">
Castle wallet must be configured first Libra wallet must be configured first
</q-tooltip> </q-tooltip>
<q-tooltip v-else> <q-tooltip v-else>
Record when a user owes the Castle Record when a user owes the Libra
</q-tooltip> </q-tooltip>
</q-btn> </q-btn>
<q-btn color="secondary" @click="loadTransactions"> <q-btn color="secondary" @click="loadTransactions">
@ -201,7 +201,7 @@
</template> </template>
<template v-slot:body-cell-actions="props"> <template v-slot:body-cell-actions="props">
<q-td :props="props"> <q-td :props="props">
<!-- User owes Castle (positive balance) - Castle receives payment --> <!-- User owes Libra (positive balance) - Libra receives payment -->
<q-btn <q-btn
v-if="props.row.balance > 0" v-if="props.row.balance > 0"
flat flat
@ -211,9 +211,9 @@
icon="payments" icon="payments"
@click="showSettleReceivableDialog(props.row)" @click="showSettleReceivableDialog(props.row)"
> >
<q-tooltip>Settle receivable (user pays castle)</q-tooltip> <q-tooltip>Settle receivable (user pays libra)</q-tooltip>
</q-btn> </q-btn>
<!-- Castle owes User (negative balance) - Castle pays user --> <!-- Libra owes User (negative balance) - Libra pays user -->
<q-btn <q-btn
v-if="props.row.balance < 0" v-if="props.row.balance < 0"
flat flat
@ -223,7 +223,7 @@
icon="send" icon="send"
@click="showPayUserDialog(props.row)" @click="showPayUserDialog(props.row)"
> >
<q-tooltip>Pay user (castle pays user)</q-tooltip> <q-tooltip>Pay user (libra pays user)</q-tooltip>
</q-btn> </q-btn>
</q-td> </q-td>
</template> </template>
@ -257,7 +257,7 @@
{% raw %}{{ balance.balance > 0 ? 'Total owed to you' : balance.balance < 0 ? 'Total you owe' : 'No outstanding balances' }}{% endraw %} {% raw %}{{ balance.balance > 0 ? 'Total owed to you' : balance.balance < 0 ? 'Total you owe' : 'No outstanding balances' }}{% endraw %}
</div> </div>
<div class="text-subtitle2" v-else> <div class="text-subtitle2" v-else>
{% raw %}{{ balance.balance >= 0 ? 'You owe Castle' : 'Castle owes you' }}{% endraw %} {% raw %}{{ balance.balance >= 0 ? 'You owe Libra' : 'Libra owes you' }}{% endraw %}
</div> </div>
<div class="q-mt-md q-gutter-sm"> <div class="q-mt-md q-gutter-sm">
<q-btn <q-btn
@ -924,7 +924,7 @@
dense dense
v-model="expenseDialog.isEquity" v-model="expenseDialog.isEquity"
:options="[ :options="[
{label: 'Liability (Castle owes me)', value: false}, {label: 'Liability (Libra owes me)', value: false},
{label: 'Equity (My contribution)', value: true} {label: 'Equity (My contribution)', value: true}
]" ]"
option-label="label" option-label="label"
@ -932,7 +932,7 @@
emit-value emit-value
map-options map-options
label="Type *" label="Type *"
hint="Choose whether this is a liability (Castle owes you) or an equity contribution" hint="Choose whether this is a liability (Libra owes you) or an equity contribution"
></q-select> ></q-select>
<!-- If user is not equity eligible, force liability --> <!-- If user is not equity eligible, force liability -->
@ -941,9 +941,9 @@
filled filled
dense dense
readonly readonly
:model-value="'Liability (Castle owes me)'" :model-value="'Liability (Libra owes me)'"
label="Type" label="Type"
hint="This expense will be recorded as a liability (Castle owes you)" hint="This expense will be recorded as a liability (Libra owes you)"
> >
<template v-slot:prepend> <template v-slot:prepend>
<q-icon name="info" color="blue-grey-7"></q-icon> <q-icon name="info" color="blue-grey-7"></q-icon>
@ -1056,7 +1056,7 @@
<div class="text-h6 q-mb-md">Request Manual Payment</div> <div class="text-h6 q-mb-md">Request Manual Payment</div>
<div class="text-caption text-grey q-mb-md"> <div class="text-caption text-grey q-mb-md">
Request the Castle to pay you manually (cash, bank transfer, etc.) to settle your balance. Request the Libra to pay you manually (cash, bank transfer, etc.) to settle your balance.
</div> </div>
<div v-if="balance" class="q-mb-md"> <div v-if="balance" class="q-mb-md">
@ -1104,7 +1104,7 @@
<q-dialog v-model="settingsDialog.show" position="top"> <q-dialog v-model="settingsDialog.show" position="top">
<q-card v-if="settingsDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card"> <q-card v-if="settingsDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="submitSettings" class="q-gutter-md"> <q-form @submit="submitSettings" class="q-gutter-md">
<div class="text-h6 q-mb-md">Castle Settings</div> <div class="text-h6 q-mb-md">Libra Settings</div>
<q-banner v-if="!isSuperUser" class="bg-warning text-dark q-mb-md" dense rounded> <q-banner v-if="!isSuperUser" class="bg-warning text-dark q-mb-md" dense rounded>
<template v-slot:avatar> <template v-slot:avatar>
@ -1119,15 +1119,15 @@
filled filled
dense dense
emit-value emit-value
v-model="settingsDialog.castleWalletId" v-model="settingsDialog.libraWalletId"
:options="g.user.walletOptions" :options="g.user.walletOptions"
label="Castle Wallet *" label="Libra Wallet *"
:readonly="!isSuperUser" :readonly="!isSuperUser"
:disable="!isSuperUser" :disable="!isSuperUser"
></q-select> ></q-select>
<div class="text-caption text-grey q-mb-md"> <div class="text-caption text-grey q-mb-md">
Select the wallet that will be used for Castle operations and transactions. Select the wallet that will be used for Libra operations and transactions.
</div> </div>
<q-separator class="q-my-md"></q-separator> <q-separator class="q-my-md"></q-separator>
@ -1149,7 +1149,7 @@
dense dense
v-model="settingsDialog.favaLedgerSlug" v-model="settingsDialog.favaLedgerSlug"
label="Ledger Slug" label="Ledger Slug"
hint="Ledger identifier in Fava URL (e.g., castle-ledger)" hint="Ledger identifier in Fava URL (e.g., libra-ledger)"
:readonly="!isSuperUser" :readonly="!isSuperUser"
:disable="!isSuperUser" :disable="!isSuperUser"
></q-input> ></q-input>
@ -1173,7 +1173,7 @@
color="primary" color="primary"
type="submit" type="submit"
:loading="settingsDialog.loading" :loading="settingsDialog.loading"
:disable="!settingsDialog.castleWalletId" :disable="!settingsDialog.libraWalletId"
> >
Save Settings Save Settings
</q-btn> </q-btn>
@ -1201,7 +1201,7 @@
></q-select> ></q-select>
<div class="text-caption text-grey"> <div class="text-caption text-grey">
Select the wallet you'll use for Castle transactions. Select the wallet you'll use for Libra transactions.
</div> </div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
@ -1307,7 +1307,7 @@
<div class="text-caption text-grey q-mb-md"> <div class="text-caption text-grey q-mb-md">
Balance assertions are written to your Beancount ledger and validated automatically by Beancount. Balance assertions are written to your Beancount ledger and validated automatically by Beancount.
This verifies that an account's actual balance matches your expected balance at a specific date. This verifies that an account's actual balance matches your expected balance at a specific date.
If the assertion fails, Beancount will alert you to investigate the discrepancy. Castle stores If the assertion fails, Beancount will alert you to investigate the discrepancy. Libra stores
metadata (tolerance, notes) for your convenience. metadata (tolerance, notes) for your convenience.
</div> </div>
@ -1531,7 +1531,7 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
<!-- Pay User Dialog (Castle pays user - Super User Only) --> <!-- Pay User Dialog (Libra pays user - Super User Only) -->
<q-dialog v-model="payUserDialog.show" position="top"> <q-dialog v-model="payUserDialog.show" position="top">
<q-card class="q-pa-md" style="min-width: 400px"> <q-card class="q-pa-md" style="min-width: 400px">
<q-form @submit="submitPayUser"> <q-form @submit="submitPayUser">
@ -1544,7 +1544,7 @@
</div> </div>
<div class="q-mb-md"> <div class="q-mb-md">
<div class="text-subtitle2">Amount Castle Owes</div> <div class="text-subtitle2">Amount Libra Owes</div>
<div class="text-positive text-h6"> <div class="text-positive text-h6">
{% raw %}{{ formatSats(payUserDialog.maxAmount) }}{% endraw %} sats {% raw %}{{ formatSats(payUserDialog.maxAmount) }}{% endraw %} sats
</div> </div>
@ -1559,7 +1559,7 @@
v-model.number="payUserDialog.amount" v-model.number="payUserDialog.amount"
type="number" type="number"
:label="paymentAmountLabel" :label="paymentAmountLabel"
hint="Amount castle is paying (max: owed amount)" hint="Amount libra is paying (max: owed amount)"
:max="paymentMaxAmount" :max="paymentMaxAmount"
:step="paymentAmountStep" :step="paymentAmountStep"
:rules="[ :rules="[

View file

@ -4,7 +4,7 @@
{% block scripts %} {% block scripts %}
{{ window_vars(user) }} {{ window_vars(user) }}
<script src="{{ static_url_for('castle/static', path='js/permissions.js') }}"></script> <script src="{{ static_url_for('libra/static', path='js/permissions.js') }}"></script>
{% endblock %} {% endblock %}
{% block page %} {% block page %}

View file

@ -4,22 +4,22 @@ from lnbits.core.models import User
from lnbits.decorators import check_user_exists from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer from lnbits.helpers import template_renderer
castle_generic_router = APIRouter(tags=["castle"]) libra_generic_router = APIRouter(tags=["libra"])
@castle_generic_router.get( @libra_generic_router.get(
"/", description="Castle accounting home page", response_class=HTMLResponse "/", description="Libra accounting home page", response_class=HTMLResponse
) )
async def index( async def index(
request: Request, request: Request,
user: User = Depends(check_user_exists), user: User = Depends(check_user_exists),
): ):
return template_renderer(["castle/templates"]).TemplateResponse( return template_renderer(["libra/templates"]).TemplateResponse(
request, "castle/index.html", {"user": user.json()} request, "libra/index.html", {"user": user.json()}
) )
@castle_generic_router.get( @libra_generic_router.get(
"/permissions", "/permissions",
description="Permission management page", description="Permission management page",
response_class=HTMLResponse, response_class=HTMLResponse,
@ -28,6 +28,6 @@ async def permissions(
request: Request, request: Request,
user: User = Depends(check_user_exists), user: User = Depends(check_user_exists),
): ):
return template_renderer(["castle/templates"]).TemplateResponse( return template_renderer(["libra/templates"]).TemplateResponse(
request, "castle/permissions.html", {"user": user.json()} request, "libra/permissions.html", {"user": user.json()}
) )

File diff suppressed because it is too large Load diff