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,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'`

View file

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