diff --git a/auth.py b/auth.py deleted file mode 100644 index b729347..0000000 --- a/auth.py +++ /dev/null @@ -1,310 +0,0 @@ -""" -Centralized Authorization Module for Castle Extension. - -Provides consistent, secure authorization patterns across all endpoints. - -Key concepts: -- AuthContext: Captures all authorization state for a request -- Dependencies: FastAPI dependencies for endpoint protection -- Permission checks: Consistent resource-level access control - -Usage: - from .auth import require_super_user, require_authenticated, AuthContext - - @router.get("/api/v1/admin-endpoint") - async def admin_endpoint(auth: AuthContext = Depends(require_super_user)): - # Only super users can access - pass - - @router.get("/api/v1/user-data") - async def user_data(auth: AuthContext = Depends(require_authenticated)): - # Any authenticated user - user_id = auth.user_id - pass -""" - -from dataclasses import dataclass -from functools import wraps -from http import HTTPStatus -from typing import Optional - -from fastapi import Depends, HTTPException -from lnbits.core.models import WalletTypeInfo -from lnbits.decorators import require_admin_key, require_invoice_key -from lnbits.settings import settings as lnbits_settings -from loguru import logger - -from .crud import get_account, get_user_permissions -from .models import PermissionType - - -@dataclass -class AuthContext: - """ - Authorization context for a request. - - Contains all information needed to make authorization decisions. - Use this instead of directly accessing wallet/user properties scattered - throughout endpoint code. - """ - user_id: str - wallet_id: str - is_super_user: bool - wallet: WalletTypeInfo - - @property - def is_admin(self) -> bool: - """ - Check if user is a Castle admin (super user). - - Note: In Castle, admin = super_user. There's no separate admin concept. - """ - return self.is_super_user - - def require_super_user(self) -> None: - """Raise HTTPException if not super user.""" - if not self.is_super_user: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="Super user access required" - ) - - def require_self_or_super_user(self, target_user_id: str) -> None: - """ - Require that user is accessing their own data or is super user. - - Args: - target_user_id: The user ID being accessed - - Raises: - HTTPException: If user is neither the target nor super user - """ - if not self.is_super_user and self.user_id != target_user_id: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="Access denied: you can only access your own data" - ) - - -def _build_auth_context(wallet: WalletTypeInfo) -> AuthContext: - """Build AuthContext from wallet info.""" - user_id = wallet.wallet.user - return AuthContext( - user_id=user_id, - wallet_id=wallet.wallet.id, - is_super_user=user_id == lnbits_settings.super_user, - wallet=wallet, - ) - - -# ===== FastAPI Dependencies ===== - -async def require_authenticated( - wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> AuthContext: - """ - Require authentication (invoice key minimum). - - Returns AuthContext with user information. - Use for read-only access to user's own data. - """ - return _build_auth_context(wallet) - - -async def require_authenticated_write( - wallet: WalletTypeInfo = Depends(require_admin_key), -) -> AuthContext: - """ - Require authentication with write permissions (admin key). - - Returns AuthContext with user information. - Use for write operations on user's own data. - """ - return _build_auth_context(wallet) - - -async def require_super_user( - wallet: WalletTypeInfo = Depends(require_admin_key), -) -> AuthContext: - """ - Require super user access. - - Raises HTTPException 403 if not super user. - Use for Castle admin operations. - """ - auth = _build_auth_context(wallet) - if not auth.is_super_user: - logger.warning( - f"Super user access denied for user {auth.user_id[:8]} " - f"attempting admin operation" - ) - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="Super user access required" - ) - return auth - - -# ===== Resource Access Checks ===== - -async def can_access_account( - auth: AuthContext, - account_id: str, - permission_type: PermissionType = PermissionType.READ, -) -> bool: - """ - Check if user can access an account. - - Access is granted if: - 1. User is super user (full access) - 2. User owns the account (user-specific accounts like Assets:Receivable:User-abc123) - 3. User has explicit permission for the account - - Args: - auth: The authorization context - account_id: The account ID to check - permission_type: The type of access needed (READ, SUBMIT_EXPENSE, MANAGE) - - Returns: - True if access is allowed, False otherwise - """ - # Super users have full access - if auth.is_super_user: - return True - - # Check if this is the user's own account - account = await get_account(account_id) - if account: - user_short = auth.user_id[:8] - if f"User-{user_short}" in account.name: - return True - - # Check explicit permissions - permissions = await get_user_permissions(auth.user_id) - for perm in permissions: - if perm.account_id == account_id: - # Check if permission type is sufficient - if perm.permission_type == PermissionType.MANAGE: - return True # MANAGE grants all access - if perm.permission_type == permission_type: - return True - if ( - permission_type == PermissionType.READ - and perm.permission_type in [PermissionType.SUBMIT_EXPENSE, PermissionType.MANAGE] - ): - return True # Higher permissions include READ - - return False - - -async def require_account_access( - auth: AuthContext, - account_id: str, - permission_type: PermissionType = PermissionType.READ, -) -> None: - """ - Require access to an account, raising HTTPException if denied. - - Args: - auth: The authorization context - account_id: The account ID to check - permission_type: The type of access needed - - Raises: - HTTPException: If access is denied - """ - if not await can_access_account(auth, account_id, permission_type): - logger.warning( - f"Account access denied: user {auth.user_id[:8]} " - f"attempted {permission_type.value} on account {account_id}" - ) - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail=f"Access denied to account {account_id}" - ) - - -async def can_access_user_data(auth: AuthContext, target_user_id: str) -> bool: - """ - Check if user can access another user's data. - - Access is granted if: - 1. User is super user - 2. User is accessing their own data - - Args: - auth: The authorization context - target_user_id: The user ID whose data is being accessed - - Returns: - True if access is allowed - """ - if auth.is_super_user: - return True - - # Users can access their own data - compare full ID or short ID - if auth.user_id == target_user_id: - return True - - # Also allow if short IDs match (8 char prefix) - if auth.user_id[:8] == target_user_id[:8]: - return True - - return False - - -async def require_user_data_access( - auth: AuthContext, - target_user_id: str, -) -> None: - """ - Require access to a user's data, raising HTTPException if denied. - - Args: - auth: The authorization context - target_user_id: The user ID whose data is being accessed - - Raises: - HTTPException: If access is denied - """ - if not await can_access_user_data(auth, target_user_id): - logger.warning( - f"User data access denied: user {auth.user_id[:8]} " - f"attempted to access data for user {target_user_id[:8]}" - ) - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="Access denied: you can only access your own data" - ) - - -# ===== Utility Functions ===== - -def get_user_id_from_wallet(wallet: WalletTypeInfo) -> str: - """ - Get user ID from wallet info. - - IMPORTANT: Always use wallet.wallet.user (not wallet.wallet.id). - - wallet.wallet.user = the user's ID - - wallet.wallet.id = the wallet's ID (NOT the same!) - - Args: - wallet: The wallet type info from LNbits - - Returns: - The user ID - """ - return wallet.wallet.user - - -def is_super_user(user_id: str) -> bool: - """ - Check if a user ID is the super user. - - Args: - user_id: The user ID to check - - Returns: - True if this is the super user - """ - return user_id == lnbits_settings.super_user diff --git a/crud.py b/crud.py index cd610b1..0976e72 100644 --- a/crud.py +++ b/crud.py @@ -424,26 +424,6 @@ async def get_user_wallet_settings(user_id: str) -> Optional[UserWalletSettings] ) -async def get_user_wallet_settings_by_prefix( - user_id_prefix: str, -) -> Optional[StoredUserWalletSettings]: - """ - Get user wallet settings by user ID prefix (for truncated 8-char IDs from Beancount). - - Beancount accounts use truncated user IDs (first 8 chars), but the database - stores full UUIDs. This function looks up by prefix to bridge the gap. - """ - return await db.fetchone( - """ - SELECT * FROM user_wallet_settings - WHERE id LIKE :prefix || '%' - LIMIT 1 - """, - {"prefix": user_id_prefix}, - StoredUserWalletSettings, - ) - - async def update_user_wallet_settings( user_id: str, data: UserWalletSettings ) -> UserWalletSettings: diff --git a/services.py b/services.py index 51c4bd8..1f9d826 100644 --- a/services.py +++ b/services.py @@ -18,29 +18,12 @@ async def get_settings(user_id: str) -> CastleSettings: async def update_settings(user_id: str, data: CastleSettings) -> CastleSettings: - from loguru import logger - - from .fava_client import init_fava_client - settings = await get_castle_settings(user_id) if not settings: settings = await create_castle_settings(user_id, data) else: settings = await update_castle_settings(user_id, data) - # Reinitialize Fava client with new settings - try: - init_fava_client( - fava_url=settings.fava_url, - ledger_slug=settings.fava_ledger_slug, - timeout=settings.fava_timeout, - ) - logger.info( - f"Fava client reinitialized: {settings.fava_url}/{settings.fava_ledger_slug}" - ) - except Exception as e: - logger.error(f"Failed to reinitialize Fava client: {e}") - return settings diff --git a/static/js/index.js b/static/js/index.js index bd52e39..3fc736e 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -31,7 +31,6 @@ window.app = Vue.createApp({ userInfo: null, // User information including equity eligibility isAdmin: false, isSuperUser: false, - settingsLoaded: false, // Flag to prevent race conditions on toolbar buttons castleWalletConfigured: false, userWalletConfigured: false, currentExchangeRate: null, // BTC/EUR rate (sats per EUR) @@ -58,9 +57,6 @@ window.app = Vue.createApp({ settingsDialog: { show: false, castleWalletId: '', - favaUrl: 'http://localhost:3333', - favaLedgerSlug: 'castle-ledger', - favaTimeout: 10.0, loading: false }, userWalletDialog: { @@ -521,9 +517,6 @@ window.app = Vue.createApp({ } catch (error) { // Settings not available this.castleWalletConfigured = false - } finally { - // Mark settings as loaded to enable toolbar buttons - this.settingsLoaded = true } }, async loadUserWallet() { @@ -541,9 +534,6 @@ window.app = Vue.createApp({ }, showSettingsDialog() { this.settingsDialog.castleWalletId = this.settings?.castle_wallet_id || '' - this.settingsDialog.favaUrl = this.settings?.fava_url || 'http://localhost:3333' - this.settingsDialog.favaLedgerSlug = this.settings?.fava_ledger_slug || 'castle-ledger' - this.settingsDialog.favaTimeout = this.settings?.fava_timeout || 10.0 this.settingsDialog.show = true }, showUserWalletDialog() { @@ -559,14 +549,6 @@ window.app = Vue.createApp({ return } - if (!this.settingsDialog.favaUrl) { - this.$q.notify({ - type: 'warning', - message: 'Fava URL is required' - }) - return - } - this.settingsDialog.loading = true try { await LNbits.api.request( @@ -574,10 +556,7 @@ window.app = Vue.createApp({ '/castle/api/v1/settings', this.g.user.wallets[0].adminkey, { - castle_wallet_id: this.settingsDialog.castleWalletId, - fava_url: this.settingsDialog.favaUrl, - fava_ledger_slug: this.settingsDialog.favaLedgerSlug || 'castle-ledger', - fava_timeout: parseFloat(this.settingsDialog.favaTimeout) || 10.0 + castle_wallet_id: this.settingsDialog.castleWalletId } ) this.$q.notify({ @@ -1424,7 +1403,7 @@ window.app = Vue.createApp({ maxAmount: maxAmountSats, // Positive sats amount castle owes maxAmountFiat: maxAmountFiat, // EUR or other fiat amount (positive) fiatCurrency: fiatCurrency, - amount: maxAmountSats, // Default to sats since lightning is the default payment method + amount: fiatCurrency ? maxAmountFiat : maxAmountSats, // Default to fiat if available payment_method: 'lightning', // Default to lightning for paying description: '', reference: '', @@ -1456,9 +1435,8 @@ window.app = Vue.createApp({ memo: `Payment from Castle to ${this.payUserDialog.username}` } ) - console.log(invoiceResponse) - const paymentRequest = invoiceResponse.data.bolt11 + const paymentRequest = invoiceResponse.data.payment_request // Pay the invoice from Castle's wallet const paymentResponse = await LNbits.api.request( diff --git a/static/js/permissions.js b/static/js/permissions.js index 4cc54f0..0de3569 100644 --- a/static/js/permissions.js +++ b/static/js/permissions.js @@ -1118,3 +1118,5 @@ window.app = Vue.createApp({ } } }) + +window.app.mount('#vue') diff --git a/templates/castle/index.html b/templates/castle/index.html index 2a1e665..6648e6c 100644 --- a/templates/castle/index.html +++ b/templates/castle/index.html @@ -17,14 +17,13 @@

