from datetime import datetime from decimal import Decimal from http import HTTPStatus from fastapi import APIRouter, Depends, HTTPException 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_account_transactions, get_all_accounts, get_all_journal_entries, get_all_manual_payment_requests, get_all_user_wallet_settings, get_balance_assertion, get_balance_assertions, get_journal_entries_by_user, 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""" transactions = await get_account_transactions(account_id, limit) return [ { "journal_entry": entry.dict(), "entry_line": line.dict(), } for entry, line in transactions ] # ===== JOURNAL ENTRY ENDPOINTS ===== @castle_api_router.get("/api/v1/entries") async def api_get_journal_entries(limit: int = 100) -> list[JournalEntry]: """Get all journal entries""" return await get_all_journal_entries(limit) @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 lnbits.settings import settings as lnbits_settings from lnbits.core.crud.users import get_user from .crud import ( count_all_journal_entries, count_journal_entries_by_user, count_journal_entries_by_user_and_account_type, get_account, get_journal_entries_by_user_and_account_type, ) # Determine which entries to fetch based on filters if wallet.wallet.user == lnbits_settings.super_user: # Super user with user_id filter if filter_user_id: # Filter by both user_id and account_type if filter_account_type: entries = await get_journal_entries_by_user_and_account_type( filter_user_id, filter_account_type, limit, offset ) total = await count_journal_entries_by_user_and_account_type( filter_user_id, filter_account_type ) else: # Filter by user_id only entries = await get_journal_entries_by_user(filter_user_id, limit, offset) total = await count_journal_entries_by_user(filter_user_id) else: # No user filter, show all entries (account_type filter not supported for all entries) entries = await get_all_journal_entries(limit, offset) total = await count_all_journal_entries() else: # Regular user if filter_account_type: entries = await get_journal_entries_by_user_and_account_type( wallet.wallet.user, filter_account_type, limit, offset ) total = await count_journal_entries_by_user_and_account_type( wallet.wallet.user, filter_account_type ) else: entries = await get_journal_entries_by_user(wallet.wallet.user, limit, offset) total = await count_journal_entries_by_user(wallet.wallet.user) # Enrich entries with username information enriched_entries = [] for entry in entries: # Find user_id from entry lines (look for user-specific accounts) # Prioritize equity accounts, then liability/asset accounts entry_user_id = None entry_username = None entry_account_type = None equity_account = None other_user_account = None # First pass: look for equity and other user accounts for line in entry.lines: account = await get_account(line.account_id) if account and account.user_id: account_type = account.account_type.value if hasattr(account.account_type, 'value') else account.account_type if account_type == 'equity': equity_account = (account.user_id, account_type, account) break # Prioritize equity, stop searching elif not other_user_account: other_user_account = (account.user_id, account_type, account) # Use equity account if found, otherwise use other user account selected_account = equity_account or other_user_account if selected_account: entry_user_id, entry_account_type, account_obj = selected_account user = await get_user(entry_user_id) entry_username = user.username if user and user.username else entry_user_id[:16] + "..." enriched_entries.append({ **entry.dict(), "user_id": entry_user_id, "username": entry_username, "account_type": entry_account_type, }) return { "entries": enriched_entries, "total": total, "limit": limit, "offset": offset, "has_next": (offset + limit) < total, "has_prev": offset > 0, } @castle_api_router.get("/api/v1/entries/pending") async def api_get_pending_entries( wallet: WalletTypeInfo = Depends(require_admin_key), ) -> list[JournalEntry]: """Get all pending expense entries that need approval (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 all journal entries and filter for pending flag all_entries = await get_all_journal_entries(limit=1000) pending_entries = [e for e in all_entries if e.flag == JournalEntryFlag.PENDING] 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 go in cost basis) posting_metadata = {k: v for k, v in line.metadata.items() if k not in ["fiat_currency", "fiat_amount"]} if line.description: posting_metadata["description"] = line.description posting = format_posting_with_cost( account=account.name, amount_sats=line.amount, fiat_currency=fiat_currency, fiat_amount=abs(fiat_amount) if fiat_amount else 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 mock JournalEntry for API compatibility # TODO: Query Fava to get the actual entry back with its hash 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=[ EntryLine( id=f"fava-{timestamp}-{i}", journal_entry_id=f"fava-{timestamp}", account_id=line.account_id, amount=line.amount, description=line.description, metadata=line.metadata ) for i, line in enumerate(data.lines) ], meta={**data.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 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 # 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=data.reference ) # Submit to Fava result = await fava.add_entry(entry) # Return a JournalEntry-like response for compatibility # TODO: Query Fava to get the actual entry back with its hash from .models import EntryLine return JournalEntry( id=f"fava-{datetime.now().timestamp()}", # Temporary 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=data.reference, flag=JournalEntryFlag.PENDING, meta=entry_meta, lines=[ EntryLine( id=f"line-1-{datetime.now().timestamp()}", journal_entry_id=f"fava-{datetime.now().timestamp()}", 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-{datetime.now().timestamp()}", journal_entry_id=f"fava-{datetime.now().timestamp()}", 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 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 # 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=data.reference ) # Submit to Fava result = await fava.add_entry(entry) # Return a JournalEntry-like response for compatibility from .models import EntryLine return JournalEntry( id=f"fava-{datetime.now().timestamp()}", description=data.description + description_suffix, entry_date=datetime.now(), created_by=wallet.wallet.id, created_at=datetime.now(), reference=data.reference, flag=JournalEntryFlag.PENDING, meta=entry_meta, lines=[ EntryLine( id=f"line-1-{datetime.now().timestamp()}", journal_entry_id=f"fava-{datetime.now().timestamp()}", 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-{datetime.now().timestamp()}", journal_entry_id=f"fava-{datetime.now().timestamp()}", 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 # 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() 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=data.reference ) # Submit to Fava result = await fava.add_entry(entry) logger.info(f"Revenue entry submitted to Fava: {result.get('data', 'Unknown')}") # Return mock JournalEntry for API compatibility # TODO: Query Fava to get the actual entry back with its hash timestamp = datetime.now().timestamp() return JournalEntry( id=f"fava-{timestamp}", description=data.description, entry_date=datetime.now(), created_by=wallet.wallet.id, created_at=datetime.now(), reference=data.reference, flag=JournalEntryFlag.CLEARED, # Revenue entries are cleared lines=[ EntryLine( id=f"fava-{timestamp}-1", journal_entry_id=f"fava-{timestamp}", account_id=payment_account.id, amount=amount_sats, description="Payment received", metadata={"fiat_currency": fiat_currency, "fiat_amount": str(fiat_amount)} if fiat_currency else {} ), EntryLine( id=f"fava-{timestamp}-2", journal_entry_id=f"fava-{timestamp}", account_id=revenue_account.id, amount=-amount_sats, description="Revenue earned", metadata={} ) ], 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: # Positive balances = Castle owes users (liabilities) # Negative balances = Users owe Castle (receivables) # Net: positive means castle owes, negative means castle is owed total_liabilities = sum(b["balance"] for b in all_balances if b["balance"] > 0) total_receivables = sum(abs(b["balance"]) for b in all_balances if b["balance"] < 0) net_balance = total_liabilities - total_receivables # 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 lnbits.core.crud.users import get_user from .fava_client import get_fava_client fava = get_fava_client() balances = await fava.get_all_user_balances() # Enrich with username information result = [] for balance in balances: user = await get_user(balance["user_id"]) username = user.username if user and user.username else balance["user_id"][:16] + "..." 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} 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, }) # 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() # Query Fava for existing entry with this payment hash link query = f"SELECT * WHERE links ~ 'ln-{data.payment_hash[:16]}'" try: async with httpx.AsyncClient(timeout=5.0) as client: response = await client.get( f"{fava.base_url}/query", params={"query_string": query} ) result = response.json() if result.get('data', {}).get('rows'): # 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): 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)) # 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 ) # 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 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 receivable if not data.amount_sats: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="amount_sats is required when settling 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_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 (admin only). With Fava integration, entries must be approved through Fava UI or API. This endpoint provides instructions on how to approve entries. """ 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 approve expenses", ) # TODO: Implement Fava entry update via PUT /api/source_slice # This requires: # 1. Query Fava for entry by link (^castle-{entry_id} or similar) # 2. Get the entry's source text # 3. Change flag from ! to * # 4. Submit updated source back to Fava # For now, return instructions raise HTTPException( status_code=HTTPStatus.NOT_IMPLEMENTED, detail=( f"Entry approval via API not yet implemented with Fava integration. " f"To approve entry {entry_id}, open Fava and edit the transaction to change the flag from '!' to '*'. " f"Fava URL: http://localhost:3333/castle-ledger/" ) ) @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), ) -> JournalEntry: """Reject a pending expense entry (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 reject expenses", ) # Get the entry entry = await get_journal_entry(entry_id) if not entry: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Journal entry not found", ) if entry.flag != JournalEntryFlag.PENDING: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail=f"Entry is not pending (current status: {entry.flag.value})", ) # Update flag to voided await db.execute( """ UPDATE journal_entries SET flag = :flag WHERE id = :id """, {"flag": JournalEntryFlag.VOID.value, "id": entry_id} ) # Return updated entry return await get_journal_entry(entry_id) # ===== 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 all_entries = await get_all_journal_entries(limit=1000) # Count entries by flag cleared = len([e for e in all_entries if e.flag == JournalEntryFlag.CLEARED]) pending_entries = len([e for e in all_entries if e.flag == JournalEntryFlag.PENDING]) flagged = len([e for e in all_entries if e.flag == JournalEntryFlag.FLAGGED]) voided = len([e for e in all_entries if e.flag == JournalEntryFlag.VOID]) # 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 all_entries = await get_all_journal_entries(limit=1000) flagged_entries = [e for e in all_entries if e.flag == JournalEntryFlag.FLAGGED] pending_entries = [e for e in all_entries if e.flag == JournalEntryFlag.PENDING] 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