Compare commits
No commits in common. "main" and "v0.0.1" have entirely different histories.
10 changed files with 507 additions and 1097 deletions
310
auth.py
310
auth.py
|
|
@ -1,310 +0,0 @@
|
||||||
"""
|
|
||||||
Centralized Authorization Module for Castle Extension.
|
|
||||||
|
|
||||||
Provides consistent, secure authorization patterns across all endpoints.
|
|
||||||
|
|
||||||
Key concepts:
|
|
||||||
- AuthContext: Captures all authorization state for a request
|
|
||||||
- Dependencies: FastAPI dependencies for endpoint protection
|
|
||||||
- Permission checks: Consistent resource-level access control
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
from .auth import require_super_user, require_authenticated, AuthContext
|
|
||||||
|
|
||||||
@router.get("/api/v1/admin-endpoint")
|
|
||||||
async def admin_endpoint(auth: AuthContext = Depends(require_super_user)):
|
|
||||||
# Only super users can access
|
|
||||||
pass
|
|
||||||
|
|
||||||
@router.get("/api/v1/user-data")
|
|
||||||
async def user_data(auth: AuthContext = Depends(require_authenticated)):
|
|
||||||
# Any authenticated user
|
|
||||||
user_id = auth.user_id
|
|
||||||
pass
|
|
||||||
"""
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from functools import wraps
|
|
||||||
from http import HTTPStatus
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException
|
|
||||||
from lnbits.core.models import WalletTypeInfo
|
|
||||||
from lnbits.decorators import require_admin_key, require_invoice_key
|
|
||||||
from lnbits.settings import settings as lnbits_settings
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from .crud import get_account, get_user_permissions
|
|
||||||
from .models import PermissionType
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AuthContext:
|
|
||||||
"""
|
|
||||||
Authorization context for a request.
|
|
||||||
|
|
||||||
Contains all information needed to make authorization decisions.
|
|
||||||
Use this instead of directly accessing wallet/user properties scattered
|
|
||||||
throughout endpoint code.
|
|
||||||
"""
|
|
||||||
user_id: str
|
|
||||||
wallet_id: str
|
|
||||||
is_super_user: bool
|
|
||||||
wallet: WalletTypeInfo
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_admin(self) -> bool:
|
|
||||||
"""
|
|
||||||
Check if user is a Castle admin (super user).
|
|
||||||
|
|
||||||
Note: In Castle, admin = super_user. There's no separate admin concept.
|
|
||||||
"""
|
|
||||||
return self.is_super_user
|
|
||||||
|
|
||||||
def require_super_user(self) -> None:
|
|
||||||
"""Raise HTTPException if not super user."""
|
|
||||||
if not self.is_super_user:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.FORBIDDEN,
|
|
||||||
detail="Super user access required"
|
|
||||||
)
|
|
||||||
|
|
||||||
def require_self_or_super_user(self, target_user_id: str) -> None:
|
|
||||||
"""
|
|
||||||
Require that user is accessing their own data or is super user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
target_user_id: The user ID being accessed
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If user is neither the target nor super user
|
|
||||||
"""
|
|
||||||
if not self.is_super_user and self.user_id != target_user_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.FORBIDDEN,
|
|
||||||
detail="Access denied: you can only access your own data"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_auth_context(wallet: WalletTypeInfo) -> AuthContext:
|
|
||||||
"""Build AuthContext from wallet info."""
|
|
||||||
user_id = wallet.wallet.user
|
|
||||||
return AuthContext(
|
|
||||||
user_id=user_id,
|
|
||||||
wallet_id=wallet.wallet.id,
|
|
||||||
is_super_user=user_id == lnbits_settings.super_user,
|
|
||||||
wallet=wallet,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ===== FastAPI Dependencies =====
|
|
||||||
|
|
||||||
async def require_authenticated(
|
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
|
||||||
) -> AuthContext:
|
|
||||||
"""
|
|
||||||
Require authentication (invoice key minimum).
|
|
||||||
|
|
||||||
Returns AuthContext with user information.
|
|
||||||
Use for read-only access to user's own data.
|
|
||||||
"""
|
|
||||||
return _build_auth_context(wallet)
|
|
||||||
|
|
||||||
|
|
||||||
async def require_authenticated_write(
|
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
||||||
) -> AuthContext:
|
|
||||||
"""
|
|
||||||
Require authentication with write permissions (admin key).
|
|
||||||
|
|
||||||
Returns AuthContext with user information.
|
|
||||||
Use for write operations on user's own data.
|
|
||||||
"""
|
|
||||||
return _build_auth_context(wallet)
|
|
||||||
|
|
||||||
|
|
||||||
async def require_super_user(
|
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
||||||
) -> AuthContext:
|
|
||||||
"""
|
|
||||||
Require super user access.
|
|
||||||
|
|
||||||
Raises HTTPException 403 if not super user.
|
|
||||||
Use for Castle admin operations.
|
|
||||||
"""
|
|
||||||
auth = _build_auth_context(wallet)
|
|
||||||
if not auth.is_super_user:
|
|
||||||
logger.warning(
|
|
||||||
f"Super user access denied for user {auth.user_id[:8]} "
|
|
||||||
f"attempting admin operation"
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.FORBIDDEN,
|
|
||||||
detail="Super user access required"
|
|
||||||
)
|
|
||||||
return auth
|
|
||||||
|
|
||||||
|
|
||||||
# ===== Resource Access Checks =====
|
|
||||||
|
|
||||||
async def can_access_account(
|
|
||||||
auth: AuthContext,
|
|
||||||
account_id: str,
|
|
||||||
permission_type: PermissionType = PermissionType.READ,
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Check if user can access an account.
|
|
||||||
|
|
||||||
Access is granted if:
|
|
||||||
1. User is super user (full access)
|
|
||||||
2. User owns the account (user-specific accounts like Assets:Receivable:User-abc123)
|
|
||||||
3. User has explicit permission for the account
|
|
||||||
|
|
||||||
Args:
|
|
||||||
auth: The authorization context
|
|
||||||
account_id: The account ID to check
|
|
||||||
permission_type: The type of access needed (READ, SUBMIT_EXPENSE, MANAGE)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if access is allowed, False otherwise
|
|
||||||
"""
|
|
||||||
# Super users have full access
|
|
||||||
if auth.is_super_user:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check if this is the user's own account
|
|
||||||
account = await get_account(account_id)
|
|
||||||
if account:
|
|
||||||
user_short = auth.user_id[:8]
|
|
||||||
if f"User-{user_short}" in account.name:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check explicit permissions
|
|
||||||
permissions = await get_user_permissions(auth.user_id)
|
|
||||||
for perm in permissions:
|
|
||||||
if perm.account_id == account_id:
|
|
||||||
# Check if permission type is sufficient
|
|
||||||
if perm.permission_type == PermissionType.MANAGE:
|
|
||||||
return True # MANAGE grants all access
|
|
||||||
if perm.permission_type == permission_type:
|
|
||||||
return True
|
|
||||||
if (
|
|
||||||
permission_type == PermissionType.READ
|
|
||||||
and perm.permission_type in [PermissionType.SUBMIT_EXPENSE, PermissionType.MANAGE]
|
|
||||||
):
|
|
||||||
return True # Higher permissions include READ
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def require_account_access(
|
|
||||||
auth: AuthContext,
|
|
||||||
account_id: str,
|
|
||||||
permission_type: PermissionType = PermissionType.READ,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Require access to an account, raising HTTPException if denied.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
auth: The authorization context
|
|
||||||
account_id: The account ID to check
|
|
||||||
permission_type: The type of access needed
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If access is denied
|
|
||||||
"""
|
|
||||||
if not await can_access_account(auth, account_id, permission_type):
|
|
||||||
logger.warning(
|
|
||||||
f"Account access denied: user {auth.user_id[:8]} "
|
|
||||||
f"attempted {permission_type.value} on account {account_id}"
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.FORBIDDEN,
|
|
||||||
detail=f"Access denied to account {account_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def can_access_user_data(auth: AuthContext, target_user_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if user can access another user's data.
|
|
||||||
|
|
||||||
Access is granted if:
|
|
||||||
1. User is super user
|
|
||||||
2. User is accessing their own data
|
|
||||||
|
|
||||||
Args:
|
|
||||||
auth: The authorization context
|
|
||||||
target_user_id: The user ID whose data is being accessed
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if access is allowed
|
|
||||||
"""
|
|
||||||
if auth.is_super_user:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Users can access their own data - compare full ID or short ID
|
|
||||||
if auth.user_id == target_user_id:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Also allow if short IDs match (8 char prefix)
|
|
||||||
if auth.user_id[:8] == target_user_id[:8]:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def require_user_data_access(
|
|
||||||
auth: AuthContext,
|
|
||||||
target_user_id: str,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Require access to a user's data, raising HTTPException if denied.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
auth: The authorization context
|
|
||||||
target_user_id: The user ID whose data is being accessed
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If access is denied
|
|
||||||
"""
|
|
||||||
if not await can_access_user_data(auth, target_user_id):
|
|
||||||
logger.warning(
|
|
||||||
f"User data access denied: user {auth.user_id[:8]} "
|
|
||||||
f"attempted to access data for user {target_user_id[:8]}"
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.FORBIDDEN,
|
|
||||||
detail="Access denied: you can only access your own data"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ===== Utility Functions =====
|
|
||||||
|
|
||||||
def get_user_id_from_wallet(wallet: WalletTypeInfo) -> str:
|
|
||||||
"""
|
|
||||||
Get user ID from wallet info.
|
|
||||||
|
|
||||||
IMPORTANT: Always use wallet.wallet.user (not wallet.wallet.id).
|
|
||||||
- wallet.wallet.user = the user's ID
|
|
||||||
- wallet.wallet.id = the wallet's ID (NOT the same!)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
wallet: The wallet type info from LNbits
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The user ID
|
|
||||||
"""
|
|
||||||
return wallet.wallet.user
|
|
||||||
|
|
||||||
|
|
||||||
def is_super_user(user_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if a user ID is the super user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: The user ID to check
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if this is the super user
|
|
||||||
"""
|
|
||||||
return user_id == lnbits_settings.super_user
|
|
||||||
|
|
@ -497,8 +497,7 @@ 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).
|
||||||
|
|
@ -517,7 +516,6 @@ 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
|
||||||
|
|
@ -586,8 +584,6 @@ 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:
|
||||||
|
|
@ -598,7 +594,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", "settlement"],
|
tags=["lightning-payment"],
|
||||||
links=links,
|
links=links,
|
||||||
meta=entry_meta
|
meta=entry_meta
|
||||||
)
|
)
|
||||||
|
|
@ -717,8 +713,7 @@ 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).
|
||||||
|
|
@ -748,7 +743,6 @@ 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
|
||||||
|
|
@ -786,8 +780,6 @@ 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
20
crud.py
|
|
@ -424,26 +424,6 @@ async def get_user_wallet_settings(user_id: str) -> Optional[UserWalletSettings]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_user_wallet_settings_by_prefix(
|
|
||||||
user_id_prefix: str,
|
|
||||||
) -> Optional[StoredUserWalletSettings]:
|
|
||||||
"""
|
|
||||||
Get user wallet settings by user ID prefix (for truncated 8-char IDs from Beancount).
|
|
||||||
|
|
||||||
Beancount accounts use truncated user IDs (first 8 chars), but the database
|
|
||||||
stores full UUIDs. This function looks up by prefix to bridge the gap.
|
|
||||||
"""
|
|
||||||
return await db.fetchone(
|
|
||||||
"""
|
|
||||||
SELECT * FROM user_wallet_settings
|
|
||||||
WHERE id LIKE :prefix || '%'
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
{"prefix": user_id_prefix},
|
|
||||||
StoredUserWalletSettings,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def update_user_wallet_settings(
|
async def update_user_wallet_settings(
|
||||||
user_id: str, data: UserWalletSettings
|
user_id: str, data: UserWalletSettings
|
||||||
) -> UserWalletSettings:
|
) -> UserWalletSettings:
|
||||||
|
|
|
||||||
183
fava_client.py
183
fava_client.py
|
|
@ -10,14 +10,11 @@ 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)
|
||||||
- Getting entry context (GET /api/context)
|
- Updating/deleting entries (PUT/DELETE /api/source_slice)
|
||||||
- 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
|
||||||
|
|
@ -25,11 +22,6 @@ 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.
|
||||||
|
|
@ -54,37 +46,6 @@ 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.
|
||||||
|
|
@ -125,13 +86,7 @@ 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:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
response = await client.put(
|
response = await client.put(
|
||||||
|
|
@ -152,74 +107,6 @@ class FavaClient:
|
||||||
logger.error(f"Fava connection error: {e}")
|
logger.error(f"Fava connection error: {e}")
|
||||||
raise
|
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:
|
|
||||||
entries = await self.get_journal_entries(days=30) # Check recent entries
|
|
||||||
|
|
||||||
for existing_entry in entries:
|
|
||||||
existing_links = existing_entry.get("links", [])
|
|
||||||
if safe_key in existing_links:
|
|
||||||
logger.info(f"Entry with idempotency key '{safe_key}' already exists, skipping insert")
|
|
||||||
return {
|
|
||||||
"data": "Entry already exists (idempotent)",
|
|
||||||
"existing": True,
|
|
||||||
"entry": existing_entry
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not check for existing entry with key '{safe_key}': {e}")
|
|
||||||
# Continue anyway - Beancount will error if there's a true duplicate
|
|
||||||
|
|
||||||
# Add the idempotency key as a link if not already present
|
|
||||||
if "links" not in entry:
|
|
||||||
entry["links"] = []
|
|
||||||
if safe_key not in entry["links"]:
|
|
||||||
entry["links"].append(safe_key)
|
|
||||||
|
|
||||||
# Now add the entry (this will acquire the write lock)
|
|
||||||
result = await self.add_entry(entry)
|
|
||||||
result["existing"] = False
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def get_account_balance(self, account_name: str) -> Dict[str, Any]:
|
async def get_account_balance(self, account_name: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get balance for a specific account (excluding pending transactions).
|
Get balance for a specific account (excluding pending transactions).
|
||||||
|
|
@ -1211,8 +1098,7 @@ class FavaClient:
|
||||||
"""
|
"""
|
||||||
Get entry source text and sha256sum for editing.
|
Get entry source text and sha256sum for editing.
|
||||||
|
|
||||||
Uses /context endpoint which returns the editable source slice.
|
Uses /source_slice endpoint which returns the editable source.
|
||||||
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()
|
||||||
|
|
@ -1231,7 +1117,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}/context",
|
f"{self.base_url}/source_slice",
|
||||||
params={"entry_hash": entry_hash}
|
params={"entry_hash": entry_hash}
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
@ -1257,10 +1143,6 @@ 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")
|
||||||
|
|
@ -1273,8 +1155,6 @@ 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)
|
||||||
"""
|
"""
|
||||||
# Acquire global write lock to serialize ledger modifications
|
|
||||||
async with self._write_lock:
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
response = await client.put(
|
response = await client.put(
|
||||||
|
|
@ -1307,16 +1187,10 @@ 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"])
|
||||||
"""
|
"""
|
||||||
# Acquire global write lock to serialize ledger modifications
|
|
||||||
async with self._write_lock:
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
response = await client.delete(
|
response = await client.delete(
|
||||||
|
|
@ -1342,8 +1216,7 @@ class FavaClient:
|
||||||
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.
|
||||||
|
|
@ -1351,25 +1224,15 @@ 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(
|
||||||
|
|
@ -1389,11 +1252,6 @@ class FavaClient:
|
||||||
if opening_date is None:
|
if opening_date is None:
|
||||||
opening_date = date_type.today()
|
opening_date = date_type.today()
|
||||||
|
|
||||||
last_error = None
|
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
|
||||||
# Acquire global write lock to serialize ledger modifications
|
|
||||||
async with self._write_lock:
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
# Step 1: Get the main Beancount file path from Fava
|
# Step 1: Get the main Beancount file path from Fava
|
||||||
|
|
@ -1404,7 +1262,7 @@ class FavaClient:
|
||||||
|
|
||||||
logger.debug(f"Fava main file: {file_path}")
|
logger.debug(f"Fava main file: {file_path}")
|
||||||
|
|
||||||
# Step 2: Get current source file (fresh read on each attempt)
|
# Step 2: Get current source file
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
f"{self.base_url}/source",
|
f"{self.base_url}/source",
|
||||||
params={"filename": file_path}
|
params={"filename": file_path}
|
||||||
|
|
@ -1415,12 +1273,12 @@ class FavaClient:
|
||||||
sha256sum = source_data["sha256sum"]
|
sha256sum = source_data["sha256sum"]
|
||||||
source = source_data["source"]
|
source = source_data["source"]
|
||||||
|
|
||||||
# Step 3: Check if account already exists (may have been created by concurrent request)
|
# Step 2: Check if account already exists
|
||||||
if f"open {account_name}" in source:
|
if f"open {account_name}" in source:
|
||||||
logger.info(f"Account {account_name} already exists in Beancount file")
|
logger.info(f"Account {account_name} already exists in Beancount file")
|
||||||
return {"data": sha256sum, "mtime": source_data.get("mtime", "")}
|
return {"data": sha256sum, "mtime": source_data.get("mtime", "")}
|
||||||
|
|
||||||
# Step 4: Find insertion point (after last Open directive AND its metadata)
|
# Step 3: Find insertion point (after last Open directive AND its metadata)
|
||||||
lines = source.split('\n')
|
lines = source.split('\n')
|
||||||
insert_index = 0
|
insert_index = 0
|
||||||
for i, line in enumerate(lines):
|
for i, line in enumerate(lines):
|
||||||
|
|
@ -1431,7 +1289,7 @@ class FavaClient:
|
||||||
while insert_index < len(lines) and lines[insert_index].startswith((' ', '\t')) and lines[insert_index].strip():
|
while insert_index < len(lines) and lines[insert_index].startswith((' ', '\t')) and lines[insert_index].strip():
|
||||||
insert_index += 1
|
insert_index += 1
|
||||||
|
|
||||||
# Step 5: Format Open directive as Beancount text
|
# Step 4: Format Open directive as Beancount text
|
||||||
currencies_str = ", ".join(currencies)
|
currencies_str = ", ".join(currencies)
|
||||||
open_lines = [
|
open_lines = [
|
||||||
"",
|
"",
|
||||||
|
|
@ -1447,13 +1305,13 @@ class FavaClient:
|
||||||
else:
|
else:
|
||||||
open_lines.append(f' {key}: {value}')
|
open_lines.append(f' {key}: {value}')
|
||||||
|
|
||||||
# Step 6: Insert into source
|
# Step 5: Insert into source
|
||||||
for i, line in enumerate(open_lines):
|
for i, line in enumerate(open_lines):
|
||||||
lines.insert(insert_index + i, line)
|
lines.insert(insert_index + i, line)
|
||||||
|
|
||||||
new_source = '\n'.join(lines)
|
new_source = '\n'.join(lines)
|
||||||
|
|
||||||
# Step 7: Update source file via PUT /api/source
|
# Step 6: Update source file via PUT /api/source
|
||||||
update_payload = {
|
update_payload = {
|
||||||
"file_path": file_path,
|
"file_path": file_path,
|
||||||
"source": new_source,
|
"source": new_source,
|
||||||
|
|
@ -1472,33 +1330,12 @@ class FavaClient:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
# Check for checksum conflict (HTTP 412 Precondition Failed or similar)
|
|
||||||
if e.response.status_code in (409, 412):
|
|
||||||
last_error = ChecksumConflictError(
|
|
||||||
f"Checksum conflict on attempt {attempt + 1}/{max_retries}: {e.response.text}"
|
|
||||||
)
|
|
||||||
logger.warning(
|
|
||||||
f"Checksum conflict adding account {account_name} "
|
|
||||||
f"(attempt {attempt + 1}/{max_retries}), retrying..."
|
|
||||||
)
|
|
||||||
# Continue to retry logic below
|
|
||||||
else:
|
|
||||||
logger.error(f"Fava HTTP error adding account: {e.response.status_code} - {e.response.text}")
|
logger.error(f"Fava HTTP error adding account: {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
|
||||||
|
|
||||||
# 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,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
|
|
|
||||||
17
services.py
17
services.py
|
|
@ -18,29 +18,12 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ 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)
|
||||||
|
|
@ -58,9 +57,6 @@ 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: {
|
||||||
|
|
@ -521,9 +517,6 @@ 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() {
|
||||||
|
|
@ -541,9 +534,6 @@ 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() {
|
||||||
|
|
@ -559,14 +549,6 @@ 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(
|
||||||
|
|
@ -574,10 +556,7 @@ 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({
|
||||||
|
|
@ -1424,7 +1403,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: maxAmountSats, // Default to sats since lightning is the default payment method
|
amount: fiatCurrency ? maxAmountFiat : maxAmountSats, // Default to fiat if available
|
||||||
payment_method: 'lightning', // Default to lightning for paying
|
payment_method: 'lightning', // Default to lightning for paying
|
||||||
description: '',
|
description: '',
|
||||||
reference: '',
|
reference: '',
|
||||||
|
|
@ -1456,9 +1435,8 @@ 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.bolt11
|
const paymentRequest = invoiceResponse.data.payment_request
|
||||||
|
|
||||||
// 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(
|
||||||
|
|
|
||||||
|
|
@ -1118,3 +1118,5 @@ window.app = Vue.createApp({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
window.app.mount('#vue')
|
||||||
|
|
|
||||||
69
tasks.py
69
tasks.py
|
|
@ -187,12 +187,6 @@ 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":
|
||||||
|
|
@ -203,19 +197,41 @@ 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()
|
||||||
|
|
||||||
# Use idempotency key based on payment hash - this ensures duplicate
|
try:
|
||||||
# processing of the same payment won't create duplicate entries
|
# Check if payment already recorded by fetching recent entries
|
||||||
idempotency_key = f"ln-{payment.payment_hash[:16]}"
|
# Note: We can't use BQL query with `links ~ 'pattern'` because links is a set type
|
||||||
|
# and BQL doesn't support regex matching on sets. Instead, fetch entries and filter in Python.
|
||||||
|
link_to_find = f"ln-{payment.payment_hash[:16]}"
|
||||||
|
|
||||||
# Acquire per-user lock to serialize processing for this user
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
# This prevents race conditions when a user has multiple payments being processed
|
# Get recent entries from Fava's journal endpoint
|
||||||
user_lock = fava.get_user_lock(user_id)
|
response = await client.get(
|
||||||
|
f"{fava.base_url}/api/journal",
|
||||||
|
params={"time": ""} # Get all entries
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
entries = data.get('entries', [])
|
||||||
|
|
||||||
|
# Check if any entry has our payment link
|
||||||
|
for entry in entries:
|
||||||
|
entry_links = entry.get('links', [])
|
||||||
|
if link_to_find in entry_links:
|
||||||
|
logger.info(f"Payment {payment.payment_hash} already recorded in Fava, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not check Fava for duplicate payment: {e}")
|
||||||
|
# Continue anyway - Fava/Beancount will catch duplicate if it exists
|
||||||
|
|
||||||
async with user_lock:
|
|
||||||
logger.info(f"Recording Castle payment {payment.payment_hash} for user {user_id[:8]} to Fava")
|
logger.info(f"Recording Castle payment {payment.payment_hash} for user {user_id[:8]} to Fava")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -271,18 +287,6 @@ async def on_invoice_paid(payment: Payment) -> None:
|
||||||
logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found")
|
logger.error("Lightning account 'Assets:Bitcoin:Lightning' not found")
|
||||||
return
|
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
|
# Format as net settlement transaction
|
||||||
entry = format_net_settlement_entry(
|
entry = format_net_settlement_entry(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
|
@ -297,21 +301,12 @@ async def on_invoice_paid(payment: Payment) -> None:
|
||||||
description=f"Lightning payment settlement from user {user_id[:8]}",
|
description=f"Lightning payment settlement from user {user_id[:8]}",
|
||||||
entry_date=datetime.now().date(),
|
entry_date=datetime.now().date(),
|
||||||
payment_hash=payment.payment_hash,
|
payment_hash=payment.payment_hash,
|
||||||
reference=payment.payment_hash,
|
reference=payment.payment_hash
|
||||||
settled_entry_links=settled_links if settled_links else None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Submit to Fava using idempotent method to prevent duplicates
|
# Submit to Fava
|
||||||
# The idempotency key is based on the payment hash, so even if this
|
result = await fava.add_entry(entry)
|
||||||
# function is called multiple times for the same payment, only one
|
|
||||||
# entry will be created
|
|
||||||
result = await fava.add_entry_idempotent(entry, idempotency_key)
|
|
||||||
|
|
||||||
if result.get("existing"):
|
|
||||||
logger.info(
|
|
||||||
f"Payment {payment.payment_hash} was already recorded in Fava (idempotent)"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Successfully recorded payment {payment.payment_hash} to Fava: "
|
f"Successfully recorded payment {payment.payment_hash} to Fava: "
|
||||||
f"{result.get('data', 'Unknown')}"
|
f"{result.get('data', 'Unknown')}"
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,13 @@
|
||||||
<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">
|
||||||
<!-- Wait for settings to load before showing role-specific buttons to prevent race conditions -->
|
<q-btn v-if="!isSuperUser" flat round icon="account_balance_wallet" @click="showUserWalletDialog">
|
||||||
<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="settingsLoaded && isSuperUser" flat round icon="admin_panel_settings" :href="'/castle/permissions'">
|
<q-btn v-if="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="settingsLoaded && isSuperUser" flat round icon="settings" @click="showSettingsDialog">
|
<q-btn v-if="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>
|
||||||
|
|
@ -33,7 +32,7 @@
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
||||||
<!-- Setup Warning -->
|
<!-- Setup Warning -->
|
||||||
<q-banner v-if="settingsLoaded && !castleWalletConfigured && isSuperUser" class="bg-warning text-white" rounded>
|
<q-banner v-if="!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>
|
||||||
|
|
@ -45,7 +44,7 @@
|
||||||
</template>
|
</template>
|
||||||
</q-banner>
|
</q-banner>
|
||||||
|
|
||||||
<q-banner v-if="settingsLoaded && !castleWalletConfigured && !isSuperUser" class="bg-info text-white" rounded>
|
<q-banner v-if="!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>
|
||||||
|
|
@ -54,7 +53,7 @@
|
||||||
</div>
|
</div>
|
||||||
</q-banner>
|
</q-banner>
|
||||||
|
|
||||||
<q-banner v-if="settingsLoaded && castleWalletConfigured && !userWalletConfigured && !isSuperUser" class="bg-orange text-white" rounded>
|
<q-banner v-if="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>
|
||||||
|
|
@ -1123,46 +1122,10 @@
|
||||||
:disable="!isSuperUser"
|
:disable="!isSuperUser"
|
||||||
></q-select>
|
></q-select>
|
||||||
|
|
||||||
<div class="text-caption text-grey q-mb-md">
|
<div class="text-caption text-grey">
|
||||||
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"
|
||||||
|
|
|
||||||
528
views_api.py
528
views_api.py
|
|
@ -83,14 +83,6 @@ 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()
|
||||||
|
|
||||||
|
|
@ -284,34 +276,26 @@ 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,
|
||||||
auth: AuthContext = Depends(require_super_user),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> Account:
|
) -> Account:
|
||||||
"""Create a new account (super user only)"""
|
"""Create a new account (admin 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(
|
async def api_get_account(account_id: str) -> Account:
|
||||||
account_id: str,
|
"""Get a specific account"""
|
||||||
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(
|
async def api_get_account_balance(account_id: str) -> dict:
|
||||||
account_id: str,
|
"""Get account balance from Fava/Beancount"""
|
||||||
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
|
||||||
|
|
@ -319,9 +303,6 @@ async def api_get_account_balance(
|
||||||
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)
|
||||||
|
|
@ -335,16 +316,11 @@ async def api_get_account_balance(
|
||||||
|
|
||||||
|
|
||||||
@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(
|
async def api_get_account_transactions(account_id: str, limit: int = 100) -> list[dict]:
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -356,9 +332,6 @@ async def api_get_account_transactions(
|
||||||
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)
|
||||||
|
|
@ -370,15 +343,11 @@ async def api_get_account_transactions(
|
||||||
|
|
||||||
|
|
||||||
@castle_api_router.get("/api/v1/entries")
|
@castle_api_router.get("/api/v1/entries")
|
||||||
async def api_get_journal_entries(
|
async def api_get_journal_entries(limit: int = 100) -> list[dict]:
|
||||||
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
|
||||||
|
|
@ -752,15 +721,22 @@ 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(
|
||||||
auth: AuthContext = Depends(require_super_user),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Get all pending expense entries that need approval (super user only).
|
Get all pending expense entries that need approval (admin only).
|
||||||
|
|
||||||
Returns transactions with flag='!' from Fava/Beancount.
|
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()
|
||||||
|
|
@ -973,7 +949,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.user # Use user_id, not wallet_id
|
entry_meta["created-by"] = wallet.wallet.id
|
||||||
|
|
||||||
# Format as Beancount entry
|
# Format as Beancount entry
|
||||||
fava = get_fava_client()
|
fava = get_fava_client()
|
||||||
|
|
@ -999,7 +975,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.user, # Use user_id, not wallet_id
|
created_by=wallet.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,
|
||||||
|
|
@ -1149,8 +1125,7 @@ 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,
|
reference=castle_reference # Add castle ID as link
|
||||||
entry_id=entry_id # Pass entry_id so all links match
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Submit to Fava
|
# Submit to Fava
|
||||||
|
|
@ -1162,7 +1137,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.user, # Use user_id, not wallet_id
|
created_by=wallet.wallet.id,
|
||||||
created_at=datetime.now(),
|
created_at=datetime.now(),
|
||||||
reference=castle_reference,
|
reference=castle_reference,
|
||||||
flag=JournalEntryFlag.PENDING,
|
flag=JournalEntryFlag.PENDING,
|
||||||
|
|
@ -1277,8 +1252,7 @@ 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,
|
reference=castle_reference # Use castle reference with unique ID
|
||||||
entry_id=entry_id # Pass entry_id so all links match
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Submit to Fava
|
# Submit to Fava
|
||||||
|
|
@ -1290,7 +1264,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.user, # Use user_id, not wallet_id
|
created_by=wallet.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,
|
||||||
|
|
@ -1404,7 +1378,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.user, # Use user_id, not wallet_id
|
created_by=wallet.wallet.id,
|
||||||
created_at=datetime.now(),
|
created_at=datetime.now(),
|
||||||
reference=castle_reference,
|
reference=castle_reference,
|
||||||
flag=JournalEntryFlag.CLEARED,
|
flag=JournalEntryFlag.CLEARED,
|
||||||
|
|
@ -1468,18 +1442,8 @@ 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(
|
async def api_get_user_balance(user_id: str) -> UserBalance:
|
||||||
user_id: str,
|
"""Get a specific user's balance with the Castle (from Fava/Beancount)"""
|
||||||
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()
|
||||||
|
|
@ -1495,9 +1459,9 @@ async def api_get_user_balance(
|
||||||
|
|
||||||
@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(
|
||||||
auth: AuthContext = Depends(require_super_user),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Get all user balances (super user only) from Fava/Beancount"""
|
"""Get all user balances (admin/super user only) from Fava/Beancount"""
|
||||||
from .fava_client import get_fava_client
|
from .fava_client import get_fava_client
|
||||||
|
|
||||||
fava = get_fava_client()
|
fava = get_fava_client()
|
||||||
|
|
@ -1738,10 +1702,6 @@ 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,
|
||||||
|
|
@ -1754,8 +1714,7 @@ 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}")
|
||||||
|
|
@ -1803,10 +1762,6 @@ 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,
|
||||||
|
|
@ -1815,8 +1770,7 @@ 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
|
||||||
|
|
@ -1836,7 +1790,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,
|
||||||
auth: AuthContext = Depends(require_super_user),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Manually settle a receivable (record when user pays castle in person).
|
Manually settle a receivable (record when user pays castle in person).
|
||||||
|
|
@ -1846,8 +1800,15 @@ async def api_settle_receivable(
|
||||||
- Bank transfers
|
- Bank transfers
|
||||||
- Other manual settlements
|
- Other manual settlements
|
||||||
|
|
||||||
Super user only.
|
Admin only.
|
||||||
"""
|
"""
|
||||||
|
from lnbits.settings import settings as lnbits_settings
|
||||||
|
|
||||||
|
if wallet.wallet.user != lnbits_settings.super_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Only super user can settle receivables",
|
||||||
|
)
|
||||||
|
|
||||||
# Validate payment method
|
# Validate payment method
|
||||||
valid_methods = ["cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"]
|
valid_methods = ["cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"]
|
||||||
|
|
@ -1934,12 +1895,6 @@ 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,
|
||||||
|
|
@ -1951,8 +1906,7 @@ 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
|
||||||
|
|
@ -1984,7 +1938,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,
|
||||||
auth: AuthContext = Depends(require_super_user),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Pay a user (castle pays user for expense/liability).
|
Pay a user (castle pays user for expense/liability).
|
||||||
|
|
@ -1993,8 +1947,15 @@ 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
|
||||||
|
|
||||||
Super user only.
|
Admin only.
|
||||||
"""
|
"""
|
||||||
|
from lnbits.settings import settings as lnbits_settings
|
||||||
|
|
||||||
|
if wallet.wallet.user != lnbits_settings.super_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Only super user can pay users",
|
||||||
|
)
|
||||||
|
|
||||||
# Validate payment method
|
# Validate payment method
|
||||||
valid_methods = ["cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"]
|
valid_methods = ["cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"]
|
||||||
|
|
@ -2088,12 +2049,6 @@ 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,
|
||||||
|
|
@ -2105,15 +2060,14 @@ 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"] = auth.user_id
|
entry["meta"]["paid-by"] = wallet.wallet.user
|
||||||
if data.txid:
|
if data.txid:
|
||||||
entry["meta"]["txid"] = data.txid
|
entry["meta"]["txid"] = data.txid
|
||||||
|
|
||||||
|
|
@ -2172,26 +2126,19 @@ 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,
|
||||||
auth: AuthContext = Depends(require_super_user),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Get user's wallet settings (super user only)
|
"""Get user's wallet settings (admin only)"""
|
||||||
|
from lnbits.settings import settings as lnbits_settings
|
||||||
|
|
||||||
Supports both full UUIDs and truncated 8-char IDs (from Beancount accounts).
|
if wallet.wallet.user != lnbits_settings.super_user:
|
||||||
"""
|
raise HTTPException(
|
||||||
from .crud import get_user_wallet_settings_by_prefix
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Only super user can access user wallet info",
|
||||||
|
)
|
||||||
|
|
||||||
# First try exact match
|
|
||||||
user_wallet = await get_user_wallet(user_id)
|
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)
|
||||||
|
|
@ -2210,9 +2157,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(
|
||||||
auth: AuthContext = Depends(require_super_user),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Get all users who have configured their wallet (super user only)"""
|
"""Get all users who have configured their wallet (admin only)"""
|
||||||
from lnbits.core.crud.users import get_user
|
from lnbits.core.crud.users import get_user
|
||||||
|
|
||||||
user_settings = await get_all_user_wallet_settings()
|
user_settings = await get_all_user_wallet_settings()
|
||||||
|
|
@ -2236,12 +2183,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(
|
||||||
auth: AuthContext = Depends(require_super_user),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> 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.).
|
||||||
Super user only.
|
Admin only.
|
||||||
"""
|
"""
|
||||||
from lnbits.core.crud.users import get_user
|
from lnbits.core.crud.users import get_user
|
||||||
|
|
||||||
|
|
@ -2274,10 +2221,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",
|
||||||
auth: AuthContext = Depends(require_super_user),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Get expense summary report using BQL. Super user only.
|
Get expense summary report using BQL.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
start_date: Filter from this date (YYYY-MM-DD), optional
|
start_date: Filter from this date (YYYY-MM-DD), optional
|
||||||
|
|
@ -2538,18 +2485,32 @@ 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,
|
||||||
auth: AuthContext = Depends(require_super_user),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> list[ManualPaymentRequest]:
|
) -> list[ManualPaymentRequest]:
|
||||||
"""Get all manual payment requests (super user only)"""
|
"""Get all manual payment requests (Castle admin only)"""
|
||||||
|
from lnbits.settings import settings as lnbits_settings
|
||||||
|
|
||||||
|
if wallet.wallet.user != lnbits_settings.super_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Only super user can access this endpoint",
|
||||||
|
)
|
||||||
return await get_all_manual_payment_requests(status)
|
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,
|
||||||
auth: AuthContext = Depends(require_super_user),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> ManualPaymentRequest:
|
) -> ManualPaymentRequest:
|
||||||
"""Approve a manual payment request and create accounting entry (super user only)"""
|
"""Approve a manual payment request and create accounting entry (Castle admin only)"""
|
||||||
|
from lnbits.settings import settings as lnbits_settings
|
||||||
|
|
||||||
|
if wallet.wallet.user != lnbits_settings.super_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Only super user can access this endpoint",
|
||||||
|
)
|
||||||
|
|
||||||
# Get the request
|
# Get the request
|
||||||
request = await get_manual_payment_request(request_id)
|
request = await get_manual_payment_request(request_id)
|
||||||
|
|
@ -2587,10 +2548,6 @@ 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,
|
||||||
|
|
@ -2599,8 +2556,7 @@ 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
|
||||||
|
|
@ -2617,9 +2573,17 @@ 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,
|
||||||
auth: AuthContext = Depends(require_super_user),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> ManualPaymentRequest:
|
) -> ManualPaymentRequest:
|
||||||
"""Reject a manual payment request (super user only)"""
|
"""Reject a manual payment request (Castle admin only)"""
|
||||||
|
from lnbits.settings import settings as lnbits_settings
|
||||||
|
|
||||||
|
if wallet.wallet.user != lnbits_settings.super_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Only super user can access this endpoint",
|
||||||
|
)
|
||||||
|
|
||||||
# Get the request
|
# 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:
|
||||||
|
|
@ -2634,7 +2598,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, auth.user_id)
|
return await reject_manual_payment_request(request_id, wallet.wallet.user)
|
||||||
|
|
||||||
|
|
||||||
# ===== EXPENSE APPROVAL ENDPOINTS =====
|
# ===== EXPENSE APPROVAL ENDPOINTS =====
|
||||||
|
|
@ -2643,22 +2607,29 @@ 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,
|
||||||
auth: AuthContext = Depends(require_super_user),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Approve a pending expense entry by changing flag from '!' to '*' (super user only).
|
Approve a pending expense entry by changing flag from '!' to '*' (admin only).
|
||||||
|
|
||||||
This updates the transaction in the Beancount file via Fava API.
|
This updates the transaction in the Beancount file via Fava API.
|
||||||
"""
|
"""
|
||||||
import httpx
|
from lnbits.settings import settings as lnbits_settings
|
||||||
from .fava_client import get_fava_client
|
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:
|
||||||
|
|
@ -2670,86 +2641,51 @@ 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:
|
if target_entry_hash:
|
||||||
break
|
break
|
||||||
|
|
||||||
if not target_entry:
|
if not target_entry_hash:
|
||||||
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get entry metadata for file location
|
# 3. Get the entry context (source text + sha256sum)
|
||||||
meta = target_entry.get("meta", {})
|
context = await fava.get_entry_context(target_entry_hash)
|
||||||
filename = meta.get("filename")
|
source = context.get("slice", "")
|
||||||
lineno = meta.get("lineno")
|
sha256sum = context.get("sha256sum", "")
|
||||||
|
|
||||||
|
if not source:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Could not retrieve entry source from Fava"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Change flag from ! to *
|
||||||
|
# Replace the first occurrence of the date + ! pattern
|
||||||
|
import re
|
||||||
date_str = target_entry.get("date", "")
|
date_str = target_entry.get("date", "")
|
||||||
|
|
||||||
if not filename or not lineno:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Entry metadata missing filename or lineno"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. Get the source file from Fava
|
|
||||||
async with httpx.AsyncClient(timeout=fava.timeout) as client:
|
|
||||||
response = await client.get(
|
|
||||||
f"{fava.base_url}/source",
|
|
||||||
params={"filename": filename}
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
source_data = response.json()["data"]
|
|
||||||
|
|
||||||
sha256sum = source_data["sha256sum"]
|
|
||||||
source = source_data["source"]
|
|
||||||
lines = source.split('\n')
|
|
||||||
|
|
||||||
# 4. Find and modify the entry at the specified line
|
|
||||||
# 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} !"
|
old_pattern = f"{date_str} !"
|
||||||
if old_pattern not in entry_line:
|
new_pattern = f"{date_str} *"
|
||||||
|
|
||||||
|
if old_pattern not in source:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
detail=f"Line {lineno} does not contain expected pattern '{old_pattern}'. Found: {entry_line}"
|
detail=f"Could not find pending flag pattern '{old_pattern}' in entry source"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Replace the flag
|
new_source = source.replace(old_pattern, new_pattern, 1)
|
||||||
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
|
# 5. Update the entry via Fava API
|
||||||
new_source = '\n'.join(lines)
|
await fava.update_entry_source(target_entry_hash, new_source, sha256sum)
|
||||||
|
|
||||||
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", "")
|
||||||
}
|
}
|
||||||
|
|
@ -2758,23 +2694,30 @@ 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,
|
||||||
auth: AuthContext = Depends(require_super_user),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Reject a pending expense entry by marking it as voided (super user only).
|
Reject a pending expense entry by marking it as voided (admin only).
|
||||||
|
|
||||||
Adds #voided tag for audit trail while keeping the '!' flag.
|
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.
|
||||||
"""
|
"""
|
||||||
import httpx
|
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 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:
|
||||||
|
|
@ -2786,77 +2729,58 @@ 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:
|
if target_entry_hash:
|
||||||
break
|
break
|
||||||
|
|
||||||
if not target_entry:
|
if not target_entry_hash:
|
||||||
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get entry metadata for file location
|
# 3. Get the entry context (source text + sha256sum)
|
||||||
meta = target_entry.get("meta", {})
|
context = await fava.get_entry_context(target_entry_hash)
|
||||||
filename = meta.get("filename")
|
source = context.get("slice", "")
|
||||||
lineno = meta.get("lineno")
|
sha256sum = context.get("sha256sum", "")
|
||||||
|
|
||||||
|
if not source:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Could not retrieve entry source from Fava"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Add #voided tag (keep ! flag as per convention)
|
||||||
date_str = target_entry.get("date", "")
|
date_str = target_entry.get("date", "")
|
||||||
|
|
||||||
if not filename or not lineno:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Entry metadata missing filename or lineno"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. Get the source file from Fava
|
|
||||||
async with httpx.AsyncClient(timeout=fava.timeout) as client:
|
|
||||||
response = await client.get(
|
|
||||||
f"{fava.base_url}/source",
|
|
||||||
params={"filename": filename}
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
source_data = response.json()["data"]
|
|
||||||
|
|
||||||
sha256sum = source_data["sha256sum"]
|
|
||||||
source = source_data["source"]
|
|
||||||
lines = source.split('\n')
|
|
||||||
|
|
||||||
# 4. Find and modify the entry at the specified line - add #voided tag
|
|
||||||
entry_line_idx = lineno - 1
|
|
||||||
|
|
||||||
if entry_line_idx >= len(lines):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Line {lineno} not found in source file"
|
|
||||||
)
|
|
||||||
|
|
||||||
entry_line = lines[entry_line_idx]
|
|
||||||
|
|
||||||
# Add #voided tag if not already present
|
# Add #voided tag if not already present
|
||||||
if "#voided" not in entry_line:
|
if "#voided" not in source:
|
||||||
|
# Find the transaction line and add #voided to the tags
|
||||||
|
# Pattern: date ! "narration" #existing-tags
|
||||||
|
lines = source.split('\n')
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if date_str in line and '"' in line and '!' in line:
|
||||||
# Add #voided tag to the transaction line
|
# Add #voided tag to the transaction line
|
||||||
new_line = entry_line.rstrip() + ' #voided'
|
if '#' in line:
|
||||||
lines[entry_line_idx] = new_line
|
# Already has tags, append voided
|
||||||
|
lines[i] = line.rstrip() + ' #voided'
|
||||||
# 5. Write back the modified source
|
else:
|
||||||
|
# No tags yet, add after narration
|
||||||
|
lines[i] = line.rstrip() + ' #voided'
|
||||||
|
break
|
||||||
new_source = '\n'.join(lines)
|
new_source = '\n'.join(lines)
|
||||||
|
else:
|
||||||
|
new_source = source
|
||||||
|
|
||||||
update_response = await client.put(
|
# 5. Update the entry via Fava API
|
||||||
f"{fava.base_url}/source",
|
await fava.update_entry_source(target_entry_hash, new_source, sha256sum)
|
||||||
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", "")
|
||||||
}
|
}
|
||||||
|
|
@ -2868,10 +2792,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,
|
||||||
auth: AuthContext = Depends(require_super_user),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> BalanceAssertion:
|
) -> BalanceAssertion:
|
||||||
"""
|
"""
|
||||||
Create a balance assertion for reconciliation (super user only).
|
Create a balance assertion for reconciliation (admin 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
|
||||||
|
|
@ -2880,9 +2804,16 @@ 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:
|
||||||
|
|
@ -2914,7 +2845,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, auth.user_id)
|
assertion = await create_balance_assertion(data, wallet.wallet.user)
|
||||||
|
|
||||||
# Check it immediately (queries Fava for actual balance)
|
# Check it immediately (queries Fava for actual balance)
|
||||||
try:
|
try:
|
||||||
|
|
@ -2949,9 +2880,16 @@ 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,
|
||||||
auth: AuthContext = Depends(require_super_user),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> list[BalanceAssertion]:
|
) -> list[BalanceAssertion]:
|
||||||
"""Get balance assertions with optional filters (super user only)"""
|
"""Get balance assertions with optional filters (admin only)"""
|
||||||
|
from lnbits.settings import settings as lnbits_settings
|
||||||
|
|
||||||
|
if wallet.wallet.user != lnbits_settings.super_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Only super user can view balance assertions",
|
||||||
|
)
|
||||||
|
|
||||||
# Parse status enum if provided
|
# Parse status enum if provided
|
||||||
status_enum = None
|
status_enum = None
|
||||||
|
|
@ -2974,9 +2912,17 @@ 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,
|
||||||
auth: AuthContext = Depends(require_super_user),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> BalanceAssertion:
|
) -> BalanceAssertion:
|
||||||
"""Get a specific balance assertion (super user only)"""
|
"""Get a specific balance assertion (admin only)"""
|
||||||
|
from lnbits.settings import settings as lnbits_settings
|
||||||
|
|
||||||
|
if wallet.wallet.user != lnbits_settings.super_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Only super user can view balance assertions",
|
||||||
|
)
|
||||||
|
|
||||||
assertion = await get_balance_assertion(assertion_id)
|
assertion = await get_balance_assertion(assertion_id)
|
||||||
if not assertion:
|
if not assertion:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -2990,9 +2936,17 @@ 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,
|
||||||
auth: AuthContext = Depends(require_super_user),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> BalanceAssertion:
|
) -> BalanceAssertion:
|
||||||
"""Re-check a balance assertion (super user only)"""
|
"""Re-check a balance assertion (admin only)"""
|
||||||
|
from lnbits.settings import settings as lnbits_settings
|
||||||
|
|
||||||
|
if wallet.wallet.user != lnbits_settings.super_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Only super user can check balance assertions",
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
assertion = await check_balance_assertion(assertion_id)
|
assertion = await check_balance_assertion(assertion_id)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|
@ -3007,9 +2961,17 @@ 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,
|
||||||
auth: AuthContext = Depends(require_super_user),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Delete a balance assertion (super user only)"""
|
"""Delete a balance assertion (admin only)"""
|
||||||
|
from lnbits.settings import settings as lnbits_settings
|
||||||
|
|
||||||
|
if wallet.wallet.user != lnbits_settings.super_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Only super user can delete balance assertions",
|
||||||
|
)
|
||||||
|
|
||||||
# Verify it exists
|
# Verify it exists
|
||||||
assertion = await get_balance_assertion(assertion_id)
|
assertion = await get_balance_assertion(assertion_id)
|
||||||
if not assertion:
|
if not assertion:
|
||||||
|
|
@ -3028,9 +2990,16 @@ 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(
|
||||||
auth: AuthContext = Depends(require_super_user),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Get reconciliation summary (super user only)"""
|
"""Get reconciliation summary (admin only)"""
|
||||||
|
from lnbits.settings import settings as lnbits_settings
|
||||||
|
|
||||||
|
if wallet.wallet.user != lnbits_settings.super_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Only super user can access reconciliation",
|
||||||
|
)
|
||||||
|
|
||||||
# Get all assertions
|
# Get all assertions
|
||||||
all_assertions = await get_balance_assertions(limit=1000)
|
all_assertions = await get_balance_assertions(limit=1000)
|
||||||
|
|
@ -3079,9 +3048,16 @@ 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(
|
||||||
auth: AuthContext = Depends(require_super_user),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Re-check all balance assertions (super user only)"""
|
"""Re-check all balance assertions (admin only)"""
|
||||||
|
from lnbits.settings import settings as lnbits_settings
|
||||||
|
|
||||||
|
if wallet.wallet.user != lnbits_settings.super_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Only super user can run reconciliation checks",
|
||||||
|
)
|
||||||
|
|
||||||
# Get all assertions
|
# Get all assertions
|
||||||
all_assertions = await get_balance_assertions(limit=1000)
|
all_assertions = await get_balance_assertions(limit=1000)
|
||||||
|
|
@ -3110,9 +3086,16 @@ 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(
|
||||||
auth: AuthContext = Depends(require_super_user),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Get all discrepancies (failed assertions, flagged entries) (super user only)"""
|
"""Get all discrepancies (failed assertions, flagged entries) (admin only)"""
|
||||||
|
from lnbits.settings import settings as lnbits_settings
|
||||||
|
|
||||||
|
if wallet.wallet.user != lnbits_settings.super_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
detail="Only super user can view discrepancies",
|
||||||
|
)
|
||||||
|
|
||||||
# Get failed assertions
|
# Get failed assertions
|
||||||
failed_assertions = await get_balance_assertions(
|
failed_assertions = await get_balance_assertions(
|
||||||
|
|
@ -3140,14 +3123,21 @@ 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(
|
||||||
auth: AuthContext = Depends(require_super_user),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Manually trigger the daily reconciliation check (super user only).
|
Manually trigger the daily reconciliation check (admin only).
|
||||||
This endpoint can also be called via cron job.
|
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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue