diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..b729347 --- /dev/null +++ b/auth.py @@ -0,0 +1,310 @@ +""" +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/beancount_format.py b/beancount_format.py index 2ba5b96..74ba53c 100644 --- a/beancount_format.py +++ b/beancount_format.py @@ -497,7 +497,8 @@ def format_payment_entry( fiat_currency: Optional[str] = None, fiat_amount: Optional[Decimal] = None, payment_hash: Optional[str] = None, - reference: Optional[str] = None + reference: Optional[str] = None, + settled_entry_links: Optional[List[str]] = None ) -> Dict[str, Any]: """ Format a payment entry (Lightning payment recorded). @@ -516,6 +517,7 @@ def format_payment_entry( fiat_amount: Optional fiat amount (unsigned) payment_hash: Lightning payment hash reference: Optional reference + settled_entry_links: List of expense/receivable links being settled (e.g., ["exp-abc123"]) Returns: Fava API entry dict @@ -584,6 +586,8 @@ def format_payment_entry( entry_meta["payment-hash"] = payment_hash links = [] + if settled_entry_links: + links.extend(settled_entry_links) if reference: links.append(reference) if payment_hash: @@ -594,7 +598,7 @@ def format_payment_entry( flag="*", # Cleared (payment already happened) narration=description, postings=postings, - tags=["lightning-payment"], + tags=["lightning-payment", "settlement"], links=links, meta=entry_meta ) @@ -713,7 +717,8 @@ def format_net_settlement_entry( description: str, entry_date: date, payment_hash: Optional[str] = None, - reference: Optional[str] = None + reference: Optional[str] = None, + settled_entry_links: Optional[List[str]] = None ) -> Dict[str, Any]: """ Format a net settlement payment entry (user paying net balance). @@ -743,6 +748,7 @@ def format_net_settlement_entry( entry_date: Date of payment payment_hash: Lightning payment hash reference: Optional reference + settled_entry_links: List of expense/receivable links being settled (e.g., ["exp-abc123", "rcv-def456"]) Returns: Fava API entry dict @@ -780,6 +786,8 @@ def format_net_settlement_entry( entry_meta["payment-hash"] = payment_hash links = [] + if settled_entry_links: + links.extend(settled_entry_links) if reference: links.append(reference) if payment_hash: diff --git a/crud.py b/crud.py index 0976e72..cd610b1 100644 --- a/crud.py +++ b/crud.py @@ -424,6 +424,26 @@ 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/fava_client.py b/fava_client.py index f7f9983..6880d11 100644 --- a/fava_client.py +++ b/fava_client.py @@ -10,11 +10,14 @@ Fava provides a REST API for: - Querying balances (GET /api/query) - Balance sheets (GET /api/balance_sheet) - Account reports (GET /api/account_report) -- Updating/deleting entries (PUT/DELETE /api/source_slice) +- Getting entry context (GET /api/context) +- Updating entries (PUT /api/source_slice) +- Deleting entries (DELETE /api/source_slice) See: https://github.com/beancount/fava/blob/main/src/fava/json_api.py """ +import asyncio import httpx from typing import Any, Dict, List, Optional from decimal import Decimal @@ -22,6 +25,11 @@ from datetime import date, datetime from loguru import logger +class ChecksumConflictError(Exception): + """Raised when a Fava write operation fails due to stale checksum (concurrent modification).""" + pass + + class FavaClient: """ Async client for Fava REST API. @@ -46,6 +54,37 @@ class FavaClient: self.base_url = f"{self.fava_url}/{self.ledger_slug}/api" self.timeout = timeout + # Concurrency control: Global write lock to serialize all ledger modifications. + # This prevents race conditions when multiple requests try to write to the + # Beancount ledger file simultaneously. Without this lock, concurrent writes + # can cause data loss, duplicate entries, or file corruption. + # + # Note: This serializes ALL writes which may become a bottleneck at scale. + # For higher throughput, consider per-user locking or distributed locking. + self._write_lock = asyncio.Lock() + + # Per-user locks for user-specific operations (reduces contention) + self._user_locks: Dict[str, asyncio.Lock] = {} + + def get_user_lock(self, user_id: str) -> asyncio.Lock: + """ + Get or create a lock for a specific user. + + This enables per-user locking to reduce contention when multiple users + are making concurrent requests. User-specific operations should acquire + this lock in addition to (or instead of) the global write lock. + + Args: + user_id: User ID (uses first 8 characters for consistency) + + Returns: + asyncio.Lock for this user + """ + user_key = user_id[:8] + if user_key not in self._user_locks: + self._user_locks[user_key] = asyncio.Lock() + return self._user_locks[user_key] + async def add_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]: """ Submit a new journal entry to Fava. @@ -86,26 +125,100 @@ class FavaClient: "meta": {"user_id": "abc123"} } result = await fava_client.add_entry(entry) + + Note: + This method acquires a global write lock to prevent concurrent + modifications to the ledger file. All writes are serialized. """ + # Acquire global write lock to serialize ledger modifications + async with self._write_lock: + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.put( + f"{self.base_url}/add_entries", + json={"entries": [entry]}, + headers={"Content-Type": "application/json"} + ) + response.raise_for_status() + result = response.json() + + logger.info(f"Added entry to Fava: {result.get('data', 'Unknown')}") + return result + + except httpx.HTTPStatusError as e: + logger.error(f"Fava HTTP error: {e.response.status_code} - {e.response.text}") + raise + except httpx.RequestError as e: + logger.error(f"Fava connection error: {e}") + raise + + async def add_entry_idempotent( + self, + entry: Dict[str, Any], + idempotency_key: str + ) -> Dict[str, Any]: + """ + Submit a journal entry with idempotency protection. + + This method checks if an entry with the given idempotency key (as a Beancount link) + already exists before inserting. This prevents duplicate entries when the same + operation is retried (e.g., due to network issues or concurrent requests). + + The idempotency key is stored as a Beancount link on the entry. Links are part + of the entry's identity and are indexed by Beancount, making lookup efficient. + + Args: + entry: Beancount entry dict (same format as add_entry) + idempotency_key: Unique key for this operation (e.g., "castle-{uuid}" or "ln-{payment_hash}") + + Returns: + Response from Fava if entry was created, or existing entry data if already exists + + Example: + # Use payment hash as idempotency key for Lightning payments + result = await fava.add_entry_idempotent( + entry=settlement_entry, + idempotency_key=f"ln-{payment_hash[:16]}" + ) + + # Use expense ID for expense entries + result = await fava.add_entry_idempotent( + entry=expense_entry, + idempotency_key=f"exp-{expense_id}" + ) + """ + from .beancount_format import sanitize_link + + # Sanitize the idempotency key to ensure it's a valid Beancount link + safe_key = sanitize_link(idempotency_key) + + # Check if entry with this link already exists try: - async with httpx.AsyncClient(timeout=self.timeout) as client: - response = await client.put( - f"{self.base_url}/add_entries", - json={"entries": [entry]}, - headers={"Content-Type": "application/json"} - ) - response.raise_for_status() - result = response.json() + entries = await self.get_journal_entries(days=30) # Check recent entries - logger.info(f"Added entry to Fava: {result.get('data', 'Unknown')}") - return result + for existing_entry in entries: + existing_links = existing_entry.get("links", []) + if safe_key in existing_links: + logger.info(f"Entry with idempotency key '{safe_key}' already exists, skipping insert") + return { + "data": "Entry already exists (idempotent)", + "existing": True, + "entry": existing_entry + } + except Exception as e: + logger.warning(f"Could not check for existing entry with key '{safe_key}': {e}") + # Continue anyway - Beancount will error if there's a true duplicate - except httpx.HTTPStatusError as e: - logger.error(f"Fava HTTP error: {e.response.status_code} - {e.response.text}") - raise - except httpx.RequestError as e: - logger.error(f"Fava connection error: {e}") - raise + # Add the idempotency key as a link if not already present + if "links" not in entry: + entry["links"] = [] + if safe_key not in entry["links"]: + entry["links"].append(safe_key) + + # Now add the entry (this will acquire the write lock) + result = await self.add_entry(entry) + result["existing"] = False + return result async def get_account_balance(self, account_name: str) -> Dict[str, Any]: """ @@ -1098,7 +1211,8 @@ class FavaClient: """ Get entry source text and sha256sum for editing. - Uses /source_slice endpoint which returns the editable source. + Uses /context endpoint which returns the editable source slice. + Note: Fava's API uses get_context for reading, put_source_slice for writing. Args: entry_hash: Entry hash from get_journal_entries() @@ -1117,7 +1231,7 @@ class FavaClient: try: async with httpx.AsyncClient(timeout=self.timeout) as client: response = await client.get( - f"{self.base_url}/source_slice", + f"{self.base_url}/context", params={"entry_hash": entry_hash} ) response.raise_for_status() @@ -1143,6 +1257,10 @@ class FavaClient: Returns: New sha256sum after update + Note: + This method acquires a global write lock to prevent concurrent + modifications to the ledger file. All writes are serialized. + Example: # Get context context = await fava.get_entry_context("abc123") @@ -1155,26 +1273,28 @@ class FavaClient: # Update new_sha256 = await fava.update_entry_source("abc123", new_source, sha256) """ - try: - async with httpx.AsyncClient(timeout=self.timeout) as client: - response = await client.put( - f"{self.base_url}/source_slice", - json={ - "entry_hash": entry_hash, - "source": new_source, - "sha256sum": sha256sum - } - ) - response.raise_for_status() - result = response.json() - return result.get("data", "") + # Acquire global write lock to serialize ledger modifications + async with self._write_lock: + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.put( + f"{self.base_url}/source_slice", + json={ + "entry_hash": entry_hash, + "source": new_source, + "sha256sum": sha256sum + } + ) + response.raise_for_status() + result = response.json() + return result.get("data", "") - except httpx.HTTPStatusError as e: - logger.error(f"Fava update error: {e.response.status_code} - {e.response.text}") - raise - except httpx.RequestError as e: - logger.error(f"Fava connection error: {e}") - raise + except httpx.HTTPStatusError as e: + logger.error(f"Fava update error: {e.response.status_code} - {e.response.text}") + raise + except httpx.RequestError as e: + logger.error(f"Fava connection error: {e}") + raise async def delete_entry(self, entry_hash: str, sha256sum: str) -> str: """ @@ -1187,36 +1307,43 @@ class FavaClient: Returns: Success message + Note: + This method acquires a global write lock to prevent concurrent + modifications to the ledger file. All writes are serialized. + Example: context = await fava.get_entry_context("abc123") await fava.delete_entry("abc123", context["sha256sum"]) """ - try: - async with httpx.AsyncClient(timeout=self.timeout) as client: - response = await client.delete( - f"{self.base_url}/source_slice", - params={ - "entry_hash": entry_hash, - "sha256sum": sha256sum - } - ) - response.raise_for_status() - result = response.json() - return result.get("data", "") + # Acquire global write lock to serialize ledger modifications + async with self._write_lock: + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.delete( + f"{self.base_url}/source_slice", + params={ + "entry_hash": entry_hash, + "sha256sum": sha256sum + } + ) + response.raise_for_status() + result = response.json() + return result.get("data", "") - except httpx.HTTPStatusError as e: - logger.error(f"Fava delete error: {e.response.status_code} - {e.response.text}") - raise - except httpx.RequestError as e: - logger.error(f"Fava connection error: {e}") - raise + except httpx.HTTPStatusError as e: + logger.error(f"Fava delete error: {e.response.status_code} - {e.response.text}") + raise + except httpx.RequestError as e: + logger.error(f"Fava connection error: {e}") + raise async def add_account( self, account_name: str, currencies: list[str], opening_date: Optional[date] = None, - metadata: Optional[Dict[str, Any]] = None + metadata: Optional[Dict[str, Any]] = None, + max_retries: int = 3 ) -> Dict[str, Any]: """ Add an account to the Beancount ledger via an Open directive. @@ -1224,15 +1351,25 @@ class FavaClient: NOTE: Fava's /api/add_entries endpoint does NOT support Open directives. This method uses /api/source to directly edit the Beancount file. + This method implements optimistic concurrency control with retry logic: + - Acquires a global write lock before modifying the ledger + - Uses SHA256 checksum to detect concurrent modifications + - Retries with exponential backoff on checksum conflicts + - Re-checks if account was created by concurrent request before retrying + Args: account_name: Full account name (e.g., "Assets:Receivable:User-abc123") currencies: List of currencies for this account (e.g., ["EUR", "SATS"]) opening_date: Date to open the account (defaults to today) metadata: Optional metadata for the account + max_retries: Maximum number of retry attempts on checksum conflict (default: 3) Returns: Response from Fava ({"data": "new_sha256sum", "mtime": "..."}) + Raises: + ChecksumConflictError: If all retry attempts fail due to concurrent modifications + Example: # Add a user's receivable account result = await fava.add_account( @@ -1252,89 +1389,115 @@ class FavaClient: if opening_date is None: opening_date = date_type.today() - try: - async with httpx.AsyncClient(timeout=self.timeout) as client: - # Step 1: Get the main Beancount file path from Fava - options_response = await client.get(f"{self.base_url}/options") - options_response.raise_for_status() - options_data = options_response.json()["data"] - file_path = options_data["beancount_options"]["filename"] + last_error = None - logger.debug(f"Fava main file: {file_path}") + for attempt in range(max_retries): + # Acquire global write lock to serialize ledger modifications + async with self._write_lock: + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + # Step 1: Get the main Beancount file path from Fava + options_response = await client.get(f"{self.base_url}/options") + options_response.raise_for_status() + options_data = options_response.json()["data"] + file_path = options_data["beancount_options"]["filename"] - # Step 2: Get current source file - response = await client.get( - f"{self.base_url}/source", - params={"filename": file_path} - ) - response.raise_for_status() - source_data = response.json()["data"] + logger.debug(f"Fava main file: {file_path}") - sha256sum = source_data["sha256sum"] - source = source_data["source"] + # Step 2: Get current source file (fresh read on each attempt) + response = await client.get( + f"{self.base_url}/source", + params={"filename": file_path} + ) + response.raise_for_status() + source_data = response.json()["data"] - # Step 2: Check if account already exists - if f"open {account_name}" in source: - logger.info(f"Account {account_name} already exists in Beancount file") - return {"data": sha256sum, "mtime": source_data.get("mtime", "")} + sha256sum = source_data["sha256sum"] + source = source_data["source"] - # Step 3: Find insertion point (after last Open directive AND its metadata) - lines = source.split('\n') - insert_index = 0 - for i, line in enumerate(lines): - if line.strip().startswith(('open ', f'{opening_date.year}-')) and 'open' in line: - # Found an Open directive, now skip over any metadata lines - insert_index = i + 1 - # Skip metadata lines (lines starting with whitespace) - while insert_index < len(lines) and lines[insert_index].startswith((' ', '\t')) and lines[insert_index].strip(): - insert_index += 1 + # Step 3: Check if account already exists (may have been created by concurrent request) + if f"open {account_name}" in source: + logger.info(f"Account {account_name} already exists in Beancount file") + return {"data": sha256sum, "mtime": source_data.get("mtime", "")} - # Step 4: Format Open directive as Beancount text - currencies_str = ", ".join(currencies) - open_lines = [ - "", - f"{opening_date.isoformat()} open {account_name} {currencies_str}" - ] + # Step 4: Find insertion point (after last Open directive AND its metadata) + lines = source.split('\n') + insert_index = 0 + for i, line in enumerate(lines): + if line.strip().startswith(('open ', f'{opening_date.year}-')) and 'open' in line: + # Found an Open directive, now skip over any metadata lines + insert_index = i + 1 + # Skip metadata lines (lines starting with whitespace) + while insert_index < len(lines) and lines[insert_index].startswith((' ', '\t')) and lines[insert_index].strip(): + insert_index += 1 - # Add metadata if provided - if metadata: - for key, value in metadata.items(): - # Format metadata with proper indentation - if isinstance(value, str): - open_lines.append(f' {key}: "{value}"') - else: - open_lines.append(f' {key}: {value}') + # Step 5: Format Open directive as Beancount text + currencies_str = ", ".join(currencies) + open_lines = [ + "", + f"{opening_date.isoformat()} open {account_name} {currencies_str}" + ] - # Step 5: Insert into source - for i, line in enumerate(open_lines): - lines.insert(insert_index + i, line) + # Add metadata if provided + if metadata: + for key, value in metadata.items(): + # Format metadata with proper indentation + if isinstance(value, str): + open_lines.append(f' {key}: "{value}"') + else: + open_lines.append(f' {key}: {value}') - new_source = '\n'.join(lines) + # Step 6: Insert into source + for i, line in enumerate(open_lines): + lines.insert(insert_index + i, line) - # Step 6: Update source file via PUT /api/source - update_payload = { - "file_path": file_path, - "source": new_source, - "sha256sum": sha256sum - } + new_source = '\n'.join(lines) - response = await client.put( - f"{self.base_url}/source", - json=update_payload, - headers={"Content-Type": "application/json"} - ) - response.raise_for_status() - result = response.json() + # Step 7: Update source file via PUT /api/source + update_payload = { + "file_path": file_path, + "source": new_source, + "sha256sum": sha256sum + } - logger.info(f"Added account {account_name} to Beancount file with currencies {currencies}") - return result + response = await client.put( + f"{self.base_url}/source", + json=update_payload, + headers={"Content-Type": "application/json"} + ) + response.raise_for_status() + result = response.json() - except httpx.HTTPStatusError as e: - logger.error(f"Fava HTTP error adding account: {e.response.status_code} - {e.response.text}") - raise - except httpx.RequestError as e: - logger.error(f"Fava connection error: {e}") - raise + logger.info(f"Added account {account_name} to Beancount file with currencies {currencies}") + return result + + except httpx.HTTPStatusError as e: + # Check for checksum conflict (HTTP 412 Precondition Failed or similar) + if e.response.status_code in (409, 412): + last_error = ChecksumConflictError( + f"Checksum conflict on attempt {attempt + 1}/{max_retries}: {e.response.text}" + ) + logger.warning( + f"Checksum conflict adding account {account_name} " + f"(attempt {attempt + 1}/{max_retries}), retrying..." + ) + # Continue to retry logic below + else: + logger.error(f"Fava HTTP error adding account: {e.response.status_code} - {e.response.text}") + raise + except httpx.RequestError as e: + logger.error(f"Fava connection error: {e}") + raise + + # If we get here due to checksum conflict, wait with exponential backoff before retry + if attempt < max_retries - 1: + backoff_time = 0.1 * (2 ** attempt) # 0.1s, 0.2s, 0.4s + logger.info(f"Waiting {backoff_time}s before retry...") + await asyncio.sleep(backoff_time) + + # All retries exhausted + logger.error(f"Failed to add account {account_name} after {max_retries} attempts due to concurrent modifications") + raise last_error or ChecksumConflictError(f"Failed to add account after {max_retries} attempts") async def get_unsettled_entries_bql( self, diff --git a/services.py b/services.py index 1f9d826..51c4bd8 100644 --- a/services.py +++ b/services.py @@ -18,12 +18,29 @@ 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 3fc736e..bd52e39 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -31,6 +31,7 @@ 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) @@ -57,6 +58,9 @@ window.app = Vue.createApp({ settingsDialog: { show: false, castleWalletId: '', + favaUrl: 'http://localhost:3333', + favaLedgerSlug: 'castle-ledger', + favaTimeout: 10.0, loading: false }, userWalletDialog: { @@ -517,6 +521,9 @@ 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() { @@ -534,6 +541,9 @@ 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() { @@ -549,6 +559,14 @@ 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( @@ -556,7 +574,10 @@ window.app = Vue.createApp({ '/castle/api/v1/settings', this.g.user.wallets[0].adminkey, { - castle_wallet_id: this.settingsDialog.castleWalletId + 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 } ) this.$q.notify({ @@ -1403,7 +1424,7 @@ window.app = Vue.createApp({ maxAmount: maxAmountSats, // Positive sats amount castle owes maxAmountFiat: maxAmountFiat, // EUR or other fiat amount (positive) fiatCurrency: fiatCurrency, - amount: fiatCurrency ? maxAmountFiat : maxAmountSats, // Default to fiat if available + amount: maxAmountSats, // Default to sats since lightning is the default payment method payment_method: 'lightning', // Default to lightning for paying description: '', reference: '', @@ -1435,8 +1456,9 @@ window.app = Vue.createApp({ memo: `Payment from Castle to ${this.payUserDialog.username}` } ) + console.log(invoiceResponse) - const paymentRequest = invoiceResponse.data.payment_request + const paymentRequest = invoiceResponse.data.bolt11 // 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 0de3569..4cc54f0 100644 --- a/static/js/permissions.js +++ b/static/js/permissions.js @@ -1118,5 +1118,3 @@ window.app = Vue.createApp({ } } }) - -window.app.mount('#vue') diff --git a/tasks.py b/tasks.py index 1a8327d..8ec83b9 100644 --- a/tasks.py +++ b/tasks.py @@ -187,6 +187,12 @@ async def on_invoice_paid(payment: Payment) -> None: This function is called automatically when any invoice on the Castle wallet is paid. It checks if the invoice is a Castle payment and records it in Beancount via Fava. + + Concurrency Protection: + - Uses per-user locking to prevent race conditions when multiple payments + for the same user are processed simultaneously + - Uses idempotent entry creation to prevent duplicate entries even if + the same payment is processed multiple times """ # Only process Castle-specific payments if not payment.extra or payment.extra.get("tag") != "castle": @@ -197,121 +203,120 @@ async def on_invoice_paid(payment: Payment) -> None: logger.warning(f"Castle invoice {payment.payment_hash} missing user_id in metadata") return - # Check if payment already recorded (idempotency) - # Query Fava for existing entry with this payment hash link from .fava_client import get_fava_client - import httpx fava = get_fava_client() - try: - # Check if payment already recorded by fetching recent entries - # Note: We can't use BQL query with `links ~ 'pattern'` because links is a set type - # and BQL doesn't support regex matching on sets. Instead, fetch entries and filter in Python. - link_to_find = f"ln-{payment.payment_hash[:16]}" + # Use idempotency key based on payment hash - this ensures duplicate + # processing of the same payment won't create duplicate entries + idempotency_key = f"ln-{payment.payment_hash[:16]}" - async with httpx.AsyncClient(timeout=5.0) as client: - # Get recent entries from Fava's journal endpoint - response = await client.get( - f"{fava.base_url}/api/journal", - params={"time": ""} # Get all entries + # Acquire per-user lock to serialize processing for this user + # This prevents race conditions when a user has multiple payments being processed + user_lock = fava.get_user_lock(user_id) + + async with user_lock: + logger.info(f"Recording Castle payment {payment.payment_hash} for user {user_id[:8]} to Fava") + + try: + from decimal import Decimal + from .crud import get_account_by_name, get_or_create_user_account + from .models import AccountType + from .beancount_format import format_net_settlement_entry + + # Convert amount from millisatoshis to satoshis + amount_sats = payment.amount // 1000 + + # Extract fiat metadata from invoice (if present) + fiat_currency = None + fiat_amount = None + if payment.extra: + fiat_currency = payment.extra.get("fiat_currency") + fiat_amount_str = payment.extra.get("fiat_amount") + if fiat_amount_str: + fiat_amount = Decimal(str(fiat_amount_str)) + + if not fiat_currency or not fiat_amount: + logger.error(f"Payment {payment.payment_hash} missing fiat currency/amount metadata") + return + + # Get user's current balance to determine receivables and payables + balance = await fava.get_user_balance(user_id) + fiat_balances = balance.get("fiat_balances", {}) + total_fiat_balance = fiat_balances.get(fiat_currency, Decimal(0)) + + # Determine receivables and payables based on balance + # Positive balance = user owes castle (receivable) + # Negative balance = castle owes user (payable) + if total_fiat_balance > 0: + # User owes castle + total_receivable = total_fiat_balance + total_payable = Decimal(0) + else: + # Castle owes user + total_receivable = Decimal(0) + total_payable = abs(total_fiat_balance) + + logger.info(f"Settlement: {fiat_amount} {fiat_currency} (Receivable: {total_receivable}, Payable: {total_payable})") + + # Get account names + user_receivable = await get_or_create_user_account( + user_id, AccountType.ASSET, "Accounts Receivable" + ) + user_payable = await get_or_create_user_account( + user_id, AccountType.LIABILITY, "Accounts Payable" + ) + lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning") + if not lightning_account: + logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found") + return + + # Query for unsettled entries to link this settlement back to them + # Net settlement can settle both expenses and receivables + settled_links = [] + try: + unsettled_expenses = await fava.get_unsettled_entries_bql(user_id, "expense") + settled_links.extend([e["link"] for e in unsettled_expenses if e.get("link")]) + unsettled_receivables = await fava.get_unsettled_entries_bql(user_id, "receivable") + settled_links.extend([e["link"] for e in unsettled_receivables if e.get("link")]) + except Exception as e: + logger.warning(f"Could not query unsettled entries for settlement links: {e}") + # Continue without links - settlement will still be recorded + + # Format as net settlement transaction + entry = format_net_settlement_entry( + user_id=user_id, + payment_account=lightning_account.name, + receivable_account=user_receivable.name, + payable_account=user_payable.name, + amount_sats=amount_sats, + net_fiat_amount=fiat_amount, + total_receivable_fiat=total_receivable, + total_payable_fiat=total_payable, + fiat_currency=fiat_currency, + description=f"Lightning payment settlement from user {user_id[:8]}", + entry_date=datetime.now().date(), + payment_hash=payment.payment_hash, + reference=payment.payment_hash, + settled_entry_links=settled_links if settled_links else None ) - if response.status_code == 200: - data = response.json() - entries = data.get('entries', []) + # Submit to Fava using idempotent method to prevent duplicates + # The idempotency key is based on the payment hash, so even if this + # function is called multiple times for the same payment, only one + # entry will be created + result = await fava.add_entry_idempotent(entry, idempotency_key) - # Check if any entry has our payment link - for entry in entries: - entry_links = entry.get('links', []) - if link_to_find in entry_links: - logger.info(f"Payment {payment.payment_hash} already recorded in Fava, skipping") - return + if result.get("existing"): + logger.info( + f"Payment {payment.payment_hash} was already recorded in Fava (idempotent)" + ) + else: + logger.info( + f"Successfully recorded payment {payment.payment_hash} to Fava: " + f"{result.get('data', 'Unknown')}" + ) - except Exception as e: - logger.warning(f"Could not check Fava for duplicate payment: {e}") - # Continue anyway - Fava/Beancount will catch duplicate if it exists - - logger.info(f"Recording Castle payment {payment.payment_hash} for user {user_id[:8]} to Fava") - - try: - from decimal import Decimal - from .crud import get_account_by_name, get_or_create_user_account - from .models import AccountType - from .beancount_format import format_net_settlement_entry - - # Convert amount from millisatoshis to satoshis - amount_sats = payment.amount // 1000 - - # Extract fiat metadata from invoice (if present) - fiat_currency = None - fiat_amount = None - if payment.extra: - fiat_currency = payment.extra.get("fiat_currency") - fiat_amount_str = payment.extra.get("fiat_amount") - if fiat_amount_str: - fiat_amount = Decimal(str(fiat_amount_str)) - - if not fiat_currency or not fiat_amount: - logger.error(f"Payment {payment.payment_hash} missing fiat currency/amount metadata") - return - - # Get user's current balance to determine receivables and payables - balance = await fava.get_user_balance(user_id) - fiat_balances = balance.get("fiat_balances", {}) - total_fiat_balance = fiat_balances.get(fiat_currency, Decimal(0)) - - # Determine receivables and payables based on balance - # Positive balance = user owes castle (receivable) - # Negative balance = castle owes user (payable) - if total_fiat_balance > 0: - # User owes castle - total_receivable = total_fiat_balance - total_payable = Decimal(0) - else: - # Castle owes user - total_receivable = Decimal(0) - total_payable = abs(total_fiat_balance) - - logger.info(f"Settlement: {fiat_amount} {fiat_currency} (Receivable: {total_receivable}, Payable: {total_payable})") - - # Get account names - user_receivable = await get_or_create_user_account( - user_id, AccountType.ASSET, "Accounts Receivable" - ) - user_payable = await get_or_create_user_account( - user_id, AccountType.LIABILITY, "Accounts Payable" - ) - lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning") - if not lightning_account: - logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found") - return - - # Format as net settlement transaction - entry = format_net_settlement_entry( - user_id=user_id, - payment_account=lightning_account.name, - receivable_account=user_receivable.name, - payable_account=user_payable.name, - amount_sats=amount_sats, - net_fiat_amount=fiat_amount, - total_receivable_fiat=total_receivable, - total_payable_fiat=total_payable, - fiat_currency=fiat_currency, - description=f"Lightning payment settlement from user {user_id[:8]}", - entry_date=datetime.now().date(), - payment_hash=payment.payment_hash, - reference=payment.payment_hash - ) - - # Submit to Fava - result = await fava.add_entry(entry) - - logger.info( - f"Successfully recorded payment {payment.payment_hash} to Fava: " - f"{result.get('data', 'Unknown')}" - ) - - except Exception as e: - logger.error(f"Error recording Castle payment {payment.payment_hash}: {e}") - raise + except Exception as e: + logger.error(f"Error recording Castle payment {payment.payment_hash}: {e}") + raise diff --git a/templates/castle/index.html b/templates/castle/index.html index 6648e6c..2a1e665 100644 --- a/templates/castle/index.html +++ b/templates/castle/index.html @@ -17,13 +17,14 @@

Track expenses, receivables, and balances for the collective

- + + Configure Your Wallet - + Manage Permissions (Admin) - + Castle Settings (Super User Only)
@@ -32,7 +33,7 @@ - + @@ -44,7 +45,7 @@ - + @@ -53,7 +54,7 @@ - + @@ -1122,10 +1123,46 @@ :disable="!isSuperUser" > -
+
Select the wallet that will be used for Castle operations and transactions.
+ + +
Fava/Beancount Integration
+ + + + + + +
Account: - """Create a new account (admin only)""" + """Create a new account (super user 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""" +async def api_get_account( + account_id: str, + auth: AuthContext = Depends(require_authenticated), +) -> Account: + """Get a specific account (requires authentication and account access)""" 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) -> dict: - """Get account balance from Fava/Beancount""" +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)""" from .fava_client import get_fava_client # Get account to retrieve its name @@ -303,6 +319,9 @@ async def api_get_account_balance(account_id: str) -> dict: 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) @@ -316,11 +335,16 @@ async def api_get_account_balance(account_id: str) -> dict: @castle_api_router.get("/api/v1/accounts/{account_id}/transactions") -async def api_get_account_transactions(account_id: str, limit: int = 100) -> list[dict]: +async def api_get_account_transactions( + account_id: str, + limit: int = 100, + auth: AuthContext = Depends(require_authenticated), +) -> 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 @@ -332,6 +356,9 @@ async def api_get_account_transactions(account_id: str, limit: int = 100) -> lis 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) @@ -343,11 +370,15 @@ async def api_get_account_transactions(account_id: str, limit: int = 100) -> lis @castle_api_router.get("/api/v1/entries") -async def api_get_journal_entries(limit: int = 100) -> list[dict]: +async def api_get_journal_entries( + limit: int = 100, + auth: AuthContext = Depends(require_super_user), +) -> 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 @@ -721,22 +752,15 @@ 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( - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> list[dict]: """ - Get all pending expense entries that need approval (admin only). + Get all pending expense entries that need approval (super user 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() @@ -949,7 +973,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.id + entry_meta["created-by"] = wallet.wallet.user # Use user_id, not wallet_id # Format as Beancount entry fava = get_fava_client() @@ -975,7 +999,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.id, + created_by=wallet.wallet.user, # Use user_id, not wallet_id created_at=datetime.now(), reference=data.reference, flag=data.flag if data.flag else JournalEntryFlag.CLEARED, @@ -1125,7 +1149,8 @@ async def api_create_expense_entry( is_equity=data.is_equity, fiat_currency=fiat_currency, fiat_amount=fiat_amount, - reference=castle_reference # Add castle ID as link + reference=castle_reference, + entry_id=entry_id # Pass entry_id so all links match ) # Submit to Fava @@ -1137,7 +1162,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.id, + created_by=wallet.wallet.user, # Use user_id, not wallet_id created_at=datetime.now(), reference=castle_reference, flag=JournalEntryFlag.PENDING, @@ -1252,7 +1277,8 @@ async def api_create_receivable_entry( entry_date=datetime.now().date(), fiat_currency=fiat_currency, fiat_amount=fiat_amount, - reference=castle_reference # Use castle reference with unique ID + reference=castle_reference, + entry_id=entry_id # Pass entry_id so all links match ) # Submit to Fava @@ -1264,7 +1290,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.id, + created_by=wallet.wallet.user, # Use user_id, not wallet_id created_at=datetime.now(), reference=castle_reference, # Use castle reference with unique ID flag=JournalEntryFlag.PENDING, @@ -1378,7 +1404,7 @@ async def api_create_revenue_entry( id=entry_id, description=data.description, entry_date=datetime.now(), - created_by=wallet.wallet.id, + created_by=wallet.wallet.user, # Use user_id, not wallet_id created_at=datetime.now(), reference=castle_reference, flag=JournalEntryFlag.CLEARED, @@ -1442,8 +1468,18 @@ async def api_get_my_balance( @castle_api_router.get("/api/v1/balance/{user_id}") -async def api_get_user_balance(user_id: str) -> UserBalance: - """Get a specific user's balance with the Castle (from Fava/Beancount)""" +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) + from .fava_client import get_fava_client fava = get_fava_client() @@ -1459,9 +1495,9 @@ async def api_get_user_balance(user_id: str) -> UserBalance: @castle_api_router.get("/api/v1/balances/all") async def api_get_all_balances( - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> list[dict]: - """Get all user balances (admin/super user only) from Fava/Beancount""" + """Get all user balances (super user only) from Fava/Beancount""" from .fava_client import get_fava_client fava = get_fava_client() @@ -1702,6 +1738,10 @@ async def api_record_payment( status_code=HTTPStatus.NOT_FOUND, detail="Lightning account not found" ) + # Get unsettled receivable entries to link to this settlement + unsettled = await fava.get_unsettled_entries_bql(target_user_id, "receivable") + settled_links = [e["link"] for e in unsettled if e.get("link")] + # Format payment entry and submit to Fava entry = format_payment_entry( user_id=target_user_id, @@ -1714,7 +1754,8 @@ async def api_record_payment( fiat_currency=fiat_currency, fiat_amount=fiat_amount, payment_hash=data.payment_hash, - reference=data.payment_hash + reference=data.payment_hash, + settled_entry_links=settled_links ) logger.info(f"Formatted payment entry: {entry}") @@ -1762,6 +1803,10 @@ async def api_pay_user( fava = get_fava_client() + # Get unsettled expense entries to link to this settlement + unsettled = await fava.get_unsettled_entries_bql(user_id, "expense") + settled_links = [e["link"] for e in unsettled if e.get("link")] + entry = format_payment_entry( user_id=user_id, payment_account=lightning_account.name, @@ -1770,7 +1815,8 @@ async def api_pay_user( description=f"Payment to user {user_id[:8]}", entry_date=datetime.now().date(), is_payable=True, # Castle paying user - reference=f"PAY-{user_id[:8]}" + reference=f"PAY-{user_id[:8]}", + settled_entry_links=settled_links ) # Submit to Fava @@ -1790,7 +1836,7 @@ async def api_pay_user( @castle_api_router.post("/api/v1/receivables/settle") async def api_settle_receivable( data: SettleReceivable, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> dict: """ Manually settle a receivable (record when user pays castle in person). @@ -1800,15 +1846,8 @@ async def api_settle_receivable( - Bank transfers - Other manual settlements - Admin only. + Super user 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"] @@ -1895,6 +1934,12 @@ async def api_settle_receivable( fiat_currency = data.currency.upper() if data.currency else None fiat_amount = Decimal(str(data.amount)) if data.currency else None + # Get settled entry links (use provided or auto-query unsettled) + settled_links = data.settled_entry_links + if not settled_links: + unsettled = await fava.get_unsettled_entries_bql(data.user_id, "receivable") + settled_links = [e["link"] for e in unsettled if e.get("link")] + entry = format_payment_entry( user_id=data.user_id, payment_account=payment_account.name, @@ -1906,7 +1951,8 @@ async def api_settle_receivable( fiat_currency=fiat_currency, fiat_amount=fiat_amount, payment_hash=data.payment_hash, - reference=data.reference or f"MANUAL-{data.user_id[:8]}" + reference=data.reference or f"MANUAL-{data.user_id[:8]}", + settled_entry_links=settled_links ) # Add additional metadata to entry @@ -1938,7 +1984,7 @@ async def api_settle_receivable( @castle_api_router.post("/api/v1/payables/pay") async def api_pay_user( data: PayUser, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> dict: """ Pay a user (castle pays user for expense/liability). @@ -1947,15 +1993,8 @@ async def api_pay_user( - Lightning payments: already executed, just record the payment - Cash/Bank/Check: record manual payment that was made - Admin only. + Super user 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"] @@ -2049,6 +2088,12 @@ async def api_pay_user( fiat_currency = None fiat_amount = None + # Get settled entry links (use provided or auto-query unsettled) + settled_links = data.settled_entry_links + if not settled_links: + unsettled = await fava.get_unsettled_entries_bql(data.user_id, "expense") + settled_links = [e["link"] for e in unsettled if e.get("link")] + entry = format_payment_entry( user_id=data.user_id, payment_account=payment_account.name, @@ -2060,14 +2105,15 @@ async def api_pay_user( fiat_currency=fiat_currency, fiat_amount=fiat_amount, payment_hash=data.payment_hash, - reference=data.reference or f"PAY-{data.user_id[:8]}" + reference=data.reference or f"PAY-{data.user_id[:8]}", + settled_entry_links=settled_links ) # Add additional metadata to entry if "meta" not in entry: entry["meta"] = {} entry["meta"]["payment-method"] = data.payment_method - entry["meta"]["paid-by"] = wallet.wallet.user + entry["meta"]["paid-by"] = auth.user_id if data.txid: entry["meta"]["txid"] = data.txid @@ -2126,19 +2172,26 @@ async def api_update_settings( @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), + auth: AuthContext = Depends(require_super_user), ) -> dict: - """Get user's wallet settings (admin only)""" - from lnbits.settings import settings as lnbits_settings + """Get user's wallet settings (super user only) - if wallet.wallet.user != lnbits_settings.super_user: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="Only super user can access user wallet info", - ) + Supports both full UUIDs and truncated 8-char IDs (from Beancount accounts). + """ + from .crud import get_user_wallet_settings_by_prefix + # First try exact match user_wallet = await get_user_wallet(user_id) - if not user_wallet: + + # 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: return {"user_id": user_id, "user_wallet_id": None} # Get invoice key for the user's wallet (needed to generate invoices) @@ -2157,9 +2210,9 @@ async def api_get_user_wallet( @castle_api_router.get("/api/v1/users") async def api_get_all_users( - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> list[dict]: - """Get all users who have configured their wallet (admin only)""" + """Get all users who have configured their wallet (super user only)""" from lnbits.core.crud.users import get_user user_settings = await get_all_user_wallet_settings() @@ -2183,12 +2236,12 @@ async def api_get_all_users( @castle_api_router.get("/api/v1/admin/castle-users") async def api_get_castle_users( - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> list[dict]: """ Get all users who have configured their wallet in Castle. These are users who can interact with Castle (submit expenses, receive permissions, etc.). - Admin only. + Super user only. """ from lnbits.core.crud.users import get_user @@ -2221,10 +2274,10 @@ async def api_expense_report( start_date: Optional[str] = None, end_date: Optional[str] = None, group_by: str = "account", - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> dict: """ - Get expense summary report using BQL. + Get expense summary report using BQL. Super user only. Args: start_date: Filter from this date (YYYY-MM-DD), optional @@ -2485,32 +2538,18 @@ 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, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> 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", - ) + """Get all manual payment requests (super user only)""" 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), + auth: AuthContext = Depends(require_super_user), ) -> 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", - ) + """Approve a manual payment request and create accounting entry (super user only)""" # Get the request request = await get_manual_payment_request(request_id) @@ -2548,6 +2587,10 @@ async def api_approve_manual_payment_request( fava = get_fava_client() + # Get unsettled expense entries to link to this settlement + unsettled = await fava.get_unsettled_entries_bql(request.user_id, "expense") + settled_links = [e["link"] for e in unsettled if e.get("link")] + entry = format_payment_entry( user_id=request.user_id, payment_account=lightning_account.name, @@ -2556,7 +2599,8 @@ async def api_approve_manual_payment_request( description=f"Manual payment to user: {request.description}", entry_date=datetime.now().date(), is_payable=True, # Castle paying user - reference=f"MPR-{request.id}" + reference=f"MPR-{request.id}", + settled_entry_links=settled_links ) # Submit to Fava @@ -2573,17 +2617,9 @@ 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, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> 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", - ) - + """Reject a manual payment request (super user only)""" # Get the request request = await get_manual_payment_request(request_id) if not request: @@ -2598,7 +2634,7 @@ async def api_reject_manual_payment_request( detail=f"Request already {request.status}", ) - return await reject_manual_payment_request(request_id, wallet.wallet.user) + return await reject_manual_payment_request(request_id, auth.user_id) # ===== EXPENSE APPROVAL ENDPOINTS ===== @@ -2607,29 +2643,22 @@ 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, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> dict: """ - Approve a pending expense entry by changing flag from '!' to '*' (admin only). + Approve a pending expense entry by changing flag from '!' to '*' (super user only). This updates the transaction in the Beancount file via Fava API. """ - from lnbits.settings import settings as lnbits_settings + import httpx 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: @@ -2641,51 +2670,86 @@ 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_hash: + if target_entry: break - if not target_entry_hash: + if not target_entry: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail=f"Pending entry {entry_id} not found in Beancount ledger" ) - # 3. Get the entry context (source text + sha256sum) - context = await fava.get_entry_context(target_entry_hash) - source = context.get("slice", "") - sha256sum = context.get("sha256sum", "") - - if not source: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Could not retrieve entry source from Fava" - ) - - # 4. Change flag from ! to * - # Replace the first occurrence of the date + ! pattern - import re + # 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", "") - old_pattern = f"{date_str} !" - new_pattern = f"{date_str} *" - if old_pattern not in source: + if not filename or not lineno: raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Could not find pending flag pattern '{old_pattern}' in entry source" + detail="Entry metadata missing filename or lineno" ) - new_source = source.replace(old_pattern, new_pattern, 1) + # 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"] - # 5. Update the entry via Fava API - await fava.update_entry_source(target_entry_hash, new_source, sha256sum) + sha256sum = source_data["sha256sum"] + source = source_data["source"] + lines = source.split('\n') + + # 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 *)") 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", "") } @@ -2694,30 +2758,23 @@ 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, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> dict: """ - Reject a pending expense entry by marking it as voided (admin only). + Reject a pending expense entry by marking it as voided (super user only). Adds #voided tag for audit trail while keeping the '!' flag. Voided transactions are excluded from balances but preserved in the ledger. """ - from lnbits.settings import settings as lnbits_settings + import httpx 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: @@ -2729,58 +2786,77 @@ 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_hash: + if target_entry: break - if not target_entry_hash: + if not target_entry: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail=f"Pending entry {entry_id} not found in Beancount ledger" ) - # 3. Get the entry context (source text + sha256sum) - context = await fava.get_entry_context(target_entry_hash) - source = context.get("slice", "") - sha256sum = context.get("sha256sum", "") - - if not source: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Could not retrieve entry source from Fava" - ) - - # 4. Add #voided tag (keep ! flag as per convention) + # 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", "") - # 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 + if not filename or not lineno: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Entry metadata missing filename or lineno" + ) - # 5. Update the entry via Fava API - await fava.update_entry_source(target_entry_hash, new_source, sha256sum) + # 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"] + + sha256sum = source_data["sha256sum"] + source = source_data["source"] + lines = source.split('\n') + + # 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)") 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", "") } @@ -2792,10 +2868,10 @@ async def api_reject_expense_entry( @castle_api_router.post("/api/v1/assertions") async def api_create_balance_assertion( data: CreateBalanceAssertion, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> BalanceAssertion: """ - Create a balance assertion for reconciliation (admin only). + Create a balance assertion for reconciliation (super user only). Uses hybrid approach: 1. Writes balance assertion to Beancount (via Fava) - source of truth @@ -2804,16 +2880,9 @@ 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: @@ -2845,7 +2914,7 @@ async def api_create_balance_assertion( ) # Store metadata in Castle DB for UI convenience - assertion = await create_balance_assertion(data, wallet.wallet.user) + assertion = await create_balance_assertion(data, auth.user_id) # Check it immediately (queries Fava for actual balance) try: @@ -2880,16 +2949,9 @@ async def api_get_balance_assertions( account_id: str = None, status: str = None, limit: int = 100, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> 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", - ) + """Get balance assertions with optional filters (super user only)""" # Parse status enum if provided status_enum = None @@ -2912,17 +2974,9 @@ async def api_get_balance_assertions( @castle_api_router.get("/api/v1/assertions/{assertion_id}") async def api_get_balance_assertion( assertion_id: str, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> 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", - ) - + """Get a specific balance assertion (super user only)""" assertion = await get_balance_assertion(assertion_id) if not assertion: raise HTTPException( @@ -2936,17 +2990,9 @@ 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, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> 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", - ) - + """Re-check a balance assertion (super user only)""" try: assertion = await check_balance_assertion(assertion_id) except ValueError as e: @@ -2961,17 +3007,9 @@ async def api_check_balance_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), + auth: AuthContext = Depends(require_super_user), ) -> 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", - ) - + """Delete a balance assertion (super user only)""" # Verify it exists assertion = await get_balance_assertion(assertion_id) if not assertion: @@ -2990,16 +3028,9 @@ async def api_delete_balance_assertion( @castle_api_router.get("/api/v1/reconciliation/summary") async def api_get_reconciliation_summary( - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> 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 reconciliation summary (super user only)""" # Get all assertions all_assertions = await get_balance_assertions(limit=1000) @@ -3048,16 +3079,9 @@ async def api_get_reconciliation_summary( @castle_api_router.post("/api/v1/reconciliation/check-all") async def api_check_all_assertions( - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> 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", - ) + """Re-check all balance assertions (super user only)""" # Get all assertions all_assertions = await get_balance_assertions(limit=1000) @@ -3086,16 +3110,9 @@ async def api_check_all_assertions( @castle_api_router.get("/api/v1/reconciliation/discrepancies") async def api_get_discrepancies( - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> 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 all discrepancies (failed assertions, flagged entries) (super user only)""" # Get failed assertions failed_assertions = await get_balance_assertions( @@ -3123,21 +3140,14 @@ async def api_get_discrepancies( @castle_api_router.post("/api/v1/tasks/daily-reconciliation") async def api_run_daily_reconciliation( - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> dict: """ - Manually trigger the daily reconciliation check (admin only). + Manually trigger the daily reconciliation check (super user 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