Compare commits

..

9 commits
v0.0.1 ... main

Author SHA1 Message Date
Patrick Mulligan
c4f784360d Fix Pay User lightning payment bugs
- Fix default amount showing fiat instead of sats when lightning payment selected
- Fix invoice response field name (bolt11 instead of payment_request)
- Fix NameError in payables/pay endpoint (wallet -> auth.user_id)
- Add get_user_wallet_settings_by_prefix() for truncated 8-char user IDs
- Update user-wallet endpoint to handle truncated IDs from Beancount accounts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 07:17:12 -05:00
8d9e14ee5a Fix approve/reject endpoints to use Fava source API correctly
The Fava /context endpoint returns structured entry data, not raw source
text with slice/sha256sum as expected. Updated both endpoints to:

1. Get entry metadata (filename, lineno) from the parsed entry
2. Read the full source file via GET /source
3. Modify the specific line at the entry's line number
4. Write back via PUT /source with sha256sum for concurrency control

- Approve: Changes flag from '!' to '*' at the entry line
- Reject: Adds #voided tag to the entry line

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 15:47:09 +01:00
cb9bc2d658 Add Fava settings UI and fix race conditions in toolbar buttons
- Add Fava URL, ledger slug, and timeout settings to super admin Settings dialog
- Reinitialize Fava client when settings are updated via services.py
- Add settingsLoaded flag to prevent race conditions where wrong toolbar
  buttons appeared before isSuperUser was determined
- Remove premature Vue mount() call from permissions.js that caused
  "Cannot read properties of undefined (reading 'user')" error

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 15:24:19 +01:00
5eb007b936 Merge branch 'fix/authorization-security-refactor' 2026-01-07 13:37:54 +01:00
ca0cee7312 Add centralized authorization module and fix security vulnerabilities
- Create auth.py with AuthContext, require_super_user, require_authenticated
- Fix 6 CRITICAL unprotected endpoints exposing sensitive data
- Consolidate 16+ admin endpoints with duplicated super_user checks
- Standardize on user_id (wallet.wallet.user) instead of wallet_id

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 13:35:07 +01:00
b5c36504fb Add concurrency protection for Fava/Beancount ledger writes
This commit addresses critical race conditions when multiple requests
try to write to the ledger file simultaneously.

Changes:
- Add global asyncio.Lock to FavaClient to serialize all write operations
- Add per-user locks for finer-grained concurrency control
- Wrap add_entry(), update_entry_source(), delete_entry() with write lock
- Add retry logic with exponential backoff to add_account() for checksum conflicts
- Add new add_entry_idempotent() method to prevent duplicate entries
- Add ChecksumConflictError exception for conflict handling
- Update on_invoice_paid() to use per-user locking and idempotent entry creation

Fixes #4

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:57:03 +01:00
Patrick Mulligan
e403ec223d Add settlement links to payment entries for traceability
- Add settled_entry_links parameter to format_payment_entry and format_net_settlement_entry
- Query unsettled expenses/receivables before creating settlement entries
- Pass original entry links to format functions so settlements reference what they settle
- Update all callers in views_api.py (5 locations) and tasks.py (1 location)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 19:34:25 +01:00
Patrick Mulligan
da74e668c8 Fix entry_id mismatch between castle links and exp/rcv links
Pass entry_id from views_api.py to format_expense_entry and
format_receivable_entry so that all links use the same ID:
- ^castle-{entry_id}
- ^exp-{entry_id} / ^rcv-{entry_id}
- entry-id metadata

Previously, views_api.py generated an entry_id for castle-* links
but didn't pass it to the format functions, which generated their
own separate IDs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 18:34:45 +01:00
Patrick Mulligan
69ce1d9601 Fix Fava API endpoint for getting entry context
Use GET /api/context instead of GET /api/source_slice. Fava's API
naming convention means source_slice only supports PUT and DELETE,
while context is the correct endpoint for reading entry data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 18:23:18 +01:00
10 changed files with 1103 additions and 513 deletions

310
auth.py Normal file
View file

@ -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

View file

@ -497,7 +497,8 @@ def format_payment_entry(
fiat_currency: Optional[str] = None, fiat_currency: Optional[str] = None,
fiat_amount: Optional[Decimal] = None, fiat_amount: Optional[Decimal] = None,
payment_hash: Optional[str] = None, payment_hash: Optional[str] = None,
reference: Optional[str] = None reference: Optional[str] = None,
settled_entry_links: Optional[List[str]] = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Format a payment entry (Lightning payment recorded). Format a payment entry (Lightning payment recorded).
@ -516,6 +517,7 @@ def format_payment_entry(
fiat_amount: Optional fiat amount (unsigned) fiat_amount: Optional fiat amount (unsigned)
payment_hash: Lightning payment hash payment_hash: Lightning payment hash
reference: Optional reference reference: Optional reference
settled_entry_links: List of expense/receivable links being settled (e.g., ["exp-abc123"])
Returns: Returns:
Fava API entry dict Fava API entry dict
@ -584,6 +586,8 @@ def format_payment_entry(
entry_meta["payment-hash"] = payment_hash entry_meta["payment-hash"] = payment_hash
links = [] links = []
if settled_entry_links:
links.extend(settled_entry_links)
if reference: if reference:
links.append(reference) links.append(reference)
if payment_hash: if payment_hash:
@ -594,7 +598,7 @@ def format_payment_entry(
flag="*", # Cleared (payment already happened) flag="*", # Cleared (payment already happened)
narration=description, narration=description,
postings=postings, postings=postings,
tags=["lightning-payment"], tags=["lightning-payment", "settlement"],
links=links, links=links,
meta=entry_meta meta=entry_meta
) )
@ -713,7 +717,8 @@ def format_net_settlement_entry(
description: str, description: str,
entry_date: date, entry_date: date,
payment_hash: Optional[str] = None, payment_hash: Optional[str] = None,
reference: Optional[str] = None reference: Optional[str] = None,
settled_entry_links: Optional[List[str]] = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Format a net settlement payment entry (user paying net balance). Format a net settlement payment entry (user paying net balance).
@ -743,6 +748,7 @@ def format_net_settlement_entry(
entry_date: Date of payment entry_date: Date of payment
payment_hash: Lightning payment hash payment_hash: Lightning payment hash
reference: Optional reference reference: Optional reference
settled_entry_links: List of expense/receivable links being settled (e.g., ["exp-abc123", "rcv-def456"])
Returns: Returns:
Fava API entry dict Fava API entry dict
@ -780,6 +786,8 @@ def format_net_settlement_entry(
entry_meta["payment-hash"] = payment_hash entry_meta["payment-hash"] = payment_hash
links = [] links = []
if settled_entry_links:
links.extend(settled_entry_links)
if reference: if reference:
links.append(reference) links.append(reference)
if payment_hash: if payment_hash:

20
crud.py
View file

@ -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( async def update_user_wallet_settings(
user_id: str, data: UserWalletSettings user_id: str, data: UserWalletSettings
) -> UserWalletSettings: ) -> UserWalletSettings:

View file

@ -10,11 +10,14 @@ Fava provides a REST API for:
- Querying balances (GET /api/query) - Querying balances (GET /api/query)
- Balance sheets (GET /api/balance_sheet) - Balance sheets (GET /api/balance_sheet)
- Account reports (GET /api/account_report) - 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 See: https://github.com/beancount/fava/blob/main/src/fava/json_api.py
""" """
import asyncio
import httpx import httpx
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from decimal import Decimal from decimal import Decimal
@ -22,6 +25,11 @@ from datetime import date, datetime
from loguru import logger from loguru import logger
class ChecksumConflictError(Exception):
"""Raised when a Fava write operation fails due to stale checksum (concurrent modification)."""
pass
class FavaClient: class FavaClient:
""" """
Async client for Fava REST API. Async client for Fava REST API.
@ -46,6 +54,37 @@ class FavaClient:
self.base_url = f"{self.fava_url}/{self.ledger_slug}/api" self.base_url = f"{self.fava_url}/{self.ledger_slug}/api"
self.timeout = timeout 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]: async def add_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
""" """
Submit a new journal entry to Fava. Submit a new journal entry to Fava.
@ -86,26 +125,100 @@ class FavaClient:
"meta": {"user_id": "abc123"} "meta": {"user_id": "abc123"}
} }
result = await fava_client.add_entry(entry) 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: try:
async with httpx.AsyncClient(timeout=self.timeout) as client: entries = await self.get_journal_entries(days=30) # Check recent entries
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')}") for existing_entry in entries:
return result 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: # Add the idempotency key as a link if not already present
logger.error(f"Fava HTTP error: {e.response.status_code} - {e.response.text}") if "links" not in entry:
raise entry["links"] = []
except httpx.RequestError as e: if safe_key not in entry["links"]:
logger.error(f"Fava connection error: {e}") entry["links"].append(safe_key)
raise
# 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]: 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. 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: Args:
entry_hash: Entry hash from get_journal_entries() entry_hash: Entry hash from get_journal_entries()
@ -1117,7 +1231,7 @@ class FavaClient:
try: try:
async with httpx.AsyncClient(timeout=self.timeout) as client: async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get( response = await client.get(
f"{self.base_url}/source_slice", f"{self.base_url}/context",
params={"entry_hash": entry_hash} params={"entry_hash": entry_hash}
) )
response.raise_for_status() response.raise_for_status()
@ -1143,6 +1257,10 @@ class FavaClient:
Returns: Returns:
New sha256sum after update 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: Example:
# Get context # Get context
context = await fava.get_entry_context("abc123") context = await fava.get_entry_context("abc123")
@ -1155,26 +1273,28 @@ class FavaClient:
# Update # Update
new_sha256 = await fava.update_entry_source("abc123", new_source, sha256) new_sha256 = await fava.update_entry_source("abc123", new_source, sha256)
""" """
try: # Acquire global write lock to serialize ledger modifications
async with httpx.AsyncClient(timeout=self.timeout) as client: async with self._write_lock:
response = await client.put( try:
f"{self.base_url}/source_slice", async with httpx.AsyncClient(timeout=self.timeout) as client:
json={ response = await client.put(
"entry_hash": entry_hash, f"{self.base_url}/source_slice",
"source": new_source, json={
"sha256sum": sha256sum "entry_hash": entry_hash,
} "source": new_source,
) "sha256sum": sha256sum
response.raise_for_status() }
result = response.json() )
return result.get("data", "") response.raise_for_status()
result = response.json()
return result.get("data", "")
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
logger.error(f"Fava update error: {e.response.status_code} - {e.response.text}") logger.error(f"Fava update error: {e.response.status_code} - {e.response.text}")
raise raise
except httpx.RequestError as e: except httpx.RequestError as e:
logger.error(f"Fava connection error: {e}") logger.error(f"Fava connection error: {e}")
raise raise
async def delete_entry(self, entry_hash: str, sha256sum: str) -> str: async def delete_entry(self, entry_hash: str, sha256sum: str) -> str:
""" """
@ -1187,36 +1307,43 @@ class FavaClient:
Returns: Returns:
Success message Success message
Note:
This method acquires a global write lock to prevent concurrent
modifications to the ledger file. All writes are serialized.
Example: Example:
context = await fava.get_entry_context("abc123") context = await fava.get_entry_context("abc123")
await fava.delete_entry("abc123", context["sha256sum"]) await fava.delete_entry("abc123", context["sha256sum"])
""" """
try: # Acquire global write lock to serialize ledger modifications
async with httpx.AsyncClient(timeout=self.timeout) as client: async with self._write_lock:
response = await client.delete( try:
f"{self.base_url}/source_slice", async with httpx.AsyncClient(timeout=self.timeout) as client:
params={ response = await client.delete(
"entry_hash": entry_hash, f"{self.base_url}/source_slice",
"sha256sum": sha256sum params={
} "entry_hash": entry_hash,
) "sha256sum": sha256sum
response.raise_for_status() }
result = response.json() )
return result.get("data", "") response.raise_for_status()
result = response.json()
return result.get("data", "")
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
logger.error(f"Fava delete error: {e.response.status_code} - {e.response.text}") logger.error(f"Fava delete error: {e.response.status_code} - {e.response.text}")
raise raise
except httpx.RequestError as e: except httpx.RequestError as e:
logger.error(f"Fava connection error: {e}") logger.error(f"Fava connection error: {e}")
raise raise
async def add_account( async def add_account(
self, self,
account_name: str, account_name: str,
currencies: list[str], currencies: list[str],
opening_date: Optional[date] = None, opening_date: Optional[date] = None,
metadata: Optional[Dict[str, Any]] = None metadata: Optional[Dict[str, Any]] = None,
max_retries: int = 3
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Add an account to the Beancount ledger via an Open directive. 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. 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 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: Args:
account_name: Full account name (e.g., "Assets:Receivable:User-abc123") account_name: Full account name (e.g., "Assets:Receivable:User-abc123")
currencies: List of currencies for this account (e.g., ["EUR", "SATS"]) currencies: List of currencies for this account (e.g., ["EUR", "SATS"])
opening_date: Date to open the account (defaults to today) opening_date: Date to open the account (defaults to today)
metadata: Optional metadata for the account metadata: Optional metadata for the account
max_retries: Maximum number of retry attempts on checksum conflict (default: 3)
Returns: Returns:
Response from Fava ({"data": "new_sha256sum", "mtime": "..."}) Response from Fava ({"data": "new_sha256sum", "mtime": "..."})
Raises:
ChecksumConflictError: If all retry attempts fail due to concurrent modifications
Example: Example:
# Add a user's receivable account # Add a user's receivable account
result = await fava.add_account( result = await fava.add_account(
@ -1252,89 +1389,115 @@ class FavaClient:
if opening_date is None: if opening_date is None:
opening_date = date_type.today() opening_date = date_type.today()
try: last_error = None
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"]
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 logger.debug(f"Fava main file: {file_path}")
response = await client.get(
f"{self.base_url}/source",
params={"filename": file_path}
)
response.raise_for_status()
source_data = response.json()["data"]
sha256sum = source_data["sha256sum"] # Step 2: Get current source file (fresh read on each attempt)
source = source_data["source"] 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 sha256sum = source_data["sha256sum"]
if f"open {account_name}" in source: source = source_data["source"]
logger.info(f"Account {account_name} already exists in Beancount file")
return {"data": sha256sum, "mtime": source_data.get("mtime", "")}
# Step 3: Find insertion point (after last Open directive AND its metadata) # Step 3: Check if account already exists (may have been created by concurrent request)
lines = source.split('\n') if f"open {account_name}" in source:
insert_index = 0 logger.info(f"Account {account_name} already exists in Beancount file")
for i, line in enumerate(lines): return {"data": sha256sum, "mtime": source_data.get("mtime", "")}
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 4: Format Open directive as Beancount text # Step 4: Find insertion point (after last Open directive AND its metadata)
currencies_str = ", ".join(currencies) lines = source.split('\n')
open_lines = [ insert_index = 0
"", for i, line in enumerate(lines):
f"{opening_date.isoformat()} open {account_name} {currencies_str}" 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 # Step 5: Format Open directive as Beancount text
if metadata: currencies_str = ", ".join(currencies)
for key, value in metadata.items(): open_lines = [
# Format metadata with proper indentation "",
if isinstance(value, str): f"{opening_date.isoformat()} open {account_name} {currencies_str}"
open_lines.append(f' {key}: "{value}"') ]
else:
open_lines.append(f' {key}: {value}')
# Step 5: Insert into source # Add metadata if provided
for i, line in enumerate(open_lines): if metadata:
lines.insert(insert_index + i, line) 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 new_source = '\n'.join(lines)
update_payload = {
"file_path": file_path,
"source": new_source,
"sha256sum": sha256sum
}
response = await client.put( # Step 7: Update source file via PUT /api/source
f"{self.base_url}/source", update_payload = {
json=update_payload, "file_path": file_path,
headers={"Content-Type": "application/json"} "source": new_source,
) "sha256sum": sha256sum
response.raise_for_status() }
result = response.json()
logger.info(f"Added account {account_name} to Beancount file with currencies {currencies}") response = await client.put(
return result 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.info(f"Added account {account_name} to Beancount file with currencies {currencies}")
logger.error(f"Fava HTTP error adding account: {e.response.status_code} - {e.response.text}") return result
raise
except httpx.RequestError as e: except httpx.HTTPStatusError as e:
logger.error(f"Fava connection error: {e}") # Check for checksum conflict (HTTP 412 Precondition Failed or similar)
raise 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( async def get_unsettled_entries_bql(
self, self,

View file

@ -18,12 +18,29 @@ async def get_settings(user_id: str) -> CastleSettings:
async def update_settings(user_id: str, data: CastleSettings) -> 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) settings = await get_castle_settings(user_id)
if not settings: if not settings:
settings = await create_castle_settings(user_id, data) settings = await create_castle_settings(user_id, data)
else: else:
settings = await update_castle_settings(user_id, data) 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 return settings

View file

@ -31,6 +31,7 @@ window.app = Vue.createApp({
userInfo: null, // User information including equity eligibility userInfo: null, // User information including equity eligibility
isAdmin: false, isAdmin: false,
isSuperUser: false, isSuperUser: false,
settingsLoaded: false, // Flag to prevent race conditions on toolbar buttons
castleWalletConfigured: false, castleWalletConfigured: false,
userWalletConfigured: false, userWalletConfigured: false,
currentExchangeRate: null, // BTC/EUR rate (sats per EUR) currentExchangeRate: null, // BTC/EUR rate (sats per EUR)
@ -57,6 +58,9 @@ window.app = Vue.createApp({
settingsDialog: { settingsDialog: {
show: false, show: false,
castleWalletId: '', castleWalletId: '',
favaUrl: 'http://localhost:3333',
favaLedgerSlug: 'castle-ledger',
favaTimeout: 10.0,
loading: false loading: false
}, },
userWalletDialog: { userWalletDialog: {
@ -517,6 +521,9 @@ window.app = Vue.createApp({
} catch (error) { } catch (error) {
// Settings not available // Settings not available
this.castleWalletConfigured = false this.castleWalletConfigured = false
} finally {
// Mark settings as loaded to enable toolbar buttons
this.settingsLoaded = true
} }
}, },
async loadUserWallet() { async loadUserWallet() {
@ -534,6 +541,9 @@ window.app = Vue.createApp({
}, },
showSettingsDialog() { showSettingsDialog() {
this.settingsDialog.castleWalletId = this.settings?.castle_wallet_id || '' 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 this.settingsDialog.show = true
}, },
showUserWalletDialog() { showUserWalletDialog() {
@ -549,6 +559,14 @@ window.app = Vue.createApp({
return return
} }
if (!this.settingsDialog.favaUrl) {
this.$q.notify({
type: 'warning',
message: 'Fava URL is required'
})
return
}
this.settingsDialog.loading = true this.settingsDialog.loading = true
try { try {
await LNbits.api.request( await LNbits.api.request(
@ -556,7 +574,10 @@ window.app = Vue.createApp({
'/castle/api/v1/settings', '/castle/api/v1/settings',
this.g.user.wallets[0].adminkey, 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({ this.$q.notify({
@ -1403,7 +1424,7 @@ window.app = Vue.createApp({
maxAmount: maxAmountSats, // Positive sats amount castle owes maxAmount: maxAmountSats, // Positive sats amount castle owes
maxAmountFiat: maxAmountFiat, // EUR or other fiat amount (positive) maxAmountFiat: maxAmountFiat, // EUR or other fiat amount (positive)
fiatCurrency: fiatCurrency, 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 payment_method: 'lightning', // Default to lightning for paying
description: '', description: '',
reference: '', reference: '',
@ -1435,8 +1456,9 @@ window.app = Vue.createApp({
memo: `Payment from Castle to ${this.payUserDialog.username}` 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 // Pay the invoice from Castle's wallet
const paymentResponse = await LNbits.api.request( const paymentResponse = await LNbits.api.request(

View file

@ -1118,5 +1118,3 @@ window.app = Vue.createApp({
} }
} }
}) })
window.app.mount('#vue')

223
tasks.py
View file

@ -187,6 +187,12 @@ async def on_invoice_paid(payment: Payment) -> None:
This function is called automatically when any invoice on the Castle wallet 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 is paid. It checks if the invoice is a Castle payment and records it in
Beancount via Fava. 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 # Only process Castle-specific payments
if not payment.extra or payment.extra.get("tag") != "castle": 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") logger.warning(f"Castle invoice {payment.payment_hash} missing user_id in metadata")
return return
# Check if payment already recorded (idempotency)
# Query Fava for existing entry with this payment hash link
from .fava_client import get_fava_client from .fava_client import get_fava_client
import httpx
fava = get_fava_client() fava = get_fava_client()
try: # Use idempotency key based on payment hash - this ensures duplicate
# Check if payment already recorded by fetching recent entries # processing of the same payment won't create duplicate entries
# Note: We can't use BQL query with `links ~ 'pattern'` because links is a set type idempotency_key = f"ln-{payment.payment_hash[:16]}"
# 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]}"
async with httpx.AsyncClient(timeout=5.0) as client: # Acquire per-user lock to serialize processing for this user
# Get recent entries from Fava's journal endpoint # This prevents race conditions when a user has multiple payments being processed
response = await client.get( user_lock = fava.get_user_lock(user_id)
f"{fava.base_url}/api/journal",
params={"time": ""} # Get all entries 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: # Submit to Fava using idempotent method to prevent duplicates
data = response.json() # The idempotency key is based on the payment hash, so even if this
entries = data.get('entries', []) # 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 if result.get("existing"):
for entry in entries: logger.info(
entry_links = entry.get('links', []) f"Payment {payment.payment_hash} was already recorded in Fava (idempotent)"
if link_to_find in entry_links: )
logger.info(f"Payment {payment.payment_hash} already recorded in Fava, skipping") else:
return logger.info(
f"Successfully recorded payment {payment.payment_hash} to Fava: "
f"{result.get('data', 'Unknown')}"
)
except Exception as e: except Exception as e:
logger.warning(f"Could not check Fava for duplicate payment: {e}") logger.error(f"Error recording Castle payment {payment.payment_hash}: {e}")
# Continue anyway - Fava/Beancount will catch duplicate if it exists raise
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

View file

@ -17,13 +17,14 @@
<p class="q-mb-none">Track expenses, receivables, and balances for the collective</p> <p class="q-mb-none">Track expenses, receivables, and balances for the collective</p>
</div> </div>
<div class="col-auto q-gutter-xs"> <div class="col-auto q-gutter-xs">
<q-btn v-if="!isSuperUser" flat round icon="account_balance_wallet" @click="showUserWalletDialog"> <!-- Wait for settings to load before showing role-specific buttons to prevent race conditions -->
<q-btn v-if="settingsLoaded && !isSuperUser" flat round icon="account_balance_wallet" @click="showUserWalletDialog">
<q-tooltip>Configure Your Wallet</q-tooltip> <q-tooltip>Configure Your Wallet</q-tooltip>
</q-btn> </q-btn>
<q-btn v-if="isSuperUser" flat round icon="admin_panel_settings" :href="'/castle/permissions'"> <q-btn v-if="settingsLoaded && isSuperUser" flat round icon="admin_panel_settings" :href="'/castle/permissions'">
<q-tooltip>Manage Permissions (Admin)</q-tooltip> <q-tooltip>Manage Permissions (Admin)</q-tooltip>
</q-btn> </q-btn>
<q-btn v-if="isSuperUser" flat round icon="settings" @click="showSettingsDialog"> <q-btn v-if="settingsLoaded && isSuperUser" flat round icon="settings" @click="showSettingsDialog">
<q-tooltip>Castle Settings (Super User Only)</q-tooltip> <q-tooltip>Castle Settings (Super User Only)</q-tooltip>
</q-btn> </q-btn>
</div> </div>
@ -32,7 +33,7 @@
</q-card> </q-card>
<!-- Setup Warning --> <!-- Setup Warning -->
<q-banner v-if="!castleWalletConfigured && isSuperUser" class="bg-warning text-white" rounded> <q-banner v-if="settingsLoaded && !castleWalletConfigured && isSuperUser" class="bg-warning text-white" rounded>
<template v-slot:avatar> <template v-slot:avatar>
<q-icon name="warning" color="white"></q-icon> <q-icon name="warning" color="white"></q-icon>
</template> </template>
@ -44,7 +45,7 @@
</template> </template>
</q-banner> </q-banner>
<q-banner v-if="!castleWalletConfigured && !isSuperUser" class="bg-info text-white" rounded> <q-banner v-if="settingsLoaded && !castleWalletConfigured && !isSuperUser" class="bg-info text-white" rounded>
<template v-slot:avatar> <template v-slot:avatar>
<q-icon name="info" color="white"></q-icon> <q-icon name="info" color="white"></q-icon>
</template> </template>
@ -53,7 +54,7 @@
</div> </div>
</q-banner> </q-banner>
<q-banner v-if="castleWalletConfigured && !userWalletConfigured && !isSuperUser" class="bg-orange text-white" rounded> <q-banner v-if="settingsLoaded && castleWalletConfigured && !userWalletConfigured && !isSuperUser" class="bg-orange text-white" rounded>
<template v-slot:avatar> <template v-slot:avatar>
<q-icon name="account_balance_wallet" color="white"></q-icon> <q-icon name="account_balance_wallet" color="white"></q-icon>
</template> </template>
@ -1122,10 +1123,46 @@
:disable="!isSuperUser" :disable="!isSuperUser"
></q-select> ></q-select>
<div class="text-caption text-grey"> <div class="text-caption text-grey q-mb-md">
Select the wallet that will be used for Castle operations and transactions. Select the wallet that will be used for Castle operations and transactions.
</div> </div>
<q-separator class="q-my-md"></q-separator>
<div class="text-subtitle2 q-mb-sm">Fava/Beancount Integration</div>
<q-input
filled
dense
v-model="settingsDialog.favaUrl"
label="Fava URL *"
hint="Base URL of the Fava server (e.g., http://localhost:3333)"
:readonly="!isSuperUser"
:disable="!isSuperUser"
></q-input>
<q-input
filled
dense
v-model="settingsDialog.favaLedgerSlug"
label="Ledger Slug"
hint="Ledger identifier in Fava URL (e.g., castle-ledger)"
:readonly="!isSuperUser"
:disable="!isSuperUser"
></q-input>
<q-input
filled
dense
type="number"
step="0.5"
v-model.number="settingsDialog.favaTimeout"
label="Timeout (seconds)"
hint="Request timeout for Fava API calls"
:readonly="!isSuperUser"
:disable="!isSuperUser"
></q-input>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
v-if="isSuperUser" v-if="isSuperUser"

View file

@ -83,6 +83,14 @@ from .models import (
UserWithRoles, UserWithRoles,
) )
from .services import get_settings, get_user_wallet, update_settings, update_user_wallet from .services import get_settings, get_user_wallet, update_settings, update_user_wallet
from .auth import (
AuthContext,
require_authenticated,
require_authenticated_write,
require_super_user,
require_account_access,
require_user_data_access,
)
castle_api_router = APIRouter() castle_api_router = APIRouter()
@ -276,26 +284,34 @@ async def api_get_accounts(
@castle_api_router.post("/api/v1/accounts", status_code=HTTPStatus.CREATED) @castle_api_router.post("/api/v1/accounts", status_code=HTTPStatus.CREATED)
async def api_create_account( async def api_create_account(
data: CreateAccount, data: CreateAccount,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> Account: ) -> Account:
"""Create a new account (admin only)""" """Create a new account (super user only)"""
return await create_account(data) return await create_account(data)
@castle_api_router.get("/api/v1/accounts/{account_id}") @castle_api_router.get("/api/v1/accounts/{account_id}")
async def api_get_account(account_id: str) -> Account: async def api_get_account(
"""Get a specific 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) account = await get_account(account_id)
if not account: if not account:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Account not found" status_code=HTTPStatus.NOT_FOUND, detail="Account not found"
) )
# Check access permission
await require_account_access(auth, account_id, PermissionType.READ)
return account return account
@castle_api_router.get("/api/v1/accounts/{account_id}/balance") @castle_api_router.get("/api/v1/accounts/{account_id}/balance")
async def api_get_account_balance(account_id: str) -> dict: async def api_get_account_balance(
"""Get account balance from Fava/Beancount""" 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 from .fava_client import get_fava_client
# Get account to retrieve its name # Get account to retrieve its name
@ -303,6 +319,9 @@ async def api_get_account_balance(account_id: str) -> dict:
if not account: if not account:
raise HTTPException(status_code=404, detail="Account not found") 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 # Query Fava for balance
fava = get_fava_client() fava = get_fava_client()
balance_data = await fava.get_account_balance(account.name) 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") @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. Get all transactions for an account from Fava/Beancount.
Returns transactions affecting this account in reverse chronological order. Returns transactions affecting this account in reverse chronological order.
Requires authentication and account access.
""" """
from .fava_client import get_fava_client 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" detail=f"Account {account_id} not found"
) )
# Check access permission
await require_account_access(auth, account_id, PermissionType.READ)
# Query Fava for transactions # Query Fava for transactions
fava = get_fava_client() fava = get_fava_client()
transactions = await fava.get_account_transactions(account.name, limit) 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") @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. Get all journal entries from Fava/Beancount.
Returns all transactions in reverse chronological order with username enrichment. 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 lnbits.core.crud.users import get_user
from .fava_client import get_fava_client 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") @castle_api_router.get("/api/v1/entries/pending")
async def api_get_pending_entries( async def api_get_pending_entries(
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> list[dict]: ) -> 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. Returns transactions with flag='!' from Fava/Beancount.
""" """
from lnbits.settings import settings as lnbits_settings
from .fava_client import get_fava_client 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) # Query Fava for all journal entries (includes links, tags, full metadata)
fava = get_fava_client() fava = get_fava_client()
all_entries = await fava.get_journal_entries() 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 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 = {k: v for k, v in data.meta.items() if k not in ["tags", "links"]}
entry_meta["source"] = "castle-api" 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 # Format as Beancount entry
fava = get_fava_client() fava = get_fava_client()
@ -975,7 +999,7 @@ async def api_create_journal_entry(
id=f"fava-{timestamp}", id=f"fava-{timestamp}",
description=data.description, description=data.description,
entry_date=data.entry_date if data.entry_date else datetime.now(), 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(), created_at=datetime.now(),
reference=data.reference, reference=data.reference,
flag=data.flag if data.flag else JournalEntryFlag.CLEARED, flag=data.flag if data.flag else JournalEntryFlag.CLEARED,
@ -1125,7 +1149,8 @@ async def api_create_expense_entry(
is_equity=data.is_equity, is_equity=data.is_equity,
fiat_currency=fiat_currency, fiat_currency=fiat_currency,
fiat_amount=fiat_amount, 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 # Submit to Fava
@ -1137,7 +1162,7 @@ async def api_create_expense_entry(
id=entry_id, # Use the generated castle entry ID id=entry_id, # Use the generated castle entry ID
description=data.description + description_suffix, description=data.description + description_suffix,
entry_date=data.entry_date if data.entry_date else datetime.now(), 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(), created_at=datetime.now(),
reference=castle_reference, reference=castle_reference,
flag=JournalEntryFlag.PENDING, flag=JournalEntryFlag.PENDING,
@ -1252,7 +1277,8 @@ async def api_create_receivable_entry(
entry_date=datetime.now().date(), entry_date=datetime.now().date(),
fiat_currency=fiat_currency, fiat_currency=fiat_currency,
fiat_amount=fiat_amount, 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 # Submit to Fava
@ -1264,7 +1290,7 @@ async def api_create_receivable_entry(
id=entry_id, # Use the generated castle entry ID id=entry_id, # Use the generated castle entry ID
description=data.description + description_suffix, description=data.description + description_suffix,
entry_date=datetime.now(), entry_date=datetime.now(),
created_by=wallet.wallet.id, created_by=wallet.wallet.user, # Use user_id, not wallet_id
created_at=datetime.now(), created_at=datetime.now(),
reference=castle_reference, # Use castle reference with unique ID reference=castle_reference, # Use castle reference with unique ID
flag=JournalEntryFlag.PENDING, flag=JournalEntryFlag.PENDING,
@ -1378,7 +1404,7 @@ async def api_create_revenue_entry(
id=entry_id, id=entry_id,
description=data.description, description=data.description,
entry_date=datetime.now(), entry_date=datetime.now(),
created_by=wallet.wallet.id, created_by=wallet.wallet.user, # Use user_id, not wallet_id
created_at=datetime.now(), created_at=datetime.now(),
reference=castle_reference, reference=castle_reference,
flag=JournalEntryFlag.CLEARED, flag=JournalEntryFlag.CLEARED,
@ -1442,8 +1468,18 @@ async def api_get_my_balance(
@castle_api_router.get("/api/v1/balance/{user_id}") @castle_api_router.get("/api/v1/balance/{user_id}")
async def api_get_user_balance(user_id: str) -> UserBalance: async def api_get_user_balance(
"""Get a specific user's balance with the Castle (from Fava/Beancount)""" 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 from .fava_client import get_fava_client
fava = 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") @castle_api_router.get("/api/v1/balances/all")
async def api_get_all_balances( async def api_get_all_balances(
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> list[dict]: ) -> 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 from .fava_client import get_fava_client
fava = 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" 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 # Format payment entry and submit to Fava
entry = format_payment_entry( entry = format_payment_entry(
user_id=target_user_id, user_id=target_user_id,
@ -1714,7 +1754,8 @@ async def api_record_payment(
fiat_currency=fiat_currency, fiat_currency=fiat_currency,
fiat_amount=fiat_amount, fiat_amount=fiat_amount,
payment_hash=data.payment_hash, 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}") logger.info(f"Formatted payment entry: {entry}")
@ -1762,6 +1803,10 @@ async def api_pay_user(
fava = get_fava_client() 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( entry = format_payment_entry(
user_id=user_id, user_id=user_id,
payment_account=lightning_account.name, payment_account=lightning_account.name,
@ -1770,7 +1815,8 @@ async def api_pay_user(
description=f"Payment to user {user_id[:8]}", description=f"Payment to user {user_id[:8]}",
entry_date=datetime.now().date(), entry_date=datetime.now().date(),
is_payable=True, # Castle paying user 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 # Submit to Fava
@ -1790,7 +1836,7 @@ async def api_pay_user(
@castle_api_router.post("/api/v1/receivables/settle") @castle_api_router.post("/api/v1/receivables/settle")
async def api_settle_receivable( async def api_settle_receivable(
data: SettleReceivable, data: SettleReceivable,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> dict: ) -> dict:
""" """
Manually settle a receivable (record when user pays castle in person). Manually settle a receivable (record when user pays castle in person).
@ -1800,15 +1846,8 @@ async def api_settle_receivable(
- Bank transfers - Bank transfers
- Other manual settlements - 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 # Validate payment method
valid_methods = ["cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"] 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_currency = data.currency.upper() if data.currency else None
fiat_amount = Decimal(str(data.amount)) 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( entry = format_payment_entry(
user_id=data.user_id, user_id=data.user_id,
payment_account=payment_account.name, payment_account=payment_account.name,
@ -1906,7 +1951,8 @@ async def api_settle_receivable(
fiat_currency=fiat_currency, fiat_currency=fiat_currency,
fiat_amount=fiat_amount, fiat_amount=fiat_amount,
payment_hash=data.payment_hash, 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 # Add additional metadata to entry
@ -1938,7 +1984,7 @@ async def api_settle_receivable(
@castle_api_router.post("/api/v1/payables/pay") @castle_api_router.post("/api/v1/payables/pay")
async def api_pay_user( async def api_pay_user(
data: PayUser, data: PayUser,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> dict: ) -> dict:
""" """
Pay a user (castle pays user for expense/liability). 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 - Lightning payments: already executed, just record the payment
- Cash/Bank/Check: record manual payment that was made - 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 # Validate payment method
valid_methods = ["cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"] valid_methods = ["cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"]
@ -2049,6 +2088,12 @@ async def api_pay_user(
fiat_currency = None fiat_currency = None
fiat_amount = 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( entry = format_payment_entry(
user_id=data.user_id, user_id=data.user_id,
payment_account=payment_account.name, payment_account=payment_account.name,
@ -2060,14 +2105,15 @@ async def api_pay_user(
fiat_currency=fiat_currency, fiat_currency=fiat_currency,
fiat_amount=fiat_amount, fiat_amount=fiat_amount,
payment_hash=data.payment_hash, 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 # Add additional metadata to entry
if "meta" not in entry: if "meta" not in entry:
entry["meta"] = {} entry["meta"] = {}
entry["meta"]["payment-method"] = data.payment_method entry["meta"]["payment-method"] = data.payment_method
entry["meta"]["paid-by"] = wallet.wallet.user entry["meta"]["paid-by"] = auth.user_id
if data.txid: if data.txid:
entry["meta"]["txid"] = 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}") @castle_api_router.get("/api/v1/user-wallet/{user_id}")
async def api_get_user_wallet( async def api_get_user_wallet(
user_id: str, user_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> dict: ) -> dict:
"""Get user's wallet settings (admin only)""" """Get user's wallet settings (super user only)
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user: Supports both full UUIDs and truncated 8-char IDs (from Beancount accounts).
raise HTTPException( """
status_code=HTTPStatus.FORBIDDEN, from .crud import get_user_wallet_settings_by_prefix
detail="Only super user can access user wallet info",
)
# First try exact match
user_wallet = await get_user_wallet(user_id) 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} return {"user_id": user_id, "user_wallet_id": None}
# Get invoice key for the user's wallet (needed to generate invoices) # 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") @castle_api_router.get("/api/v1/users")
async def api_get_all_users( async def api_get_all_users(
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> list[dict]: ) -> 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 from lnbits.core.crud.users import get_user
user_settings = await get_all_user_wallet_settings() 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") @castle_api_router.get("/api/v1/admin/castle-users")
async def api_get_castle_users( async def api_get_castle_users(
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> list[dict]: ) -> list[dict]:
""" """
Get all users who have configured their wallet in Castle. Get all users who have configured their wallet in Castle.
These are users who can interact with Castle (submit expenses, receive permissions, etc.). 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 from lnbits.core.crud.users import get_user
@ -2221,10 +2274,10 @@ async def api_expense_report(
start_date: Optional[str] = None, start_date: Optional[str] = None,
end_date: Optional[str] = None, end_date: Optional[str] = None,
group_by: str = "account", group_by: str = "account",
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> dict: ) -> dict:
""" """
Get expense summary report using BQL. Get expense summary report using BQL. Super user only.
Args: Args:
start_date: Filter from this date (YYYY-MM-DD), optional 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") @castle_api_router.get("/api/v1/manual-payment-requests/all")
async def api_get_all_manual_payment_requests( async def api_get_all_manual_payment_requests(
status: str = None, status: str = None,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> list[ManualPaymentRequest]: ) -> list[ManualPaymentRequest]:
"""Get all manual payment requests (Castle admin only)""" """Get all manual payment requests (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 access this endpoint",
)
return await get_all_manual_payment_requests(status) return await get_all_manual_payment_requests(status)
@castle_api_router.post("/api/v1/manual-payment-requests/{request_id}/approve") @castle_api_router.post("/api/v1/manual-payment-requests/{request_id}/approve")
async def api_approve_manual_payment_request( async def api_approve_manual_payment_request(
request_id: str, request_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> ManualPaymentRequest: ) -> ManualPaymentRequest:
"""Approve a manual payment request and create accounting entry (Castle admin only)""" """Approve a manual payment request and create accounting entry (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 access this endpoint",
)
# Get the request # Get the request
request = await get_manual_payment_request(request_id) request = await get_manual_payment_request(request_id)
@ -2548,6 +2587,10 @@ async def api_approve_manual_payment_request(
fava = get_fava_client() 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( entry = format_payment_entry(
user_id=request.user_id, user_id=request.user_id,
payment_account=lightning_account.name, payment_account=lightning_account.name,
@ -2556,7 +2599,8 @@ async def api_approve_manual_payment_request(
description=f"Manual payment to user: {request.description}", description=f"Manual payment to user: {request.description}",
entry_date=datetime.now().date(), entry_date=datetime.now().date(),
is_payable=True, # Castle paying user is_payable=True, # Castle paying user
reference=f"MPR-{request.id}" reference=f"MPR-{request.id}",
settled_entry_links=settled_links
) )
# Submit to Fava # 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") @castle_api_router.post("/api/v1/manual-payment-requests/{request_id}/reject")
async def api_reject_manual_payment_request( async def api_reject_manual_payment_request(
request_id: str, request_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> ManualPaymentRequest: ) -> ManualPaymentRequest:
"""Reject a manual payment request (Castle admin only)""" """Reject a manual payment request (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 access this endpoint",
)
# Get the request # Get the request
request = await get_manual_payment_request(request_id) request = await get_manual_payment_request(request_id)
if not request: if not request:
@ -2598,7 +2634,7 @@ async def api_reject_manual_payment_request(
detail=f"Request already {request.status}", 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 ===== # ===== EXPENSE APPROVAL ENDPOINTS =====
@ -2607,29 +2643,22 @@ async def api_reject_manual_payment_request(
@castle_api_router.post("/api/v1/entries/{entry_id}/approve") @castle_api_router.post("/api/v1/entries/{entry_id}/approve")
async def api_approve_expense_entry( async def api_approve_expense_entry(
entry_id: str, entry_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> dict: ) -> 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. 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 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() fava = get_fava_client()
# 1. Get all journal entries from Fava # 1. Get all journal entries from Fava
all_entries = await fava.get_journal_entries() all_entries = await fava.get_journal_entries()
# 2. Find the entry with matching castle ID in links # 2. Find the entry with matching castle ID in links
target_entry_hash = None
target_entry = None target_entry = None
for entry in all_entries: for entry in all_entries:
@ -2641,51 +2670,86 @@ async def api_approve_expense_entry(
link_clean = link.lstrip('^') link_clean = link.lstrip('^')
# Check if this entry has our castle ID # Check if this entry has our castle ID
if link_clean == f"castle-{entry_id}" or link_clean.endswith(f"-{entry_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 target_entry = entry
break break
if target_entry_hash: if target_entry:
break break
if not target_entry_hash: if not target_entry:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, status_code=HTTPStatus.NOT_FOUND,
detail=f"Pending entry {entry_id} not found in Beancount ledger" detail=f"Pending entry {entry_id} not found in Beancount ledger"
) )
# 3. Get the entry context (source text + sha256sum) # Get entry metadata for file location
context = await fava.get_entry_context(target_entry_hash) meta = target_entry.get("meta", {})
source = context.get("slice", "") filename = meta.get("filename")
sha256sum = context.get("sha256sum", "") lineno = meta.get("lineno")
if not source:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Could not retrieve entry source from Fava"
)
# 4. Change flag from ! to *
# Replace the first occurrence of the date + ! pattern
import re
date_str = target_entry.get("date", "") 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( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, 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 sha256sum = source_data["sha256sum"]
await fava.update_entry_source(target_entry_hash, new_source, 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 { return {
"message": f"Entry {entry_id} approved successfully", "message": f"Entry {entry_id} approved successfully",
"entry_id": entry_id, "entry_id": entry_id,
"entry_hash": target_entry_hash,
"date": date_str, "date": date_str,
"description": target_entry.get("narration", "") "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") @castle_api_router.post("/api/v1/entries/{entry_id}/reject")
async def api_reject_expense_entry( async def api_reject_expense_entry(
entry_id: str, entry_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> dict: ) -> 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. Adds #voided tag for audit trail while keeping the '!' flag.
Voided transactions are excluded from balances but preserved in the ledger. 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 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() fava = get_fava_client()
# 1. Get all journal entries from Fava # 1. Get all journal entries from Fava
all_entries = await fava.get_journal_entries() all_entries = await fava.get_journal_entries()
# 2. Find the entry with matching castle ID in links # 2. Find the entry with matching castle ID in links
target_entry_hash = None
target_entry = None target_entry = None
for entry in all_entries: for entry in all_entries:
@ -2729,58 +2786,77 @@ async def api_reject_expense_entry(
link_clean = link.lstrip('^') link_clean = link.lstrip('^')
# Check if this entry has our castle ID # Check if this entry has our castle ID
if link_clean == f"castle-{entry_id}" or link_clean.endswith(f"-{entry_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 target_entry = entry
break break
if target_entry_hash: if target_entry:
break break
if not target_entry_hash: if not target_entry:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, status_code=HTTPStatus.NOT_FOUND,
detail=f"Pending entry {entry_id} not found in Beancount ledger" detail=f"Pending entry {entry_id} not found in Beancount ledger"
) )
# 3. Get the entry context (source text + sha256sum) # Get entry metadata for file location
context = await fava.get_entry_context(target_entry_hash) meta = target_entry.get("meta", {})
source = context.get("slice", "") filename = meta.get("filename")
sha256sum = context.get("sha256sum", "") lineno = meta.get("lineno")
if not source:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Could not retrieve entry source from Fava"
)
# 4. Add #voided tag (keep ! flag as per convention)
date_str = target_entry.get("date", "") date_str = target_entry.get("date", "")
# Add #voided tag if not already present if not filename or not lineno:
if "#voided" not in source: raise HTTPException(
# Find the transaction line and add #voided to the tags status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
# Pattern: date ! "narration" #existing-tags detail="Entry metadata missing filename or lineno"
lines = source.split('\n') )
for i, line in enumerate(lines):
if date_str in line and '"' in line and '!' in line:
# Add #voided tag to the transaction line
if '#' in line:
# Already has tags, append voided
lines[i] = line.rstrip() + ' #voided'
else:
# No tags yet, add after narration
lines[i] = line.rstrip() + ' #voided'
break
new_source = '\n'.join(lines)
else:
new_source = source
# 5. Update the entry via Fava API # 3. Get the source file from Fava
await fava.update_entry_source(target_entry_hash, new_source, sha256sum) 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 { return {
"message": f"Entry {entry_id} rejected (marked as voided)", "message": f"Entry {entry_id} rejected (marked as voided)",
"entry_id": entry_id, "entry_id": entry_id,
"entry_hash": target_entry_hash,
"date": date_str, "date": date_str,
"description": target_entry.get("narration", "") "description": target_entry.get("narration", "")
} }
@ -2792,10 +2868,10 @@ async def api_reject_expense_entry(
@castle_api_router.post("/api/v1/assertions") @castle_api_router.post("/api/v1/assertions")
async def api_create_balance_assertion( async def api_create_balance_assertion(
data: CreateBalanceAssertion, data: CreateBalanceAssertion,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> BalanceAssertion: ) -> BalanceAssertion:
""" """
Create a balance assertion for reconciliation (admin only). Create a balance assertion for reconciliation (super user only).
Uses hybrid approach: Uses hybrid approach:
1. Writes balance assertion to Beancount (via Fava) - source of truth 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. The assertion will be checked immediately upon creation.
""" """
from lnbits.settings import settings as lnbits_settings
from .fava_client import get_fava_client from .fava_client import get_fava_client
from .beancount_format import format_balance 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 # Verify account exists
account = await get_account(data.account_id) account = await get_account(data.account_id)
if not account: if not account:
@ -2845,7 +2914,7 @@ async def api_create_balance_assertion(
) )
# Store metadata in Castle DB for UI convenience # 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) # Check it immediately (queries Fava for actual balance)
try: try:
@ -2880,16 +2949,9 @@ async def api_get_balance_assertions(
account_id: str = None, account_id: str = None,
status: str = None, status: str = None,
limit: int = 100, limit: int = 100,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> list[BalanceAssertion]: ) -> list[BalanceAssertion]:
"""Get balance assertions with optional filters (admin only)""" """Get balance assertions with optional filters (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 view balance assertions",
)
# Parse status enum if provided # Parse status enum if provided
status_enum = None status_enum = None
@ -2912,17 +2974,9 @@ async def api_get_balance_assertions(
@castle_api_router.get("/api/v1/assertions/{assertion_id}") @castle_api_router.get("/api/v1/assertions/{assertion_id}")
async def api_get_balance_assertion( async def api_get_balance_assertion(
assertion_id: str, assertion_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> BalanceAssertion: ) -> BalanceAssertion:
"""Get a specific balance assertion (admin only)""" """Get a specific balance assertion (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 view balance assertions",
)
assertion = await get_balance_assertion(assertion_id) assertion = await get_balance_assertion(assertion_id)
if not assertion: if not assertion:
raise HTTPException( raise HTTPException(
@ -2936,17 +2990,9 @@ async def api_get_balance_assertion(
@castle_api_router.post("/api/v1/assertions/{assertion_id}/check") @castle_api_router.post("/api/v1/assertions/{assertion_id}/check")
async def api_check_balance_assertion( async def api_check_balance_assertion(
assertion_id: str, assertion_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> BalanceAssertion: ) -> BalanceAssertion:
"""Re-check a balance assertion (admin only)""" """Re-check a balance assertion (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 check balance assertions",
)
try: try:
assertion = await check_balance_assertion(assertion_id) assertion = await check_balance_assertion(assertion_id)
except ValueError as e: except ValueError as e:
@ -2961,17 +3007,9 @@ async def api_check_balance_assertion(
@castle_api_router.delete("/api/v1/assertions/{assertion_id}") @castle_api_router.delete("/api/v1/assertions/{assertion_id}")
async def api_delete_balance_assertion( async def api_delete_balance_assertion(
assertion_id: str, assertion_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> dict: ) -> dict:
"""Delete a balance assertion (admin only)""" """Delete a balance assertion (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 delete balance assertions",
)
# Verify it exists # Verify it exists
assertion = await get_balance_assertion(assertion_id) assertion = await get_balance_assertion(assertion_id)
if not assertion: if not assertion:
@ -2990,16 +3028,9 @@ async def api_delete_balance_assertion(
@castle_api_router.get("/api/v1/reconciliation/summary") @castle_api_router.get("/api/v1/reconciliation/summary")
async def api_get_reconciliation_summary( async def api_get_reconciliation_summary(
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> dict: ) -> dict:
"""Get reconciliation summary (admin only)""" """Get reconciliation summary (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 access reconciliation",
)
# Get all assertions # Get all assertions
all_assertions = await get_balance_assertions(limit=1000) 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") @castle_api_router.post("/api/v1/reconciliation/check-all")
async def api_check_all_assertions( async def api_check_all_assertions(
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> dict: ) -> dict:
"""Re-check all balance assertions (admin only)""" """Re-check all balance assertions (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 run reconciliation checks",
)
# Get all assertions # Get all assertions
all_assertions = await get_balance_assertions(limit=1000) 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") @castle_api_router.get("/api/v1/reconciliation/discrepancies")
async def api_get_discrepancies( async def api_get_discrepancies(
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> dict: ) -> dict:
"""Get all discrepancies (failed assertions, flagged entries) (admin only)""" """Get all discrepancies (failed assertions, flagged entries) (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 view discrepancies",
)
# Get failed assertions # Get failed assertions
failed_assertions = await get_balance_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") @castle_api_router.post("/api/v1/tasks/daily-reconciliation")
async def api_run_daily_reconciliation( async def api_run_daily_reconciliation(
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> dict: ) -> 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. This endpoint can also be called via cron job.
Returns a summary of the reconciliation check results. 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 from .tasks import check_all_balance_assertions