Split views_api into a domain package; unshadow /accounts/hierarchy #61

Open
padreug wants to merge 3 commits from refactor/views-api-package into chore/hygiene
12 changed files with 4356 additions and 4121 deletions

View file

@ -310,6 +310,38 @@ def format_posting_simple(
}
_SYSTEM_LINK_PREFIXES = ("exp-", "rcv-", "inc-", "ln-", "libra-")
def _extract_entry_id(entry: dict) -> Optional[str]:
"""Resolve the canonical libra entry id for a Fava transaction.
The ``entry-id`` transaction metadata is the single source of truth
written by every libra entry formatter since dfdcc44. Ledger history
predating it carries only a ``libra-{id}`` link; parse that as a
fallback so old entries still resolve.
Returns None when no id can be determined (e.g. settlement/payment
transactions, which are not approvable).
"""
meta = entry.get("meta", {})
entry_id = meta.get("entry-id")
if entry_id:
return str(entry_id)
# Legacy fallback: pre-entry-id ledger history (single libra-{id} link)
links = entry.get("links", [])
if isinstance(links, (list, set)):
for link in links:
if isinstance(link, str):
link_clean = link.lstrip('^')
if link_clean.startswith("libra-"):
return link_clean[len("libra-"):]
return None
def fiat_rate_metadata(amount_sats: int, fiat_amount: Decimal) -> Dict[str, str]:
"""Exchange-rate metadata (sats per fiat unit, fiat per BTC) as exact
Decimal strings.

147
tests/test_route_table.py Normal file
View file

@ -0,0 +1,147 @@
"""Route-table snapshot guard for the views_api package split.
FastAPI matches routes in registration order, so a mechanical move of
endpoints between modules can silently change which endpoint answers a
path (literal vs {param} siblings). This snapshot pins the ordered
(methods, path, endpoint) table; the split must reproduce it exactly.
Regenerate after a DELIBERATE routing change with:
from libra.views_api import libra_api_router
for r in libra_api_router.routes:
print((",".join(sorted(r.methods)), r.path, r.endpoint.__name__))
"""
import importlib
import pytest
def _module(name: str):
for prefix in ("lnbits.extensions.libra", "libra"):
try:
return importlib.import_module(f"{prefix}.{name}")
except ModuleNotFoundError:
continue
raise ModuleNotFoundError(f"libra.{name}: tried both import paths")
views_api = _module("views_api")
EXPECTED_ROUTES = [
("GET", "/api/v1/currencies", "api_get_currencies"),
("GET", "/api/v1/accounts", "api_get_accounts"),
("POST", "/api/v1/accounts", "api_create_account"),
("GET", "/api/v1/accounts/hierarchy", "api_get_account_hierarchy"),
("GET", "/api/v1/accounts/{account_id}", "api_get_account"),
("GET", "/api/v1/accounts/{account_id}/balance", "api_get_account_balance"),
("GET", "/api/v1/accounts/{account_id}/transactions", "api_get_account_transactions"),
("GET", "/api/v1/entries", "api_get_journal_entries"),
("GET", "/api/v1/entries/user", "api_get_user_entries"),
("GET", "/api/v1/entries/pending", "api_get_pending_entries"),
("POST", "/api/v1/entries", "api_create_journal_entry"),
("POST", "/api/v1/entries/expense", "api_create_expense_entry"),
("POST", "/api/v1/entries/income", "api_create_income_entry"),
("POST", "/api/v1/entries/receivable", "api_create_receivable_entry"),
("POST", "/api/v1/entries/revenue", "api_create_revenue_entry"),
("POST", "/api/v1/entries/{entry_id}/approve", "api_approve_expense_entry"),
("POST", "/api/v1/entries/{entry_id}/reject", "api_reject_expense_entry"),
("GET", "/api/v1/balance", "api_get_my_balance"),
("GET", "/api/v1/balance/{user_id}", "api_get_user_balance"),
("GET", "/api/v1/balances/all", "api_get_all_balances"),
("POST", "/api/v1/generate-payment-invoice", "api_generate_payment_invoice"),
("POST", "/api/v1/record-payment", "api_record_payment"),
("POST", "/api/v1/receivables/settle", "api_settle_receivable"),
("POST", "/api/v1/payables/pay", "api_pay_user"),
("POST", "/api/v1/manual-payment-request", "api_create_manual_payment_request"),
("GET", "/api/v1/manual-payment-requests", "api_get_manual_payment_requests"),
("GET", "/api/v1/manual-payment-requests/all", "api_get_all_manual_payment_requests"),
("POST", "/api/v1/manual-payment-requests/{request_id}/approve", "api_approve_manual_payment_request"),
("POST", "/api/v1/manual-payment-requests/{request_id}/reject", "api_reject_manual_payment_request"),
("GET", "/api/v1/settings", "api_get_settings"),
("PUT", "/api/v1/settings", "api_update_settings"),
("GET", "/api/v1/user-wallet/{user_id}", "api_get_user_wallet"),
("GET", "/api/v1/users", "api_get_all_users"),
("GET", "/api/v1/admin/libra-users", "api_get_libra_users"),
("GET", "/api/v1/reports/expenses", "api_expense_report"),
("GET", "/api/v1/reports/contributions", "api_contributions_report"),
("GET", "/api/v1/users/{user_id}/unsettled-entries", "api_get_unsettled_entries"),
("GET", "/api/v1/user/wallet", "api_get_user_wallet"),
("PUT", "/api/v1/user/wallet", "api_update_user_wallet"),
("GET", "/api/v1/user/info", "api_get_user_info"),
("POST", "/api/v1/assertions", "api_create_balance_assertion"),
("GET", "/api/v1/assertions", "api_get_balance_assertions"),
("GET", "/api/v1/assertions/{assertion_id}", "api_get_balance_assertion"),
("POST", "/api/v1/assertions/{assertion_id}/check", "api_check_balance_assertion"),
("DELETE", "/api/v1/assertions/{assertion_id}", "api_delete_balance_assertion"),
("GET", "/api/v1/reconciliation/summary", "api_get_reconciliation_summary"),
("POST", "/api/v1/reconciliation/check-all", "api_check_all_assertions"),
("GET", "/api/v1/reconciliation/discrepancies", "api_get_discrepancies"),
("POST", "/api/v1/tasks/daily-reconciliation", "api_run_daily_reconciliation"),
("POST", "/api/v1/admin/equity-eligibility", "api_grant_equity_eligibility"),
("DELETE", "/api/v1/admin/equity-eligibility/{user_id}", "api_revoke_equity_eligibility"),
("GET", "/api/v1/admin/equity-eligibility", "api_list_equity_eligible_users"),
("POST", "/api/v1/admin/permissions", "api_grant_permission"),
("GET", "/api/v1/admin/permissions", "api_list_permissions"),
("DELETE", "/api/v1/admin/permissions/{permission_id}", "api_revoke_permission"),
("POST", "/api/v1/admin/permissions/bulk", "api_bulk_grant_permissions"),
("POST", "/api/v1/admin/permissions/bulk-grant", "api_bulk_grant_permission_to_users"),
("GET", "/api/v1/users/me/permissions", "api_get_user_permissions"),
("POST", "/api/v1/admin/accounts", "api_admin_add_chart_account"),
("POST", "/api/v1/admin/accounts/sync", "api_sync_all_accounts"),
("POST", "/api/v1/admin/accounts/sync/{account_name:path}", "api_sync_single_account"),
("GET", "/api/v1/admin/roles", "api_get_all_roles"),
("POST", "/api/v1/admin/roles", "api_create_role"),
("GET", "/api/v1/admin/roles/{role_id}", "api_get_role"),
("PUT", "/api/v1/admin/roles/{role_id}", "api_update_role"),
("DELETE", "/api/v1/admin/roles/{role_id}", "api_delete_role"),
("POST", "/api/v1/admin/roles/{role_id}/permissions", "api_add_role_permission"),
("DELETE", "/api/v1/admin/roles/{role_id}/permissions/{permission_id}", "api_delete_role_permission"),
("POST", "/api/v1/admin/user-roles", "api_assign_user_role"),
("GET", "/api/v1/admin/user-roles/{user_id}", "api_get_user_roles"),
("DELETE", "/api/v1/admin/user-roles/{user_role_id}", "api_revoke_user_role"),
("GET", "/api/v1/admin/users/roles", "api_get_all_user_roles"),
("GET", "/api/v1/users/me/roles", "api_get_my_roles"),
]
def test_route_table_matches_snapshot():
actual = [
(",".join(sorted(r.methods)), r.path, r.endpoint.__name__)
for r in views_api.libra_api_router.routes
]
assert actual == EXPECTED_ROUTES
def _index(path: str) -> int:
paths = [p for _, p, _ in EXPECTED_ROUTES]
return paths.index(path)
def test_overlapping_route_order_is_preserved():
"""Only relative order among OVERLAPPING patterns is behavior;
these are the two overlap families in the table. The package split
keeps each family inside one module so include order can't reorder
them."""
# The literal must precede the {param} sibling or it is unreachable.
assert _index("/api/v1/accounts/hierarchy") < _index(
"/api/v1/accounts/{account_id}"
)
assert _index("/api/v1/admin/accounts/sync") < _index(
"/api/v1/admin/accounts/sync/{account_name:path}"
)
@pytest.mark.anyio
async def test_accounts_hierarchy_is_reachable(
client, configured_user, standard_accounts,
):
"""GET /accounts/hierarchy must reach the hierarchy endpoint. It was
registered after /accounts/{account_id} since it was added, so every
request resolved as account_id="hierarchy" and 404'd."""
_, wallet = configured_user
r = await client.get(
"/libra/api/v1/accounts/hierarchy",
headers={"X-Api-Key": wallet.inkey},
)
assert r.status_code == 200, f"hierarchy shadowed again? {r.status_code} {r.text}"
assert isinstance(r.json(), list)

File diff suppressed because it is too large Load diff

31
views_api/__init__.py Normal file
View file

@ -0,0 +1,31 @@
"""Libra API endpoints, split by domain.
Each module registers full literal paths on its own APIRouter; this
package includes them in a canonical order pinned by
tests/test_route_table.py. Route ORDER matters only for overlapping
patterns (literal vs {param} siblings) those live within a single
module so their relative order is stable regardless of include order.
"""
from fastapi import APIRouter
from . import (
accounts,
entries,
payments,
settings_reports,
reconciliation,
permissions,
admin,
)
libra_api_router = APIRouter()
for _module in (
accounts,
entries,
payments,
settings_reports,
reconciliation,
permissions,
admin,
):
libra_api_router.include_router(_module.router)

177
views_api/_shared.py Normal file
View file

@ -0,0 +1,177 @@
"""Shared imports and helpers for the views_api package.
Everything module-level that the pre-split views_api.py defined above
its first route lives here; endpoint modules pull it in with a
wildcard import plus explicit imports for underscore-prefixed names
(which `import *` does not export).
"""
from datetime import datetime
from decimal import Decimal
from http import HTTPStatus
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
from lnbits.core.models import User, WalletTypeInfo
from lnbits.decorators import (
check_super_user,
check_user_exists,
require_invoice_key,
)
from lnbits.utils.exchange_rates import allowed_currencies, fiat_amount_as_satoshis
from ..account_utils import VALID_ACCOUNT_PREFIXES, validate_account_name
from ..beancount_format import (
_SYSTEM_LINK_PREFIXES,
_extract_entry_id,
fiat_rate_metadata,
)
from ..user_lookup import get_username
from ..crud import (
approve_manual_payment_request,
check_balance_assertion,
create_account,
create_account_permission,
create_balance_assertion,
create_manual_payment_request,
db,
delete_account_permission,
delete_balance_assertion,
get_account,
get_account_by_name,
get_account_permission,
get_account_permissions,
get_all_accounts,
get_all_manual_payment_requests,
get_all_user_wallet_settings,
get_balance_assertion,
get_balance_assertions,
get_manual_payment_request,
get_or_create_user_account,
get_user_manual_payment_requests,
get_user_permissions,
get_user_permissions_with_inheritance,
reject_manual_payment_request,
)
from ..models import (
Account,
AccountPermission,
AccountType,
AccountWithPermissions,
AssertionStatus,
AssignUserRole,
BalanceAssertion,
BulkGrantPermission,
BulkGrantResult,
LibraSettings,
CreateAccount,
CreateAccountPermission,
CreateChartAccount,
CreateBalanceAssertion,
CreateEntryLine,
CreateJournalEntry,
CreateManualPaymentRequest,
CreateRole,
CreateRolePermission,
CreateUserEquityStatus,
ExpenseEntry,
GeneratePaymentInvoice,
IncomeEntry,
JournalEntry,
JournalEntryFlag,
ManualPaymentRequest,
PayUser,
PermissionType,
ReceivableEntry,
RecordPayment,
RevenueEntry,
Role,
RolePermission,
RoleWithPermissions,
SettleReceivable,
UpdateRole,
UserBalance,
UserEquityStatus,
UserInfo,
UserRole,
UserWalletSettings,
UserWithRoles,
)
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,
)
# Synthetic Beancount flags marking auto-generated entries (summarization,
# padding, transfers, conversions, unrealized gains, returns, merging) that
# should not appear in user-facing transaction lists. Mirrors Fava's
# _EXCL_FLAGS in fava/core/file.py.
_SYNTHETIC_FLAGS = frozenset({"S", "T", "C", "P", "U", "R", "M"})
# ===== HELPER FUNCTIONS =====
async def check_libra_wallet_configured() -> str:
"""Ensure libra wallet is configured, return wallet_id"""
settings = await get_settings("admin")
if not settings or not settings.libra_wallet_id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Libra wallet not configured. Please contact the super user to configure the Libra wallet in settings.",
)
return settings.libra_wallet_id
async def check_user_wallet_configured(user_id: str) -> str:
"""Ensure user has configured their wallet, return wallet_id"""
from lnbits.settings import settings as lnbits_settings
# If user is super user, use the libra wallet
if user_id == lnbits_settings.super_user:
libra_settings = await get_settings("admin")
if libra_settings and libra_settings.libra_wallet_id:
return libra_settings.libra_wallet_id
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Libra wallet not configured. Please configure the Libra wallet in settings.",
)
# For regular users, check their personal wallet
user_wallet = await get_user_wallet(user_id)
if not user_wallet or not user_wallet.user_wallet_id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="You must configure your wallet in settings before using this feature.",
)
return user_wallet.user_wallet_id
# ===== UTILITY ENDPOINTS =====
_VALID_ACCOUNT_PREFIXES = VALID_ACCOUNT_PREFIXES
def _validate_account_name(name: str) -> None:
"""Raise HTTP 400 if ``name`` is not a syntactically valid Beancount account.
Thin HTTP wrapper around account_utils.validate_account_name the
single source of truth for account-name syntax (libra-#51).
"""
try:
validate_account_name(name)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(e),
)

333
views_api/accounts.py Normal file
View file

@ -0,0 +1,333 @@
"""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 =====

535
views_api/admin.py Normal file
View file

@ -0,0 +1,535 @@
"""Libra API — admin endpoints (moved verbatim from views_api.py)."""
from fastapi import APIRouter
from ._shared import * # noqa: F401,F403
from ._shared import _VALID_ACCOUNT_PREFIXES, _validate_account_name # noqa: F401
router = APIRouter()
@router.post("/api/v1/admin/accounts", status_code=HTTPStatus.CREATED)
async def api_admin_add_chart_account(
payload: CreateChartAccount,
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Add a chart-of-accounts entry (super-user only).
Writes an Open directive to accounts/chart.beancount via Fava's /api/source,
then syncs the account into Libra's DB so permissions can be granted on it.
Per-user accounts (matching :User-xxxxxxxx) take a different code path via
crud.get_or_create_user_account and are not created through this endpoint.
"""
from ..fava_client import get_fava_client
if not payload.name.startswith(_VALID_ACCOUNT_PREFIXES):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
f"Account name must start with one of "
f"{', '.join(_VALID_ACCOUNT_PREFIXES)} (got {payload.name!r})"
),
)
_validate_account_name(payload.name)
logger.info(
f"Admin {auth.user_id[:8]} adding chart account {payload.name} "
f"with currencies {payload.currencies}"
)
fava = get_fava_client()
metadata: dict = {"added_by": auth.user_id[:8], "source": "admin-ui"}
if payload.description:
metadata["description"] = payload.description
result = await fava.add_account(
account_name=payload.name,
currencies=payload.currencies,
target_file="accounts/chart.beancount",
metadata=metadata,
)
from ..account_sync import sync_single_account_from_beancount
if result.get("already_existed"):
# The Open directive is already in the ledger. If it's also already
# mirrored into libra's DB, it's a true duplicate → 409. If not (a prior
# sync failed — there's no cross-DB atomicity — or it was opened out of
# band), mirror it now so it becomes grantable instead of being stranded
# with no recovery path.
from ..crud import get_account_by_name
if await get_account_by_name(payload.name) is not None:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail=f"Account {payload.name} already exists",
)
synced = await sync_single_account_from_beancount(payload.name)
return {
"success": True,
"account_name": payload.name,
"synced_to_libra_db": synced,
"already_existed": True,
}
# Mirror into libra DB so permissions / metadata layer sees it. We just
# wrote the Open directive ourselves, so skip the verification
# round-trip through Fava (libra-#53).
synced = await sync_single_account_from_beancount(
payload.name,
description=payload.description,
assume_exists=True,
)
return {
"success": True,
"account_name": payload.name,
"synced_to_libra_db": synced,
}
@router.post("/api/v1/admin/accounts/sync")
async def api_sync_all_accounts(
force_full_sync: bool = False,
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Sync all accounts from Beancount to Libra DB (admin only).
This ensures Libra DB has metadata entries for all accounts that exist
in Beancount, enabling permissions and user associations to work properly.
Args:
force_full_sync: If True, re-check all accounts. If False, only add new ones.
Returns:
Sync statistics: {total_beancount_accounts, accounts_added, accounts_skipped, errors}
"""
from ..account_sync import sync_accounts_from_beancount
logger.info(f"Admin {auth.user_id[:8]} triggered account sync (force={force_full_sync})")
try:
stats = await sync_accounts_from_beancount(force_full_sync=force_full_sync)
logger.info(f"Account sync complete: {stats['accounts_added']} added, {stats['accounts_skipped']} skipped")
return stats
except Exception as e:
logger.error(f"Account sync failed: {e}")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Account sync failed: {str(e)}"
)
@router.post("/api/v1/admin/accounts/sync/{account_name:path}")
async def api_sync_single_account(
account_name: str,
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Sync a single account from Beancount to Libra DB (admin only).
Useful for ensuring a specific account exists in Libra DB before
granting permissions on it.
Args:
account_name: Hierarchical account name (e.g., "Expenses:Food:Groceries")
Returns:
{success: bool, account_name: str, message: str}
"""
from ..account_sync import sync_single_account_from_beancount
logger.info(f"Admin {auth.user_id[:8]} triggered sync for account: {account_name}")
try:
created = await sync_single_account_from_beancount(account_name)
if created:
return {
"success": True,
"account_name": account_name,
"message": f"Account '{account_name}' synced successfully"
}
else:
return {
"success": False,
"account_name": account_name,
"message": f"Account '{account_name}' already exists or not found in Beancount"
}
except Exception as e:
logger.error(f"Single account sync failed for {account_name}: {e}")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Account sync failed: {str(e)}"
)
# ===== RBAC (ROLE-BASED ACCESS CONTROL) ENDPOINTS =====
@router.get("/api/v1/admin/roles")
async def api_get_all_roles(
auth: AuthContext = Depends(require_super_user),
) -> list:
"""Get all roles (admin only)"""
from .. import crud
roles = await crud.get_all_roles()
# Enrich each role with user count and permission count
enriched_roles = []
for role in roles:
user_count = await crud.get_user_count_for_role(role.id)
permissions = await crud.get_role_permissions(role.id)
enriched_roles.append({
"id": role.id,
"name": role.name,
"description": role.description,
"is_default": role.is_default,
"created_by": role.created_by,
"created_at": role.created_at.isoformat(),
"user_count": user_count,
"permission_count": len(permissions),
})
return enriched_roles
@router.post("/api/v1/admin/roles", status_code=HTTPStatus.CREATED)
async def api_create_role(
data: CreateRole,
auth: AuthContext = Depends(require_super_user),
):
"""Create a new role (admin only)"""
from .. import crud
try:
role = await crud.create_role(data, created_by=auth.user_id)
return {
"id": role.id,
"name": role.name,
"description": role.description,
"is_default": role.is_default,
"created_by": role.created_by,
"created_at": role.created_at.isoformat(),
}
except Exception as e:
logger.error(f"Failed to create role: {e}")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Failed to create role: {str(e)}"
)
@router.get("/api/v1/admin/roles/{role_id}")
async def api_get_role(
role_id: str,
auth: AuthContext = Depends(require_super_user),
):
"""Get a specific role with its permissions and users (admin only)"""
from .. import crud
role = await crud.get_role(role_id)
if not role:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Role {role_id} not found"
)
permissions = await crud.get_role_permissions(role.id)
user_roles = await crud.get_role_users(role.id)
return {
"id": role.id,
"name": role.name,
"description": role.description,
"is_default": role.is_default,
"created_by": role.created_by,
"created_at": role.created_at.isoformat(),
"permissions": [
{
"id": p.id,
"account_id": p.account_id,
"permission_type": p.permission_type.value,
"notes": p.notes,
"created_at": p.created_at.isoformat(),
}
for p in permissions
],
"users": [
{
"id": ur.id,
"user_id": ur.user_id,
"granted_by": ur.granted_by,
"granted_at": ur.granted_at.isoformat(),
"expires_at": ur.expires_at.isoformat() if ur.expires_at else None,
"notes": ur.notes,
}
for ur in user_roles
],
}
@router.put("/api/v1/admin/roles/{role_id}")
async def api_update_role(
role_id: str,
data: UpdateRole,
auth: AuthContext = Depends(require_super_user),
):
"""Update a role (admin only)"""
from .. import crud
role = await crud.update_role(role_id, data)
if not role:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Role {role_id} not found"
)
return {
"id": role.id,
"name": role.name,
"description": role.description,
"is_default": role.is_default,
"created_by": role.created_by,
"created_at": role.created_at.isoformat(),
}
@router.delete("/api/v1/admin/roles/{role_id}")
async def api_delete_role(
role_id: str,
auth: AuthContext = Depends(require_super_user),
):
"""Delete a role (admin only) - cascades to role_permissions and user_roles"""
from .. import crud
role = await crud.get_role(role_id)
if not role:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Role {role_id} not found"
)
await crud.delete_role(role_id)
return {"success": True, "message": f"Role '{role.name}' deleted successfully"}
# ===== ROLE PERMISSION ENDPOINTS =====
@router.post("/api/v1/admin/roles/{role_id}/permissions", status_code=HTTPStatus.CREATED)
async def api_add_role_permission(
role_id: str,
data: CreateRolePermission,
auth: AuthContext = Depends(require_super_user),
):
"""Add a permission to a role (admin only)"""
from .. import crud
# Verify role exists
role = await crud.get_role(role_id)
if not role:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Role {role_id} not found"
)
# Ensure data has correct role_id
data.role_id = role_id
try:
permission = await crud.create_role_permission(data)
return {
"id": permission.id,
"role_id": permission.role_id,
"account_id": permission.account_id,
"permission_type": permission.permission_type.value,
"notes": permission.notes,
"created_at": permission.created_at.isoformat(),
}
except Exception as e:
logger.error(f"Failed to add role permission: {e}")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Failed to add permission: {str(e)}"
)
@router.delete("/api/v1/admin/roles/{role_id}/permissions/{permission_id}")
async def api_delete_role_permission(
role_id: str,
permission_id: str,
auth: AuthContext = Depends(require_super_user),
):
"""Remove a permission from a role (admin only)"""
from .. import crud
await crud.delete_role_permission(permission_id)
return {"success": True, "message": "Permission removed from role"}
# ===== USER ROLE ASSIGNMENT ENDPOINTS =====
@router.post("/api/v1/admin/user-roles", status_code=HTTPStatus.CREATED)
async def api_assign_user_role(
data: AssignUserRole,
auth: AuthContext = Depends(require_super_user),
):
"""Assign a user to a role (admin only)"""
from .. import crud
# Verify role exists
role = await crud.get_role(data.role_id)
if not role:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Role {data.role_id} not found"
)
try:
user_role = await crud.assign_user_role(data, granted_by=auth.user_id)
return {
"id": user_role.id,
"user_id": user_role.user_id,
"role_id": user_role.role_id,
"granted_by": user_role.granted_by,
"granted_at": user_role.granted_at.isoformat(),
"expires_at": user_role.expires_at.isoformat() if user_role.expires_at else None,
"notes": user_role.notes,
}
except Exception as e:
logger.error(f"Failed to assign user role: {e}")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Failed to assign role: {str(e)}"
)
@router.get("/api/v1/admin/user-roles/{user_id}")
async def api_get_user_roles(
user_id: str,
auth: AuthContext = Depends(require_super_user),
):
"""Get all roles assigned to a user (admin only)"""
from .. import crud
user_roles = await crud.get_user_roles(user_id)
# Enrich with role details
enriched = []
for ur in user_roles:
role = await crud.get_role(ur.role_id)
if role:
enriched.append({
"user_role_id": ur.id,
"user_id": ur.user_id,
"role": {
"id": role.id,
"name": role.name,
"description": role.description,
"is_default": role.is_default,
},
"granted_by": ur.granted_by,
"granted_at": ur.granted_at.isoformat(),
"expires_at": ur.expires_at.isoformat() if ur.expires_at else None,
"notes": ur.notes,
})
return enriched
@router.delete("/api/v1/admin/user-roles/{user_role_id}")
async def api_revoke_user_role(
user_role_id: str,
auth: AuthContext = Depends(require_super_user),
):
"""Revoke a user's role assignment (admin only)"""
from .. import crud
await crud.revoke_user_role(user_role_id)
return {"success": True, "message": "Role assignment revoked"}
@router.get("/api/v1/admin/users/roles")
async def api_get_all_user_roles(
auth: AuthContext = Depends(require_super_user),
):
"""Get all user role assignments (admin only)"""
from .. import crud
user_roles = await crud.get_all_user_roles()
return [
{
"id": ur.id,
"user_id": ur.user_id,
"role_id": ur.role_id,
"granted_by": ur.granted_by,
"granted_at": ur.granted_at.isoformat(),
"expires_at": ur.expires_at.isoformat() if ur.expires_at else None,
"notes": ur.notes,
}
for ur in user_roles
]
@router.get("/api/v1/users/me/roles")
async def api_get_my_roles(
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
"""Get current user's roles and effective permissions"""
from .. import crud
user_id = wallet.wallet.user
# Get user's roles
user_roles = await crud.get_user_roles(user_id)
# Get permissions from roles
role_permissions_list = await crud.get_user_permissions_from_roles(user_id)
# Get direct permissions
direct_permissions = await crud.get_user_permissions(user_id)
# Build response
roles_data = []
for ur in user_roles:
role = await crud.get_role(ur.role_id)
if role:
permissions = await crud.get_role_permissions(role.id)
roles_data.append({
"role": {
"id": role.id,
"name": role.name,
"description": role.description,
},
"permissions": [
{
"account_id": p.account_id,
"permission_type": p.permission_type.value,
}
for p in permissions
],
"granted_at": ur.granted_at.isoformat(),
"expires_at": ur.expires_at.isoformat() if ur.expires_at else None,
})
return {
"roles": roles_data,
"direct_permissions": [
{
"id": p.id,
"account_id": p.account_id,
"permission_type": p.permission_type.value,
"granted_at": p.granted_at.isoformat(),
"expires_at": p.expires_at.isoformat() if p.expires_at else None,
"notes": p.notes,
}
for p in direct_permissions
],
}

1209
views_api/entries.py Normal file

File diff suppressed because it is too large Load diff

979
views_api/payments.py Normal file
View file

@ -0,0 +1,979 @@
"""Libra API — payments endpoints (moved verbatim from views_api.py)."""
from fastapi import APIRouter
from ._shared import * # noqa: F401,F403
router = APIRouter()
@router.get("/api/v1/balance")
async def api_get_my_balance(
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> UserBalance:
"""Get current user's balance with the organization (from Fava/Beancount)"""
from lnbits.settings import settings as lnbits_settings
from ..fava_client import get_fava_client
fava = get_fava_client()
# If super user, show total libra position
if wallet.wallet.user == lnbits_settings.super_user:
all_balances = await fava.get_all_user_balances_bql()
# Calculate total:
# From get_user_balance_bql(): positive = user owes libra, negative = libra owes user
# Positive balances = Users owe Libra (receivables for Libra)
# Negative balances = Libra owes users (liabilities for Libra)
# Net: positive means libra is owed money, negative means libra owes money
total_receivables = sum(b["balance"] for b in all_balances if b["balance"] > 0)
total_liabilities = sum(abs(b["balance"]) for b in all_balances if b["balance"] < 0)
net_balance = total_receivables - total_liabilities
# Aggregate fiat balances from all users
total_fiat_balances = {}
for user_balance in all_balances:
for currency, amount in user_balance["fiat_balances"].items():
if currency not in total_fiat_balances:
total_fiat_balances[currency] = Decimal("0")
# Add all balances (positive and negative)
total_fiat_balances[currency] += amount
# Super-user totals reflect their personal submissions (if any), not org-wide
super_totals = await fava.get_user_lifetime_totals_bql(wallet.wallet.user)
# Return net position
return UserBalance(
user_id=wallet.wallet.user,
balance=net_balance,
accounts=[],
fiat_balances=total_fiat_balances,
total_expenses_sats=super_totals["total_expenses_sats"],
total_expenses_fiat=super_totals["total_expenses_fiat"],
total_income_sats=super_totals["total_income_sats"],
total_income_fiat=super_totals["total_income_fiat"],
)
# For regular users, show their individual balance from Fava
balance_data = await fava.get_user_balance_bql(wallet.wallet.user)
totals = await fava.get_user_lifetime_totals_bql(wallet.wallet.user)
return UserBalance(
user_id=wallet.wallet.user,
balance=balance_data["balance"],
accounts=[],
account_balances=balance_data.get("accounts", []),
fiat_balances=balance_data["fiat_balances"],
total_expenses_sats=totals["total_expenses_sats"],
total_expenses_fiat=totals["total_expenses_fiat"],
total_income_sats=totals["total_income_sats"],
total_income_fiat=totals["total_income_fiat"],
)
@router.get("/api/v1/balance/{user_id}")
async def api_get_user_balance(
user_id: str,
auth: AuthContext = Depends(require_authenticated),
) -> UserBalance:
"""
Get a specific user's balance with the organization (from Fava/Beancount).
Users can only access their own balance. Super users can access any user's balance.
"""
# Check access: must be own data or super user
await require_user_data_access(auth, user_id)
from ..fava_client import get_fava_client
fava = get_fava_client()
balance_data = await fava.get_user_balance_bql(user_id)
totals = await fava.get_user_lifetime_totals_bql(user_id)
return UserBalance(
user_id=user_id,
balance=balance_data["balance"],
accounts=[],
fiat_balances=balance_data["fiat_balances"],
total_expenses_sats=totals["total_expenses_sats"],
total_expenses_fiat=totals["total_expenses_fiat"],
total_income_sats=totals["total_income_sats"],
total_income_fiat=totals["total_income_fiat"],
)
@router.get("/api/v1/balances/all")
async def api_get_all_balances(
auth: AuthContext = Depends(require_super_user),
) -> list[dict]:
"""Get all user balances (super user only) from Fava/Beancount"""
from ..fava_client import get_fava_client
fava = get_fava_client()
balances = await fava.get_all_user_balances_bql()
# Enrich with username information using helper function
result = []
for balance in balances:
username = await get_username(balance["user_id"])
result.append({
"user_id": balance["user_id"],
"username": username,
"balance": balance["balance"],
"fiat_balances": balance["fiat_balances"],
"accounts": balance["accounts"],
})
return result
# ===== PAYMENT ENDPOINTS =====
@router.post("/api/v1/generate-payment-invoice")
async def api_generate_payment_invoice(
data: GeneratePaymentInvoice,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> dict:
"""
Generate an invoice on the Libra wallet for user to pay their balance.
User can then pay this invoice to settle their debt.
If user_id is provided (admin only), the invoice is generated for that specific user.
"""
from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models import CreateInvoice
from lnbits.core.services import create_payment_request
from lnbits.settings import settings as lnbits_settings
# Determine which user this invoice is for
if data.user_id:
# Admin generating invoice for a specific user
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can generate invoices for other users",
)
target_user_id = data.user_id
else:
# User generating invoice for themselves
target_user_id = wallet.wallet.user
# Get libra wallet ID
libra_wallet_id = await check_libra_wallet_configured()
# Get user's balance from Fava to calculate fiat metadata
from ..fava_client import get_fava_client
fava = get_fava_client()
balance_data = await fava.get_user_balance_bql(target_user_id)
# Build UserBalance object for compatibility
user_balance = UserBalance(
user_id=target_user_id,
balance=balance_data["balance"],
accounts=[],
fiat_balances=balance_data["fiat_balances"]
)
# Calculate proportional fiat amount for this invoice
invoice_extra = {"tag": "libra", "user_id": target_user_id}
logger.info(f"User balance for invoice generation - sats: {user_balance.balance}, fiat_balances: {user_balance.fiat_balances}")
if user_balance.fiat_balances:
# Simple single-currency solution: use the first (and should be only) currency
currencies = list(user_balance.fiat_balances.keys())
if len(currencies) > 1:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"User has multiple currencies ({', '.join(currencies)}). Please settle to a single currency first.",
)
if len(currencies) == 1:
fiat_currency = currencies[0]
total_fiat_balance = user_balance.fiat_balances[fiat_currency]
total_sat_balance = abs(user_balance.balance) # Use absolute value
if total_sat_balance > 0:
# Calculate proportional fiat amount for this invoice
# fiat_amount = (invoice_amount / total_sats) * total_fiat
from decimal import Decimal
proportion = Decimal(data.amount) / Decimal(total_sat_balance)
invoice_fiat_amount = abs(total_fiat_balance) * proportion
invoice_extra.update({
"fiat_currency": fiat_currency,
"fiat_amount": str(invoice_fiat_amount.quantize(Decimal("0.001"))),
**fiat_rate_metadata(data.amount, invoice_fiat_amount),
})
logger.info(f"Invoice extra metadata: {invoice_extra}")
# Create invoice on libra wallet
invoice_data = CreateInvoice(
out=False,
amount=data.amount,
memo=f"Payment from user {target_user_id[:8]} to Libra",
unit="sat",
extra=invoice_extra,
)
payment = await create_payment_request(libra_wallet_id, invoice_data)
# Get libra wallet to return its inkey for payment checking
libra_wallet = await get_wallet(libra_wallet_id)
if not libra_wallet:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Libra wallet not found"
)
return {
"payment_hash": payment.payment_hash,
"payment_request": payment.bolt11,
"amount": data.amount,
"memo": invoice_data.memo,
"check_wallet_key": libra_wallet.inkey, # Key to check payment status
}
@router.post("/api/v1/record-payment")
async def api_record_payment(
data: RecordPayment,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> dict:
"""
Record a lightning payment in accounting after invoice is paid.
This reduces what the user owes to the libra.
The user_id is extracted from the payment metadata (set during invoice generation).
"""
from lnbits.core.crud.payments import get_standalone_payment
# Get the payment details (incoming=True to get the invoice, not the payment)
payment = await get_standalone_payment(data.payment_hash, incoming=True)
if not payment:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Payment not found"
)
if payment.pending:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Payment not yet settled"
)
# Get user_id from payment metadata (set during invoice generation)
target_user_id = None
if payment.extra and isinstance(payment.extra, dict):
target_user_id = payment.extra.get("user_id")
if not target_user_id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Payment metadata missing user_id. Cannot determine which user to credit.",
)
# Check if payment already recorded in Fava (idempotency)
from ..fava_client import get_fava_client
from ..beancount_format import format_payment_entry
import httpx
fava = get_fava_client()
# Check if payment already recorded by fetching recent entries
# Note: We can't use BQL query with `links ~ 'pattern'` because links is a set type
# and BQL doesn't support regex matching on sets. Instead, fetch entries and filter in Python.
link_to_find = f"ln-{data.payment_hash[:16]}"
try:
async with httpx.AsyncClient(timeout=5.0) as client:
# Get recent entries from Fava's journal endpoint. base_url
# already ends in /api — the previous "/api/journal" path
# 404'd, so this duplicate check silently never ran.
response = await client.get(
f"{fava.base_url}/journal",
params={"time": ""} # Get all entries
)
response.raise_for_status()
response_data = response.json()
entries = response_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:
# Payment already recorded, return existing entry
balance_data = await fava.get_user_balance_bql(target_user_id)
return {
"journal_entry_id": f"fava-exists-{data.payment_hash[:16]}",
"new_balance": balance_data["balance"],
"message": "Payment already recorded",
}
except httpx.HTTPError as e:
# Fail CLOSED: if Fava can't confirm the payment isn't already
# recorded, refuse to write — proceeding on a transient blip is
# how double entries happen. The client can simply retry.
logger.warning(f"Could not check Fava for duplicate payment: {e}")
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail="Cannot verify payment duplicate status; try again shortly",
)
# Local idempotency gate shared with the background invoice listener
# (tasks.on_invoice_paid): exactly one claimant records a payment_hash.
from ..crud import (
claim_payment,
get_processed_payment,
mark_payment_done,
release_payment_claim,
)
if not await claim_payment(data.payment_hash):
existing = await get_processed_payment(data.payment_hash)
if existing and existing["status"] == "done":
balance_data = await fava.get_user_balance_bql(target_user_id)
return {
"journal_entry_id": existing.get("entry_id")
or f"fava-exists-{data.payment_hash[:16]}",
"new_balance": balance_data["balance"],
"message": "Payment already recorded",
}
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail="Payment is being recorded; check balance shortly",
)
# Convert amount from millisatoshis to satoshis
try:
amount_sats = payment.amount // 1000
# Extract fiat metadata from invoice (if present)
fiat_currency = None
fiat_amount = None
if payment.extra and isinstance(payment.extra, dict):
logger.info(f"Payment.extra contents: {payment.extra}")
fiat_currency = payment.extra.get("fiat_currency")
fiat_amount_str = payment.extra.get("fiat_amount")
if fiat_amount_str:
from decimal import Decimal
fiat_amount = Decimal(str(fiat_amount_str))
logger.info(f"Extracted fiat metadata - currency: {fiat_currency}, amount: {fiat_amount}")
# Get user's receivable account (what user owes)
user_receivable = await get_or_create_user_account(
target_user_id, AccountType.ASSET, "Accounts Receivable"
)
# Get lightning account
lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
if not lightning_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Lightning account not found"
)
# Get unsettled receivable entries to link to this settlement
unsettled = await fava.get_unsettled_entries_bql(target_user_id, "receivable")
settled_links = [e["link"] for e in unsettled if e.get("link")]
# Format payment entry and submit to Fava
entry = format_payment_entry(
user_id=target_user_id,
payment_account=lightning_account.name,
payable_or_receivable_account=user_receivable.name,
amount_sats=amount_sats,
description=f"Lightning payment from user {target_user_id[:8]}",
entry_date=datetime.now().date(),
is_payable=False, # User paying libra (receivable settlement)
fiat_currency=fiat_currency,
fiat_amount=fiat_amount,
payment_hash=data.payment_hash,
reference=data.payment_hash,
settled_entry_links=settled_links
)
logger.info(f"Formatted payment entry: {entry}")
# Submit to Fava
result = await fava.add_entry(entry)
logger.info(f"Payment entry submitted to Fava: {result.get('data', 'Unknown')}")
entry_id = f"ln-{data.payment_hash[:16]}"
await mark_payment_done(data.payment_hash, entry_id)
except BaseException:
# Release the claim so a retry (client or background listener)
# can record this payment.
await release_payment_claim(data.payment_hash)
raise
# Get updated balance from Fava
balance_data = await fava.get_user_balance_bql(target_user_id)
return {
"journal_entry_id": entry_id,
"new_balance": balance_data["balance"],
"message": "Payment recorded successfully",
}
@router.post("/api/v1/receivables/settle")
async def api_settle_receivable(
data: SettleReceivable,
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Manually settle a receivable (record when user pays libra in person).
This endpoint is for non-lightning payments like:
- Cash payments
- Bank transfers
- Other manual settlements
Super user only.
"""
# Validate payment method
valid_methods = ["cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"]
if data.payment_method.lower() not in valid_methods:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Invalid payment method. Must be one of: {', '.join(valid_methods)}",
)
# Get user's receivable account (what user owes)
user_receivable = await get_or_create_user_account(
data.user_id, AccountType.ASSET, "Accounts Receivable"
)
# Get the appropriate asset account based on payment method
payment_account_map = {
"cash": "Assets:Cash",
"bank_transfer": "Assets:Bank",
"check": "Assets:Bank",
"lightning": "Assets:Bitcoin:Lightning",
"btc_onchain": "Assets:Bitcoin:OnChain",
"other": "Assets:Cash"
}
account_name = payment_account_map.get(data.payment_method.lower(), "Assets:Cash")
payment_account = await get_account_by_name(account_name)
# If account doesn't exist, try to find or create a generic one
if not payment_account:
# Try to find any asset account that's not receivable
all_accounts = await get_all_accounts()
for acc in all_accounts:
if acc.account_type == AccountType.ASSET and "receivable" not in acc.name.lower():
payment_account = acc
break
if not payment_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Payment account '{account_name}' not found. Please create it first.",
)
# Format settlement entry and submit to Fava
# DR Cash/Bank (asset increased), CR Accounts Receivable (asset decreased)
# This records that user paid their debt
from ..fava_client import get_fava_client
from ..beancount_format import (
format_payment_entry,
format_fiat_settlement_entry,
format_fiat_net_settlement_entry,
)
from decimal import Decimal
fava = get_fava_client()
# Determine if this is a fiat or lightning payment
is_fiat_payment = data.currency and data.payment_method.lower() in [
"cash", "bank_transfer", "check", "other"
]
if is_fiat_payment and data.settled_entry_links is None:
# Auto-detect netting + credit-overflow path (libra-#33 + libra-#41).
# The operator hasn't picked specific entries — backend nets all
# open balances in both directions, validates cash matches the net
# obligation (or absorbs excess into credit), and writes a single
# transaction that links every reconciled source entry.
unsettled_payables = await fava.get_unsettled_entries_bql(data.user_id, "expense")
unsettled_receivables = await fava.get_unsettled_entries_bql(data.user_id, "receivable")
# Net only entries denominated in the settlement currency — summing
# mixed currencies as if they were one silently mis-states the net
# obligation and links entries this settlement doesn't actually clear.
settle_currency = data.currency.upper()
unsettled_payables = [
e for e in unsettled_payables
if e.get("fiat_currency") == settle_currency
]
unsettled_receivables = [
e for e in unsettled_receivables
if e.get("fiat_currency") == settle_currency
]
payable_total = sum(
(Decimal(str(e["fiat_amount"])) for e in unsettled_payables),
Decimal(0),
)
receivable_total = sum(
(Decimal(str(e["fiat_amount"])) for e in unsettled_receivables),
Decimal(0),
)
all_links = (
[e["link"] for e in unsettled_payables if e.get("link")]
+ [e["link"] for e in unsettled_receivables if e.get("link")]
)
if receivable_total <= 0:
# Endpoint is `/receivables/settle` — user paying off something
# they owe. With no open receivable there's nothing this endpoint
# can settle. Operator should use `/payables/pay` (libra pays user)
# or wait until the user has open receivables.
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
f"User {data.user_id[:8]} has no open receivables to settle. "
f"If libra owes them, use `/payables/pay`. If they want to "
f"deposit credit without an open obligation, that's a future "
f"feature (libra-#41 follow-up)."
),
)
cash_paid = Decimal(str(data.amount))
net_obligation = receivable_total - payable_total
tolerance = Decimal("0.01") # forex rounding slack
if cash_paid + tolerance < net_obligation:
# Under-pay without explicit entry-picks — backend can't guess
# which receivable(s) the operator means to settle.
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail={
"message": (
"Cash paid is less than net obligation. Pay the exact "
"net to clear all open entries, or pass "
"`settled_entry_links` to settle a specific subset."
),
"cash_paid": str(cash_paid),
"net_obligation": str(net_obligation),
"receivable_total": str(receivable_total),
"payable_total": str(payable_total),
"currency": data.currency.upper(),
},
)
credit_overflow = cash_paid - net_obligation
if credit_overflow < tolerance:
credit_overflow = Decimal(0)
# Auto-create the user-side accounts as needed.
user_payable = None
if payable_total > 0:
user_payable = await get_or_create_user_account(
data.user_id, AccountType.LIABILITY, "Accounts Payable",
)
user_credit = None
if credit_overflow > 0:
user_credit = await get_or_create_user_account(
data.user_id, AccountType.LIABILITY, "Credit",
)
entry = format_fiat_net_settlement_entry(
user_id=data.user_id,
cash_account=payment_account.name,
receivable_account=user_receivable.name,
payable_account=user_payable.name if user_payable else None,
credit_account=user_credit.name if user_credit else None,
cash_paid_fiat=cash_paid,
total_receivable_fiat=receivable_total,
total_payable_fiat=payable_total,
credit_overflow_fiat=credit_overflow,
fiat_currency=data.currency.upper(),
description=data.description,
entry_date=datetime.now().date(),
payment_method=data.payment_method,
reference=data.reference or f"MANUAL-{data.user_id[:8]}",
settled_entry_links=all_links,
)
elif is_fiat_payment:
# Legacy fiat path — operator provided `settled_entry_links` explicitly,
# meaning they're settling a specific subset. Backwards-compatible
# 2-leg behaviour: trust the caller's list, no auto-netting, no
# credit-overflow validation. Use the auto-detect path above (omit
# settled_entry_links) to get netting + credit handling.
if not data.amount_sats:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="amount_sats is required when settling with fiat currency"
)
entry = format_fiat_settlement_entry(
user_id=data.user_id,
payment_account=payment_account.name,
payable_or_receivable_account=user_receivable.name,
fiat_amount=Decimal(str(data.amount)),
fiat_currency=data.currency.upper(),
amount_sats=data.amount_sats,
description=data.description,
entry_date=datetime.now().date(),
is_payable=False, # User paying libra (receivable settlement)
payment_method=data.payment_method,
reference=data.reference or f"MANUAL-{data.user_id[:8]}",
settled_entry_links=data.settled_entry_links
)
else:
# Lightning or BTC onchain payment
# Record in SATS with optional fiat metadata
amount_in_sats = data.amount_sats if data.amount_sats else int(data.amount)
fiat_currency = data.currency.upper() if data.currency else None
fiat_amount = Decimal(str(data.amount)) if data.currency else None
# Get settled entry links (use provided or auto-query unsettled)
settled_links = data.settled_entry_links
if not settled_links:
unsettled = await fava.get_unsettled_entries_bql(data.user_id, "receivable")
settled_links = [e["link"] for e in unsettled if e.get("link")]
entry = format_payment_entry(
user_id=data.user_id,
payment_account=payment_account.name,
payable_or_receivable_account=user_receivable.name,
amount_sats=amount_in_sats,
description=data.description,
entry_date=datetime.now().date(),
is_payable=False, # User paying libra (receivable settlement)
fiat_currency=fiat_currency,
fiat_amount=fiat_amount,
payment_hash=data.payment_hash,
reference=data.reference or f"MANUAL-{data.user_id[:8]}",
settled_entry_links=settled_links
)
# Add additional metadata to entry
if "meta" not in entry:
entry["meta"] = {}
entry["meta"]["payment-method"] = data.payment_method
entry["meta"]["settled-by"] = auth.user_id
if data.txid:
entry["meta"]["txid"] = data.txid
# Submit to Fava
result = await fava.add_entry(entry)
logger.info(f"Receivable settlement submitted to Fava: {result.get('data', 'Unknown')}")
# Get updated balance from Fava
balance_data = await fava.get_user_balance_bql(data.user_id)
return {
"journal_entry_id": f"fava-{datetime.now().timestamp()}",
"user_id": data.user_id,
"amount_settled": float(data.amount),
"currency": data.currency,
"payment_method": data.payment_method,
"new_balance": balance_data["balance"],
"message": f"Receivable settled successfully via {data.payment_method}",
}
@router.post("/api/v1/payables/pay")
async def api_pay_user(
data: PayUser,
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Pay a user (libra pays user for expense/liability).
This endpoint is for both lightning and manual payments:
- Lightning payments: already executed, just record the payment
- Cash/Bank/Check: record manual payment that was made
Super user only.
"""
# Validate payment method
valid_methods = ["cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"]
if data.payment_method.lower() not in valid_methods:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Invalid payment method. Must be one of: {', '.join(valid_methods)}",
)
# Get user's payable account (what libra owes)
user_payable = await get_or_create_user_account(
data.user_id, AccountType.LIABILITY, "Accounts Payable"
)
# Get the appropriate asset account based on payment method
payment_account_map = {
"cash": "Assets:Cash",
"bank_transfer": "Assets:Bank",
"check": "Assets:Bank",
"lightning": "Assets:Bitcoin:Lightning",
"btc_onchain": "Assets:Bitcoin:OnChain",
"other": "Assets:Cash"
}
account_name = payment_account_map.get(data.payment_method.lower(), "Assets:Cash")
payment_account = await get_account_by_name(account_name)
if not payment_account:
# Try to find any asset account that's not receivable
all_accounts = await get_all_accounts()
for acc in all_accounts:
if acc.account_type == AccountType.ASSET and "receivable" not in acc.name.lower():
payment_account = acc
break
if not payment_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Payment account '{account_name}' not found. Please create it first.",
)
# Format payment entry and submit to Fava
# DR Accounts Payable (liability decreased), CR Cash/Lightning/Bank (asset decreased)
# This records that libra paid its debt
from ..fava_client import get_fava_client
from ..beancount_format import format_payment_entry, format_fiat_settlement_entry
from decimal import Decimal
fava = get_fava_client()
# Determine if this is a fiat payment that should use format_fiat_settlement_entry
is_fiat_payment = data.currency and data.payment_method.lower() in [
"cash", "bank_transfer", "check", "other"
]
if is_fiat_payment:
# Fiat currency payment (cash, bank transfer, etc.)
if not data.amount_sats:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="amount_sats is required when paying with fiat currency"
)
entry = format_fiat_settlement_entry(
user_id=data.user_id,
payment_account=payment_account.name,
payable_or_receivable_account=user_payable.name,
fiat_amount=Decimal(str(data.amount)),
fiat_currency=data.currency.upper(),
amount_sats=data.amount_sats,
description=data.description or f"Payment to user via {data.payment_method}",
entry_date=datetime.now().date(),
is_payable=True, # Libra paying user (payable settlement)
payment_method=data.payment_method,
reference=data.reference or f"PAY-{data.user_id[:8]}",
settled_entry_links=data.settled_entry_links
)
else:
# Lightning or BTC onchain payment
if data.currency:
if not data.amount_sats:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="amount_sats is required when paying with fiat currency"
)
amount_in_sats = data.amount_sats
fiat_currency = data.currency.upper()
fiat_amount = data.amount
else:
amount_in_sats = int(data.amount)
fiat_currency = None
fiat_amount = None
# Get settled entry links (use provided or auto-query unsettled)
settled_links = data.settled_entry_links
if not settled_links:
unsettled = await fava.get_unsettled_entries_bql(data.user_id, "expense")
settled_links = [e["link"] for e in unsettled if e.get("link")]
entry = format_payment_entry(
user_id=data.user_id,
payment_account=payment_account.name,
payable_or_receivable_account=user_payable.name,
amount_sats=amount_in_sats,
description=data.description or f"Payment to user via {data.payment_method}",
entry_date=datetime.now().date(),
is_payable=True, # Libra paying user (payable settlement)
fiat_currency=fiat_currency,
fiat_amount=fiat_amount,
payment_hash=data.payment_hash,
reference=data.reference or f"PAY-{data.user_id[:8]}",
settled_entry_links=settled_links
)
# Add additional metadata to entry
if "meta" not in entry:
entry["meta"] = {}
entry["meta"]["payment-method"] = data.payment_method
entry["meta"]["paid-by"] = auth.user_id
if data.txid:
entry["meta"]["txid"] = data.txid
# Submit to Fava
result = await fava.add_entry(entry)
logger.info(f"Payable payment submitted to Fava: {result.get('data', 'Unknown')}")
# Get updated balance from Fava
balance_data = await fava.get_user_balance_bql(data.user_id)
return {
"journal_entry_id": f"fava-{datetime.now().timestamp()}",
"user_id": data.user_id,
"amount_paid": float(data.amount),
"currency": data.currency,
"payment_method": data.payment_method,
"new_balance": balance_data["balance"],
"message": f"User paid successfully via {data.payment_method}",
}
# ===== SETTINGS ENDPOINTS =====
@router.post("/api/v1/manual-payment-request")
async def api_create_manual_payment_request(
data: CreateManualPaymentRequest,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> ManualPaymentRequest:
"""Create a manual payment request for an admin to review"""
return await create_manual_payment_request(
wallet.wallet.user, data.amount, data.description
)
@router.get("/api/v1/manual-payment-requests")
async def api_get_manual_payment_requests(
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> list[ManualPaymentRequest]:
"""Get manual payment requests for the current user"""
return await get_user_manual_payment_requests(wallet.wallet.user)
@router.get("/api/v1/manual-payment-requests/all")
async def api_get_all_manual_payment_requests(
status: str = None,
auth: AuthContext = Depends(require_super_user),
) -> list[ManualPaymentRequest]:
"""Get all manual payment requests (super user only)"""
return await get_all_manual_payment_requests(status)
@router.post("/api/v1/manual-payment-requests/{request_id}/approve")
async def api_approve_manual_payment_request(
request_id: str,
auth: AuthContext = Depends(require_super_user),
) -> ManualPaymentRequest:
"""Approve a manual payment request and create accounting entry (super user only)"""
# Get the request
request = await get_manual_payment_request(request_id)
if not request:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Manual payment request not found",
)
if request.status != "pending":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Request already {request.status}",
)
# Get libra wallet from settings
libra_wallet_id = await check_libra_wallet_configured()
# Get or create liability account for user (libra owes the user)
liability_account = await get_or_create_user_account(
request.user_id, AccountType.LIABILITY, "Accounts Payable"
)
# Get the Lightning asset account
lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
if not lightning_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Lightning account not found",
)
# Format payment entry and submit to Fava
from ..fava_client import get_fava_client
from ..beancount_format import format_payment_entry
fava = get_fava_client()
# Get unsettled expense entries to link to this settlement
unsettled = await fava.get_unsettled_entries_bql(request.user_id, "expense")
settled_links = [e["link"] for e in unsettled if e.get("link")]
entry = format_payment_entry(
user_id=request.user_id,
payment_account=lightning_account.name,
payable_or_receivable_account=liability_account.name,
amount_sats=request.amount,
description=f"Manual payment to user: {request.description}",
entry_date=datetime.now().date(),
is_payable=True, # Libra paying user
reference=f"MPR-{request.id}",
settled_entry_links=settled_links
)
# Claim the request BEFORE writing the ledger entry — the
# status-guarded UPDATE makes exactly one concurrent admin win, so
# only one journal entry can ever be created for this request.
approved = await approve_manual_payment_request(
request_id, auth.user_id, f"MPR-{request.id}"
)
if approved is None:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail="Request was already reviewed by another admin",
)
try:
result = await fava.add_entry(entry)
logger.info(f"Manual payment entry submitted to Fava: {result.get('data', 'Unknown')}")
except BaseException:
# Ledger write failed — release the claim so the request can be
# approved again.
from ..crud import revert_manual_payment_request
await revert_manual_payment_request(request_id)
raise
return approved
@router.post("/api/v1/manual-payment-requests/{request_id}/reject")
async def api_reject_manual_payment_request(
request_id: str,
auth: AuthContext = Depends(require_super_user),
) -> ManualPaymentRequest:
"""Reject a manual payment request (super user only)"""
# Get the request
request = await get_manual_payment_request(request_id)
if not request:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Manual payment request not found",
)
if request.status != "pending":
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Request already {request.status}",
)
rejected = await reject_manual_payment_request(request_id, auth.user_id)
if rejected is None:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail="Request was already reviewed by another admin",
)
return rejected
# ===== EXPENSE APPROVAL ENDPOINTS =====

210
views_api/permissions.py Normal file
View file

@ -0,0 +1,210 @@
"""Libra API — permissions endpoints (moved verbatim from views_api.py)."""
from fastapi import APIRouter
from ._shared import * # noqa: F401,F403
router = APIRouter()
@router.post("/api/v1/admin/equity-eligibility", status_code=HTTPStatus.CREATED)
async def api_grant_equity_eligibility(
data: CreateUserEquityStatus,
auth: AuthContext = Depends(require_super_user),
) -> UserEquityStatus:
"""Grant equity contribution eligibility to a user (admin only)"""
from ..crud import create_or_update_user_equity_status
return await create_or_update_user_equity_status(data, auth.user_id)
@router.delete("/api/v1/admin/equity-eligibility/{user_id}")
async def api_revoke_equity_eligibility(
user_id: str,
auth: AuthContext = Depends(require_super_user),
) -> UserEquityStatus:
"""Revoke equity contribution eligibility from a user (admin only)"""
from ..crud import revoke_user_equity_eligibility
result = await revoke_user_equity_eligibility(user_id)
if not result:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"User {user_id} not found in equity status records",
)
return result
@router.get("/api/v1/admin/equity-eligibility")
async def api_list_equity_eligible_users(
auth: AuthContext = Depends(require_super_user),
) -> list[UserEquityStatus]:
"""List all equity-eligible users (admin only)"""
from ..crud import get_all_equity_eligible_users
return await get_all_equity_eligible_users()
# ===== ACCOUNT PERMISSION ADMIN ENDPOINTS =====
@router.post("/api/v1/admin/permissions", status_code=HTTPStatus.CREATED)
async def api_grant_permission(
data: CreateAccountPermission,
auth: AuthContext = Depends(require_super_user),
) -> AccountPermission:
"""Grant account permission to a user (admin only)"""
# Validate that account exists
account = await get_account(data.account_id)
if not account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Account with ID '{data.account_id}' not found",
)
return await create_account_permission(data, auth.user_id)
@router.get("/api/v1/admin/permissions")
async def api_list_permissions(
user_id: str | None = None,
account_id: str | None = None,
auth: AuthContext = Depends(require_super_user),
) -> list[AccountPermission]:
"""
List account permissions (admin only).
Can filter by user_id or account_id.
"""
if user_id:
return await get_user_permissions(user_id)
elif account_id:
return await get_account_permissions(account_id)
else:
# Get all permissions (get all users' permissions)
# This is a bit inefficient but works for admin overview
all_accounts = await get_all_accounts()
all_permissions = []
for account in all_accounts:
account_perms = await get_account_permissions(account.id)
all_permissions.extend(account_perms)
# Deduplicate by permission ID
seen_ids = set()
unique_permissions = []
for perm in all_permissions:
if perm.id not in seen_ids:
seen_ids.add(perm.id)
unique_permissions.append(perm)
return unique_permissions
@router.delete("/api/v1/admin/permissions/{permission_id}")
async def api_revoke_permission(
permission_id: str,
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""Revoke (delete) an account permission (admin only)"""
# Verify permission exists
permission = await get_account_permission(permission_id)
if not permission:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Permission with ID '{permission_id}' not found",
)
await delete_account_permission(permission_id)
return {
"success": True,
"message": f"Permission {permission_id} revoked successfully",
}
@router.post("/api/v1/admin/permissions/bulk", status_code=HTTPStatus.CREATED)
async def api_bulk_grant_permissions(
permissions: list[CreateAccountPermission],
auth: AuthContext = Depends(require_super_user),
) -> list[AccountPermission]:
"""Grant multiple account permissions at once (admin only)"""
created_permissions = []
for perm_data in permissions:
# Validate that account exists
account = await get_account(perm_data.account_id)
if not account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Account with ID '{perm_data.account_id}' not found",
)
perm = await create_account_permission(perm_data, auth.user_id)
created_permissions.append(perm)
return created_permissions
@router.post("/api/v1/admin/permissions/bulk-grant", status_code=HTTPStatus.CREATED)
async def api_bulk_grant_permission_to_users(
data: "BulkGrantPermission",
auth: AuthContext = Depends(require_super_user),
) -> "BulkGrantResult":
"""
Grant the same permission to multiple users at once (admin only).
This is a convenience endpoint that grants the same account permission
to multiple users in one operation. Useful for onboarding teams or
granting access to a shared expense account.
Returns detailed results including successes and failures.
"""
from ..models import BulkGrantResult
granted = []
failed = []
# Validate account exists and is active
account = await get_account(data.account_id)
if not account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Account with ID '{data.account_id}' not found",
)
# Grant permission to each user
for user_id in data.user_ids:
try:
perm_data = CreateAccountPermission(
user_id=user_id,
account_id=data.account_id,
permission_type=data.permission_type,
expires_at=data.expires_at,
notes=data.notes,
)
perm = await create_account_permission(perm_data, auth.user_id)
granted.append(perm)
except Exception as e:
failed.append({
"user_id": user_id,
"error": str(e),
})
return BulkGrantResult(
granted=granted,
failed=failed,
total=len(data.user_ids),
success_count=len(granted),
failure_count=len(failed),
)
# ===== USER PERMISSION ENDPOINTS =====
@router.get("/api/v1/users/me/permissions")
async def api_get_user_permissions(
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> list[AccountPermission]:
"""Get current user's account permissions"""
return await get_user_permissions(wallet.wallet.user)
# ===== ACCOUNT HIERARCHY ENDPOINT =====

304
views_api/reconciliation.py Normal file
View file

@ -0,0 +1,304 @@
"""Libra API — reconciliation endpoints (moved verbatim from views_api.py)."""
from fastapi import APIRouter
from ._shared import * # noqa: F401,F403
router = APIRouter()
@router.post("/api/v1/assertions")
async def api_create_balance_assertion(
data: CreateBalanceAssertion,
auth: AuthContext = Depends(require_super_user),
) -> BalanceAssertion:
"""
Create a balance assertion for reconciliation (super user only).
Uses hybrid approach:
1. Writes balance assertion to Beancount (via Fava) - source of truth
2. Stores metadata in Libra DB for UI convenience (created_by, notes, tolerance)
3. Lets Beancount validate the assertion automatically
The assertion will be checked immediately upon creation.
"""
from ..fava_client import get_fava_client
from ..beancount_format import format_balance
# Verify account exists
account = await get_account(data.account_id)
if not account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Account {data.account_id} not found",
)
assertion_date = data.date or datetime.now()
# HYBRID APPROACH: Write to Beancount first (source of truth)
balance_directive = format_balance(
date_val=assertion_date.date() if isinstance(assertion_date, datetime) else assertion_date,
account=account.name,
amount=data.expected_balance_sats,
currency="SATS"
)
# Submit to Fava/Beancount
try:
fava = get_fava_client()
result = await fava.add_entry(balance_directive)
logger.info(f"Balance assertion submitted to Fava: {result}")
except Exception as e:
logger.error(f"Failed to write balance assertion to Fava: {e}")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Failed to write balance assertion to Beancount: {str(e)}"
)
# Store metadata in Libra DB for UI convenience
assertion = await create_balance_assertion(data, auth.user_id)
# Check it immediately (queries Fava for actual balance)
try:
assertion = await check_balance_assertion(assertion.id)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(e),
)
# If assertion failed, return 409 Conflict with details
if assertion.status == AssertionStatus.FAILED:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail={
"message": "Balance assertion failed (validated by Beancount)",
"expected_sats": assertion.expected_balance_sats,
"actual_sats": assertion.checked_balance_sats,
"difference_sats": assertion.difference_sats,
"expected_fiat": float(assertion.expected_balance_fiat) if assertion.expected_balance_fiat else None,
"actual_fiat": float(assertion.checked_balance_fiat) if assertion.checked_balance_fiat else None,
"difference_fiat": float(assertion.difference_fiat) if assertion.difference_fiat else None,
"fiat_currency": assertion.fiat_currency,
},
)
return assertion
@router.get("/api/v1/assertions")
async def api_get_balance_assertions(
account_id: str = None,
status: str = None,
limit: int = 100,
auth: AuthContext = Depends(require_super_user),
) -> list[BalanceAssertion]:
"""Get balance assertions with optional filters (super user only)"""
# Parse status enum if provided
status_enum = None
if status:
try:
status_enum = AssertionStatus(status)
except ValueError:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Invalid status: {status}. Must be one of: pending, passed, failed",
)
return await get_balance_assertions(
account_id=account_id,
status=status_enum,
limit=limit,
)
@router.get("/api/v1/assertions/{assertion_id}")
async def api_get_balance_assertion(
assertion_id: str,
auth: AuthContext = Depends(require_super_user),
) -> BalanceAssertion:
"""Get a specific balance assertion (super user only)"""
assertion = await get_balance_assertion(assertion_id)
if not assertion:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Balance assertion not found",
)
return assertion
@router.post("/api/v1/assertions/{assertion_id}/check")
async def api_check_balance_assertion(
assertion_id: str,
auth: AuthContext = Depends(require_super_user),
) -> BalanceAssertion:
"""Re-check a balance assertion (super user only)"""
try:
assertion = await check_balance_assertion(assertion_id)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=str(e),
)
return assertion
@router.delete("/api/v1/assertions/{assertion_id}")
async def api_delete_balance_assertion(
assertion_id: str,
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""Delete a balance assertion (super user only)"""
# Verify it exists
assertion = await get_balance_assertion(assertion_id)
if not assertion:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Balance assertion not found",
)
await delete_balance_assertion(assertion_id)
return {"success": True, "message": "Balance assertion deleted"}
# ===== RECONCILIATION ENDPOINTS =====
@router.get("/api/v1/reconciliation/summary")
async def api_get_reconciliation_summary(
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""Get reconciliation summary (super user only)"""
# Get all assertions
all_assertions = await get_balance_assertions(limit=1000)
# Count by status
passed = len([a for a in all_assertions if a.status == AssertionStatus.PASSED])
failed = len([a for a in all_assertions if a.status == AssertionStatus.FAILED])
pending = len([a for a in all_assertions if a.status == AssertionStatus.PENDING])
# Get all journal entries from Fava
from ..fava_client import get_fava_client
fava = get_fava_client()
all_entries = await fava.query_transactions(limit=1000, include_pending=True)
# Count entries by flag (Beancount only supports * and !)
cleared = len([e for e in all_entries if e.get("flag") == "*"])
pending_entries = len([e for e in all_entries if e.get("flag") == "!"])
# Count entries with special tags
voided = len([e for e in all_entries if "voided" in e.get("tags", [])])
flagged = len([e for e in all_entries if "review" in e.get("tags", []) or "flagged" in e.get("tags", [])])
# Get all accounts
accounts = await get_all_accounts()
return {
"assertions": {
"total": len(all_assertions),
"passed": passed,
"failed": failed,
"pending": pending,
},
"entries": {
"total": len(all_entries),
"cleared": cleared,
"pending": pending_entries,
"flagged": flagged,
"voided": voided,
},
"accounts": {
"total": len(accounts),
},
"last_checked": datetime.now().isoformat(),
}
@router.post("/api/v1/reconciliation/check-all")
async def api_check_all_assertions(
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""Re-check all balance assertions (super user only)"""
# Get all assertions
all_assertions = await get_balance_assertions(limit=1000)
results = {
"total": len(all_assertions),
"checked": 0,
"passed": 0,
"failed": 0,
"errors": 0,
}
for assertion in all_assertions:
try:
checked = await check_balance_assertion(assertion.id)
results["checked"] += 1
if checked.status == AssertionStatus.PASSED:
results["passed"] += 1
elif checked.status == AssertionStatus.FAILED:
results["failed"] += 1
except Exception as e:
results["errors"] += 1
return results
@router.get("/api/v1/reconciliation/discrepancies")
async def api_get_discrepancies(
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""Get all discrepancies (failed assertions, flagged entries) (super user only)"""
# Get failed assertions
failed_assertions = await get_balance_assertions(
status=AssertionStatus.FAILED,
limit=1000,
)
# Get flagged entries from Fava
from ..fava_client import get_fava_client
fava = get_fava_client()
all_entries = await fava.query_transactions(limit=1000, include_pending=True)
flagged_entries = [e for e in all_entries if e.get("flag") == "#"]
pending_entries = [e for e in all_entries if e.get("flag") == "!"]
return {
"failed_assertions": failed_assertions,
"flagged_entries": flagged_entries,
"pending_entries": pending_entries,
"total_discrepancies": len(failed_assertions) + len(flagged_entries),
}
# ===== AUTOMATED TASKS ENDPOINTS =====
@router.post("/api/v1/tasks/daily-reconciliation")
async def api_run_daily_reconciliation(
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Manually trigger the daily reconciliation check (super user only).
This endpoint can also be called via cron job.
Returns a summary of the reconciliation check results.
"""
from ..tasks import check_all_balance_assertions
try:
results = await check_all_balance_assertions()
return results
except Exception as e:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Error running daily reconciliation: {str(e)}",
)
# ===== USER EQUITY ELIGIBILITY ENDPOINTS =====

View file

@ -0,0 +1,399 @@
"""Libra API — settings reports endpoints (moved verbatim from views_api.py)."""
from fastapi import APIRouter
from ._shared import * # noqa: F401,F403
router = APIRouter()
@router.get("/api/v1/settings")
async def api_get_settings(
user: User = Depends(check_user_exists),
) -> LibraSettings:
"""Get Libra settings"""
user_id = "admin"
settings = await get_settings(user_id)
# Return empty settings if not configured (so UI can show setup screen)
if not settings:
return LibraSettings()
return settings
@router.put("/api/v1/settings")
async def api_update_settings(
data: LibraSettings,
user: User = Depends(check_super_user),
) -> LibraSettings:
"""Update Libra settings (super user only)"""
if not data.libra_wallet_id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Libra wallet ID is required",
)
user_id = "admin"
return await update_settings(user_id, data)
# ===== USER WALLET ENDPOINTS =====
@router.get("/api/v1/user-wallet/{user_id}")
async def api_get_user_wallet(
user_id: str,
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""Get user's wallet settings (super user only)
Supports both full UUIDs and truncated 8-char IDs (from Beancount accounts).
"""
from ..crud import get_user_wallet_settings_by_prefix
# First try exact match
user_wallet = await get_user_wallet(user_id)
# If not found and user_id looks like a truncated ID (8 chars), try prefix match
if not user_wallet or not user_wallet.user_wallet_id:
if len(user_id) <= 8:
stored_wallet = await get_user_wallet_settings_by_prefix(user_id)
if stored_wallet and stored_wallet.user_wallet_id:
user_wallet = stored_wallet
user_id = stored_wallet.id # Use the full ID
if not user_wallet or not user_wallet.user_wallet_id:
return {"user_id": user_id, "user_wallet_id": None}
# Get invoice key for the user's wallet (needed to generate invoices)
from lnbits.core.crud import get_wallet
wallet_obj = await get_wallet(user_wallet.user_wallet_id)
if not wallet_obj:
return {"user_id": user_id, "user_wallet_id": user_wallet.user_wallet_id}
return {
"user_id": user_id,
"user_wallet_id": user_wallet.user_wallet_id,
"user_wallet_id_invoice_key": wallet_obj.inkey,
}
@router.get("/api/v1/users")
async def api_get_all_users(
auth: AuthContext = Depends(require_super_user),
) -> list[dict]:
"""Get all users who have configured their wallet (super user only)"""
from lnbits.core.crud.users import get_user
user_settings = await get_all_user_wallet_settings()
users = []
for setting in user_settings:
# Get user details from core
user = await get_user(setting.id)
# Use username if available, otherwise truncate user_id
username = user.username if user and user.username else setting.id[:16] + "..."
users.append({
"user_id": setting.id,
"user_wallet_id": setting.user_wallet_id,
"username": username,
})
return users
@router.get("/api/v1/admin/libra-users")
async def api_get_libra_users(
auth: AuthContext = Depends(require_super_user),
) -> list[dict]:
"""
Get all users who have configured their wallet in Libra.
These are users who can interact with Libra (submit expenses, receive permissions, etc.).
Super user only.
"""
from lnbits.core.crud.users import get_user
# Get all users who have configured their wallet
user_settings = await get_all_user_wallet_settings()
users = []
for setting in user_settings:
# Get user details from core
user = await get_user(setting.id)
# Use username if available, otherwise use user_id
username = user.username if user and user.username else None
users.append({
"id": setting.id,
"user_id": setting.id, # Compatibility with existing code
"username": username,
"user_wallet_id": setting.user_wallet_id,
})
# Sort by username (None values last)
users.sort(key=lambda x: (x["username"] is None, x["username"] or "", x["user_id"]))
return users
@router.get("/api/v1/reports/expenses")
async def api_expense_report(
start_date: Optional[str] = None,
end_date: Optional[str] = None,
group_by: str = "account",
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Get expense summary report using BQL. Super user only.
Args:
start_date: Filter from this date (YYYY-MM-DD), optional
end_date: Filter to this date (YYYY-MM-DD), optional
group_by: "account" (by expense category) or "month" (by month)
Returns:
{
"summary": [
{"account": "Expenses:Supplies:Food", "fiat": 500.00, "sats": 550000},
...
],
"total_fiat": 1500.00,
"total_sats": 1650000,
"fiat_currency": "EUR",
"group_by": "account",
"start_date": "2025-01-01",
"end_date": "2025-12-31"
}
Admin only.
"""
from ..fava_client import get_fava_client
if group_by not in ["account", "month"]:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="group_by must be 'account' or 'month'"
)
fava = get_fava_client()
summaries = await fava.get_expense_summary_bql(
start_date=start_date,
end_date=end_date,
group_by=group_by
)
# Calculate totals
total_fiat = sum(s.get("fiat", 0) for s in summaries)
total_sats = sum(s.get("sats", 0) for s in summaries)
return {
"summary": summaries,
"total_fiat": total_fiat,
"total_sats": total_sats,
"fiat_currency": "EUR",
"group_by": group_by,
"start_date": start_date,
"end_date": end_date,
"count": len(summaries)
}
@router.get("/api/v1/reports/contributions")
async def api_contributions_report(
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Get user contribution report using BQL.
Shows total expenses submitted by each user (creating payables).
Returns:
{
"contributions": [
{
"user_id": "cfe378b3",
"username": "alice",
"total_fiat": 1500.00,
"total_sats": 1650000,
"entry_count": 25
},
...
],
"total_fiat": 5000.00,
"total_sats": 5500000,
"fiat_currency": "EUR",
"user_count": 5
}
Admin only.
"""
from lnbits.core.crud.users import get_user
from ..fava_client import get_fava_client
fava = get_fava_client()
contributions = await fava.get_user_contributions_bql()
# Enrich with usernames
for contrib in contributions:
user_id = contrib["user_id"]
# Try to find full user_id from wallet settings
settings = await get_all_user_wallet_settings()
full_user_id = None
for s in settings:
if s.id.startswith(user_id):
full_user_id = s.id
break
if full_user_id:
user = await get_user(full_user_id)
contrib["username"] = user.username if user and user.username else None
contrib["full_user_id"] = full_user_id
else:
contrib["username"] = None
contrib["full_user_id"] = None
# Calculate totals
total_fiat = sum(c.get("total_fiat", 0) for c in contributions)
total_sats = sum(c.get("total_sats", 0) for c in contributions)
return {
"contributions": contributions,
"total_fiat": total_fiat,
"total_sats": total_sats,
"fiat_currency": "EUR",
"user_count": len(contributions)
}
@router.get("/api/v1/users/{user_id}/unsettled-entries")
async def api_get_unsettled_entries(
user_id: str,
entry_type: str = "expense",
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Get unsettled expense or receivable entries for a user.
Returns entries that have unique links (exp-xxx or rcv-xxx) which
have not yet appeared in a settlement transaction.
Args:
user_id: The user's ID
entry_type: "expense" (payables - libra owes user) or
"receivable" (user owes libra)
Returns:
{
"user_id": "abc123...",
"entry_type": "expense",
"unsettled_entries": [
{
"link": "exp-abc123",
"date": "2025-12-01",
"narration": "Groceries at Biocoop",
"fiat_amount": 50.00,
"fiat_currency": "EUR",
"sats_amount": 47000,
"flag": "!" # pending or "*" cleared
},
...
],
"total_fiat": 150.00,
"total_fiat_currency": "EUR",
"total_sats": 141000,
"count": 3
}
Admin only - used when settling user balances.
"""
from ..fava_client import get_fava_client
if entry_type not in ["expense", "receivable"]:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="entry_type must be 'expense' or 'receivable'"
)
fava = get_fava_client()
unsettled = await fava.get_unsettled_entries_bql(user_id, entry_type)
# Calculate totals
total_fiat = sum(e.get("fiat_amount", 0) for e in unsettled)
total_sats = sum(e.get("sats_amount", 0) for e in unsettled)
# Get currency (assume all same currency for a user)
fiat_currency = unsettled[0].get("fiat_currency", "EUR") if unsettled else "EUR"
return {
"user_id": user_id,
"entry_type": entry_type,
"unsettled_entries": unsettled,
"total_fiat": total_fiat,
"total_fiat_currency": fiat_currency,
"total_sats": total_sats,
"count": len(unsettled)
}
@router.get("/api/v1/user/wallet")
async def api_get_user_wallet(
user: User = Depends(check_user_exists),
) -> UserWalletSettings:
"""Get current user's wallet settings"""
from lnbits.settings import settings as lnbits_settings
# If user is super user, return the libra wallet
if user.id == lnbits_settings.super_user:
libra_settings = await get_settings("admin")
if libra_settings and libra_settings.libra_wallet_id:
return UserWalletSettings(user_wallet_id=libra_settings.libra_wallet_id)
return UserWalletSettings()
# For regular users, get their personal wallet
settings = await get_user_wallet(user.id)
# Return empty settings if not configured (so UI can show setup screen)
if not settings:
return UserWalletSettings()
return settings
@router.put("/api/v1/user/wallet")
async def api_update_user_wallet(
data: UserWalletSettings,
user: User = Depends(check_user_exists),
) -> UserWalletSettings:
"""Update current user's wallet settings"""
from lnbits.settings import settings as lnbits_settings
# Super user cannot set their wallet separately - it's always the libra wallet
if user.id == lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Super user wallet is automatically set to the Libra wallet. Update Libra settings instead.",
)
if not data.user_wallet_id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="User wallet ID is required",
)
return await update_user_wallet(user.id, data)
# ===== MANUAL PAYMENT REQUESTS =====
@router.get("/api/v1/user/info")
async def api_get_user_info(
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> UserInfo:
"""Get current user's information including equity eligibility"""
from ..crud import get_user_equity_status
from ..models import UserInfo
equity_status = await get_user_equity_status(wallet.wallet.user)
return UserInfo(
user_id=wallet.wallet.user,
is_equity_eligible=equity_status.is_equity_eligible if equity_status else False,
equity_account_name=equity_status.equity_account_name if equity_status else None,
)