FastAPI matches in registration order, and hierarchy was registered
~3,300 lines after the {account_id} route — every request resolved as
account_id="hierarchy" and 404'd, so the endpoint has been unreachable
since it was added. Moved above the param routes in the accounts
module, with a functional reachability test and an updated route
snapshot (literal-before-param is now asserted for both overlap
families).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
333 lines
12 KiB
Python
333 lines
12 KiB
Python
"""Libra API — accounts endpoints (moved verbatim from views_api.py)."""
|
|
from fastapi import APIRouter
|
|
|
|
from ._shared import * # noqa: F401,F403
|
|
|
|
router = APIRouter()
|
|
|
|
@router.get("/api/v1/currencies")
|
|
async def api_get_currencies() -> list[str]:
|
|
"""Get list of allowed currencies for fiat conversion"""
|
|
return allowed_currencies()
|
|
|
|
|
|
# ===== ACCOUNT ENDPOINTS =====
|
|
|
|
|
|
@router.get("/api/v1/accounts")
|
|
async def api_get_accounts(
|
|
filter_by_user: bool = False,
|
|
exclude_virtual: bool = True,
|
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
|
) -> list[Account] | list[AccountWithPermissions]:
|
|
"""
|
|
Get all accounts in the chart of accounts.
|
|
|
|
- filter_by_user: If true, only return accounts the user has permissions for
|
|
- exclude_virtual: If true, exclude virtual parent accounts (default True)
|
|
- Returns AccountWithPermissions objects when filter_by_user=true, otherwise Account objects
|
|
"""
|
|
from lnbits.settings import settings as lnbits_settings
|
|
from .. import crud
|
|
|
|
all_accounts = await get_all_accounts()
|
|
|
|
user_id = wallet.wallet.user
|
|
is_super_user = user_id == lnbits_settings.super_user
|
|
|
|
# Auto-assign default role if user has no roles (only for non-super users)
|
|
if not is_super_user:
|
|
assigned_role = await crud.auto_assign_default_role(user_id, "system")
|
|
if assigned_role:
|
|
logger.info(f"[ACCOUNTS] Auto-assigned role to user {user_id}")
|
|
|
|
# Super users bypass permission filtering - they see everything
|
|
if not filter_by_user or is_super_user:
|
|
# Filter out virtual accounts if requested (default behavior for user views)
|
|
if exclude_virtual:
|
|
all_accounts = [acc for acc in all_accounts if not acc.is_virtual]
|
|
# Return all accounts without filtering by permissions
|
|
return all_accounts
|
|
|
|
# Filter by user permissions
|
|
# NOTE: Do NOT filter out virtual accounts yet - they're needed for inheritance logic
|
|
# Get direct user permissions
|
|
user_permissions = await get_user_permissions(user_id)
|
|
|
|
# Get role-based permissions
|
|
role_permissions_list = await crud.get_user_permissions_from_roles(user_id)
|
|
# Flatten role permissions into a single list
|
|
role_perms = []
|
|
for role, perms in role_permissions_list:
|
|
role_perms.extend(perms)
|
|
|
|
# Combine direct and role-based permissions
|
|
all_permissions = list(user_permissions) + role_perms
|
|
|
|
logger.info(f"[ACCOUNTS] User {user_id} has {len(user_permissions)} direct permissions and {len(role_perms)} role permissions (total: {len(all_permissions)})")
|
|
if role_perms:
|
|
logger.info(f"[ACCOUNTS] Role permissions: {[(p.account_id, p.permission_type) for p in role_perms]}")
|
|
logger.info(f"[ACCOUNTS] Total accounts in system: {len(all_accounts)}")
|
|
if len(all_accounts) > 0:
|
|
logger.info(f"[ACCOUNTS] Sample account IDs: {[acc.id for acc in all_accounts[:5]]}")
|
|
|
|
# Get set of account IDs the user has any permission on
|
|
permitted_account_ids = {perm.account_id for perm in all_permissions}
|
|
|
|
# Build list of accounts with permission metadata
|
|
accounts_with_permissions = []
|
|
|
|
for account in all_accounts:
|
|
# Check if user has permission on this account (direct or from role)
|
|
account_perms = [
|
|
perm for perm in all_permissions if perm.account_id == account.id
|
|
]
|
|
|
|
# Check if user has inherited permission from parent account (using combined permissions)
|
|
# Check both direct and role-based permissions for parent accounts
|
|
inherited_perms = []
|
|
for perm in all_permissions:
|
|
# Get the account for this permission
|
|
perm_account = await get_account(perm.account_id)
|
|
if not perm_account:
|
|
continue
|
|
|
|
# Check if this permission's account is a parent of the current account
|
|
# e.g., "Expenses:Supplies" is parent of "Expenses:Supplies:Food"
|
|
if account.name.startswith(perm_account.name + ":"):
|
|
# Inherited permission from parent account
|
|
inherited_perms.append((perm, perm_account.name))
|
|
|
|
# Determine if account should be included
|
|
has_access = bool(account_perms) or bool(inherited_perms)
|
|
|
|
if has_access:
|
|
# Parse hierarchical account name to get parent and level
|
|
parts = account.name.split(":")
|
|
level = len(parts) - 1
|
|
parent_account = ":".join(parts[:-1]) if level > 0 else None
|
|
|
|
# Determine inherited_from (which parent account gave access)
|
|
inherited_from = None
|
|
if inherited_perms and not account_perms:
|
|
# Permission is inherited, use the parent account name
|
|
_, parent_name = inherited_perms[0]
|
|
inherited_from = parent_name
|
|
|
|
# Collect permission types for this account
|
|
permission_types = [perm.permission_type for perm in account_perms]
|
|
|
|
# Check if account has children
|
|
has_children = any(
|
|
a.name.startswith(account.name + ":") for a in all_accounts
|
|
)
|
|
|
|
accounts_with_permissions.append(
|
|
AccountWithPermissions(
|
|
id=account.id,
|
|
name=account.name,
|
|
account_type=account.account_type,
|
|
description=account.description,
|
|
user_id=account.user_id,
|
|
created_at=account.created_at,
|
|
is_active=account.is_active,
|
|
is_virtual=account.is_virtual,
|
|
user_permissions=permission_types if permission_types else None,
|
|
inherited_from=inherited_from,
|
|
parent_account=parent_account,
|
|
level=level,
|
|
has_children=has_children,
|
|
)
|
|
)
|
|
|
|
# Filter out virtual accounts if requested (after permission inheritance logic)
|
|
if exclude_virtual:
|
|
accounts_with_permissions = [
|
|
acc for acc in accounts_with_permissions if not acc.is_virtual
|
|
]
|
|
|
|
logger.info(f"[ACCOUNTS] Returning {len(accounts_with_permissions)} accounts for user {user_id}")
|
|
return accounts_with_permissions
|
|
|
|
|
|
@router.post("/api/v1/accounts", status_code=HTTPStatus.CREATED)
|
|
async def api_create_account(
|
|
data: CreateAccount,
|
|
auth: AuthContext = Depends(require_super_user),
|
|
) -> Account:
|
|
"""Create a new account (super user only)"""
|
|
from ..crud import AccountExistsError
|
|
|
|
try:
|
|
return await create_account(data)
|
|
except ValueError as e:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST, detail=str(e)
|
|
)
|
|
except AccountExistsError:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.CONFLICT,
|
|
detail=f"Account {data.name} already exists",
|
|
)
|
|
|
|
|
|
# NOTE: hierarchy must be registered BEFORE /accounts/{account_id} —
|
|
# FastAPI matches in registration order, and the param route otherwise
|
|
# swallows the literal path (it resolved as account_id="hierarchy" and
|
|
# 404'd for as long as the endpoint existed).
|
|
@router.get("/api/v1/accounts/hierarchy")
|
|
async def api_get_account_hierarchy(
|
|
root_account: str | None = None,
|
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
|
) -> list[AccountWithPermissions]:
|
|
"""
|
|
Get hierarchical account structure with user permissions.
|
|
Optionally filter by root account (e.g., "Expenses" to get all expense sub-accounts).
|
|
"""
|
|
all_accounts = await get_all_accounts()
|
|
user_id = wallet.wallet.user
|
|
user_permissions = await get_user_permissions(user_id)
|
|
|
|
# Filter by root account if specified
|
|
if root_account:
|
|
all_accounts = [
|
|
acc for acc in all_accounts
|
|
if acc.name == root_account or acc.name.startswith(root_account + ":")
|
|
]
|
|
|
|
# Build hierarchy with permission metadata
|
|
accounts_with_hierarchy = []
|
|
|
|
for account in all_accounts:
|
|
# Check if user has direct permission on this account
|
|
account_perms = [
|
|
perm for perm in user_permissions if perm.account_id == account.id
|
|
]
|
|
|
|
# Check if user has inherited permission from parent account
|
|
inherited_perms = await get_user_permissions_with_inheritance(
|
|
user_id, account.name, PermissionType.READ
|
|
)
|
|
|
|
# Parse hierarchical account name to get parent and level
|
|
parts = account.name.split(":")
|
|
level = len(parts) - 1
|
|
parent_account = ":".join(parts[:-1]) if level > 0 else None
|
|
|
|
# Determine inherited_from (which parent account gave access)
|
|
inherited_from = None
|
|
if inherited_perms and not account_perms:
|
|
# Permission is inherited, use the parent account name
|
|
_, parent_name = inherited_perms[0]
|
|
inherited_from = parent_name
|
|
|
|
# Collect permission types for this account
|
|
permission_types = [perm.permission_type for perm in account_perms]
|
|
|
|
# Check if account has children
|
|
has_children = any(
|
|
a.name.startswith(account.name + ":") for a in all_accounts
|
|
)
|
|
|
|
accounts_with_hierarchy.append(
|
|
AccountWithPermissions(
|
|
id=account.id,
|
|
name=account.name,
|
|
account_type=account.account_type,
|
|
description=account.description,
|
|
user_id=account.user_id,
|
|
created_at=account.created_at,
|
|
user_permissions=permission_types if permission_types else None,
|
|
inherited_from=inherited_from,
|
|
parent_account=parent_account,
|
|
level=level,
|
|
has_children=has_children,
|
|
)
|
|
)
|
|
|
|
# Sort by hierarchical name for natural ordering
|
|
accounts_with_hierarchy.sort(key=lambda a: a.name)
|
|
|
|
return accounts_with_hierarchy
|
|
|
|
|
|
# ===== ACCOUNT SYNC ENDPOINTS =====
|
|
|
|
|
|
@router.get("/api/v1/accounts/{account_id}")
|
|
async def api_get_account(
|
|
account_id: str,
|
|
auth: AuthContext = Depends(require_authenticated),
|
|
) -> Account:
|
|
"""Get a specific account (requires authentication and account access)"""
|
|
account = await get_account(account_id)
|
|
if not account:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Account not found"
|
|
)
|
|
# Check access permission
|
|
await require_account_access(auth, account_id, PermissionType.READ)
|
|
return account
|
|
|
|
|
|
@router.get("/api/v1/accounts/{account_id}/balance")
|
|
async def api_get_account_balance(
|
|
account_id: str,
|
|
auth: AuthContext = Depends(require_authenticated),
|
|
) -> dict:
|
|
"""Get account balance from Fava/Beancount (requires authentication and account access)"""
|
|
from ..fava_client import get_fava_client
|
|
|
|
# Get account to retrieve its name
|
|
account = await get_account(account_id)
|
|
if not account:
|
|
raise HTTPException(status_code=404, detail="Account not found")
|
|
|
|
# Check access permission
|
|
await require_account_access(auth, account_id, PermissionType.READ)
|
|
|
|
# Query Fava for balance
|
|
fava = get_fava_client()
|
|
balance_data = await fava.get_account_balance(account.name)
|
|
|
|
return {
|
|
"account_id": account_id,
|
|
"balance": balance_data["sats"], # Balance in satoshis
|
|
"fiat": float(balance_data.get("fiat", 0)), # Fiat amount
|
|
"fiat_currency": balance_data.get("fiat_currency", "EUR")
|
|
}
|
|
|
|
|
|
@router.get("/api/v1/accounts/{account_id}/transactions")
|
|
async def api_get_account_transactions(
|
|
account_id: str,
|
|
limit: int = 100,
|
|
auth: AuthContext = Depends(require_authenticated),
|
|
) -> list[dict]:
|
|
"""
|
|
Get all transactions for an account from Fava/Beancount.
|
|
|
|
Returns transactions affecting this account in reverse chronological order.
|
|
Requires authentication and account access.
|
|
"""
|
|
from ..fava_client import get_fava_client
|
|
|
|
# Get account details
|
|
account = await get_account(account_id)
|
|
if not account:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.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
|
|
fava = get_fava_client()
|
|
transactions = await fava.get_account_transactions(account.name, limit)
|
|
|
|
return transactions
|
|
|
|
|
|
# ===== JOURNAL ENTRY ENDPOINTS =====
|