From b06c53c40f02c7eca655758cb75b002e68b4a8b9 Mon Sep 17 00:00:00 2001
From: Patrick Mulligan
Date: Tue, 31 Mar 2026 11:43:38 -0400
Subject: [PATCH 01/42] Fix BQL balance queries mixing EUR and SATS face values
The BQL queries in get_user_balance_bql() and get_all_user_balances_bql()
used GROUP BY account without currency, causing sum(number) to add EUR
face values from expense entries (EUR @@ SATS notation) with SATS face
values from payment entries (plain SATS). This inflated displayed fiat
amounts by orders of magnitude for users with settlement payments.
Fix: add currency to GROUP BY so EUR and SATS rows are separate, use
sum(weight) for net SATS (correct across all entry formats), and scale
fiat proportionally for partial settlements.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
fava_client.py | 153 +++++++++++++++++++++++++++++++------------------
1 file changed, 96 insertions(+), 57 deletions(-)
diff --git a/fava_client.py b/fava_client.py
index 6880d11..fea8c64 100644
--- a/fava_client.py
+++ b/fava_client.py
@@ -734,17 +734,23 @@ class FavaClient:
async def get_user_balance_bql(self, user_id: str) -> Dict[str, Any]:
"""
- Get user balance using BQL with price notation (efficient server-side aggregation).
+ Get user balance using BQL with currency-grouped aggregation.
- Uses sum(weight) to aggregate SATS from @@ price notation.
- This provides 5-10x performance improvement over manual aggregation.
+ Groups by account AND currency to correctly handle mixed entry formats:
+ - Expense entries use EUR @@ SATS (position=EUR, weight=SATS)
+ - Payment entries use plain SATS (position=SATS, weight=SATS)
+
+ Without currency grouping, sum(number) would mix EUR and SATS face values.
+ The net SATS balance is computed from sum(weight) which normalizes to SATS
+ across both formats. Fiat is taken only from EUR rows and scaled by the
+ fraction of SATS debt still outstanding.
Args:
user_id: User ID
Returns:
{
- "balance": int (sats from weight column),
+ "balance": int (net sats owed),
"fiat_balances": {"EUR": Decimal("100.50"), ...},
"accounts": [{"account": "...", "sats": 150000, "eur": Decimal("100.50")}, ...]
}
@@ -757,48 +763,61 @@ class FavaClient:
user_id_prefix = user_id[:8]
- # BQL query using sum(weight) for SATS aggregation
- # weight column returns the @@ price value (SATS) from price notation
+ # GROUP BY currency prevents mixing EUR and SATS face values in sum(number).
+ # sum(weight) gives SATS for both EUR @@ SATS entries and plain SATS entries.
+ # sum(number) on EUR rows gives the fiat amount; on SATS rows gives sats paid.
query = f"""
- SELECT account, sum(number), sum(weight)
+ SELECT account, currency, sum(number), sum(weight)
WHERE account ~ ':User-{user_id_prefix}'
AND (account ~ 'Payable' OR account ~ 'Receivable')
AND flag = '*'
- GROUP BY account
+ GROUP BY account, currency
"""
result = await self.query_bql(query)
- total_sats = 0
+ # First pass: collect EUR fiat totals and SATS weights per account
+ total_eur_sats = 0 # SATS equivalent of EUR entries (from weight)
+ total_sats_paid = 0 # SATS from payment entries
fiat_balances = {}
accounts = []
for row in result["rows"]:
- account_name, fiat_sum, weight_sum = row
+ account_name, currency, number_sum, weight_sum = row
- # Parse fiat amount (sum of EUR/USD amounts)
- fiat_amount = Decimal(str(fiat_sum)) if fiat_sum else Decimal(0)
-
- # Parse SATS from weight column
- # weight_sum is an Inventory dict like {"SATS": -10442635.00}
- sats_amount = 0
+ # Parse SATS from weight column (always SATS for both entry formats)
+ sats_weight = 0
if isinstance(weight_sum, dict) and "SATS" in weight_sum:
- sats_value = weight_sum["SATS"]
- sats_amount = int(Decimal(str(sats_value)))
+ sats_weight = int(Decimal(str(weight_sum["SATS"])))
- total_sats += sats_amount
+ if currency == "SATS":
+ # Payment entry: SATS position, track separately
+ total_sats_paid += int(Decimal(str(number_sum))) if number_sum else 0
+ else:
+ # EUR (fiat) entry: number is the fiat amount, weight is SATS equivalent
+ fiat_amount = Decimal(str(number_sum)) if number_sum else Decimal(0)
+ total_eur_sats += sats_weight
- # Aggregate fiat (assume EUR for now, could be extended)
- if fiat_amount != 0:
- if "EUR" not in fiat_balances:
- fiat_balances["EUR"] = Decimal(0)
- fiat_balances["EUR"] += fiat_amount
+ if fiat_amount != 0:
+ if currency not in fiat_balances:
+ fiat_balances[currency] = Decimal(0)
+ fiat_balances[currency] += fiat_amount
- accounts.append({
- "account": account_name,
- "sats": sats_amount,
- "eur": fiat_amount
- })
+ accounts.append({
+ "account": account_name,
+ "sats": sats_weight,
+ "eur": fiat_amount
+ })
+
+ # Net SATS balance: EUR-entry SATS (negative=owed) + payment SATS (positive=paid)
+ total_sats = total_eur_sats + total_sats_paid
+
+ # Scale fiat proportionally if partially settled
+ # e.g., if 80% of SATS debt paid, reduce fiat owed by 80%
+ if total_eur_sats != 0 and total_sats_paid != 0:
+ remaining_fraction = Decimal(str(total_sats)) / Decimal(str(total_eur_sats))
+ for currency in fiat_balances:
+ fiat_balances[currency] = (fiat_balances[currency] * remaining_fraction).quantize(Decimal("0.01"))
logger.info(f"User {user_id[:8]} balance (BQL): {total_sats} sats, fiat: {dict(fiat_balances)}")
@@ -810,10 +829,14 @@ class FavaClient:
async def get_all_user_balances_bql(self) -> List[Dict[str, Any]]:
"""
- Get balances for all users using BQL with price notation (efficient admin view).
+ Get balances for all users using BQL with currency-grouped aggregation.
- Uses sum(weight) to aggregate SATS from @@ price notation in a single query.
- This provides significant performance benefits for admin views.
+ Groups by account AND currency to correctly handle mixed entry formats:
+ - Expense entries use EUR @@ SATS (position=EUR, weight=SATS)
+ - Payment entries use plain SATS (position=SATS, weight=SATS)
+
+ Without currency grouping, sum(number) would mix EUR and SATS face values,
+ causing wildly inflated fiat amounts for users with payment entries.
Returns:
[
@@ -833,23 +856,22 @@ class FavaClient:
"""
from decimal import Decimal
- # BQL query using sum(weight) for SATS aggregation
+ # GROUP BY currency prevents mixing EUR and SATS face values in sum(number)
query = """
- SELECT account, sum(number), sum(weight)
+ SELECT account, currency, sum(number), sum(weight)
WHERE (account ~ 'Payable:User-' OR account ~ 'Receivable:User-')
AND flag = '*'
- GROUP BY account
+ GROUP BY account, currency
"""
result = await self.query_bql(query)
- # Group by user_id
+ # First pass: collect per-user EUR fiat totals and SATS amounts separately
user_data = {}
for row in result["rows"]:
- account_name, fiat_sum, weight_sum = row
+ account_name, currency, number_sum, weight_sum = row
- # Extract user_id from account name
if ":User-" not in account_name:
continue
@@ -861,31 +883,48 @@ class FavaClient:
"user_id": user_id,
"balance": 0,
"fiat_balances": {},
- "accounts": []
+ "accounts": [],
+ "_eur_sats": 0, # SATS equivalent of EUR entries (from weight)
+ "_sats_paid": 0, # SATS from payment entries
}
- # Parse fiat amount
- fiat_amount = Decimal(str(fiat_sum)) if fiat_sum else Decimal(0)
-
- # Parse SATS from weight column
- sats_amount = 0
+ # Parse SATS from weight column (always SATS for both entry formats)
+ sats_weight = 0
if isinstance(weight_sum, dict) and "SATS" in weight_sum:
- sats_value = weight_sum["SATS"]
- sats_amount = int(Decimal(str(sats_value)))
+ sats_weight = int(Decimal(str(weight_sum["SATS"])))
- user_data[user_id]["balance"] += sats_amount
+ if currency == "SATS":
+ # Payment entry: SATS position, track separately
+ user_data[user_id]["_sats_paid"] += int(Decimal(str(number_sum))) if number_sum else 0
+ else:
+ # EUR (fiat) entry: number is the fiat amount, weight is SATS equivalent
+ fiat_amount = Decimal(str(number_sum)) if number_sum else Decimal(0)
+ user_data[user_id]["_eur_sats"] += sats_weight
- # Aggregate fiat
- if fiat_amount != 0:
- if "EUR" not in user_data[user_id]["fiat_balances"]:
- user_data[user_id]["fiat_balances"]["EUR"] = Decimal(0)
- user_data[user_id]["fiat_balances"]["EUR"] += fiat_amount
+ if fiat_amount != 0:
+ if currency not in user_data[user_id]["fiat_balances"]:
+ user_data[user_id]["fiat_balances"][currency] = Decimal(0)
+ user_data[user_id]["fiat_balances"][currency] += fiat_amount
- user_data[user_id]["accounts"].append({
- "account": account_name,
- "sats": sats_amount,
- "eur": fiat_amount
- })
+ user_data[user_id]["accounts"].append({
+ "account": account_name,
+ "sats": sats_weight,
+ "eur": fiat_amount
+ })
+
+ # Second pass: compute net balances and scale fiat for partial settlements
+ for user_id, data in user_data.items():
+ eur_sats = data.pop("_eur_sats")
+ sats_paid = data.pop("_sats_paid")
+
+ # Net SATS balance: EUR-entry SATS (negative=owed) + payment SATS (positive=paid)
+ data["balance"] = eur_sats + sats_paid
+
+ # Scale fiat proportionally if partially settled
+ if eur_sats != 0 and sats_paid != 0:
+ remaining_fraction = Decimal(str(data["balance"])) / Decimal(str(eur_sats))
+ for currency in data["fiat_balances"]:
+ data["fiat_balances"][currency] = (data["fiat_balances"][currency] * remaining_fraction).quantize(Decimal("0.01"))
logger.info(f"Fetched balances for {len(user_data)} users (BQL)")
From f2f9183106028c60c99eff472537f64ed6fc49e4 Mon Sep 17 00:00:00 2001
From: Padreug
Date: Sat, 25 Apr 2026 18:54:54 +0200
Subject: [PATCH 02/42] Fix get_all_accounts to discover accounts from open
directives
The previous BQL query (SELECT DISTINCT account) only returned accounts
with postings, missing all accounts that were opened but had no
transactions yet. On a fresh ledger this returned 0 accounts, causing
the account sync to deactivate everything.
Now uses Fava's balance_sheet and income_statement API endpoints which
return the full account tree including zero-balance accounts. Falls back
to BQL if the tree endpoints fail.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
fava_client.py | 55 +++++++++++++++++++++++++++++++++++++++++++++-----
1 file changed, 50 insertions(+), 5 deletions(-)
diff --git a/fava_client.py b/fava_client.py
index fea8c64..27e147d 100644
--- a/fava_client.py
+++ b/fava_client.py
@@ -1132,9 +1132,27 @@ class FavaClient:
limit=limit
)
+ def _extract_accounts_from_tree(self, tree: Any) -> List[str]:
+ """Recursively extract account names from a Fava tree structure."""
+ accounts = []
+ if isinstance(tree, dict):
+ for key, val in tree.items():
+ if key == "account":
+ accounts.append(val)
+ elif isinstance(val, (dict, list)):
+ accounts.extend(self._extract_accounts_from_tree(val))
+ elif isinstance(tree, list):
+ for item in tree:
+ accounts.extend(self._extract_accounts_from_tree(item))
+ return accounts
+
async def get_all_accounts(self) -> List[Dict[str, Any]]:
"""
- Get all accounts from Beancount/Fava using BQL query.
+ Get all accounts from Beancount/Fava.
+
+ Uses Fava's balance_sheet and income_statement API endpoints to
+ discover all opened accounts, including those with zero balances.
+ Falls back to BQL query if the tree endpoints fail.
Returns:
List of account dictionaries:
@@ -1150,25 +1168,52 @@ class FavaClient:
print(acc["account"]) # "Assets:Cash"
"""
try:
- # Use BQL to get all unique accounts
+ # Use balance_sheet + income_statement to get ALL opened accounts
+ # (BQL's SELECT DISTINCT account only returns accounts with postings)
+ account_names: set[str] = set()
+
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
+ for endpoint in ("balance_sheet", "income_statement"):
+ try:
+ response = await client.get(f"{self.base_url}/{endpoint}")
+ if response.status_code == 200:
+ data = response.json().get("data", {})
+ trees = data.get("trees", {})
+ names = self._extract_accounts_from_tree(trees)
+ account_names.update(names)
+ except Exception as e:
+ logger.warning(f"Failed to fetch {endpoint}: {e}")
+
+ # Filter out synthetic entries like "Net Profit"
+ account_names = {
+ name for name in account_names
+ if ":" in name or name in ("Assets", "Liabilities", "Equity", "Income", "Expenses")
+ }
+
+ if account_names:
+ accounts = [{"account": name, "meta": {}} for name in sorted(account_names)]
+ logger.debug(f"Fava returned {len(accounts)} accounts via tree endpoints")
+ return accounts
+
+ # Fallback: BQL query (only finds accounts with postings)
+ logger.info("Tree endpoints returned no accounts, falling back to BQL")
query = "SELECT DISTINCT account"
result = await self.query_bql(query)
- # Convert BQL result to expected format
accounts = []
for row in result["rows"]:
account_name = row[0] if isinstance(row, list) else row.get("account")
if account_name:
accounts.append({
"account": account_name,
- "meta": {} # BQL doesn't return metadata easily
+ "meta": {}
})
logger.debug(f"Fava returned {len(accounts)} accounts via BQL")
return accounts
except Exception as e:
- logger.error(f"Failed to fetch accounts via BQL: {e}")
+ logger.error(f"Failed to fetch accounts: {e}")
raise
async def get_journal_entries(
From 9a1893c54695a098e19aeb65a6e94f560b8c3410 Mon Sep 17 00:00:00 2001
From: Padreug
Date: Sat, 25 Apr 2026 19:08:13 +0200
Subject: [PATCH 03/42] Fix startup: load Fava settings from DB instead of
hardcoded defaults
castle_start() was using CastleSettings() defaults (slug=castle-ledger)
instead of reading the saved settings from the database. This caused all
Fava queries to 404 on instances where the ledger slug differs from the
default (e.g. demo-ledger).
Now loads settings from extension_settings table at startup, falling
back to defaults only if no saved settings exist.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
__init__.py | 35 +++++++++++++++++++++++++++--------
1 file changed, 27 insertions(+), 8 deletions(-)
diff --git a/__init__.py b/__init__.py
index 6209e9d..438d0f1 100644
--- a/__init__.py
+++ b/__init__.py
@@ -38,16 +38,35 @@ def castle_start():
from .models import CastleSettings
from .tasks import wait_for_account_sync
- # Initialize Fava client with default settings
- # (Will be re-initialized if admin updates settings)
- defaults = CastleSettings()
- try:
+ async def _init_fava():
+ """Load saved settings from DB, fall back to defaults."""
+ from .crud import db as castle_db
+
+ settings = None
+ try:
+ row = await castle_db.fetchone(
+ "SELECT * FROM extension_settings LIMIT 1",
+ model=CastleSettings,
+ )
+ if row:
+ settings = row
+ logger.info(f"Loaded Castle 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}")
+
init_fava_client(
- fava_url=defaults.fava_url,
- ledger_slug=defaults.fava_ledger_slug,
- timeout=defaults.fava_timeout
+ fava_url=settings.fava_url,
+ ledger_slug=settings.fava_ledger_slug,
+ timeout=settings.fava_timeout
)
- logger.info(f"Fava client initialized: {defaults.fava_url}/{defaults.fava_ledger_slug}")
+ logger.info(f"Fava client initialized: {settings.fava_url}/{settings.fava_ledger_slug}")
+
+ try:
+ asyncio.get_event_loop().create_task(_init_fava())
except Exception as e:
logger.error(f"Failed to initialize Fava client: {e}")
logger.warning("Castle will not function without Fava. Please configure Fava settings.")
From 9c577c740c872dc7f60fac1d050bba1652b6493d Mon Sep 17 00:00:00 2001
From: Padreug
Date: Tue, 5 May 2026 08:51:16 +0200
Subject: [PATCH 04/42] Add account sync button to super user toolbar
Wires the existing POST /api/v1/admin/accounts/sync endpoint into the
Castle index toolbar (sync icon between permissions and settings).
Surfaces sync stats (added/reactivated/deactivated/virtual_parents/errors)
via a Quasar notification and refreshes the accounts list on success.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
static/js/index.js | 27 +++++++++++++++++++++++++++
templates/castle/index.html | 3 +++
2 files changed, 30 insertions(+)
diff --git a/static/js/index.js b/static/js/index.js
index bd52e39..4cc5c06 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -34,6 +34,7 @@ window.app = Vue.createApp({
settingsLoaded: false, // Flag to prevent race conditions on toolbar buttons
castleWalletConfigured: false,
userWalletConfigured: false,
+ syncingAccounts: false,
currentExchangeRate: null, // BTC/EUR rate (sats per EUR)
expenseDialog: {
show: false,
@@ -539,6 +540,32 @@ window.app = Vue.createApp({
this.userWalletConfigured = false
}
},
+ async syncAccounts() {
+ this.syncingAccounts = true
+ try {
+ const {data} = await LNbits.api.request(
+ 'POST',
+ '/castle/api/v1/admin/accounts/sync',
+ this.g.user.wallets[0].adminkey
+ )
+ const errors = (data?.errors || []).length
+ const message = `Synced: ${data?.accounts_added ?? 0} added, ` +
+ `${data?.accounts_reactivated ?? 0} reactivated, ` +
+ `${data?.accounts_deactivated ?? 0} deactivated, ` +
+ `${data?.virtual_parents_created ?? 0} virtual parents` +
+ (errors ? `, ${errors} errors` : '')
+ this.$q.notify({
+ type: errors ? 'warning' : 'positive',
+ message,
+ timeout: errors ? 8000 : 4000
+ })
+ await this.loadAccounts()
+ } catch (error) {
+ LNbits.utils.notifyApiError(error)
+ } finally {
+ this.syncingAccounts = false
+ }
+ },
showSettingsDialog() {
this.settingsDialog.castleWalletId = this.settings?.castle_wallet_id || ''
this.settingsDialog.favaUrl = this.settings?.fava_url || 'http://localhost:3333'
diff --git a/templates/castle/index.html b/templates/castle/index.html
index 2a1e665..98b1625 100644
--- a/templates/castle/index.html
+++ b/templates/castle/index.html
@@ -24,6 +24,9 @@
Manage Permissions (Admin)
+
+ Sync Accounts from Beancount
+
Castle Settings (Super User Only)
From c174cda48d9db0ace9a26353d71e6a1aab91f3ba Mon Sep 17 00:00:00 2001
From: Padreug
Date: Tue, 5 May 2026 10:24:46 +0200
Subject: [PATCH 05/42] Rename Castle Accounting extension to Libra
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
CLAUDE.md | 78 ++--
MIGRATION_SQUASH_SUMMARY.md | 46 +-
README.md | 20 +-
__init__.py | 46 +-
account_sync.py | 70 ++--
account_utils.py | 4 +-
auth.py | 8 +-
beancount_format.py | 38 +-
config.json | 8 +-
core/__init__.py | 2 +-
core/validation.py | 4 +-
crud.py | 64 +--
description.md | 18 +-
...CCOUNT-SYNC-AND-PERMISSION-IMPROVEMENTS.md | 72 ++--
docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.html | 40 +-
docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.md | 40 +-
docs/BEANCOUNT_PATTERNS.md | 74 ++--
docs/BQL-BALANCE-QUERIES.md | 8 +-
docs/BQL-PRICE-NOTATION-SOLUTION.md | 6 +-
docs/DAILY_RECONCILIATION.md | 42 +-
docs/DOCUMENTATION.md | 156 +++----
docs/EXPENSE_APPROVAL.md | 10 +-
docs/PERMISSIONS-SYSTEM.md | 8 +-
docs/PHASE2_COMPLETE.md | 18 +-
docs/PHASE3_COMPLETE.md | 38 +-
docs/SATS-EQUIVALENT-METADATA.md | 24 +-
docs/UI-IMPROVEMENTS-PLAN.md | 12 +-
fava_client.py | 30 +-
helper/README.md | 26 +-
helper/import_beancount.py | 82 ++--
manifest.json | 4 +-
migrations.py | 24 +-
models.py | 24 +-
package.json | 2 +-
permission_management.py | 4 +-
services.py | 22 +-
static/image/{castle.png => libra.png} | Bin
static/js/index.js | 144 +++----
static/js/permissions.js | 38 +-
tasks.py | 64 +--
templates/{castle => libra}/index.html | 74 ++--
templates/{castle => libra}/permissions.html | 2 +-
views.py | 16 +-
views_api.py | 396 +++++++++---------
44 files changed, 953 insertions(+), 953 deletions(-)
rename static/image/{castle.png => libra.png} (100%)
rename templates/{castle => libra}/index.html (95%)
rename templates/{castle => libra}/permissions.html (99%)
diff --git a/CLAUDE.md b/CLAUDE.md
index 3086441..b58591c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
-Castle Accounting is a double-entry bookkeeping extension for LNbits that enables collectives (co-living spaces, makerspaces, community projects) to track finances with proper accounting principles. It integrates Lightning Network payments with traditional accounting, supporting both cryptocurrency and fiat currency tracking.
+Libra is a double-entry bookkeeping extension for LNbits that enables collectives (co-living spaces, makerspaces, community projects) to track finances with proper accounting principles. It integrates Lightning Network payments with traditional accounting, supporting both cryptocurrency and fiat currency tracking.
## Architecture
@@ -12,9 +12,9 @@ Castle Accounting is a double-entry bookkeeping extension for LNbits that enable
**Double-Entry Accounting**: Every transaction affects at least two accounts. Debits must equal credits. Five account types: Assets, Liabilities, Equity, Revenue (Income), Expenses.
-**Fava/Beancount Backend**: Castle now uses [Fava](https://github.com/beancount/fava) as the primary accounting engine. Fava is a web interface for Beancount that provides a REST API for ledger operations. All accounting calculations (balance sheets, trial balances, account reports) are delegated to Fava/Beancount. Castle formats transactions as Beancount entries and submits them via Fava's API.
+**Fava/Beancount Backend**: Libra now uses [Fava](https://github.com/beancount/fava) as the primary accounting engine. Fava is a web interface for Beancount that provides a REST API for ledger operations. All accounting calculations (balance sheets, trial balances, account reports) are delegated to Fava/Beancount. Libra formats transactions as Beancount entries and submits them via Fava's API.
-**Required External Dependency**: Fava must be running as a separate service. Configure `fava_url` and `fava_ledger_slug` in Castle settings (default: `http://localhost:3333` with slug `castle-accounting`). Castle will not function without Fava.
+**Required External Dependency**: Fava must be running as a separate service. Configure `fava_url` and `fava_ledger_slug` in Libra settings (default: `http://localhost:3333` with slug `libra-accounting`). Libra will not function without Fava.
**Pure Functional Core**: The `core/` directory contains pure accounting logic independent of the database layer:
- `core/validation.py` - Entry validation rules
@@ -44,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"
```
diff --git a/MIGRATION_SQUASH_SUMMARY.md b/MIGRATION_SQUASH_SUMMARY.md
index 8d86b9b..4b03ed0 100644
--- a/MIGRATION_SQUASH_SUMMARY.md
+++ b/MIGRATION_SQUASH_SUMMARY.md
@@ -1,11 +1,11 @@
-# Castle Migration Squash Summary
+# Libra Migration Squash Summary
**Date:** November 10, 2025
**Action:** Squashed 16 incremental migrations into a single clean initial migration
## Overview
-The Castle extension had accumulated 16 migrations (m001-m016) during development. Since the software has not been released yet, we safely squashed all migrations into a single clean `m001_initial` migration.
+The Libra extension had accumulated 16 migrations (m001-m016) during development. Since the software has not been released yet, we safely squashed all migrations into a single clean `m001_initial` migration.
## Files Changed
@@ -16,37 +16,37 @@ The Castle extension had accumulated 16 migrations (m001-m016) during developmen
The squashed migration creates **7 tables**:
-### 1. castle_accounts
+### 1. libra_accounts
- Core chart of accounts with hierarchical Beancount-style names
- Examples: "Assets:Bitcoin:Lightning", "Expenses:Food:Groceries"
- User-specific accounts: "Assets:Receivable:User-af983632"
- Includes comprehensive default account set (40+ accounts)
-### 2. castle_extension_settings
-- Castle-wide configuration
-- Stores castle_wallet_id for Lightning payments
+### 2. libra_extension_settings
+- Libra-wide configuration
+- Stores libra_wallet_id for Lightning payments
-### 3. castle_user_wallet_settings
+### 3. libra_user_wallet_settings
- Per-user wallet configuration
- Allows users to have separate wallet preferences
-### 4. castle_manual_payment_requests
-- User-submitted payment requests to Castle
+### 4. libra_manual_payment_requests
+- User-submitted payment requests to Libra
- Reviewed by admins before processing
- Includes notes field for additional context
-### 5. castle_balance_assertions
+### 5. libra_balance_assertions
- Reconciliation and balance checking at specific dates
- Multi-currency support (satoshis + fiat)
- Tolerance checking for small discrepancies
- Includes notes field for reconciliation comments
-### 6. castle_user_equity_status
+### 6. libra_user_equity_status
- Manages equity contribution eligibility
- Equity-eligible users can convert expenses to equity
- Creates dynamic user-specific equity accounts: Equity:User-{user_id}
-### 7. castle_account_permissions
+### 7. libra_account_permissions
- Granular access control for accounts
- Permission types: read, submit_expense, manage
- Supports hierarchical inheritance (parent permissions cascade)
@@ -56,10 +56,10 @@ The squashed migration creates **7 tables**:
The following tables were **intentionally NOT included** in the final schema (they were dropped in m016):
-- **castle_journal_entries** - Journal entries now managed by Fava/Beancount (external source of truth)
-- **castle_entry_lines** - Entry lines now managed by Fava/Beancount
+- **libra_journal_entries** - Journal entries now managed by Fava/Beancount (external source of truth)
+- **libra_entry_lines** - Entry lines now managed by Fava/Beancount
-Castle now uses Fava as the single source of truth for accounting data. Journal operations:
+Libra now uses Fava as the single source of truth for accounting data. Journal operations:
- **Write:** Submit to Fava via FavaClient.add_entry()
- **Read:** Query Fava via FavaClient.get_entries()
@@ -106,7 +106,7 @@ For reference, the original migration sequence (preserved in migrations_old.py.b
For new installations:
```bash
-# Castle's migration system will run m001_initial automatically
+# Libra's migration system will run m001_initial automatically
# No manual intervention needed
```
@@ -174,20 +174,20 @@ After squashing, verify the migration works:
```bash
# 1. Backup existing database (if any)
-cp castle.sqlite3 castle.sqlite3.backup
+cp libra.sqlite3 libra.sqlite3.backup
# 2. Drop and recreate database to test fresh install
-rm castle.sqlite3
+rm libra.sqlite3
# 3. Start LNbits - migration should run automatically
poetry run lnbits
# 4. Verify tables created
-sqlite3 castle.sqlite3 ".tables"
-# Should show: castle_accounts, castle_extension_settings, etc.
+sqlite3 libra.sqlite3 ".tables"
+# Should show: libra_accounts, libra_extension_settings, etc.
# 5. Verify default accounts
-sqlite3 castle.sqlite3 "SELECT COUNT(*) FROM castle_accounts;"
+sqlite3 libra.sqlite3 "SELECT COUNT(*) FROM libra_accounts;"
# Should show: 40 (default accounts)
```
@@ -200,12 +200,12 @@ If issues are discovered:
cp migrations_old.py.bak migrations.py
# Restore database
-cp castle.sqlite3.backup castle.sqlite3
+cp libra.sqlite3.backup libra.sqlite3
```
## Notes
-- This squash is safe because Castle has not been released yet
+- This squash is safe because Libra has not been released yet
- No existing production databases need migration
- Historical migrations preserved in migrations_old.py.bak
- All functionality preserved in final schema
diff --git a/README.md b/README.md
index 6174bfb..f67e15c 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,10 @@
-# Castle Accounting Extension for LNbits
+# Libra Extension for LNbits
A full-featured double-entry accounting system for collective projects, integrated with LNbits Lightning payments.
## Overview
-Castle Accounting enables collectives like co-living spaces, makerspaces, and community projects to:
+Libra enables collectives like co-living spaces, makerspaces, and community projects to:
- Track expenses and revenue with proper accounting
- Manage individual member balances
- Record contributions as equity or reimbursable expenses
@@ -17,7 +17,7 @@ This extension is designed to be installed in the `lnbits/extensions/` directory
```bash
cd lnbits/extensions/
-# Copy or clone the castle directory here
+# Copy or clone the libra directory here
```
Enable the extension through the LNbits admin interface or by adding it to your configuration.
@@ -30,7 +30,7 @@ Enable the extension through the LNbits admin interface or by adding it to your
- Choose "Liability" if you want reimbursement
- Choose "Equity" if it's a contribution
-2. **View Your Balance**: See if the Castle owes you money or vice versa
+2. **View Your Balance**: See if the 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
diff --git a/__init__.py b/__init__.py
index 438d0f1..614a8ba 100644
--- a/__init__.py
+++ b/__init__.py
@@ -5,24 +5,24 @@ from loguru import logger
from .crud import db
from .tasks import wait_for_paid_invoices
-from .views import castle_generic_router
-from .views_api import castle_api_router
+from .views import libra_generic_router
+from .views_api import libra_api_router
-castle_ext: APIRouter = APIRouter(prefix="/castle", tags=["Castle"])
-castle_ext.include_router(castle_generic_router)
-castle_ext.include_router(castle_api_router)
+libra_ext: APIRouter = APIRouter(prefix="/libra", tags=["Libra"])
+libra_ext.include_router(libra_generic_router)
+libra_ext.include_router(libra_api_router)
-castle_static_files = [
+libra_static_files = [
{
- "path": "/castle/static",
- "name": "castle_static",
+ "path": "/libra/static",
+ "name": "libra_static",
}
]
scheduled_tasks: list[asyncio.Task] = []
-def castle_stop():
+def libra_stop():
"""Clean up background tasks on extension shutdown"""
for task in scheduled_tasks:
try:
@@ -31,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"]
diff --git a/account_sync.py b/account_sync.py
index 95fe41c..7e875f8 100644
--- a/account_sync.py
+++ b/account_sync.py
@@ -1,11 +1,11 @@
"""
Account Synchronization Module
-Syncs accounts from Beancount (source of truth) to Castle DB (metadata store).
+Syncs accounts from Beancount (source of truth) to Libra DB (metadata store).
This implements the hybrid approach:
- Beancount owns account existence (Open directives)
-- Castle DB stores permissions and user associations
+- Libra DB stores permissions and user associations
- Background sync keeps them in sync
Related: ACCOUNTS-TABLE-REMOVAL-FEASIBILITY.md - Phase 2 implementation
@@ -89,14 +89,14 @@ def extract_user_id_from_account_name(account_name: str) -> Optional[str]:
async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
"""
- Sync accounts from Beancount to Castle DB.
+ Sync accounts from Beancount to Libra DB.
- This ensures Castle DB has metadata entries for all accounts that exist
+ This ensures Libra DB has metadata entries for all accounts that exist
in Beancount, enabling permissions and user associations to work properly.
New behavior (soft delete + virtual parents):
- - Accounts in Beancount but not in Castle DB: Added as active
- - Accounts in Castle DB but not in Beancount: Marked as inactive (soft delete)
+ - Accounts in Beancount but not in Libra DB: Added as active
+ - Accounts in Libra DB but not in Beancount: Marked as inactive (soft delete)
- Inactive accounts that return to Beancount: Reactivated
- Missing intermediate parents: Auto-created as virtual accounts
@@ -113,7 +113,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
dict with sync statistics:
{
"total_beancount_accounts": 150,
- "total_castle_accounts": 148,
+ "total_libra_accounts": 148,
"accounts_added": 2,
"accounts_updated": 0,
"accounts_skipped": 148,
@@ -123,7 +123,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
"errors": []
}
"""
- logger.info("Starting account sync from Beancount to Castle DB")
+ logger.info("Starting account sync from Beancount to Libra DB")
fava = get_fava_client()
@@ -134,7 +134,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
logger.error(f"Failed to fetch accounts from Beancount: {e}")
return {
"total_beancount_accounts": 0,
- "total_castle_accounts": 0,
+ "total_libra_accounts": 0,
"accounts_added": 0,
"accounts_updated": 0,
"accounts_skipped": 0,
@@ -143,16 +143,16 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
"errors": [str(e)],
}
- # Get all accounts from Castle DB (including inactive ones for sync)
- castle_accounts = await get_all_accounts(include_inactive=True)
+ # Get all accounts from Libra DB (including inactive ones for sync)
+ libra_accounts = await get_all_accounts(include_inactive=True)
# Build lookup maps
beancount_account_names = {acc["account"] for acc in beancount_accounts}
- castle_accounts_by_name = {acc.name: acc for acc in castle_accounts}
+ libra_accounts_by_name = {acc.name: acc for acc in libra_accounts}
stats = {
"total_beancount_accounts": len(beancount_accounts),
- "total_castle_accounts": len(castle_accounts),
+ "total_libra_accounts": len(libra_accounts),
"accounts_added": 0,
"accounts_updated": 0,
"accounts_skipped": 0,
@@ -162,15 +162,15 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
"errors": [],
}
- # Step 1: Sync accounts from Beancount to Castle DB
+ # Step 1: Sync accounts from Beancount to Libra DB
for bc_account in beancount_accounts:
account_name = bc_account["account"]
try:
- existing = castle_accounts_by_name.get(account_name)
+ existing = libra_accounts_by_name.get(account_name)
if existing:
- # Account exists in Castle DB
+ # Account exists in Libra DB
# Check if it needs to be reactivated
if not existing.is_active:
await update_account_is_active(existing.id, True)
@@ -181,7 +181,7 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
logger.debug(f"Account already active: {account_name}")
continue
- # Create new account in Castle DB
+ # Create new account in Libra DB
account_type = infer_account_type_from_name(account_name)
user_id = extract_user_id_from_account_name(account_name)
@@ -207,25 +207,25 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
logger.error(error_msg)
stats["errors"].append(error_msg)
- # Step 2: Mark orphaned accounts (in Castle DB but not in Beancount) as inactive
+ # Step 2: Mark orphaned accounts (in Libra DB but not in Beancount) as inactive
# SKIP virtual accounts (they're intentionally metadata-only)
- for castle_account in castle_accounts:
- if castle_account.is_virtual:
+ for libra_account in libra_accounts:
+ if libra_account.is_virtual:
# Virtual accounts are metadata-only, never deactivate them
continue
- if castle_account.name not in beancount_account_names:
+ if libra_account.name not in beancount_account_names:
# Account no longer exists in Beancount
- if castle_account.is_active:
+ if libra_account.is_active:
try:
- await update_account_is_active(castle_account.id, False)
+ await update_account_is_active(libra_account.id, False)
stats["accounts_deactivated"] += 1
logger.info(
- f"Deactivated orphaned account: {castle_account.name}"
+ f"Deactivated orphaned account: {libra_account.name}"
)
except Exception as e:
error_msg = (
- f"Failed to deactivate account {castle_account.name}: {e}"
+ f"Failed to deactivate account {libra_account.name}: {e}"
)
logger.error(error_msg)
stats["errors"].append(error_msg)
@@ -236,8 +236,8 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
# IMPORTANT: Re-fetch accounts from DB after Step 1 added new accounts
# Otherwise we'll be checking against stale data and miss newly synced children
- current_castle_accounts = await get_all_accounts(include_inactive=True)
- all_account_names = {acc.name for acc in current_castle_accounts}
+ current_libra_accounts = await get_all_accounts(include_inactive=True)
+ all_account_names = {acc.name for acc in current_libra_accounts}
for bc_account in beancount_accounts:
account_name = bc_account["account"]
@@ -287,9 +287,9 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict:
async def sync_single_account_from_beancount(account_name: str) -> bool:
"""
- Sync a single account from Beancount to Castle DB.
+ Sync a single account from Beancount to Libra DB.
- Useful for ensuring a specific account exists in Castle DB before
+ Useful for ensuring a specific account exists in Libra DB before
granting permissions on it.
Args:
@@ -318,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
diff --git a/account_utils.py b/account_utils.py
index 46db327..7e71630 100644
--- a/account_utils.py
+++ b/account_utils.py
@@ -200,11 +200,11 @@ DEFAULT_HIERARCHICAL_ACCOUNTS = [
("Assets:FixedAssets:ProductionFacility", AccountType.ASSET, "Production facilities"),
("Assets:Inventory", AccountType.ASSET, "Inventory and stock"),
("Assets:Livestock", AccountType.ASSET, "Livestock and animals"),
- ("Assets:Receivable", AccountType.ASSET, "Money owed to the Castle"),
+ ("Assets:Receivable", AccountType.ASSET, "Money owed to the 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
diff --git a/auth.py b/auth.py
index b729347..8cc9c17 100644
--- a/auth.py
+++ b/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:
diff --git a/beancount_format.py b/beancount_format.py
index 74ba53c..956a4ee 100644
--- a/beancount_format.py
+++ b/beancount_format.py
@@ -1,8 +1,8 @@
"""
-Format Castle entries as Beancount transactions for Fava API.
+Format Libra entries as Beancount transactions for Fava API.
All entries submitted to Fava must follow Beancount syntax.
-This module converts Castle data models to Fava API format.
+This module converts Libra data models to Fava API format.
Key concepts:
- Amounts are strings: "200000 SATS" or "100.00 EUR"
@@ -35,8 +35,8 @@ def sanitize_link(text: str) -> str:
'Test-pending'
>>> sanitize_link("Invoice #123")
'Invoice-123'
- >>> sanitize_link("castle-abc123")
- 'castle-abc123'
+ >>> sanitize_link("libra-abc123")
+ 'libra-abc123'
"""
# Replace any character that's not alphanumeric, dash, underscore, slash, or period with a hyphen
sanitized = re.sub(r'[^A-Za-z0-9\-_/.]', '-', text)
@@ -67,7 +67,7 @@ def format_transaction(
postings: List of posting dicts (formatted by format_posting)
payee: Optional payee
tags: Optional tags (e.g., ["expense-entry", "approved"])
- links: Optional links (e.g., ["castle-abc123", "^invoice-xyz"])
+ links: Optional links (e.g., ["libra-abc123", "^invoice-xyz"])
meta: Optional transaction metadata
Returns:
@@ -93,8 +93,8 @@ def format_transaction(
)
],
tags=["expense-entry"],
- links=["castle-abc123"],
- meta={"user-id": "abc123", "source": "castle-expense-entry"}
+ links=["libra-abc123"],
+ meta={"user-id": "abc123", "source": "libra-expense-entry"}
)
"""
return {
@@ -150,7 +150,7 @@ def format_posting_with_cost(
"""
Format a posting with cost basis for Fava API.
- This is the RECOMMENDED format for all Castle transactions.
+ This is the RECOMMENDED format for all Libra transactions.
Uses Beancount's cost basis syntax to preserve exchange rates.
IMPORTANT: Beancount cost syntax uses PER-UNIT cost, not total cost.
@@ -381,7 +381,7 @@ def format_expense_entry(
# Build entry metadata
entry_meta = {
"user-id": user_id,
- "source": "castle-api",
+ "source": "libra-api",
"entry-id": entry_id
}
@@ -419,7 +419,7 @@ def format_receivable_entry(
entry_id: Optional[str] = None
) -> Dict[str, Any]:
"""
- Format a receivable entry (user owes castle).
+ Format a receivable entry (user owes libra).
Uses price notation (@@ SATS) for BQL-queryable SATS tracking.
Generates unique receivable link (^rcv-{entry_id}) for settlement tracking.
@@ -466,7 +466,7 @@ def format_receivable_entry(
entry_meta = {
"user-id": user_id,
- "source": "castle-api",
+ "source": "libra-api",
"entry-id": entry_id
}
@@ -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 = []
diff --git a/config.json b/config.json
index b83f290..00c8c50 100644
--- a/config.json
+++ b/config.json
@@ -1,11 +1,11 @@
{
- "name": "Castle Accounting",
+ "name": "Libra",
"short_description": "Double-entry accounting system for collective projects",
- "tile": "/castle/static/image/castle.png",
+ "tile": "/libra/static/image/libra.png",
"contributors": [
"Your Name"
],
"hidden": false,
- "migration_module": "lnbits.extensions.castle.migrations",
- "db_name": "ext_castle"
+ "migration_module": "lnbits.extensions.libra.migrations",
+ "db_name": "ext_libra"
}
diff --git a/core/__init__.py b/core/__init__.py
index 662bb20..10c362c 100644
--- a/core/__init__.py
+++ b/core/__init__.py
@@ -1,5 +1,5 @@
"""
-Castle Core Module - Pure accounting logic separated from database operations.
+Libra Core Module - Pure accounting logic separated from database operations.
This module contains the core business logic for double-entry accounting,
following Beancount patterns for clean architecture:
diff --git a/core/validation.py b/core/validation.py
index d2372b8..c29f069 100644
--- a/core/validation.py
+++ b/core/validation.py
@@ -1,5 +1,5 @@
"""
-Validation rules for Castle accounting.
+Validation rules for Libra accounting.
Comprehensive validation following Beancount's plugin system approach,
but implemented as simple functions that can be called directly.
@@ -159,7 +159,7 @@ def validate_receivable_entry(
revenue_account_type: str
) -> None:
"""
- Validate a receivable entry (user owes castle).
+ Validate a receivable entry (user owes libra).
Args:
user_id: User ID
diff --git a/crud.py b/crud.py
index cd610b1..b2b43dd 100644
--- a/crud.py
+++ b/crud.py
@@ -14,7 +14,7 @@ from .models import (
AssertionStatus,
AssignUserRole,
BalanceAssertion,
- CastleSettings,
+ LibraSettings,
CreateAccount,
CreateAccountPermission,
CreateBalanceAssertion,
@@ -32,7 +32,7 @@ from .models import (
StoredUserWalletSettings,
UpdateRole,
UserBalance,
- UserCastleSettings,
+ UserLibraSettings,
UserEquityStatus,
UserRole,
UserWalletSettings,
@@ -49,7 +49,7 @@ from .core.validation import (
validate_payment_entry,
)
-db = Database("ext_castle")
+db = Database("ext_libra")
# ===== CACHING =====
# Cache for account and permission lookups to reduce DB queries
@@ -197,7 +197,7 @@ async def get_or_create_user_account(
Get or create a user-specific account with hierarchical naming.
This function checks if the account exists in Fava/Beancount and creates it
- if it doesn't exist. The account is also registered in Castle's database for
+ if it doesn't exist. The account is also registered in Libra's database for
metadata tracking (permissions, descriptions, etc.).
Examples:
@@ -214,7 +214,7 @@ async def get_or_create_user_account(
# Generate hierarchical account name
account_name = format_hierarchical_account_name(account_type, base_name, user_id)
- # Try to find existing account with this hierarchical name in Castle DB
+ # Try to find existing account with this hierarchical name in Libra DB
account = await db.fetchone(
"""
SELECT * FROM accounts
@@ -224,9 +224,9 @@ async def get_or_create_user_account(
Account,
)
- logger.info(f"[ACCOUNT CHECK] User {user_id[:8]}, Account: {account_name}, In Castle DB: {account is not None}")
+ logger.info(f"[ACCOUNT CHECK] User {user_id[:8]}, Account: {account_name}, In Libra DB: {account is not None}")
- # Always check/create in Fava, even if account exists in Castle DB
+ # Always check/create in Fava, even if account exists in Libra DB
# This ensures Beancount has the Open directive
fava_account_exists = False
if True: # Always check Fava
@@ -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
diff --git a/description.md b/description.md
index b3628a2..294a3b3 100644
--- a/description.md
+++ b/description.md
@@ -1,4 +1,4 @@
-# Castle Accounting
+# Libra
A comprehensive double-entry accounting system for collective projects, designed specifically for LNbits.
@@ -7,29 +7,29 @@ A comprehensive double-entry accounting system for collective projects, designed
- **Double-Entry Bookkeeping**: Full accounting system with debits and credits
- **Chart of Accounts**: Pre-configured accounts for Assets, Liabilities, Equity, Revenue, and Expenses
- **User Expense Tracking**: Members can record out-of-pocket expenses as either:
- - **Liabilities**: Castle owes them money (reimbursable)
+ - **Liabilities**: Libra owes them money (reimbursable)
- **Equity**: Their contribution to the collective
-- **Accounts Receivable**: Track what users owe the Castle (e.g., accommodation fees)
+- **Accounts Receivable**: Track what users owe the 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.
diff --git a/docs/ACCOUNT-SYNC-AND-PERMISSION-IMPROVEMENTS.md b/docs/ACCOUNT-SYNC-AND-PERMISSION-IMPROVEMENTS.md
index f5528f5..ea37aaa 100644
--- a/docs/ACCOUNT-SYNC-AND-PERMISSION-IMPROVEMENTS.md
+++ b/docs/ACCOUNT-SYNC-AND-PERMISSION-IMPROVEMENTS.md
@@ -8,9 +8,9 @@
## Summary
-Implemented two major improvements for Castle administration:
+Implemented two major improvements for Libra administration:
-1. **Account Synchronization** - Automatically sync accounts from Beancount → Castle DB
+1. **Account Synchronization** - Automatically sync accounts from Beancount → Libra DB
2. **Bulk Permission Management** - Tools for managing permissions at scale
**Total Implementation Time**: ~4 hours
@@ -23,24 +23,24 @@ Implemented two major improvements for Castle administration:
### Problem Solved
-**Before**: Accounts existed in both Beancount and Castle DB, with manual sync required.
-**After**: Automatic sync keeps Castle DB in sync with Beancount (source of truth).
+**Before**: Accounts existed in both Beancount and Libra DB, with manual sync required.
+**After**: Automatic sync keeps Libra DB in sync with Beancount (source of truth).
### Implementation
-**New Module**: `castle/account_sync.py`
+**New Module**: `libra/account_sync.py`
**Core Functions**:
```python
-# 1. Full sync from Beancount to Castle
+# 1. Full sync from Beancount to Libra
stats = await sync_accounts_from_beancount(force_full_sync=False)
# 2. Sync single account
success = await sync_single_account_from_beancount("Expenses:Food")
# 3. Ensure account exists (recommended before granting permissions)
-exists = await ensure_account_exists_in_castle("Expenses:Marketing")
+exists = await ensure_account_exists_in_libra("Expenses:Marketing")
# 4. Scheduled background sync (run hourly)
stats = await scheduled_account_sync()
@@ -77,7 +77,7 @@ stats = await scheduled_account_sync()
```python
# Sync all accounts from Beancount
-from castle.account_sync import sync_accounts_from_beancount
+from libra.account_sync import sync_accounts_from_beancount
stats = await sync_accounts_from_beancount()
@@ -96,11 +96,11 @@ Errors: 0
#### Before Granting Permission (Best Practice)
```python
-from castle.account_sync import ensure_account_exists_in_castle
-from castle.crud import create_account_permission
+from libra.account_sync import ensure_account_exists_in_libra
+from libra.crud import create_account_permission
-# Ensure account exists in Castle DB first
-account_exists = await ensure_account_exists_in_castle("Expenses:Marketing")
+# Ensure account exists in Libra DB first
+account_exists = await ensure_account_exists_in_libra("Expenses:Marketing")
if account_exists:
# Now safe to grant permission
@@ -116,9 +116,9 @@ if account_exists:
```python
# Add to your scheduler (cron, APScheduler, etc.)
-from castle.account_sync import scheduled_account_sync
+from libra.account_sync import scheduled_account_sync
-# Run every hour to keep Castle DB in sync
+# Run every hour to keep Libra DB in sync
scheduler.add_job(
scheduled_account_sync,
'interval',
@@ -142,7 +142,7 @@ Authorization: Bearer {admin_key}
```json
{
"total_beancount_accounts": 150,
- "total_castle_accounts": 150,
+ "total_libra_accounts": 150,
"accounts_added": 2,
"accounts_updated": 0,
"accounts_skipped": 148,
@@ -152,8 +152,8 @@ Authorization: Bearer {admin_key}
### Benefits
-1. **Beancount as Source of Truth**: Castle DB automatically reflects Beancount state
-2. **Reduced Manual Work**: No more manual account creation in Castle
+1. **Beancount as Source of Truth**: Libra DB automatically reflects Beancount state
+2. **Reduced Manual Work**: No more manual account creation in Libra
3. **Prevents Permission Errors**: Cannot grant permission on non-existent account
4. **Audit Trail**: Tracks which accounts were synced and when
5. **Safe Operations**: Continues on errors, never deletes accounts
@@ -169,7 +169,7 @@ Authorization: Bearer {admin_key}
### Implementation
-**New Module**: `castle/permission_management.py`
+**New Module**: `libra/permission_management.py`
**Core Functions**:
@@ -471,19 +471,19 @@ print(f"Permission types removed: {result['permission_types_removed']}")
# OLD: Manual permission creation (risky)
await create_account_permission(
user_id="alice",
- account_id="acc123", # What if account doesn't exist in Castle DB?
+ account_id="acc123", # What if account doesn't exist in Libra DB?
permission_type=PermissionType.SUBMIT_EXPENSE,
granted_by="admin"
)
# NEW: Safe permission creation with account sync
-from castle.account_sync import ensure_account_exists_in_castle
+from libra.account_sync import ensure_account_exists_in_libra
# Ensure account exists first
-account_exists = await ensure_account_exists_in_castle("Expenses:Marketing")
+account_exists = await ensure_account_exists_in_libra("Expenses:Marketing")
if account_exists:
- # Now safe - account guaranteed to be in Castle DB
+ # Now safe - account guaranteed to be in Libra DB
await create_account_permission(
user_id="alice",
account_id=account_id,
@@ -497,10 +497,10 @@ else:
### Scheduler Integration
```python
-# Add to your Castle extension startup
+# Add to your Libra extension startup
from apscheduler.schedulers.asyncio import AsyncIOScheduler
-from castle.account_sync import scheduled_account_sync
-from castle.permission_management import cleanup_expired_permissions
+from libra.account_sync import scheduled_account_sync
+from libra.permission_management import cleanup_expired_permissions
scheduler = AsyncIOScheduler()
@@ -610,7 +610,7 @@ async def test_copy_permissions():
async def test_onboarding_workflow():
"""Test complete onboarding workflow"""
# 1. Sync account
- await ensure_account_exists_in_castle("Expenses:Food")
+ await ensure_account_exists_in_libra("Expenses:Food")
# 2. Copy permissions from template user
result = await copy_permissions(
@@ -745,19 +745,19 @@ logger.error(f"Account sync error: {error}")
## Migration Guide
-### For Existing Castle Installations
+### For Existing Libra Installations
**Step 1: Deploy New Modules**
```bash
-# Copy new files to Castle extension
-cp account_sync.py /path/to/castle/
-cp permission_management.py /path/to/castle/
+# Copy new files to Libra extension
+cp account_sync.py /path/to/libra/
+cp permission_management.py /path/to/libra/
```
**Step 2: Initial Account Sync**
```python
# Run once to sync existing accounts
-from castle.account_sync import sync_accounts_from_beancount
+from libra.account_sync import sync_accounts_from_beancount
stats = await sync_accounts_from_beancount(force_full_sync=True)
print(f"Synced {stats['accounts_added']} accounts")
@@ -784,14 +784,14 @@ await bulk_grant_permission(...)
## Documentation Updates
**New files created**:
-- ✅ `castle/account_sync.py` (230 lines)
-- ✅ `castle/permission_management.py` (400 lines)
+- ✅ `libra/account_sync.py` (230 lines)
+- ✅ `libra/permission_management.py` (400 lines)
- ✅ `docs/PERMISSIONS-SYSTEM.md` (full permission system docs)
- ✅ `docs/ACCOUNT-SYNC-AND-PERMISSION-IMPROVEMENTS.md` (this file)
**Files to update**:
-- `castle/views_api.py` - Add new admin endpoints
-- `castle/README.md` - Document new features
+- `libra/views_api.py` - Add new admin endpoints
+- `libra/README.md` - Document new features
- `tests/` - Add comprehensive tests
---
@@ -801,7 +801,7 @@ await bulk_grant_permission(...)
### What Was Built
1. **Account Sync Module** (230 lines)
- - Automatic sync from Beancount → Castle DB
+ - Automatic sync from Beancount → Libra DB
- Type inference and user ID extraction
- Background scheduling support
diff --git a/docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.html b/docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.html
index d0e9bfe..6271865 100644
--- a/docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.html
+++ b/docs/ACCOUNTING-ANALYSIS-NET-SETTLEMENT.html
@@ -195,13 +195,13 @@ id="toc-professional-assessment">Professional Assessment
Accounting
Analysis: Net Settlement Entry Pattern
Date: 2025-01-12 Prepared By:
-Senior Accounting Review Subject: Castle Extension -
+Senior Accounting Review Subject: Libra Extension -
Lightning Payment Settlement Entries Status: Technical
Review
Executive Summary
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
Background: The Technical
Challenge
-Castle operates as a Lightning Network-integrated accounting system
+
Libra operates as a Lightning Network-integrated accounting system
for collectives (co-living spaces, makerspaces). It faces a unique
accounting challenge:
Scenario: User creates a receivable in EUR (e.g.,
@@ -223,7 +223,7 @@ accounting challenge:
Challenge: Record the payment while: 1. Clearing the
exact EUR receivable amount 2. Recording the exact satoshi amount
received 3. Handling cases where users have both receivables (owe
-Castle) and payables (Castle owes them) 4. Maintaining Beancount
+Libra) and payables (Libra owes them) 4. Maintaining Beancount
double-entry balance
Current Implementation
@@ -231,7 +231,7 @@ double-entry balance
; Step 1: Receivable Created
2025-11-12 * "room (200.00 EUR)" #receivable-entry
user-id: "375ec158"
- source: "castle-api"
+ source: "libra-api"
sats-amount: "225033"
Assets:Receivable:User-375ec158 200.00 EUR
sats-equivalent: "225033"
@@ -344,7 +344,7 @@ class="sourceCode sql">Assets:Bitcoin:Lightning 200.00 EUR
sats-received: "225033"
@@ -452,8 +452,8 @@ OR payable)
(receivable AND payable)
When Net Settlement is Appropriate:
-User owes Castle: 555.00 EUR (receivable)
-Castle owes User: 38.00 EUR (payable)
+User owes Libra: 555.00 EUR (receivable)
+Libra owes User: 38.00 EUR (payable)
Net amount due: 517.00 EUR (true settlement)
Proper three-posting entry:
Assets:Bitcoin:Lightning 565251 SATS @@ 517.00 EUR
@@ -461,8 +461,8 @@ Assets:Receivable:User -555.00 EUR
Liabilities:Payable:User 38.00 EUR
; Net: 517.00 = -555.00 + 38.00 ✓
When Two Postings Suffice:
-User owes Castle: 200.00 EUR (receivable)
-Castle owes User: 0.00 EUR (no payable)
+User owes Libra: 200.00 EUR (receivable)
+Libra owes User: 0.00 EUR (no payable)
Amount due: 200.00 EUR (simple payment)
Simpler two-posting entry:
Assets:Bitcoin:Lightning 225033 SATS @@ 200.00 EUR
@@ -515,7 +515,7 @@ positions - ❌ Requires metadata parsing for SATS balances
id="approach-3-true-net-settlement-when-both-obligations-exist">Approach
3: True Net Settlement (When Both Obligations Exist)
2025-11-12 * "Net settlement via Lightning"
- ; User owes 555 EUR, Castle owes 38 EUR, net: 517 EUR
+ ; User owes 555 EUR, Libra owes 38 EUR, net: 517 EUR
Assets:Bitcoin:Lightning 517.00 EUR
sats-received: "565251"
Assets:Receivable:User-375ec158 -555.00 EUR
@@ -570,7 +570,7 @@ Method
Decision Required: Select either position-based OR
metadata-based satoshi tracking.
Option A - Keep Metadata Approach (recommended for
-Castle):
+Libra):
# In format_net_settlement_entry()
postings = [
@@ -604,7 +604,7 @@ class="sourceCode python"> }
]
Recommendation: Choose Option A (metadata) for
-consistency with Castle’s architecture.
+consistency with Libra’s architecture.
1.3 Rename Function for
Clarity
@@ -713,7 +713,7 @@ class="sourceCode python"> payment_hash=payment_hash
)
else:
- # PAYABLE PAYMENT: Castle paying user (different flow)
+ # PAYABLE PAYMENT: Libra paying user (different flow)
return await format_payable_payment_entry(...)
Priority 3:
@@ -742,7 +742,7 @@ architectures:
Recommendation: Architecture A (EUR primary)
because: 1. Most receivables created in EUR 2. Financial reporting
requirements typically in fiat 3. Tax obligations calculated in fiat 4.
-Aligns with current Castle metadata approach
+Aligns with current Libra metadata approach
3.2
Consider Separate Ledger for Cryptocurrency Holdings
@@ -754,7 +754,7 @@ from fiat accounting
Assets:Receivable:User-375ec158 -200.00 EUR
Cryptocurrency Sub-Ledger (SATS-denominated):
2025-11-12 * "Lightning payment received"
- Assets:Bitcoin:Lightning:Castle 225033 SATS
+ Assets:Bitcoin:Lightning:Libra 225033 SATS
Assets:Bitcoin:Custody:User-375ec 225033 SATS
Benefits: - ✅ Clean separation of concerns - ✅
Cryptocurrency movements tracked independently - ✅ Fiat accounting
@@ -902,7 +902,7 @@ Entry balances
Is this “best practice” accounting?
No, this implementation deviates from traditional
accounting standards in several ways.
-Is it acceptable for Castle’s use case? Yes,
+Is it acceptable for Libra’s use case? Yes,
with modifications, it’s a reasonable pragmatic solution for a
novel problem (cryptocurrency payments of fiat debts).
Critical improvements needed: 1. ✅ Remove
@@ -912,7 +912,7 @@ Separate payment vs. settlement logic (accuracy and clarity)
The fundamental challenge: Traditional accounting
wasn’t designed for this scenario. There is no established “standard”
for recording cryptocurrency payments of fiat-denominated receivables.
-Castle’s approach is functional, but should be refined to align better
+Libra’s approach is functional, but should be refined to align better
with accounting principles where possible.
Next Steps
@@ -935,7 +935,7 @@ Characteristics of Accounting Information
- ASC 105-10-05: Substance Over Form
- Beancount Documentation:
http://furius.ca/beancount/doc/index
-- Castle Extension:
+
- Libra Extension:
docs/SATS-EQUIVALENT-METADATA.md
- BQL Analysis:
docs/BQL-BALANCE-QUERIES.md
@@ -948,6 +948,6 @@ implemented
This analysis was prepared for internal review and development
planning. It represents a professional accounting assessment of the
current implementation and should be used to guide improvements to
-Castle’s payment recording system.
+Libra’s payment recording system.