Track expenses, receivables, and balances for the collective

- - + Configure Your Wallet - + Manage Permissions (Admin) - + Castle Settings (Super User Only)
@@ -33,7 +32,7 @@ - + @@ -45,7 +44,7 @@ - + @@ -54,7 +53,7 @@ - + @@ -1123,46 +1122,10 @@ :disable="!isSuperUser" > -
+
Select the wallet that will be used for Castle operations and transactions.
- - -
Fava/Beancount Integration
- - - - - - -
Account: - """Create a new account (super user only)""" + """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, - auth: AuthContext = Depends(require_authenticated), -) -> Account: - """Get a specific account (requires authentication and account access)""" +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" ) - # Check access permission - await require_account_access(auth, account_id, PermissionType.READ) return account @castle_api_router.get("/api/v1/accounts/{account_id}/balance") -async def api_get_account_balance( - account_id: str, - auth: AuthContext = Depends(require_authenticated), -) -> dict: - """Get account balance from Fava/Beancount (requires authentication and account access)""" +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 @@ -319,9 +303,6 @@ async def api_get_account_balance( if not account: raise HTTPException(status_code=404, detail="Account not found") - # Check access permission - await require_account_access(auth, account_id, PermissionType.READ) - # Query Fava for balance fava = get_fava_client() balance_data = await fava.get_account_balance(account.name) @@ -335,16 +316,11 @@ async def api_get_account_balance( @castle_api_router.get("/api/v1/accounts/{account_id}/transactions") -async def api_get_account_transactions( - account_id: str, - limit: int = 100, - auth: AuthContext = Depends(require_authenticated), -) -> list[dict]: +async def api_get_account_transactions(account_id: str, limit: int = 100) -> list[dict]: """ Get all transactions for an account from Fava/Beancount. Returns transactions affecting this account in reverse chronological order. - Requires authentication and account access. """ from .fava_client import get_fava_client @@ -356,9 +332,6 @@ async def api_get_account_transactions( detail=f"Account {account_id} not found" ) - # Check access permission - await require_account_access(auth, account_id, PermissionType.READ) - # Query Fava for transactions fava = get_fava_client() transactions = await fava.get_account_transactions(account.name, limit) @@ -370,15 +343,11 @@ async def api_get_account_transactions( @castle_api_router.get("/api/v1/entries") -async def api_get_journal_entries( - limit: int = 100, - auth: AuthContext = Depends(require_super_user), -) -> list[dict]: +async def api_get_journal_entries(limit: int = 100) -> list[dict]: """ Get all journal entries from Fava/Beancount. Returns all transactions in reverse chronological order with username enrichment. - SUPER USER ONLY - exposes all transaction data. """ from lnbits.core.crud.users import get_user from .fava_client import get_fava_client @@ -752,15 +721,22 @@ async def _get_username_from_user_id(user_id: str) -> str: @castle_api_router.get("/api/v1/entries/pending") async def api_get_pending_entries( - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> list[dict]: """ - Get all pending expense entries that need approval (super user only). + Get all pending expense entries that need approval (admin only). Returns transactions with flag='!' from Fava/Beancount. """ + from lnbits.settings import settings as lnbits_settings from .fava_client import get_fava_client + if wallet.wallet.user != lnbits_settings.super_user: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Only super user can access this endpoint", + ) + # Query Fava for all journal entries (includes links, tags, full metadata) fava = get_fava_client() all_entries = await fava.get_journal_entries() @@ -973,7 +949,7 @@ async def api_create_journal_entry( # 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.user # Use user_id, not wallet_id + entry_meta["created-by"] = wallet.wallet.id # Format as Beancount entry fava = get_fava_client() @@ -999,7 +975,7 @@ async def api_create_journal_entry( id=f"fava-{timestamp}", description=data.description, entry_date=data.entry_date if data.entry_date else datetime.now(), - created_by=wallet.wallet.user, # Use user_id, not wallet_id + created_by=wallet.wallet.id, created_at=datetime.now(), reference=data.reference, flag=data.flag if data.flag else JournalEntryFlag.CLEARED, @@ -1162,7 +1138,7 @@ async def api_create_expense_entry( id=entry_id, # Use the generated castle entry ID description=data.description + description_suffix, entry_date=data.entry_date if data.entry_date else datetime.now(), - created_by=wallet.wallet.user, # Use user_id, not wallet_id + created_by=wallet.wallet.id, created_at=datetime.now(), reference=castle_reference, flag=JournalEntryFlag.PENDING, @@ -1290,7 +1266,7 @@ async def api_create_receivable_entry( id=entry_id, # Use the generated castle entry ID description=data.description + description_suffix, entry_date=datetime.now(), - created_by=wallet.wallet.user, # Use user_id, not wallet_id + created_by=wallet.wallet.id, created_at=datetime.now(), reference=castle_reference, # Use castle reference with unique ID flag=JournalEntryFlag.PENDING, @@ -1404,7 +1380,7 @@ async def api_create_revenue_entry( id=entry_id, description=data.description, entry_date=datetime.now(), - created_by=wallet.wallet.user, # Use user_id, not wallet_id + created_by=wallet.wallet.id, created_at=datetime.now(), reference=castle_reference, flag=JournalEntryFlag.CLEARED, @@ -1468,18 +1444,8 @@ async def api_get_my_balance( @castle_api_router.get("/api/v1/balance/{user_id}") -async def api_get_user_balance( - user_id: str, - auth: AuthContext = Depends(require_authenticated), -) -> UserBalance: - """ - Get a specific user's balance with the Castle (from Fava/Beancount). - - Users can only access their own balance. Super users can access any user's balance. - """ - # Check access: must be own data or super user - await require_user_data_access(auth, 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() @@ -1495,9 +1461,9 @@ async def api_get_user_balance( @castle_api_router.get("/api/v1/balances/all") async def api_get_all_balances( - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> list[dict]: - """Get all user balances (super user only) from Fava/Beancount""" + """Get all user balances (admin/super user only) from Fava/Beancount""" from .fava_client import get_fava_client fava = get_fava_client() @@ -1836,7 +1802,7 @@ async def api_pay_user( @castle_api_router.post("/api/v1/receivables/settle") async def api_settle_receivable( data: SettleReceivable, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: """ Manually settle a receivable (record when user pays castle in person). @@ -1846,8 +1812,15 @@ async def api_settle_receivable( - Bank transfers - Other manual settlements - Super user only. + 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"] @@ -1984,7 +1957,7 @@ async def api_settle_receivable( @castle_api_router.post("/api/v1/payables/pay") async def api_pay_user( data: PayUser, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: """ Pay a user (castle pays user for expense/liability). @@ -1993,8 +1966,15 @@ async def api_pay_user( - Lightning payments: already executed, just record the payment - Cash/Bank/Check: record manual payment that was made - Super user only. + 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"] @@ -2113,7 +2093,7 @@ async def api_pay_user( if "meta" not in entry: entry["meta"] = {} entry["meta"]["payment-method"] = data.payment_method - entry["meta"]["paid-by"] = auth.user_id + entry["meta"]["paid-by"] = wallet.wallet.user if data.txid: entry["meta"]["txid"] = data.txid @@ -2172,26 +2152,19 @@ async def api_update_settings( @castle_api_router.get("/api/v1/user-wallet/{user_id}") async def api_get_user_wallet( user_id: str, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: - """Get user's wallet settings (super user only) + """Get user's wallet settings (admin only)""" + from lnbits.settings import settings as lnbits_settings - Supports both full UUIDs and truncated 8-char IDs (from Beancount accounts). - """ - from .crud import get_user_wallet_settings_by_prefix + if wallet.wallet.user != lnbits_settings.super_user: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Only super user can access user wallet info", + ) - # First try exact match user_wallet = await get_user_wallet(user_id) - - # If not found and user_id looks like a truncated ID (8 chars), try prefix match - if not user_wallet or not user_wallet.user_wallet_id: - if len(user_id) <= 8: - stored_wallet = await get_user_wallet_settings_by_prefix(user_id) - if stored_wallet and stored_wallet.user_wallet_id: - user_wallet = stored_wallet - user_id = stored_wallet.id # Use the full ID - - if not user_wallet or not user_wallet.user_wallet_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) @@ -2210,9 +2183,9 @@ async def api_get_user_wallet( @castle_api_router.get("/api/v1/users") async def api_get_all_users( - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> list[dict]: - """Get all users who have configured their wallet (super user only)""" + """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() @@ -2236,12 +2209,12 @@ async def api_get_all_users( @castle_api_router.get("/api/v1/admin/castle-users") async def api_get_castle_users( - auth: AuthContext = Depends(require_super_user), + 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.). - Super user only. + Admin only. """ from lnbits.core.crud.users import get_user @@ -2274,10 +2247,10 @@ async def api_expense_report( start_date: Optional[str] = None, end_date: Optional[str] = None, group_by: str = "account", - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: """ - Get expense summary report using BQL. Super user only. + Get expense summary report using BQL. Args: start_date: Filter from this date (YYYY-MM-DD), optional @@ -2538,18 +2511,32 @@ async def api_get_manual_payment_requests( @castle_api_router.get("/api/v1/manual-payment-requests/all") async def api_get_all_manual_payment_requests( status: str = None, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> list[ManualPaymentRequest]: - """Get all manual payment requests (super user only)""" + """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, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> ManualPaymentRequest: - """Approve a manual payment request and create accounting entry (super user only)""" + """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) @@ -2617,9 +2604,17 @@ async def api_approve_manual_payment_request( @castle_api_router.post("/api/v1/manual-payment-requests/{request_id}/reject") async def api_reject_manual_payment_request( request_id: str, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> ManualPaymentRequest: - """Reject a manual payment request (super user only)""" + """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: @@ -2634,7 +2629,7 @@ async def api_reject_manual_payment_request( detail=f"Request already {request.status}", ) - return await reject_manual_payment_request(request_id, auth.user_id) + return await reject_manual_payment_request(request_id, wallet.wallet.user) # ===== EXPENSE APPROVAL ENDPOINTS ===== @@ -2643,22 +2638,29 @@ async def api_reject_manual_payment_request( @castle_api_router.post("/api/v1/entries/{entry_id}/approve") async def api_approve_expense_entry( entry_id: str, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: """ - Approve a pending expense entry by changing flag from '!' to '*' (super user only). + Approve a pending expense entry by changing flag from '!' to '*' (admin only). This updates the transaction in the Beancount file via Fava API. """ - import httpx + from lnbits.settings import settings as lnbits_settings from .fava_client import get_fava_client + if wallet.wallet.user != lnbits_settings.super_user: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Only super user can approve expenses", + ) + fava = get_fava_client() # 1. Get all journal entries from Fava all_entries = await fava.get_journal_entries() # 2. Find the entry with matching castle ID in links + target_entry_hash = None target_entry = None for entry in all_entries: @@ -2670,86 +2672,51 @@ async def api_approve_expense_entry( link_clean = link.lstrip('^') # Check if this entry has our castle ID if link_clean == f"castle-{entry_id}" or link_clean.endswith(f"-{entry_id}"): + target_entry_hash = entry.get("entry_hash") target_entry = entry break - if target_entry: + if target_entry_hash: break - if not target_entry: + if not target_entry_hash: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail=f"Pending entry {entry_id} not found in Beancount ledger" ) - # Get entry metadata for file location - meta = target_entry.get("meta", {}) - filename = meta.get("filename") - lineno = meta.get("lineno") - date_str = target_entry.get("date", "") + # 3. Get the entry context (source text + sha256sum) + context = await fava.get_entry_context(target_entry_hash) + source = context.get("slice", "") + sha256sum = context.get("sha256sum", "") - if not filename or not lineno: + if not source: raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Entry metadata missing filename or lineno" + detail="Could not retrieve entry source from Fava" ) - # 3. Get the source file from Fava - async with httpx.AsyncClient(timeout=fava.timeout) as client: - response = await client.get( - f"{fava.base_url}/source", - params={"filename": filename} + # 4. Change flag from ! to * + # Replace the first occurrence of the date + ! pattern + import re + date_str = target_entry.get("date", "") + old_pattern = f"{date_str} !" + new_pattern = f"{date_str} *" + + if old_pattern not in source: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Could not find pending flag pattern '{old_pattern}' in entry source" ) - response.raise_for_status() - source_data = response.json()["data"] - sha256sum = source_data["sha256sum"] - source = source_data["source"] - lines = source.split('\n') + new_source = source.replace(old_pattern, new_pattern, 1) - # 4. Find and modify the entry at the specified line - # Line numbers are 1-indexed, list is 0-indexed - entry_line_idx = lineno - 1 - - if entry_line_idx >= len(lines): - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Line {lineno} not found in source file" - ) - - entry_line = lines[entry_line_idx] - - # Check if the line contains the pending flag pattern - old_pattern = f"{date_str} !" - if old_pattern not in entry_line: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Line {lineno} does not contain expected pattern '{old_pattern}'. Found: {entry_line}" - ) - - # Replace the flag - new_pattern = f"{date_str} *" - new_line = entry_line.replace(old_pattern, new_pattern, 1) - lines[entry_line_idx] = new_line - - # 5. Write back the modified source - new_source = '\n'.join(lines) - - update_response = await client.put( - f"{fava.base_url}/source", - json={ - "file_path": filename, - "source": new_source, - "sha256sum": sha256sum - }, - headers={"Content-Type": "application/json"} - ) - update_response.raise_for_status() - - logger.info(f"Entry {entry_id} approved (flag changed to *)") + # 5. Update the entry via Fava API + await fava.update_entry_source(target_entry_hash, new_source, sha256sum) return { "message": f"Entry {entry_id} approved successfully", "entry_id": entry_id, + "entry_hash": target_entry_hash, "date": date_str, "description": target_entry.get("narration", "") } @@ -2758,23 +2725,30 @@ async def api_approve_expense_entry( @castle_api_router.post("/api/v1/entries/{entry_id}/reject") async def api_reject_expense_entry( entry_id: str, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: """ - Reject a pending expense entry by marking it as voided (super user only). + Reject a pending expense entry by marking it as voided (admin only). Adds #voided tag for audit trail while keeping the '!' flag. Voided transactions are excluded from balances but preserved in the ledger. """ - import httpx + from lnbits.settings import settings as lnbits_settings from .fava_client import get_fava_client + if wallet.wallet.user != lnbits_settings.super_user: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Only super user can reject expenses", + ) + fava = get_fava_client() # 1. Get all journal entries from Fava all_entries = await fava.get_journal_entries() # 2. Find the entry with matching castle ID in links + target_entry_hash = None target_entry = None for entry in all_entries: @@ -2786,77 +2760,58 @@ async def api_reject_expense_entry( link_clean = link.lstrip('^') # Check if this entry has our castle ID if link_clean == f"castle-{entry_id}" or link_clean.endswith(f"-{entry_id}"): + target_entry_hash = entry.get("entry_hash") target_entry = entry break - if target_entry: + if target_entry_hash: break - if not target_entry: + if not target_entry_hash: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail=f"Pending entry {entry_id} not found in Beancount ledger" ) - # Get entry metadata for file location - meta = target_entry.get("meta", {}) - filename = meta.get("filename") - lineno = meta.get("lineno") - date_str = target_entry.get("date", "") + # 3. Get the entry context (source text + sha256sum) + context = await fava.get_entry_context(target_entry_hash) + source = context.get("slice", "") + sha256sum = context.get("sha256sum", "") - if not filename or not lineno: + if not source: raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Entry metadata missing filename or lineno" + detail="Could not retrieve entry source from Fava" ) - # 3. Get the source file from Fava - async with httpx.AsyncClient(timeout=fava.timeout) as client: - response = await client.get( - f"{fava.base_url}/source", - params={"filename": filename} - ) - response.raise_for_status() - source_data = response.json()["data"] + # 4. Add #voided tag (keep ! flag as per convention) + date_str = target_entry.get("date", "") - sha256sum = source_data["sha256sum"] - source = source_data["source"] + # Add #voided tag if not already present + if "#voided" not in source: + # Find the transaction line and add #voided to the tags + # Pattern: date ! "narration" #existing-tags lines = source.split('\n') + for i, line in enumerate(lines): + if date_str in line and '"' in line and '!' in line: + # Add #voided tag to the transaction line + if '#' in line: + # Already has tags, append voided + lines[i] = line.rstrip() + ' #voided' + else: + # No tags yet, add after narration + lines[i] = line.rstrip() + ' #voided' + break + new_source = '\n'.join(lines) + else: + new_source = source - # 4. Find and modify the entry at the specified line - add #voided tag - entry_line_idx = lineno - 1 - - if entry_line_idx >= len(lines): - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Line {lineno} not found in source file" - ) - - entry_line = lines[entry_line_idx] - - # Add #voided tag if not already present - if "#voided" not in entry_line: - # Add #voided tag to the transaction line - new_line = entry_line.rstrip() + ' #voided' - lines[entry_line_idx] = new_line - - # 5. Write back the modified source - new_source = '\n'.join(lines) - - update_response = await client.put( - f"{fava.base_url}/source", - json={ - "file_path": filename, - "source": new_source, - "sha256sum": sha256sum - }, - headers={"Content-Type": "application/json"} - ) - update_response.raise_for_status() - logger.info(f"Entry {entry_id} rejected (added #voided tag)") + # 5. Update the entry via Fava API + await fava.update_entry_source(target_entry_hash, new_source, sha256sum) return { "message": f"Entry {entry_id} rejected (marked as voided)", "entry_id": entry_id, + "entry_hash": target_entry_hash, "date": date_str, "description": target_entry.get("narration", "") } @@ -2868,10 +2823,10 @@ async def api_reject_expense_entry( @castle_api_router.post("/api/v1/assertions") async def api_create_balance_assertion( data: CreateBalanceAssertion, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> BalanceAssertion: """ - Create a balance assertion for reconciliation (super user only). + Create a balance assertion for reconciliation (admin only). Uses hybrid approach: 1. Writes balance assertion to Beancount (via Fava) - source of truth @@ -2880,9 +2835,16 @@ async def api_create_balance_assertion( The assertion will be checked immediately upon creation. """ + from lnbits.settings import settings as lnbits_settings from .fava_client import get_fava_client from .beancount_format import format_balance + 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: @@ -2914,7 +2876,7 @@ async def api_create_balance_assertion( ) # Store metadata in Castle DB for UI convenience - assertion = await create_balance_assertion(data, auth.user_id) + assertion = await create_balance_assertion(data, wallet.wallet.user) # Check it immediately (queries Fava for actual balance) try: @@ -2949,9 +2911,16 @@ async def api_get_balance_assertions( account_id: str = None, status: str = None, limit: int = 100, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> list[BalanceAssertion]: - """Get balance assertions with optional filters (super user only)""" + """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 @@ -2974,9 +2943,17 @@ async def api_get_balance_assertions( @castle_api_router.get("/api/v1/assertions/{assertion_id}") async def api_get_balance_assertion( assertion_id: str, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> BalanceAssertion: - """Get a specific balance assertion (super user only)""" + """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( @@ -2990,9 +2967,17 @@ async def api_get_balance_assertion( @castle_api_router.post("/api/v1/assertions/{assertion_id}/check") async def api_check_balance_assertion( assertion_id: str, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> BalanceAssertion: - """Re-check a balance assertion (super user only)""" + """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: @@ -3007,9 +2992,17 @@ async def api_check_balance_assertion( @castle_api_router.delete("/api/v1/assertions/{assertion_id}") async def api_delete_balance_assertion( assertion_id: str, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: - """Delete a balance assertion (super user only)""" + """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: @@ -3028,9 +3021,16 @@ async def api_delete_balance_assertion( @castle_api_router.get("/api/v1/reconciliation/summary") async def api_get_reconciliation_summary( - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: - """Get reconciliation summary (super user only)""" + """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) @@ -3079,9 +3079,16 @@ async def api_get_reconciliation_summary( @castle_api_router.post("/api/v1/reconciliation/check-all") async def api_check_all_assertions( - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: - """Re-check all balance assertions (super user only)""" + """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) @@ -3110,9 +3117,16 @@ async def api_check_all_assertions( @castle_api_router.get("/api/v1/reconciliation/discrepancies") async def api_get_discrepancies( - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: - """Get all discrepancies (failed assertions, flagged entries) (super user only)""" + """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( @@ -3140,14 +3154,21 @@ async def api_get_discrepancies( @castle_api_router.post("/api/v1/tasks/daily-reconciliation") async def api_run_daily_reconciliation( - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: """ - Manually trigger the daily reconciliation check (super user only). + 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