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:
parent
9c577c740c
commit
5988cd05d0
44 changed files with 953 additions and 953 deletions
78
CLAUDE.md
78
CLAUDE.md
|
|
@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||
|
||||
## Project Overview
|
||||
|
||||
Castle Accounting is a double-entry bookkeeping extension for LNbits that enables collectives (co-living spaces, makerspaces, community projects) to track finances with proper accounting principles. It integrates Lightning Network payments with traditional accounting, supporting both cryptocurrency and fiat currency tracking.
|
||||
Libra is a double-entry bookkeeping extension for LNbits that enables collectives (co-living spaces, makerspaces, community projects) to track finances with proper accounting principles. It integrates Lightning Network payments with traditional accounting, supporting both cryptocurrency and fiat currency tracking.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
|
@ -12,9 +12,9 @@ Castle Accounting is a double-entry bookkeeping extension for LNbits that enable
|
|||
|
||||
**Double-Entry Accounting**: Every transaction affects at least two accounts. Debits must equal credits. Five account types: Assets, Liabilities, Equity, Revenue (Income), Expenses.
|
||||
|
||||
**Fava/Beancount Backend**: Castle now uses [Fava](https://github.com/beancount/fava) as the primary accounting engine. Fava is a web interface for Beancount that provides a REST API for ledger operations. All accounting calculations (balance sheets, trial balances, account reports) are delegated to Fava/Beancount. Castle formats transactions as Beancount entries and submits them via Fava's API.
|
||||
**Fava/Beancount Backend**: Libra now uses [Fava](https://github.com/beancount/fava) as the primary accounting engine. Fava is a web interface for Beancount that provides a REST API for ledger operations. All accounting calculations (balance sheets, trial balances, account reports) are delegated to Fava/Beancount. Libra formats transactions as Beancount entries and submits them via Fava's API.
|
||||
|
||||
**Required External Dependency**: Fava must be running as a separate service. Configure `fava_url` and `fava_ledger_slug` in Castle settings (default: `http://localhost:3333` with slug `castle-accounting`). Castle will not function without Fava.
|
||||
**Required External Dependency**: Fava must be running as a separate service. Configure `fava_url` and `fava_ledger_slug` in Libra settings (default: `http://localhost:3333` with slug `libra-accounting`). Libra will not function without Fava.
|
||||
|
||||
**Pure Functional Core**: The `core/` directory contains pure accounting logic independent of the database layer:
|
||||
- `core/validation.py` - Entry validation rules
|
||||
|
|
@ -44,12 +44,12 @@ Castle Accounting is a double-entry bookkeeping extension for LNbits that enable
|
|||
- `tasks.py` - Background tasks (invoice payment monitoring)
|
||||
- `account_utils.py` - Hierarchical account naming utilities
|
||||
- `fava_client.py` - HTTP client for Fava REST API (add_entry, query, balance_sheet)
|
||||
- `beancount_format.py` - Converts Castle entries to Beancount transaction format
|
||||
- `beancount_format.py` - Converts Libra entries to Beancount transaction format
|
||||
- `core/validation.py` - Pure validation functions for accounting rules
|
||||
|
||||
### Database Schema
|
||||
|
||||
**Note**: With Fava integration, Castle maintains a local cache of some data but delegates authoritative balance calculations to Beancount/Fava.
|
||||
**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
|
||||
- `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.
|
||||
- Enriched with `username` field when retrieved via API (added from LNbits user data)
|
||||
|
||||
**extension_settings**: Castle wallet configuration (admin-only)
|
||||
- `castle_wallet_id` - The LNbits wallet used for Castle operations
|
||||
**extension_settings**: Libra wallet configuration (admin-only)
|
||||
- `libra_wallet_id` - The LNbits wallet used for Libra operations
|
||||
- `fava_url` - Fava service URL (default: http://localhost:3333)
|
||||
- `fava_ledger_slug` - Ledger identifier in Fava (default: castle-accounting)
|
||||
- `fava_ledger_slug` - Ledger identifier in Fava (default: libra-accounting)
|
||||
- `fava_timeout` - API request timeout in seconds
|
||||
|
||||
**user_wallet_settings**: Per-user wallet configuration
|
||||
|
|
@ -70,22 +70,22 @@ Castle Accounting is a double-entry bookkeeping extension for LNbits that enable
|
|||
## Transaction Flows
|
||||
|
||||
### User Adds Expense (Liability)
|
||||
User pays cash for groceries, Castle owes them:
|
||||
User pays cash for groceries, Libra owes them:
|
||||
```
|
||||
DR Expenses:Food 39,669 sats
|
||||
CR Liabilities:Payable:User-af983632 39,669 sats
|
||||
```
|
||||
Metadata preserves: `{"fiat_currency": "EUR", "fiat_amount": "36.93", "fiat_rate": "1074.192"}`
|
||||
|
||||
### Castle Adds Receivable
|
||||
User owes Castle for accommodation:
|
||||
### Libra Adds Receivable
|
||||
User owes Libra for accommodation:
|
||||
```
|
||||
DR Assets:Receivable:User-af983632 268,548 sats
|
||||
CR Income:Accommodation 268,548 sats
|
||||
```
|
||||
|
||||
### User Pays with Lightning
|
||||
Invoice generated on **Castle's wallet** (not user's). After payment:
|
||||
Invoice generated on **Libra's wallet** (not user's). After payment:
|
||||
```
|
||||
DR Assets:Lightning:Balance 268,548 sats
|
||||
CR Assets:Receivable:User-af983632 268,548 sats
|
||||
|
|
@ -101,14 +101,14 @@ DR Liabilities:Payable:User-af983632 39,669 sats
|
|||
## Balance Calculation Logic
|
||||
|
||||
**User Balance** (calculated by Beancount via Fava):
|
||||
- Positive = Castle owes user (LIABILITY accounts have credit balance)
|
||||
- Negative = User owes Castle (ASSET accounts have debit balance)
|
||||
- Positive = Libra owes user (LIABILITY accounts have credit balance)
|
||||
- Negative = User owes Libra (ASSET accounts have debit balance)
|
||||
- Calculated by querying Fava for sum of all postings across user's accounts
|
||||
- Fiat balances calculated by Beancount from cost basis annotations, NOT converted from current sats
|
||||
|
||||
**Perspective-Based UI**:
|
||||
- **User View**: Green = Castle owes them, Red = They owe Castle
|
||||
- **Castle Admin View**: Green = User owes Castle, Red = Castle owes user
|
||||
- **User View**: Green = Libra owes them, Red = They owe Libra
|
||||
- **Libra Admin View**: Green = User owes Libra, Red = Libra owes user
|
||||
|
||||
**Balance Retrieval**: Use `GET /api/v1/balance` which queries Fava's balance sheet or account reports for accurate, Beancount-calculated balances.
|
||||
|
||||
|
|
@ -127,12 +127,12 @@ DR Liabilities:Payable:User-af983632 39,669 sats
|
|||
- `POST /api/v1/entries` - Create raw journal entry (admin only)
|
||||
|
||||
### Payments & Balances
|
||||
- `GET /api/v1/balance` - Get user balance (or Castle total if super user)
|
||||
- `GET /api/v1/balance` - Get user balance (or Libra total if super user)
|
||||
- `GET /api/v1/balances/all` - Get all user balances (admin, enriched with usernames)
|
||||
- `POST /api/v1/generate-payment-invoice` - Generate invoice for user to pay Castle
|
||||
- `POST /api/v1/record-payment` - Record Lightning payment from user to Castle
|
||||
- `POST /api/v1/generate-payment-invoice` - Generate invoice for user to pay Libra
|
||||
- `POST /api/v1/record-payment` - Record Lightning payment from user to Libra
|
||||
- `POST /api/v1/settle-receivable` - Manually settle receivable (cash/bank)
|
||||
- `POST /api/v1/pay-user` - Castle pays user (cash/bank/lightning)
|
||||
- `POST /api/v1/pay-user` - Libra pays user (cash/bank/lightning)
|
||||
|
||||
### Manual Payment Requests
|
||||
- `POST /api/v1/manual-payment-requests` - User requests payment
|
||||
|
|
@ -148,8 +148,8 @@ DR Liabilities:Payable:User-af983632 39,669 sats
|
|||
- `POST /api/v1/tasks/daily-reconciliation` - Run daily reconciliation (admin)
|
||||
|
||||
### Settings
|
||||
- `GET /api/v1/settings` - Get Castle settings (super user)
|
||||
- `PUT /api/v1/settings` - Update Castle settings (super user)
|
||||
- `GET /api/v1/settings` - Get Libra settings (super user)
|
||||
- `PUT /api/v1/settings` - Update Libra settings (super user)
|
||||
- `GET /api/v1/user/wallet` - Get user wallet settings
|
||||
- `PUT /api/v1/user/wallet` - Update user wallet settings
|
||||
|
||||
|
|
@ -213,7 +213,7 @@ entry = format_transaction(
|
|||
{"account": "Liabilities:Payable:User-abc123", "amount": "-50000 SATS"}
|
||||
],
|
||||
tags=["groceries"],
|
||||
links=["castle-entry-123"]
|
||||
links=["libra-entry-123"]
|
||||
)
|
||||
|
||||
# Submit to Fava
|
||||
|
|
@ -241,15 +241,15 @@ balance_result = await client.query(
|
|||
### Extension as LNbits Module
|
||||
|
||||
This extension follows LNbits extension structure:
|
||||
- Registered via `castle_ext` router in `__init__.py`
|
||||
- Registered via `libra_ext` router in `__init__.py`
|
||||
- Static files served from `static/` directory
|
||||
- Templates in `templates/castle/`
|
||||
- Database accessed via `db = Database("ext_castle")`
|
||||
- Templates in `templates/libra/`
|
||||
- Database accessed via `db = Database("ext_libra")`
|
||||
|
||||
**Startup Requirements**:
|
||||
- `castle_start()` initializes Fava client on extension load
|
||||
- `libra_start()` initializes Fava client on extension load
|
||||
- Background task `wait_for_paid_invoices()` monitors Lightning invoice payments
|
||||
- Fava service MUST be running before starting LNbits with Castle extension
|
||||
- Fava service MUST be running before starting LNbits with Libra extension
|
||||
|
||||
## Common Tasks
|
||||
|
||||
|
|
@ -282,7 +282,7 @@ entry = format_transaction(
|
|||
{"account": "Assets:Lightning:Balance", "amount": "-50000 SATS"}
|
||||
],
|
||||
tags=["utilities"],
|
||||
links=["castle-tx-123"]
|
||||
links=["libra-tx-123"]
|
||||
)
|
||||
|
||||
client = get_fava_client()
|
||||
|
|
@ -337,24 +337,24 @@ result = await client.query(query)
|
|||
### Prerequisites
|
||||
|
||||
1. **LNbits**: This extension must be installed in the `lnbits/extensions/` directory
|
||||
2. **Fava Service**: Must be running before starting LNbits with Castle enabled
|
||||
2. **Fava Service**: Must be running before starting LNbits with Libra enabled
|
||||
```bash
|
||||
# Install Fava
|
||||
pip install fava
|
||||
|
||||
# Create a basic Beancount file
|
||||
touch castle-ledger.beancount
|
||||
touch libra-ledger.beancount
|
||||
|
||||
# Start Fava (default: http://localhost:3333)
|
||||
fava castle-ledger.beancount
|
||||
fava libra-ledger.beancount
|
||||
```
|
||||
3. **Configure Castle Settings**: Set `fava_url` and `fava_ledger_slug` via settings API or UI
|
||||
3. **Configure Libra Settings**: Set `fava_url` and `fava_ledger_slug` via settings API or UI
|
||||
|
||||
### Running Castle Extension
|
||||
### Running Libra Extension
|
||||
|
||||
Castle is loaded as part of LNbits. No separate build or test commands are needed for the extension itself. Development workflow:
|
||||
Libra is loaded as part of LNbits. No separate build or test commands are needed for the extension itself. Development workflow:
|
||||
|
||||
1. Modify code in `lnbits/extensions/castle/`
|
||||
1. Modify code in `lnbits/extensions/libra/`
|
||||
2. Restart LNbits
|
||||
3. Extension hot-reloads are supported by LNbits in development mode
|
||||
|
||||
|
|
@ -363,13 +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:
|
||||
|
||||
```bash
|
||||
# Create expense (user owes Castle)
|
||||
curl -X POST http://localhost:5000/castle/api/v1/entries/expense \
|
||||
# Create expense (user owes Libra)
|
||||
curl -X POST http://localhost:5000/libra/api/v1/entries/expense \
|
||||
-H "X-Api-Key: YOUR_INVOICE_KEY" \
|
||||
-d '{"description": "Test expense", "amount": "100.00 EUR", "account_name": "Expenses:Test"}'
|
||||
|
||||
# Check user balance
|
||||
curl http://localhost:5000/castle/api/v1/balance \
|
||||
curl http://localhost:5000/libra/api/v1/balance \
|
||||
-H "X-Api-Key: YOUR_INVOICE_KEY"
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
# Castle Migration Squash Summary
|
||||
# Libra Migration Squash Summary
|
||||
|
||||
**Date:** November 10, 2025
|
||||
**Action:** Squashed 16 incremental migrations into a single clean initial migration
|
||||
|
||||
## Overview
|
||||
|
||||
The Castle extension had accumulated 16 migrations (m001-m016) during development. Since the software has not been released yet, we safely squashed all migrations into a single clean `m001_initial` migration.
|
||||
The Libra extension had accumulated 16 migrations (m001-m016) during development. Since the software has not been released yet, we safely squashed all migrations into a single clean `m001_initial` migration.
|
||||
|
||||
## Files Changed
|
||||
|
||||
|
|
@ -16,37 +16,37 @@ The Castle extension had accumulated 16 migrations (m001-m016) during developmen
|
|||
|
||||
The squashed migration creates **7 tables**:
|
||||
|
||||
### 1. castle_accounts
|
||||
### 1. libra_accounts
|
||||
- Core chart of accounts with hierarchical Beancount-style names
|
||||
- Examples: "Assets:Bitcoin:Lightning", "Expenses:Food:Groceries"
|
||||
- User-specific accounts: "Assets:Receivable:User-af983632"
|
||||
- Includes comprehensive default account set (40+ accounts)
|
||||
|
||||
### 2. castle_extension_settings
|
||||
- Castle-wide configuration
|
||||
- Stores castle_wallet_id for Lightning payments
|
||||
### 2. libra_extension_settings
|
||||
- Libra-wide configuration
|
||||
- Stores libra_wallet_id for Lightning payments
|
||||
|
||||
### 3. castle_user_wallet_settings
|
||||
### 3. libra_user_wallet_settings
|
||||
- Per-user wallet configuration
|
||||
- Allows users to have separate wallet preferences
|
||||
|
||||
### 4. castle_manual_payment_requests
|
||||
- User-submitted payment requests to Castle
|
||||
### 4. libra_manual_payment_requests
|
||||
- User-submitted payment requests to Libra
|
||||
- Reviewed by admins before processing
|
||||
- Includes notes field for additional context
|
||||
|
||||
### 5. castle_balance_assertions
|
||||
### 5. libra_balance_assertions
|
||||
- Reconciliation and balance checking at specific dates
|
||||
- Multi-currency support (satoshis + fiat)
|
||||
- Tolerance checking for small discrepancies
|
||||
- Includes notes field for reconciliation comments
|
||||
|
||||
### 6. castle_user_equity_status
|
||||
### 6. libra_user_equity_status
|
||||
- Manages equity contribution eligibility
|
||||
- Equity-eligible users can convert expenses to equity
|
||||
- Creates dynamic user-specific equity accounts: Equity:User-{user_id}
|
||||
|
||||
### 7. castle_account_permissions
|
||||
### 7. libra_account_permissions
|
||||
- Granular access control for accounts
|
||||
- Permission types: read, submit_expense, manage
|
||||
- Supports hierarchical inheritance (parent permissions cascade)
|
||||
|
|
@ -56,10 +56,10 @@ The squashed migration creates **7 tables**:
|
|||
|
||||
The following tables were **intentionally NOT included** in the final schema (they were dropped in m016):
|
||||
|
||||
- **castle_journal_entries** - Journal entries now managed by Fava/Beancount (external source of truth)
|
||||
- **castle_entry_lines** - Entry lines now managed by Fava/Beancount
|
||||
- **libra_journal_entries** - Journal entries now managed by Fava/Beancount (external source of truth)
|
||||
- **libra_entry_lines** - Entry lines now managed by Fava/Beancount
|
||||
|
||||
Castle now uses Fava as the single source of truth for accounting data. Journal operations:
|
||||
Libra now uses Fava as the single source of truth for accounting data. Journal operations:
|
||||
- **Write:** Submit to Fava via FavaClient.add_entry()
|
||||
- **Read:** Query Fava via FavaClient.get_entries()
|
||||
|
||||
|
|
@ -106,7 +106,7 @@ For reference, the original migration sequence (preserved in migrations_old.py.b
|
|||
For new installations:
|
||||
|
||||
```bash
|
||||
# Castle's migration system will run m001_initial automatically
|
||||
# Libra's migration system will run m001_initial automatically
|
||||
# No manual intervention needed
|
||||
```
|
||||
|
||||
|
|
@ -174,20 +174,20 @@ After squashing, verify the migration works:
|
|||
|
||||
```bash
|
||||
# 1. Backup existing database (if any)
|
||||
cp castle.sqlite3 castle.sqlite3.backup
|
||||
cp libra.sqlite3 libra.sqlite3.backup
|
||||
|
||||
# 2. Drop and recreate database to test fresh install
|
||||
rm castle.sqlite3
|
||||
rm libra.sqlite3
|
||||
|
||||
# 3. Start LNbits - migration should run automatically
|
||||
poetry run lnbits
|
||||
|
||||
# 4. Verify tables created
|
||||
sqlite3 castle.sqlite3 ".tables"
|
||||
# Should show: castle_accounts, castle_extension_settings, etc.
|
||||
sqlite3 libra.sqlite3 ".tables"
|
||||
# Should show: libra_accounts, libra_extension_settings, etc.
|
||||
|
||||
# 5. Verify default accounts
|
||||
sqlite3 castle.sqlite3 "SELECT COUNT(*) FROM castle_accounts;"
|
||||
sqlite3 libra.sqlite3 "SELECT COUNT(*) FROM libra_accounts;"
|
||||
# Should show: 40 (default accounts)
|
||||
```
|
||||
|
||||
|
|
@ -200,12 +200,12 @@ If issues are discovered:
|
|||
cp migrations_old.py.bak migrations.py
|
||||
|
||||
# Restore database
|
||||
cp castle.sqlite3.backup castle.sqlite3
|
||||
cp libra.sqlite3.backup libra.sqlite3
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- This squash is safe because Castle has not been released yet
|
||||
- This squash is safe because Libra has not been released yet
|
||||
- No existing production databases need migration
|
||||
- Historical migrations preserved in migrations_old.py.bak
|
||||
- All functionality preserved in final schema
|
||||
|
|
|
|||
20
README.md
20
README.md
|
|
@ -1,10 +1,10 @@
|
|||
# Castle Accounting Extension for LNbits
|
||||
# Libra Extension for LNbits
|
||||
|
||||
A full-featured double-entry accounting system for collective projects, integrated with LNbits Lightning payments.
|
||||
|
||||
## Overview
|
||||
|
||||
Castle Accounting enables collectives like co-living spaces, makerspaces, and community projects to:
|
||||
Libra enables collectives like co-living spaces, makerspaces, and community projects to:
|
||||
- Track expenses and revenue with proper accounting
|
||||
- Manage individual member balances
|
||||
- Record contributions as equity or reimbursable expenses
|
||||
|
|
@ -17,7 +17,7 @@ This extension is designed to be installed in the `lnbits/extensions/` directory
|
|||
|
||||
```bash
|
||||
cd lnbits/extensions/
|
||||
# Copy or clone the castle directory here
|
||||
# Copy or clone the libra directory here
|
||||
```
|
||||
|
||||
Enable the extension through the LNbits admin interface or by adding it to your configuration.
|
||||
|
|
@ -30,7 +30,7 @@ Enable the extension through the LNbits admin interface or by adding it to your
|
|||
- Choose "Liability" if you want reimbursement
|
||||
- Choose "Equity" if it's a contribution
|
||||
|
||||
2. **View Your Balance**: See if the Castle owes you money or vice versa
|
||||
2. **View Your Balance**: See if the Libra owes you money or vice versa
|
||||
|
||||
3. **Pay Outstanding Balance**: Generate a Lightning invoice to settle what you owe
|
||||
|
||||
|
|
@ -54,8 +54,8 @@ Enable the extension through the LNbits admin interface or by adding it to your
|
|||
|
||||
### Account Types
|
||||
|
||||
- **Assets**: Things the Castle owns (Cash, Bank, Accounts Receivable)
|
||||
- **Liabilities**: What the Castle owes (Accounts Payable to members)
|
||||
- **Assets**: Things the Libra owns (Cash, Bank, Accounts Receivable)
|
||||
- **Liabilities**: What the Libra owes (Accounts Payable to members)
|
||||
- **Equity**: Member contributions and retained earnings
|
||||
- **Revenue**: Income streams
|
||||
- **Expenses**: Operating costs
|
||||
|
|
@ -63,9 +63,9 @@ Enable the extension through the LNbits admin interface or by adding it to your
|
|||
### Database Schema
|
||||
|
||||
The extension creates three tables:
|
||||
- `castle.accounts` - Chart of accounts
|
||||
- `castle.journal_entries` - Transaction headers
|
||||
- `castle.entry_lines` - Debit/credit lines
|
||||
- `libra.accounts` - Chart of accounts
|
||||
- `libra.journal_entries` - Transaction headers
|
||||
- `libra.entry_lines` - Debit/credit lines
|
||||
|
||||
## API Reference
|
||||
|
||||
|
|
@ -79,7 +79,7 @@ To modify this extension:
|
|||
2. Add database migrations in `migrations.py`
|
||||
3. Implement business logic in `crud.py`
|
||||
4. Create API endpoints in `views_api.py`
|
||||
5. Update UI in `templates/castle/index.html`
|
||||
5. Update UI in `templates/libra/index.html`
|
||||
|
||||
## Contributing
|
||||
|
||||
|
|
|
|||
46
__init__.py
46
__init__.py
|
|
@ -5,24 +5,24 @@ from loguru import logger
|
|||
|
||||
from .crud import db
|
||||
from .tasks import wait_for_paid_invoices
|
||||
from .views import castle_generic_router
|
||||
from .views_api import castle_api_router
|
||||
from .views import libra_generic_router
|
||||
from .views_api import libra_api_router
|
||||
|
||||
castle_ext: APIRouter = APIRouter(prefix="/castle", tags=["Castle"])
|
||||
castle_ext.include_router(castle_generic_router)
|
||||
castle_ext.include_router(castle_api_router)
|
||||
libra_ext: APIRouter = APIRouter(prefix="/libra", tags=["Libra"])
|
||||
libra_ext.include_router(libra_generic_router)
|
||||
libra_ext.include_router(libra_api_router)
|
||||
|
||||
castle_static_files = [
|
||||
libra_static_files = [
|
||||
{
|
||||
"path": "/castle/static",
|
||||
"name": "castle_static",
|
||||
"path": "/libra/static",
|
||||
"name": "libra_static",
|
||||
}
|
||||
]
|
||||
|
||||
scheduled_tasks: list[asyncio.Task] = []
|
||||
|
||||
|
||||
def castle_stop():
|
||||
def libra_stop():
|
||||
"""Clean up background tasks on extension shutdown"""
|
||||
for task in scheduled_tasks:
|
||||
try:
|
||||
|
|
@ -31,32 +31,32 @@ def castle_stop():
|
|||
logger.warning(ex)
|
||||
|
||||
|
||||
def castle_start():
|
||||
"""Initialize Castle extension background tasks"""
|
||||
def libra_start():
|
||||
"""Initialize Libra extension background tasks"""
|
||||
from lnbits.tasks import create_permanent_unique_task
|
||||
from .fava_client import init_fava_client
|
||||
from .models import CastleSettings
|
||||
from .models import LibraSettings
|
||||
from .tasks import wait_for_account_sync
|
||||
|
||||
async def _init_fava():
|
||||
"""Load saved settings from DB, fall back to defaults."""
|
||||
from .crud import db as castle_db
|
||||
from .crud import db as libra_db
|
||||
|
||||
settings = None
|
||||
try:
|
||||
row = await castle_db.fetchone(
|
||||
row = await libra_db.fetchone(
|
||||
"SELECT * FROM extension_settings LIMIT 1",
|
||||
model=CastleSettings,
|
||||
model=LibraSettings,
|
||||
)
|
||||
if row:
|
||||
settings = row
|
||||
logger.info(f"Loaded Castle settings from DB: {settings.fava_url}/{settings.fava_ledger_slug}")
|
||||
logger.info(f"Loaded Libra settings from DB: {settings.fava_url}/{settings.fava_ledger_slug}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load settings from DB: {e}")
|
||||
|
||||
if not settings:
|
||||
settings = CastleSettings()
|
||||
logger.info(f"Using default Castle settings: {settings.fava_url}/{settings.fava_ledger_slug}")
|
||||
settings = LibraSettings()
|
||||
logger.info(f"Using default Libra settings: {settings.fava_url}/{settings.fava_ledger_slug}")
|
||||
|
||||
init_fava_client(
|
||||
fava_url=settings.fava_url,
|
||||
|
|
@ -69,16 +69,16 @@ def castle_start():
|
|||
asyncio.get_event_loop().create_task(_init_fava())
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Fava client: {e}")
|
||||
logger.warning("Castle will not function without Fava. Please configure Fava settings.")
|
||||
logger.warning("Libra will not function without Fava. Please configure Fava settings.")
|
||||
|
||||
# Start background tasks
|
||||
task = create_permanent_unique_task("ext_castle", wait_for_paid_invoices)
|
||||
task = create_permanent_unique_task("ext_libra", wait_for_paid_invoices)
|
||||
scheduled_tasks.append(task)
|
||||
|
||||
# Start account sync task (runs hourly)
|
||||
sync_task = create_permanent_unique_task("ext_castle_account_sync", wait_for_account_sync)
|
||||
sync_task = create_permanent_unique_task("ext_libra_account_sync", wait_for_account_sync)
|
||||
scheduled_tasks.append(sync_task)
|
||||
logger.info("Castle account sync task started (runs hourly)")
|
||||
logger.info("Libra account sync task started (runs hourly)")
|
||||
|
||||
|
||||
__all__ = ["castle_ext", "castle_static_files", "db", "castle_start", "castle_stop"]
|
||||
__all__ = ["libra_ext", "libra_static_files", "db", "libra_start", "libra_stop"]
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
"""
|
||||
Account Synchronization Module
|
||||
|
||||
Syncs accounts from Beancount (source of truth) to Castle DB (metadata store).
|
||||
Syncs accounts from Beancount (source of truth) to Libra DB (metadata store).
|
||||
|
||||
This implements the hybrid approach:
|
||||
- Beancount owns account existence (Open directives)
|
||||
- Castle DB stores permissions and user associations
|
||||
- Libra DB stores permissions and user associations
|
||||
- Background sync keeps them in sync
|
||||
|
||||
Related: ACCOUNTS-TABLE-REMOVAL-FEASIBILITY.md - Phase 2 implementation
|
||||
|
|
@ -89,14 +89,14 @@ def extract_user_id_from_account_name(account_name: str) -> Optional[str]:
|
|||
|
||||
async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
|
||||
"""
|
||||
Sync accounts from Beancount to Castle DB.
|
||||
Sync accounts from Beancount to Libra DB.
|
||||
|
||||
This ensures Castle DB has metadata entries for all accounts that exist
|
||||
This ensures Libra DB has metadata entries for all accounts that exist
|
||||
in Beancount, enabling permissions and user associations to work properly.
|
||||
|
||||
New behavior (soft delete + virtual parents):
|
||||
- Accounts in Beancount but not in Castle DB: Added as active
|
||||
- Accounts in Castle DB but not in Beancount: Marked as inactive (soft delete)
|
||||
- Accounts in Beancount but not in Libra DB: Added as active
|
||||
- Accounts in Libra DB but not in Beancount: Marked as inactive (soft delete)
|
||||
- Inactive accounts that return to Beancount: Reactivated
|
||||
- Missing intermediate parents: Auto-created as virtual accounts
|
||||
|
||||
|
|
@ -113,7 +113,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
|
|||
dict with sync statistics:
|
||||
{
|
||||
"total_beancount_accounts": 150,
|
||||
"total_castle_accounts": 148,
|
||||
"total_libra_accounts": 148,
|
||||
"accounts_added": 2,
|
||||
"accounts_updated": 0,
|
||||
"accounts_skipped": 148,
|
||||
|
|
@ -123,7 +123,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
|
|||
"errors": []
|
||||
}
|
||||
"""
|
||||
logger.info("Starting account sync from Beancount to Castle DB")
|
||||
logger.info("Starting account sync from Beancount to Libra DB")
|
||||
|
||||
fava = get_fava_client()
|
||||
|
||||
|
|
@ -134,7 +134,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
|
|||
logger.error(f"Failed to fetch accounts from Beancount: {e}")
|
||||
return {
|
||||
"total_beancount_accounts": 0,
|
||||
"total_castle_accounts": 0,
|
||||
"total_libra_accounts": 0,
|
||||
"accounts_added": 0,
|
||||
"accounts_updated": 0,
|
||||
"accounts_skipped": 0,
|
||||
|
|
@ -143,16 +143,16 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
|
|||
"errors": [str(e)],
|
||||
}
|
||||
|
||||
# Get all accounts from Castle DB (including inactive ones for sync)
|
||||
castle_accounts = await get_all_accounts(include_inactive=True)
|
||||
# Get all accounts from Libra DB (including inactive ones for sync)
|
||||
libra_accounts = await get_all_accounts(include_inactive=True)
|
||||
|
||||
# Build lookup maps
|
||||
beancount_account_names = {acc["account"] for acc in beancount_accounts}
|
||||
castle_accounts_by_name = {acc.name: acc for acc in castle_accounts}
|
||||
libra_accounts_by_name = {acc.name: acc for acc in libra_accounts}
|
||||
|
||||
stats = {
|
||||
"total_beancount_accounts": len(beancount_accounts),
|
||||
"total_castle_accounts": len(castle_accounts),
|
||||
"total_libra_accounts": len(libra_accounts),
|
||||
"accounts_added": 0,
|
||||
"accounts_updated": 0,
|
||||
"accounts_skipped": 0,
|
||||
|
|
@ -162,15 +162,15 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
|
|||
"errors": [],
|
||||
}
|
||||
|
||||
# Step 1: Sync accounts from Beancount to Castle DB
|
||||
# Step 1: Sync accounts from Beancount to Libra DB
|
||||
for bc_account in beancount_accounts:
|
||||
account_name = bc_account["account"]
|
||||
|
||||
try:
|
||||
existing = castle_accounts_by_name.get(account_name)
|
||||
existing = libra_accounts_by_name.get(account_name)
|
||||
|
||||
if existing:
|
||||
# Account exists in Castle DB
|
||||
# Account exists in Libra DB
|
||||
# Check if it needs to be reactivated
|
||||
if not existing.is_active:
|
||||
await update_account_is_active(existing.id, True)
|
||||
|
|
@ -181,7 +181,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
|
|||
logger.debug(f"Account already active: {account_name}")
|
||||
continue
|
||||
|
||||
# Create new account in Castle DB
|
||||
# Create new account in Libra DB
|
||||
account_type = infer_account_type_from_name(account_name)
|
||||
user_id = extract_user_id_from_account_name(account_name)
|
||||
|
||||
|
|
@ -207,25 +207,25 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
|
|||
logger.error(error_msg)
|
||||
stats["errors"].append(error_msg)
|
||||
|
||||
# Step 2: Mark orphaned accounts (in Castle DB but not in Beancount) as inactive
|
||||
# Step 2: Mark orphaned accounts (in Libra DB but not in Beancount) as inactive
|
||||
# SKIP virtual accounts (they're intentionally metadata-only)
|
||||
for castle_account in castle_accounts:
|
||||
if castle_account.is_virtual:
|
||||
for libra_account in libra_accounts:
|
||||
if libra_account.is_virtual:
|
||||
# Virtual accounts are metadata-only, never deactivate them
|
||||
continue
|
||||
|
||||
if castle_account.name not in beancount_account_names:
|
||||
if libra_account.name not in beancount_account_names:
|
||||
# Account no longer exists in Beancount
|
||||
if castle_account.is_active:
|
||||
if libra_account.is_active:
|
||||
try:
|
||||
await update_account_is_active(castle_account.id, False)
|
||||
await update_account_is_active(libra_account.id, False)
|
||||
stats["accounts_deactivated"] += 1
|
||||
logger.info(
|
||||
f"Deactivated orphaned account: {castle_account.name}"
|
||||
f"Deactivated orphaned account: {libra_account.name}"
|
||||
)
|
||||
except Exception as e:
|
||||
error_msg = (
|
||||
f"Failed to deactivate account {castle_account.name}: {e}"
|
||||
f"Failed to deactivate account {libra_account.name}: {e}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
stats["errors"].append(error_msg)
|
||||
|
|
@ -236,8 +236,8 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
|
|||
|
||||
# IMPORTANT: Re-fetch accounts from DB after Step 1 added new accounts
|
||||
# Otherwise we'll be checking against stale data and miss newly synced children
|
||||
current_castle_accounts = await get_all_accounts(include_inactive=True)
|
||||
all_account_names = {acc.name for acc in current_castle_accounts}
|
||||
current_libra_accounts = await get_all_accounts(include_inactive=True)
|
||||
all_account_names = {acc.name for acc in current_libra_accounts}
|
||||
|
||||
for bc_account in beancount_accounts:
|
||||
account_name = bc_account["account"]
|
||||
|
|
@ -287,9 +287,9 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
|
|||
|
||||
async def sync_single_account_from_beancount(account_name: str) -> bool:
|
||||
"""
|
||||
Sync a single account from Beancount to Castle DB.
|
||||
Sync a single account from Beancount to Libra DB.
|
||||
|
||||
Useful for ensuring a specific account exists in Castle DB before
|
||||
Useful for ensuring a specific account exists in Libra DB before
|
||||
granting permissions on it.
|
||||
|
||||
Args:
|
||||
|
|
@ -318,7 +318,7 @@ async def sync_single_account_from_beancount(account_name: str) -> bool:
|
|||
logger.error(f"Account not found in Beancount: {account_name}")
|
||||
return False
|
||||
|
||||
# Create in Castle DB
|
||||
# Create in Libra DB
|
||||
account_type = infer_account_type_from_name(account_name)
|
||||
user_id = extract_user_id_from_account_name(account_name)
|
||||
|
||||
|
|
@ -343,9 +343,9 @@ async def sync_single_account_from_beancount(account_name: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
async def ensure_account_exists_in_castle(account_name: str) -> bool:
|
||||
async def ensure_account_exists_in_libra(account_name: str) -> bool:
|
||||
"""
|
||||
Ensure account exists in Castle DB, creating from Beancount if needed.
|
||||
Ensure account exists in Libra DB, creating from Beancount if needed.
|
||||
|
||||
This is the recommended function to call before granting permissions.
|
||||
|
||||
|
|
@ -355,7 +355,7 @@ async def ensure_account_exists_in_castle(account_name: str) -> bool:
|
|||
Returns:
|
||||
True if account exists (or was created), False if failed
|
||||
"""
|
||||
# Check Castle DB first
|
||||
# Check Libra DB first
|
||||
existing = await get_account_by_name(account_name)
|
||||
if existing:
|
||||
return True
|
||||
|
|
@ -367,9 +367,9 @@ async def ensure_account_exists_in_castle(account_name: str) -> bool:
|
|||
# Background sync task (can be scheduled with cron or async scheduler)
|
||||
async def scheduled_account_sync():
|
||||
"""
|
||||
Scheduled task to sync accounts from Beancount to Castle DB.
|
||||
Scheduled task to sync accounts from Beancount to Libra DB.
|
||||
|
||||
Run this periodically (e.g., every hour) to keep Castle DB in sync with Beancount.
|
||||
Run this periodically (e.g., every hour) to keep Libra DB in sync with Beancount.
|
||||
|
||||
Example with APScheduler:
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
|
|
|||
|
|
@ -200,11 +200,11 @@ DEFAULT_HIERARCHICAL_ACCOUNTS = [
|
|||
("Assets:FixedAssets:ProductionFacility", AccountType.ASSET, "Production facilities"),
|
||||
("Assets:Inventory", AccountType.ASSET, "Inventory and stock"),
|
||||
("Assets:Livestock", AccountType.ASSET, "Livestock and animals"),
|
||||
("Assets:Receivable", AccountType.ASSET, "Money owed to the Castle"),
|
||||
("Assets:Receivable", AccountType.ASSET, "Money owed to the Libra"),
|
||||
("Assets:Tools", AccountType.ASSET, "Tools and hand equipment"),
|
||||
|
||||
# Liabilities
|
||||
("Liabilities:Payable", AccountType.LIABILITY, "Money owed by the Castle"),
|
||||
("Liabilities:Payable", AccountType.LIABILITY, "Money owed by the Libra"),
|
||||
|
||||
# Equity - User equity accounts created dynamically as Equity:User-{user_id}
|
||||
# No parent "Equity" account needed - hierarchy is implicit in the name
|
||||
|
|
|
|||
8
auth.py
8
auth.py
|
|
@ -1,5 +1,5 @@
|
|||
"""
|
||||
Centralized Authorization Module for Castle Extension.
|
||||
Centralized Authorization Module for Libra Extension.
|
||||
|
||||
Provides consistent, secure authorization patterns across all endpoints.
|
||||
|
||||
|
|
@ -55,9 +55,9 @@ class AuthContext:
|
|||
@property
|
||||
def is_admin(self) -> bool:
|
||||
"""
|
||||
Check if user is a Castle admin (super user).
|
||||
Check if user is a Libra admin (super user).
|
||||
|
||||
Note: In Castle, admin = super_user. There's no separate admin concept.
|
||||
Note: In Libra, admin = super_user. There's no separate admin concept.
|
||||
"""
|
||||
return self.is_super_user
|
||||
|
||||
|
|
@ -130,7 +130,7 @@ async def require_super_user(
|
|||
Require super user access.
|
||||
|
||||
Raises HTTPException 403 if not super user.
|
||||
Use for Castle admin operations.
|
||||
Use for Libra admin operations.
|
||||
"""
|
||||
auth = _build_auth_context(wallet)
|
||||
if not auth.is_super_user:
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"""
|
||||
Format Castle entries as Beancount transactions for Fava API.
|
||||
Format Libra entries as Beancount transactions for Fava API.
|
||||
|
||||
All entries submitted to Fava must follow Beancount syntax.
|
||||
This module converts Castle data models to Fava API format.
|
||||
This module converts Libra data models to Fava API format.
|
||||
|
||||
Key concepts:
|
||||
- Amounts are strings: "200000 SATS" or "100.00 EUR"
|
||||
|
|
@ -35,8 +35,8 @@ def sanitize_link(text: str) -> str:
|
|||
'Test-pending'
|
||||
>>> sanitize_link("Invoice #123")
|
||||
'Invoice-123'
|
||||
>>> sanitize_link("castle-abc123")
|
||||
'castle-abc123'
|
||||
>>> sanitize_link("libra-abc123")
|
||||
'libra-abc123'
|
||||
"""
|
||||
# Replace any character that's not alphanumeric, dash, underscore, slash, or period with a hyphen
|
||||
sanitized = re.sub(r'[^A-Za-z0-9\-_/.]', '-', text)
|
||||
|
|
@ -67,7 +67,7 @@ def format_transaction(
|
|||
postings: List of posting dicts (formatted by format_posting)
|
||||
payee: Optional payee
|
||||
tags: Optional tags (e.g., ["expense-entry", "approved"])
|
||||
links: Optional links (e.g., ["castle-abc123", "^invoice-xyz"])
|
||||
links: Optional links (e.g., ["libra-abc123", "^invoice-xyz"])
|
||||
meta: Optional transaction metadata
|
||||
|
||||
Returns:
|
||||
|
|
@ -93,8 +93,8 @@ def format_transaction(
|
|||
)
|
||||
],
|
||||
tags=["expense-entry"],
|
||||
links=["castle-abc123"],
|
||||
meta={"user-id": "abc123", "source": "castle-expense-entry"}
|
||||
links=["libra-abc123"],
|
||||
meta={"user-id": "abc123", "source": "libra-expense-entry"}
|
||||
)
|
||||
"""
|
||||
return {
|
||||
|
|
@ -150,7 +150,7 @@ def format_posting_with_cost(
|
|||
"""
|
||||
Format a posting with cost basis for Fava API.
|
||||
|
||||
This is the RECOMMENDED format for all Castle transactions.
|
||||
This is the RECOMMENDED format for all Libra transactions.
|
||||
Uses Beancount's cost basis syntax to preserve exchange rates.
|
||||
|
||||
IMPORTANT: Beancount cost syntax uses PER-UNIT cost, not total cost.
|
||||
|
|
@ -381,7 +381,7 @@ def format_expense_entry(
|
|||
# Build entry metadata
|
||||
entry_meta = {
|
||||
"user-id": user_id,
|
||||
"source": "castle-api",
|
||||
"source": "libra-api",
|
||||
"entry-id": entry_id
|
||||
}
|
||||
|
||||
|
|
@ -419,7 +419,7 @@ def format_receivable_entry(
|
|||
entry_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Format a receivable entry (user owes castle).
|
||||
Format a receivable entry (user owes libra).
|
||||
|
||||
Uses price notation (@@ SATS) for BQL-queryable SATS tracking.
|
||||
Generates unique receivable link (^rcv-{entry_id}) for settlement tracking.
|
||||
|
|
@ -466,7 +466,7 @@ def format_receivable_entry(
|
|||
|
||||
entry_meta = {
|
||||
"user-id": user_id,
|
||||
"source": "castle-api",
|
||||
"source": "libra-api",
|
||||
"entry-id": entry_id
|
||||
}
|
||||
|
||||
|
|
@ -512,7 +512,7 @@ def format_payment_entry(
|
|||
amount_sats: Amount in satoshis (unsigned)
|
||||
description: Payment description
|
||||
entry_date: Date of payment
|
||||
is_payable: True if castle paying user (payable), False if user paying castle (receivable)
|
||||
is_payable: True if libra paying user (payable), False if user paying libra (receivable)
|
||||
fiat_currency: Optional fiat currency
|
||||
fiat_amount: Optional fiat amount (unsigned)
|
||||
payment_hash: Lightning payment hash
|
||||
|
|
@ -531,7 +531,7 @@ def format_payment_entry(
|
|||
# Example: 908.44 EUR / 996896 SATS = 0.000911268 EUR/SAT (matches original receivable rate)
|
||||
if fiat_currency and fiat_amount_abs and amount_sats_abs > 0:
|
||||
if is_payable:
|
||||
# Castle paying user: DR Payable, CR Lightning
|
||||
# Libra paying user: DR Payable, CR Lightning
|
||||
postings = [
|
||||
format_posting_with_cost(
|
||||
account=payable_or_receivable_account,
|
||||
|
|
@ -546,7 +546,7 @@ def format_payment_entry(
|
|||
)
|
||||
]
|
||||
else:
|
||||
# User paying castle: DR Lightning, CR Receivable
|
||||
# User paying libra: DR Lightning, CR Receivable
|
||||
postings = [
|
||||
format_posting_simple(
|
||||
account=payment_account,
|
||||
|
|
@ -633,7 +633,7 @@ def format_fiat_settlement_entry(
|
|||
amount_sats: Equivalent amount in satoshis
|
||||
description: Payment description
|
||||
entry_date: Date of settlement
|
||||
is_payable: True if castle paying user (payable), False if user paying castle (receivable)
|
||||
is_payable: True if libra paying user (payable), False if user paying libra (receivable)
|
||||
payment_method: Payment method (cash, bank_transfer, check, etc.)
|
||||
reference: Optional reference
|
||||
settled_entry_links: List of expense/receivable links being settled (e.g., ["exp-abc123", "exp-def456"])
|
||||
|
|
@ -646,7 +646,7 @@ def format_fiat_settlement_entry(
|
|||
|
||||
# Build postings using price notation (@@ SATS) for BQL queryability
|
||||
if is_payable:
|
||||
# Castle paying user: DR Payable, CR Cash/Bank
|
||||
# Libra paying user: DR Payable, CR Cash/Bank
|
||||
postings = [
|
||||
{
|
||||
"account": payable_or_receivable_account,
|
||||
|
|
@ -658,7 +658,7 @@ def format_fiat_settlement_entry(
|
|||
}
|
||||
]
|
||||
else:
|
||||
# User paying castle: DR Cash/Bank, CR Receivable
|
||||
# User paying libra: DR Cash/Bank, CR Receivable
|
||||
postings = [
|
||||
{
|
||||
"account": payment_account,
|
||||
|
|
@ -815,7 +815,7 @@ def format_revenue_entry(
|
|||
reference: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Format a revenue entry (castle receives payment directly).
|
||||
Format a revenue entry (libra receives payment directly).
|
||||
|
||||
Creates a cleared transaction (flag="*") since payment was received.
|
||||
|
||||
|
|
@ -869,7 +869,7 @@ def format_revenue_entry(
|
|||
|
||||
# Note: created-via is redundant with #revenue-entry tag
|
||||
entry_meta = {
|
||||
"source": "castle-api"
|
||||
"source": "libra-api"
|
||||
}
|
||||
|
||||
links = []
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"name": "Castle Accounting",
|
||||
"name": "Libra",
|
||||
"short_description": "Double-entry accounting system for collective projects",
|
||||
"tile": "/castle/static/image/castle.png",
|
||||
"tile": "/libra/static/image/libra.png",
|
||||
"contributors": [
|
||||
"Your Name"
|
||||
],
|
||||
"hidden": false,
|
||||
"migration_module": "lnbits.extensions.castle.migrations",
|
||||
"db_name": "ext_castle"
|
||||
"migration_module": "lnbits.extensions.libra.migrations",
|
||||
"db_name": "ext_libra"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"""
|
||||
Castle Core Module - Pure accounting logic separated from database operations.
|
||||
Libra Core Module - Pure accounting logic separated from database operations.
|
||||
|
||||
This module contains the core business logic for double-entry accounting,
|
||||
following Beancount patterns for clean architecture:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"""
|
||||
Validation rules for Castle accounting.
|
||||
Validation rules for Libra accounting.
|
||||
|
||||
Comprehensive validation following Beancount's plugin system approach,
|
||||
but implemented as simple functions that can be called directly.
|
||||
|
|
@ -159,7 +159,7 @@ def validate_receivable_entry(
|
|||
revenue_account_type: str
|
||||
) -> None:
|
||||
"""
|
||||
Validate a receivable entry (user owes castle).
|
||||
Validate a receivable entry (user owes libra).
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
|
|
|
|||
64
crud.py
64
crud.py
|
|
@ -14,7 +14,7 @@ from .models import (
|
|||
AssertionStatus,
|
||||
AssignUserRole,
|
||||
BalanceAssertion,
|
||||
CastleSettings,
|
||||
LibraSettings,
|
||||
CreateAccount,
|
||||
CreateAccountPermission,
|
||||
CreateBalanceAssertion,
|
||||
|
|
@ -32,7 +32,7 @@ from .models import (
|
|||
StoredUserWalletSettings,
|
||||
UpdateRole,
|
||||
UserBalance,
|
||||
UserCastleSettings,
|
||||
UserLibraSettings,
|
||||
UserEquityStatus,
|
||||
UserRole,
|
||||
UserWalletSettings,
|
||||
|
|
@ -49,7 +49,7 @@ from .core.validation import (
|
|||
validate_payment_entry,
|
||||
)
|
||||
|
||||
db = Database("ext_castle")
|
||||
db = Database("ext_libra")
|
||||
|
||||
# ===== CACHING =====
|
||||
# Cache for account and permission lookups to reduce DB queries
|
||||
|
|
@ -197,7 +197,7 @@ async def get_or_create_user_account(
|
|||
Get or create a user-specific account with hierarchical naming.
|
||||
|
||||
This function checks if the account exists in Fava/Beancount and creates it
|
||||
if it doesn't exist. The account is also registered in Castle's database for
|
||||
if it doesn't exist. The account is also registered in Libra's database for
|
||||
metadata tracking (permissions, descriptions, etc.).
|
||||
|
||||
Examples:
|
||||
|
|
@ -214,7 +214,7 @@ async def get_or_create_user_account(
|
|||
# Generate hierarchical account name
|
||||
account_name = format_hierarchical_account_name(account_type, base_name, user_id)
|
||||
|
||||
# Try to find existing account with this hierarchical name in Castle DB
|
||||
# Try to find existing account with this hierarchical name in Libra DB
|
||||
account = await db.fetchone(
|
||||
"""
|
||||
SELECT * FROM accounts
|
||||
|
|
@ -224,9 +224,9 @@ async def get_or_create_user_account(
|
|||
Account,
|
||||
)
|
||||
|
||||
logger.info(f"[ACCOUNT CHECK] User {user_id[:8]}, Account: {account_name}, In Castle DB: {account is not None}")
|
||||
logger.info(f"[ACCOUNT CHECK] User {user_id[:8]}, Account: {account_name}, In Libra DB: {account is not None}")
|
||||
|
||||
# Always check/create in Fava, even if account exists in Castle DB
|
||||
# Always check/create in Fava, even if account exists in Libra DB
|
||||
# This ensures Beancount has the Open directive
|
||||
fava_account_exists = False
|
||||
if True: # Always check Fava
|
||||
|
|
@ -262,23 +262,23 @@ async def get_or_create_user_account(
|
|||
|
||||
except Exception as e:
|
||||
logger.error(f"[FAVA ERROR] Could not check/create account in Fava: {e}", exc_info=True)
|
||||
# Continue anyway - account creation in Castle DB is still useful for metadata
|
||||
# Continue anyway - account creation in Libra DB is still useful for metadata
|
||||
|
||||
# Ensure account exists in Castle DB (sync from Beancount if needed)
|
||||
# Ensure account exists in Libra DB (sync from Beancount if needed)
|
||||
# This uses the account sync module for consistency
|
||||
if not account:
|
||||
logger.info(f"[CASTLE DB] Syncing account from Beancount to Castle DB: {account_name}")
|
||||
logger.info(f"[LIBRA DB] Syncing account from Beancount to Libra DB: {account_name}")
|
||||
from .account_sync import sync_single_account_from_beancount
|
||||
|
||||
# Sync from Beancount to Castle DB
|
||||
# Sync from Beancount to Libra DB
|
||||
created = await sync_single_account_from_beancount(account_name)
|
||||
|
||||
if created:
|
||||
logger.info(f"[CASTLE DB] Account synced from Beancount: {account_name}")
|
||||
logger.info(f"[LIBRA DB] Account synced from Beancount: {account_name}")
|
||||
else:
|
||||
logger.warning(f"[CASTLE DB] Failed to sync account from Beancount: {account_name}")
|
||||
logger.warning(f"[LIBRA DB] Failed to sync account from Beancount: {account_name}")
|
||||
|
||||
# Fetch the account from Castle DB
|
||||
# Fetch the account from Libra DB
|
||||
account = await db.fetchone(
|
||||
"""
|
||||
SELECT * FROM accounts
|
||||
|
|
@ -289,9 +289,9 @@ async def get_or_create_user_account(
|
|||
)
|
||||
|
||||
if not account:
|
||||
logger.error(f"[CASTLE DB] Account still not found after sync: {account_name}")
|
||||
# Fallback: create directly in Castle DB if sync failed
|
||||
logger.info(f"[CASTLE DB] Creating account directly in Castle DB: {account_name}")
|
||||
logger.error(f"[LIBRA DB] Account still not found after sync: {account_name}")
|
||||
# Fallback: create directly in Libra DB if sync failed
|
||||
logger.info(f"[LIBRA DB] Creating account directly in Libra DB: {account_name}")
|
||||
try:
|
||||
account = await create_account(
|
||||
CreateAccount(
|
||||
|
|
@ -304,7 +304,7 @@ async def get_or_create_user_account(
|
|||
except Exception as e:
|
||||
# Handle UNIQUE constraint error - account already exists
|
||||
if "UNIQUE constraint failed" in str(e) and "accounts.name" in str(e):
|
||||
logger.warning(f"[CASTLE DB] Account already exists (UNIQUE constraint), fetching by name: {account_name}")
|
||||
logger.warning(f"[LIBRA DB] Account already exists (UNIQUE constraint), fetching by name: {account_name}")
|
||||
# Fetch existing account by name only (ignore user_id in query)
|
||||
account = await db.fetchone(
|
||||
"""
|
||||
|
|
@ -315,10 +315,10 @@ async def get_or_create_user_account(
|
|||
Account,
|
||||
)
|
||||
if account:
|
||||
logger.info(f"[CASTLE DB] Found existing account: {account_name} (user_id: {account.user_id})")
|
||||
logger.info(f"[LIBRA DB] Found existing account: {account_name} (user_id: {account.user_id})")
|
||||
# Update user_id if it's NULL or different
|
||||
if account.user_id != user_id:
|
||||
logger.info(f"[CASTLE DB] Updating account user_id from {account.user_id} to {user_id}")
|
||||
logger.info(f"[LIBRA DB] Updating account user_id from {account.user_id} to {user_id}")
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE accounts
|
||||
|
|
@ -340,7 +340,7 @@ async def get_or_create_user_account(
|
|||
# Re-raise if it's a different error
|
||||
raise
|
||||
else:
|
||||
logger.info(f"[CASTLE DB] Account already exists in Castle DB: {account_name}")
|
||||
logger.info(f"[LIBRA DB] Account already exists in Libra DB: {account_name}")
|
||||
|
||||
return account
|
||||
|
||||
|
|
@ -351,7 +351,7 @@ async def get_or_create_user_account(
|
|||
# ===== JOURNAL ENTRY OPERATIONS (REMOVED) =====
|
||||
#
|
||||
# All journal entry operations have been moved to Fava/Beancount.
|
||||
# Castle no longer maintains its own journal_entries and entry_lines tables.
|
||||
# Libra no longer maintains its own journal_entries and entry_lines tables.
|
||||
#
|
||||
# For journal entry operations, see:
|
||||
# - views_api.py: api_create_journal_entry() - writes to Fava via FavaClient
|
||||
|
|
@ -375,29 +375,29 @@ async def get_or_create_user_account(
|
|||
# ===== SETTINGS =====
|
||||
|
||||
|
||||
async def create_castle_settings(
|
||||
user_id: str, data: CastleSettings
|
||||
) -> CastleSettings:
|
||||
settings = UserCastleSettings(**data.dict(), id=user_id)
|
||||
async def create_libra_settings(
|
||||
user_id: str, data: LibraSettings
|
||||
) -> LibraSettings:
|
||||
settings = UserLibraSettings(**data.dict(), id=user_id)
|
||||
await db.insert("extension_settings", settings)
|
||||
return settings
|
||||
|
||||
|
||||
async def get_castle_settings(user_id: str) -> Optional[CastleSettings]:
|
||||
async def get_libra_settings(user_id: str) -> Optional[LibraSettings]:
|
||||
return await db.fetchone(
|
||||
"""
|
||||
SELECT * FROM extension_settings
|
||||
WHERE id = :user_id
|
||||
""",
|
||||
{"user_id": user_id},
|
||||
CastleSettings,
|
||||
LibraSettings,
|
||||
)
|
||||
|
||||
|
||||
async def update_castle_settings(
|
||||
user_id: str, data: CastleSettings
|
||||
) -> CastleSettings:
|
||||
settings = UserCastleSettings(**data.dict(), id=user_id)
|
||||
async def update_libra_settings(
|
||||
user_id: str, data: LibraSettings
|
||||
) -> LibraSettings:
|
||||
settings = UserLibraSettings(**data.dict(), id=user_id)
|
||||
await db.update("extension_settings", settings)
|
||||
return settings
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Castle Accounting
|
||||
# Libra
|
||||
|
||||
A comprehensive double-entry accounting system for collective projects, designed specifically for LNbits.
|
||||
|
||||
|
|
@ -7,29 +7,29 @@ A comprehensive double-entry accounting system for collective projects, designed
|
|||
- **Double-Entry Bookkeeping**: Full accounting system with debits and credits
|
||||
- **Chart of Accounts**: Pre-configured accounts for Assets, Liabilities, Equity, Revenue, and Expenses
|
||||
- **User Expense Tracking**: Members can record out-of-pocket expenses as either:
|
||||
- **Liabilities**: Castle owes them money (reimbursable)
|
||||
- **Liabilities**: Libra owes them money (reimbursable)
|
||||
- **Equity**: Their contribution to the collective
|
||||
- **Accounts Receivable**: Track what users owe the Castle (e.g., accommodation fees)
|
||||
- **Accounts Receivable**: Track what users owe the Libra (e.g., accommodation fees)
|
||||
- **Revenue Tracking**: Record revenue received by the collective
|
||||
- **User Balance Dashboard**: Each user sees their balance with the Castle
|
||||
- **User Balance Dashboard**: Each user sees their balance with the Libra
|
||||
- **Lightning Integration**: Generate invoices for outstanding balances
|
||||
- **Transaction History**: View all accounting entries and transactions
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 1. User Pays Expense Out of Pocket
|
||||
When a member buys supplies for the Castle:
|
||||
When a member buys supplies for the Libra:
|
||||
- They can choose to be reimbursed (Liability)
|
||||
- Or contribute it as equity (Equity)
|
||||
|
||||
### 2. Accounts Receivable
|
||||
When someone stays at the Castle and owes money:
|
||||
When someone stays at the Libra and owes money:
|
||||
- Admin creates an AR entry (e.g., "5 nights @ 10€/night = 50€")
|
||||
- User sees they owe 50€ in their dashboard
|
||||
- They can generate an invoice to pay it off
|
||||
|
||||
### 3. Revenue Recording
|
||||
When the Castle receives revenue:
|
||||
When the Libra receives revenue:
|
||||
- Record revenue with the payment method (Cash, Lightning, Bank)
|
||||
- Properly categorized in the accounting system
|
||||
|
||||
|
|
@ -58,8 +58,8 @@ When the Castle receives revenue:
|
|||
|
||||
## Getting Started
|
||||
|
||||
1. Enable the Castle extension in LNbits
|
||||
2. Visit the Castle page to see your dashboard
|
||||
1. Enable the Libra extension in LNbits
|
||||
2. Visit the Libra page to see your dashboard
|
||||
3. Start tracking expenses and balances!
|
||||
|
||||
The extension automatically creates a default chart of accounts on first run.
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@
|
|||
|
||||
## Summary
|
||||
|
||||
Implemented two major improvements for Castle administration:
|
||||
Implemented two major improvements for Libra administration:
|
||||
|
||||
1. **Account Synchronization** - Automatically sync accounts from Beancount → Castle DB
|
||||
1. **Account Synchronization** - Automatically sync accounts from Beancount → Libra DB
|
||||
2. **Bulk Permission Management** - Tools for managing permissions at scale
|
||||
|
||||
**Total Implementation Time**: ~4 hours
|
||||
|
|
@ -23,24 +23,24 @@ Implemented two major improvements for Castle administration:
|
|||
|
||||
### Problem Solved
|
||||
|
||||
**Before**: Accounts existed in both Beancount and Castle DB, with manual sync required.
|
||||
**After**: Automatic sync keeps Castle DB in sync with Beancount (source of truth).
|
||||
**Before**: Accounts existed in both Beancount and Libra DB, with manual sync required.
|
||||
**After**: Automatic sync keeps Libra DB in sync with Beancount (source of truth).
|
||||
|
||||
### Implementation
|
||||
|
||||
**New Module**: `castle/account_sync.py`
|
||||
**New Module**: `libra/account_sync.py`
|
||||
|
||||
**Core Functions**:
|
||||
|
||||
```python
|
||||
# 1. Full sync from Beancount to Castle
|
||||
# 1. Full sync from Beancount to Libra
|
||||
stats = await sync_accounts_from_beancount(force_full_sync=False)
|
||||
|
||||
# 2. Sync single account
|
||||
success = await sync_single_account_from_beancount("Expenses:Food")
|
||||
|
||||
# 3. Ensure account exists (recommended before granting permissions)
|
||||
exists = await ensure_account_exists_in_castle("Expenses:Marketing")
|
||||
exists = await ensure_account_exists_in_libra("Expenses:Marketing")
|
||||
|
||||
# 4. Scheduled background sync (run hourly)
|
||||
stats = await scheduled_account_sync()
|
||||
|
|
@ -77,7 +77,7 @@ stats = await scheduled_account_sync()
|
|||
|
||||
```python
|
||||
# Sync all accounts from Beancount
|
||||
from castle.account_sync import sync_accounts_from_beancount
|
||||
from libra.account_sync import sync_accounts_from_beancount
|
||||
|
||||
stats = await sync_accounts_from_beancount()
|
||||
|
||||
|
|
@ -96,11 +96,11 @@ Errors: 0
|
|||
#### Before Granting Permission (Best Practice)
|
||||
|
||||
```python
|
||||
from castle.account_sync import ensure_account_exists_in_castle
|
||||
from castle.crud import create_account_permission
|
||||
from libra.account_sync import ensure_account_exists_in_libra
|
||||
from libra.crud import create_account_permission
|
||||
|
||||
# Ensure account exists in Castle DB first
|
||||
account_exists = await ensure_account_exists_in_castle("Expenses:Marketing")
|
||||
# Ensure account exists in Libra DB first
|
||||
account_exists = await ensure_account_exists_in_libra("Expenses:Marketing")
|
||||
|
||||
if account_exists:
|
||||
# Now safe to grant permission
|
||||
|
|
@ -116,9 +116,9 @@ if account_exists:
|
|||
|
||||
```python
|
||||
# Add to your scheduler (cron, APScheduler, etc.)
|
||||
from castle.account_sync import scheduled_account_sync
|
||||
from libra.account_sync import scheduled_account_sync
|
||||
|
||||
# Run every hour to keep Castle DB in sync
|
||||
# Run every hour to keep Libra DB in sync
|
||||
scheduler.add_job(
|
||||
scheduled_account_sync,
|
||||
'interval',
|
||||
|
|
@ -142,7 +142,7 @@ Authorization: Bearer {admin_key}
|
|||
```json
|
||||
{
|
||||
"total_beancount_accounts": 150,
|
||||
"total_castle_accounts": 150,
|
||||
"total_libra_accounts": 150,
|
||||
"accounts_added": 2,
|
||||
"accounts_updated": 0,
|
||||
"accounts_skipped": 148,
|
||||
|
|
@ -152,8 +152,8 @@ Authorization: Bearer {admin_key}
|
|||
|
||||
### Benefits
|
||||
|
||||
1. **Beancount as Source of Truth**: Castle DB automatically reflects Beancount state
|
||||
2. **Reduced Manual Work**: No more manual account creation in Castle
|
||||
1. **Beancount as Source of Truth**: Libra DB automatically reflects Beancount state
|
||||
2. **Reduced Manual Work**: No more manual account creation in Libra
|
||||
3. **Prevents Permission Errors**: Cannot grant permission on non-existent account
|
||||
4. **Audit Trail**: Tracks which accounts were synced and when
|
||||
5. **Safe Operations**: Continues on errors, never deletes accounts
|
||||
|
|
@ -169,7 +169,7 @@ Authorization: Bearer {admin_key}
|
|||
|
||||
### Implementation
|
||||
|
||||
**New Module**: `castle/permission_management.py`
|
||||
**New Module**: `libra/permission_management.py`
|
||||
|
||||
**Core Functions**:
|
||||
|
||||
|
|
@ -471,19 +471,19 @@ print(f"Permission types removed: {result['permission_types_removed']}")
|
|||
# OLD: Manual permission creation (risky)
|
||||
await create_account_permission(
|
||||
user_id="alice",
|
||||
account_id="acc123", # What if account doesn't exist in Castle DB?
|
||||
account_id="acc123", # What if account doesn't exist in Libra DB?
|
||||
permission_type=PermissionType.SUBMIT_EXPENSE,
|
||||
granted_by="admin"
|
||||
)
|
||||
|
||||
# NEW: Safe permission creation with account sync
|
||||
from castle.account_sync import ensure_account_exists_in_castle
|
||||
from libra.account_sync import ensure_account_exists_in_libra
|
||||
|
||||
# Ensure account exists first
|
||||
account_exists = await ensure_account_exists_in_castle("Expenses:Marketing")
|
||||
account_exists = await ensure_account_exists_in_libra("Expenses:Marketing")
|
||||
|
||||
if account_exists:
|
||||
# Now safe - account guaranteed to be in Castle DB
|
||||
# Now safe - account guaranteed to be in Libra DB
|
||||
await create_account_permission(
|
||||
user_id="alice",
|
||||
account_id=account_id,
|
||||
|
|
@ -497,10 +497,10 @@ else:
|
|||
### Scheduler Integration
|
||||
|
||||
```python
|
||||
# Add to your Castle extension startup
|
||||
# Add to your Libra extension startup
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from castle.account_sync import scheduled_account_sync
|
||||
from castle.permission_management import cleanup_expired_permissions
|
||||
from libra.account_sync import scheduled_account_sync
|
||||
from libra.permission_management import cleanup_expired_permissions
|
||||
|
||||
scheduler = AsyncIOScheduler()
|
||||
|
||||
|
|
@ -610,7 +610,7 @@ async def test_copy_permissions():
|
|||
async def test_onboarding_workflow():
|
||||
"""Test complete onboarding workflow"""
|
||||
# 1. Sync account
|
||||
await ensure_account_exists_in_castle("Expenses:Food")
|
||||
await ensure_account_exists_in_libra("Expenses:Food")
|
||||
|
||||
# 2. Copy permissions from template user
|
||||
result = await copy_permissions(
|
||||
|
|
@ -745,19 +745,19 @@ logger.error(f"Account sync error: {error}")
|
|||
|
||||
## Migration Guide
|
||||
|
||||
### For Existing Castle Installations
|
||||
### For Existing Libra Installations
|
||||
|
||||
**Step 1: Deploy New Modules**
|
||||
```bash
|
||||
# Copy new files to Castle extension
|
||||
cp account_sync.py /path/to/castle/
|
||||
cp permission_management.py /path/to/castle/
|
||||
# Copy new files to Libra extension
|
||||
cp account_sync.py /path/to/libra/
|
||||
cp permission_management.py /path/to/libra/
|
||||
```
|
||||
|
||||
**Step 2: Initial Account Sync**
|
||||
```python
|
||||
# Run once to sync existing accounts
|
||||
from castle.account_sync import sync_accounts_from_beancount
|
||||
from libra.account_sync import sync_accounts_from_beancount
|
||||
|
||||
stats = await sync_accounts_from_beancount(force_full_sync=True)
|
||||
print(f"Synced {stats['accounts_added']} accounts")
|
||||
|
|
@ -784,14 +784,14 @@ await bulk_grant_permission(...)
|
|||
## Documentation Updates
|
||||
|
||||
**New files created**:
|
||||
- ✅ `castle/account_sync.py` (230 lines)
|
||||
- ✅ `castle/permission_management.py` (400 lines)
|
||||
- ✅ `libra/account_sync.py` (230 lines)
|
||||
- ✅ `libra/permission_management.py` (400 lines)
|
||||
- ✅ `docs/PERMISSIONS-SYSTEM.md` (full permission system docs)
|
||||
- ✅ `docs/ACCOUNT-SYNC-AND-PERMISSION-IMPROVEMENTS.md` (this file)
|
||||
|
||||
**Files to update**:
|
||||
- `castle/views_api.py` - Add new admin endpoints
|
||||
- `castle/README.md` - Document new features
|
||||
- `libra/views_api.py` - Add new admin endpoints
|
||||
- `libra/README.md` - Document new features
|
||||
- `tests/` - Add comprehensive tests
|
||||
|
||||
---
|
||||
|
|
@ -801,7 +801,7 @@ await bulk_grant_permission(...)
|
|||
### What Was Built
|
||||
|
||||
1. **Account Sync Module** (230 lines)
|
||||
- Automatic sync from Beancount → Castle DB
|
||||
- Automatic sync from Beancount → Libra DB
|
||||
- Type inference and user ID extraction
|
||||
- Background scheduling support
|
||||
|
||||
|
|
|
|||
|
|
@ -195,13 +195,13 @@ id="toc-professional-assessment">Professional Assessment</a></li>
|
|||
<h1 id="accounting-analysis-net-settlement-entry-pattern">Accounting
|
||||
Analysis: Net Settlement Entry Pattern</h1>
|
||||
<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
|
||||
Review</p>
|
||||
<hr />
|
||||
<h2 id="executive-summary">Executive Summary</h2>
|
||||
<p>This document provides a professional accounting assessment of
|
||||
Castle’s net settlement entry pattern used for recording Lightning
|
||||
Libra’s net settlement entry pattern used for recording Lightning
|
||||
Network payments that settle fiat-denominated receivables. The analysis
|
||||
identifies areas where the implementation deviates from traditional
|
||||
accounting best practices and provides specific recommendations for
|
||||
|
|
@ -214,7 +214,7 @@ hierarchy</p>
|
|||
<hr />
|
||||
<h2 id="background-the-technical-challenge">Background: The Technical
|
||||
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
|
||||
accounting challenge:</p>
|
||||
<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
|
||||
exact EUR receivable amount 2. Recording the exact satoshi amount
|
||||
received 3. Handling cases where users have both receivables (owe
|
||||
Castle) and payables (Castle owes them) 4. Maintaining Beancount
|
||||
Libra) and payables (Libra owes them) 4. Maintaining Beancount
|
||||
double-entry balance</p>
|
||||
<hr />
|
||||
<h2 id="current-implementation">Current Implementation</h2>
|
||||
|
|
@ -231,7 +231,7 @@ double-entry balance</p>
|
|||
<pre class="beancount"><code>; Step 1: Receivable Created
|
||||
2025-11-12 * "room (200.00 EUR)" #receivable-entry
|
||||
user-id: "375ec158"
|
||||
source: "castle-api"
|
||||
source: "libra-api"
|
||||
sats-amount: "225033"
|
||||
Assets:Receivable:User-375ec158 200.00 EUR
|
||||
sats-equivalent: "225033"
|
||||
|
|
@ -344,7 +344,7 @@ class="sourceCode sql"><code class="sourceCode sql"><span id="cb8-1"><a href="#c
|
|||
payment-hash: "8d080ec4..."
|
||||
Assets:Receivable:User-375ec158 -200.00 EUR
|
||||
; No sats-equivalent needed here</code></pre>
|
||||
<p><strong>Option B - Use EUR positions with metadata</strong> (Castle’s
|
||||
<p><strong>Option B - Use EUR positions with metadata</strong> (Libra’s
|
||||
current approach):</p>
|
||||
<pre class="beancount"><code>Assets:Bitcoin:Lightning 200.00 EUR
|
||||
sats-received: "225033"
|
||||
|
|
@ -452,8 +452,8 @@ OR payable)</li>
|
|||
(receivable AND payable)</li>
|
||||
</ul>
|
||||
<p><strong>When Net Settlement is Appropriate</strong>:</p>
|
||||
<pre><code>User owes Castle: 555.00 EUR (receivable)
|
||||
Castle owes User: 38.00 EUR (payable)
|
||||
<pre><code>User owes Libra: 555.00 EUR (receivable)
|
||||
Libra owes User: 38.00 EUR (payable)
|
||||
Net amount due: 517.00 EUR (true settlement)</code></pre>
|
||||
<p>Proper three-posting entry:</p>
|
||||
<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
|
||||
; Net: 517.00 = -555.00 + 38.00 ✓</code></pre>
|
||||
<p><strong>When Two Postings Suffice</strong>:</p>
|
||||
<pre><code>User owes Castle: 200.00 EUR (receivable)
|
||||
Castle owes User: 0.00 EUR (no payable)
|
||||
<pre><code>User owes Libra: 200.00 EUR (receivable)
|
||||
Libra owes User: 0.00 EUR (no payable)
|
||||
Amount due: 200.00 EUR (simple payment)</code></pre>
|
||||
<p>Simpler two-posting entry:</p>
|
||||
<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
|
||||
3: True Net Settlement (When Both Obligations Exist)</h3>
|
||||
<pre class="beancount"><code>2025-11-12 * "Net settlement via Lightning"
|
||||
; User owes 555 EUR, Castle owes 38 EUR, net: 517 EUR
|
||||
; User owes 555 EUR, Libra owes 38 EUR, net: 517 EUR
|
||||
Assets:Bitcoin:Lightning 517.00 EUR
|
||||
sats-received: "565251"
|
||||
Assets:Receivable:User-375ec158 -555.00 EUR
|
||||
|
|
@ -570,7 +570,7 @@ Method</h4>
|
|||
<p><strong>Decision Required</strong>: Select either position-based OR
|
||||
metadata-based satoshi tracking.</p>
|
||||
<p><strong>Option A - Keep Metadata Approach</strong> (recommended for
|
||||
Castle):</p>
|
||||
Libra):</p>
|
||||
<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>
|
||||
<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-13"><a href="#cb26-13" aria-hidden="true" tabindex="-1"></a>]</span></code></pre></div>
|
||||
<p><strong>Recommendation</strong>: Choose Option A (metadata) for
|
||||
consistency with Castle’s architecture.</p>
|
||||
consistency with Libra’s architecture.</p>
|
||||
<hr />
|
||||
<h4 id="rename-function-for-clarity">1.3 Rename Function for
|
||||
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-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-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>
|
||||
<hr />
|
||||
<h3 id="priority-3-long-term-architectural-decisions">Priority 3:
|
||||
|
|
@ -742,7 +742,7 @@ architectures:</p>
|
|||
<p><strong>Recommendation</strong>: Architecture A (EUR primary)
|
||||
because: 1. Most receivables created in EUR 2. Financial reporting
|
||||
requirements typically in fiat 3. Tax obligations calculated in fiat 4.
|
||||
Aligns with current Castle metadata approach</p>
|
||||
Aligns with current Libra metadata approach</p>
|
||||
<hr />
|
||||
<h4 id="consider-separate-ledger-for-cryptocurrency-holdings">3.2
|
||||
Consider Separate Ledger for Cryptocurrency Holdings</h4>
|
||||
|
|
@ -754,7 +754,7 @@ from fiat accounting</p>
|
|||
Assets:Receivable:User-375ec158 -200.00 EUR</code></pre>
|
||||
<p><strong>Cryptocurrency Sub-Ledger</strong> (SATS-denominated):</p>
|
||||
<pre class="beancount"><code>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</code></pre>
|
||||
<p><strong>Benefits</strong>: - ✅ Clean separation of concerns - ✅
|
||||
Cryptocurrency movements tracked independently - ✅ Fiat accounting
|
||||
|
|
@ -902,7 +902,7 @@ Entry balances</p>
|
|||
<p><strong>Is this “best practice” accounting?</strong>
|
||||
<strong>No</strong>, this implementation deviates from traditional
|
||||
accounting standards in several ways.</p>
|
||||
<p><strong>Is it acceptable for Castle’s use case?</strong> <strong>Yes,
|
||||
<p><strong>Is it acceptable for Libra’s use case?</strong> <strong>Yes,
|
||||
with modifications</strong>, it’s a reasonable pragmatic solution for a
|
||||
novel problem (cryptocurrency payments of fiat debts).</p>
|
||||
<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
|
||||
wasn’t designed for this scenario. There is no established “standard”
|
||||
for recording cryptocurrency payments of fiat-denominated receivables.
|
||||
Castle’s approach is functional, but should be refined to align better
|
||||
Libra’s approach is functional, but should be refined to align better
|
||||
with accounting principles where possible.</p>
|
||||
<h3 id="next-steps">Next Steps</h3>
|
||||
<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>Beancount Documentation</strong>:
|
||||
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>
|
||||
<li><strong>BQL Analysis</strong>:
|
||||
<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
|
||||
planning. It represents a professional accounting assessment of the
|
||||
current implementation and should be used to guide improvements to
|
||||
Castle’s payment recording system.</em></p>
|
||||
Libra’s payment recording system.</em></p>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
|
||||
**Date**: 2025-01-12
|
||||
**Prepared By**: Senior Accounting Review
|
||||
**Subject**: Castle Extension - Lightning Payment Settlement Entries
|
||||
**Subject**: Libra Extension - Lightning Payment Settlement Entries
|
||||
**Status**: Technical Review
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document provides a professional accounting assessment of Castle's net settlement entry pattern used for recording Lightning Network payments that settle fiat-denominated receivables. The analysis identifies areas where the implementation deviates from traditional accounting best practices and provides specific recommendations for improvement.
|
||||
This document provides a professional accounting assessment of Libra's net settlement entry pattern used for recording Lightning Network payments that settle fiat-denominated receivables. The analysis identifies areas where the implementation deviates from traditional accounting best practices and provides specific recommendations for improvement.
|
||||
|
||||
**Key Findings**:
|
||||
- ✅ Double-entry integrity maintained
|
||||
|
|
@ -23,14 +23,14 @@ This document provides a professional accounting assessment of Castle's net sett
|
|||
|
||||
## Background: The Technical Challenge
|
||||
|
||||
Castle operates as a Lightning Network-integrated accounting system for collectives (co-living spaces, makerspaces). It faces a unique accounting challenge:
|
||||
Libra operates as a Lightning Network-integrated accounting system for collectives (co-living spaces, makerspaces). It faces a unique accounting challenge:
|
||||
|
||||
**Scenario**: User creates a receivable in EUR (e.g., €200 for room rent), then pays via Lightning Network in satoshis (225,033 sats).
|
||||
|
||||
**Challenge**: Record the payment while:
|
||||
1. Clearing the exact EUR receivable amount
|
||||
2. Recording the exact satoshi amount received
|
||||
3. Handling cases where users have both receivables (owe Castle) and payables (Castle owes them)
|
||||
3. Handling cases where users have both receivables (owe Libra) and payables (Libra owes them)
|
||||
4. Maintaining Beancount double-entry balance
|
||||
|
||||
---
|
||||
|
|
@ -43,7 +43,7 @@ Castle operates as a Lightning Network-integrated accounting system for collecti
|
|||
; Step 1: Receivable Created
|
||||
2025-11-12 * "room (200.00 EUR)" #receivable-entry
|
||||
user-id: "375ec158"
|
||||
source: "castle-api"
|
||||
source: "libra-api"
|
||||
sats-amount: "225033"
|
||||
Assets:Receivable:User-375ec158 200.00 EUR
|
||||
sats-equivalent: "225033"
|
||||
|
|
@ -187,7 +187,7 @@ Assets:Receivable:User-375ec158 -200.00 EUR
|
|||
; No sats-equivalent needed here
|
||||
```
|
||||
|
||||
**Option B - Use EUR positions with metadata** (Castle's current approach):
|
||||
**Option B - Use EUR positions with metadata** (Libra's current approach):
|
||||
```beancount
|
||||
Assets:Bitcoin:Lightning 200.00 EUR
|
||||
sats-received: "225033"
|
||||
|
|
@ -314,8 +314,8 @@ Assets:Receivable:User-375ec158 -200.00 EUR
|
|||
**When Net Settlement is Appropriate**:
|
||||
|
||||
```
|
||||
User owes Castle: 555.00 EUR (receivable)
|
||||
Castle owes User: 38.00 EUR (payable)
|
||||
User owes Libra: 555.00 EUR (receivable)
|
||||
Libra owes User: 38.00 EUR (payable)
|
||||
Net amount due: 517.00 EUR (true settlement)
|
||||
```
|
||||
|
||||
|
|
@ -330,8 +330,8 @@ Liabilities:Payable:User 38.00 EUR
|
|||
**When Two Postings Suffice**:
|
||||
|
||||
```
|
||||
User owes Castle: 200.00 EUR (receivable)
|
||||
Castle owes User: 0.00 EUR (no payable)
|
||||
User owes Libra: 200.00 EUR (receivable)
|
||||
Libra owes User: 0.00 EUR (no payable)
|
||||
Amount due: 200.00 EUR (simple payment)
|
||||
```
|
||||
|
||||
|
|
@ -405,7 +405,7 @@ Assets:Receivable:User -200.00 EUR
|
|||
|
||||
```beancount
|
||||
2025-11-12 * "Net settlement via Lightning"
|
||||
; User owes 555 EUR, Castle owes 38 EUR, net: 517 EUR
|
||||
; User owes 555 EUR, Libra owes 38 EUR, net: 517 EUR
|
||||
Assets:Bitcoin:Lightning 517.00 EUR
|
||||
sats-received: "565251"
|
||||
Assets:Receivable:User-375ec158 -555.00 EUR
|
||||
|
|
@ -469,7 +469,7 @@ if total_payable_fiat > 0:
|
|||
|
||||
**Decision Required**: Select either position-based OR metadata-based satoshi tracking.
|
||||
|
||||
**Option A - Keep Metadata Approach** (recommended for Castle):
|
||||
**Option A - Keep Metadata Approach** (recommended for Libra):
|
||||
```python
|
||||
# In format_net_settlement_entry()
|
||||
postings = [
|
||||
|
|
@ -506,7 +506,7 @@ postings = [
|
|||
]
|
||||
```
|
||||
|
||||
**Recommendation**: Choose Option A (metadata) for consistency with Castle's architecture.
|
||||
**Recommendation**: Choose Option A (metadata) for consistency with Libra's architecture.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -625,7 +625,7 @@ async def create_payment_entry(
|
|||
payment_hash=payment_hash
|
||||
)
|
||||
else:
|
||||
# PAYABLE PAYMENT: Castle paying user (different flow)
|
||||
# PAYABLE PAYMENT: Libra paying user (different flow)
|
||||
return await format_payable_payment_entry(...)
|
||||
```
|
||||
|
||||
|
|
@ -663,7 +663,7 @@ async def create_payment_entry(
|
|||
1. Most receivables created in EUR
|
||||
2. Financial reporting requirements typically in fiat
|
||||
3. Tax obligations calculated in fiat
|
||||
4. Aligns with current Castle metadata approach
|
||||
4. Aligns with current Libra metadata approach
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -681,7 +681,7 @@ async def create_payment_entry(
|
|||
**Cryptocurrency Sub-Ledger** (SATS-denominated):
|
||||
```beancount
|
||||
2025-11-12 * "Lightning payment received"
|
||||
Assets:Bitcoin:Lightning:Castle 225033 SATS
|
||||
Assets:Bitcoin:Lightning:Libra 225033 SATS
|
||||
Assets:Bitcoin:Custody:User-375ec 225033 SATS
|
||||
```
|
||||
|
||||
|
|
@ -821,7 +821,7 @@ async def create_payment_entry(
|
|||
**Is this "best practice" accounting?**
|
||||
**No**, this implementation deviates from traditional accounting standards in several ways.
|
||||
|
||||
**Is it acceptable for Castle's use case?**
|
||||
**Is it acceptable for Libra's use case?**
|
||||
**Yes, with modifications**, it's a reasonable pragmatic solution for a novel problem (cryptocurrency payments of fiat debts).
|
||||
|
||||
**Critical improvements needed**:
|
||||
|
|
@ -829,7 +829,7 @@ async def create_payment_entry(
|
|||
2. ✅ Implement exchange gain/loss tracking (required for compliance)
|
||||
3. ✅ Separate payment vs. settlement logic (accuracy and clarity)
|
||||
|
||||
**The fundamental challenge**: Traditional accounting wasn't designed for this scenario. There is no established "standard" for recording cryptocurrency payments of fiat-denominated receivables. Castle's approach is functional, but should be refined to align better with accounting principles where possible.
|
||||
**The fundamental challenge**: Traditional accounting wasn't designed for this scenario. There is no established "standard" for recording cryptocurrency payments of fiat-denominated receivables. Libra's approach is functional, but should be refined to align better with accounting principles where possible.
|
||||
|
||||
### Next Steps
|
||||
|
||||
|
|
@ -847,7 +847,7 @@ async def create_payment_entry(
|
|||
- **FASB Concept Statement No. 2**: Qualitative Characteristics of Accounting Information
|
||||
- **ASC 105-10-05**: Substance Over Form
|
||||
- **Beancount Documentation**: http://furius.ca/beancount/doc/index
|
||||
- **Castle Extension**: `docs/SATS-EQUIVALENT-METADATA.md`
|
||||
- **Libra Extension**: `docs/SATS-EQUIVALENT-METADATA.md`
|
||||
- **BQL Analysis**: `docs/BQL-BALANCE-QUERIES.md`
|
||||
|
||||
---
|
||||
|
|
@ -858,4 +858,4 @@ async def create_payment_entry(
|
|||
|
||||
---
|
||||
|
||||
*This analysis was prepared for internal review and development planning. It represents a professional accounting assessment of the current implementation and should be used to guide improvements to Castle's payment recording system.*
|
||||
*This analysis was prepared for internal review and development planning. It represents a professional accounting assessment of the current implementation and should be used to guide improvements to Libra's payment recording system.*
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Beancount Patterns Analysis for Castle Extension
|
||||
# Beancount Patterns Analysis for Libra Extension
|
||||
|
||||
## Overview
|
||||
|
||||
After analyzing the [Beancount repository](https://github.com/beancount/beancount), I've identified several excellent design patterns and architectural decisions that we should adopt or consider for the Castle Accounting extension.
|
||||
After analyzing the [Beancount repository](https://github.com/beancount/beancount), I've identified several excellent design patterns and architectural decisions that we should adopt or consider for the Libra extension.
|
||||
|
||||
## Key Patterns to Adopt
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ class Posting(NamedTuple):
|
|||
- More memory efficient than regular classes
|
||||
- Thread-safe by design
|
||||
|
||||
**Castle Application:**
|
||||
**Libra Application:**
|
||||
```python
|
||||
# In models.py
|
||||
from typing import NamedTuple, Optional
|
||||
|
|
@ -109,15 +109,15 @@ def validate_commodity_directives(entries, options_map, config):
|
|||
return entries, errors
|
||||
```
|
||||
|
||||
**Castle Application:**
|
||||
**Libra Application:**
|
||||
```python
|
||||
# Create plugins/ directory
|
||||
# lnbits/extensions/castle/plugins/__init__.py
|
||||
# lnbits/extensions/libra/plugins/__init__.py
|
||||
|
||||
from typing import Protocol, Tuple, List, Any
|
||||
|
||||
class CastlePlugin(Protocol):
|
||||
"""Protocol for Castle plugins"""
|
||||
class LibraPlugin(Protocol):
|
||||
"""Protocol for Libra plugins"""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
|
|
@ -130,7 +130,7 @@ class CastlePlugin(Protocol):
|
|||
|
||||
Args:
|
||||
entries: Journal entries to process
|
||||
settings: Castle settings
|
||||
settings: Libra settings
|
||||
config: Plugin-specific configuration
|
||||
|
||||
Returns:
|
||||
|
|
@ -212,7 +212,7 @@ class PluginManager:
|
|||
if plugin_file.name.startswith('_'):
|
||||
continue
|
||||
|
||||
module_name = f"castle.plugins.{plugin_file.stem}"
|
||||
module_name = f"libra.plugins.{plugin_file.stem}"
|
||||
module = importlib.import_module(module_name)
|
||||
|
||||
if hasattr(module, '__plugins__'):
|
||||
|
|
@ -276,7 +276,7 @@ class Inventory(dict[tuple[str, Optional[Cost]], Position]):
|
|||
)
|
||||
```
|
||||
|
||||
**Castle Application:**
|
||||
**Libra Application:**
|
||||
```python
|
||||
# core/inventory.py
|
||||
from decimal import Decimal
|
||||
|
|
@ -284,8 +284,8 @@ from typing import Optional, Dict, Tuple
|
|||
from dataclasses import dataclass
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CastlePosition:
|
||||
"""A position in the Castle inventory"""
|
||||
class LibraPosition:
|
||||
"""A position in the Libra inventory"""
|
||||
currency: str # "SATS", "EUR", "USD"
|
||||
amount: Decimal
|
||||
cost_currency: Optional[str] = None # Original currency if converted
|
||||
|
|
@ -293,22 +293,22 @@ class CastlePosition:
|
|||
date: Optional[datetime] = None
|
||||
metadata: Dict[str, Any] = None
|
||||
|
||||
class CastleInventory:
|
||||
class LibraInventory:
|
||||
"""
|
||||
Track user balances across multiple currencies with conversion tracking.
|
||||
Similar to Beancount's Inventory but optimized for Castle's use case.
|
||||
Similar to Beancount's Inventory but optimized for Libra's use case.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.positions: Dict[Tuple[str, Optional[str]], CastlePosition] = {}
|
||||
self.positions: Dict[Tuple[str, Optional[str]], LibraPosition] = {}
|
||||
|
||||
def add_position(self, position: CastlePosition):
|
||||
def add_position(self, position: LibraPosition):
|
||||
"""Add or merge a position"""
|
||||
key = (position.currency, position.cost_currency)
|
||||
|
||||
if key in self.positions:
|
||||
existing = self.positions[key]
|
||||
self.positions[key] = CastlePosition(
|
||||
self.positions[key] = LibraPosition(
|
||||
currency=position.currency,
|
||||
amount=existing.amount + position.amount,
|
||||
cost_currency=position.cost_currency,
|
||||
|
|
@ -353,9 +353,9 @@ class CastleInventory:
|
|||
}
|
||||
|
||||
# Usage in balance calculation:
|
||||
async def get_user_inventory(user_id: str) -> CastleInventory:
|
||||
async def get_user_inventory(user_id: str) -> LibraInventory:
|
||||
"""Calculate user's inventory from journal entries"""
|
||||
inventory = CastleInventory()
|
||||
inventory = LibraInventory()
|
||||
|
||||
user_accounts = await get_user_accounts(user_id)
|
||||
for account in user_accounts:
|
||||
|
|
@ -369,7 +369,7 @@ async def get_user_inventory(user_id: str) -> CastleInventory:
|
|||
# Beancount-style: positive = debit, negative = credit
|
||||
# Adjust sign for cost amount based on amount direction
|
||||
cost_sign = 1 if line.amount > 0 else -1
|
||||
inventory.add_position(CastlePosition(
|
||||
inventory.add_position(LibraPosition(
|
||||
currency="SATS",
|
||||
amount=Decimal(line.amount),
|
||||
cost_currency=metadata.get("fiat_currency"),
|
||||
|
|
@ -397,7 +397,7 @@ Every directive has a `meta: dict[str, Any]` attribute that stores:
|
|||
- `lineno`: Line number
|
||||
- Custom metadata like tags, links, notes
|
||||
|
||||
**Castle Application:**
|
||||
**Libra Application:**
|
||||
```python
|
||||
class JournalEntryMeta(BaseModel):
|
||||
"""Metadata for journal entries"""
|
||||
|
|
@ -447,7 +447,7 @@ entry = await create_journal_entry(
|
|||
|
||||
This asserts that the account balance should be exactly 268,548 sats on that date. If it's not, Beancount throws an error.
|
||||
|
||||
**Castle Application:**
|
||||
**Libra Application:**
|
||||
```python
|
||||
# models.py
|
||||
class BalanceAssertion(BaseModel):
|
||||
|
|
@ -464,7 +464,7 @@ class BalanceAssertion(BaseModel):
|
|||
created_at: datetime
|
||||
|
||||
# API endpoint
|
||||
@castle_api_router.post("/api/v1/assertions/balance")
|
||||
@libra_api_router.post("/api/v1/assertions/balance")
|
||||
async def create_balance_assertion(
|
||||
data: CreateBalanceAssertion,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
|
|
@ -554,7 +554,7 @@ Liabilities:US:CreditCard:Amex
|
|||
|
||||
Accounts are organized hierarchically with `:` separator.
|
||||
|
||||
**Castle Application:**
|
||||
**Libra Application:**
|
||||
```python
|
||||
# Currently: "Accounts Receivable - af983632"
|
||||
# Better: "Assets:Receivable:User-af983632"
|
||||
|
|
@ -617,7 +617,7 @@ def format_account_name(
|
|||
|
||||
Flags: `*` = cleared, `!` = pending, `#` = flagged for review
|
||||
|
||||
**Castle Application:**
|
||||
**Libra Application:**
|
||||
```python
|
||||
# Add flag field to journal_entries
|
||||
class JournalEntryFlag(str, Enum):
|
||||
|
|
@ -661,7 +661,7 @@ from decimal import Decimal
|
|||
amount = Decimal("19.99")
|
||||
```
|
||||
|
||||
**Castle Current Issue:**
|
||||
**Libra Current Issue:**
|
||||
We're using `int` for satoshis (good!) but `float` for fiat amounts (bad!).
|
||||
|
||||
**Fix:**
|
||||
|
|
@ -709,10 +709,10 @@ WHERE account = 'Assets:Checking'
|
|||
AND date >= 2025-01-01;
|
||||
```
|
||||
|
||||
**Castle Application (Future):**
|
||||
**Libra Application (Future):**
|
||||
```python
|
||||
# Add query endpoint
|
||||
@castle_api_router.post("/api/v1/query")
|
||||
@libra_api_router.post("/api/v1/query")
|
||||
async def execute_query(
|
||||
query: str,
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
|
|
@ -756,12 +756,12 @@ beancount/
|
|||
tools/ # Reporting and analysis
|
||||
```
|
||||
|
||||
**Castle Should Adopt:**
|
||||
**Libra Should Adopt:**
|
||||
```
|
||||
castle/
|
||||
libra/
|
||||
core/ # NEW: Pure accounting logic
|
||||
__init__.py
|
||||
inventory.py # CastleInventory for position tracking
|
||||
inventory.py # LibraInventory for position tracking
|
||||
balance.py # Balance calculation logic
|
||||
validation.py # Entry validation (debits=credits, etc)
|
||||
account.py # Account hierarchy and naming
|
||||
|
|
@ -805,11 +805,11 @@ def validate_entries(entries):
|
|||
return errors
|
||||
```
|
||||
|
||||
**Castle Application:**
|
||||
**Libra Application:**
|
||||
```python
|
||||
from typing import NamedTuple, Optional
|
||||
|
||||
class CastleError(NamedTuple):
|
||||
class LibraError(NamedTuple):
|
||||
"""Base error type"""
|
||||
source: dict # {'endpoint': '...', 'user_id': '...'}
|
||||
message: str
|
||||
|
|
@ -828,7 +828,7 @@ class UnbalancedEntryError(NamedTuple):
|
|||
difference: int
|
||||
|
||||
# Return errors from validation
|
||||
async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError]:
|
||||
async def validate_journal_entry(entry: CreateJournalEntry) -> list[LibraError]:
|
||||
errors = []
|
||||
|
||||
# Beancount-style: sum of amounts must equal 0
|
||||
|
|
@ -870,7 +870,7 @@ async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError]
|
|||
|
||||
### Phase 3: Core Logic Refactoring (Medium Priority) ✅ COMPLETE
|
||||
9. ✅ Create `core/` module with pure accounting logic
|
||||
10. ✅ Implement `CastleInventory` for position tracking
|
||||
10. ✅ Implement `LibraInventory` for position tracking
|
||||
11. ✅ Move balance calculation to `core/balance.py`
|
||||
12. ✅ Add comprehensive validation in `core/validation.py`
|
||||
|
||||
|
|
@ -900,7 +900,7 @@ async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError]
|
|||
7. ✅ Separation of core logic from I/O
|
||||
8. ✅ Comprehensive validation
|
||||
|
||||
**What Castle Should Adopt First:**
|
||||
**What Libra Should Adopt First:**
|
||||
1. **Decimal for fiat amounts** (prevent rounding errors)
|
||||
2. **Meta field** (audit trail, source tracking)
|
||||
3. **Flag field** (transaction status)
|
||||
|
|
@ -916,7 +916,7 @@ async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError]
|
|||
|
||||
## Conclusion
|
||||
|
||||
Beancount's architecture is battle-tested for serious accounting work. By adopting these patterns, Castle can:
|
||||
Beancount's architecture is battle-tested for serious accounting work. By adopting these patterns, Libra can:
|
||||
- Prevent financial calculation errors (Decimal)
|
||||
- Support complex workflows (plugins)
|
||||
- Build user trust (balance assertions, audit trail)
|
||||
|
|
|
|||
|
|
@ -496,7 +496,7 @@ Improvement: 5-10x faster
|
|||
## Test Results and Findings
|
||||
|
||||
**Date**: November 10, 2025
|
||||
**Status**: ⚠️ **NOT FEASIBLE for Castle's Current Data Structure**
|
||||
**Status**: ⚠️ **NOT FEASIBLE for Libra's Current Data Structure**
|
||||
|
||||
### Implementation Completed
|
||||
|
||||
|
|
@ -523,7 +523,7 @@ Improvement: 5-10x faster
|
|||
|
||||
### Root Cause: Architecture Limitation
|
||||
|
||||
**Current Castle Ledger Structure:**
|
||||
**Current Libra Ledger Structure:**
|
||||
```
|
||||
Posting format:
|
||||
Amount: -360.00 EUR ← Position (BQL can query this)
|
||||
|
|
@ -549,7 +549,7 @@ SELECT account, sum(meta["sats-equivalent"]) WHERE account ~ 'User-'
|
|||
|
||||
### Why Manual Aggregation is Necessary
|
||||
|
||||
1. **SATS are Castle's primary currency** for balance tracking
|
||||
1. **SATS are Libra's primary currency** for balance tracking
|
||||
2. **SATS values are in metadata**, not positions
|
||||
3. **BQL has no metadata query capability**
|
||||
4. **Must iterate through postings** to read `meta["sats-equivalent"]`
|
||||
|
|
@ -590,7 +590,7 @@ SELECT account, sum(meta["sats-equivalent"]) WHERE account ~ 'User-'
|
|||
|
||||
## Future Consideration: Ledger Format Change
|
||||
|
||||
**If** Castle's ledger format changes to use SATS as position amounts:
|
||||
**If** Libra's ledger format changes to use SATS as position amounts:
|
||||
|
||||
```beancount
|
||||
; Current format (EUR position, SATS in metadata):
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ Expenses:Food -360.00 EUR @@ 337096 SATS
|
|||
**Total calculation**: Exact 337,096 SATS (no rounding)
|
||||
**Precision**: Preserves exact SATS amount from original calculation
|
||||
|
||||
**Why `@@` is better for Castle:**
|
||||
**Why `@@` is better for Libra:**
|
||||
- ✅ Preserves exact SATS amount (no rounding errors)
|
||||
- ✅ Matches current metadata storage exactly
|
||||
- ✅ Clearer intent: "this transaction equals X SATS total"
|
||||
|
|
@ -124,7 +124,7 @@ GROUP BY account;
|
|||
### Step 1: Run Metadata Test
|
||||
|
||||
```bash
|
||||
cd /home/padreug/projects/castle-beancounter
|
||||
cd /home/padreug/projects/libra-beancounter
|
||||
./test_metadata_simple.sh
|
||||
```
|
||||
|
||||
|
|
@ -166,7 +166,7 @@ Add one test entry to your ledger:
|
|||
|
||||
Then query:
|
||||
```bash
|
||||
curl -s "http://localhost:3333/castle-ledger/api/query" \
|
||||
curl -s "http://localhost:3333/libra-ledger/api/query" \
|
||||
-G \
|
||||
--data-urlencode "query_string=SELECT account, position, price_number, price_currency WHERE account ~ 'TEST'" \
|
||||
| jq '.'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Automated Daily Reconciliation
|
||||
|
||||
The Castle extension includes automated daily balance checking to ensure accounting accuracy.
|
||||
The Libra extension includes automated daily balance checking to ensure accounting accuracy.
|
||||
|
||||
## Overview
|
||||
|
||||
|
|
@ -16,7 +16,7 @@ You can manually trigger the reconciliation check from the UI or via API:
|
|||
|
||||
### Via API
|
||||
```bash
|
||||
curl -X POST https://your-lnbits-instance.com/castle/api/v1/tasks/daily-reconciliation \
|
||||
curl -X POST https://your-lnbits-instance.com/libra/api/v1/tasks/daily-reconciliation \
|
||||
-H "X-Api-Key: YOUR_ADMIN_KEY"
|
||||
```
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ Add to your crontab:
|
|||
|
||||
```bash
|
||||
# Run daily at 2 AM
|
||||
0 2 * * * curl -X POST http://localhost:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY" >> /var/log/castle-reconciliation.log 2>&1
|
||||
0 2 * * * curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY" >> /var/log/libra-reconciliation.log 2>&1
|
||||
```
|
||||
|
||||
To edit crontab:
|
||||
|
|
@ -38,22 +38,22 @@ crontab -e
|
|||
|
||||
### Option 2: Systemd Timer
|
||||
|
||||
Create `/etc/systemd/system/castle-reconciliation.service`:
|
||||
Create `/etc/systemd/system/libra-reconciliation.service`:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Castle Daily Reconciliation Check
|
||||
Description=Libra Daily Reconciliation Check
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=lnbits
|
||||
ExecStart=/usr/bin/curl -X POST http://localhost:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY"
|
||||
ExecStart=/usr/bin/curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY"
|
||||
```
|
||||
|
||||
Create `/etc/systemd/system/castle-reconciliation.timer`:
|
||||
Create `/etc/systemd/system/libra-reconciliation.timer`:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Run Castle reconciliation daily
|
||||
Description=Run Libra reconciliation daily
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
|
|
@ -66,8 +66,8 @@ WantedBy=timers.target
|
|||
|
||||
Enable and start:
|
||||
```bash
|
||||
sudo systemctl enable castle-reconciliation.timer
|
||||
sudo systemctl start castle-reconciliation.timer
|
||||
sudo systemctl enable libra-reconciliation.timer
|
||||
sudo systemctl start libra-reconciliation.timer
|
||||
```
|
||||
|
||||
### Option 3: Docker/Kubernetes CronJob
|
||||
|
|
@ -78,7 +78,7 @@ For containerized deployments:
|
|||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: castle-reconciliation
|
||||
name: libra-reconciliation
|
||||
spec:
|
||||
schedule: "0 2 * * *" # Daily at 2 AM
|
||||
jobTemplate:
|
||||
|
|
@ -91,7 +91,7 @@ spec:
|
|||
args:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- curl -X POST http://lnbits:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: ${ADMIN_KEY}"
|
||||
- curl -X POST http://lnbits:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: ${ADMIN_KEY}"
|
||||
restartPolicy: OnFailure
|
||||
```
|
||||
|
||||
|
|
@ -129,7 +129,7 @@ The endpoint returns:
|
|||
grep CRON /var/log/syslog
|
||||
|
||||
# View custom log (if using cron with redirect)
|
||||
tail -f /var/log/castle-reconciliation.log
|
||||
tail -f /var/log/libra-reconciliation.log
|
||||
```
|
||||
|
||||
### Success Criteria
|
||||
|
|
@ -142,7 +142,7 @@ tail -f /var/log/castle-reconciliation.log
|
|||
|
||||
If `failed > 0`:
|
||||
1. Check the `failed_assertions` array for details
|
||||
2. Investigate discrepancies in the Castle UI
|
||||
2. Investigate discrepancies in the Libra UI
|
||||
3. Review recent transactions
|
||||
4. Check for data entry errors
|
||||
5. Verify exchange rate conversions (for fiat)
|
||||
|
|
@ -172,7 +172,7 @@ Planned features:
|
|||
|
||||
3. **Check network connectivity**:
|
||||
```bash
|
||||
curl http://localhost:5000/castle/api/v1/reconciliation/summary -H "X-Api-Key: YOUR_KEY"
|
||||
curl http://localhost:5000/libra/api/v1/reconciliation/summary -H "X-Api-Key: YOUR_KEY"
|
||||
```
|
||||
|
||||
### Permission Denied
|
||||
|
|
@ -202,31 +202,31 @@ Planned features:
|
|||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# setup-castle-reconciliation.sh
|
||||
# setup-libra-reconciliation.sh
|
||||
|
||||
# Configuration
|
||||
LNBITS_URL="http://localhost:5000"
|
||||
ADMIN_KEY="your_admin_key_here"
|
||||
LOG_FILE="/var/log/castle-reconciliation.log"
|
||||
LOG_FILE="/var/log/libra-reconciliation.log"
|
||||
|
||||
# Create log file
|
||||
touch "$LOG_FILE"
|
||||
chmod 644 "$LOG_FILE"
|
||||
|
||||
# Add cron job
|
||||
(crontab -l 2>/dev/null; echo "0 2 * * * curl -X POST $LNBITS_URL/castle/api/v1/tasks/daily-reconciliation -H 'X-Api-Key: $ADMIN_KEY' >> $LOG_FILE 2>&1") | crontab -
|
||||
(crontab -l 2>/dev/null; echo "0 2 * * * curl -X POST $LNBITS_URL/libra/api/v1/tasks/daily-reconciliation -H 'X-Api-Key: $ADMIN_KEY' >> $LOG_FILE 2>&1") | crontab -
|
||||
|
||||
echo "Daily reconciliation scheduled for 2 AM"
|
||||
echo "Logs will be written to: $LOG_FILE"
|
||||
|
||||
# Test the endpoint
|
||||
echo "Running test reconciliation..."
|
||||
curl -X POST "$LNBITS_URL/castle/api/v1/tasks/daily-reconciliation" \
|
||||
curl -X POST "$LNBITS_URL/libra/api/v1/tasks/daily-reconciliation" \
|
||||
-H "X-Api-Key: $ADMIN_KEY"
|
||||
```
|
||||
|
||||
Make executable and run:
|
||||
```bash
|
||||
chmod +x setup-castle-reconciliation.sh
|
||||
./setup-castle-reconciliation.sh
|
||||
chmod +x setup-libra-reconciliation.sh
|
||||
./setup-libra-reconciliation.sh
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Castle Accounting Extension - Comprehensive Documentation
|
||||
# Libra Extension - Comprehensive Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The Castle Accounting extension for LNbits implements a double-entry bookkeeping system designed for cooperative/communal living spaces (like "castles"). It tracks financial relationships between a central entity (the Castle) and multiple users, handling both Lightning Network payments and manual/cash transactions.
|
||||
The Libra extension for LNbits implements a double-entry bookkeeping system designed for 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
|
||||
|
||||
|
|
@ -19,23 +19,23 @@ The system implements traditional **double-entry bookkeeping** principles:
|
|||
|
||||
| Account Type | Normal Balance | Increases With | Decreases With | Purpose |
|
||||
|--------------|----------------|----------------|----------------|---------|
|
||||
| Asset | Debit | Debit | Credit | What Castle owns or is owed |
|
||||
| Liability | Credit | Credit | Debit | What Castle owes to others |
|
||||
| Asset | Debit | Debit | Credit | What Libra owns or is owed |
|
||||
| Liability | Credit | Credit | Debit | What Libra owes to others |
|
||||
| Equity | Credit | Credit | Debit | Member contributions, retained earnings |
|
||||
| Revenue | Credit | Credit | Debit | Income earned by Castle |
|
||||
| Expense | Debit | Debit | Credit | Costs incurred by Castle |
|
||||
| Revenue | Credit | Credit | Debit | Income earned by Libra |
|
||||
| Expense | Debit | Debit | Credit | Costs incurred by Libra |
|
||||
|
||||
### User-Specific Accounts
|
||||
|
||||
The system creates **per-user accounts** for tracking individual balances:
|
||||
|
||||
- `Accounts Receivable - {user_id[:8]}` (Asset) - User owes Castle
|
||||
- `Accounts Payable - {user_id[:8]}` (Liability) - Castle owes User
|
||||
- `Accounts Receivable - {user_id[:8]}` (Asset) - User owes Libra
|
||||
- `Accounts Payable - {user_id[:8]}` (Liability) - Libra owes User
|
||||
- `Member Equity - {user_id[:8]}` (Equity) - User's equity contributions
|
||||
|
||||
**Balance Interpretation:**
|
||||
- `balance > 0` and account is Liability → Castle owes user (user is creditor)
|
||||
- `balance < 0` (or positive Asset balance) → User owes Castle (user is debtor)
|
||||
- `balance > 0` and account is Liability → Libra owes user (user is creditor)
|
||||
- `balance < 0` (or positive Asset balance) → User owes Libra (user is debtor)
|
||||
|
||||
### Database Schema
|
||||
|
||||
|
|
@ -81,7 +81,7 @@ CREATE TABLE entry_lines (
|
|||
```sql
|
||||
CREATE TABLE extension_settings (
|
||||
id TEXT NOT NULL PRIMARY KEY, -- Always "admin"
|
||||
castle_wallet_id TEXT, -- LNbits wallet ID for Castle operations
|
||||
libra_wallet_id TEXT, -- LNbits wallet ID for Libra operations
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
|
@ -129,11 +129,11 @@ Each `entry_line` can store metadata as JSON to preserve original fiat amounts:
|
|||
|
||||
### 1. User Adds Expense (Liability Model)
|
||||
|
||||
**Use Case:** User pays for groceries with cash, Castle reimburses them
|
||||
**Use Case:** User pays for groceries with cash, Libra reimburses them
|
||||
|
||||
**User Action:** Add expense via UI
|
||||
```javascript
|
||||
POST /castle/api/v1/entries/expense
|
||||
POST /libra/api/v1/entries/expense
|
||||
{
|
||||
"description": "Biocoop groceries",
|
||||
"amount": 36.93,
|
||||
|
|
@ -162,15 +162,15 @@ Metadata on both lines:
|
|||
}
|
||||
```
|
||||
|
||||
**Effect:** Castle owes user €36.93 (39,669 sats)
|
||||
**Effect:** Libra owes user €36.93 (39,669 sats)
|
||||
|
||||
### 2. Castle Adds Receivable
|
||||
### 2. Libra Adds Receivable
|
||||
|
||||
**Use Case:** User stays in a room, owes Castle for accommodation
|
||||
**Use Case:** User stays in a room, owes Libra for accommodation
|
||||
|
||||
**Castle Admin Action:** Add receivable via UI
|
||||
**Libra Admin Action:** Add receivable via UI
|
||||
```javascript
|
||||
POST /castle/api/v1/entries/receivable
|
||||
POST /libra/api/v1/entries/receivable
|
||||
{
|
||||
"description": "room 5 days",
|
||||
"amount": 250.0,
|
||||
|
|
@ -198,7 +198,7 @@ Metadata:
|
|||
}
|
||||
```
|
||||
|
||||
**Effect:** User owes Castle €250.00 (268,548 sats)
|
||||
**Effect:** User owes Libra €250.00 (268,548 sats)
|
||||
|
||||
### 3. User Pays with Lightning
|
||||
|
||||
|
|
@ -206,7 +206,7 @@ Metadata:
|
|||
|
||||
**Step A: Generate Invoice**
|
||||
```javascript
|
||||
POST /castle/api/v1/generate-payment-invoice
|
||||
POST /libra/api/v1/generate-payment-invoice
|
||||
{
|
||||
"amount": 268548
|
||||
}
|
||||
|
|
@ -218,19 +218,19 @@ Returns:
|
|||
"payment_hash": "...",
|
||||
"payment_request": "lnbc...",
|
||||
"amount": 268548,
|
||||
"memo": "Payment from user af983632 to Castle",
|
||||
"check_wallet_key": "castle_wallet_inkey"
|
||||
"memo": "Payment from user af983632 to Libra",
|
||||
"check_wallet_key": "libra_wallet_inkey"
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Invoice is generated on **Castle's wallet**, not user's wallet. User polls using `check_wallet_key`.
|
||||
**Note:** Invoice is generated on **Libra's wallet**, not user's wallet. User polls using `check_wallet_key`.
|
||||
|
||||
**Step B: User Pays Invoice**
|
||||
(External Lightning wallet or LNbits wallet)
|
||||
|
||||
**Step C: Record Payment**
|
||||
```javascript
|
||||
POST /castle/api/v1/record-payment
|
||||
POST /libra/api/v1/record-payment
|
||||
{
|
||||
"payment_hash": "..."
|
||||
}
|
||||
|
|
@ -250,11 +250,11 @@ DR Lightning Balance 268,548 sats
|
|||
|
||||
### 4. Manual Payment Request Flow
|
||||
|
||||
**Use Case:** User wants Castle to pay them in cash instead of Lightning
|
||||
**Use Case:** User wants Libra to pay them in cash instead of Lightning
|
||||
|
||||
**Step A: User Requests Payment**
|
||||
```javascript
|
||||
POST /castle/api/v1/manual-payment-requests
|
||||
POST /libra/api/v1/manual-payment-requests
|
||||
{
|
||||
"amount": 39669,
|
||||
"description": "Please pay me in cash for groceries"
|
||||
|
|
@ -263,16 +263,16 @@ POST /castle/api/v1/manual-payment-requests
|
|||
|
||||
Creates `manual_payment_request` with status='pending'
|
||||
|
||||
**Step B: Castle Admin Reviews**
|
||||
**Step B: Libra Admin Reviews**
|
||||
|
||||
Admin sees pending request in UI:
|
||||
- User: af983632
|
||||
- Amount: 39,669 sats (€36.93)
|
||||
- Description: "Please pay me in cash for groceries"
|
||||
|
||||
**Step C: Castle Admin Approves**
|
||||
**Step C: Libra Admin Approves**
|
||||
```javascript
|
||||
POST /castle/api/v1/manual-payment-requests/{id}/approve
|
||||
POST /libra/api/v1/manual-payment-requests/{id}/approve
|
||||
```
|
||||
|
||||
**Journal Entry Created:**
|
||||
|
|
@ -285,11 +285,11 @@ DR Accounts Payable - af983632 39,669 sats
|
|||
CR Lightning Balance 39,669 sats
|
||||
```
|
||||
|
||||
**Effect:** Castle's liability to user reduced by 39,669 sats
|
||||
**Effect:** Libra's liability to user reduced by 39,669 sats
|
||||
|
||||
**Alternative: Castle Admin Rejects**
|
||||
**Alternative: Libra Admin Rejects**
|
||||
```javascript
|
||||
POST /castle/api/v1/manual-payment-requests/{id}/reject
|
||||
POST /libra/api/v1/manual-payment-requests/{id}/reject
|
||||
```
|
||||
No journal entry created, request marked as 'rejected'.
|
||||
|
||||
|
|
@ -308,20 +308,20 @@ for account in user_accounts:
|
|||
|
||||
# Calculate satoshi balance
|
||||
if account.account_type == AccountType.LIABILITY:
|
||||
total_balance += account_balance # Positive = Castle owes user
|
||||
total_balance += account_balance # Positive = Libra owes user
|
||||
elif account.account_type == AccountType.ASSET:
|
||||
total_balance -= account_balance # Positive asset = User owes Castle, so negative balance
|
||||
total_balance -= account_balance # Positive asset = User owes Libra, so negative balance
|
||||
|
||||
# Calculate fiat balance from metadata
|
||||
# Beancount-style: positive amount = debit, negative amount = credit
|
||||
for line in account_entry_lines:
|
||||
if line.metadata.fiat_currency and line.metadata.fiat_amount:
|
||||
if account.account_type == AccountType.LIABILITY:
|
||||
# For liabilities, negative amounts (credits) increase what castle owes
|
||||
# For liabilities, negative amounts (credits) increase what libra owes
|
||||
if line.amount < 0:
|
||||
fiat_balances[currency] += fiat_amount # Castle owes more
|
||||
fiat_balances[currency] += fiat_amount # Libra owes more
|
||||
else:
|
||||
fiat_balances[currency] -= fiat_amount # Castle owes less
|
||||
fiat_balances[currency] -= fiat_amount # Libra owes less
|
||||
elif account.account_type == AccountType.ASSET:
|
||||
# For assets, positive amounts (debits) increase what user owes
|
||||
if line.amount > 0:
|
||||
|
|
@ -331,19 +331,19 @@ for account in user_accounts:
|
|||
```
|
||||
|
||||
**Result:**
|
||||
- `balance > 0`: Castle owes user (LIABILITY side dominates)
|
||||
- `balance < 0`: User owes Castle (ASSET side dominates)
|
||||
- `balance > 0`: Libra owes user (LIABILITY side dominates)
|
||||
- `balance < 0`: User owes Libra (ASSET side dominates)
|
||||
- `fiat_balances`: Net fiat position per currency
|
||||
|
||||
### Castle Balance Calculation
|
||||
### Libra Balance Calculation
|
||||
|
||||
From `views_api.py:api_get_my_balance()` (super user):
|
||||
|
||||
```python
|
||||
all_balances = get_all_user_balances()
|
||||
|
||||
total_liabilities = sum(b.balance for b in all_balances if b.balance > 0) # What Castle owes
|
||||
total_receivables = sum(abs(b.balance) for b in all_balances if b.balance < 0) # What is owed to Castle
|
||||
total_liabilities = sum(b.balance for b in all_balances if b.balance > 0) # What Libra owes
|
||||
total_receivables = sum(abs(b.balance) for b in all_balances if b.balance < 0) # What is owed to Libra
|
||||
net_balance = total_liabilities - total_receivables
|
||||
|
||||
# Aggregate all fiat balances
|
||||
|
|
@ -354,34 +354,34 @@ for user_balance in all_balances:
|
|||
```
|
||||
|
||||
**Result:**
|
||||
- `net_balance > 0`: Castle owes users (net liability)
|
||||
- `net_balance < 0`: Users owe Castle (net receivable)
|
||||
- `net_balance > 0`: Libra owes users (net liability)
|
||||
- `net_balance < 0`: Users owe Libra (net receivable)
|
||||
|
||||
## UI/UX Design
|
||||
|
||||
### Perspective-Based Display
|
||||
|
||||
The UI adapts based on whether the viewer is a regular user or Castle admin (super user):
|
||||
The UI adapts based on whether the viewer is a regular user or Libra admin (super user):
|
||||
|
||||
#### User View
|
||||
|
||||
**Balance Display:**
|
||||
- Green text: Castle owes them (positive balance, incoming money)
|
||||
- Red text: They owe Castle (negative balance, outgoing money)
|
||||
- Green text: Libra owes them (positive balance, incoming money)
|
||||
- Red text: They owe Libra (negative balance, outgoing money)
|
||||
|
||||
**Transaction Badges:**
|
||||
- Green "Receivable": Castle owes them (Accounts Payable entry)
|
||||
- Red "Payable": They owe Castle (Accounts Receivable entry)
|
||||
- Green "Receivable": Libra owes them (Accounts Payable entry)
|
||||
- Red "Payable": They owe Libra (Accounts Receivable entry)
|
||||
|
||||
#### Castle Admin View (Super User)
|
||||
#### Libra Admin View (Super User)
|
||||
|
||||
**Balance Display:**
|
||||
- Red text: Castle owes users (positive balance, outgoing money)
|
||||
- Green text: Users owe Castle (negative balance, incoming money)
|
||||
- Red text: Libra owes users (positive balance, outgoing money)
|
||||
- Green text: Users owe Libra (negative balance, incoming money)
|
||||
|
||||
**Transaction Badges:**
|
||||
- Green "Receivable": User owes Castle (Accounts Receivable entry)
|
||||
- Red "Payable": Castle owes user (Accounts Payable entry)
|
||||
- Green "Receivable": User owes Libra (Accounts Receivable entry)
|
||||
- Red "Payable": Libra owes user (Accounts Payable entry)
|
||||
|
||||
**Outstanding Balances Table:**
|
||||
Shows all users with non-zero balances:
|
||||
|
|
@ -411,10 +411,10 @@ Created by `m001_initial` migration:
|
|||
- `cash` - Cash on hand
|
||||
- `bank` - Bank Account
|
||||
- `lightning` - Lightning Balance
|
||||
- `accounts_receivable` - Money owed to the Castle
|
||||
- `accounts_receivable` - Money owed to the Libra
|
||||
|
||||
### Liabilities
|
||||
- `accounts_payable` - Money owed by the Castle
|
||||
- `accounts_payable` - Money owed by the Libra
|
||||
|
||||
### Equity
|
||||
- `member_equity` - Member contributions
|
||||
|
|
@ -449,11 +449,11 @@ Created by `m001_initial` migration:
|
|||
- `POST /api/v1/entries/revenue` - Create direct revenue entry (admin only)
|
||||
|
||||
### Balance & Payments
|
||||
- `GET /api/v1/balance` - Get current user's balance (or Castle total if super user)
|
||||
- `GET /api/v1/balance` - Get current user's balance (or Libra total if super user)
|
||||
- `GET /api/v1/balance/{user_id}` - Get specific user's balance
|
||||
- `GET /api/v1/balances/all` - Get all user balances (admin only, enriched with usernames)
|
||||
- `POST /api/v1/generate-payment-invoice` - Generate invoice for user to pay Castle
|
||||
- `POST /api/v1/record-payment` - Record Lightning payment to Castle
|
||||
- `POST /api/v1/generate-payment-invoice` - Generate invoice for user to pay Libra
|
||||
- `POST /api/v1/record-payment` - Record Lightning payment to Libra
|
||||
|
||||
### Manual Payments
|
||||
- `POST /api/v1/manual-payment-requests` - User creates manual payment request
|
||||
|
|
@ -463,8 +463,8 @@ Created by `m001_initial` migration:
|
|||
- `POST /api/v1/manual-payment-requests/{id}/reject` - Admin rejects request
|
||||
|
||||
### Settings
|
||||
- `GET /api/v1/settings` - Get Castle settings (super user only)
|
||||
- `PUT /api/v1/settings` - Update Castle settings (super user only)
|
||||
- `GET /api/v1/settings` - Get Libra settings (super user only)
|
||||
- `PUT /api/v1/settings` - Update Libra settings (super user only)
|
||||
- `GET /api/v1/user/wallet` - Get user's wallet settings
|
||||
- `PUT /api/v1/user/wallet` - Update user's wallet settings
|
||||
- `GET /api/v1/users` - Get all users with configured wallets (admin only)
|
||||
|
|
@ -712,7 +712,7 @@ GET /api/v1/entries/user?start_date=2025-01-01&end_date=2025-03-31
|
|||
|
||||
**Add Endpoint:**
|
||||
```python
|
||||
@castle_api_router.get("/api/v1/export/beancount")
|
||||
@libra_api_router.get("/api/v1/export/beancount")
|
||||
async def export_beancount(
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
|
|
@ -812,7 +812,7 @@ async def export_beancount(
|
|||
|
||||
**UI Addition:**
|
||||
|
||||
Add export button to Castle admin UI:
|
||||
Add export button to Libra admin UI:
|
||||
```html
|
||||
<q-btn color="primary" @click="exportBeancount">
|
||||
Export to Beancount
|
||||
|
|
@ -825,7 +825,7 @@ async exportBeancount() {
|
|||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'GET',
|
||||
'/castle/api/v1/export/beancount',
|
||||
'/libra/api/v1/export/beancount',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
|
||||
|
|
@ -834,7 +834,7 @@ async exportBeancount() {
|
|||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `castle-accounting-${new Date().toISOString().split('T')[0]}.beancount`
|
||||
link.download = `libra-accounting-${new Date().toISOString().split('T')[0]}.beancount`
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
|
|
@ -854,12 +854,12 @@ After export, users can verify with Beancount:
|
|||
|
||||
```bash
|
||||
# Check file is valid
|
||||
bean-check castle-accounting-2025-10-22.beancount
|
||||
bean-check libra-accounting-2025-10-22.beancount
|
||||
|
||||
# Generate reports
|
||||
bean-report castle-accounting-2025-10-22.beancount balances
|
||||
bean-report castle-accounting-2025-10-22.beancount income
|
||||
bean-web castle-accounting-2025-10-22.beancount
|
||||
bean-report libra-accounting-2025-10-22.beancount balances
|
||||
bean-report libra-accounting-2025-10-22.beancount income
|
||||
bean-web libra-accounting-2025-10-22.beancount
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
|
@ -891,7 +891,7 @@ bean-web castle-accounting-2025-10-22.beancount
|
|||
|
||||
1. **End-to-End User Flow**
|
||||
- User adds expense
|
||||
- Castle adds receivable
|
||||
- Libra adds receivable
|
||||
- User pays via Lightning
|
||||
- Verify balances at each step
|
||||
|
||||
|
|
@ -904,7 +904,7 @@ bean-web castle-accounting-2025-10-22.beancount
|
|||
3. **Multi-User Scenarios**
|
||||
- Multiple users with positive balances
|
||||
- Multiple users with negative balances
|
||||
- Verify Castle net balance calculation
|
||||
- Verify Libra net balance calculation
|
||||
|
||||
## Security Considerations
|
||||
|
||||
|
|
@ -916,12 +916,12 @@ bean-web castle-accounting-2025-10-22.beancount
|
|||
|
||||
2. **User Isolation**
|
||||
- Users can only see their own balances and transactions
|
||||
- Users cannot create receivables (only Castle admin can)
|
||||
- Users cannot create receivables (only Libra admin can)
|
||||
- Users cannot approve their own manual payment requests
|
||||
|
||||
3. **Wallet Key Requirements**
|
||||
- `require_invoice_key`: Read access to user's data
|
||||
- `require_admin_key`: Write access, Castle admin operations
|
||||
- `require_admin_key`: Write access, Libra admin operations
|
||||
|
||||
### Potential Vulnerabilities
|
||||
|
||||
|
|
@ -959,7 +959,7 @@ bean-web castle-accounting-2025-10-22.beancount
|
|||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
@limiter.limit("10/minute")
|
||||
@castle_api_router.post("/api/v1/entries/expense")
|
||||
@libra_api_router.post("/api/v1/entries/expense")
|
||||
async def api_create_expense_entry(...):
|
||||
...
|
||||
```
|
||||
|
|
@ -1020,7 +1020,7 @@ bean-web castle-accounting-2025-10-22.beancount
|
|||
|
||||
2. **Add Pagination**
|
||||
```python
|
||||
@castle_api_router.get("/api/v1/entries/user")
|
||||
@libra_api_router.get("/api/v1/entries/user")
|
||||
async def api_get_user_entries(
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
limit: int = 100,
|
||||
|
|
@ -1092,7 +1092,7 @@ bean-web castle-accounting-2025-10-22.beancount
|
|||
|
||||
## Migration Path for Existing Data
|
||||
|
||||
If Castle is already in production with the old code:
|
||||
If Libra is already in production with the old code:
|
||||
|
||||
### Migration Script: `m005_fix_user_accounts.py`
|
||||
|
||||
|
|
@ -1185,7 +1185,7 @@ async def m005_fix_user_accounts(db):
|
|||
|
||||
## Conclusion
|
||||
|
||||
The Castle Accounting extension provides a solid foundation for double-entry bookkeeping in LNbits. The core accounting logic is sound, with proper debit/credit handling and user-specific account isolation.
|
||||
The Libra extension provides a solid foundation for double-entry bookkeeping in LNbits. The core accounting logic is sound, with proper debit/credit handling and user-specific account isolation.
|
||||
|
||||
### Strengths
|
||||
✅ Correct double-entry bookkeeping implementation
|
||||
|
|
@ -1193,7 +1193,7 @@ The Castle Accounting extension provides a solid foundation for double-entry boo
|
|||
✅ Metadata preservation for fiat amounts
|
||||
✅ Lightning payment integration
|
||||
✅ Manual payment workflow
|
||||
✅ Perspective-based UI (user vs Castle view)
|
||||
✅ Perspective-based UI (user vs Libra view)
|
||||
|
||||
### Immediate Action Items
|
||||
1. ✅ Fix user account creation bug (COMPLETED)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Overview
|
||||
|
||||
The Castle extension now requires admin approval for all user-submitted expenses. This prevents invalid or incorrect expenses from affecting balances until they are verified by the Castle admin.
|
||||
The Libra extension now requires admin approval for all user-submitted expenses. This prevents invalid or incorrect expenses from affecting balances until they are verified by the Libra admin.
|
||||
|
||||
## How It Works
|
||||
|
||||
|
|
@ -61,7 +61,7 @@ AND je.flag = '*' -- Only cleared entries
|
|||
|
||||
### Get Pending Entries (Admin Only)
|
||||
```
|
||||
GET /castle/api/v1/entries/pending
|
||||
GET /libra/api/v1/entries/pending
|
||||
Authorization: Admin Key
|
||||
|
||||
Returns: list[JournalEntry]
|
||||
|
|
@ -69,7 +69,7 @@ Returns: list[JournalEntry]
|
|||
|
||||
### Approve Expense (Admin Only)
|
||||
```
|
||||
POST /castle/api/v1/entries/{entry_id}/approve
|
||||
POST /libra/api/v1/entries/{entry_id}/approve
|
||||
Authorization: Admin Key
|
||||
|
||||
Returns: JournalEntry (with flag='*')
|
||||
|
|
@ -77,7 +77,7 @@ Returns: JournalEntry (with flag='*')
|
|||
|
||||
### Reject Expense (Admin Only)
|
||||
```
|
||||
POST /castle/api/v1/entries/{entry_id}/reject
|
||||
POST /libra/api/v1/entries/{entry_id}/reject
|
||||
Authorization: Admin Key
|
||||
|
||||
Returns: JournalEntry (with flag='x')
|
||||
|
|
@ -133,7 +133,7 @@ Returns: JournalEntry (with flag='x')
|
|||
|
||||
1. **Submit test expense as regular user**
|
||||
```
|
||||
POST /castle/api/v1/entries/expense
|
||||
POST /libra/api/v1/entries/expense
|
||||
{
|
||||
"description": "Test groceries",
|
||||
"amount": 50.00,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Castle Permissions System - Overview & Administration Guide
|
||||
# Libra Permissions System - Overview & Administration Guide
|
||||
|
||||
**Date**: November 10, 2025
|
||||
**Status**: 📚 **Documentation** + 🔧 **Improvement Recommendations**
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
## Executive Summary
|
||||
|
||||
Castle implements a **granular, hierarchical permission system** that controls who can access which accounts and perform what actions. The system supports permission inheritance, making it easy to grant access to entire account hierarchies with a single permission.
|
||||
Libra implements a **granular, hierarchical permission system** that controls who can access which accounts and perform what actions. The system supports permission inheritance, making it easy to grant access to entire account hierarchies with a single permission.
|
||||
|
||||
**Key Features:**
|
||||
- ✅ **Three permission levels**: READ, SUBMIT_EXPENSE, MANAGE
|
||||
|
|
@ -680,7 +680,7 @@ CREATE TABLE account_permissions (
|
|||
expires_at TIMESTAMP,
|
||||
notes TEXT,
|
||||
|
||||
FOREIGN KEY (account_id) REFERENCES castle_accounts (id)
|
||||
FOREIGN KEY (account_id) REFERENCES libra_accounts (id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_account_permissions_user_id ON account_permissions (user_id);
|
||||
|
|
@ -840,7 +840,7 @@ async def test_expense_submission_without_permission():
|
|||
|
||||
## Summary
|
||||
|
||||
The Castle permissions system is **well-designed** with strong features:
|
||||
The Libra permissions system is **well-designed** with strong features:
|
||||
- Hierarchical inheritance reduces admin burden
|
||||
- Caching provides good performance
|
||||
- Expiration and audit trail support compliance
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ Phase 2 of the Beancount-inspired refactor focused on **reconciliation and autom
|
|||
- `POST /api/v1/assertions/{id}/check` - Re-check assertion
|
||||
- `DELETE /api/v1/assertions/{id}` - Delete assertion
|
||||
|
||||
- **UI** (`templates/castle/index.html:254-378`):
|
||||
- **UI** (`templates/libra/index.html:254-378`):
|
||||
- Balance Assertions card (super user only)
|
||||
- Failed assertions prominently displayed with red banner
|
||||
- Passed assertions in collapsible panel
|
||||
|
|
@ -77,7 +77,7 @@ Phase 2 of the Beancount-inspired refactor focused on **reconciliation and autom
|
|||
|
||||
**Purpose**: Visual dashboard for reconciliation status and quick access to reconciliation tools
|
||||
|
||||
**Implementation** (`templates/castle/index.html:380-499`):
|
||||
**Implementation** (`templates/libra/index.html:380-499`):
|
||||
- **Summary Cards**:
|
||||
- Balance Assertions stats (total, passed, failed, pending)
|
||||
- Journal Entries stats (total, cleared, pending, flagged)
|
||||
|
|
@ -161,7 +161,7 @@ Phase 2 of the Beancount-inspired refactor focused on **reconciliation and autom
|
|||
2. `migrations.py` - Added `m007_balance_assertions` migration
|
||||
3. `crud.py` - Added balance assertion CRUD operations
|
||||
4. `views_api.py` - Added assertion, reconciliation, and task endpoints
|
||||
5. `templates/castle/index.html` - Added assertions and reconciliation UI
|
||||
5. `templates/libra/index.html` - Added assertions and reconciliation UI
|
||||
6. `static/js/index.js` - Added assertion and reconciliation functionality
|
||||
7. `BEANCOUNT_PATTERNS.md` - Updated roadmap to mark Phase 2 complete
|
||||
|
||||
|
|
@ -186,7 +186,7 @@ Phase 2 of the Beancount-inspired refactor focused on **reconciliation and autom
|
|||
|
||||
### Create a Balance Assertion
|
||||
```bash
|
||||
curl -X POST http://localhost:5000/castle/api/v1/assertions \
|
||||
curl -X POST http://localhost:5000/libra/api/v1/assertions \
|
||||
-H "X-Api-Key: ADMIN_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
|
|
@ -198,20 +198,20 @@ curl -X POST http://localhost:5000/castle/api/v1/assertions \
|
|||
|
||||
### Get Reconciliation Summary
|
||||
```bash
|
||||
curl http://localhost:5000/castle/api/v1/reconciliation/summary \
|
||||
curl http://localhost:5000/libra/api/v1/reconciliation/summary \
|
||||
-H "X-Api-Key: ADMIN_KEY"
|
||||
```
|
||||
|
||||
### Run Full Reconciliation
|
||||
```bash
|
||||
curl -X POST http://localhost:5000/castle/api/v1/reconciliation/check-all \
|
||||
curl -X POST http://localhost:5000/libra/api/v1/reconciliation/check-all \
|
||||
-H "X-Api-Key: ADMIN_KEY"
|
||||
```
|
||||
|
||||
### Schedule Daily Reconciliation (Cron)
|
||||
```bash
|
||||
# Add to crontab
|
||||
0 2 * * * curl -X POST http://localhost:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: ADMIN_KEY"
|
||||
0 2 * * * curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: ADMIN_KEY"
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
|
@ -238,7 +238,7 @@ curl -X POST http://localhost:5000/castle/api/v1/reconciliation/check-all \
|
|||
|
||||
**Phase 3: Core Logic Refactoring (Medium Priority)**
|
||||
- Create `core/` module with pure accounting logic
|
||||
- Implement `CastleInventory` for position tracking
|
||||
- Implement `LibraInventory` for position tracking
|
||||
- Move balance calculation to `core/balance.py`
|
||||
- Add comprehensive validation in `core/validation.py`
|
||||
|
||||
|
|
@ -256,7 +256,7 @@ curl -X POST http://localhost:5000/castle/api/v1/reconciliation/check-all \
|
|||
|
||||
## Conclusion
|
||||
|
||||
Phase 2 successfully implements Beancount's reconciliation philosophy in the Castle extension. With balance assertions, comprehensive reconciliation APIs, a visual dashboard, and automated daily checks, users can:
|
||||
Phase 2 successfully implements Beancount's reconciliation philosophy in the Libra extension. With balance assertions, comprehensive reconciliation APIs, a visual dashboard, and automated daily checks, users can:
|
||||
|
||||
- **Trust their data** with automated verification
|
||||
- **Catch errors early** through regular reconciliation
|
||||
|
|
|
|||
|
|
@ -21,13 +21,13 @@ Phase 3 of the Beancount-inspired refactor focused on **separating business logi
|
|||
- Easier to audit and verify
|
||||
- Clear architecture
|
||||
|
||||
### 2. CastleInventory for Position Tracking ✅
|
||||
### 2. LibraInventory for Position Tracking ✅
|
||||
|
||||
**Purpose**: Track balances across multiple currencies with cost basis information (following Beancount's Inventory pattern)
|
||||
|
||||
**Implementation** (`core/inventory.py`):
|
||||
|
||||
**CastlePosition** (Lines 11-84):
|
||||
**LibraPosition** (Lines 11-84):
|
||||
- Immutable dataclass representing a single position
|
||||
- Tracks currency, amount, cost basis, and metadata
|
||||
- Supports addition and negation operations
|
||||
|
|
@ -35,7 +35,7 @@ Phase 3 of the Beancount-inspired refactor focused on **separating business logi
|
|||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class CastlePosition:
|
||||
class LibraPosition:
|
||||
currency: str # "SATS", "EUR", "USD"
|
||||
amount: Decimal
|
||||
cost_currency: Optional[str] = None
|
||||
|
|
@ -44,7 +44,7 @@ class CastlePosition:
|
|||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
```
|
||||
|
||||
**CastleInventory** (Lines 87-201):
|
||||
**LibraInventory** (Lines 87-201):
|
||||
- Container for multiple positions
|
||||
- Positions keyed by `(currency, cost_currency)` tuple
|
||||
- Methods for querying balances:
|
||||
|
|
@ -83,7 +83,7 @@ class AccountType(str, Enum):
|
|||
- Liabilities/Equity/Revenue: Credit balance (credit - debit)
|
||||
|
||||
2. **`build_inventory_from_entry_lines()`** (Lines 56-117):
|
||||
- Build CastleInventory from journal entry lines
|
||||
- Build LibraInventory from journal entry lines
|
||||
- Handles both sats and fiat currency tracking
|
||||
- Accounts for account type when determining sign
|
||||
|
||||
|
|
@ -123,7 +123,7 @@ class AccountType(str, Enum):
|
|||
- Checks both sats and fiat within tolerance
|
||||
|
||||
3. **`validate_receivable_entry()`** (Lines 180-199):
|
||||
- Validates receivable (user owes castle) entries
|
||||
- Validates receivable (user owes libra) entries
|
||||
- Ensures positive amount
|
||||
- Ensures revenue account type
|
||||
|
||||
|
|
@ -216,10 +216,10 @@ views_api.py → crud.py → core/
|
|||
## File Structure
|
||||
|
||||
```
|
||||
lnbits/extensions/castle/
|
||||
lnbits/extensions/libra/
|
||||
├── core/
|
||||
│ ├── __init__.py # Module exports
|
||||
│ ├── inventory.py # CastleInventory, CastlePosition
|
||||
│ ├── inventory.py # LibraInventory, LibraPosition
|
||||
│ ├── balance.py # BalanceCalculator
|
||||
│ └── validation.py # Validation functions
|
||||
├── crud.py # DB operations (refactored to use core/)
|
||||
|
|
@ -230,22 +230,22 @@ lnbits/extensions/castle/
|
|||
|
||||
## Usage Examples
|
||||
|
||||
### Using CastleInventory
|
||||
### Using LibraInventory
|
||||
|
||||
```python
|
||||
from decimal import Decimal
|
||||
from castle.core.inventory import CastleInventory, CastlePosition
|
||||
from libra.core.inventory import LibraInventory, LibraPosition
|
||||
|
||||
# Create inventory
|
||||
inv = CastleInventory()
|
||||
inv = LibraInventory()
|
||||
|
||||
# Add positions
|
||||
inv.add_position(CastlePosition(
|
||||
inv.add_position(LibraPosition(
|
||||
currency="SATS",
|
||||
amount=Decimal("100000")
|
||||
))
|
||||
|
||||
inv.add_position(CastlePosition(
|
||||
inv.add_position(LibraPosition(
|
||||
currency="SATS",
|
||||
amount=Decimal("50000"),
|
||||
cost_currency="EUR",
|
||||
|
|
@ -264,7 +264,7 @@ data = inv.to_dict()
|
|||
### Using BalanceCalculator
|
||||
|
||||
```python
|
||||
from castle.core.balance import BalanceCalculator, AccountType
|
||||
from libra.core.balance import BalanceCalculator, AccountType
|
||||
|
||||
# Calculate account balance
|
||||
balance = BalanceCalculator.calculate_account_balance(
|
||||
|
|
@ -297,7 +297,7 @@ is_valid = BalanceCalculator.check_balance_matches(
|
|||
### Using Validation
|
||||
|
||||
```python
|
||||
from castle.core.validation import validate_journal_entry, ValidationError
|
||||
from libra.core.validation import validate_journal_entry, ValidationError
|
||||
|
||||
entry = {
|
||||
"id": "abc123",
|
||||
|
|
@ -320,8 +320,8 @@ except ValidationError as e:
|
|||
|
||||
## Testing Checklist
|
||||
|
||||
- [x] CastleInventory created and tested
|
||||
- [x] CastlePosition addition works
|
||||
- [x] LibraInventory created and tested
|
||||
- [x] LibraPosition addition works
|
||||
- [x] Inventory balance calculations work
|
||||
- [x] BalanceCalculator account balance calculation works
|
||||
- [x] BalanceCalculator inventory building works
|
||||
|
|
@ -348,10 +348,10 @@ except ValidationError as e:
|
|||
|
||||
## Conclusion
|
||||
|
||||
Phase 3 successfully refactors Castle's accounting logic into a clean, testable core module. By following Beancount's architecture patterns, we've created:
|
||||
Phase 3 successfully refactors Libra's accounting logic into a clean, testable core module. By following Beancount's architecture patterns, we've created:
|
||||
|
||||
- **Pure accounting logic** separated from database concerns
|
||||
- **CastleInventory** for position tracking across currencies
|
||||
- **LibraInventory** for position tracking across currencies
|
||||
- **BalanceCalculator** for consistent balance calculations
|
||||
- **Comprehensive validation** for data integrity
|
||||
|
||||
|
|
|
|||
|
|
@ -8,21 +8,21 @@
|
|||
|
||||
## Overview
|
||||
|
||||
The `sats-equivalent` metadata field is Castle's solution for **dual-currency tracking** in a fiat-denominated ledger. It preserves Bitcoin (satoshi) amounts alongside fiat currency amounts without violating accounting principles or creating multi-currency complexity in position balances.
|
||||
The `sats-equivalent` metadata field is Libra's solution for **dual-currency tracking** in a fiat-denominated ledger. It preserves Bitcoin (satoshi) amounts alongside fiat currency amounts without violating accounting principles or creating multi-currency complexity in position balances.
|
||||
|
||||
### Quick Summary
|
||||
|
||||
- **Purpose**: Track Bitcoin/Lightning amounts in a EUR-denominated ledger
|
||||
- **Location**: Beancount posting metadata (not position amounts)
|
||||
- **Format**: String containing absolute satoshi amount (e.g., `"337096"`)
|
||||
- **Primary Use**: Calculate user balances in satoshis (Castle's primary currency)
|
||||
- **Primary Use**: Calculate user balances in satoshis (Libra's primary currency)
|
||||
- **Key Principle**: Satoshis are for reference; EUR is the actual transaction currency
|
||||
|
||||
---
|
||||
|
||||
## The Problem: Dual-Currency Tracking
|
||||
|
||||
Castle needs to track both:
|
||||
Libra needs to track both:
|
||||
1. **Fiat amounts** (EUR, USD) - The actual transaction currency
|
||||
2. **Bitcoin amounts** (satoshis) - The Lightning Network settlement currency
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ Castle needs to track both:
|
|||
- ❌ Complicate traditional accounting reconciliation
|
||||
- ❌ Make fiat-based reporting difficult
|
||||
|
||||
**Castle's Philosophy**: Record transactions in their **actual currency**, with Bitcoin as supplementary data.
|
||||
**Libra's Philosophy**: Record transactions in their **actual currency**, with Bitcoin as supplementary data.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -88,7 +88,7 @@ if fiat_currency and fiat_amount:
|
|||
|
||||
### Primary Use Case: User Balances
|
||||
|
||||
Castle's core function is tracking **who owes whom** in satoshis. The `sats-equivalent` metadata enables this.
|
||||
Libra's core function is tracking **who owes whom** in satoshis. The `sats-equivalent` metadata enables this.
|
||||
|
||||
**Flow** (`fava_client.py:220-248`):
|
||||
|
||||
|
|
@ -147,7 +147,7 @@ SELECT account, sum(meta["sats-equivalent"]) WHERE account ~ 'User-5987ae95'
|
|||
-- Error: BQL cannot access metadata
|
||||
```
|
||||
|
||||
### Why Castle Accepts This Trade-off
|
||||
### Why Libra Accepts This Trade-off
|
||||
|
||||
**Performance Analysis** (see `docs/BQL-BALANCE-QUERIES.md`):
|
||||
1. **Caching solves the bottleneck**: 60-80% performance improvement from caching account/permission lookups
|
||||
|
|
@ -196,9 +196,9 @@ See `docs/BQL-PRICE-NOTATION-SOLUTION.md` for full analysis.
|
|||
|
||||
**User Action**: "I paid €36.93 cash for groceries"
|
||||
|
||||
**Castle's Internal Representation**:
|
||||
**Libra's Internal Representation**:
|
||||
```python
|
||||
# User provides or Castle calculates:
|
||||
# User provides or Libra calculates:
|
||||
fiat_amount = Decimal("36.93") # EUR
|
||||
fiat_currency = "EUR"
|
||||
amount_sats = 39669 # Calculated from exchange rate
|
||||
|
|
@ -232,16 +232,16 @@ line = CreateEntryLine(
|
|||
# - Apply sign: -36.93 is negative → sats = -39669
|
||||
# - Accumulate: user_balance_sats += -39669
|
||||
|
||||
# Result: negative balance = Castle owes user
|
||||
# Result: negative balance = Libra owes user
|
||||
```
|
||||
|
||||
**User Balance Response**:
|
||||
```json
|
||||
{
|
||||
"user_id": "5987ae95",
|
||||
"balance": -39669, // Castle owes user 39,669 sats
|
||||
"balance": -39669, // Libra owes user 39,669 sats
|
||||
"fiat_balances": {
|
||||
"EUR": "-36.93" // Castle owes user €36.93
|
||||
"EUR": "-36.93" // Libra owes user €36.93
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -306,7 +306,7 @@ The `sats-equivalent` is the **exact satoshi amount at transaction time**. It do
|
|||
|
||||
### 3. Separate Fiat and Sats Balances
|
||||
|
||||
Castle tracks TWO independent balances:
|
||||
Libra tracks TWO independent balances:
|
||||
- **Satoshi balance**: Sum of `sats-equivalent` metadata (primary)
|
||||
- **Fiat balances**: Sum of EUR/USD position amounts (secondary)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Castle UI Improvements Plan
|
||||
# Libra UI Improvements Plan
|
||||
|
||||
**Date**: November 10, 2025
|
||||
**Status**: 📋 **Planning Document**
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
## Overview
|
||||
|
||||
Enhance the Castle permissions UI to showcase new bulk permission management and account sync features, making admin tasks faster and more intuitive.
|
||||
Enhance the Libra permissions UI to showcase new bulk permission management and account sync features, making admin tasks faster and more intuitive.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -230,7 +230,7 @@ Enhance the Castle permissions UI to showcase new bulk permission management and
|
|||
│ │
|
||||
│ ⚠️ Warning: This will revoke ALL │
|
||||
│ permissions for this user. They will │
|
||||
│ immediately lose access to Castle. │
|
||||
│ immediately lose access to Libra. │
|
||||
│ │
|
||||
│ Reason for Offboarding │
|
||||
│ [Employee departure - last day] │
|
||||
|
|
@ -257,13 +257,13 @@ Enhance the Castle permissions UI to showcase new bulk permission management and
|
|||
├───────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Sync accounts from your Beancount ledger │
|
||||
│ to Castle database for permission mgmt. │
|
||||
│ to Libra database for permission mgmt. │
|
||||
│ │
|
||||
│ Last Sync: 2 hours ago │
|
||||
│ Status: ✅ Up to date │
|
||||
│ │
|
||||
│ Accounts in Beancount: 150 │
|
||||
│ Accounts in Castle DB: 150 │
|
||||
│ Accounts in Libra DB: 150 │
|
||||
│ │
|
||||
│ Options: │
|
||||
│ ☐ Force full sync (re-check all) │
|
||||
|
|
@ -509,7 +509,7 @@ permissions.html
|
|||
syncStatus: {
|
||||
lastSync: null,
|
||||
beancountAccounts: 0,
|
||||
castleAccounts: 0,
|
||||
libraAccounts: 0,
|
||||
status: 'idle'
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"""
|
||||
Fava API client for Castle.
|
||||
Fava API client for Libra.
|
||||
|
||||
This module provides an async HTTP client for interacting with Fava's JSON API.
|
||||
All accounting logic is delegated to Fava/Beancount.
|
||||
|
|
@ -46,7 +46,7 @@ class FavaClient:
|
|||
|
||||
Args:
|
||||
fava_url: Base URL of Fava server (e.g., http://localhost:3333)
|
||||
ledger_slug: URL-safe ledger identifier (e.g., castle-accounting)
|
||||
ledger_slug: URL-safe ledger identifier (e.g., libra-accounting)
|
||||
timeout: Request timeout in seconds
|
||||
"""
|
||||
self.fava_url = fava_url.rstrip('/')
|
||||
|
|
@ -169,7 +169,7 @@ class FavaClient:
|
|||
|
||||
Args:
|
||||
entry: Beancount entry dict (same format as add_entry)
|
||||
idempotency_key: Unique key for this operation (e.g., "castle-{uuid}" or "ln-{payment_hash}")
|
||||
idempotency_key: Unique key for this operation (e.g., "libra-{uuid}" or "ln-{payment_hash}")
|
||||
|
||||
Returns:
|
||||
Response from Fava if entry was created, or existing entry data if already exists
|
||||
|
|
@ -289,18 +289,18 @@ class FavaClient:
|
|||
|
||||
async def get_user_balance(self, user_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get user's balance from castle's perspective.
|
||||
Get user's balance from libra's perspective.
|
||||
|
||||
Aggregates:
|
||||
- Liabilities:Payable:User-{user_id} (negative = castle owes user)
|
||||
- Assets:Receivable:User-{user_id} (positive = user owes castle)
|
||||
- Liabilities:Payable:User-{user_id} (negative = libra owes user)
|
||||
- Assets:Receivable:User-{user_id} (positive = user owes libra)
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
{
|
||||
"balance": int (sats, positive = user owes castle, negative = castle owes user),
|
||||
"balance": int (sats, positive = user owes libra, negative = libra owes user),
|
||||
"fiat_balances": {"EUR": Decimal("100.50")},
|
||||
"accounts": [list of account dicts with balances]
|
||||
}
|
||||
|
|
@ -676,12 +676,12 @@ class FavaClient:
|
|||
Use this for efficient aggregations, filtering, and data retrieval.
|
||||
|
||||
⚠️ LIMITATION: BQL can only query position amounts and transaction-level data.
|
||||
It CANNOT access posting metadata (like 'sats-equivalent'). For Castle's current
|
||||
It CANNOT access posting metadata (like 'sats-equivalent'). For Libra's current
|
||||
ledger format where SATS are stored in metadata, manual aggregation is required.
|
||||
|
||||
See: docs/BQL-BALANCE-QUERIES.md for detailed analysis and test results.
|
||||
|
||||
FUTURE CONSIDERATION: If Castle's ledger format changes to use SATS as position
|
||||
FUTURE CONSIDERATION: If Libra's ledger format changes to use SATS as position
|
||||
amounts (instead of metadata), BQL could provide significant performance benefits.
|
||||
|
||||
Args:
|
||||
|
|
@ -1031,7 +1031,7 @@ class FavaClient:
|
|||
Get total expense contributions per user using BQL.
|
||||
|
||||
Uses sum(weight) to aggregate all expenses each user has submitted
|
||||
that created liabilities (castle owes user).
|
||||
that created liabilities (libra owes user).
|
||||
|
||||
Returns:
|
||||
List of user contribution summaries:
|
||||
|
|
@ -1601,8 +1601,8 @@ class FavaClient:
|
|||
|
||||
Args:
|
||||
user_id: User ID (first 8 characters used for account matching)
|
||||
entry_type: "expense" (payables - castle owes user) or
|
||||
"receivable" (user owes castle)
|
||||
entry_type: "expense" (payables - libra owes user) or
|
||||
"receivable" (user owes libra)
|
||||
|
||||
Returns:
|
||||
List of unsettled entries with:
|
||||
|
|
@ -1742,8 +1742,8 @@ class FavaClient:
|
|||
|
||||
Args:
|
||||
user_id: User ID (first 8 characters used for account matching)
|
||||
entry_type: "expense" (payables - castle owes user) or
|
||||
"receivable" (user owes castle)
|
||||
entry_type: "expense" (payables - libra owes user) or
|
||||
"receivable" (user owes libra)
|
||||
|
||||
Returns:
|
||||
List of unsettled entries with:
|
||||
|
|
@ -1896,6 +1896,6 @@ def get_fava_client() -> FavaClient:
|
|||
if _fava_client is None:
|
||||
raise RuntimeError(
|
||||
"Fava client not initialized. Call init_fava_client() first. "
|
||||
"Castle requires Fava for all accounting operations."
|
||||
"Libra requires Fava for all accounting operations."
|
||||
)
|
||||
return _fava_client
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Castle Beancount Import Helper
|
||||
# Libra Beancount Import Helper
|
||||
|
||||
Import Beancount ledger transactions into Castle accounting extension.
|
||||
Import Beancount ledger transactions into Libra accounting extension.
|
||||
|
||||
## 📁 Files
|
||||
|
||||
|
|
@ -40,14 +40,14 @@ USER_MAPPINGS = {
|
|||
### 3. Set API Key
|
||||
|
||||
```bash
|
||||
export CASTLE_ADMIN_KEY="your_lnbits_admin_invoice_key"
|
||||
export LIBRA_ADMIN_KEY="your_lnbits_admin_invoice_key"
|
||||
export LNBITS_URL="http://localhost:5000" # Optional
|
||||
```
|
||||
|
||||
## 📖 Usage
|
||||
|
||||
```bash
|
||||
cd /path/to/castle/helper
|
||||
cd /path/to/libra/helper
|
||||
|
||||
# Test with dry run
|
||||
python import_beancount.py ledger.beancount --dry-run
|
||||
|
|
@ -72,30 +72,30 @@ Your Beancount transactions must have an `Equity:<name>` account:
|
|||
|
||||
**Requirements:**
|
||||
- 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`
|
||||
|
||||
## 🔄 How It Works
|
||||
|
||||
1. **Loads rates** from `btc_eur_rates.csv`
|
||||
2. **Loads accounts** from Castle API automatically
|
||||
2. **Loads accounts** from Libra API automatically
|
||||
3. **Maps users** - Extracts user name from `Equity:Name` accounts
|
||||
4. **Parses** Beancount transactions
|
||||
5. **Converts** EUR → sats using daily rate
|
||||
6. **Uploads** to Castle with metadata
|
||||
6. **Uploads** to Libra with metadata
|
||||
|
||||
## 📊 Example Output
|
||||
|
||||
```bash
|
||||
$ python import_beancount.py ledger.beancount
|
||||
======================================================================
|
||||
🏰 Beancount to Castle Import Script
|
||||
🏰 Beancount to Libra Import Script
|
||||
======================================================================
|
||||
|
||||
📊 Loaded 15 daily rates from btc_eur_rates.csv
|
||||
Date range: 2025-07-01 to 2025-07-15
|
||||
|
||||
🏦 Loaded 28 accounts from Castle
|
||||
🏦 Loaded 28 accounts from Libra
|
||||
|
||||
👥 User ID mappings:
|
||||
- Pat → wallet_abc123
|
||||
|
|
@ -112,15 +112,15 @@ $ python import_beancount.py ledger.beancount
|
|||
📊 Summary: 25 succeeded, 0 failed, 0 skipped
|
||||
======================================================================
|
||||
|
||||
✅ Successfully imported 25 transactions to Castle!
|
||||
✅ Successfully imported 25 transactions to Libra!
|
||||
```
|
||||
|
||||
## ❓ Troubleshooting
|
||||
|
||||
### "No account found in Castle"
|
||||
**Error:** `No account found in Castle with name 'Expenses:XYZ'`
|
||||
### "No account found in Libra"
|
||||
**Error:** `No account found in Libra with name 'Expenses:XYZ'`
|
||||
|
||||
**Solution:** Create the account in Castle first with that exact name.
|
||||
**Solution:** Create the account in Libra first with that exact name.
|
||||
|
||||
### "No user ID mapping found"
|
||||
**Error:** `No user ID mapping found for 'Pat'`
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Beancount to Castle Import Script
|
||||
Beancount to Libra Import Script
|
||||
|
||||
⚠️ NOTE: This script is for ONE-OFF MIGRATION purposes only.
|
||||
|
||||
Now that Castle uses Fava/Beancount as the single source of truth,
|
||||
the data flow is: Castle → Fava/Beancount (not the reverse).
|
||||
Now that Libra uses Fava/Beancount as the single source of truth,
|
||||
the data flow is: Libra → Fava/Beancount (not the reverse).
|
||||
|
||||
This script was used for initial data import from existing Beancount files.
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ Beancount to Castle Import Script
|
|||
- REPURPOSE for bidirectional sync if that becomes a requirement
|
||||
- ARCHIVE to misc-docs/old-helpers/ if keeping for reference
|
||||
|
||||
Imports Beancount ledger transactions into Castle accounting extension.
|
||||
Imports Beancount ledger transactions into Libra accounting extension.
|
||||
Reads daily BTC/EUR rates from btc_eur_rates.csv in the same directory.
|
||||
|
||||
Usage:
|
||||
|
|
@ -35,14 +35,14 @@ from typing import Dict, Optional
|
|||
|
||||
# LNbits URL and API Key
|
||||
LNBITS_URL = os.environ.get("LNBITS_URL", "http://localhost:5000")
|
||||
ADMIN_API_KEY = os.environ.get("CASTLE_ADMIN_KEY", "48d787d862484a6c89d6a557b4d5be9d")
|
||||
ADMIN_API_KEY = os.environ.get("LIBRA_ADMIN_KEY", "48d787d862484a6c89d6a557b4d5be9d")
|
||||
|
||||
# Rates CSV file (looks in same directory as this script)
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
RATES_CSV_FILE = os.path.join(SCRIPT_DIR, "btc_eur_rates.csv")
|
||||
|
||||
# User ID mappings: Equity account name -> Castle user ID (wallet ID)
|
||||
# TODO: Update these with your actual Castle user/wallet IDs
|
||||
# User ID mappings: Equity account name -> Libra user ID (wallet ID)
|
||||
# TODO: Update these with your actual Libra user/wallet IDs
|
||||
USER_MAPPINGS = {
|
||||
"Pat": "75be145a42884b22b60bf97510ed46e3",
|
||||
"Coco": "375ec158ceca46de86cf6561ca20f881",
|
||||
|
|
@ -116,7 +116,7 @@ class RateLookup:
|
|||
# ===== ACCOUNT LOOKUP =====
|
||||
|
||||
class AccountLookup:
|
||||
"""Fetch and lookup Castle accounts from API"""
|
||||
"""Fetch and lookup Libra accounts from API"""
|
||||
|
||||
def __init__(self, lnbits_url: str, api_key: str):
|
||||
self.accounts = {} # name -> account_id
|
||||
|
|
@ -125,8 +125,8 @@ class AccountLookup:
|
|||
self._fetch_accounts(lnbits_url, api_key)
|
||||
|
||||
def _fetch_accounts(self, lnbits_url: str, api_key: str):
|
||||
"""Fetch all accounts from Castle API"""
|
||||
url = f"{lnbits_url}/castle/api/v1/accounts"
|
||||
"""Fetch all accounts from Libra API"""
|
||||
url = f"{lnbits_url}/libra/api/v1/accounts"
|
||||
headers = {"X-Api-Key": api_key}
|
||||
|
||||
try:
|
||||
|
|
@ -153,28 +153,28 @@ class AccountLookup:
|
|||
self.accounts_by_user[user_id] = {}
|
||||
self.accounts_by_user[user_id][account_type] = account_id
|
||||
|
||||
print(f"🏦 Loaded {len(self.accounts)} accounts from Castle")
|
||||
print(f"🏦 Loaded {len(self.accounts)} accounts from Libra")
|
||||
|
||||
except requests.RequestException as e:
|
||||
raise ConnectionError(f"Failed to fetch accounts from Castle API: {e}")
|
||||
raise ConnectionError(f"Failed to fetch accounts from Libra API: {e}")
|
||||
|
||||
def get_account_id(self, account_name: str) -> Optional[str]:
|
||||
"""
|
||||
Get Castle account ID for a Beancount account name.
|
||||
Get Libra account ID for a Beancount account name.
|
||||
|
||||
Special handling for user-specific accounts:
|
||||
- "Liabilities:Payable:Pat" -> looks up Pat's user_id and finds their Castle payable account
|
||||
- "Assets:Receivable:Pat" -> looks up Pat's user_id and finds their Castle receivable account
|
||||
- "Equity:Pat" -> looks up Pat's user_id and finds their Castle equity account
|
||||
- "Liabilities:Payable:Pat" -> looks up Pat's user_id and finds their Libra payable account
|
||||
- "Assets:Receivable:Pat" -> looks up Pat's user_id and finds their Libra receivable account
|
||||
- "Equity:Pat" -> looks up Pat's user_id and finds their Libra equity account
|
||||
|
||||
Args:
|
||||
account_name: Beancount account name (e.g., "Expenses:Food:Supplies", "Liabilities:Payable:Pat", "Assets:Receivable:Pat", "Equity:Pat")
|
||||
|
||||
Returns:
|
||||
Castle account UUID or None if not found
|
||||
Libra account UUID or None if not found
|
||||
"""
|
||||
# Check if this is a Liabilities:Payable:<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:"):
|
||||
user_name = extract_user_from_user_account(account_name)
|
||||
if user_name:
|
||||
|
|
@ -182,7 +182,7 @@ class AccountLookup:
|
|||
user_id = USER_MAPPINGS.get(user_name)
|
||||
if user_id:
|
||||
# Find this user's liability (payable) account
|
||||
# This is the Liabilities:Payable:User-<id> account in Castle
|
||||
# This is the Liabilities:Payable:User-<id> account in Libra
|
||||
if user_id in self.accounts_by_user:
|
||||
liability_account_id = self.accounts_by_user[user_id].get('liability')
|
||||
if liability_account_id:
|
||||
|
|
@ -196,7 +196,7 @@ class AccountLookup:
|
|||
)
|
||||
|
||||
# Check if this is an Assets:Receivable:<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:"):
|
||||
user_name = extract_user_from_user_account(account_name)
|
||||
if user_name:
|
||||
|
|
@ -204,7 +204,7 @@ class AccountLookup:
|
|||
user_id = USER_MAPPINGS.get(user_name)
|
||||
if user_id:
|
||||
# Find this user's asset (receivable) account
|
||||
# This is the Assets:Receivable:User-<id> account in Castle
|
||||
# This is the Assets:Receivable:User-<id> account in Libra
|
||||
if user_id in self.accounts_by_user:
|
||||
asset_account_id = self.accounts_by_user[user_id].get('asset')
|
||||
if asset_account_id:
|
||||
|
|
@ -218,7 +218,7 @@ class AccountLookup:
|
|||
)
|
||||
|
||||
# Check if this is an Equity:<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:"):
|
||||
user_name = extract_user_from_user_account(account_name)
|
||||
if user_name:
|
||||
|
|
@ -226,7 +226,7 @@ class AccountLookup:
|
|||
user_id = USER_MAPPINGS.get(user_name)
|
||||
if user_id:
|
||||
# Find this user's equity account
|
||||
# This is the Equity:User-<id> account in Castle
|
||||
# This is the Equity:User-<id> account in Libra
|
||||
if user_id in self.accounts_by_user:
|
||||
equity_account_id = self.accounts_by_user[user_id].get('equity')
|
||||
if equity_account_id:
|
||||
|
|
@ -235,7 +235,7 @@ class AccountLookup:
|
|||
# If not found, provide helpful error
|
||||
raise ValueError(
|
||||
f"User '{user_name}' (ID: {user_id}) does not have an equity account.\n"
|
||||
f"Equity eligibility must be enabled for this user in Castle.\n"
|
||||
f"Equity eligibility must be enabled for this user in Libra.\n"
|
||||
f"Please enable equity for user ID: {user_id}"
|
||||
)
|
||||
|
||||
|
|
@ -282,7 +282,7 @@ def eur_to_sats(eur_amount: Decimal, btc_eur_rate: float) -> int:
|
|||
|
||||
def build_metadata(eur_amount: Decimal, btc_eur_rate: float) -> dict:
|
||||
"""
|
||||
Build metadata dict for Castle entry line.
|
||||
Build metadata dict for Libra entry line.
|
||||
|
||||
The API will extract fiat_currency and fiat_amount and use them
|
||||
to create proper EUR-based postings with SATS in metadata.
|
||||
|
|
@ -441,13 +441,13 @@ def determine_user_id(postings: list) -> Optional[str]:
|
|||
# No user-specific account found - this shouldn't happen for typical transactions
|
||||
return None
|
||||
|
||||
# ===== CASTLE CONVERTER =====
|
||||
# ===== LIBRA CONVERTER =====
|
||||
|
||||
def convert_to_castle_entry(parsed: dict, btc_eur_rate: float, account_lookup: AccountLookup) -> dict:
|
||||
def convert_to_libra_entry(parsed: dict, btc_eur_rate: float, account_lookup: AccountLookup) -> dict:
|
||||
"""
|
||||
Convert parsed Beancount transaction to Castle format.
|
||||
Convert parsed Beancount transaction to Libra format.
|
||||
|
||||
Sends SATS amounts with fiat metadata. The Castle API will automatically
|
||||
Sends SATS amounts with fiat metadata. The Libra API will automatically
|
||||
convert to EUR-based postings with SATS stored in metadata.
|
||||
"""
|
||||
|
||||
|
|
@ -469,8 +469,8 @@ def convert_to_castle_entry(parsed: dict, btc_eur_rate: float, account_lookup: A
|
|||
account_id = account_lookup.get_account_id(posting['account'])
|
||||
if not account_id:
|
||||
raise ValueError(
|
||||
f"No account found in Castle with name '{posting['account']}'.\n"
|
||||
f"Please create this account in Castle first."
|
||||
f"No account found in Libra with name '{posting['account']}'.\n"
|
||||
f"Please create this account in Libra first."
|
||||
)
|
||||
|
||||
eur_amount = posting['eur_amount']
|
||||
|
|
@ -510,7 +510,7 @@ def convert_to_castle_entry(parsed: dict, btc_eur_rate: float, account_lookup: A
|
|||
# ===== API UPLOAD =====
|
||||
|
||||
def upload_entry(entry: dict, api_key: str, dry_run: bool = False) -> dict:
|
||||
"""Upload journal entry to Castle API"""
|
||||
"""Upload journal entry to Libra API"""
|
||||
if dry_run:
|
||||
print(f"\n[DRY RUN] Entry preview:")
|
||||
print(f" Description: {entry['description']}")
|
||||
|
|
@ -525,7 +525,7 @@ def upload_entry(entry: dict, api_key: str, dry_run: bool = False) -> dict:
|
|||
print(f" Balance check: {total_sats} (should be 0)")
|
||||
return {"id": "dry-run"}
|
||||
|
||||
url = f"{LNBITS_URL}/castle/api/v1/entries"
|
||||
url = f"{LNBITS_URL}/libra/api/v1/entries"
|
||||
headers = {
|
||||
"X-Api-Key": api_key,
|
||||
"Content-Type": "application/json"
|
||||
|
|
@ -551,7 +551,7 @@ def import_beancount_file(beancount_file: str, dry_run: bool = False):
|
|||
|
||||
# Validate configuration
|
||||
if not ADMIN_API_KEY:
|
||||
print("❌ Error: CASTLE_ADMIN_KEY not set!")
|
||||
print("❌ Error: LIBRA_ADMIN_KEY not set!")
|
||||
print(" Set it as environment variable or update ADMIN_API_KEY in the script.")
|
||||
return
|
||||
|
||||
|
|
@ -562,7 +562,7 @@ def import_beancount_file(beancount_file: str, dry_run: bool = False):
|
|||
print(f"❌ Error loading rates: {e}")
|
||||
return
|
||||
|
||||
# Load accounts from Castle
|
||||
# Load accounts from Libra
|
||||
try:
|
||||
account_lookup = AccountLookup(LNBITS_URL, ADMIN_API_KEY)
|
||||
except (ConnectionError, ValueError) as e:
|
||||
|
|
@ -574,7 +574,7 @@ def import_beancount_file(beancount_file: str, dry_run: bool = False):
|
|||
for name, user_id in USER_MAPPINGS.items():
|
||||
has_equity = user_id in account_lookup.accounts_by_user and 'equity' in account_lookup.accounts_by_user[user_id]
|
||||
status = "✅" if has_equity else "❌"
|
||||
print(f" {status} {name} → {user_id} {'(has equity account)' if has_equity else '(NO EQUITY ACCOUNT - create in Castle!)'}")
|
||||
print(f" {status} {name} → {user_id} {'(has equity account)' if has_equity else '(NO EQUITY ACCOUNT - create in Libra!)'}")
|
||||
|
||||
# Read beancount file
|
||||
if not os.path.exists(beancount_file):
|
||||
|
|
@ -612,8 +612,8 @@ def import_beancount_file(beancount_file: str, dry_run: bool = False):
|
|||
if not btc_eur_rate:
|
||||
raise ValueError(f"No BTC/EUR rate found for {parsed['date'].date()}")
|
||||
|
||||
castle_entry = convert_to_castle_entry(parsed, btc_eur_rate, account_lookup)
|
||||
result = upload_entry(castle_entry, ADMIN_API_KEY, dry_run)
|
||||
libra_entry = convert_to_libra_entry(parsed, btc_eur_rate, account_lookup)
|
||||
result = upload_entry(libra_entry, ADMIN_API_KEY, dry_run)
|
||||
|
||||
# Get user name for display
|
||||
user_name = None
|
||||
|
|
@ -643,7 +643,7 @@ def import_beancount_file(beancount_file: str, dry_run: bool = False):
|
|||
print(f" {item}")
|
||||
|
||||
if success_count > 0 and not dry_run:
|
||||
print(f"\n✅ Successfully imported {success_count} transactions to Castle!")
|
||||
print(f"\n✅ Successfully imported {success_count} transactions to Libra!")
|
||||
print(f"\n💡 Note: Transactions are stored in EUR with SATS in metadata.")
|
||||
print(f" Check Fava to see the imported entries.")
|
||||
|
||||
|
|
@ -653,7 +653,7 @@ if __name__ == "__main__":
|
|||
import sys
|
||||
|
||||
print("=" * 70)
|
||||
print("🏰 Beancount to Castle Import Script")
|
||||
print("🏰 Beancount to Libra Import Script")
|
||||
print("=" * 70)
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
|
|
@ -664,7 +664,7 @@ if __name__ == "__main__":
|
|||
print("\nConfiguration:")
|
||||
print(f" LNBITS_URL: {LNBITS_URL}")
|
||||
print(f" RATES_CSV: {RATES_CSV_FILE}")
|
||||
print(f" API Key set: {'Yes' if ADMIN_API_KEY else 'No (set CASTLE_ADMIN_KEY env var)'}")
|
||||
print(f" API Key set: {'Yes' if ADMIN_API_KEY else 'No (set LIBRA_ADMIN_KEY env var)'}")
|
||||
sys.exit(1)
|
||||
|
||||
beancount_file = sys.argv[1]
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"repos": [
|
||||
{
|
||||
"id": "castle",
|
||||
"id": "libra",
|
||||
"organisation": "lnbits",
|
||||
"repository": "castle"
|
||||
"repository": "libra"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"""
|
||||
Castle Extension Database Migrations
|
||||
Libra Extension Database Migrations
|
||||
|
||||
This file contains a single squashed migration that creates the complete
|
||||
database schema for the Castle extension.
|
||||
database schema for the Libra extension.
|
||||
|
||||
MIGRATION HISTORY:
|
||||
This is a squashed migration that combines m001-m016 from the original
|
||||
|
|
@ -39,19 +39,19 @@ Original migration sequence (Nov 2025):
|
|||
|
||||
async def m001_initial(db):
|
||||
"""
|
||||
Initial Castle database schema (squashed from m001-m016).
|
||||
Initial Libra database schema (squashed from m001-m016).
|
||||
|
||||
Creates complete database structure for Castle accounting extension:
|
||||
Creates complete database structure for Libra accounting extension:
|
||||
- Accounts: Chart of accounts with hierarchical Beancount-style names
|
||||
- Extension settings: Castle-wide configuration
|
||||
- Extension settings: Libra-wide configuration
|
||||
- User wallet settings: Per-user wallet configuration
|
||||
- Manual payment requests: User-submitted payment requests to Castle
|
||||
- Manual payment requests: User-submitted payment requests to Libra
|
||||
- Balance assertions: Reconciliation and balance checking
|
||||
- User equity status: Equity contribution eligibility
|
||||
- Account permissions: Granular access control
|
||||
|
||||
Note: Journal entries are managed by Fava/Beancount (external source of truth).
|
||||
Castle submits entries to Fava and queries Fava for journal data.
|
||||
Libra submits entries to Fava and queries Fava for journal data.
|
||||
"""
|
||||
|
||||
# =========================================================================
|
||||
|
|
@ -89,15 +89,15 @@ async def m001_initial(db):
|
|||
# =========================================================================
|
||||
# EXTENSION SETTINGS TABLE
|
||||
# =========================================================================
|
||||
# Castle-wide configuration settings
|
||||
# Libra-wide configuration settings
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE extension_settings (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
castle_wallet_id TEXT,
|
||||
libra_wallet_id TEXT,
|
||||
fava_url TEXT NOT NULL DEFAULT 'http://localhost:3333',
|
||||
fava_ledger_slug TEXT NOT NULL DEFAULT 'castle-ledger',
|
||||
fava_ledger_slug TEXT NOT NULL DEFAULT 'libra-ledger',
|
||||
fava_timeout REAL NOT NULL DEFAULT 10.0,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
|
|
@ -122,7 +122,7 @@ async def m001_initial(db):
|
|||
# =========================================================================
|
||||
# MANUAL PAYMENT REQUESTS TABLE
|
||||
# =========================================================================
|
||||
# User-submitted payment requests to Castle (reviewed by admins)
|
||||
# User-submitted payment requests to Libra (reviewed by admins)
|
||||
|
||||
await db.execute(
|
||||
f"""
|
||||
|
|
@ -362,7 +362,7 @@ async def m003_add_account_is_virtual(db):
|
|||
Add is_virtual field to accounts table for virtual parent accounts.
|
||||
|
||||
Virtual parent accounts:
|
||||
- Exist only in Castle DB (metadata-only, not in Beancount)
|
||||
- Exist only in Libra DB (metadata-only, not in Beancount)
|
||||
- Used solely for permission inheritance
|
||||
- Allow granting permissions on top-level accounts like "Expenses", "Assets"
|
||||
- Are not synced to/from Beancount
|
||||
|
|
|
|||
24
models.py
24
models.py
|
|
@ -87,7 +87,7 @@ class CreateJournalEntry(BaseModel):
|
|||
|
||||
class UserBalance(BaseModel):
|
||||
user_id: str
|
||||
balance: int # positive = castle owes user, negative = user owes castle
|
||||
balance: int # positive = libra owes user, negative = user owes libra
|
||||
accounts: list[Account] = []
|
||||
fiat_balances: dict[str, Decimal] = {} # e.g. {"EUR": Decimal("250.0"), "USD": Decimal("100.0")}
|
||||
|
||||
|
|
@ -98,7 +98,7 @@ class ExpenseEntry(BaseModel):
|
|||
description: str
|
||||
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
|
||||
expense_account: str # account name or ID
|
||||
is_equity: bool = False # True = equity contribution, False = liability (castle owes user)
|
||||
is_equity: bool = False # True = equity contribution, False = liability (libra owes user)
|
||||
user_wallet: str
|
||||
reference: Optional[str] = None
|
||||
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code (EUR, USD, etc.)
|
||||
|
|
@ -111,7 +111,7 @@ class ReceivableEntry(BaseModel):
|
|||
description: str
|
||||
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
|
||||
revenue_account: str # account name or ID
|
||||
user_id: str # The user_id (not wallet_id) of the user who owes the castle
|
||||
user_id: str # The user_id (not wallet_id) of the user who owes the libra
|
||||
reference: Optional[str] = None
|
||||
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code
|
||||
|
||||
|
|
@ -127,14 +127,14 @@ class RevenueEntry(BaseModel):
|
|||
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code
|
||||
|
||||
|
||||
class CastleSettings(BaseModel):
|
||||
"""Settings for the Castle extension"""
|
||||
class LibraSettings(BaseModel):
|
||||
"""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_url: str = "http://localhost:3333" # Base URL of Fava server
|
||||
fava_ledger_slug: str = "castle-ledger" # Ledger identifier in Fava URL
|
||||
fava_ledger_slug: str = "libra-ledger" # Ledger identifier in Fava URL
|
||||
fava_timeout: float = 10.0 # Request timeout in seconds
|
||||
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now())
|
||||
|
|
@ -144,7 +144,7 @@ class CastleSettings(BaseModel):
|
|||
return True
|
||||
|
||||
|
||||
class UserCastleSettings(CastleSettings):
|
||||
class UserLibraSettings(LibraSettings):
|
||||
"""User-specific settings (stored with user_id)"""
|
||||
|
||||
id: str
|
||||
|
|
@ -164,7 +164,7 @@ class StoredUserWalletSettings(UserWalletSettings):
|
|||
|
||||
|
||||
class ManualPaymentRequest(BaseModel):
|
||||
"""Manual payment request from user to castle"""
|
||||
"""Manual payment request from user to libra"""
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
|
|
@ -173,7 +173,7 @@ class ManualPaymentRequest(BaseModel):
|
|||
status: str = "pending" # pending, approved, rejected
|
||||
created_at: datetime
|
||||
reviewed_at: Optional[datetime] = None
|
||||
reviewed_by: Optional[str] = None # user_id of castle admin who reviewed
|
||||
reviewed_by: Optional[str] = None # user_id of libra admin who reviewed
|
||||
journal_entry_id: Optional[str] = None # set when approved
|
||||
|
||||
|
||||
|
|
@ -198,7 +198,7 @@ class RecordPayment(BaseModel):
|
|||
|
||||
|
||||
class SettleReceivable(BaseModel):
|
||||
"""Manually settle a receivable (user pays castle in person)"""
|
||||
"""Manually settle a receivable (user pays libra in person)"""
|
||||
|
||||
user_id: str
|
||||
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
|
||||
|
|
@ -213,7 +213,7 @@ class SettleReceivable(BaseModel):
|
|||
|
||||
|
||||
class PayUser(BaseModel):
|
||||
"""Pay a user (castle pays user for expense/liability)"""
|
||||
"""Pay a user (libra pays user for expense/liability)"""
|
||||
|
||||
user_id: str
|
||||
amount: Decimal # Amount in the specified currency (or satoshis if currency is None)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "castle",
|
||||
"name": "libra",
|
||||
"version": "0.0.2",
|
||||
"description": "Accounting for a collective entity",
|
||||
"main": "index.js",
|
||||
|
|
|
|||
|
|
@ -361,7 +361,7 @@ async def get_permission_analytics() -> dict:
|
|||
"""
|
||||
SELECT ap.*, a.name as account_name
|
||||
FROM account_permissions ap
|
||||
JOIN castle_accounts a ON ap.account_id = a.id
|
||||
JOIN libra_accounts a ON ap.account_id = a.id
|
||||
WHERE ap.expires_at IS NOT NULL
|
||||
AND ap.expires_at > :now
|
||||
AND ap.expires_at <= :seven_days
|
||||
|
|
@ -385,7 +385,7 @@ async def get_permission_analytics() -> dict:
|
|||
top_accounts_result = await db.fetchall(
|
||||
"""
|
||||
SELECT a.name, COUNT(ap.id) as permission_count
|
||||
FROM castle_accounts a
|
||||
FROM libra_accounts a
|
||||
LEFT JOIN account_permissions ap ON a.id = ap.account_id
|
||||
GROUP BY a.id, a.name
|
||||
HAVING COUNT(ap.id) > 0
|
||||
|
|
|
|||
22
services.py
22
services.py
|
|
@ -1,32 +1,32 @@
|
|||
from .crud import (
|
||||
create_castle_settings,
|
||||
create_libra_settings,
|
||||
create_user_wallet_settings,
|
||||
get_castle_settings,
|
||||
get_libra_settings,
|
||||
get_or_create_user_account,
|
||||
get_user_wallet_settings,
|
||||
update_castle_settings,
|
||||
update_libra_settings,
|
||||
update_user_wallet_settings,
|
||||
)
|
||||
from .models import AccountType, CastleSettings, UserWalletSettings
|
||||
from .models import AccountType, LibraSettings, UserWalletSettings
|
||||
|
||||
|
||||
async def get_settings(user_id: str) -> CastleSettings:
|
||||
settings = await get_castle_settings(user_id)
|
||||
async def get_settings(user_id: str) -> LibraSettings:
|
||||
settings = await get_libra_settings(user_id)
|
||||
if not settings:
|
||||
settings = await create_castle_settings(user_id, CastleSettings())
|
||||
settings = await create_libra_settings(user_id, LibraSettings())
|
||||
return settings
|
||||
|
||||
|
||||
async def update_settings(user_id: str, data: CastleSettings) -> CastleSettings:
|
||||
async def update_settings(user_id: str, data: LibraSettings) -> LibraSettings:
|
||||
from loguru import logger
|
||||
|
||||
from .fava_client import init_fava_client
|
||||
|
||||
settings = await get_castle_settings(user_id)
|
||||
settings = await get_libra_settings(user_id)
|
||||
if not settings:
|
||||
settings = await create_castle_settings(user_id, data)
|
||||
settings = await create_libra_settings(user_id, data)
|
||||
else:
|
||||
settings = await update_castle_settings(user_id, data)
|
||||
settings = await update_libra_settings(user_id, data)
|
||||
|
||||
# Reinitialize Fava client with new settings
|
||||
try:
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
|
@ -32,7 +32,7 @@ window.app = Vue.createApp({
|
|||
isAdmin: false,
|
||||
isSuperUser: false,
|
||||
settingsLoaded: false, // Flag to prevent race conditions on toolbar buttons
|
||||
castleWalletConfigured: false,
|
||||
libraWalletConfigured: false,
|
||||
userWalletConfigured: false,
|
||||
syncingAccounts: false,
|
||||
currentExchangeRate: null, // BTC/EUR rate (sats per EUR)
|
||||
|
|
@ -58,9 +58,9 @@ window.app = Vue.createApp({
|
|||
},
|
||||
settingsDialog: {
|
||||
show: false,
|
||||
castleWalletId: '',
|
||||
libraWalletId: '',
|
||||
favaUrl: 'http://localhost:3333',
|
||||
favaLedgerSlug: 'castle-ledger',
|
||||
favaLedgerSlug: 'libra-ledger',
|
||||
favaTimeout: 10.0,
|
||||
loading: false
|
||||
},
|
||||
|
|
@ -208,8 +208,8 @@ window.app = Vue.createApp({
|
|||
accountTypeOptions() {
|
||||
return [
|
||||
{ label: 'All Types', value: null },
|
||||
{ label: 'Receivable (User owes Castle)', value: 'asset' },
|
||||
{ label: 'Payable (Castle owes User)', value: 'liability' },
|
||||
{ label: 'Receivable (User owes Libra)', value: 'asset' },
|
||||
{ label: 'Payable (Libra owes User)', value: 'liability' },
|
||||
{ label: 'Equity (User Balance)', value: 'equity' }
|
||||
]
|
||||
},
|
||||
|
|
@ -318,7 +318,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'GET',
|
||||
'/castle/api/v1/balance',
|
||||
'/libra/api/v1/balance',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
this.balance = response.data
|
||||
|
|
@ -341,7 +341,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'GET',
|
||||
'/castle/api/v1/balances/all',
|
||||
'/libra/api/v1/balances/all',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
this.allUserBalances = response.data
|
||||
|
|
@ -389,7 +389,7 @@ window.app = Vue.createApp({
|
|||
|
||||
const response = await LNbits.api.request(
|
||||
'GET',
|
||||
`/castle/api/v1/entries/user?${queryParams}`,
|
||||
`/libra/api/v1/entries/user?${queryParams}`,
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
|
||||
|
|
@ -458,7 +458,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'GET',
|
||||
'/castle/api/v1/accounts?filter_by_user=true&exclude_virtual=true',
|
||||
'/libra/api/v1/accounts?filter_by_user=true&exclude_virtual=true',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
this.accounts = response.data
|
||||
|
|
@ -472,7 +472,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'GET',
|
||||
'/castle/api/v1/currencies',
|
||||
'/libra/api/v1/currencies',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
this.currencies = response.data
|
||||
|
|
@ -484,7 +484,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'GET',
|
||||
'/castle/api/v1/users',
|
||||
'/libra/api/v1/users',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
this.users = response.data
|
||||
|
|
@ -496,7 +496,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'GET',
|
||||
'/castle/api/v1/user/info',
|
||||
'/libra/api/v1/user/info',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
this.userInfo = response.data
|
||||
|
|
@ -510,18 +510,18 @@ window.app = Vue.createApp({
|
|||
// Try with admin key first to check settings
|
||||
const response = await LNbits.api.request(
|
||||
'GET',
|
||||
'/castle/api/v1/settings',
|
||||
'/libra/api/v1/settings',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
this.settings = response.data
|
||||
this.castleWalletConfigured = !!(this.settings && this.settings.castle_wallet_id)
|
||||
this.libraWalletConfigured = !!(this.settings && this.settings.libra_wallet_id)
|
||||
|
||||
// Check if user is super user by seeing if they can access admin features
|
||||
this.isSuperUser = this.g.user.super_user || false
|
||||
this.isAdmin = this.g.user.admin || this.isSuperUser
|
||||
} catch (error) {
|
||||
// Settings not available
|
||||
this.castleWalletConfigured = false
|
||||
this.libraWalletConfigured = false
|
||||
} finally {
|
||||
// Mark settings as loaded to enable toolbar buttons
|
||||
this.settingsLoaded = true
|
||||
|
|
@ -531,7 +531,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'GET',
|
||||
'/castle/api/v1/user/wallet',
|
||||
'/libra/api/v1/user/wallet',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
this.userWalletSettings = response.data
|
||||
|
|
@ -545,7 +545,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'POST',
|
||||
'/castle/api/v1/admin/accounts/sync',
|
||||
'/libra/api/v1/admin/accounts/sync',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
const errors = (data?.errors || []).length
|
||||
|
|
@ -567,9 +567,9 @@ window.app = Vue.createApp({
|
|||
}
|
||||
},
|
||||
showSettingsDialog() {
|
||||
this.settingsDialog.castleWalletId = this.settings?.castle_wallet_id || ''
|
||||
this.settingsDialog.libraWalletId = this.settings?.libra_wallet_id || ''
|
||||
this.settingsDialog.favaUrl = this.settings?.fava_url || 'http://localhost:3333'
|
||||
this.settingsDialog.favaLedgerSlug = this.settings?.fava_ledger_slug || 'castle-ledger'
|
||||
this.settingsDialog.favaLedgerSlug = this.settings?.fava_ledger_slug || 'libra-ledger'
|
||||
this.settingsDialog.favaTimeout = this.settings?.fava_timeout || 10.0
|
||||
this.settingsDialog.show = true
|
||||
},
|
||||
|
|
@ -578,10 +578,10 @@ window.app = Vue.createApp({
|
|||
this.userWalletDialog.show = true
|
||||
},
|
||||
async submitSettings() {
|
||||
if (!this.settingsDialog.castleWalletId) {
|
||||
if (!this.settingsDialog.libraWalletId) {
|
||||
this.$q.notify({
|
||||
type: 'warning',
|
||||
message: 'Castle Wallet ID is required'
|
||||
message: 'Libra Wallet ID is required'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
|
@ -598,12 +598,12 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
'/castle/api/v1/settings',
|
||||
'/libra/api/v1/settings',
|
||||
this.g.user.wallets[0].adminkey,
|
||||
{
|
||||
castle_wallet_id: this.settingsDialog.castleWalletId,
|
||||
libra_wallet_id: this.settingsDialog.libraWalletId,
|
||||
fava_url: this.settingsDialog.favaUrl,
|
||||
fava_ledger_slug: this.settingsDialog.favaLedgerSlug || 'castle-ledger',
|
||||
fava_ledger_slug: this.settingsDialog.favaLedgerSlug || 'libra-ledger',
|
||||
fava_timeout: parseFloat(this.settingsDialog.favaTimeout) || 10.0
|
||||
}
|
||||
)
|
||||
|
|
@ -613,7 +613,7 @@ window.app = Vue.createApp({
|
|||
})
|
||||
this.settingsDialog.show = false
|
||||
await this.loadSettings()
|
||||
// Reload user wallet to reflect castle wallet for super user
|
||||
// Reload user wallet to reflect libra wallet for super user
|
||||
if (this.isSuperUser) {
|
||||
await this.loadUserWallet()
|
||||
}
|
||||
|
|
@ -636,7 +636,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
'/castle/api/v1/user/wallet',
|
||||
'/libra/api/v1/user/wallet',
|
||||
this.g.user.wallets[0].inkey,
|
||||
{
|
||||
user_wallet_id: this.userWalletDialog.userWalletId
|
||||
|
|
@ -659,7 +659,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
'/castle/api/v1/entries/expense',
|
||||
'/libra/api/v1/entries/expense',
|
||||
this.g.user.wallets[0].inkey,
|
||||
{
|
||||
description: this.expenseDialog.description,
|
||||
|
|
@ -696,10 +696,10 @@ window.app = Vue.createApp({
|
|||
}
|
||||
|
||||
try {
|
||||
// Generate an invoice on the Castle wallet
|
||||
// Generate an invoice on the Libra wallet
|
||||
const response = await LNbits.api.request(
|
||||
'POST',
|
||||
'/castle/api/v1/generate-payment-invoice',
|
||||
'/libra/api/v1/generate-payment-invoice',
|
||||
this.g.user.wallets[0].inkey,
|
||||
{
|
||||
amount: this.payDialog.amount
|
||||
|
|
@ -745,7 +745,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
'/castle/api/v1/record-payment',
|
||||
'/libra/api/v1/record-payment',
|
||||
this.g.user.wallets[0].inkey,
|
||||
{
|
||||
payment_hash: paymentHash
|
||||
|
|
@ -788,15 +788,15 @@ window.app = Vue.createApp({
|
|||
},
|
||||
showManualPaymentOption() {
|
||||
// This is for when user wants to pay their debt manually
|
||||
// For now, just notify them to contact castle
|
||||
// For now, just notify them to contact libra
|
||||
this.$q.notify({
|
||||
type: 'info',
|
||||
message: 'Please contact Castle directly to arrange manual payment.',
|
||||
message: 'Please contact Libra directly to arrange manual payment.',
|
||||
timeout: 3000
|
||||
})
|
||||
},
|
||||
showManualPaymentDialog() {
|
||||
// This is for when Castle owes user and they want to request manual payment
|
||||
// This is for when Libra owes user and they want to request manual payment
|
||||
this.manualPaymentDialog.amount = Math.abs(this.balance.balance)
|
||||
this.manualPaymentDialog.description = ''
|
||||
this.manualPaymentDialog.show = true
|
||||
|
|
@ -806,7 +806,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
'/castle/api/v1/manual-payment-request',
|
||||
'/libra/api/v1/manual-payment-request',
|
||||
this.g.user.wallets[0].inkey,
|
||||
{
|
||||
amount: this.manualPaymentDialog.amount,
|
||||
|
|
@ -831,8 +831,8 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
// If super user, load all requests; otherwise load user's own requests
|
||||
const endpoint = this.isSuperUser
|
||||
? '/castle/api/v1/manual-payment-requests/all'
|
||||
: '/castle/api/v1/manual-payment-requests'
|
||||
? '/libra/api/v1/manual-payment-requests/all'
|
||||
: '/libra/api/v1/manual-payment-requests'
|
||||
const key = this.isSuperUser
|
||||
? this.g.user.wallets[0].adminkey
|
||||
: this.g.user.wallets[0].inkey
|
||||
|
|
@ -855,7 +855,7 @@ window.app = Vue.createApp({
|
|||
|
||||
const response = await LNbits.api.request(
|
||||
'GET',
|
||||
'/castle/api/v1/entries/pending',
|
||||
'/libra/api/v1/entries/pending',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
this.pendingExpenses = response.data
|
||||
|
|
@ -867,7 +867,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
`/castle/api/v1/manual-payment-requests/${requestId}/approve`,
|
||||
`/libra/api/v1/manual-payment-requests/${requestId}/approve`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
this.$q.notify({
|
||||
|
|
@ -885,7 +885,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
`/castle/api/v1/manual-payment-requests/${requestId}/reject`,
|
||||
`/libra/api/v1/manual-payment-requests/${requestId}/reject`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
this.$q.notify({
|
||||
|
|
@ -901,7 +901,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
`/castle/api/v1/entries/${entryId}/approve`,
|
||||
`/libra/api/v1/entries/${entryId}/approve`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
this.$q.notify({
|
||||
|
|
@ -920,7 +920,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
`/castle/api/v1/entries/${entryId}/reject`,
|
||||
`/libra/api/v1/entries/${entryId}/reject`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
this.$q.notify({
|
||||
|
|
@ -939,7 +939,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'GET',
|
||||
'/castle/api/v1/assertions',
|
||||
'/libra/api/v1/assertions',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
this.balanceAssertions = response.data
|
||||
|
|
@ -965,7 +965,7 @@ window.app = Vue.createApp({
|
|||
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
'/castle/api/v1/assertions',
|
||||
'/libra/api/v1/assertions',
|
||||
this.g.user.wallets[0].adminkey,
|
||||
payload
|
||||
)
|
||||
|
|
@ -1014,7 +1014,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
`/castle/api/v1/assertions/${assertionId}/check`,
|
||||
`/libra/api/v1/assertions/${assertionId}/check`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
|
||||
|
|
@ -1033,7 +1033,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
await LNbits.api.request(
|
||||
'DELETE',
|
||||
`/castle/api/v1/assertions/${assertionId}`,
|
||||
`/libra/api/v1/assertions/${assertionId}`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
|
||||
|
|
@ -1062,7 +1062,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'GET',
|
||||
'/castle/api/v1/reconciliation/summary',
|
||||
'/libra/api/v1/reconciliation/summary',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
this.reconciliation.summary = response.data
|
||||
|
|
@ -1076,7 +1076,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'GET',
|
||||
'/castle/api/v1/reconciliation/discrepancies',
|
||||
'/libra/api/v1/reconciliation/discrepancies',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
this.reconciliation.discrepancies = response.data
|
||||
|
|
@ -1089,7 +1089,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'POST',
|
||||
'/castle/api/v1/reconciliation/check-all',
|
||||
'/libra/api/v1/reconciliation/check-all',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
|
||||
|
|
@ -1143,7 +1143,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
'/castle/api/v1/entries/receivable',
|
||||
'/libra/api/v1/entries/receivable',
|
||||
this.g.user.wallets[0].adminkey,
|
||||
{
|
||||
description: this.receivableDialog.description,
|
||||
|
|
@ -1186,7 +1186,7 @@ window.app = Vue.createApp({
|
|||
this.receivableDialog.currency = null
|
||||
},
|
||||
async showSettleReceivableDialog(userBalance) {
|
||||
// Only show for users who owe castle (positive balance = receivable)
|
||||
// Only show for users who owe libra (positive balance = receivable)
|
||||
if (userBalance.balance <= 0) return
|
||||
|
||||
// Clear any existing polling
|
||||
|
|
@ -1202,19 +1202,19 @@ window.app = Vue.createApp({
|
|||
// Fetch unsettled entries for this user (BOTH receivables AND expenses for net settlement)
|
||||
let allEntryLinks = []
|
||||
try {
|
||||
// Fetch receivable entries (user owes castle)
|
||||
// Fetch receivable entries (user owes libra)
|
||||
const receivableResponse = await LNbits.api.request(
|
||||
'GET',
|
||||
`/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`,
|
||||
`/libra/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
const receivableEntries = receivableResponse.data.unsettled_entries || []
|
||||
allEntryLinks.push(...receivableEntries.map(e => e.link).filter(l => l))
|
||||
|
||||
// Also fetch expense entries (castle owes user) - these are netted in the settlement
|
||||
// Also fetch expense entries (libra owes user) - these are netted in the settlement
|
||||
const expenseResponse = await LNbits.api.request(
|
||||
'GET',
|
||||
`/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`,
|
||||
`/libra/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
const expenseEntries = expenseResponse.data.unsettled_entries || []
|
||||
|
|
@ -1254,10 +1254,10 @@ window.app = Vue.createApp({
|
|||
}
|
||||
|
||||
try {
|
||||
// Generate an invoice on the Castle wallet for the user to pay
|
||||
// Generate an invoice on the Libra wallet for the user to pay
|
||||
const response = await LNbits.api.request(
|
||||
'POST',
|
||||
'/castle/api/v1/generate-payment-invoice',
|
||||
'/libra/api/v1/generate-payment-invoice',
|
||||
this.g.user.wallets[0].adminkey,
|
||||
{
|
||||
amount: this.settleReceivableDialog.amount,
|
||||
|
|
@ -1384,7 +1384,7 @@ window.app = Vue.createApp({
|
|||
|
||||
const response = await LNbits.api.request(
|
||||
'POST',
|
||||
'/castle/api/v1/receivables/settle',
|
||||
'/libra/api/v1/receivables/settle',
|
||||
this.g.user.wallets[0].adminkey,
|
||||
payload
|
||||
)
|
||||
|
|
@ -1408,7 +1408,7 @@ window.app = Vue.createApp({
|
|||
}
|
||||
},
|
||||
async showPayUserDialog(userBalance) {
|
||||
// Only show for users castle owes (negative balance = payable)
|
||||
// Only show for users libra owes (negative balance = payable)
|
||||
if (userBalance.balance >= 0) return
|
||||
|
||||
// Extract fiat balances (e.g., EUR)
|
||||
|
|
@ -1416,26 +1416,26 @@ window.app = Vue.createApp({
|
|||
const fiatCurrency = Object.keys(fiatBalances)[0] || null
|
||||
const fiatAmount = fiatCurrency ? fiatBalances[fiatCurrency] : 0
|
||||
|
||||
// Use absolute values since balance is negative (liability = castle owes user)
|
||||
// Use absolute values since balance is negative (liability = libra owes user)
|
||||
const maxAmountSats = Math.abs(userBalance.balance)
|
||||
const maxAmountFiat = Math.abs(fiatAmount)
|
||||
|
||||
// Fetch unsettled entries for this user (BOTH expenses AND receivables for net settlement)
|
||||
let allEntryLinks = []
|
||||
try {
|
||||
// Fetch expense entries (castle owes user)
|
||||
// Fetch expense entries (libra owes user)
|
||||
const expenseResponse = await LNbits.api.request(
|
||||
'GET',
|
||||
`/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`,
|
||||
`/libra/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=expense`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
const expenseEntries = expenseResponse.data.unsettled_entries || []
|
||||
allEntryLinks.push(...expenseEntries.map(e => e.link).filter(l => l))
|
||||
|
||||
// Also fetch receivable entries (user owes castle) - these are netted in the settlement
|
||||
// Also fetch receivable entries (user owes libra) - these are netted in the settlement
|
||||
const receivableResponse = await LNbits.api.request(
|
||||
'GET',
|
||||
`/castle/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`,
|
||||
`/libra/api/v1/users/${userBalance.user_id}/unsettled-entries?entry_type=receivable`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
const receivableEntries = receivableResponse.data.unsettled_entries || []
|
||||
|
|
@ -1448,7 +1448,7 @@ window.app = Vue.createApp({
|
|||
show: true,
|
||||
user_id: userBalance.user_id,
|
||||
username: userBalance.username,
|
||||
maxAmount: maxAmountSats, // Positive sats amount castle owes
|
||||
maxAmount: maxAmountSats, // Positive sats amount libra owes
|
||||
maxAmountFiat: maxAmountFiat, // EUR or other fiat amount (positive)
|
||||
fiatCurrency: fiatCurrency,
|
||||
amount: maxAmountSats, // Default to sats since lightning is the default payment method
|
||||
|
|
@ -1480,14 +1480,14 @@ window.app = Vue.createApp({
|
|||
{
|
||||
out: false,
|
||||
amount: this.payUserDialog.amount,
|
||||
memo: `Payment from Castle to ${this.payUserDialog.username}`
|
||||
memo: `Payment from Libra to ${this.payUserDialog.username}`
|
||||
}
|
||||
)
|
||||
console.log(invoiceResponse)
|
||||
|
||||
const paymentRequest = invoiceResponse.data.bolt11
|
||||
|
||||
// Pay the invoice from Castle's wallet
|
||||
// Pay the invoice from Libra's wallet
|
||||
const paymentResponse = await LNbits.api.request(
|
||||
'POST',
|
||||
`/api/v1/payments`,
|
||||
|
|
@ -1498,7 +1498,7 @@ window.app = Vue.createApp({
|
|||
}
|
||||
)
|
||||
|
||||
// Record the payment in Castle accounting
|
||||
// Record the payment in Libra accounting
|
||||
const payPayload = {
|
||||
user_id: this.payUserDialog.user_id,
|
||||
amount: this.payUserDialog.amount,
|
||||
|
|
@ -1513,7 +1513,7 @@ window.app = Vue.createApp({
|
|||
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
'/castle/api/v1/payables/pay',
|
||||
'/libra/api/v1/payables/pay',
|
||||
this.g.user.wallets[0].adminkey,
|
||||
payPayload
|
||||
)
|
||||
|
|
@ -1579,7 +1579,7 @@ window.app = Vue.createApp({
|
|||
|
||||
const response = await LNbits.api.request(
|
||||
'POST',
|
||||
'/castle/api/v1/payables/pay',
|
||||
'/libra/api/v1/payables/pay',
|
||||
this.g.user.wallets[0].adminkey,
|
||||
payload
|
||||
)
|
||||
|
|
@ -1606,7 +1606,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'GET',
|
||||
`/castle/api/v1/user-wallet/${userId}`,
|
||||
`/libra/api/v1/user-wallet/${userId}`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
return response.data
|
||||
|
|
@ -1663,13 +1663,13 @@ window.app = Vue.createApp({
|
|||
return null
|
||||
},
|
||||
isReceivable(entry) {
|
||||
// Check if this is a receivable entry (user owes castle)
|
||||
// Check if this is a receivable entry (user owes libra)
|
||||
if (entry.tags && entry.tags.includes('receivable-entry')) return true
|
||||
if (entry.account && entry.account.includes('Receivable')) return true
|
||||
return false
|
||||
},
|
||||
isPayable(entry) {
|
||||
// Check if this is a payable entry (castle owes user)
|
||||
// Check if this is a payable entry (libra owes user)
|
||||
if (entry.tags && entry.tags.includes('expense-entry')) return true
|
||||
if (entry.account && entry.account.includes('Payable')) return true
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -206,7 +206,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'GET',
|
||||
'/castle/api/v1/admin/permissions',
|
||||
'/libra/api/v1/admin/permissions',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
this.permissions = response.data
|
||||
|
|
@ -228,7 +228,7 @@ window.app = Vue.createApp({
|
|||
// Admin permissions UI needs to see virtual accounts to grant permissions on them
|
||||
const response = await LNbits.api.request(
|
||||
'GET',
|
||||
'/castle/api/v1/accounts?exclude_virtual=false',
|
||||
'/libra/api/v1/accounts?exclude_virtual=false',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
this.accounts = response.data
|
||||
|
|
@ -251,7 +251,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'GET',
|
||||
'/castle/api/v1/admin/castle-users',
|
||||
'/libra/api/v1/admin/libra-users',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
this.users = response.data || []
|
||||
|
|
@ -318,7 +318,7 @@ window.app = Vue.createApp({
|
|||
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
'/castle/api/v1/admin/permissions',
|
||||
'/libra/api/v1/admin/permissions',
|
||||
this.g.user.wallets[0].adminkey,
|
||||
payload
|
||||
)
|
||||
|
|
@ -357,7 +357,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
await LNbits.api.request(
|
||||
'DELETE',
|
||||
`/castle/api/v1/admin/permissions/${this.permissionToRevoke.id}`,
|
||||
`/libra/api/v1/admin/permissions/${this.permissionToRevoke.id}`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
|
||||
|
|
@ -428,7 +428,7 @@ window.app = Vue.createApp({
|
|||
|
||||
const response = await LNbits.api.request(
|
||||
'POST',
|
||||
'/castle/api/v1/admin/permissions/bulk-grant',
|
||||
'/libra/api/v1/admin/permissions/bulk-grant',
|
||||
this.g.user.wallets[0].adminkey,
|
||||
payload
|
||||
)
|
||||
|
|
@ -535,7 +535,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'GET',
|
||||
'/castle/api/v1/admin/equity-eligibility',
|
||||
'/libra/api/v1/admin/equity-eligibility',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
this.equityEligibleUsers = response.data || []
|
||||
|
|
@ -573,7 +573,7 @@ window.app = Vue.createApp({
|
|||
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
'/castle/api/v1/admin/equity-eligibility',
|
||||
'/libra/api/v1/admin/equity-eligibility',
|
||||
this.g.user.wallets[0].adminkey,
|
||||
payload
|
||||
)
|
||||
|
|
@ -612,7 +612,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
await LNbits.api.request(
|
||||
'DELETE',
|
||||
`/castle/api/v1/admin/equity-eligibility/${this.equityToRevoke.user_id}`,
|
||||
`/libra/api/v1/admin/equity-eligibility/${this.equityToRevoke.user_id}`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
|
||||
|
|
@ -655,7 +655,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'GET',
|
||||
'/castle/api/v1/admin/roles',
|
||||
'/libra/api/v1/admin/roles',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
this.roles = response.data || []
|
||||
|
|
@ -678,7 +678,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'GET',
|
||||
`/castle/api/v1/admin/roles/${role.id}`,
|
||||
`/libra/api/v1/admin/roles/${role.id}`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
|
||||
|
|
@ -733,7 +733,7 @@ window.app = Vue.createApp({
|
|||
// Update existing role
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
`/castle/api/v1/admin/roles/${this.selectedRole.id}`,
|
||||
`/libra/api/v1/admin/roles/${this.selectedRole.id}`,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
payload
|
||||
)
|
||||
|
|
@ -747,7 +747,7 @@ window.app = Vue.createApp({
|
|||
// Create new role
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
'/castle/api/v1/admin/roles',
|
||||
'/libra/api/v1/admin/roles',
|
||||
this.g.user.wallets[0].adminkey,
|
||||
payload
|
||||
)
|
||||
|
|
@ -786,7 +786,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
await LNbits.api.request(
|
||||
'DELETE',
|
||||
`/castle/api/v1/admin/roles/${this.roleToDelete.id}`,
|
||||
`/libra/api/v1/admin/roles/${this.roleToDelete.id}`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
|
||||
|
|
@ -862,7 +862,7 @@ window.app = Vue.createApp({
|
|||
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
'/castle/api/v1/admin/user-roles',
|
||||
'/libra/api/v1/admin/user-roles',
|
||||
this.g.user.wallets[0].adminkey,
|
||||
payload
|
||||
)
|
||||
|
|
@ -920,7 +920,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
const response = await LNbits.api.request(
|
||||
'GET',
|
||||
'/castle/api/v1/admin/users/roles',
|
||||
'/libra/api/v1/admin/users/roles',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
|
||||
|
|
@ -984,7 +984,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
await LNbits.api.request(
|
||||
'DELETE',
|
||||
`/castle/api/v1/admin/user-roles/${this.userRoleToRevoke.id}`,
|
||||
`/libra/api/v1/admin/user-roles/${this.userRoleToRevoke.id}`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
|
||||
|
|
@ -1033,7 +1033,7 @@ window.app = Vue.createApp({
|
|||
}
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
`/castle/api/v1/admin/roles/${this.selectedRole.id}/permissions`,
|
||||
`/libra/api/v1/admin/roles/${this.selectedRole.id}/permissions`,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
payload
|
||||
)
|
||||
|
|
@ -1067,7 +1067,7 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
await LNbits.api.request(
|
||||
'DELETE',
|
||||
`/castle/api/v1/admin/roles/${this.selectedRole.id}/permissions/${permissionId}`,
|
||||
`/libra/api/v1/admin/roles/${this.selectedRole.id}/permissions/${permissionId}`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
// Reload role permissions
|
||||
|
|
|
|||
64
tasks.py
64
tasks.py
|
|
@ -1,5 +1,5 @@
|
|||
"""
|
||||
Background tasks for Castle accounting extension.
|
||||
Background tasks for Libra accounting extension.
|
||||
These tasks handle automated reconciliation checks and maintenance.
|
||||
"""
|
||||
|
||||
|
|
@ -62,11 +62,11 @@ async def check_all_balance_assertions() -> dict:
|
|||
|
||||
# Log results
|
||||
if results["failed"] > 0:
|
||||
print(f"[CASTLE] Daily reconciliation check: {results['failed']} FAILED assertions!")
|
||||
print(f"[LIBRA] Daily reconciliation check: {results['failed']} FAILED assertions!")
|
||||
for failed in results["failed_assertions"]:
|
||||
print(f" - Account {failed['account_id']}: expected {failed['expected_sats']}, got {failed['actual_sats']}")
|
||||
else:
|
||||
print(f"[CASTLE] Daily reconciliation check: All {results['passed']} assertions passed ✓")
|
||||
print(f"[LIBRA] Daily reconciliation check: All {results['passed']} assertions passed ✓")
|
||||
|
||||
return results
|
||||
|
||||
|
|
@ -78,7 +78,7 @@ async def scheduled_daily_reconciliation():
|
|||
This function is meant to be called by a scheduler (cron, systemd timer, etc.)
|
||||
or by LNbits background task system.
|
||||
"""
|
||||
print(f"[CASTLE] Running scheduled daily reconciliation check at {datetime.now()}")
|
||||
print(f"[LIBRA] Running scheduled daily reconciliation check at {datetime.now()}")
|
||||
|
||||
try:
|
||||
results = await check_all_balance_assertions()
|
||||
|
|
@ -86,38 +86,38 @@ async def scheduled_daily_reconciliation():
|
|||
# TODO: Send notifications if there are failures
|
||||
# This could send email, webhook, or in-app notification
|
||||
if results["failed"] > 0:
|
||||
print(f"[CASTLE] WARNING: {results['failed']} balance assertions failed!")
|
||||
print(f"[LIBRA] WARNING: {results['failed']} balance assertions failed!")
|
||||
# Future: Send alert notification
|
||||
|
||||
return results
|
||||
except Exception as e:
|
||||
print(f"[CASTLE] Error in scheduled reconciliation: {e}")
|
||||
print(f"[LIBRA] Error in scheduled reconciliation: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def scheduled_account_sync():
|
||||
"""
|
||||
Scheduled task that runs hourly to sync accounts from Beancount to Castle DB.
|
||||
Scheduled task that runs hourly to sync accounts from Beancount to Libra DB.
|
||||
|
||||
This ensures Castle DB stays in sync with Beancount (source of truth) by
|
||||
automatically adding any new accounts created in Beancount to Castle's
|
||||
This ensures Libra DB stays in sync with Beancount (source of truth) by
|
||||
automatically adding any new accounts created in Beancount to Libra's
|
||||
metadata database for permission tracking.
|
||||
"""
|
||||
from .account_sync import sync_accounts_from_beancount
|
||||
|
||||
logger.info(f"[CASTLE] Running scheduled account sync at {datetime.now()}")
|
||||
logger.info(f"[LIBRA] Running scheduled account sync at {datetime.now()}")
|
||||
|
||||
try:
|
||||
stats = await sync_accounts_from_beancount(force_full_sync=False)
|
||||
|
||||
if stats["accounts_added"] > 0:
|
||||
logger.info(
|
||||
f"[CASTLE] Account sync: Added {stats['accounts_added']} new accounts"
|
||||
f"[LIBRA] Account sync: Added {stats['accounts_added']} new accounts"
|
||||
)
|
||||
|
||||
if stats["errors"]:
|
||||
logger.warning(
|
||||
f"[CASTLE] Account sync: {len(stats['errors'])} errors encountered"
|
||||
f"[LIBRA] Account sync: {len(stats['errors'])} errors encountered"
|
||||
)
|
||||
for error in stats["errors"][:5]: # Log first 5 errors
|
||||
logger.error(f" - {error}")
|
||||
|
|
@ -125,24 +125,24 @@ async def scheduled_account_sync():
|
|||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[CASTLE] Error in scheduled account sync: {e}")
|
||||
logger.error(f"[LIBRA] Error in scheduled account sync: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def wait_for_account_sync():
|
||||
"""
|
||||
Background task that periodically syncs accounts from Beancount to Castle DB.
|
||||
Background task that periodically syncs accounts from Beancount to Libra DB.
|
||||
|
||||
Runs hourly to ensure Castle DB stays in sync with Beancount.
|
||||
Runs hourly to ensure Libra DB stays in sync with Beancount.
|
||||
"""
|
||||
logger.info("[CASTLE] Account sync background task started")
|
||||
logger.info("[LIBRA] Account sync background task started")
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Run sync
|
||||
await scheduled_account_sync()
|
||||
except Exception as e:
|
||||
logger.error(f"[CASTLE] Account sync error: {e}")
|
||||
logger.error(f"[LIBRA] Account sync error: {e}")
|
||||
|
||||
# Wait 1 hour before next sync
|
||||
await asyncio.sleep(3600) # 3600 seconds = 1 hour
|
||||
|
|
@ -157,9 +157,9 @@ def start_daily_reconciliation_task():
|
|||
|
||||
For cron setup:
|
||||
# Run daily at 2 AM
|
||||
0 2 * * * curl -X POST http://localhost:5000/castle/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY"
|
||||
0 2 * * * curl -X POST http://localhost:5000/libra/api/v1/tasks/daily-reconciliation -H "X-Api-Key: YOUR_ADMIN_KEY"
|
||||
"""
|
||||
print("[CASTLE] Daily reconciliation task registered")
|
||||
print("[LIBRA] Daily reconciliation task registered")
|
||||
# In a production system, you would register this with LNbits task scheduler
|
||||
# For now, it can be triggered manually via API endpoint
|
||||
|
||||
|
|
@ -173,7 +173,7 @@ async def wait_for_paid_invoices():
|
|||
before the payment is detected by client-side polling.
|
||||
"""
|
||||
invoice_queue = Queue()
|
||||
register_invoice_listener(invoice_queue, "ext_castle")
|
||||
register_invoice_listener(invoice_queue, "ext_libra")
|
||||
|
||||
while True:
|
||||
payment = await invoice_queue.get()
|
||||
|
|
@ -182,10 +182,10 @@ async def wait_for_paid_invoices():
|
|||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
"""
|
||||
Handle a paid Castle invoice by automatically submitting to Fava.
|
||||
Handle a paid Libra invoice by automatically submitting to Fava.
|
||||
|
||||
This function is called automatically when any invoice on the Castle wallet
|
||||
is paid. It checks if the invoice is a Castle payment and records it in
|
||||
This function is called automatically when any invoice on the Libra wallet
|
||||
is paid. It checks if the invoice is a Libra payment and records it in
|
||||
Beancount via Fava.
|
||||
|
||||
Concurrency Protection:
|
||||
|
|
@ -194,13 +194,13 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
- Uses idempotent entry creation to prevent duplicate entries even if
|
||||
the same payment is processed multiple times
|
||||
"""
|
||||
# Only process Castle-specific payments
|
||||
if not payment.extra or payment.extra.get("tag") != "castle":
|
||||
# Only process Libra-specific payments
|
||||
if not payment.extra or payment.extra.get("tag") != "libra":
|
||||
return
|
||||
|
||||
user_id = payment.extra.get("user_id")
|
||||
if not user_id:
|
||||
logger.warning(f"Castle invoice {payment.payment_hash} missing user_id in metadata")
|
||||
logger.warning(f"Libra invoice {payment.payment_hash} missing user_id in metadata")
|
||||
return
|
||||
|
||||
from .fava_client import get_fava_client
|
||||
|
|
@ -216,7 +216,7 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
user_lock = fava.get_user_lock(user_id)
|
||||
|
||||
async with user_lock:
|
||||
logger.info(f"Recording Castle payment {payment.payment_hash} for user {user_id[:8]} to Fava")
|
||||
logger.info(f"Recording Libra payment {payment.payment_hash} for user {user_id[:8]} to Fava")
|
||||
|
||||
try:
|
||||
from decimal import Decimal
|
||||
|
|
@ -246,14 +246,14 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
total_fiat_balance = fiat_balances.get(fiat_currency, Decimal(0))
|
||||
|
||||
# Determine receivables and payables based on balance
|
||||
# Positive balance = user owes castle (receivable)
|
||||
# Negative balance = castle owes user (payable)
|
||||
# Positive balance = user owes libra (receivable)
|
||||
# Negative balance = libra owes user (payable)
|
||||
if total_fiat_balance > 0:
|
||||
# User owes castle
|
||||
# User owes libra
|
||||
total_receivable = total_fiat_balance
|
||||
total_payable = Decimal(0)
|
||||
else:
|
||||
# Castle owes user
|
||||
# Libra owes user
|
||||
total_receivable = Decimal(0)
|
||||
total_payable = abs(total_fiat_balance)
|
||||
|
||||
|
|
@ -318,5 +318,5 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error recording Castle payment {payment.payment_hash}: {e}")
|
||||
logger.error(f"Error recording Libra payment {payment.payment_hash}: {e}")
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
{% block scripts %}
|
||||
{{ 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 %}
|
||||
|
||||
{% block page %}
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
<q-card-section>
|
||||
<div class="row items-center no-wrap">
|
||||
<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>
|
||||
</div>
|
||||
<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-tooltip>Configure Your Wallet</q-tooltip>
|
||||
</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-btn>
|
||||
<q-btn v-if="settingsLoaded && isSuperUser" flat round icon="sync" :loading="syncingAccounts" @click="syncAccounts">
|
||||
<q-tooltip>Sync Accounts from Beancount</q-tooltip>
|
||||
</q-btn>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -36,19 +36,19 @@
|
|||
</q-card>
|
||||
|
||||
<!-- 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>
|
||||
<q-icon name="warning" color="white"></q-icon>
|
||||
</template>
|
||||
<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>
|
||||
<template v-slot:action>
|
||||
<q-btn flat color="white" label="Configure Now" @click="showSettingsDialog"></q-btn>
|
||||
</template>
|
||||
</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>
|
||||
<q-icon name="info" color="white"></q-icon>
|
||||
</template>
|
||||
|
|
@ -57,7 +57,7 @@
|
|||
</div>
|
||||
</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>
|
||||
<q-icon name="account_balance_wallet" color="white"></q-icon>
|
||||
</template>
|
||||
|
|
@ -131,13 +131,13 @@
|
|||
<q-btn
|
||||
color="primary"
|
||||
@click="expenseDialog.show = true"
|
||||
:disable="!castleWalletConfigured || (!userWalletConfigured && !isSuperUser)"
|
||||
:disable="!libraWalletConfigured || (!userWalletConfigured && !isSuperUser)"
|
||||
>
|
||||
Add Expense
|
||||
<q-tooltip v-if="!castleWalletConfigured">
|
||||
Castle wallet must be configured first
|
||||
<q-tooltip v-if="!libraWalletConfigured">
|
||||
Libra wallet must be configured first
|
||||
</q-tooltip>
|
||||
<q-tooltip v-if="castleWalletConfigured && !userWalletConfigured && !isSuperUser">
|
||||
<q-tooltip v-if="libraWalletConfigured && !userWalletConfigured && !isSuperUser">
|
||||
You must configure your wallet first
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
|
|
@ -145,14 +145,14 @@
|
|||
v-if="isSuperUser"
|
||||
color="orange"
|
||||
@click="showReceivableDialog"
|
||||
:disable="!castleWalletConfigured"
|
||||
:disable="!libraWalletConfigured"
|
||||
>
|
||||
Add Receivable
|
||||
<q-tooltip v-if="!castleWalletConfigured">
|
||||
Castle wallet must be configured first
|
||||
<q-tooltip v-if="!libraWalletConfigured">
|
||||
Libra wallet must be configured first
|
||||
</q-tooltip>
|
||||
<q-tooltip v-else>
|
||||
Record when a user owes the Castle
|
||||
Record when a user owes the Libra
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn color="secondary" @click="loadTransactions">
|
||||
|
|
@ -201,7 +201,7 @@
|
|||
</template>
|
||||
<template v-slot:body-cell-actions="props">
|
||||
<q-td :props="props">
|
||||
<!-- User owes Castle (positive balance) - Castle receives payment -->
|
||||
<!-- User owes Libra (positive balance) - Libra receives payment -->
|
||||
<q-btn
|
||||
v-if="props.row.balance > 0"
|
||||
flat
|
||||
|
|
@ -211,9 +211,9 @@
|
|||
icon="payments"
|
||||
@click="showSettleReceivableDialog(props.row)"
|
||||
>
|
||||
<q-tooltip>Settle receivable (user pays castle)</q-tooltip>
|
||||
<q-tooltip>Settle receivable (user pays libra)</q-tooltip>
|
||||
</q-btn>
|
||||
<!-- Castle owes User (negative balance) - Castle pays user -->
|
||||
<!-- Libra owes User (negative balance) - Libra pays user -->
|
||||
<q-btn
|
||||
v-if="props.row.balance < 0"
|
||||
flat
|
||||
|
|
@ -223,7 +223,7 @@
|
|||
icon="send"
|
||||
@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-td>
|
||||
</template>
|
||||
|
|
@ -257,7 +257,7 @@
|
|||
{% raw %}{{ balance.balance > 0 ? 'Total owed to you' : balance.balance < 0 ? 'Total you owe' : 'No outstanding balances' }}{% endraw %}
|
||||
</div>
|
||||
<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 class="q-mt-md q-gutter-sm">
|
||||
<q-btn
|
||||
|
|
@ -924,7 +924,7 @@
|
|||
dense
|
||||
v-model="expenseDialog.isEquity"
|
||||
:options="[
|
||||
{label: 'Liability (Castle owes me)', value: false},
|
||||
{label: 'Liability (Libra owes me)', value: false},
|
||||
{label: 'Equity (My contribution)', value: true}
|
||||
]"
|
||||
option-label="label"
|
||||
|
|
@ -932,7 +932,7 @@
|
|||
emit-value
|
||||
map-options
|
||||
label="Type *"
|
||||
hint="Choose whether this is a liability (Castle owes you) or an equity contribution"
|
||||
hint="Choose whether this is a liability (Libra owes you) or an equity contribution"
|
||||
></q-select>
|
||||
|
||||
<!-- If user is not equity eligible, force liability -->
|
||||
|
|
@ -941,9 +941,9 @@
|
|||
filled
|
||||
dense
|
||||
readonly
|
||||
:model-value="'Liability (Castle owes me)'"
|
||||
:model-value="'Liability (Libra owes me)'"
|
||||
label="Type"
|
||||
hint="This expense will be recorded as a liability (Castle owes you)"
|
||||
hint="This expense will be recorded as a liability (Libra owes you)"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<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-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 v-if="balance" class="q-mb-md">
|
||||
|
|
@ -1104,7 +1104,7 @@
|
|||
<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-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>
|
||||
<template v-slot:avatar>
|
||||
|
|
@ -1119,15 +1119,15 @@
|
|||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="settingsDialog.castleWalletId"
|
||||
v-model="settingsDialog.libraWalletId"
|
||||
:options="g.user.walletOptions"
|
||||
label="Castle Wallet *"
|
||||
label="Libra Wallet *"
|
||||
:readonly="!isSuperUser"
|
||||
:disable="!isSuperUser"
|
||||
></q-select>
|
||||
|
||||
<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>
|
||||
|
||||
<q-separator class="q-my-md"></q-separator>
|
||||
|
|
@ -1149,7 +1149,7 @@
|
|||
dense
|
||||
v-model="settingsDialog.favaLedgerSlug"
|
||||
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"
|
||||
:disable="!isSuperUser"
|
||||
></q-input>
|
||||
|
|
@ -1173,7 +1173,7 @@
|
|||
color="primary"
|
||||
type="submit"
|
||||
:loading="settingsDialog.loading"
|
||||
:disable="!settingsDialog.castleWalletId"
|
||||
:disable="!settingsDialog.libraWalletId"
|
||||
>
|
||||
Save Settings
|
||||
</q-btn>
|
||||
|
|
@ -1201,7 +1201,7 @@
|
|||
></q-select>
|
||||
|
||||
<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 class="row q-mt-lg">
|
||||
|
|
@ -1307,7 +1307,7 @@
|
|||
<div class="text-caption text-grey q-mb-md">
|
||||
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.
|
||||
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.
|
||||
</div>
|
||||
|
||||
|
|
@ -1531,7 +1531,7 @@
|
|||
</q-card>
|
||||
</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-card class="q-pa-md" style="min-width: 400px">
|
||||
<q-form @submit="submitPayUser">
|
||||
|
|
@ -1544,7 +1544,7 @@
|
|||
</div>
|
||||
|
||||
<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">
|
||||
{% raw %}{{ formatSats(payUserDialog.maxAmount) }}{% endraw %} sats
|
||||
</div>
|
||||
|
|
@ -1559,7 +1559,7 @@
|
|||
v-model.number="payUserDialog.amount"
|
||||
type="number"
|
||||
:label="paymentAmountLabel"
|
||||
hint="Amount castle is paying (max: owed amount)"
|
||||
hint="Amount libra is paying (max: owed amount)"
|
||||
:max="paymentMaxAmount"
|
||||
:step="paymentAmountStep"
|
||||
:rules="[
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
{% block scripts %}
|
||||
{{ 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 %}
|
||||
|
||||
{% block page %}
|
||||
16
views.py
16
views.py
|
|
@ -4,22 +4,22 @@ from lnbits.core.models import User
|
|||
from lnbits.decorators import check_user_exists
|
||||
from lnbits.helpers import template_renderer
|
||||
|
||||
castle_generic_router = APIRouter(tags=["castle"])
|
||||
libra_generic_router = APIRouter(tags=["libra"])
|
||||
|
||||
|
||||
@castle_generic_router.get(
|
||||
"/", description="Castle accounting home page", response_class=HTMLResponse
|
||||
@libra_generic_router.get(
|
||||
"/", description="Libra accounting home page", response_class=HTMLResponse
|
||||
)
|
||||
async def index(
|
||||
request: Request,
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
return template_renderer(["castle/templates"]).TemplateResponse(
|
||||
request, "castle/index.html", {"user": user.json()}
|
||||
return template_renderer(["libra/templates"]).TemplateResponse(
|
||||
request, "libra/index.html", {"user": user.json()}
|
||||
)
|
||||
|
||||
|
||||
@castle_generic_router.get(
|
||||
@libra_generic_router.get(
|
||||
"/permissions",
|
||||
description="Permission management page",
|
||||
response_class=HTMLResponse,
|
||||
|
|
@ -28,6 +28,6 @@ async def permissions(
|
|||
request: Request,
|
||||
user: User = Depends(check_user_exists),
|
||||
):
|
||||
return template_renderer(["castle/templates"]).TemplateResponse(
|
||||
request, "castle/permissions.html", {"user": user.json()}
|
||||
return template_renderer(["libra/templates"]).TemplateResponse(
|
||||
request, "libra/permissions.html", {"user": user.json()}
|
||||
)
|
||||
|
|
|
|||
396
views_api.py
396
views_api.py
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue