From a7d7740a3a14ccceeaefeb66df0c07753a42a33a Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 12 Jul 2026 16:09:58 +0200 Subject: [PATCH] refactor(api): split views_api.py into a package of domain modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure move — no logic changes. The 4,100-line single file becomes views_api/ with one module per domain (accounts, entries, payments, settings_reports, reconciliation, permissions, admin), each registering full literal paths on its own APIRouter; __init__ builds the combined libra_api_router so libra/__init__.py is untouched. Shared imports/helpers live in views_api/_shared.py; _extract_entry_id and _SYSTEM_LINK_PREFIXES move to beancount_format.py (they are pure entry-dict parsing). Route behavior is pinned by tests/test_route_table.py: the 73-route set is unchanged, and the two order-sensitive families (the shadowed /accounts/hierarchy wart — deliberately preserved here, fixed in the next commit — and the admin sync literal/param pair) keep their relative order inside a single module each. Addresses CODE-REVIEW-2026-06 structure findings (views_api monolith). Co-Authored-By: Claude Fable 5 --- beancount_format.py | 32 + tests/test_route_table.py | 38 +- views_api.py | 4121 --------------------------------- views_api/__init__.py | 31 + views_api/_shared.py | 177 ++ views_api/accounts.py | 328 +++ views_api/admin.py | 535 +++++ views_api/entries.py | 1209 ++++++++++ views_api/payments.py | 979 ++++++++ views_api/permissions.py | 210 ++ views_api/reconciliation.py | 304 +++ views_api/settings_reports.py | 399 ++++ 12 files changed, 4233 insertions(+), 4130 deletions(-) delete mode 100644 views_api.py create mode 100644 views_api/__init__.py create mode 100644 views_api/_shared.py create mode 100644 views_api/accounts.py create mode 100644 views_api/admin.py create mode 100644 views_api/entries.py create mode 100644 views_api/payments.py create mode 100644 views_api/permissions.py create mode 100644 views_api/reconciliation.py create mode 100644 views_api/settings_reports.py diff --git a/beancount_format.py b/beancount_format.py index fbdfba9..1cba2c7 100644 --- a/beancount_format.py +++ b/beancount_format.py @@ -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. diff --git a/tests/test_route_table.py b/tests/test_route_table.py index 4064975..ae463e3 100644 --- a/tests/test_route_table.py +++ b/tests/test_route_table.py @@ -37,6 +37,7 @@ EXPECTED_ROUTES = [ ("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/accounts/hierarchy", "api_get_account_hierarchy"), ("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"), @@ -45,6 +46,8 @@ EXPECTED_ROUTES = [ ("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"), @@ -52,6 +55,11 @@ EXPECTED_ROUTES = [ ("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"), @@ -62,13 +70,7 @@ EXPECTED_ROUTES = [ ("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"), - ("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"), - ("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/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"), @@ -78,7 +80,6 @@ EXPECTED_ROUTES = [ ("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"), - ("GET", "/api/v1/user/info", "api_get_user_info"), ("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"), @@ -88,7 +89,6 @@ EXPECTED_ROUTES = [ ("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"), - ("GET", "/api/v1/accounts/hierarchy", "api_get_account_hierarchy"), ("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"), @@ -113,3 +113,23 @@ def test_route_table_matches_snapshot(): 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.""" + # Known wart carried over from before the split: hierarchy is + # shadowed by the {account_id} route (fixed in a separate commit). + assert _index("/api/v1/accounts/{account_id}") < _index( + "/api/v1/accounts/hierarchy" + ) + assert _index("/api/v1/admin/accounts/sync") < _index( + "/api/v1/admin/accounts/sync/{account_name:path}" + ) diff --git a/views_api.py b/views_api.py deleted file mode 100644 index 65185e8..0000000 --- a/views_api.py +++ /dev/null @@ -1,4121 +0,0 @@ -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 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, -) - -libra_api_router = APIRouter() - -# 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 ===== - - -@libra_api_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 ===== - - -@libra_api_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 - - -@libra_api_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", - ) - - -@libra_api_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 - - -@libra_api_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") - } - - -@libra_api_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 ===== - - -@libra_api_router.get("/api/v1/entries") -async def api_get_journal_entries( - limit: int = 100, - auth: AuthContext = Depends(require_super_user), -) -> list[dict]: - """ - Get all journal entries from Fava/Beancount. - - Returns all transactions in reverse chronological order with username enrichment. - SUPER USER ONLY - exposes all transaction data. - """ - from lnbits.core.crud.users import get_user - from .fava_client import get_fava_client - - fava = get_fava_client() - all_entries = await fava.get_journal_entries() - - # Filter to transactions only and enrich with username - enriched_entries = [] - for e in all_entries: - if e.get("t") != "Transaction": - continue - if e.get("flag") in _SYNTHETIC_FLAGS: - continue - - # Extract user ID from metadata or account names - user_id = None - entry_meta = e.get("meta", {}) - if "user-id" in entry_meta: - user_id = entry_meta["user-id"] - else: - # Try to extract from account names in postings - for posting in e.get("postings", []): - account = posting.get("account", "") - if "User-" in account: - parts = account.split("User-") - if len(parts) > 1: - user_id = parts[1] - break - - # Look up username - username = None - if user_id: - user = await get_user(user_id) - username = user.username if user and user.username else f"User-{user_id[:8]}" - - # Add username to entry - enriched_entry = dict(e) - enriched_entry["user_id"] = user_id - enriched_entry["username"] = username - enriched_entries.append(enriched_entry) - - if len(enriched_entries) >= limit: - break - - return enriched_entries - - -# Link prefixes written by libra itself (vs user-supplied references): -# exp-/rcv-/inc- typed entry links, ln- lightning payment links, and the -# legacy libra-{id} identity link. -_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 - - -@libra_api_router.get("/api/v1/entries/user") -async def api_get_user_entries( - wallet: WalletTypeInfo = Depends(require_invoice_key), - limit: int = 20, - offset: int = 0, - filter_user_id: str = None, - filter_account_type: str = None, # 'asset' for receivable, 'liability' for payable - days: int = 15, # Default 15 days, options: 15, 30, 60 - start_date: str = None, # ISO format: YYYY-MM-DD - end_date: str = None, # ISO format: YYYY-MM-DD -) -> dict: - """ - Get journal entries that affect the current user's accounts from Fava/Beancount. - - Returns transactions in reverse chronological order with optional filtering. - - Args: - days: Number of days to fetch (default: 15, options: 15, 30, 60) - start_date: Start date for custom range (YYYY-MM-DD). Requires end_date. - end_date: End date for custom range (YYYY-MM-DD). Requires start_date. - - Note: - If both days and start_date/end_date are provided, start_date/end_date takes precedence. - """ - from lnbits.settings import settings as lnbits_settings - from .fava_client import get_fava_client - - fava = get_fava_client() - - # Determine which user's entries to fetch - if wallet.wallet.user == lnbits_settings.super_user: - # Super user can view all or filter by user_id - target_user_id = filter_user_id - else: - # Regular user can only see their own entries - target_user_id = wallet.wallet.user - - # Get journal entries from Fava - # Priority: custom date range > days > default (5 days) - all_entries = await fava.get_journal_entries( - days=days, - start_date=start_date, - end_date=end_date - ) - - # Filter and transform entries - filtered_entries = [] - for e in all_entries: - if e.get("t") != "Transaction": - continue - if e.get("flag") in _SYNTHETIC_FLAGS: - continue - - # Extract user ID from metadata or account names - user_id_match = None - entry_meta = e.get("meta", {}) - if "user-id" in entry_meta: - user_id_match = entry_meta["user-id"] - else: - # Try to extract from account names in postings - for posting in e.get("postings", []): - account = posting.get("account", "") - if "User-" in account: - # Extract user ID from account name (e.g., "Liabilities:Payable:User-abc123") - parts = account.split("User-") - if len(parts) > 1: - user_id_match = parts[1] # Just the short ID after User- - break - - # Filter by target user if specified - if target_user_id and user_id_match: - if not user_id_match.startswith(target_user_id[:8]): - continue - - # Filter by account type if specified - if filter_account_type and user_id_match: - postings = e.get("postings", []) - has_matching_account = False - for posting in postings: - account = posting.get("account", "") - if filter_account_type.lower() == "asset" and "Receivable" in account: - has_matching_account = True - break - elif filter_account_type.lower() == "liability" and "Payable" in account: - has_matching_account = True - break - if not has_matching_account: - continue - - # Extract data for frontend - # Resolve canonical entry ID (metadata first, link fallback) - entry_id = _extract_entry_id(e) - links = e.get("links", []) - - # Extract amount from postings - amount_sats = 0 - fiat_amount = None - fiat_currency = None - - postings = e.get("postings", []) - if postings: - first_posting = postings[0] - if isinstance(first_posting, dict): - amount_str = first_posting.get("amount", "") - - # Parse amount string: price notation, simple fiat, or legacy SATS format - if isinstance(amount_str, str) and amount_str: - import re - - # Try total price notation: "50.00 EUR @@ 50000 SATS" - total_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@@\s+(\d+)\s+SATS$', amount_str) - # Try per-unit price notation: "50.00 EUR @ 1000.5 SATS" - unit_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$', amount_str) - - if total_price_match: - fiat_amount = abs(float(total_price_match.group(1))) - fiat_currency = total_price_match.group(2) - amount_sats = abs(int(total_price_match.group(3))) - elif unit_price_match: - fiat_amount = abs(float(unit_price_match.group(1))) - fiat_currency = unit_price_match.group(2) - sats_per_unit = float(unit_price_match.group(3)) - amount_sats = abs(int(fiat_amount * sats_per_unit)) - - # Try simple fiat format: "50.00 EUR" (check metadata for sats) - elif re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str): - fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str) - if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'): - fiat_amount = abs(float(fiat_match.group(1))) - fiat_currency = fiat_match.group(2) - - # Get SATS from metadata (legacy) - posting_meta = first_posting.get("meta", {}) - sats_equiv = posting_meta.get("sats-equivalent") - if sats_equiv: - amount_sats = abs(int(sats_equiv)) - - else: - # Old format: "36791 SATS {33.33 EUR, 2025-11-09}" or "36791 SATS" - sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str) - if sats_match: - amount_sats = abs(int(sats_match.group(1))) - - # Extract fiat from cost syntax: {33.33 EUR, ...} - cost_match = re.search(r'\{([\d.]+)\s+([A-Z]+)', amount_str) - if cost_match: - fiat_amount = float(cost_match.group(1)) - fiat_currency = cost_match.group(2) - - # Extract reference from links (first link that isn't a - # libra-system link: typed entry/settlement links, lightning - # payment links, or the legacy libra-{id} identity link) - reference = None - if isinstance(links, (list, set)): - for link in links: - if isinstance(link, str): - link_clean = link.lstrip('^') - if not link_clean.startswith(_SYSTEM_LINK_PREFIXES): - reference = link_clean - break - - # Look up actual username using helper function - username = await get_username(user_id_match) if user_id_match else None - - entry_data = { - "id": entry_id or e.get("entry_hash", "unknown"), - "date": e.get("date", ""), - "entry_date": e.get("date", ""), - "flag": e.get("flag"), - "description": e.get("narration", ""), - "payee": e.get("payee"), - "tags": e.get("tags", []), - "links": links, - "amount": amount_sats, - "user_id": user_id_match, - "username": username, - "reference": reference, - "meta": entry_meta, # Include metadata for frontend - } - - if fiat_amount and fiat_currency: - entry_data["fiat_amount"] = fiat_amount - entry_data["fiat_currency"] = fiat_currency - - filtered_entries.append(entry_data) - - # Sort by date descending - filtered_entries.sort(key=lambda x: x.get("date", ""), reverse=True) - - # Apply pagination - total = len(filtered_entries) - paginated_entries = filtered_entries[offset:offset + limit] - - return { - "entries": paginated_entries, - "total": total, - "limit": limit, - "offset": offset, - "has_next": (offset + limit) < total, - "has_prev": offset > 0, - } - - -@libra_api_router.get("/api/v1/entries/pending") -async def api_get_pending_entries( - auth: AuthContext = Depends(require_super_user), -) -> list[dict]: - """ - Get all pending expense entries that need approval (super user only). - - Returns transactions with flag='!' from Fava/Beancount. - """ - from .fava_client import get_fava_client - - # Query Fava for all journal entries (includes links, tags, full metadata) - fava = get_fava_client() - all_entries = await fava.get_journal_entries() - - # Filter for pending transactions and extract info - pending_entries = [] - - for e in all_entries: - # Only include pending transactions that are NOT voided - if e.get("t") == "Transaction" and e.get("flag") == "!" and "voided" not in e.get("tags", []): - # Resolve canonical entry ID (metadata first, link fallback) - entry_id = _extract_entry_id(e) - links = e.get("links", []) - - # Extract user ID from metadata or account names - user_id = None - entry_meta = e.get("meta", {}) - logger.info(f"[EXTRACT] Entry metadata keys: {list(entry_meta.keys())}") - logger.info(f"[EXTRACT] Entry metadata: {entry_meta}") - if "user-id" in entry_meta: - user_id = entry_meta["user-id"] - logger.info(f"[EXTRACT] Found user-id in metadata: {user_id}") - else: - logger.info(f"[EXTRACT] No user-id in metadata, checking account names") - # Try to extract from account names in postings - for posting in e.get("postings", []): - account = posting.get("account", "") - if "User-" in account: - # Extract user ID from account name (e.g., "Liabilities:Payable:User-abc123") - parts = account.split("User-") - if len(parts) > 1: - user_id = parts[1] # Short ID after User- - logger.info(f"[EXTRACT] Extracted user_id from account name: {user_id}") - break - - # Look up username using helper function - username = await get_username(user_id) if user_id else None - - # Extract amount from postings (sum of absolute values / 2) - amount_sats = 0 - fiat_amount = None - fiat_currency = None - - postings = e.get("postings", []) - if postings: - first_posting = postings[0] - if isinstance(first_posting, dict): - amount_str = first_posting.get("amount", "") - - # Parse amount string format - if isinstance(amount_str, str) and amount_str: - import re - - # Try total price notation: "50.00 EUR @@ 50000 SATS" - total_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@@\s+(\d+)\s+SATS$', amount_str) - # Try per-unit price notation: "50.00 EUR @ 1000.5 SATS" - unit_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$', amount_str) - - if total_price_match: - fiat_amount = abs(float(total_price_match.group(1))) - fiat_currency = total_price_match.group(2) - amount_sats = abs(int(total_price_match.group(3))) - elif unit_price_match: - fiat_amount = abs(float(unit_price_match.group(1))) - fiat_currency = unit_price_match.group(2) - sats_per_unit = float(unit_price_match.group(3)) - amount_sats = abs(int(fiat_amount * sats_per_unit)) - - # Try simple fiat format: "50.00 EUR" (check metadata for sats) - elif re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str): - fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str) - if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'): - fiat_amount = abs(float(fiat_match.group(1))) - fiat_currency = fiat_match.group(2) - - # Extract sats equivalent from metadata (legacy) - posting_meta = first_posting.get("meta", {}) - sats_equiv = posting_meta.get("sats-equivalent") - if sats_equiv: - amount_sats = abs(int(sats_equiv)) - - else: - # Legacy SATS format: "36791 SATS {33.33 EUR, 2025-11-09}" or "36791 SATS" - sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str) - if sats_match: - amount_sats = abs(int(sats_match.group(1))) - - # Extract fiat from cost syntax: {33.33 EUR, ...} - cost_match = re.search(r'\{([\d.]+)\s+([A-Z]+)', amount_str) - if cost_match: - fiat_amount = float(cost_match.group(1)) - fiat_currency = cost_match.group(2) - - entry_data = { - "id": entry_id or "unknown", - "date": e.get("date", ""), - "entry_date": e.get("date", ""), - "flag": e.get("flag"), - "description": e.get("narration", ""), - "payee": e.get("payee"), - "tags": e.get("tags", []), - "links": links, - "amount": amount_sats, - "user_id": user_id, - "username": username, - } - - # Add fiat info if available - if fiat_amount and fiat_currency: - entry_data["fiat_amount"] = fiat_amount - entry_data["fiat_currency"] = fiat_currency - - pending_entries.append(entry_data) - - return pending_entries - - -@libra_api_router.post("/api/v1/entries", status_code=HTTPStatus.CREATED) -async def api_create_journal_entry( - data: CreateJournalEntry, - wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> JournalEntry: - """ - Create a new generic journal entry. - - Submits entry to Fava/Beancount. - """ - from .fava_client import get_fava_client - from .beancount_format import ( - format_transaction, - format_posting_with_cost, - sanitize_link, - ) - - # Validate that entry balances to zero - total = sum(line.amount for line in data.lines) - if total != 0: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Entry does not balance (total: {total}, expected: 0)" - ) - - # Get all accounts and validate they exist - account_map = {} - for line in data.lines: - account = await get_account(line.account_id) - if not account: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail=f"Account '{line.account_id}' not found" - ) - account_map[line.account_id] = account - - # Format postings - postings = [] - for line in data.lines: - account = account_map[line.account_id] - - # Extract fiat info from metadata if present - fiat_currency = line.metadata.get("fiat_currency") - fiat_amount_str = line.metadata.get("fiat_amount") - fiat_amount = Decimal(fiat_amount_str) if fiat_amount_str else None - - # Create posting metadata (excluding fiat fields that are used for primary amount) - posting_metadata = {k: v for k, v in line.metadata.items() - if k not in ["fiat_currency", "fiat_amount"]} - - # If fiat currency is provided, use EUR-based format (primary amount in EUR, sats in metadata) - # Otherwise, use SATS-based format - if fiat_currency and fiat_amount: - # EUR-based posting (current architecture) - posting_metadata["sats-equivalent"] = str(abs(line.amount)) - - # Apply the sign from line.amount to fiat_amount - # line.amount is positive for debits, negative for credits - signed_fiat_amount = fiat_amount if line.amount >= 0 else -fiat_amount - - posting = { - "account": account.name, - "amount": f"{signed_fiat_amount:.2f} {fiat_currency}", - "meta": posting_metadata if posting_metadata else None - } - else: - # SATS-based posting (legacy/fallback) - if line.description: - posting_metadata["description"] = line.description - - posting = format_posting_with_cost( - account=account.name, - amount_sats=line.amount, - fiat_currency=None, - fiat_amount=None, - metadata=posting_metadata if posting_metadata else None - ) - - postings.append(posting) - - # Extract tags and links from meta - tags = data.meta.get("tags", []) - links = data.meta.get("links", []) - if data.reference: - links.append(sanitize_link(data.reference)) - - # Entry metadata (excluding tags and links which go at transaction level) - entry_meta = {k: v for k, v in data.meta.items() if k not in ["tags", "links"]} - entry_meta["source"] = "libra-api" - entry_meta["created-by"] = wallet.wallet.user # Use user_id, not wallet_id - - # Format as Beancount entry - fava = get_fava_client() - - entry = format_transaction( - date_val=data.entry_date.date() if data.entry_date else datetime.now().date(), - flag=data.flag.value if data.flag else "*", - narration=data.description, - postings=postings, - tags=tags if tags else None, - links=links if links else None, - meta=entry_meta - ) - - # Submit to Fava - result = await fava.add_entry(entry) - logger.info(f"Journal entry submitted to Fava: {result.get('data', 'Unknown')}") - - # Return simplified JournalEntry for API compatibility - # Note: Libra no longer stores entries in DB, Fava is the source of truth - timestamp = datetime.now().timestamp() - return JournalEntry( - id=f"fava-{timestamp}", - description=data.description, - entry_date=data.entry_date if data.entry_date else datetime.now(), - created_by=wallet.wallet.user, # Use user_id, not wallet_id - created_at=datetime.now(), - reference=data.reference, - flag=data.flag if data.flag else JournalEntryFlag.CLEARED, - lines=[], # Empty - entry is stored in Fava, not Libra DB - meta={"source": "fava", "fava_response": result.get('data', 'Unknown')} - ) - - -# ===== SIMPLIFIED ENTRY ENDPOINTS ===== - - -@libra_api_router.post("/api/v1/entries/expense", status_code=HTTPStatus.CREATED) -async def api_create_expense_entry( - data: ExpenseEntry, - wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> JournalEntry: - """ - Create an expense entry for a user. - If is_equity=True, records as equity contribution. - If is_equity=False, records as liability (libra owes user). - - If currency is provided, amount is converted from fiat to satoshis. - """ - # Check that libra wallet is configured - await check_libra_wallet_configured() - - # Check that user has configured their wallet - await check_user_wallet_configured(wallet.wallet.user) - # Handle currency conversion - amount_sats = int(data.amount) - metadata = {} - - if data.currency: - # Validate currency - if data.currency.upper() not in allowed_currencies(): - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Currency '{data.currency}' not allowed. Use one of: {', '.join(allowed_currencies())}", - ) - - # Convert fiat to satoshis - amount_sats = await fiat_amount_as_satoshis(float(data.amount), data.currency) - - # Store currency metadata (store fiat_amount as string to preserve Decimal precision) - metadata = { - "fiat_currency": data.currency.upper(), - "fiat_amount": str(data.amount.quantize(Decimal("0.001"))), # Store as string with 3 decimal places - **fiat_rate_metadata(amount_sats, data.amount), - } - - # Get or create expense account - expense_account = await get_account_by_name(data.expense_account) - if not expense_account: - # Try to get it by ID - expense_account = await get_account(data.expense_account) - if not expense_account: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail=f"Expense account '{data.expense_account}' not found", - ) - - # Validate user has permission to submit expenses to this account - from .crud import get_user_permissions_with_inheritance - - submit_perms = await get_user_permissions_with_inheritance( - wallet.wallet.user, expense_account.name, PermissionType.SUBMIT_EXPENSE - ) - - if not submit_perms: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail=f"You do not have permission to submit expenses to account '{expense_account.name}'. Please contact an administrator to request access.", - ) - - # Get or create user-specific account - if data.is_equity: - # Validate equity eligibility - from .crud import get_user_equity_status - - equity_status = await get_user_equity_status(wallet.wallet.user) - - if not equity_status or not equity_status.is_equity_eligible: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="User is not eligible to contribute expenses to equity. Please submit for cash reimbursement.", - ) - - if not equity_status.equity_account_name: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="User equity account not configured. Contact administrator.", - ) - - # Equity contribution - use user's specific equity account - user_account = await get_account_by_name(equity_status.equity_account_name) - if not user_account: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Equity account '{equity_status.equity_account_name}' not found. Contact administrator.", - ) - else: - # Liability (libra owes user) - user_account = await get_or_create_user_account( - wallet.wallet.user, AccountType.LIABILITY, "Accounts Payable" - ) - - # Create journal entry - # DR Expense, CR User Account (Liability or Equity) - description_suffix = f" ({metadata['fiat_amount']} {metadata['fiat_currency']})" if metadata else "" - - # Add meta information for audit trail - entry_meta = { - "source": "api", - "created_via": "expense_entry", - "user_id": wallet.wallet.user, - "is_equity": data.is_equity, - } - - # Format as Beancount entry and submit to Fava - from .fava_client import get_fava_client - from .beancount_format import format_expense_entry - - fava = get_fava_client() - - # Extract fiat info from metadata - fiat_currency = metadata.get("fiat_currency") if metadata else None - fiat_amount = Decimal(metadata.get("fiat_amount")) if metadata and metadata.get("fiat_amount") else None - - # Generate unique entry ID for tracking - import uuid - entry_id = str(uuid.uuid4()).replace("-", "")[:16] - - # Format Beancount entry. Identity travels as entry-id metadata + - # exp-{entry_id} link; the user reference becomes its own link. - entry = format_expense_entry( - user_id=wallet.wallet.user, - expense_account=expense_account.name, - user_account=user_account.name, - amount_sats=amount_sats, - description=data.description, - entry_date=data.entry_date.date() if data.entry_date else datetime.now().date(), - is_equity=data.is_equity, - fiat_currency=fiat_currency, - fiat_amount=fiat_amount, - reference=data.reference, - entry_id=entry_id - ) - - # Submit to Fava - result = await fava.add_entry(entry) - - # Return a JournalEntry-like response for compatibility - from .models import EntryLine - return JournalEntry( - id=entry_id, # Use the generated libra entry ID - description=data.description + description_suffix, - entry_date=data.entry_date if data.entry_date else datetime.now(), - created_by=wallet.wallet.user, # Use user_id, not wallet_id - created_at=datetime.now(), - reference=data.reference, - flag=JournalEntryFlag.PENDING, - meta=entry_meta, - lines=[ - EntryLine( - id=f"line-1-{entry_id}", - journal_entry_id=entry_id, - account_id=expense_account.id, - amount=amount_sats, - description=f"Expense paid by user {wallet.wallet.user[:8]}", - metadata=metadata or {} - ), - EntryLine( - id=f"line-2-{entry_id}", - journal_entry_id=entry_id, - account_id=user_account.id, - amount=-amount_sats, - description=f"{'Equity contribution' if data.is_equity else 'Amount owed to user'}", - metadata=metadata or {} - ), - ] - ) - - -@libra_api_router.post("/api/v1/entries/income", status_code=HTTPStatus.CREATED) -async def api_create_income_entry( - data: IncomeEntry, - wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> JournalEntry: - """ - Create a user-submitted income/revenue entry (pending approval). - - Mirrors the expense submission flow: entry is created with '!' (pending) - flag and goes through the same /api/v1/entries/{id}/approve and /reject - endpoints. Requires SUBMIT_INCOME permission on the revenue account. - - Postings: DR payment_method_account (asset), CR revenue_account. - """ - # Validate currency - if data.currency.upper() not in allowed_currencies(): - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Currency '{data.currency}' not allowed. Use one of: {', '.join(allowed_currencies())}", - ) - - # Resolve revenue account by name or ID - revenue_account = await get_account_by_name(data.revenue_account) - if not revenue_account: - revenue_account = await get_account(data.revenue_account) - if not revenue_account: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail=f"Revenue account '{data.revenue_account}' not found", - ) - if revenue_account.account_type != AccountType.REVENUE: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Account '{revenue_account.name}' is not a revenue account (type: {revenue_account.account_type.value})", - ) - - # Permission check on the revenue account - from .crud import get_user_permissions_with_inheritance - - submit_perms = await get_user_permissions_with_inheritance( - wallet.wallet.user, revenue_account.name, PermissionType.SUBMIT_INCOME - ) - if not submit_perms: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail=f"You do not have permission to submit income to account '{revenue_account.name}'. Please contact an administrator to request access.", - ) - - # Income lands on the user as a receivable — they're holding cash on - # behalf of the entity until they hand it over via /settle-receivable. - user_account = await get_or_create_user_account( - wallet.wallet.user, AccountType.ASSET, "Accounts Receivable" - ) - - # Convert fiat to sats - fiat_currency = data.currency.upper() - amount_sats = await fiat_amount_as_satoshis(float(data.amount), data.currency) - - metadata = { - "fiat_currency": fiat_currency, - "fiat_amount": str(data.amount.quantize(Decimal("0.001"))), - **fiat_rate_metadata(amount_sats, data.amount), - } - - # Submit to Fava - from .fava_client import get_fava_client - from .beancount_format import format_income_entry - - fava = get_fava_client() - - import uuid - entry_id = str(uuid.uuid4()).replace("-", "")[:16] - - # Identity travels as entry-id metadata + inc-{entry_id} link; the - # user reference becomes its own link. - entry = format_income_entry( - user_id=wallet.wallet.user, - user_account=user_account.name, - revenue_account=revenue_account.name, - amount_sats=amount_sats, - description=data.description, - entry_date=data.entry_date.date() if data.entry_date else datetime.now().date(), - fiat_currency=fiat_currency, - fiat_amount=data.amount, - reference=data.reference, - entry_id=entry_id, - ) - - result = await fava.add_entry(entry) - logger.info(f"Income entry {entry_id} submitted to Fava (pending): {result.get('data', 'Unknown')}") - - description_suffix = f" ({metadata['fiat_amount']} {fiat_currency})" - entry_meta = { - "source": "api", - "created_via": "income_entry", - "user_id": wallet.wallet.user, - } - - from .models import EntryLine - return JournalEntry( - id=entry_id, - description=data.description + description_suffix, - entry_date=data.entry_date if data.entry_date else datetime.now(), - created_by=wallet.wallet.user, - created_at=datetime.now(), - reference=data.reference, - flag=JournalEntryFlag.PENDING, - meta=entry_meta, - lines=[ - EntryLine( - id=f"line-1-{entry_id}", - journal_entry_id=entry_id, - account_id=user_account.id, - amount=amount_sats, - description=f"User holds cash receivable to entity ({user_account.name})", - metadata=metadata, - ), - EntryLine( - id=f"line-2-{entry_id}", - journal_entry_id=entry_id, - account_id=revenue_account.id, - amount=-amount_sats, - description="Revenue earned (pending approval)", - metadata=metadata, - ), - ], - ) - - -@libra_api_router.post("/api/v1/entries/receivable", status_code=HTTPStatus.CREATED) -async def api_create_receivable_entry( - data: ReceivableEntry, - auth: AuthContext = Depends(require_super_user), -) -> JournalEntry: - """ - Create an accounts receivable entry (user owes libra). - Admin only to prevent abuse. - - If currency is provided, amount is converted from fiat to satoshis. - """ - # Handle currency conversion - amount_sats = int(data.amount) - metadata = {} - - if data.currency: - # Validate currency - if data.currency.upper() not in allowed_currencies(): - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Currency '{data.currency}' not allowed. Use one of: {', '.join(allowed_currencies())}", - ) - - # Convert fiat to satoshis - amount_sats = await fiat_amount_as_satoshis(float(data.amount), data.currency) - - # Store currency metadata (store fiat_amount as string to preserve Decimal precision) - metadata = { - "fiat_currency": data.currency.upper(), - "fiat_amount": str(data.amount.quantize(Decimal("0.001"))), # Store as string with 3 decimal places - **fiat_rate_metadata(amount_sats, data.amount), - } - - # Get or create revenue account - revenue_account = await get_account_by_name(data.revenue_account) - if not revenue_account: - revenue_account = await get_account(data.revenue_account) - if not revenue_account: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail=f"Revenue account '{data.revenue_account}' not found", - ) - - # Get or create user-specific receivable account - user_receivable = await get_or_create_user_account( - data.user_id, AccountType.ASSET, "Accounts Receivable" - ) - - # Create journal entry - # DR Accounts Receivable (User), CR Revenue - description_suffix = f" ({metadata['fiat_amount']} {metadata['fiat_currency']})" if metadata else "" - - # Add meta information for audit trail - entry_meta = { - "source": "api", - "created_via": "receivable_entry", - "debtor_user_id": data.user_id, - } - - # Format as Beancount entry and submit to Fava - from .fava_client import get_fava_client - from .beancount_format import format_receivable_entry - - fava = get_fava_client() - - # Extract fiat info from metadata - fiat_currency = metadata.get("fiat_currency") if metadata else None - fiat_amount = Decimal(metadata.get("fiat_amount")) if metadata and metadata.get("fiat_amount") else None - - # Generate unique entry ID for tracking - import uuid - entry_id = str(uuid.uuid4()).replace("-", "")[:16] - - # Format Beancount entry. Identity travels as entry-id metadata + - # rcv-{entry_id} link; the user reference becomes its own link. - entry = format_receivable_entry( - user_id=data.user_id, - revenue_account=revenue_account.name, - receivable_account=user_receivable.name, - amount_sats=amount_sats, - description=data.description, - entry_date=datetime.now().date(), - fiat_currency=fiat_currency, - fiat_amount=fiat_amount, - reference=data.reference, - entry_id=entry_id - ) - - # Submit to Fava - result = await fava.add_entry(entry) - - # Return a JournalEntry-like response for compatibility - from .models import EntryLine - return JournalEntry( - id=entry_id, # Use the generated libra entry ID - description=data.description + description_suffix, - entry_date=datetime.now(), - created_by=auth.user_id, - created_at=datetime.now(), - reference=data.reference, - # Receivables are written cleared (format_receivable_entry uses - # flag="*") — reporting PENDING here misled the UI (libra-#35). - flag=JournalEntryFlag.CLEARED, - meta=entry_meta, - lines=[ - EntryLine( - id=f"line-1-{entry_id}", - journal_entry_id=entry_id, - account_id=user_receivable.id, - amount=amount_sats, - description=f"Amount owed by user {data.user_id[:8]}", - metadata=metadata or {} - ), - EntryLine( - id=f"line-2-{entry_id}", - journal_entry_id=entry_id, - account_id=revenue_account.id, - amount=-amount_sats, - description="Revenue earned", - metadata=metadata or {} - ), - ] - ) - - -@libra_api_router.post("/api/v1/entries/revenue", status_code=HTTPStatus.CREATED) -async def api_create_revenue_entry( - data: RevenueEntry, - auth: AuthContext = Depends(require_super_user), -) -> JournalEntry: - """ - Create a revenue entry (libra receives payment). - Admin only. - - Submits entry to Fava/Beancount. - """ - from .fava_client import get_fava_client - from .beancount_format import format_revenue_entry - - # Get revenue account - revenue_account = await get_account_by_name(data.revenue_account) - if not revenue_account: - revenue_account = await get_account(data.revenue_account) - if not revenue_account: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail=f"Revenue account '{data.revenue_account}' not found", - ) - - # Get payment method account - payment_account = await get_account_by_name(data.payment_method_account) - if not payment_account: - payment_account = await get_account(data.payment_method_account) - if not payment_account: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail=f"Payment account '{data.payment_method_account}' not found", - ) - - # Handle currency conversion if provided - amount_sats = int(data.amount) - fiat_currency = None - fiat_amount = None - - if data.currency: - # Validate currency - if data.currency.upper() not in allowed_currencies(): - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Currency '{data.currency}' not supported. Allowed: {', '.join(allowed_currencies())}", - ) - - # Store fiat info for cost basis - fiat_currency = data.currency.upper() - fiat_amount = data.amount # Original fiat amount - # In this case, data.amount should be the satoshi amount - # This is a bit confusing - the API accepts amount as Decimal which could be either sats or fiat - # For now, assume if currency is provided, amount is fiat and needs conversion - # TODO: Consider updating the API model to be clearer about this - - # Format as Beancount entry and submit to Fava - fava = get_fava_client() - - # Generate unique entry ID for tracking - import uuid - entry_id = str(uuid.uuid4()).replace("-", "")[:16] - - # Identity travels as entry-id metadata; the user reference becomes - # its own link. - entry = format_revenue_entry( - payment_account=payment_account.name, - revenue_account=revenue_account.name, - amount_sats=amount_sats, - description=data.description, - entry_date=datetime.now().date(), - fiat_currency=fiat_currency, - fiat_amount=fiat_amount, - reference=data.reference, - entry_id=entry_id, - ) - - # Submit to Fava - result = await fava.add_entry(entry) - logger.info(f"Revenue entry submitted to Fava: {result.get('data', 'Unknown')}") - - # Return simplified JournalEntry for API compatibility - # Note: Libra no longer stores entries in DB, Fava is the source of truth - return JournalEntry( - id=entry_id, - description=data.description, - entry_date=datetime.now(), - created_by=auth.user_id, - created_at=datetime.now(), - reference=data.reference, - flag=JournalEntryFlag.CLEARED, - lines=[], # Empty - entry is stored in Fava, not Libra DB - meta={"source": "fava", "fava_response": result.get('data', 'Unknown')} - ) - - -# ===== USER BALANCE ENDPOINTS ===== - - -@libra_api_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"], - ) - - -@libra_api_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"], - ) - - -@libra_api_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 ===== - - -@libra_api_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 - } - - -@libra_api_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", - } - - -@libra_api_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}", - } - - -@libra_api_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 ===== - - -@libra_api_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 - - -@libra_api_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 ===== - - -@libra_api_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, - } - - -@libra_api_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 - - -@libra_api_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 - - -@libra_api_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) - } - - -@libra_api_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) - } - - -@libra_api_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) - } - - -@libra_api_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 - - -@libra_api_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 ===== - - -@libra_api_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 - ) - - -@libra_api_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) - - -@libra_api_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) - - -@libra_api_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 - - -@libra_api_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 ===== - - -@libra_api_router.post("/api/v1/entries/{entry_id}/approve") -async def api_approve_expense_entry( - entry_id: str, - auth: AuthContext = Depends(require_super_user), -) -> dict: - """ - Approve a pending expense entry by changing flag from '!' to '*' (super user only). - - This updates the transaction in the Beancount file via Fava API. - """ - from .fava_client import ChecksumConflictError, get_fava_client - - fava = get_fava_client() - - # 1. Get all journal entries from Fava - all_entries = await fava.get_journal_entries() - - # 2. Find the pending transaction with matching canonical entry id - target_entry = None - - for entry in all_entries: - # Only look at transactions with pending flag - if entry.get("t") == "Transaction" and entry.get("flag") == "!": - if _extract_entry_id(entry) == entry_id: - target_entry = entry - break - - if not target_entry: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail=f"Pending entry {entry_id} not found in Beancount ledger" - ) - - # Get entry metadata for file location - meta = target_entry.get("meta", {}) - filename = meta.get("filename") - lineno = meta.get("lineno") - date_str = target_entry.get("date", "") - - if not filename or not lineno: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Entry metadata missing filename or lineno" - ) - - # 3. Flip the flag under FavaClient's write lock — the whole - # read-modify-write is atomic against every other ledger writer. - old_pattern = f"{date_str} !" - - def _approve(line: str) -> str: - if old_pattern not in line: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Line {lineno} does not contain expected pattern '{old_pattern}'. Found: {line}" - ) - return line.replace(old_pattern, f"{date_str} *", 1) - - try: - await fava.transform_source_line(filename, lineno, _approve) - except ValueError as e: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e) - ) - except ChecksumConflictError: - raise HTTPException( - status_code=HTTPStatus.CONFLICT, - detail="Ledger changed concurrently; retry the approval", - ) - - logger.info(f"Entry {entry_id} approved (flag changed to *)") - - return { - "message": f"Entry {entry_id} approved successfully", - "entry_id": entry_id, - "date": date_str, - "description": target_entry.get("narration", "") - } - - -@libra_api_router.post("/api/v1/entries/{entry_id}/reject") -async def api_reject_expense_entry( - entry_id: str, - auth: AuthContext = Depends(require_super_user), -) -> dict: - """ - Reject a pending expense entry by marking it as voided (super user only). - - Adds #voided tag for audit trail while keeping the '!' flag. - Voided transactions are excluded from balances but preserved in the ledger. - """ - from .fava_client import ChecksumConflictError, get_fava_client - - fava = get_fava_client() - - # 1. Get all journal entries from Fava - all_entries = await fava.get_journal_entries() - - # 2. Find the pending transaction with matching canonical entry id - target_entry = None - - for entry in all_entries: - # Only look at transactions with pending flag - if entry.get("t") == "Transaction" and entry.get("flag") == "!": - if _extract_entry_id(entry) == entry_id: - target_entry = entry - break - - if not target_entry: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail=f"Pending entry {entry_id} not found in Beancount ledger" - ) - - # Get entry metadata for file location - meta = target_entry.get("meta", {}) - filename = meta.get("filename") - lineno = meta.get("lineno") - date_str = target_entry.get("date", "") - - if not filename or not lineno: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail="Entry metadata missing filename or lineno" - ) - - # 3. Add the #voided tag under FavaClient's write lock — the whole - # read-modify-write is atomic against every other ledger writer. - def _void(line: str) -> str: - if "#voided" in line: - return line # already voided — no-op - return line.rstrip() + ' #voided' - - try: - changed = await fava.transform_source_line(filename, lineno, _void) - except ValueError as e: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e) - ) - except ChecksumConflictError: - raise HTTPException( - status_code=HTTPStatus.CONFLICT, - detail="Ledger changed concurrently; retry the rejection", - ) - if changed: - logger.info(f"Entry {entry_id} rejected (added #voided tag)") - - return { - "message": f"Entry {entry_id} rejected (marked as voided)", - "entry_id": entry_id, - "date": date_str, - "description": target_entry.get("narration", "") - } - - -# ===== BALANCE ASSERTION ENDPOINTS ===== - - -@libra_api_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 - - -@libra_api_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, - ) - - -@libra_api_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 - - -@libra_api_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 - - -@libra_api_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 ===== - - -@libra_api_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(), - } - - -@libra_api_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 - - -@libra_api_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 ===== - - -@libra_api_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 ===== - - -@libra_api_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, - ) - - -@libra_api_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) - - -@libra_api_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 - - -@libra_api_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 ===== - - -@libra_api_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) - - -@libra_api_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 - - -@libra_api_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", - } - - -@libra_api_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 - - -@libra_api_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 ===== - - -@libra_api_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 ===== - - -@libra_api_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 ===== - - -_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), - ) - - -@libra_api_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, - } - - -@libra_api_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)}" - ) - - -@libra_api_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 ===== - - -@libra_api_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 - - -@libra_api_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)}" - ) - - -@libra_api_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 - ], - } - - -@libra_api_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(), - } - - -@libra_api_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 ===== - - -@libra_api_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)}" - ) - - -@libra_api_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 ===== - - -@libra_api_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)}" - ) - - -@libra_api_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 - - -@libra_api_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"} - - -@libra_api_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 - ] - - -@libra_api_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 - ], - } diff --git a/views_api/__init__.py b/views_api/__init__.py new file mode 100644 index 0000000..bd79f14 --- /dev/null +++ b/views_api/__init__.py @@ -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) diff --git a/views_api/_shared.py b/views_api/_shared.py new file mode 100644 index 0000000..09b218a --- /dev/null +++ b/views_api/_shared.py @@ -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), + ) + + diff --git a/views_api/accounts.py b/views_api/accounts.py new file mode 100644 index 0000000..eff317d --- /dev/null +++ b/views_api/accounts.py @@ -0,0 +1,328 @@ +"""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", + ) + + +@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 ===== + +@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 ===== diff --git a/views_api/admin.py b/views_api/admin.py new file mode 100644 index 0000000..88114ec --- /dev/null +++ b/views_api/admin.py @@ -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 + ], + } diff --git a/views_api/entries.py b/views_api/entries.py new file mode 100644 index 0000000..6df5545 --- /dev/null +++ b/views_api/entries.py @@ -0,0 +1,1209 @@ +"""Libra API — entries endpoints (moved verbatim from views_api.py).""" +from fastapi import APIRouter + +from ._shared import * # noqa: F401,F403 +from ._shared import ( # noqa: F401 + _SYNTHETIC_FLAGS, + _SYSTEM_LINK_PREFIXES, + _extract_entry_id, +) + +router = APIRouter() + +@router.get("/api/v1/entries") +async def api_get_journal_entries( + limit: int = 100, + auth: AuthContext = Depends(require_super_user), +) -> list[dict]: + """ + Get all journal entries from Fava/Beancount. + + Returns all transactions in reverse chronological order with username enrichment. + SUPER USER ONLY - exposes all transaction data. + """ + from lnbits.core.crud.users import get_user + from ..fava_client import get_fava_client + + fava = get_fava_client() + all_entries = await fava.get_journal_entries() + + # Filter to transactions only and enrich with username + enriched_entries = [] + for e in all_entries: + if e.get("t") != "Transaction": + continue + if e.get("flag") in _SYNTHETIC_FLAGS: + continue + + # Extract user ID from metadata or account names + user_id = None + entry_meta = e.get("meta", {}) + if "user-id" in entry_meta: + user_id = entry_meta["user-id"] + else: + # Try to extract from account names in postings + for posting in e.get("postings", []): + account = posting.get("account", "") + if "User-" in account: + parts = account.split("User-") + if len(parts) > 1: + user_id = parts[1] + break + + # Look up username + username = None + if user_id: + user = await get_user(user_id) + username = user.username if user and user.username else f"User-{user_id[:8]}" + + # Add username to entry + enriched_entry = dict(e) + enriched_entry["user_id"] = user_id + enriched_entry["username"] = username + enriched_entries.append(enriched_entry) + + if len(enriched_entries) >= limit: + break + + return enriched_entries + + +# Link prefixes written by libra itself (vs user-supplied references): +# exp-/rcv-/inc- typed entry links, ln- lightning payment links, and the +# legacy libra-{id} identity link. +@router.get("/api/v1/entries/user") +async def api_get_user_entries( + wallet: WalletTypeInfo = Depends(require_invoice_key), + limit: int = 20, + offset: int = 0, + filter_user_id: str = None, + filter_account_type: str = None, # 'asset' for receivable, 'liability' for payable + days: int = 15, # Default 15 days, options: 15, 30, 60 + start_date: str = None, # ISO format: YYYY-MM-DD + end_date: str = None, # ISO format: YYYY-MM-DD +) -> dict: + """ + Get journal entries that affect the current user's accounts from Fava/Beancount. + + Returns transactions in reverse chronological order with optional filtering. + + Args: + days: Number of days to fetch (default: 15, options: 15, 30, 60) + start_date: Start date for custom range (YYYY-MM-DD). Requires end_date. + end_date: End date for custom range (YYYY-MM-DD). Requires start_date. + + Note: + If both days and start_date/end_date are provided, start_date/end_date takes precedence. + """ + from lnbits.settings import settings as lnbits_settings + from ..fava_client import get_fava_client + + fava = get_fava_client() + + # Determine which user's entries to fetch + if wallet.wallet.user == lnbits_settings.super_user: + # Super user can view all or filter by user_id + target_user_id = filter_user_id + else: + # Regular user can only see their own entries + target_user_id = wallet.wallet.user + + # Get journal entries from Fava + # Priority: custom date range > days > default (5 days) + all_entries = await fava.get_journal_entries( + days=days, + start_date=start_date, + end_date=end_date + ) + + # Filter and transform entries + filtered_entries = [] + for e in all_entries: + if e.get("t") != "Transaction": + continue + if e.get("flag") in _SYNTHETIC_FLAGS: + continue + + # Extract user ID from metadata or account names + user_id_match = None + entry_meta = e.get("meta", {}) + if "user-id" in entry_meta: + user_id_match = entry_meta["user-id"] + else: + # Try to extract from account names in postings + for posting in e.get("postings", []): + account = posting.get("account", "") + if "User-" in account: + # Extract user ID from account name (e.g., "Liabilities:Payable:User-abc123") + parts = account.split("User-") + if len(parts) > 1: + user_id_match = parts[1] # Just the short ID after User- + break + + # Filter by target user if specified + if target_user_id and user_id_match: + if not user_id_match.startswith(target_user_id[:8]): + continue + + # Filter by account type if specified + if filter_account_type and user_id_match: + postings = e.get("postings", []) + has_matching_account = False + for posting in postings: + account = posting.get("account", "") + if filter_account_type.lower() == "asset" and "Receivable" in account: + has_matching_account = True + break + elif filter_account_type.lower() == "liability" and "Payable" in account: + has_matching_account = True + break + if not has_matching_account: + continue + + # Extract data for frontend + # Resolve canonical entry ID (metadata first, link fallback) + entry_id = _extract_entry_id(e) + links = e.get("links", []) + + # Extract amount from postings + amount_sats = 0 + fiat_amount = None + fiat_currency = None + + postings = e.get("postings", []) + if postings: + first_posting = postings[0] + if isinstance(first_posting, dict): + amount_str = first_posting.get("amount", "") + + # Parse amount string: price notation, simple fiat, or legacy SATS format + if isinstance(amount_str, str) and amount_str: + import re + + # Try total price notation: "50.00 EUR @@ 50000 SATS" + total_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@@\s+(\d+)\s+SATS$', amount_str) + # Try per-unit price notation: "50.00 EUR @ 1000.5 SATS" + unit_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$', amount_str) + + if total_price_match: + fiat_amount = abs(float(total_price_match.group(1))) + fiat_currency = total_price_match.group(2) + amount_sats = abs(int(total_price_match.group(3))) + elif unit_price_match: + fiat_amount = abs(float(unit_price_match.group(1))) + fiat_currency = unit_price_match.group(2) + sats_per_unit = float(unit_price_match.group(3)) + amount_sats = abs(int(fiat_amount * sats_per_unit)) + + # Try simple fiat format: "50.00 EUR" (check metadata for sats) + elif re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str): + fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str) + if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'): + fiat_amount = abs(float(fiat_match.group(1))) + fiat_currency = fiat_match.group(2) + + # Get SATS from metadata (legacy) + posting_meta = first_posting.get("meta", {}) + sats_equiv = posting_meta.get("sats-equivalent") + if sats_equiv: + amount_sats = abs(int(sats_equiv)) + + else: + # Old format: "36791 SATS {33.33 EUR, 2025-11-09}" or "36791 SATS" + sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str) + if sats_match: + amount_sats = abs(int(sats_match.group(1))) + + # Extract fiat from cost syntax: {33.33 EUR, ...} + cost_match = re.search(r'\{([\d.]+)\s+([A-Z]+)', amount_str) + if cost_match: + fiat_amount = float(cost_match.group(1)) + fiat_currency = cost_match.group(2) + + # Extract reference from links (first link that isn't a + # libra-system link: typed entry/settlement links, lightning + # payment links, or the legacy libra-{id} identity link) + reference = None + if isinstance(links, (list, set)): + for link in links: + if isinstance(link, str): + link_clean = link.lstrip('^') + if not link_clean.startswith(_SYSTEM_LINK_PREFIXES): + reference = link_clean + break + + # Look up actual username using helper function + username = await get_username(user_id_match) if user_id_match else None + + entry_data = { + "id": entry_id or e.get("entry_hash", "unknown"), + "date": e.get("date", ""), + "entry_date": e.get("date", ""), + "flag": e.get("flag"), + "description": e.get("narration", ""), + "payee": e.get("payee"), + "tags": e.get("tags", []), + "links": links, + "amount": amount_sats, + "user_id": user_id_match, + "username": username, + "reference": reference, + "meta": entry_meta, # Include metadata for frontend + } + + if fiat_amount and fiat_currency: + entry_data["fiat_amount"] = fiat_amount + entry_data["fiat_currency"] = fiat_currency + + filtered_entries.append(entry_data) + + # Sort by date descending + filtered_entries.sort(key=lambda x: x.get("date", ""), reverse=True) + + # Apply pagination + total = len(filtered_entries) + paginated_entries = filtered_entries[offset:offset + limit] + + return { + "entries": paginated_entries, + "total": total, + "limit": limit, + "offset": offset, + "has_next": (offset + limit) < total, + "has_prev": offset > 0, + } + + +@router.get("/api/v1/entries/pending") +async def api_get_pending_entries( + auth: AuthContext = Depends(require_super_user), +) -> list[dict]: + """ + Get all pending expense entries that need approval (super user only). + + Returns transactions with flag='!' from Fava/Beancount. + """ + from ..fava_client import get_fava_client + + # Query Fava for all journal entries (includes links, tags, full metadata) + fava = get_fava_client() + all_entries = await fava.get_journal_entries() + + # Filter for pending transactions and extract info + pending_entries = [] + + for e in all_entries: + # Only include pending transactions that are NOT voided + if e.get("t") == "Transaction" and e.get("flag") == "!" and "voided" not in e.get("tags", []): + # Resolve canonical entry ID (metadata first, link fallback) + entry_id = _extract_entry_id(e) + links = e.get("links", []) + + # Extract user ID from metadata or account names + user_id = None + entry_meta = e.get("meta", {}) + logger.info(f"[EXTRACT] Entry metadata keys: {list(entry_meta.keys())}") + logger.info(f"[EXTRACT] Entry metadata: {entry_meta}") + if "user-id" in entry_meta: + user_id = entry_meta["user-id"] + logger.info(f"[EXTRACT] Found user-id in metadata: {user_id}") + else: + logger.info(f"[EXTRACT] No user-id in metadata, checking account names") + # Try to extract from account names in postings + for posting in e.get("postings", []): + account = posting.get("account", "") + if "User-" in account: + # Extract user ID from account name (e.g., "Liabilities:Payable:User-abc123") + parts = account.split("User-") + if len(parts) > 1: + user_id = parts[1] # Short ID after User- + logger.info(f"[EXTRACT] Extracted user_id from account name: {user_id}") + break + + # Look up username using helper function + username = await get_username(user_id) if user_id else None + + # Extract amount from postings (sum of absolute values / 2) + amount_sats = 0 + fiat_amount = None + fiat_currency = None + + postings = e.get("postings", []) + if postings: + first_posting = postings[0] + if isinstance(first_posting, dict): + amount_str = first_posting.get("amount", "") + + # Parse amount string format + if isinstance(amount_str, str) and amount_str: + import re + + # Try total price notation: "50.00 EUR @@ 50000 SATS" + total_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@@\s+(\d+)\s+SATS$', amount_str) + # Try per-unit price notation: "50.00 EUR @ 1000.5 SATS" + unit_price_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})\s+@\s+([\d.]+)\s+SATS$', amount_str) + + if total_price_match: + fiat_amount = abs(float(total_price_match.group(1))) + fiat_currency = total_price_match.group(2) + amount_sats = abs(int(total_price_match.group(3))) + elif unit_price_match: + fiat_amount = abs(float(unit_price_match.group(1))) + fiat_currency = unit_price_match.group(2) + sats_per_unit = float(unit_price_match.group(3)) + amount_sats = abs(int(fiat_amount * sats_per_unit)) + + # Try simple fiat format: "50.00 EUR" (check metadata for sats) + elif re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str): + fiat_match = re.match(r'^(-?[\d.]+)\s+([A-Z]{3})$', amount_str) + if fiat_match and fiat_match.group(2) in ('EUR', 'USD', 'GBP'): + fiat_amount = abs(float(fiat_match.group(1))) + fiat_currency = fiat_match.group(2) + + # Extract sats equivalent from metadata (legacy) + posting_meta = first_posting.get("meta", {}) + sats_equiv = posting_meta.get("sats-equivalent") + if sats_equiv: + amount_sats = abs(int(sats_equiv)) + + else: + # Legacy SATS format: "36791 SATS {33.33 EUR, 2025-11-09}" or "36791 SATS" + sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str) + if sats_match: + amount_sats = abs(int(sats_match.group(1))) + + # Extract fiat from cost syntax: {33.33 EUR, ...} + cost_match = re.search(r'\{([\d.]+)\s+([A-Z]+)', amount_str) + if cost_match: + fiat_amount = float(cost_match.group(1)) + fiat_currency = cost_match.group(2) + + entry_data = { + "id": entry_id or "unknown", + "date": e.get("date", ""), + "entry_date": e.get("date", ""), + "flag": e.get("flag"), + "description": e.get("narration", ""), + "payee": e.get("payee"), + "tags": e.get("tags", []), + "links": links, + "amount": amount_sats, + "user_id": user_id, + "username": username, + } + + # Add fiat info if available + if fiat_amount and fiat_currency: + entry_data["fiat_amount"] = fiat_amount + entry_data["fiat_currency"] = fiat_currency + + pending_entries.append(entry_data) + + return pending_entries + + +@router.post("/api/v1/entries", status_code=HTTPStatus.CREATED) +async def api_create_journal_entry( + data: CreateJournalEntry, + wallet: WalletTypeInfo = Depends(require_invoice_key), +) -> JournalEntry: + """ + Create a new generic journal entry. + + Submits entry to Fava/Beancount. + """ + from ..fava_client import get_fava_client + from ..beancount_format import ( + format_transaction, + format_posting_with_cost, + sanitize_link, + ) + + # Validate that entry balances to zero + total = sum(line.amount for line in data.lines) + if total != 0: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Entry does not balance (total: {total}, expected: 0)" + ) + + # Get all accounts and validate they exist + account_map = {} + for line in data.lines: + account = await get_account(line.account_id) + if not account: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Account '{line.account_id}' not found" + ) + account_map[line.account_id] = account + + # Format postings + postings = [] + for line in data.lines: + account = account_map[line.account_id] + + # Extract fiat info from metadata if present + fiat_currency = line.metadata.get("fiat_currency") + fiat_amount_str = line.metadata.get("fiat_amount") + fiat_amount = Decimal(fiat_amount_str) if fiat_amount_str else None + + # Create posting metadata (excluding fiat fields that are used for primary amount) + posting_metadata = {k: v for k, v in line.metadata.items() + if k not in ["fiat_currency", "fiat_amount"]} + + # If fiat currency is provided, use EUR-based format (primary amount in EUR, sats in metadata) + # Otherwise, use SATS-based format + if fiat_currency and fiat_amount: + # EUR-based posting (current architecture) + posting_metadata["sats-equivalent"] = str(abs(line.amount)) + + # Apply the sign from line.amount to fiat_amount + # line.amount is positive for debits, negative for credits + signed_fiat_amount = fiat_amount if line.amount >= 0 else -fiat_amount + + posting = { + "account": account.name, + "amount": f"{signed_fiat_amount:.2f} {fiat_currency}", + "meta": posting_metadata if posting_metadata else None + } + else: + # SATS-based posting (legacy/fallback) + if line.description: + posting_metadata["description"] = line.description + + posting = format_posting_with_cost( + account=account.name, + amount_sats=line.amount, + fiat_currency=None, + fiat_amount=None, + metadata=posting_metadata if posting_metadata else None + ) + + postings.append(posting) + + # Extract tags and links from meta + tags = data.meta.get("tags", []) + links = data.meta.get("links", []) + if data.reference: + links.append(sanitize_link(data.reference)) + + # Entry metadata (excluding tags and links which go at transaction level) + entry_meta = {k: v for k, v in data.meta.items() if k not in ["tags", "links"]} + entry_meta["source"] = "libra-api" + entry_meta["created-by"] = wallet.wallet.user # Use user_id, not wallet_id + + # Format as Beancount entry + fava = get_fava_client() + + entry = format_transaction( + date_val=data.entry_date.date() if data.entry_date else datetime.now().date(), + flag=data.flag.value if data.flag else "*", + narration=data.description, + postings=postings, + tags=tags if tags else None, + links=links if links else None, + meta=entry_meta + ) + + # Submit to Fava + result = await fava.add_entry(entry) + logger.info(f"Journal entry submitted to Fava: {result.get('data', 'Unknown')}") + + # Return simplified JournalEntry for API compatibility + # Note: Libra no longer stores entries in DB, Fava is the source of truth + timestamp = datetime.now().timestamp() + return JournalEntry( + id=f"fava-{timestamp}", + description=data.description, + entry_date=data.entry_date if data.entry_date else datetime.now(), + created_by=wallet.wallet.user, # Use user_id, not wallet_id + created_at=datetime.now(), + reference=data.reference, + flag=data.flag if data.flag else JournalEntryFlag.CLEARED, + lines=[], # Empty - entry is stored in Fava, not Libra DB + meta={"source": "fava", "fava_response": result.get('data', 'Unknown')} + ) + + +# ===== SIMPLIFIED ENTRY ENDPOINTS ===== + + +@router.post("/api/v1/entries/expense", status_code=HTTPStatus.CREATED) +async def api_create_expense_entry( + data: ExpenseEntry, + wallet: WalletTypeInfo = Depends(require_invoice_key), +) -> JournalEntry: + """ + Create an expense entry for a user. + If is_equity=True, records as equity contribution. + If is_equity=False, records as liability (libra owes user). + + If currency is provided, amount is converted from fiat to satoshis. + """ + # Check that libra wallet is configured + await check_libra_wallet_configured() + + # Check that user has configured their wallet + await check_user_wallet_configured(wallet.wallet.user) + # Handle currency conversion + amount_sats = int(data.amount) + metadata = {} + + if data.currency: + # Validate currency + if data.currency.upper() not in allowed_currencies(): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Currency '{data.currency}' not allowed. Use one of: {', '.join(allowed_currencies())}", + ) + + # Convert fiat to satoshis + amount_sats = await fiat_amount_as_satoshis(float(data.amount), data.currency) + + # Store currency metadata (store fiat_amount as string to preserve Decimal precision) + metadata = { + "fiat_currency": data.currency.upper(), + "fiat_amount": str(data.amount.quantize(Decimal("0.001"))), # Store as string with 3 decimal places + **fiat_rate_metadata(amount_sats, data.amount), + } + + # Get or create expense account + expense_account = await get_account_by_name(data.expense_account) + if not expense_account: + # Try to get it by ID + expense_account = await get_account(data.expense_account) + if not expense_account: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Expense account '{data.expense_account}' not found", + ) + + # Validate user has permission to submit expenses to this account + from ..crud import get_user_permissions_with_inheritance + + submit_perms = await get_user_permissions_with_inheritance( + wallet.wallet.user, expense_account.name, PermissionType.SUBMIT_EXPENSE + ) + + if not submit_perms: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail=f"You do not have permission to submit expenses to account '{expense_account.name}'. Please contact an administrator to request access.", + ) + + # Get or create user-specific account + if data.is_equity: + # Validate equity eligibility + from ..crud import get_user_equity_status + + equity_status = await get_user_equity_status(wallet.wallet.user) + + if not equity_status or not equity_status.is_equity_eligible: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="User is not eligible to contribute expenses to equity. Please submit for cash reimbursement.", + ) + + if not equity_status.equity_account_name: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="User equity account not configured. Contact administrator.", + ) + + # Equity contribution - use user's specific equity account + user_account = await get_account_by_name(equity_status.equity_account_name) + if not user_account: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Equity account '{equity_status.equity_account_name}' not found. Contact administrator.", + ) + else: + # Liability (libra owes user) + user_account = await get_or_create_user_account( + wallet.wallet.user, AccountType.LIABILITY, "Accounts Payable" + ) + + # Create journal entry + # DR Expense, CR User Account (Liability or Equity) + description_suffix = f" ({metadata['fiat_amount']} {metadata['fiat_currency']})" if metadata else "" + + # Add meta information for audit trail + entry_meta = { + "source": "api", + "created_via": "expense_entry", + "user_id": wallet.wallet.user, + "is_equity": data.is_equity, + } + + # Format as Beancount entry and submit to Fava + from ..fava_client import get_fava_client + from ..beancount_format import format_expense_entry + + fava = get_fava_client() + + # Extract fiat info from metadata + fiat_currency = metadata.get("fiat_currency") if metadata else None + fiat_amount = Decimal(metadata.get("fiat_amount")) if metadata and metadata.get("fiat_amount") else None + + # Generate unique entry ID for tracking + import uuid + entry_id = str(uuid.uuid4()).replace("-", "")[:16] + + # Format Beancount entry. Identity travels as entry-id metadata + + # exp-{entry_id} link; the user reference becomes its own link. + entry = format_expense_entry( + user_id=wallet.wallet.user, + expense_account=expense_account.name, + user_account=user_account.name, + amount_sats=amount_sats, + description=data.description, + entry_date=data.entry_date.date() if data.entry_date else datetime.now().date(), + is_equity=data.is_equity, + fiat_currency=fiat_currency, + fiat_amount=fiat_amount, + reference=data.reference, + entry_id=entry_id + ) + + # Submit to Fava + result = await fava.add_entry(entry) + + # Return a JournalEntry-like response for compatibility + from ..models import EntryLine + return JournalEntry( + id=entry_id, # Use the generated libra entry ID + description=data.description + description_suffix, + entry_date=data.entry_date if data.entry_date else datetime.now(), + created_by=wallet.wallet.user, # Use user_id, not wallet_id + created_at=datetime.now(), + reference=data.reference, + flag=JournalEntryFlag.PENDING, + meta=entry_meta, + lines=[ + EntryLine( + id=f"line-1-{entry_id}", + journal_entry_id=entry_id, + account_id=expense_account.id, + amount=amount_sats, + description=f"Expense paid by user {wallet.wallet.user[:8]}", + metadata=metadata or {} + ), + EntryLine( + id=f"line-2-{entry_id}", + journal_entry_id=entry_id, + account_id=user_account.id, + amount=-amount_sats, + description=f"{'Equity contribution' if data.is_equity else 'Amount owed to user'}", + metadata=metadata or {} + ), + ] + ) + + +@router.post("/api/v1/entries/income", status_code=HTTPStatus.CREATED) +async def api_create_income_entry( + data: IncomeEntry, + wallet: WalletTypeInfo = Depends(require_invoice_key), +) -> JournalEntry: + """ + Create a user-submitted income/revenue entry (pending approval). + + Mirrors the expense submission flow: entry is created with '!' (pending) + flag and goes through the same /api/v1/entries/{id}/approve and /reject + endpoints. Requires SUBMIT_INCOME permission on the revenue account. + + Postings: DR payment_method_account (asset), CR revenue_account. + """ + # Validate currency + if data.currency.upper() not in allowed_currencies(): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Currency '{data.currency}' not allowed. Use one of: {', '.join(allowed_currencies())}", + ) + + # Resolve revenue account by name or ID + revenue_account = await get_account_by_name(data.revenue_account) + if not revenue_account: + revenue_account = await get_account(data.revenue_account) + if not revenue_account: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Revenue account '{data.revenue_account}' not found", + ) + if revenue_account.account_type != AccountType.REVENUE: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Account '{revenue_account.name}' is not a revenue account (type: {revenue_account.account_type.value})", + ) + + # Permission check on the revenue account + from ..crud import get_user_permissions_with_inheritance + + submit_perms = await get_user_permissions_with_inheritance( + wallet.wallet.user, revenue_account.name, PermissionType.SUBMIT_INCOME + ) + if not submit_perms: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail=f"You do not have permission to submit income to account '{revenue_account.name}'. Please contact an administrator to request access.", + ) + + # Income lands on the user as a receivable — they're holding cash on + # behalf of the entity until they hand it over via /settle-receivable. + user_account = await get_or_create_user_account( + wallet.wallet.user, AccountType.ASSET, "Accounts Receivable" + ) + + # Convert fiat to sats + fiat_currency = data.currency.upper() + amount_sats = await fiat_amount_as_satoshis(float(data.amount), data.currency) + + metadata = { + "fiat_currency": fiat_currency, + "fiat_amount": str(data.amount.quantize(Decimal("0.001"))), + **fiat_rate_metadata(amount_sats, data.amount), + } + + # Submit to Fava + from ..fava_client import get_fava_client + from ..beancount_format import format_income_entry + + fava = get_fava_client() + + import uuid + entry_id = str(uuid.uuid4()).replace("-", "")[:16] + + # Identity travels as entry-id metadata + inc-{entry_id} link; the + # user reference becomes its own link. + entry = format_income_entry( + user_id=wallet.wallet.user, + user_account=user_account.name, + revenue_account=revenue_account.name, + amount_sats=amount_sats, + description=data.description, + entry_date=data.entry_date.date() if data.entry_date else datetime.now().date(), + fiat_currency=fiat_currency, + fiat_amount=data.amount, + reference=data.reference, + entry_id=entry_id, + ) + + result = await fava.add_entry(entry) + logger.info(f"Income entry {entry_id} submitted to Fava (pending): {result.get('data', 'Unknown')}") + + description_suffix = f" ({metadata['fiat_amount']} {fiat_currency})" + entry_meta = { + "source": "api", + "created_via": "income_entry", + "user_id": wallet.wallet.user, + } + + from ..models import EntryLine + return JournalEntry( + id=entry_id, + description=data.description + description_suffix, + entry_date=data.entry_date if data.entry_date else datetime.now(), + created_by=wallet.wallet.user, + created_at=datetime.now(), + reference=data.reference, + flag=JournalEntryFlag.PENDING, + meta=entry_meta, + lines=[ + EntryLine( + id=f"line-1-{entry_id}", + journal_entry_id=entry_id, + account_id=user_account.id, + amount=amount_sats, + description=f"User holds cash receivable to entity ({user_account.name})", + metadata=metadata, + ), + EntryLine( + id=f"line-2-{entry_id}", + journal_entry_id=entry_id, + account_id=revenue_account.id, + amount=-amount_sats, + description="Revenue earned (pending approval)", + metadata=metadata, + ), + ], + ) + + +@router.post("/api/v1/entries/receivable", status_code=HTTPStatus.CREATED) +async def api_create_receivable_entry( + data: ReceivableEntry, + auth: AuthContext = Depends(require_super_user), +) -> JournalEntry: + """ + Create an accounts receivable entry (user owes libra). + Admin only to prevent abuse. + + If currency is provided, amount is converted from fiat to satoshis. + """ + # Handle currency conversion + amount_sats = int(data.amount) + metadata = {} + + if data.currency: + # Validate currency + if data.currency.upper() not in allowed_currencies(): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Currency '{data.currency}' not allowed. Use one of: {', '.join(allowed_currencies())}", + ) + + # Convert fiat to satoshis + amount_sats = await fiat_amount_as_satoshis(float(data.amount), data.currency) + + # Store currency metadata (store fiat_amount as string to preserve Decimal precision) + metadata = { + "fiat_currency": data.currency.upper(), + "fiat_amount": str(data.amount.quantize(Decimal("0.001"))), # Store as string with 3 decimal places + **fiat_rate_metadata(amount_sats, data.amount), + } + + # Get or create revenue account + revenue_account = await get_account_by_name(data.revenue_account) + if not revenue_account: + revenue_account = await get_account(data.revenue_account) + if not revenue_account: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Revenue account '{data.revenue_account}' not found", + ) + + # Get or create user-specific receivable account + user_receivable = await get_or_create_user_account( + data.user_id, AccountType.ASSET, "Accounts Receivable" + ) + + # Create journal entry + # DR Accounts Receivable (User), CR Revenue + description_suffix = f" ({metadata['fiat_amount']} {metadata['fiat_currency']})" if metadata else "" + + # Add meta information for audit trail + entry_meta = { + "source": "api", + "created_via": "receivable_entry", + "debtor_user_id": data.user_id, + } + + # Format as Beancount entry and submit to Fava + from ..fava_client import get_fava_client + from ..beancount_format import format_receivable_entry + + fava = get_fava_client() + + # Extract fiat info from metadata + fiat_currency = metadata.get("fiat_currency") if metadata else None + fiat_amount = Decimal(metadata.get("fiat_amount")) if metadata and metadata.get("fiat_amount") else None + + # Generate unique entry ID for tracking + import uuid + entry_id = str(uuid.uuid4()).replace("-", "")[:16] + + # Format Beancount entry. Identity travels as entry-id metadata + + # rcv-{entry_id} link; the user reference becomes its own link. + entry = format_receivable_entry( + user_id=data.user_id, + revenue_account=revenue_account.name, + receivable_account=user_receivable.name, + amount_sats=amount_sats, + description=data.description, + entry_date=datetime.now().date(), + fiat_currency=fiat_currency, + fiat_amount=fiat_amount, + reference=data.reference, + entry_id=entry_id + ) + + # Submit to Fava + result = await fava.add_entry(entry) + + # Return a JournalEntry-like response for compatibility + from ..models import EntryLine + return JournalEntry( + id=entry_id, # Use the generated libra entry ID + description=data.description + description_suffix, + entry_date=datetime.now(), + created_by=auth.user_id, + created_at=datetime.now(), + reference=data.reference, + # Receivables are written cleared (format_receivable_entry uses + # flag="*") — reporting PENDING here misled the UI (libra-#35). + flag=JournalEntryFlag.CLEARED, + meta=entry_meta, + lines=[ + EntryLine( + id=f"line-1-{entry_id}", + journal_entry_id=entry_id, + account_id=user_receivable.id, + amount=amount_sats, + description=f"Amount owed by user {data.user_id[:8]}", + metadata=metadata or {} + ), + EntryLine( + id=f"line-2-{entry_id}", + journal_entry_id=entry_id, + account_id=revenue_account.id, + amount=-amount_sats, + description="Revenue earned", + metadata=metadata or {} + ), + ] + ) + + +@router.post("/api/v1/entries/revenue", status_code=HTTPStatus.CREATED) +async def api_create_revenue_entry( + data: RevenueEntry, + auth: AuthContext = Depends(require_super_user), +) -> JournalEntry: + """ + Create a revenue entry (libra receives payment). + Admin only. + + Submits entry to Fava/Beancount. + """ + from ..fava_client import get_fava_client + from ..beancount_format import format_revenue_entry + + # Get revenue account + revenue_account = await get_account_by_name(data.revenue_account) + if not revenue_account: + revenue_account = await get_account(data.revenue_account) + if not revenue_account: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Revenue account '{data.revenue_account}' not found", + ) + + # Get payment method account + payment_account = await get_account_by_name(data.payment_method_account) + if not payment_account: + payment_account = await get_account(data.payment_method_account) + if not payment_account: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Payment account '{data.payment_method_account}' not found", + ) + + # Handle currency conversion if provided + amount_sats = int(data.amount) + fiat_currency = None + fiat_amount = None + + if data.currency: + # Validate currency + if data.currency.upper() not in allowed_currencies(): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Currency '{data.currency}' not supported. Allowed: {', '.join(allowed_currencies())}", + ) + + # Store fiat info for cost basis + fiat_currency = data.currency.upper() + fiat_amount = data.amount # Original fiat amount + # In this case, data.amount should be the satoshi amount + # This is a bit confusing - the API accepts amount as Decimal which could be either sats or fiat + # For now, assume if currency is provided, amount is fiat and needs conversion + # TODO: Consider updating the API model to be clearer about this + + # Format as Beancount entry and submit to Fava + fava = get_fava_client() + + # Generate unique entry ID for tracking + import uuid + entry_id = str(uuid.uuid4()).replace("-", "")[:16] + + # Identity travels as entry-id metadata; the user reference becomes + # its own link. + entry = format_revenue_entry( + payment_account=payment_account.name, + revenue_account=revenue_account.name, + amount_sats=amount_sats, + description=data.description, + entry_date=datetime.now().date(), + fiat_currency=fiat_currency, + fiat_amount=fiat_amount, + reference=data.reference, + entry_id=entry_id, + ) + + # Submit to Fava + result = await fava.add_entry(entry) + logger.info(f"Revenue entry submitted to Fava: {result.get('data', 'Unknown')}") + + # Return simplified JournalEntry for API compatibility + # Note: Libra no longer stores entries in DB, Fava is the source of truth + return JournalEntry( + id=entry_id, + description=data.description, + entry_date=datetime.now(), + created_by=auth.user_id, + created_at=datetime.now(), + reference=data.reference, + flag=JournalEntryFlag.CLEARED, + lines=[], # Empty - entry is stored in Fava, not Libra DB + meta={"source": "fava", "fava_response": result.get('data', 'Unknown')} + ) + + +# ===== USER BALANCE ENDPOINTS ===== + +@router.post("/api/v1/entries/{entry_id}/approve") +async def api_approve_expense_entry( + entry_id: str, + auth: AuthContext = Depends(require_super_user), +) -> dict: + """ + Approve a pending expense entry by changing flag from '!' to '*' (super user only). + + This updates the transaction in the Beancount file via Fava API. + """ + from ..fava_client import ChecksumConflictError, get_fava_client + + fava = get_fava_client() + + # 1. Get all journal entries from Fava + all_entries = await fava.get_journal_entries() + + # 2. Find the pending transaction with matching canonical entry id + target_entry = None + + for entry in all_entries: + # Only look at transactions with pending flag + if entry.get("t") == "Transaction" and entry.get("flag") == "!": + if _extract_entry_id(entry) == entry_id: + target_entry = entry + break + + if not target_entry: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Pending entry {entry_id} not found in Beancount ledger" + ) + + # Get entry metadata for file location + meta = target_entry.get("meta", {}) + filename = meta.get("filename") + lineno = meta.get("lineno") + date_str = target_entry.get("date", "") + + if not filename or not lineno: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Entry metadata missing filename or lineno" + ) + + # 3. Flip the flag under FavaClient's write lock — the whole + # read-modify-write is atomic against every other ledger writer. + old_pattern = f"{date_str} !" + + def _approve(line: str) -> str: + if old_pattern not in line: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f"Line {lineno} does not contain expected pattern '{old_pattern}'. Found: {line}" + ) + return line.replace(old_pattern, f"{date_str} *", 1) + + try: + await fava.transform_source_line(filename, lineno, _approve) + except ValueError as e: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e) + ) + except ChecksumConflictError: + raise HTTPException( + status_code=HTTPStatus.CONFLICT, + detail="Ledger changed concurrently; retry the approval", + ) + + logger.info(f"Entry {entry_id} approved (flag changed to *)") + + return { + "message": f"Entry {entry_id} approved successfully", + "entry_id": entry_id, + "date": date_str, + "description": target_entry.get("narration", "") + } + + +@router.post("/api/v1/entries/{entry_id}/reject") +async def api_reject_expense_entry( + entry_id: str, + auth: AuthContext = Depends(require_super_user), +) -> dict: + """ + Reject a pending expense entry by marking it as voided (super user only). + + Adds #voided tag for audit trail while keeping the '!' flag. + Voided transactions are excluded from balances but preserved in the ledger. + """ + from ..fava_client import ChecksumConflictError, get_fava_client + + fava = get_fava_client() + + # 1. Get all journal entries from Fava + all_entries = await fava.get_journal_entries() + + # 2. Find the pending transaction with matching canonical entry id + target_entry = None + + for entry in all_entries: + # Only look at transactions with pending flag + if entry.get("t") == "Transaction" and entry.get("flag") == "!": + if _extract_entry_id(entry) == entry_id: + target_entry = entry + break + + if not target_entry: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Pending entry {entry_id} not found in Beancount ledger" + ) + + # Get entry metadata for file location + meta = target_entry.get("meta", {}) + filename = meta.get("filename") + lineno = meta.get("lineno") + date_str = target_entry.get("date", "") + + if not filename or not lineno: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Entry metadata missing filename or lineno" + ) + + # 3. Add the #voided tag under FavaClient's write lock — the whole + # read-modify-write is atomic against every other ledger writer. + def _void(line: str) -> str: + if "#voided" in line: + return line # already voided — no-op + return line.rstrip() + ' #voided' + + try: + changed = await fava.transform_source_line(filename, lineno, _void) + except ValueError as e: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e) + ) + except ChecksumConflictError: + raise HTTPException( + status_code=HTTPStatus.CONFLICT, + detail="Ledger changed concurrently; retry the rejection", + ) + if changed: + logger.info(f"Entry {entry_id} rejected (added #voided tag)") + + return { + "message": f"Entry {entry_id} rejected (marked as voided)", + "entry_id": entry_id, + "date": date_str, + "description": target_entry.get("narration", "") + } + + +# ===== BALANCE ASSERTION ENDPOINTS ===== diff --git a/views_api/payments.py b/views_api/payments.py new file mode 100644 index 0000000..4d48ea3 --- /dev/null +++ b/views_api/payments.py @@ -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 ===== diff --git a/views_api/permissions.py b/views_api/permissions.py new file mode 100644 index 0000000..acec4cc --- /dev/null +++ b/views_api/permissions.py @@ -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 ===== diff --git a/views_api/reconciliation.py b/views_api/reconciliation.py new file mode 100644 index 0000000..fa7f300 --- /dev/null +++ b/views_api/reconciliation.py @@ -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 ===== diff --git a/views_api/settings_reports.py b/views_api/settings_reports.py new file mode 100644 index 0000000..0254e28 --- /dev/null +++ b/views_api/settings_reports.py @@ -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, + )