1
0
Fork 0
forked from aiolabs/libra

Rename Castle Accounting extension to Libra

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

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

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

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

View file

@ -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