from datetime import datetime from decimal import Decimal from http import HTTPStatus from fastapi import APIRouter, Depends, HTTPException from loguru import logger from lnbits.core.models import User, WalletTypeInfo from lnbits.decorators import ( check_super_user, check_user_exists, require_admin_key, require_invoice_key, ) from lnbits.utils.exchange_rates import allowed_currencies, fiat_amount_as_satoshis from .crud import ( approve_manual_payment_request, check_balance_assertion, create_account, create_account_permission, create_balance_assertion, create_manual_payment_request, db, delete_account_permission, delete_balance_assertion, get_account, get_account_by_name, get_account_permission, get_account_permissions, get_all_accounts, get_all_manual_payment_requests, get_all_user_wallet_settings, get_balance_assertion, get_balance_assertions, get_journal_entry, get_manual_payment_request, get_or_create_user_account, get_user_manual_payment_requests, get_user_permissions, get_user_permissions_with_inheritance, reject_manual_payment_request, ) from .models import ( Account, AccountPermission, AccountType, AccountWithPermissions, AssertionStatus, BalanceAssertion, CastleSettings, CreateAccount, CreateAccountPermission, CreateBalanceAssertion, CreateEntryLine, CreateJournalEntry, CreateManualPaymentRequest, CreateUserEquityStatus, ExpenseEntry, GeneratePaymentInvoice, JournalEntry, JournalEntryFlag, ManualPaymentRequest, PayUser, PermissionType, ReceivableEntry, RecordPayment, RevenueEntry, SettleReceivable, UserBalance, UserEquityStatus, UserInfo, UserWalletSettings, ) from .services import get_settings, get_user_wallet, update_settings, update_user_wallet castle_api_router = APIRouter() # ===== HELPER FUNCTIONS ===== async def check_castle_wallet_configured() -> str: """Ensure castle wallet is configured, return wallet_id""" settings = await get_settings("admin") if not settings or not settings.castle_wallet_id: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="Castle wallet not configured. Please contact the super user to configure the Castle wallet in settings.", ) return settings.castle_wallet_id async def check_user_wallet_configured(user_id: str) -> str: """Ensure user has configured their wallet, return wallet_id""" from lnbits.settings import settings as lnbits_settings # If user is super user, use the castle wallet if user_id == lnbits_settings.super_user: castle_settings = await get_settings("admin") if castle_settings and castle_settings.castle_wallet_id: return castle_settings.castle_wallet_id raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="Castle wallet not configured. Please configure the Castle wallet in settings.", ) # For regular users, check their personal wallet user_wallet = await get_user_wallet(user_id) if not user_wallet or not user_wallet.user_wallet_id: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="You must configure your wallet in settings before using this feature.", ) return user_wallet.user_wallet_id # ===== UTILITY ENDPOINTS ===== @castle_api_router.get("/api/v1/currencies") async def api_get_currencies() -> list[str]: """Get list of allowed currencies for fiat conversion""" return allowed_currencies() # ===== ACCOUNT ENDPOINTS ===== @castle_api_router.get("/api/v1/accounts") async def api_get_accounts( filter_by_user: bool = False, wallet: WalletTypeInfo = Depends(require_invoice_key), ) -> list[Account] | list[AccountWithPermissions]: """ Get all accounts in the chart of accounts. - filter_by_user: If true, only return accounts the user has permissions for - Returns AccountWithPermissions objects when filter_by_user=true, otherwise Account objects """ all_accounts = await get_all_accounts() if not filter_by_user: # Return all accounts without filtering return all_accounts # Filter by user permissions user_id = wallet.wallet.user user_permissions = await get_user_permissions(user_id) # Get set of account IDs the user has any permission on permitted_account_ids = {perm.account_id for perm in user_permissions} # Build list of accounts with permission metadata accounts_with_permissions = [] for account in all_accounts: # Check if user has direct permission on this account account_perms = [ perm for perm in user_permissions if perm.account_id == account.id ] # Check if user has inherited permission from parent account inherited_perms = await get_user_permissions_with_inheritance( user_id, account.name, PermissionType.READ ) # Determine if account should be included has_access = bool(account_perms) or bool(inherited_perms) if has_access: # Parse hierarchical account name to get parent and level parts = account.name.split(":") level = len(parts) - 1 parent_account = ":".join(parts[:-1]) if level > 0 else None # Determine inherited_from (which parent account gave access) inherited_from = None if inherited_perms and not account_perms: # Permission is inherited, use the parent account name _, parent_name = inherited_perms[0] inherited_from = parent_name # Collect permission types for this account permission_types = [perm.permission_type for perm in account_perms] # Check if account has children has_children = any( a.name.startswith(account.name + ":") for a in all_accounts ) accounts_with_permissions.append( AccountWithPermissions( id=account.id, name=account.name, account_type=account.account_type, description=account.description, user_id=account.user_id, created_at=account.created_at, user_permissions=permission_types if permission_types else None, inherited_from=inherited_from, parent_account=parent_account, level=level, has_children=has_children, ) ) return accounts_with_permissions @castle_api_router.post("/api/v1/accounts", status_code=HTTPStatus.CREATED) async def api_create_account( data: CreateAccount, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> Account: """Create a new account (admin only)""" return await create_account(data) @castle_api_router.get("/api/v1/accounts/{account_id}") async def api_get_account(account_id: str) -> Account: """Get a specific account""" account = await get_account(account_id) if not account: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Account not found" ) return account @castle_api_router.get("/api/v1/accounts/{account_id}/balance") async def api_get_account_balance(account_id: str) -> dict: """Get account balance from Fava/Beancount""" from .fava_client import get_fava_client # Get account to retrieve its name account = await get_account(account_id) if not account: raise HTTPException(status_code=404, detail="Account not found") # Query Fava for balance fava = get_fava_client() balance_data = await fava.get_account_balance(account.name) return { "account_id": account_id, "balance": balance_data["sats"], # Balance in satoshis "positions": balance_data["positions"] # Full Beancount positions with cost basis } @castle_api_router.get("/api/v1/accounts/{account_id}/transactions") async def api_get_account_transactions(account_id: str, limit: int = 100) -> list[dict]: """ Get all transactions for an account from Fava/Beancount. Returns transactions affecting this account in reverse chronological order. """ from .fava_client import get_fava_client # Get account details account = await get_account(account_id) if not account: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail=f"Account {account_id} not found" ) # Query Fava for transactions fava = get_fava_client() transactions = await fava.get_account_transactions(account.name, limit) return transactions # ===== JOURNAL ENTRY ENDPOINTS ===== @castle_api_router.get("/api/v1/entries") async def api_get_journal_entries(limit: int = 100) -> list[dict]: """ Get all journal entries from Fava/Beancount. Returns all transactions in reverse chronological order with username enrichment. """ from lnbits.core.crud.users import get_user from .fava_client import get_fava_client fava = get_fava_client() all_entries = await fava.get_journal_entries() # Filter to transactions only and enrich with username enriched_entries = [] for e in all_entries: if e.get("t") != "Transaction": continue # Extract user ID from metadata or account names user_id = None entry_meta = e.get("meta", {}) if "user-id" in entry_meta: user_id = entry_meta["user-id"] else: # Try to extract from account names in postings for posting in e.get("postings", []): account = posting.get("account", "") if "User-" in account: parts = account.split("User-") if len(parts) > 1: user_id = parts[1] break # Look up username username = None if user_id: user = await get_user(user_id) username = user.username if user and user.username else f"User-{user_id[:8]}" # Add username to entry enriched_entry = dict(e) enriched_entry["user_id"] = user_id enriched_entry["username"] = username enriched_entries.append(enriched_entry) if len(enriched_entries) >= limit: break return enriched_entries @castle_api_router.get("/api/v1/entries/user") async def api_get_user_entries( wallet: WalletTypeInfo = Depends(require_invoice_key), limit: int = 20, offset: int = 0, filter_user_id: str = None, filter_account_type: str = None, # 'asset' for receivable, 'liability' for payable ) -> dict: """ Get journal entries that affect the current user's accounts from Fava/Beancount. Returns transactions in reverse chronological order with optional filtering. """ from lnbits.settings import settings as lnbits_settings from .fava_client import get_fava_client fava = get_fava_client() # Determine which user's entries to fetch if wallet.wallet.user == lnbits_settings.super_user: # Super user can view all or filter by user_id target_user_id = filter_user_id else: # Regular user can only see their own entries target_user_id = wallet.wallet.user # Get all journal entries from Fava (full transaction objects) all_entries = await fava.get_journal_entries() # Filter and transform entries filtered_entries = [] for e in all_entries: if e.get("t") != "Transaction": continue # Skip voided transactions if "voided" in e.get("tags", []): continue # Extract user ID from metadata or account names user_id_match = None entry_meta = e.get("meta", {}) if "user-id" in entry_meta: user_id_match = entry_meta["user-id"] else: # Try to extract from account names in postings for posting in e.get("postings", []): account = posting.get("account", "") if "User-" in account: # Extract user ID from account name (e.g., "Liabilities:Payable:User-abc123") parts = account.split("User-") if len(parts) > 1: user_id_match = parts[1] # Just the short ID after User- break # Filter by target user if specified if target_user_id and user_id_match: if not user_id_match.startswith(target_user_id[:8]): continue # Filter by account type if specified if filter_account_type and user_id_match: postings = e.get("postings", []) has_matching_account = False for posting in postings: account = posting.get("account", "") if filter_account_type.lower() == "asset" and "Receivable" in account: has_matching_account = True break elif filter_account_type.lower() == "liability" and "Payable" in account: has_matching_account = True break if not has_matching_account: continue # Extract data for frontend # Extract entry ID from links entry_id = None links = e.get("links", []) if isinstance(links, (list, set)): for link in links: if isinstance(link, str): link_clean = link.lstrip('^') if "castle-" in link_clean: parts = link_clean.split("castle-") if len(parts) > 1: entry_id = parts[-1] break # Extract amount from postings amount_sats = 0 fiat_amount = None fiat_currency = None postings = e.get("postings", []) if postings: first_posting = postings[0] if isinstance(first_posting, dict): amount_str = first_posting.get("amount", "") # Parse amount string: can be EUR/USD directly (new format) or "SATS {EUR}" (old format) if isinstance(amount_str, str) and amount_str: import re # Try EUR/USD format first (new format: "37.22 EUR") fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str) if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'): # Direct fiat amount (new approach) fiat_amount = abs(float(fiat_match.group(1))) fiat_currency = fiat_match.group(2) # Get SATS from metadata posting_meta = first_posting.get("meta", {}) sats_equiv = posting_meta.get("sats-equivalent") if sats_equiv: amount_sats = abs(int(sats_equiv)) else: # Old format: "36791 SATS {33.33 EUR, 2025-11-09}" or "36791 SATS" sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str) if sats_match: amount_sats = abs(int(sats_match.group(1))) # Extract fiat from cost syntax: {33.33 EUR, ...} cost_match = re.search(r'\{([\d.]+)\s+([A-Z]+)', amount_str) if cost_match: fiat_amount = float(cost_match.group(1)) fiat_currency = cost_match.group(2) # Extract reference from links (first non-castle link) reference = None if isinstance(links, (list, set)): for link in links: if isinstance(link, str): link_clean = link.lstrip('^') if not link_clean.startswith("castle-") and not link_clean.startswith("ln-"): reference = link_clean break # Look up actual username using helper function username = await _get_username_from_user_id(user_id_match) if user_id_match else None entry_data = { "id": entry_id or e.get("entry_hash", "unknown"), "date": e.get("date", ""), "entry_date": e.get("date", ""), "flag": e.get("flag"), "description": e.get("narration", ""), "payee": e.get("payee"), "tags": e.get("tags", []), "links": links, "amount": amount_sats, "user_id": user_id_match, "username": username, "reference": reference, "meta": entry_meta, # Include metadata for frontend } if fiat_amount and fiat_currency: entry_data["fiat_amount"] = fiat_amount entry_data["fiat_currency"] = fiat_currency filtered_entries.append(entry_data) # Sort by date descending filtered_entries.sort(key=lambda x: x.get("date", ""), reverse=True) # Apply pagination total = len(filtered_entries) paginated_entries = filtered_entries[offset:offset + limit] return { "entries": paginated_entries, "total": total, "limit": limit, "offset": offset, "has_next": (offset + limit) < total, "has_prev": offset > 0, } async def _get_username_from_user_id(user_id: str) -> str: """ Helper function to get username from user_id, handling various formats. Supports: - Full UUID with dashes (36 chars): "375ec158-686c-4a21-b44d-a51cc90ef07d" - Dashless UUID (32 chars): "375ec158686c4a21b44da51cc90ef07d" - Partial ID (8 chars from account names): "375ec158" Returns username or formatted fallback. """ from lnbits.core.crud.users import get_user logger.info(f"[USERNAME] Called with: '{user_id}' (len={len(user_id) if user_id else 0})") if not user_id: return None # Case 1: Already in standard UUID format (36 chars with dashes) if len(user_id) == 36 and user_id.count('-') == 4: logger.info(f"[USERNAME] Case 1: Full UUID format") user = await get_user(user_id) result = user.username if user and user.username else f"User-{user_id[:8]}" logger.info(f"[USERNAME] Case 1 result: '{result}'") return result # Case 2: Dashless 32-char UUID - lookup via Castle user settings elif len(user_id) == 32 and '-' not in user_id: logger.info(f"[USERNAME] Case 2: Dashless UUID format - looking up in Castle user settings") try: # Get all Castle users (which have full user_ids) user_settings = await get_all_user_wallet_settings() # Convert dashless to dashed format for comparison user_id_with_dashes = f"{user_id[0:8]}-{user_id[8:12]}-{user_id[12:16]}-{user_id[16:20]}-{user_id[20:32]}" logger.info(f"[USERNAME] Converted to dashed format: {user_id_with_dashes}") # Find matching user for setting in user_settings: if setting.id == user_id_with_dashes: logger.info(f"[USERNAME] Found matching user in Castle settings") # Get username from LNbits user = await get_user(setting.id) result = user.username if user and user.username else f"User-{user_id[:8]}" logger.info(f"[USERNAME] Case 2 result (found): '{result}'") return result # No matching user found logger.info(f"[USERNAME] No matching user found in Castle settings") result = f"User-{user_id[:8]}" logger.info(f"[USERNAME] Case 2 result (not found): '{result}'") return result except Exception as e: logger.error(f"Error looking up user by dashless UUID {user_id}: {e}") result = f"User-{user_id[:8]}" return result # Case 3: Partial ID (8 chars from account name) - lookup via Castle user settings elif len(user_id) == 8: logger.info(f"[USERNAME] Case 3: Partial ID format - looking up in Castle user settings") try: # Get all Castle users (which have full user_ids) user_settings = await get_all_user_wallet_settings() # Find matching user by first 8 chars for setting in user_settings: if setting.id.startswith(user_id): logger.info(f"[USERNAME] Found full user_id: {setting.id}") # Now get username from LNbits with full ID user = await get_user(setting.id) result = user.username if user and user.username else f"User-{user_id}" logger.info(f"[USERNAME] Case 3 result (found): '{result}'") return result # No matching user found in Castle settings logger.info(f"[USERNAME] No matching user found in Castle settings") result = f"User-{user_id}" logger.info(f"[USERNAME] Case 3 result (not found): '{result}'") return result except Exception as e: logger.error(f"Error looking up user by partial ID {user_id}: {e}") result = f"User-{user_id}" return result # Case 4: Unknown format - try as-is and fall back else: logger.info(f"[USERNAME] Case 4: Unknown format - trying as-is") try: user = await get_user(user_id) result = user.username if user and user.username else f"User-{user_id[:8]}" logger.info(f"[USERNAME] Case 4 result: '{result}'") return result except Exception as e: logger.info(f"[USERNAME] Case 4 exception: {e}") result = f"User-{user_id[:8]}" logger.info(f"[USERNAME] Case 4 fallback result: '{result}'") return result @castle_api_router.get("/api/v1/entries/pending") async def api_get_pending_entries( wallet: WalletTypeInfo = Depends(require_admin_key), ) -> list[dict]: """ Get all pending expense entries that need approval (admin only). Returns transactions with flag='!' from Fava/Beancount. """ from lnbits.settings import settings as lnbits_settings from .fava_client import get_fava_client if wallet.wallet.user != lnbits_settings.super_user: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Only super user can access this endpoint", ) # Query Fava for all journal entries (includes links, tags, full metadata) fava = get_fava_client() all_entries = await fava.get_journal_entries() # Filter for pending transactions and extract info pending_entries = [] for e in all_entries: # Only include pending transactions that are NOT voided if e.get("t") == "Transaction" and e.get("flag") == "!" and "voided" not in e.get("tags", []): # Extract entry ID from links field entry_id = None links = e.get("links", []) if isinstance(links, (list, set)): for link in links: if isinstance(link, str): # Strip ^ prefix if present (Beancount link syntax) link_clean = link.lstrip('^') if "castle-" in link_clean: parts = link_clean.split("castle-") if len(parts) > 1: entry_id = parts[-1] break # Extract user ID from metadata or account names user_id = None entry_meta = e.get("meta", {}) logger.info(f"[EXTRACT] Entry metadata keys: {list(entry_meta.keys())}") logger.info(f"[EXTRACT] Entry metadata: {entry_meta}") if "user-id" in entry_meta: user_id = entry_meta["user-id"] logger.info(f"[EXTRACT] Found user-id in metadata: {user_id}") else: logger.info(f"[EXTRACT] No user-id in metadata, checking account names") # Try to extract from account names in postings for posting in e.get("postings", []): account = posting.get("account", "") if "User-" in account: # Extract user ID from account name (e.g., "Liabilities:Payable:User-abc123") parts = account.split("User-") if len(parts) > 1: user_id = parts[1] # Short ID after User- logger.info(f"[EXTRACT] Extracted user_id from account name: {user_id}") break # Look up username using helper function username = await _get_username_from_user_id(user_id) if user_id else None # Extract amount from postings (sum of absolute values / 2) amount_sats = 0 fiat_amount = None fiat_currency = None postings = e.get("postings", []) if postings: first_posting = postings[0] if isinstance(first_posting, dict): amount_str = first_posting.get("amount", "") # Parse amount string format if isinstance(amount_str, str) and amount_str: import re # Try EUR/USD format first (new architecture): "50.00 EUR" fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str) if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'): fiat_amount = abs(float(fiat_match.group(1))) fiat_currency = fiat_match.group(2) # Extract sats equivalent from metadata posting_meta = first_posting.get("meta", {}) sats_equiv = posting_meta.get("sats-equivalent") if sats_equiv: amount_sats = abs(int(sats_equiv)) else: # Legacy SATS format: "36791 SATS {33.33 EUR, 2025-11-09}" or "36791 SATS" sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str) if sats_match: amount_sats = abs(int(sats_match.group(1))) # Extract fiat from cost syntax: {33.33 EUR, ...} cost_match = re.search(r'\{([\d.]+)\s+([A-Z]+)', amount_str) if cost_match: fiat_amount = float(cost_match.group(1)) fiat_currency = cost_match.group(2) entry_data = { "id": entry_id or "unknown", "date": e.get("date", ""), "entry_date": e.get("date", ""), "flag": e.get("flag"), "description": e.get("narration", ""), "payee": e.get("payee"), "tags": e.get("tags", []), "links": links, "amount": amount_sats, "user_id": user_id, "username": username, } # Add fiat info if available if fiat_amount and fiat_currency: entry_data["fiat_amount"] = fiat_amount entry_data["fiat_currency"] = fiat_currency pending_entries.append(entry_data) return pending_entries @castle_api_router.get("/api/v1/entries/{entry_id}") async def api_get_journal_entry(entry_id: str) -> JournalEntry: """Get a specific journal entry""" entry = await get_journal_entry(entry_id) if not entry: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Journal entry not found" ) return entry @castle_api_router.post("/api/v1/entries", status_code=HTTPStatus.CREATED) async def api_create_journal_entry( data: CreateJournalEntry, wallet: WalletTypeInfo = Depends(require_invoice_key), ) -> JournalEntry: """ Create a new generic journal entry. Submits entry to Fava/Beancount. """ from .fava_client import get_fava_client from .beancount_format import format_transaction, format_posting_with_cost # Validate that entry balances to zero total = sum(line.amount for line in data.lines) if total != 0: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail=f"Entry does not balance (total: {total}, expected: 0)" ) # Get all accounts and validate they exist account_map = {} for line in data.lines: account = await get_account(line.account_id) if not account: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail=f"Account '{line.account_id}' not found" ) account_map[line.account_id] = account # Format postings postings = [] for line in data.lines: account = account_map[line.account_id] # Extract fiat info from metadata if present fiat_currency = line.metadata.get("fiat_currency") fiat_amount_str = line.metadata.get("fiat_amount") fiat_amount = Decimal(fiat_amount_str) if fiat_amount_str else None # Create posting metadata (excluding fiat fields that are used for primary amount) posting_metadata = {k: v for k, v in line.metadata.items() if k not in ["fiat_currency", "fiat_amount"]} # If fiat currency is provided, use EUR-based format (primary amount in EUR, sats in metadata) # Otherwise, use SATS-based format if fiat_currency and fiat_amount: # EUR-based posting (current architecture) posting_metadata["sats-equivalent"] = str(abs(line.amount)) # Apply the sign from line.amount to fiat_amount # line.amount is positive for debits, negative for credits signed_fiat_amount = fiat_amount if line.amount >= 0 else -fiat_amount posting = { "account": account.name, "amount": f"{signed_fiat_amount:.2f} {fiat_currency}", "meta": posting_metadata if posting_metadata else None } else: # SATS-based posting (legacy/fallback) if line.description: posting_metadata["description"] = line.description posting = format_posting_with_cost( account=account.name, amount_sats=line.amount, fiat_currency=None, fiat_amount=None, metadata=posting_metadata if posting_metadata else None ) postings.append(posting) # Extract tags and links from meta tags = data.meta.get("tags", []) links = data.meta.get("links", []) if data.reference: links.append(data.reference) # Entry metadata (excluding tags and links which go at transaction level) entry_meta = {k: v for k, v in data.meta.items() if k not in ["tags", "links"]} entry_meta["source"] = "castle-api" entry_meta["created-by"] = wallet.wallet.id # Format as Beancount entry fava = get_fava_client() entry = format_transaction( date_val=data.entry_date.date() if data.entry_date else datetime.now().date(), flag=data.flag.value if data.flag else "*", narration=data.description, postings=postings, tags=tags if tags else None, links=links if links else None, meta=entry_meta ) # Submit to Fava result = await fava.add_entry(entry) logger.info(f"Journal entry submitted to Fava: {result.get('data', 'Unknown')}") # Return simplified JournalEntry for API compatibility # Note: Castle no longer stores entries in DB, Fava is the source of truth timestamp = datetime.now().timestamp() return JournalEntry( id=f"fava-{timestamp}", description=data.description, entry_date=data.entry_date if data.entry_date else datetime.now(), created_by=wallet.wallet.id, created_at=datetime.now(), reference=data.reference, flag=data.flag if data.flag else JournalEntryFlag.CLEARED, lines=[], # Empty - entry is stored in Fava, not Castle DB meta={"source": "fava", "fava_response": result.get('data', 'Unknown')} ) # ===== SIMPLIFIED ENTRY ENDPOINTS ===== @castle_api_router.post("/api/v1/entries/expense", status_code=HTTPStatus.CREATED) async def api_create_expense_entry( data: ExpenseEntry, wallet: WalletTypeInfo = Depends(require_invoice_key), ) -> JournalEntry: """ Create an expense entry for a user. If is_equity=True, records as equity contribution. If is_equity=False, records as liability (castle owes user). If currency is provided, amount is converted from fiat to satoshis. """ # Check that castle wallet is configured await check_castle_wallet_configured() # Check that user has configured their wallet await check_user_wallet_configured(wallet.wallet.user) # Handle currency conversion amount_sats = int(data.amount) metadata = {} if data.currency: # Validate currency if data.currency.upper() not in allowed_currencies(): raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail=f"Currency '{data.currency}' not allowed. Use one of: {', '.join(allowed_currencies())}", ) # Convert fiat to satoshis amount_sats = await fiat_amount_as_satoshis(float(data.amount), data.currency) # Store currency metadata (store fiat_amount as string to preserve Decimal precision) metadata = { "fiat_currency": data.currency.upper(), "fiat_amount": str(data.amount.quantize(Decimal("0.001"))), # Store as string with 3 decimal places "fiat_rate": float(amount_sats) / float(data.amount) if data.amount > 0 else 0, "btc_rate": float(data.amount) / float(amount_sats) * 100_000_000 if amount_sats > 0 else 0, } # Get or create expense account expense_account = await get_account_by_name(data.expense_account) if not expense_account: # Try to get it by ID expense_account = await get_account(data.expense_account) if not expense_account: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail=f"Expense account '{data.expense_account}' not found", ) # Validate user has permission to submit expenses to this account from .crud import get_user_permissions_with_inheritance submit_perms = await get_user_permissions_with_inheritance( wallet.wallet.user, expense_account.name, PermissionType.SUBMIT_EXPENSE ) if not submit_perms: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail=f"You do not have permission to submit expenses to account '{expense_account.name}'. Please contact an administrator to request access.", ) # Get or create user-specific account if data.is_equity: # Validate equity eligibility from .crud import get_user_equity_status equity_status = await get_user_equity_status(wallet.wallet.user) if not equity_status or not equity_status.is_equity_eligible: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="User is not eligible to contribute expenses to equity. Please submit for cash reimbursement.", ) if not equity_status.equity_account_name: raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="User equity account not configured. Contact administrator.", ) # Equity contribution - use user's specific equity account user_account = await get_account_by_name(equity_status.equity_account_name) if not user_account: raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"Equity account '{equity_status.equity_account_name}' not found. Contact administrator.", ) else: # Liability (castle owes user) user_account = await get_or_create_user_account( wallet.wallet.user, AccountType.LIABILITY, "Accounts Payable" ) # Create journal entry # DR Expense, CR User Account (Liability or Equity) description_suffix = f" ({metadata['fiat_amount']} {metadata['fiat_currency']})" if metadata else "" # Add meta information for audit trail entry_meta = { "source": "api", "created_via": "expense_entry", "user_id": wallet.wallet.user, "is_equity": data.is_equity, } # Format as Beancount entry and submit to Fava from .fava_client import get_fava_client from .beancount_format import format_expense_entry, sanitize_link fava = get_fava_client() # Extract fiat info from metadata fiat_currency = metadata.get("fiat_currency") if metadata else None fiat_amount = Decimal(metadata.get("fiat_amount")) if metadata and metadata.get("fiat_amount") else None # Generate unique entry ID for tracking import uuid entry_id = str(uuid.uuid4()).replace("-", "")[:16] # Add castle ID as reference/link (sanitized for Beancount) castle_reference = f"castle-{entry_id}" if data.reference: castle_reference = f"{sanitize_link(data.reference)}-{entry_id}" # Format Beancount entry entry = format_expense_entry( user_id=wallet.wallet.user, expense_account=expense_account.name, user_account=user_account.name, amount_sats=amount_sats, description=data.description, entry_date=data.entry_date.date() if data.entry_date else datetime.now().date(), is_equity=data.is_equity, fiat_currency=fiat_currency, fiat_amount=fiat_amount, reference=castle_reference # Add castle ID as link ) # Submit to Fava result = await fava.add_entry(entry) # Return a JournalEntry-like response for compatibility from .models import EntryLine return JournalEntry( id=entry_id, # Use the generated castle entry ID description=data.description + description_suffix, entry_date=data.entry_date if data.entry_date else datetime.now(), created_by=wallet.wallet.id, created_at=datetime.now(), reference=castle_reference, flag=JournalEntryFlag.PENDING, meta=entry_meta, lines=[ EntryLine( id=f"line-1-{entry_id}", journal_entry_id=entry_id, account_id=expense_account.id, amount=amount_sats, description=f"Expense paid by user {wallet.wallet.user[:8]}", metadata=metadata or {} ), EntryLine( id=f"line-2-{entry_id}", journal_entry_id=entry_id, account_id=user_account.id, amount=-amount_sats, description=f"{'Equity contribution' if data.is_equity else 'Amount owed to user'}", metadata=metadata or {} ), ] ) @castle_api_router.post("/api/v1/entries/receivable", status_code=HTTPStatus.CREATED) async def api_create_receivable_entry( data: ReceivableEntry, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> JournalEntry: """ Create an accounts receivable entry (user owes castle). Admin only to prevent abuse. If currency is provided, amount is converted from fiat to satoshis. """ # Handle currency conversion amount_sats = int(data.amount) metadata = {} if data.currency: # Validate currency if data.currency.upper() not in allowed_currencies(): raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail=f"Currency '{data.currency}' not allowed. Use one of: {', '.join(allowed_currencies())}", ) # Convert fiat to satoshis amount_sats = await fiat_amount_as_satoshis(float(data.amount), data.currency) # Store currency metadata (store fiat_amount as string to preserve Decimal precision) metadata = { "fiat_currency": data.currency.upper(), "fiat_amount": str(data.amount.quantize(Decimal("0.001"))), # Store as string with 3 decimal places "fiat_rate": float(amount_sats) / float(data.amount) if data.amount > 0 else 0, "btc_rate": float(data.amount) / float(amount_sats) * 100_000_000 if amount_sats > 0 else 0, } # Get or create revenue account revenue_account = await get_account_by_name(data.revenue_account) if not revenue_account: revenue_account = await get_account(data.revenue_account) if not revenue_account: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail=f"Revenue account '{data.revenue_account}' not found", ) # Get or create user-specific receivable account user_receivable = await get_or_create_user_account( data.user_id, AccountType.ASSET, "Accounts Receivable" ) # Create journal entry # DR Accounts Receivable (User), CR Revenue description_suffix = f" ({metadata['fiat_amount']} {metadata['fiat_currency']})" if metadata else "" # Add meta information for audit trail entry_meta = { "source": "api", "created_via": "receivable_entry", "debtor_user_id": data.user_id, } # Format as Beancount entry and submit to Fava from .fava_client import get_fava_client from .beancount_format import format_receivable_entry, sanitize_link fava = get_fava_client() # Extract fiat info from metadata fiat_currency = metadata.get("fiat_currency") if metadata else None fiat_amount = Decimal(metadata.get("fiat_amount")) if metadata and metadata.get("fiat_amount") else None # Generate unique entry ID for tracking import uuid entry_id = str(uuid.uuid4()).replace("-", "")[:16] # Add castle ID as reference/link (sanitized for Beancount) castle_reference = f"castle-{entry_id}" if data.reference: castle_reference = f"{sanitize_link(data.reference)}-{entry_id}" # Format Beancount entry entry = format_receivable_entry( user_id=data.user_id, revenue_account=revenue_account.name, receivable_account=user_receivable.name, amount_sats=amount_sats, description=data.description, entry_date=datetime.now().date(), fiat_currency=fiat_currency, fiat_amount=fiat_amount, reference=castle_reference # Use castle reference with unique ID ) # Submit to Fava result = await fava.add_entry(entry) # Return a JournalEntry-like response for compatibility from .models import EntryLine return JournalEntry( id=entry_id, # Use the generated castle entry ID description=data.description + description_suffix, entry_date=datetime.now(), created_by=wallet.wallet.id, created_at=datetime.now(), reference=castle_reference, # Use castle reference with unique ID flag=JournalEntryFlag.PENDING, meta=entry_meta, lines=[ EntryLine( id=f"line-1-{entry_id}", journal_entry_id=entry_id, account_id=user_receivable.id, amount=amount_sats, description=f"Amount owed by user {data.user_id[:8]}", metadata=metadata or {} ), EntryLine( id=f"line-2-{entry_id}", journal_entry_id=entry_id, account_id=revenue_account.id, amount=-amount_sats, description="Revenue earned", metadata=metadata or {} ), ] ) @castle_api_router.post("/api/v1/entries/revenue", status_code=HTTPStatus.CREATED) async def api_create_revenue_entry( data: RevenueEntry, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> JournalEntry: """ Create a revenue entry (castle receives payment). Admin only. Submits entry to Fava/Beancount. """ from .fava_client import get_fava_client from .beancount_format import format_revenue_entry, sanitize_link # Get revenue account revenue_account = await get_account_by_name(data.revenue_account) if not revenue_account: revenue_account = await get_account(data.revenue_account) if not revenue_account: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail=f"Revenue account '{data.revenue_account}' not found", ) # Get payment method account payment_account = await get_account_by_name(data.payment_method_account) if not payment_account: payment_account = await get_account(data.payment_method_account) if not payment_account: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail=f"Payment account '{data.payment_method_account}' not found", ) # Handle currency conversion if provided amount_sats = int(data.amount) fiat_currency = None fiat_amount = None if data.currency: # Validate currency if data.currency.upper() not in allowed_currencies(): raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail=f"Currency '{data.currency}' not supported. Allowed: {', '.join(allowed_currencies())}", ) # Store fiat info for cost basis fiat_currency = data.currency.upper() fiat_amount = data.amount # Original fiat amount # In this case, data.amount should be the satoshi amount # This is a bit confusing - the API accepts amount as Decimal which could be either sats or fiat # For now, assume if currency is provided, amount is fiat and needs conversion # TODO: Consider updating the API model to be clearer about this # Format as Beancount entry and submit to Fava fava = get_fava_client() # Generate unique entry ID for tracking import uuid entry_id = str(uuid.uuid4()).replace("-", "")[:16] # Add castle ID as reference/link (sanitized for Beancount) castle_reference = f"castle-{entry_id}" if data.reference: castle_reference = f"{sanitize_link(data.reference)}-{entry_id}" entry = format_revenue_entry( payment_account=payment_account.name, revenue_account=revenue_account.name, amount_sats=amount_sats, description=data.description, entry_date=datetime.now().date(), fiat_currency=fiat_currency, fiat_amount=fiat_amount, reference=castle_reference # Use castle reference with unique ID ) # Submit to Fava result = await fava.add_entry(entry) logger.info(f"Revenue entry submitted to Fava: {result.get('data', 'Unknown')}") # Return simplified JournalEntry for API compatibility # Note: Castle no longer stores entries in DB, Fava is the source of truth return JournalEntry( id=entry_id, description=data.description, entry_date=datetime.now(), created_by=wallet.wallet.id, created_at=datetime.now(), reference=castle_reference, flag=JournalEntryFlag.CLEARED, lines=[], # Empty - entry is stored in Fava, not Castle DB meta={"source": "fava", "fava_response": result.get('data', 'Unknown')} ) # ===== USER BALANCE ENDPOINTS ===== @castle_api_router.get("/api/v1/balance") async def api_get_my_balance( wallet: WalletTypeInfo = Depends(require_invoice_key), ) -> UserBalance: """Get current user's balance with the Castle (from Fava/Beancount)""" from lnbits.settings import settings as lnbits_settings from .fava_client import get_fava_client fava = get_fava_client() # If super user, show total castle position if wallet.wallet.user == lnbits_settings.super_user: all_balances = await fava.get_all_user_balances() # Calculate total: # From get_user_balance(): positive = user owes castle, negative = castle owes user # Positive balances = Users owe Castle (receivables for Castle) # Negative balances = Castle owes users (liabilities for Castle) # Net: positive means castle is owed money, negative means castle owes money total_receivables = sum(b["balance"] for b in all_balances if b["balance"] > 0) total_liabilities = sum(abs(b["balance"]) for b in all_balances if b["balance"] < 0) net_balance = total_receivables - total_liabilities # Aggregate fiat balances from all users total_fiat_balances = {} for user_balance in all_balances: for currency, amount in user_balance["fiat_balances"].items(): if currency not in total_fiat_balances: total_fiat_balances[currency] = Decimal("0") # Add all balances (positive and negative) total_fiat_balances[currency] += amount # Return net position return UserBalance( user_id=wallet.wallet.user, balance=net_balance, accounts=[], fiat_balances=total_fiat_balances, ) # For regular users, show their individual balance from Fava balance_data = await fava.get_user_balance(wallet.wallet.user) return UserBalance( user_id=wallet.wallet.user, balance=balance_data["balance"], accounts=[], # Could populate from balance_data["accounts"] if needed fiat_balances=balance_data["fiat_balances"], ) @castle_api_router.get("/api/v1/balance/{user_id}") async def api_get_user_balance(user_id: str) -> UserBalance: """Get a specific user's balance with the Castle (from Fava/Beancount)""" from .fava_client import get_fava_client fava = get_fava_client() balance_data = await fava.get_user_balance(user_id) return UserBalance( user_id=user_id, balance=balance_data["balance"], accounts=[], fiat_balances=balance_data["fiat_balances"], ) @castle_api_router.get("/api/v1/balances/all") async def api_get_all_balances( wallet: WalletTypeInfo = Depends(require_admin_key), ) -> list[dict]: """Get all user balances (admin/super user only) from Fava/Beancount""" from .fava_client import get_fava_client fava = get_fava_client() balances = await fava.get_all_user_balances() # Enrich with username information using helper function result = [] for balance in balances: username = await _get_username_from_user_id(balance["user_id"]) result.append({ "user_id": balance["user_id"], "username": username, "balance": balance["balance"], "fiat_balances": balance["fiat_balances"], "accounts": balance["accounts"], }) return result # ===== PAYMENT ENDPOINTS ===== @castle_api_router.post("/api/v1/generate-payment-invoice") async def api_generate_payment_invoice( data: GeneratePaymentInvoice, wallet: WalletTypeInfo = Depends(require_invoice_key), ) -> dict: """ Generate an invoice on the Castle wallet for user to pay their balance. User can then pay this invoice to settle their debt. If user_id is provided (admin only), the invoice is generated for that specific user. """ from lnbits.core.crud.wallets import get_wallet from lnbits.core.models import CreateInvoice from lnbits.core.services import create_payment_request from lnbits.settings import settings as lnbits_settings # Determine which user this invoice is for if data.user_id: # Admin generating invoice for a specific user if wallet.wallet.user != lnbits_settings.super_user: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Only super user can generate invoices for other users", ) target_user_id = data.user_id else: # User generating invoice for themselves target_user_id = wallet.wallet.user # Get castle wallet ID castle_wallet_id = await check_castle_wallet_configured() # Get user's balance from Fava to calculate fiat metadata from .fava_client import get_fava_client fava = get_fava_client() balance_data = await fava.get_user_balance(target_user_id) # Build UserBalance object for compatibility user_balance = UserBalance( user_id=target_user_id, balance=balance_data["balance"], accounts=[], fiat_balances=balance_data["fiat_balances"] ) # Calculate proportional fiat amount for this invoice invoice_extra = {"tag": "castle", "user_id": target_user_id} logger.info(f"User balance for invoice generation - sats: {user_balance.balance}, fiat_balances: {user_balance.fiat_balances}") if user_balance.fiat_balances: # Simple single-currency solution: use the first (and should be only) currency currencies = list(user_balance.fiat_balances.keys()) if len(currencies) > 1: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail=f"User has multiple currencies ({', '.join(currencies)}). Please settle to a single currency first.", ) if len(currencies) == 1: fiat_currency = currencies[0] total_fiat_balance = user_balance.fiat_balances[fiat_currency] total_sat_balance = abs(user_balance.balance) # Use absolute value if total_sat_balance > 0: # Calculate proportional fiat amount for this invoice # fiat_amount = (invoice_amount / total_sats) * total_fiat from decimal import Decimal proportion = Decimal(data.amount) / Decimal(total_sat_balance) invoice_fiat_amount = abs(total_fiat_balance) * proportion # Calculate fiat rate (sats per fiat unit) fiat_rate = float(data.amount) / float(invoice_fiat_amount) if invoice_fiat_amount > 0 else 0 btc_rate = float(invoice_fiat_amount) / float(data.amount) * 100_000_000 if data.amount > 0 else 0 invoice_extra.update({ "fiat_currency": fiat_currency, "fiat_amount": str(invoice_fiat_amount.quantize(Decimal("0.001"))), "fiat_rate": fiat_rate, "btc_rate": btc_rate, }) logger.info(f"Invoice extra metadata: {invoice_extra}") # Create invoice on castle wallet invoice_data = CreateInvoice( out=False, amount=data.amount, memo=f"Payment from user {target_user_id[:8]} to Castle", unit="sat", extra=invoice_extra, ) payment = await create_payment_request(castle_wallet_id, invoice_data) # Get castle wallet to return its inkey for payment checking castle_wallet = await get_wallet(castle_wallet_id) if not castle_wallet: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Castle wallet not found" ) return { "payment_hash": payment.payment_hash, "payment_request": payment.bolt11, "amount": data.amount, "memo": invoice_data.memo, "check_wallet_key": castle_wallet.inkey, # Key to check payment status } @castle_api_router.post("/api/v1/record-payment") async def api_record_payment( data: RecordPayment, wallet: WalletTypeInfo = Depends(require_invoice_key), ) -> dict: """ Record a lightning payment in accounting after invoice is paid. This reduces what the user owes to the castle. The user_id is extracted from the payment metadata (set during invoice generation). """ from lnbits.core.crud.payments import get_standalone_payment # Get the payment details (incoming=True to get the invoice, not the payment) payment = await get_standalone_payment(data.payment_hash, incoming=True) if not payment: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Payment not found" ) if payment.pending: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="Payment not yet settled" ) # Get user_id from payment metadata (set during invoice generation) target_user_id = None if payment.extra and isinstance(payment.extra, dict): target_user_id = payment.extra.get("user_id") if not target_user_id: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="Payment metadata missing user_id. Cannot determine which user to credit.", ) # Check if payment already recorded in Fava (idempotency) from .fava_client import get_fava_client from .beancount_format import format_payment_entry import httpx fava = get_fava_client() # Check if payment already recorded by fetching recent entries # Note: We can't use BQL query with `links ~ 'pattern'` because links is a set type # and BQL doesn't support regex matching on sets. Instead, fetch entries and filter in Python. link_to_find = f"ln-{data.payment_hash[:16]}" try: async with httpx.AsyncClient(timeout=5.0) as client: # Get recent entries from Fava's journal endpoint response = await client.get( f"{fava.base_url}/api/journal", params={"time": ""} # Get all entries ) if response.status_code == 200: response_data = response.json() entries = response_data.get('entries', []) # Check if any entry has our payment link for entry in entries: entry_links = entry.get('links', []) if link_to_find in entry_links: # Payment already recorded, return existing entry balance_data = await fava.get_user_balance(target_user_id) return { "journal_entry_id": f"fava-exists-{data.payment_hash[:16]}", "new_balance": balance_data["balance"], "message": "Payment already recorded", } except Exception as e: logger.warning(f"Could not check Fava for duplicate payment: {e}") # Continue anyway - Fava/Beancount will catch duplicate if it exists # Convert amount from millisatoshis to satoshis amount_sats = payment.amount // 1000 # Extract fiat metadata from invoice (if present) fiat_currency = None fiat_amount = None if payment.extra and isinstance(payment.extra, dict): logger.info(f"Payment.extra contents: {payment.extra}") fiat_currency = payment.extra.get("fiat_currency") fiat_amount_str = payment.extra.get("fiat_amount") if fiat_amount_str: from decimal import Decimal fiat_amount = Decimal(str(fiat_amount_str)) logger.info(f"Extracted fiat metadata - currency: {fiat_currency}, amount: {fiat_amount}") # Get user's receivable account (what user owes) user_receivable = await get_or_create_user_account( target_user_id, AccountType.ASSET, "Accounts Receivable" ) # Get lightning account lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning") if not lightning_account: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Lightning account not found" ) # Format payment entry and submit to Fava entry = format_payment_entry( user_id=target_user_id, payment_account=lightning_account.name, payable_or_receivable_account=user_receivable.name, amount_sats=amount_sats, description=f"Lightning payment from user {target_user_id[:8]}", entry_date=datetime.now().date(), is_payable=False, # User paying castle (receivable settlement) fiat_currency=fiat_currency, fiat_amount=fiat_amount, payment_hash=data.payment_hash, reference=data.payment_hash ) logger.info(f"Formatted payment entry: {entry}") # Submit to Fava result = await fava.add_entry(entry) logger.info(f"Payment entry submitted to Fava: {result.get('data', 'Unknown')}") # Get updated balance from Fava balance_data = await fava.get_user_balance(target_user_id) return { "journal_entry_id": f"fava-{datetime.now().timestamp()}", "new_balance": balance_data["balance"], "message": "Payment recorded successfully", } @castle_api_router.post("/api/v1/pay-user") async def api_pay_user( user_id: str, amount: int, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: """ Record a payment from castle to user (reduces what castle owes user). Admin only. """ # Get user's payable account (what castle owes) user_payable = await get_or_create_user_account( user_id, AccountType.LIABILITY, "Accounts Payable" ) # Get lightning account lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning") if not lightning_account: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Lightning account not found" ) # Format payment entry and submit to Fava # DR Liabilities:Payable (User), CR Assets:Bitcoin:Lightning from .fava_client import get_fava_client from .beancount_format import format_payment_entry fava = get_fava_client() entry = format_payment_entry( user_id=user_id, payment_account=lightning_account.name, payable_or_receivable_account=user_payable.name, amount_sats=amount, description=f"Payment to user {user_id[:8]}", entry_date=datetime.now().date(), is_payable=True, # Castle paying user reference=f"PAY-{user_id[:8]}" ) # Submit to Fava result = await fava.add_entry(entry) logger.info(f"Payment submitted to Fava: {result.get('data', 'Unknown')}") # Get updated balance from Fava balance_data = await fava.get_user_balance(user_id) return { "journal_entry_id": f"fava-{datetime.now().timestamp()}", "new_balance": balance_data["balance"], "message": "Payment recorded successfully", } @castle_api_router.post("/api/v1/receivables/settle") async def api_settle_receivable( data: SettleReceivable, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: """ Manually settle a receivable (record when user pays castle in person). This endpoint is for non-lightning payments like: - Cash payments - Bank transfers - Other manual settlements Admin only. """ from lnbits.settings import settings as lnbits_settings if wallet.wallet.user != lnbits_settings.super_user: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Only super user can settle receivables", ) # Validate payment method valid_methods = ["cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"] if data.payment_method.lower() not in valid_methods: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail=f"Invalid payment method. Must be one of: {', '.join(valid_methods)}", ) # Get user's receivable account (what user owes) user_receivable = await get_or_create_user_account( data.user_id, AccountType.ASSET, "Accounts Receivable" ) # Get the appropriate asset account based on payment method payment_account_map = { "cash": "Assets:Cash", "bank_transfer": "Assets:Bank", "check": "Assets:Bank", "lightning": "Assets:Bitcoin:Lightning", "btc_onchain": "Assets:Bitcoin:OnChain", "other": "Assets:Cash" } account_name = payment_account_map.get(data.payment_method.lower(), "Assets:Cash") payment_account = await get_account_by_name(account_name) # If account doesn't exist, try to find or create a generic one if not payment_account: # Try to find any asset account that's not receivable all_accounts = await get_all_accounts() for acc in all_accounts: if acc.account_type == AccountType.ASSET and "receivable" not in acc.name.lower(): payment_account = acc break if not payment_account: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail=f"Payment account '{account_name}' not found. Please create it first.", ) # Format settlement entry and submit to Fava # DR Cash/Bank (asset increased), CR Accounts Receivable (asset decreased) # This records that user paid their debt from .fava_client import get_fava_client from .beancount_format import format_payment_entry, format_fiat_settlement_entry from decimal import Decimal fava = get_fava_client() # Determine if this is a fiat or lightning payment is_fiat_payment = data.currency and data.payment_method.lower() in [ "cash", "bank_transfer", "check", "other" ] if is_fiat_payment: # Fiat currency payment (cash, bank transfer, etc.) # Record in fiat currency with sats as metadata if not data.amount_sats: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="amount_sats is required when settling with fiat currency" ) entry = format_fiat_settlement_entry( user_id=data.user_id, payment_account=payment_account.name, payable_or_receivable_account=user_receivable.name, fiat_amount=Decimal(str(data.amount)), fiat_currency=data.currency.upper(), amount_sats=data.amount_sats, description=data.description, entry_date=datetime.now().date(), is_payable=False, # User paying castle (receivable settlement) payment_method=data.payment_method, reference=data.reference or f"MANUAL-{data.user_id[:8]}" ) else: # Lightning or BTC onchain payment # Record in SATS with optional fiat metadata amount_in_sats = data.amount_sats if data.amount_sats else int(data.amount) fiat_currency = data.currency.upper() if data.currency else None fiat_amount = Decimal(str(data.amount)) if data.currency else None entry = format_payment_entry( user_id=data.user_id, payment_account=payment_account.name, payable_or_receivable_account=user_receivable.name, amount_sats=amount_in_sats, description=data.description, entry_date=datetime.now().date(), is_payable=False, # User paying castle (receivable settlement) fiat_currency=fiat_currency, fiat_amount=fiat_amount, payment_hash=data.payment_hash, reference=data.reference or f"MANUAL-{data.user_id[:8]}" ) # Add additional metadata to entry if "meta" not in entry: entry["meta"] = {} entry["meta"]["payment-method"] = data.payment_method entry["meta"]["settled-by"] = wallet.wallet.user if data.txid: entry["meta"]["txid"] = data.txid # Submit to Fava result = await fava.add_entry(entry) logger.info(f"Receivable settlement submitted to Fava: {result.get('data', 'Unknown')}") # Get updated balance from Fava balance_data = await fava.get_user_balance(data.user_id) return { "journal_entry_id": f"fava-{datetime.now().timestamp()}", "user_id": data.user_id, "amount_settled": float(data.amount), "currency": data.currency, "payment_method": data.payment_method, "new_balance": balance_data["balance"], "message": f"Receivable settled successfully via {data.payment_method}", } @castle_api_router.post("/api/v1/payables/pay") async def api_pay_user( data: PayUser, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: """ Pay a user (castle pays user for expense/liability). This endpoint is for both lightning and manual payments: - Lightning payments: already executed, just record the payment - Cash/Bank/Check: record manual payment that was made Admin only. """ from lnbits.settings import settings as lnbits_settings if wallet.wallet.user != lnbits_settings.super_user: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Only super user can pay users", ) # Validate payment method valid_methods = ["cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"] if data.payment_method.lower() not in valid_methods: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail=f"Invalid payment method. Must be one of: {', '.join(valid_methods)}", ) # Get user's payable account (what castle owes) user_payable = await get_or_create_user_account( data.user_id, AccountType.LIABILITY, "Accounts Payable" ) # Get the appropriate asset account based on payment method payment_account_map = { "cash": "Assets:Cash", "bank_transfer": "Assets:Bank", "check": "Assets:Bank", "lightning": "Assets:Bitcoin:Lightning", "btc_onchain": "Assets:Bitcoin:OnChain", "other": "Assets:Cash" } account_name = payment_account_map.get(data.payment_method.lower(), "Assets:Cash") payment_account = await get_account_by_name(account_name) if not payment_account: # Try to find any asset account that's not receivable all_accounts = await get_all_accounts() for acc in all_accounts: if acc.account_type == AccountType.ASSET and "receivable" not in acc.name.lower(): payment_account = acc break if not payment_account: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail=f"Payment account '{account_name}' not found. Please create it first.", ) # Format payment entry and submit to Fava # DR Accounts Payable (liability decreased), CR Cash/Lightning/Bank (asset decreased) # This records that castle paid its debt from .fava_client import get_fava_client from .beancount_format import format_payment_entry from decimal import Decimal fava = get_fava_client() # Determine amount and currency if data.currency: # Fiat currency payment (e.g., EUR, USD) # Use the sats equivalent for the journal entry to match the payable if not data.amount_sats: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="amount_sats is required when paying with fiat currency" ) amount_in_sats = data.amount_sats fiat_currency = data.currency.upper() fiat_amount = data.amount else: # Satoshi payment amount_in_sats = int(data.amount) fiat_currency = None fiat_amount = None # Format payment entry entry = format_payment_entry( user_id=data.user_id, payment_account=payment_account.name, payable_or_receivable_account=user_payable.name, amount_sats=amount_in_sats, description=data.description or f"Payment to user via {data.payment_method}", entry_date=datetime.now().date(), is_payable=True, # Castle paying user (payable settlement) fiat_currency=fiat_currency, fiat_amount=fiat_amount, payment_hash=data.payment_hash, reference=data.reference or f"PAY-{data.user_id[:8]}" ) # Add additional metadata to entry if "meta" not in entry: entry["meta"] = {} entry["meta"]["payment-method"] = data.payment_method entry["meta"]["paid-by"] = wallet.wallet.user if data.txid: entry["meta"]["txid"] = data.txid # Submit to Fava result = await fava.add_entry(entry) logger.info(f"Payable payment submitted to Fava: {result.get('data', 'Unknown')}") # Get updated balance from Fava balance_data = await fava.get_user_balance(data.user_id) return { "journal_entry_id": f"fava-{datetime.now().timestamp()}", "user_id": data.user_id, "amount_paid": float(data.amount), "currency": data.currency, "payment_method": data.payment_method, "new_balance": balance_data["balance"], "message": f"User paid successfully via {data.payment_method}", } # ===== SETTINGS ENDPOINTS ===== @castle_api_router.get("/api/v1/settings") async def api_get_settings( user: User = Depends(check_user_exists), ) -> CastleSettings: """Get Castle settings""" user_id = "admin" settings = await get_settings(user_id) # Return empty settings if not configured (so UI can show setup screen) if not settings: return CastleSettings() return settings @castle_api_router.put("/api/v1/settings") async def api_update_settings( data: CastleSettings, user: User = Depends(check_super_user), ) -> CastleSettings: """Update Castle settings (super user only)""" if not data.castle_wallet_id: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="Castle wallet ID is required", ) user_id = "admin" return await update_settings(user_id, data) # ===== USER WALLET ENDPOINTS ===== @castle_api_router.get("/api/v1/user-wallet/{user_id}") async def api_get_user_wallet( user_id: str, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: """Get user's wallet settings (admin only)""" from lnbits.settings import settings as lnbits_settings if wallet.wallet.user != lnbits_settings.super_user: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Only super user can access user wallet info", ) user_wallet = await get_user_wallet(user_id) if not user_wallet: return {"user_id": user_id, "user_wallet_id": None} # Get invoice key for the user's wallet (needed to generate invoices) from lnbits.core.crud import get_wallet wallet_obj = await get_wallet(user_wallet.user_wallet_id) if not wallet_obj: return {"user_id": user_id, "user_wallet_id": user_wallet.user_wallet_id} return { "user_id": user_id, "user_wallet_id": user_wallet.user_wallet_id, "user_wallet_id_invoice_key": wallet_obj.inkey, } @castle_api_router.get("/api/v1/users") async def api_get_all_users( wallet: WalletTypeInfo = Depends(require_admin_key), ) -> list[dict]: """Get all users who have configured their wallet (admin only)""" from lnbits.core.crud.users import get_user user_settings = await get_all_user_wallet_settings() users = [] for setting in user_settings: # Get user details from core user = await get_user(setting.id) # Use username if available, otherwise truncate user_id username = user.username if user and user.username else setting.id[:16] + "..." users.append({ "user_id": setting.id, "user_wallet_id": setting.user_wallet_id, "username": username, }) return users @castle_api_router.get("/api/v1/admin/castle-users") async def api_get_castle_users( wallet: WalletTypeInfo = Depends(require_admin_key), ) -> list[dict]: """ Get all users who have configured their wallet in Castle. These are users who can interact with Castle (submit expenses, receive permissions, etc.). Admin only. """ from lnbits.core.crud.users import get_user # Get all users who have configured their wallet user_settings = await get_all_user_wallet_settings() users = [] for setting in user_settings: # Get user details from core user = await get_user(setting.id) # Use username if available, otherwise use user_id username = user.username if user and user.username else None users.append({ "id": setting.id, "user_id": setting.id, # Compatibility with existing code "username": username, "user_wallet_id": setting.user_wallet_id, }) # Sort by username (None values last) users.sort(key=lambda x: (x["username"] is None, x["username"] or "", x["user_id"])) return users @castle_api_router.get("/api/v1/user/wallet") async def api_get_user_wallet( user: User = Depends(check_user_exists), ) -> UserWalletSettings: """Get current user's wallet settings""" from lnbits.settings import settings as lnbits_settings # If user is super user, return the castle wallet if user.id == lnbits_settings.super_user: castle_settings = await get_settings("admin") if castle_settings and castle_settings.castle_wallet_id: return UserWalletSettings(user_wallet_id=castle_settings.castle_wallet_id) return UserWalletSettings() # For regular users, get their personal wallet settings = await get_user_wallet(user.id) # Return empty settings if not configured (so UI can show setup screen) if not settings: return UserWalletSettings() return settings @castle_api_router.put("/api/v1/user/wallet") async def api_update_user_wallet( data: UserWalletSettings, user: User = Depends(check_user_exists), ) -> UserWalletSettings: """Update current user's wallet settings""" from lnbits.settings import settings as lnbits_settings # Super user cannot set their wallet separately - it's always the castle wallet if user.id == lnbits_settings.super_user: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Super user wallet is automatically set to the Castle wallet. Update Castle settings instead.", ) if not data.user_wallet_id: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="User wallet ID is required", ) return await update_user_wallet(user.id, data) # ===== MANUAL PAYMENT REQUESTS ===== @castle_api_router.post("/api/v1/manual-payment-request") async def api_create_manual_payment_request( data: CreateManualPaymentRequest, wallet: WalletTypeInfo = Depends(require_invoice_key), ) -> ManualPaymentRequest: """Create a manual payment request for the Castle to review""" return await create_manual_payment_request( wallet.wallet.user, data.amount, data.description ) @castle_api_router.get("/api/v1/manual-payment-requests") async def api_get_manual_payment_requests( wallet: WalletTypeInfo = Depends(require_invoice_key), ) -> list[ManualPaymentRequest]: """Get manual payment requests for the current user""" return await get_user_manual_payment_requests(wallet.wallet.user) @castle_api_router.get("/api/v1/manual-payment-requests/all") async def api_get_all_manual_payment_requests( status: str = None, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> list[ManualPaymentRequest]: """Get all manual payment requests (Castle admin only)""" from lnbits.settings import settings as lnbits_settings if wallet.wallet.user != lnbits_settings.super_user: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Only super user can access this endpoint", ) return await get_all_manual_payment_requests(status) @castle_api_router.post("/api/v1/manual-payment-requests/{request_id}/approve") async def api_approve_manual_payment_request( request_id: str, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> ManualPaymentRequest: """Approve a manual payment request and create accounting entry (Castle admin only)""" from lnbits.settings import settings as lnbits_settings if wallet.wallet.user != lnbits_settings.super_user: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Only super user can access this endpoint", ) # Get the request request = await get_manual_payment_request(request_id) if not request: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Manual payment request not found", ) if request.status != "pending": raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail=f"Request already {request.status}", ) # Get castle wallet from settings castle_wallet_id = await check_castle_wallet_configured() # Get or create liability account for user (castle owes the user) liability_account = await get_or_create_user_account( request.user_id, AccountType.LIABILITY, "Accounts Payable" ) # Get the Lightning asset account lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning") if not lightning_account: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Lightning account not found", ) # Format payment entry and submit to Fava from .fava_client import get_fava_client from .beancount_format import format_payment_entry fava = get_fava_client() entry = format_payment_entry( user_id=request.user_id, payment_account=lightning_account.name, payable_or_receivable_account=liability_account.name, amount_sats=request.amount, description=f"Manual payment to user: {request.description}", entry_date=datetime.now().date(), is_payable=True, # Castle paying user reference=f"MPR-{request.id}" ) # Submit to Fava result = await fava.add_entry(entry) logger.info(f"Manual payment entry submitted to Fava: {result.get('data', 'Unknown')}") # Approve the request with Fava entry reference entry_id = f"fava-{datetime.now().timestamp()}" return await approve_manual_payment_request( request_id, wallet.wallet.user, entry_id ) @castle_api_router.post("/api/v1/manual-payment-requests/{request_id}/reject") async def api_reject_manual_payment_request( request_id: str, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> ManualPaymentRequest: """Reject a manual payment request (Castle admin only)""" from lnbits.settings import settings as lnbits_settings if wallet.wallet.user != lnbits_settings.super_user: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Only super user can access this endpoint", ) # Get the request request = await get_manual_payment_request(request_id) if not request: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Manual payment request not found", ) if request.status != "pending": raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail=f"Request already {request.status}", ) return await reject_manual_payment_request(request_id, wallet.wallet.user) # ===== EXPENSE APPROVAL ENDPOINTS ===== @castle_api_router.post("/api/v1/entries/{entry_id}/approve") async def api_approve_expense_entry( entry_id: str, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: """ Approve a pending expense entry by changing flag from '!' to '*' (admin only). This updates the transaction in the Beancount file via Fava API. """ from lnbits.settings import settings as lnbits_settings from .fava_client import get_fava_client if wallet.wallet.user != lnbits_settings.super_user: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Only super user can approve expenses", ) fava = get_fava_client() # 1. Get all journal entries from Fava all_entries = await fava.get_journal_entries() # 2. Find the entry with matching castle ID in links target_entry_hash = None target_entry = None for entry in all_entries: # Only look at transactions with pending flag if entry.get("t") == "Transaction" and entry.get("flag") == "!": links = entry.get("links", []) for link in links: # Strip ^ prefix if present (Beancount link syntax) link_clean = link.lstrip('^') # Check if this entry has our castle ID if link_clean == f"castle-{entry_id}" or link_clean.endswith(f"-{entry_id}"): target_entry_hash = entry.get("entry_hash") target_entry = entry break if target_entry_hash: break if not target_entry_hash: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail=f"Pending entry {entry_id} not found in Beancount ledger" ) # 3. Get the entry context (source text + sha256sum) context = await fava.get_entry_context(target_entry_hash) source = context.get("slice", "") sha256sum = context.get("sha256sum", "") if not source: raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Could not retrieve entry source from Fava" ) # 4. Change flag from ! to * # Replace the first occurrence of the date + ! pattern import re date_str = target_entry.get("date", "") old_pattern = f"{date_str} !" new_pattern = f"{date_str} *" if old_pattern not in source: raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"Could not find pending flag pattern '{old_pattern}' in entry source" ) new_source = source.replace(old_pattern, new_pattern, 1) # 5. Update the entry via Fava API await fava.update_entry_source(target_entry_hash, new_source, sha256sum) return { "message": f"Entry {entry_id} approved successfully", "entry_id": entry_id, "entry_hash": target_entry_hash, "date": date_str, "description": target_entry.get("narration", "") } @castle_api_router.post("/api/v1/entries/{entry_id}/reject") async def api_reject_expense_entry( entry_id: str, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: """ Reject a pending expense entry by marking it as voided (admin only). Adds #voided tag for audit trail while keeping the '!' flag. Voided transactions are excluded from balances but preserved in the ledger. """ from lnbits.settings import settings as lnbits_settings from .fava_client import get_fava_client if wallet.wallet.user != lnbits_settings.super_user: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Only super user can reject expenses", ) fava = get_fava_client() # 1. Get all journal entries from Fava all_entries = await fava.get_journal_entries() # 2. Find the entry with matching castle ID in links target_entry_hash = None target_entry = None for entry in all_entries: # Only look at transactions with pending flag if entry.get("t") == "Transaction" and entry.get("flag") == "!": links = entry.get("links", []) for link in links: # Strip ^ prefix if present (Beancount link syntax) link_clean = link.lstrip('^') # Check if this entry has our castle ID if link_clean == f"castle-{entry_id}" or link_clean.endswith(f"-{entry_id}"): target_entry_hash = entry.get("entry_hash") target_entry = entry break if target_entry_hash: break if not target_entry_hash: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail=f"Pending entry {entry_id} not found in Beancount ledger" ) # 3. Get the entry context (source text + sha256sum) context = await fava.get_entry_context(target_entry_hash) source = context.get("slice", "") sha256sum = context.get("sha256sum", "") if not source: raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Could not retrieve entry source from Fava" ) # 4. Add #voided tag (keep ! flag as per convention) date_str = target_entry.get("date", "") # Add #voided tag if not already present if "#voided" not in source: # Find the transaction line and add #voided to the tags # Pattern: date ! "narration" #existing-tags lines = source.split('\n') for i, line in enumerate(lines): if date_str in line and '"' in line and '!' in line: # Add #voided tag to the transaction line if '#' in line: # Already has tags, append voided lines[i] = line.rstrip() + ' #voided' else: # No tags yet, add after narration lines[i] = line.rstrip() + ' #voided' break new_source = '\n'.join(lines) else: new_source = source # 5. Update the entry via Fava API await fava.update_entry_source(target_entry_hash, new_source, sha256sum) return { "message": f"Entry {entry_id} rejected (marked as voided)", "entry_id": entry_id, "entry_hash": target_entry_hash, "date": date_str, "description": target_entry.get("narration", "") } # ===== BALANCE ASSERTION ENDPOINTS ===== @castle_api_router.post("/api/v1/assertions") async def api_create_balance_assertion( data: CreateBalanceAssertion, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> BalanceAssertion: """ Create a balance assertion for reconciliation (admin only). The assertion will be checked immediately upon creation. """ from lnbits.settings import settings as lnbits_settings if wallet.wallet.user != lnbits_settings.super_user: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Only super user can create balance assertions", ) # Verify account exists account = await get_account(data.account_id) if not account: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail=f"Account {data.account_id} not found", ) # Create the assertion assertion = await create_balance_assertion(data, wallet.wallet.user) # Check it immediately try: assertion = await check_balance_assertion(assertion.id) except ValueError as e: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail=str(e), ) # If assertion failed, return 409 Conflict with details if assertion.status == AssertionStatus.FAILED: raise HTTPException( status_code=HTTPStatus.CONFLICT, detail={ "message": "Balance assertion failed", "expected_sats": assertion.expected_balance_sats, "actual_sats": assertion.checked_balance_sats, "difference_sats": assertion.difference_sats, "expected_fiat": float(assertion.expected_balance_fiat) if assertion.expected_balance_fiat else None, "actual_fiat": float(assertion.checked_balance_fiat) if assertion.checked_balance_fiat else None, "difference_fiat": float(assertion.difference_fiat) if assertion.difference_fiat else None, "fiat_currency": assertion.fiat_currency, }, ) return assertion @castle_api_router.get("/api/v1/assertions") async def api_get_balance_assertions( account_id: str = None, status: str = None, limit: int = 100, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> list[BalanceAssertion]: """Get balance assertions with optional filters (admin only)""" from lnbits.settings import settings as lnbits_settings if wallet.wallet.user != lnbits_settings.super_user: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Only super user can view balance assertions", ) # Parse status enum if provided status_enum = None if status: try: status_enum = AssertionStatus(status) except ValueError: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail=f"Invalid status: {status}. Must be one of: pending, passed, failed", ) return await get_balance_assertions( account_id=account_id, status=status_enum, limit=limit, ) @castle_api_router.get("/api/v1/assertions/{assertion_id}") async def api_get_balance_assertion( assertion_id: str, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> BalanceAssertion: """Get a specific balance assertion (admin only)""" from lnbits.settings import settings as lnbits_settings if wallet.wallet.user != lnbits_settings.super_user: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Only super user can view balance assertions", ) assertion = await get_balance_assertion(assertion_id) if not assertion: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Balance assertion not found", ) return assertion @castle_api_router.post("/api/v1/assertions/{assertion_id}/check") async def api_check_balance_assertion( assertion_id: str, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> BalanceAssertion: """Re-check a balance assertion (admin only)""" from lnbits.settings import settings as lnbits_settings if wallet.wallet.user != lnbits_settings.super_user: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Only super user can check balance assertions", ) try: assertion = await check_balance_assertion(assertion_id) except ValueError as e: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail=str(e), ) return assertion @castle_api_router.delete("/api/v1/assertions/{assertion_id}") async def api_delete_balance_assertion( assertion_id: str, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: """Delete a balance assertion (admin only)""" from lnbits.settings import settings as lnbits_settings if wallet.wallet.user != lnbits_settings.super_user: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Only super user can delete balance assertions", ) # Verify it exists assertion = await get_balance_assertion(assertion_id) if not assertion: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Balance assertion not found", ) await delete_balance_assertion(assertion_id) return {"success": True, "message": "Balance assertion deleted"} # ===== RECONCILIATION ENDPOINTS ===== @castle_api_router.get("/api/v1/reconciliation/summary") async def api_get_reconciliation_summary( wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: """Get reconciliation summary (admin only)""" from lnbits.settings import settings as lnbits_settings if wallet.wallet.user != lnbits_settings.super_user: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Only super user can access reconciliation", ) # Get all assertions all_assertions = await get_balance_assertions(limit=1000) # Count by status passed = len([a for a in all_assertions if a.status == AssertionStatus.PASSED]) failed = len([a for a in all_assertions if a.status == AssertionStatus.FAILED]) pending = len([a for a in all_assertions if a.status == AssertionStatus.PENDING]) # Get all journal entries from Fava from .fava_client import get_fava_client fava = get_fava_client() all_entries = await fava.query_transactions(limit=1000, include_pending=True) # Count entries by flag (Beancount only supports * and !) cleared = len([e for e in all_entries if e.get("flag") == "*"]) pending_entries = len([e for e in all_entries if e.get("flag") == "!"]) # Count entries with special tags voided = len([e for e in all_entries if "voided" in e.get("tags", [])]) flagged = len([e for e in all_entries if "review" in e.get("tags", []) or "flagged" in e.get("tags", [])]) # Get all accounts accounts = await get_all_accounts() return { "assertions": { "total": len(all_assertions), "passed": passed, "failed": failed, "pending": pending, }, "entries": { "total": len(all_entries), "cleared": cleared, "pending": pending_entries, "flagged": flagged, "voided": voided, }, "accounts": { "total": len(accounts), }, "last_checked": datetime.now().isoformat(), } @castle_api_router.post("/api/v1/reconciliation/check-all") async def api_check_all_assertions( wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: """Re-check all balance assertions (admin only)""" from lnbits.settings import settings as lnbits_settings if wallet.wallet.user != lnbits_settings.super_user: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Only super user can run reconciliation checks", ) # Get all assertions all_assertions = await get_balance_assertions(limit=1000) results = { "total": len(all_assertions), "checked": 0, "passed": 0, "failed": 0, "errors": 0, } for assertion in all_assertions: try: checked = await check_balance_assertion(assertion.id) results["checked"] += 1 if checked.status == AssertionStatus.PASSED: results["passed"] += 1 elif checked.status == AssertionStatus.FAILED: results["failed"] += 1 except Exception as e: results["errors"] += 1 return results @castle_api_router.get("/api/v1/reconciliation/discrepancies") async def api_get_discrepancies( wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: """Get all discrepancies (failed assertions, flagged entries) (admin only)""" from lnbits.settings import settings as lnbits_settings if wallet.wallet.user != lnbits_settings.super_user: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Only super user can view discrepancies", ) # Get failed assertions failed_assertions = await get_balance_assertions( status=AssertionStatus.FAILED, limit=1000, ) # Get flagged entries from Fava from .fava_client import get_fava_client fava = get_fava_client() all_entries = await fava.query_transactions(limit=1000, include_pending=True) flagged_entries = [e for e in all_entries if e.get("flag") == "#"] pending_entries = [e for e in all_entries if e.get("flag") == "!"] return { "failed_assertions": failed_assertions, "flagged_entries": flagged_entries, "pending_entries": pending_entries, "total_discrepancies": len(failed_assertions) + len(flagged_entries), } # ===== AUTOMATED TASKS ENDPOINTS ===== @castle_api_router.post("/api/v1/tasks/daily-reconciliation") async def api_run_daily_reconciliation( wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: """ Manually trigger the daily reconciliation check (admin only). This endpoint can also be called via cron job. Returns a summary of the reconciliation check results. """ from lnbits.settings import settings as lnbits_settings if wallet.wallet.user != lnbits_settings.super_user: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Only super user can run daily reconciliation", ) from .tasks import check_all_balance_assertions try: results = await check_all_balance_assertions() return results except Exception as e: raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"Error running daily reconciliation: {str(e)}", ) # ===== USER EQUITY ELIGIBILITY ENDPOINTS ===== @castle_api_router.get("/api/v1/user/info") async def api_get_user_info( wallet: WalletTypeInfo = Depends(require_invoice_key), ) -> UserInfo: """Get current user's information including equity eligibility""" from .crud import get_user_equity_status from .models import UserInfo equity_status = await get_user_equity_status(wallet.wallet.user) return UserInfo( user_id=wallet.wallet.user, is_equity_eligible=equity_status.is_equity_eligible if equity_status else False, equity_account_name=equity_status.equity_account_name if equity_status else None, ) @castle_api_router.post("/api/v1/admin/equity-eligibility", status_code=HTTPStatus.CREATED) async def api_grant_equity_eligibility( data: CreateUserEquityStatus, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> UserEquityStatus: """Grant equity contribution eligibility to a user (admin only)""" from .crud import create_or_update_user_equity_status return await create_or_update_user_equity_status(data, wallet.wallet.user) @castle_api_router.delete("/api/v1/admin/equity-eligibility/{user_id}") async def api_revoke_equity_eligibility( user_id: str, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> UserEquityStatus: """Revoke equity contribution eligibility from a user (admin only)""" from .crud import revoke_user_equity_eligibility result = await revoke_user_equity_eligibility(user_id) if not result: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail=f"User {user_id} not found in equity status records", ) return result @castle_api_router.get("/api/v1/admin/equity-eligibility") async def api_list_equity_eligible_users( wallet: WalletTypeInfo = Depends(require_admin_key), ) -> list[UserEquityStatus]: """List all equity-eligible users (admin only)""" from .crud import get_all_equity_eligible_users return await get_all_equity_eligible_users() # ===== ACCOUNT PERMISSION ADMIN ENDPOINTS ===== @castle_api_router.post("/api/v1/admin/permissions", status_code=HTTPStatus.CREATED) async def api_grant_permission( data: CreateAccountPermission, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> AccountPermission: """Grant account permission to a user (admin only)""" # Validate that account exists account = await get_account(data.account_id) if not account: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail=f"Account with ID '{data.account_id}' not found", ) return await create_account_permission(data, wallet.wallet.user) @castle_api_router.get("/api/v1/admin/permissions") async def api_list_permissions( user_id: str | None = None, account_id: str | None = None, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> list[AccountPermission]: """ List account permissions (admin only). Can filter by user_id or account_id. """ if user_id: return await get_user_permissions(user_id) elif account_id: return await get_account_permissions(account_id) else: # Get all permissions (get all users' permissions) # This is a bit inefficient but works for admin overview all_accounts = await get_all_accounts() all_permissions = [] for account in all_accounts: account_perms = await get_account_permissions(account.id) all_permissions.extend(account_perms) # Deduplicate by permission ID seen_ids = set() unique_permissions = [] for perm in all_permissions: if perm.id not in seen_ids: seen_ids.add(perm.id) unique_permissions.append(perm) return unique_permissions @castle_api_router.delete("/api/v1/admin/permissions/{permission_id}") async def api_revoke_permission( permission_id: str, wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: """Revoke (delete) an account permission (admin only)""" # Verify permission exists permission = await get_account_permission(permission_id) if not permission: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail=f"Permission with ID '{permission_id}' not found", ) await delete_account_permission(permission_id) return { "success": True, "message": f"Permission {permission_id} revoked successfully", } @castle_api_router.post("/api/v1/admin/permissions/bulk", status_code=HTTPStatus.CREATED) async def api_bulk_grant_permissions( permissions: list[CreateAccountPermission], wallet: WalletTypeInfo = Depends(require_admin_key), ) -> list[AccountPermission]: """Grant multiple account permissions at once (admin only)""" created_permissions = [] for perm_data in permissions: # Validate that account exists account = await get_account(perm_data.account_id) if not account: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail=f"Account with ID '{perm_data.account_id}' not found", ) perm = await create_account_permission(perm_data, wallet.wallet.user) created_permissions.append(perm) return created_permissions # ===== USER PERMISSION ENDPOINTS ===== @castle_api_router.get("/api/v1/users/me/permissions") async def api_get_user_permissions( wallet: WalletTypeInfo = Depends(require_invoice_key), ) -> list[AccountPermission]: """Get current user's account permissions""" return await get_user_permissions(wallet.wallet.user) # ===== ACCOUNT HIERARCHY ENDPOINT ===== @castle_api_router.get("/api/v1/accounts/hierarchy") async def api_get_account_hierarchy( root_account: str | None = None, wallet: WalletTypeInfo = Depends(require_invoice_key), ) -> list[AccountWithPermissions]: """ Get hierarchical account structure with user permissions. Optionally filter by root account (e.g., "Expenses" to get all expense sub-accounts). """ all_accounts = await get_all_accounts() user_id = wallet.wallet.user user_permissions = await get_user_permissions(user_id) # Filter by root account if specified if root_account: all_accounts = [ acc for acc in all_accounts if acc.name == root_account or acc.name.startswith(root_account + ":") ] # Build hierarchy with permission metadata accounts_with_hierarchy = [] for account in all_accounts: # Check if user has direct permission on this account account_perms = [ perm for perm in user_permissions if perm.account_id == account.id ] # Check if user has inherited permission from parent account inherited_perms = await get_user_permissions_with_inheritance( user_id, account.name, PermissionType.READ ) # Parse hierarchical account name to get parent and level parts = account.name.split(":") level = len(parts) - 1 parent_account = ":".join(parts[:-1]) if level > 0 else None # Determine inherited_from (which parent account gave access) inherited_from = None if inherited_perms and not account_perms: # Permission is inherited, use the parent account name _, parent_name = inherited_perms[0] inherited_from = parent_name # Collect permission types for this account permission_types = [perm.permission_type for perm in account_perms] # Check if account has children has_children = any( a.name.startswith(account.name + ":") for a in all_accounts ) accounts_with_hierarchy.append( AccountWithPermissions( id=account.id, name=account.name, account_type=account.account_type, description=account.description, user_id=account.user_id, created_at=account.created_at, user_permissions=permission_types if permission_types else None, inherited_from=inherited_from, parent_account=parent_account, level=level, has_children=has_children, ) ) # Sort by hierarchical name for natural ordering accounts_with_hierarchy.sort(key=lambda a: a.name) return accounts_with_hierarchy