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_balance_assertion, create_journal_entry, create_manual_payment_request, db, delete_balance_assertion, get_account, get_account_balance, get_account_by_name, get_account_transactions, get_all_accounts, get_all_journal_entries, get_all_manual_payment_requests, get_all_user_balances, 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_balance, get_user_manual_payment_requests, reject_manual_payment_request, ) from .models import ( Account, AccountType, AssertionStatus, BalanceAssertion, CastleSettings, CreateAccount, CreateBalanceAssertion, CreateEntryLine, CreateJournalEntry, CreateManualPaymentRequest, ExpenseEntry, GeneratePaymentInvoice, JournalEntry, JournalEntryFlag, ManualPaymentRequest, PayUser, ReceivableEntry, RecordPayment, RevenueEntry, SettleReceivable, UserBalance, 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() -> list[Account]: """Get all accounts in the chart of accounts""" return await get_all_accounts() @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""" balance = await get_account_balance(account_id) return {"account_id": account_id, "balance": balance} @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 = 100, ) -> list[JournalEntry]: """Get journal entries that affect the current user's accounts""" from lnbits.settings import settings as lnbits_settings # If super user, show all journal entries if wallet.wallet.user == lnbits_settings.super_user: return await get_all_journal_entries(limit) return await get_journal_entries_by_user(wallet.wallet.user, limit) @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 journal entry""" try: return await create_journal_entry(data, wallet.wallet.id) except ValueError as e: raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) # ===== 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", ) # Get or create user-specific account if data.is_equity: # Equity contribution user_account = await get_or_create_user_account( wallet.wallet.user, AccountType.EQUITY, "Member Equity" ) 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, } entry_data = CreateJournalEntry( description=data.description + description_suffix, reference=data.reference, entry_date=data.entry_date, flag=JournalEntryFlag.PENDING, # Expenses require admin approval meta=entry_meta, lines=[ CreateEntryLine( account_id=expense_account.id, debit=amount_sats, credit=0, description=f"Expense paid by user {wallet.wallet.user[:8]}", metadata=metadata, ), CreateEntryLine( account_id=user_account.id, debit=0, credit=amount_sats, description=f"{'Equity contribution' if data.is_equity else 'Amount owed to user'}", metadata=metadata, ), ], ) return await create_journal_entry(entry_data, wallet.wallet.id) @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, } entry_data = CreateJournalEntry( description=data.description + description_suffix, reference=data.reference, flag=JournalEntryFlag.PENDING, # Receivables start as pending until paid meta=entry_meta, lines=[ CreateEntryLine( account_id=user_receivable.id, debit=amount_sats, credit=0, description=f"Amount owed by user {data.user_id[:8]}", metadata=metadata, ), CreateEntryLine( account_id=revenue_account.id, debit=0, credit=amount_sats, description="Revenue earned", metadata=metadata, ), ], ) return await create_journal_entry(entry_data, wallet.wallet.id) @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. """ # 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", ) # Create journal entry # DR Cash/Lightning/Bank, CR Revenue entry_data = CreateJournalEntry( description=data.description, reference=data.reference, lines=[ CreateEntryLine( account_id=payment_account.id, debit=data.amount, credit=0, description="Payment received", ), CreateEntryLine( account_id=revenue_account.id, debit=0, credit=data.amount, description="Revenue earned", ), ], ) return await create_journal_entry(entry_data, wallet.wallet.id) # ===== 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 lnbits.settings import settings as lnbits_settings # If super user, show total castle position if wallet.wallet.user == lnbits_settings.super_user: all_balances = await 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 return await get_user_balance(wallet.wallet.user) @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""" return await get_user_balance(user_id) @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 lnbits.core.crud.users import get_user balances = await 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": [acc.dict() for acc in 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() # 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={"user_id": target_user_id, "type": "castle_payment"}, ) 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.", ) # Convert amount from millisatoshis to satoshis amount_sats = payment.amount // 1000 # 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" ) # Create journal entry to record payment # DR Assets:Bitcoin:Lightning, CR Assets:Receivable (User) # This reduces what the user owes # Add meta information for audit trail entry_meta = { "source": "lightning_payment", "created_via": "record_payment", "payment_hash": data.payment_hash, "payer_user_id": target_user_id, } entry_data = CreateJournalEntry( description=f"Lightning payment from user {target_user_id[:8]}", reference=data.payment_hash, flag=JournalEntryFlag.CLEARED, # Payment is immediately cleared meta=entry_meta, lines=[ CreateEntryLine( account_id=lightning_account.id, debit=amount_sats, credit=0, description="Lightning payment received", ), CreateEntryLine( account_id=user_receivable.id, debit=0, credit=amount_sats, description="Payment applied to balance", ), ], ) entry = await create_journal_entry(entry_data, target_user_id) # Get updated balance balance = await get_user_balance(target_user_id) return { "journal_entry_id": entry.id, "new_balance": balance.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" ) # Create journal entry # DR Liabilities:Payable (User), CR Assets:Bitcoin:Lightning entry_data = CreateJournalEntry( description=f"Payment to user {user_id[:8]}", lines=[ CreateEntryLine( account_id=user_payable.id, debit=amount, credit=0, description="Payment made to user", ), CreateEntryLine( account_id=lightning_account.id, debit=0, credit=amount, description="Lightning payment sent", ), ], ) entry = await create_journal_entry(entry_data, wallet.wallet.id) # Get updated balance balance = await get_user_balance(user_id) return { "journal_entry": entry.dict(), "new_balance": balance.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": "Cash", "bank_transfer": "Bank Account", "check": "Bank Account", "lightning": "Assets:Bitcoin:Lightning", "btc_onchain": "Assets:Bitcoin:OnChain", "other": "Cash" } account_name = payment_account_map.get(data.payment_method.lower(), "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.", ) # Create journal entry # DR Cash/Bank (asset increased), CR Accounts Receivable (asset decreased) # This records that user paid their debt # Determine the amount to record in the journal # IMPORTANT: Always record in satoshis to match the receivable account balance from decimal import Decimal 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 line_metadata = { "fiat_currency": data.currency.upper(), "fiat_amount": str(data.amount.quantize(Decimal("0.001"))), "fiat_rate": float(data.amount_sats) / float(data.amount) if data.amount > 0 else 0, "btc_rate": float(data.amount) / float(data.amount_sats) * 100_000_000 if data.amount_sats > 0 else 0, } else: # Satoshi payment amount_in_sats = int(data.amount) line_metadata = {} # Add payment hash for lightning payments if data.payment_hash: line_metadata["payment_hash"] = data.payment_hash # Add transaction ID for on-chain Bitcoin payments if data.txid: line_metadata["txid"] = data.txid # Add meta information for audit trail entry_meta = { "source": "manual_settlement", "payment_method": data.payment_method, "settled_by": wallet.wallet.user, "payer_user_id": data.user_id, } if data.currency: entry_meta["currency"] = data.currency entry_data = CreateJournalEntry( description=data.description, reference=data.reference or f"MANUAL-{data.user_id[:8]}", flag=JournalEntryFlag.CLEARED, # Manual payments are immediately cleared meta=entry_meta, lines=[ CreateEntryLine( account_id=payment_account.id, debit=amount_in_sats, credit=0, description=f"Payment received via {data.payment_method}", metadata=line_metadata, ), CreateEntryLine( account_id=user_receivable.id, debit=0, credit=amount_in_sats, description="Receivable settled", metadata=line_metadata, ), ], ) entry = await create_journal_entry(entry_data, wallet.wallet.id) # Get updated balance balance = await get_user_balance(data.user_id) return { "journal_entry_id": entry.id, "user_id": data.user_id, "amount_settled": float(data.amount), "currency": data.currency, "payment_method": data.payment_method, "new_balance": balance.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": "Cash", "bank_transfer": "Bank Account", "check": "Bank Account", "lightning": "Assets:Bitcoin:Lightning", "btc_onchain": "Assets:Bitcoin:OnChain", "other": "Cash" } account_name = payment_account_map.get(data.payment_method.lower(), "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.", ) # Determine the amount to record in the journal # IMPORTANT: Always record in satoshis to match the payable account balance from decimal import Decimal 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 line_metadata = { "fiat_currency": data.currency.upper(), "fiat_amount": str(data.amount.quantize(Decimal("0.001"))), "fiat_rate": float(data.amount_sats) / float(data.amount) if data.amount > 0 else 0, "btc_rate": float(data.amount) / float(data.amount_sats) * 100_000_000 if data.amount_sats > 0 else 0, } else: # Satoshi payment amount_in_sats = int(data.amount) line_metadata = {} # Add payment hash for lightning payments if data.payment_hash: line_metadata["payment_hash"] = data.payment_hash # Add transaction ID for on-chain Bitcoin payments if data.txid: line_metadata["txid"] = data.txid # Create journal entry # DR Accounts Payable (liability decreased), CR Cash/Lightning/Bank (asset decreased) # This records that castle paid its debt entry_meta = { "source": "manual_payment" if data.payment_method != "lightning" else "lightning_payment", "payment_method": data.payment_method, "paid_by": wallet.wallet.user, "payee_user_id": data.user_id, } if data.currency: entry_meta["currency"] = data.currency entry_data = CreateJournalEntry( description=data.description or f"Payment to user via {data.payment_method}", reference=data.reference or f"PAY-{data.user_id[:8]}", flag=JournalEntryFlag.CLEARED, # Payments are immediately cleared meta=entry_meta, lines=[ CreateEntryLine( account_id=user_payable.id, debit=amount_in_sats, credit=0, description="Payable settled", metadata=line_metadata, ), CreateEntryLine( account_id=payment_account.id, debit=0, credit=amount_in_sats, description=f"Payment sent via {data.payment_method}", metadata=line_metadata, ), ], ) entry = await create_journal_entry(entry_data, wallet.wallet.id) # Get updated balance balance = await get_user_balance(data.user_id) return { "journal_entry_id": entry.id, "user_id": data.user_id, "amount_paid": float(data.amount), "currency": data.currency, "payment_method": data.payment_method, "new_balance": balance.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/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", ) # Create journal entry: Debit Lightning (asset decreased), Credit Accounts Payable (liability increased) # This records that the Castle paid the user, reducing the lightning balance and reducing what castle owes journal_entry = await create_journal_entry( CreateJournalEntry( description=f"Manual payment to user: {request.description}", reference=f"MPR-{request.id}", lines=[ CreateEntryLine( account_id=liability_account.id, debit=request.amount, # Decrease liability (castle owes less) credit=0, description="Payment to user", ), CreateEntryLine( account_id=lightning_account.id, debit=0, credit=request.amount, # Decrease asset (lightning balance reduced) description="Payment from castle", ), ], ), castle_wallet_id, ) # Approve the request return await approve_manual_payment_request( request_id, wallet.wallet.user, journal_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), ) -> JournalEntry: """Approve 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 approve 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 cleared await db.execute( """ UPDATE journal_entries SET flag = :flag WHERE id = :id """, {"flag": JournalEntryFlag.CLEARED.value, "id": entry_id} ) # Return updated entry return await get_journal_entry(entry_id) @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)}", )