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:
parent
9c577c740c
commit
c174cda48d
44 changed files with 953 additions and 953 deletions
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue