Compare commits
No commits in common. "a0166d4026b0482867e2e6498baa59613392d6cb" and "ec6cac51f06fcee62b01081e3771145ef813cb89" have entirely different histories.
a0166d4026
...
ec6cac51f0
12 changed files with 4121 additions and 4356 deletions
|
|
@ -310,38 +310,6 @@ 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]:
|
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
|
"""Exchange-rate metadata (sats per fiat unit, fiat per BTC) as exact
|
||||||
Decimal strings.
|
Decimal strings.
|
||||||
|
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
"""Route-table snapshot guard for the views_api package split.
|
|
||||||
|
|
||||||
FastAPI matches routes in registration order, so a mechanical move of
|
|
||||||
endpoints between modules can silently change which endpoint answers a
|
|
||||||
path (literal vs {param} siblings). This snapshot pins the ordered
|
|
||||||
(methods, path, endpoint) table; the split must reproduce it exactly.
|
|
||||||
|
|
||||||
Regenerate after a DELIBERATE routing change with:
|
|
||||||
|
|
||||||
from libra.views_api import libra_api_router
|
|
||||||
for r in libra_api_router.routes:
|
|
||||||
print((",".join(sorted(r.methods)), r.path, r.endpoint.__name__))
|
|
||||||
"""
|
|
||||||
import importlib
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
def _module(name: str):
|
|
||||||
for prefix in ("lnbits.extensions.libra", "libra"):
|
|
||||||
try:
|
|
||||||
return importlib.import_module(f"{prefix}.{name}")
|
|
||||||
except ModuleNotFoundError:
|
|
||||||
continue
|
|
||||||
raise ModuleNotFoundError(f"libra.{name}: tried both import paths")
|
|
||||||
|
|
||||||
|
|
||||||
views_api = _module("views_api")
|
|
||||||
|
|
||||||
EXPECTED_ROUTES = [
|
|
||||||
("GET", "/api/v1/currencies", "api_get_currencies"),
|
|
||||||
("GET", "/api/v1/accounts", "api_get_accounts"),
|
|
||||||
("POST", "/api/v1/accounts", "api_create_account"),
|
|
||||||
("GET", "/api/v1/accounts/hierarchy", "api_get_account_hierarchy"),
|
|
||||||
("GET", "/api/v1/accounts/{account_id}", "api_get_account"),
|
|
||||||
("GET", "/api/v1/accounts/{account_id}/balance", "api_get_account_balance"),
|
|
||||||
("GET", "/api/v1/accounts/{account_id}/transactions", "api_get_account_transactions"),
|
|
||||||
("GET", "/api/v1/entries", "api_get_journal_entries"),
|
|
||||||
("GET", "/api/v1/entries/user", "api_get_user_entries"),
|
|
||||||
("GET", "/api/v1/entries/pending", "api_get_pending_entries"),
|
|
||||||
("POST", "/api/v1/entries", "api_create_journal_entry"),
|
|
||||||
("POST", "/api/v1/entries/expense", "api_create_expense_entry"),
|
|
||||||
("POST", "/api/v1/entries/income", "api_create_income_entry"),
|
|
||||||
("POST", "/api/v1/entries/receivable", "api_create_receivable_entry"),
|
|
||||||
("POST", "/api/v1/entries/revenue", "api_create_revenue_entry"),
|
|
||||||
("POST", "/api/v1/entries/{entry_id}/approve", "api_approve_expense_entry"),
|
|
||||||
("POST", "/api/v1/entries/{entry_id}/reject", "api_reject_expense_entry"),
|
|
||||||
("GET", "/api/v1/balance", "api_get_my_balance"),
|
|
||||||
("GET", "/api/v1/balance/{user_id}", "api_get_user_balance"),
|
|
||||||
("GET", "/api/v1/balances/all", "api_get_all_balances"),
|
|
||||||
("POST", "/api/v1/generate-payment-invoice", "api_generate_payment_invoice"),
|
|
||||||
("POST", "/api/v1/record-payment", "api_record_payment"),
|
|
||||||
("POST", "/api/v1/receivables/settle", "api_settle_receivable"),
|
|
||||||
("POST", "/api/v1/payables/pay", "api_pay_user"),
|
|
||||||
("POST", "/api/v1/manual-payment-request", "api_create_manual_payment_request"),
|
|
||||||
("GET", "/api/v1/manual-payment-requests", "api_get_manual_payment_requests"),
|
|
||||||
("GET", "/api/v1/manual-payment-requests/all", "api_get_all_manual_payment_requests"),
|
|
||||||
("POST", "/api/v1/manual-payment-requests/{request_id}/approve", "api_approve_manual_payment_request"),
|
|
||||||
("POST", "/api/v1/manual-payment-requests/{request_id}/reject", "api_reject_manual_payment_request"),
|
|
||||||
("GET", "/api/v1/settings", "api_get_settings"),
|
|
||||||
("PUT", "/api/v1/settings", "api_update_settings"),
|
|
||||||
("GET", "/api/v1/user-wallet/{user_id}", "api_get_user_wallet"),
|
|
||||||
("GET", "/api/v1/users", "api_get_all_users"),
|
|
||||||
("GET", "/api/v1/admin/libra-users", "api_get_libra_users"),
|
|
||||||
("GET", "/api/v1/reports/expenses", "api_expense_report"),
|
|
||||||
("GET", "/api/v1/reports/contributions", "api_contributions_report"),
|
|
||||||
("GET", "/api/v1/users/{user_id}/unsettled-entries", "api_get_unsettled_entries"),
|
|
||||||
("GET", "/api/v1/user/wallet", "api_get_user_wallet"),
|
|
||||||
("PUT", "/api/v1/user/wallet", "api_update_user_wallet"),
|
|
||||||
("GET", "/api/v1/user/info", "api_get_user_info"),
|
|
||||||
("POST", "/api/v1/assertions", "api_create_balance_assertion"),
|
|
||||||
("GET", "/api/v1/assertions", "api_get_balance_assertions"),
|
|
||||||
("GET", "/api/v1/assertions/{assertion_id}", "api_get_balance_assertion"),
|
|
||||||
("POST", "/api/v1/assertions/{assertion_id}/check", "api_check_balance_assertion"),
|
|
||||||
("DELETE", "/api/v1/assertions/{assertion_id}", "api_delete_balance_assertion"),
|
|
||||||
("GET", "/api/v1/reconciliation/summary", "api_get_reconciliation_summary"),
|
|
||||||
("POST", "/api/v1/reconciliation/check-all", "api_check_all_assertions"),
|
|
||||||
("GET", "/api/v1/reconciliation/discrepancies", "api_get_discrepancies"),
|
|
||||||
("POST", "/api/v1/tasks/daily-reconciliation", "api_run_daily_reconciliation"),
|
|
||||||
("POST", "/api/v1/admin/equity-eligibility", "api_grant_equity_eligibility"),
|
|
||||||
("DELETE", "/api/v1/admin/equity-eligibility/{user_id}", "api_revoke_equity_eligibility"),
|
|
||||||
("GET", "/api/v1/admin/equity-eligibility", "api_list_equity_eligible_users"),
|
|
||||||
("POST", "/api/v1/admin/permissions", "api_grant_permission"),
|
|
||||||
("GET", "/api/v1/admin/permissions", "api_list_permissions"),
|
|
||||||
("DELETE", "/api/v1/admin/permissions/{permission_id}", "api_revoke_permission"),
|
|
||||||
("POST", "/api/v1/admin/permissions/bulk", "api_bulk_grant_permissions"),
|
|
||||||
("POST", "/api/v1/admin/permissions/bulk-grant", "api_bulk_grant_permission_to_users"),
|
|
||||||
("GET", "/api/v1/users/me/permissions", "api_get_user_permissions"),
|
|
||||||
("POST", "/api/v1/admin/accounts", "api_admin_add_chart_account"),
|
|
||||||
("POST", "/api/v1/admin/accounts/sync", "api_sync_all_accounts"),
|
|
||||||
("POST", "/api/v1/admin/accounts/sync/{account_name:path}", "api_sync_single_account"),
|
|
||||||
("GET", "/api/v1/admin/roles", "api_get_all_roles"),
|
|
||||||
("POST", "/api/v1/admin/roles", "api_create_role"),
|
|
||||||
("GET", "/api/v1/admin/roles/{role_id}", "api_get_role"),
|
|
||||||
("PUT", "/api/v1/admin/roles/{role_id}", "api_update_role"),
|
|
||||||
("DELETE", "/api/v1/admin/roles/{role_id}", "api_delete_role"),
|
|
||||||
("POST", "/api/v1/admin/roles/{role_id}/permissions", "api_add_role_permission"),
|
|
||||||
("DELETE", "/api/v1/admin/roles/{role_id}/permissions/{permission_id}", "api_delete_role_permission"),
|
|
||||||
("POST", "/api/v1/admin/user-roles", "api_assign_user_role"),
|
|
||||||
("GET", "/api/v1/admin/user-roles/{user_id}", "api_get_user_roles"),
|
|
||||||
("DELETE", "/api/v1/admin/user-roles/{user_role_id}", "api_revoke_user_role"),
|
|
||||||
("GET", "/api/v1/admin/users/roles", "api_get_all_user_roles"),
|
|
||||||
("GET", "/api/v1/users/me/roles", "api_get_my_roles"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_route_table_matches_snapshot():
|
|
||||||
actual = [
|
|
||||||
(",".join(sorted(r.methods)), r.path, r.endpoint.__name__)
|
|
||||||
for r in views_api.libra_api_router.routes
|
|
||||||
]
|
|
||||||
assert actual == EXPECTED_ROUTES
|
|
||||||
|
|
||||||
|
|
||||||
def _index(path: str) -> int:
|
|
||||||
paths = [p for _, p, _ in EXPECTED_ROUTES]
|
|
||||||
return paths.index(path)
|
|
||||||
|
|
||||||
|
|
||||||
def test_overlapping_route_order_is_preserved():
|
|
||||||
"""Only relative order among OVERLAPPING patterns is behavior;
|
|
||||||
these are the two overlap families in the table. The package split
|
|
||||||
keeps each family inside one module so include order can't reorder
|
|
||||||
them."""
|
|
||||||
# The literal must precede the {param} sibling or it is unreachable.
|
|
||||||
assert _index("/api/v1/accounts/hierarchy") < _index(
|
|
||||||
"/api/v1/accounts/{account_id}"
|
|
||||||
)
|
|
||||||
assert _index("/api/v1/admin/accounts/sync") < _index(
|
|
||||||
"/api/v1/admin/accounts/sync/{account_name:path}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_accounts_hierarchy_is_reachable(
|
|
||||||
client, configured_user, standard_accounts,
|
|
||||||
):
|
|
||||||
"""GET /accounts/hierarchy must reach the hierarchy endpoint. It was
|
|
||||||
registered after /accounts/{account_id} since it was added, so every
|
|
||||||
request resolved as account_id="hierarchy" and 404'd."""
|
|
||||||
_, wallet = configured_user
|
|
||||||
r = await client.get(
|
|
||||||
"/libra/api/v1/accounts/hierarchy",
|
|
||||||
headers={"X-Api-Key": wallet.inkey},
|
|
||||||
)
|
|
||||||
assert r.status_code == 200, f"hierarchy shadowed again? {r.status_code} {r.text}"
|
|
||||||
assert isinstance(r.json(), list)
|
|
||||||
4121
views_api.py
Normal file
4121
views_api.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,31 +0,0 @@
|
||||||
"""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)
|
|
||||||
|
|
@ -1,177 +0,0 @@
|
||||||
"""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),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,333 +0,0 @@
|
||||||
"""Libra API — accounts endpoints (moved verbatim from views_api.py)."""
|
|
||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
from ._shared import * # noqa: F401,F403
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
@router.get("/api/v1/currencies")
|
|
||||||
async def api_get_currencies() -> list[str]:
|
|
||||||
"""Get list of allowed currencies for fiat conversion"""
|
|
||||||
return allowed_currencies()
|
|
||||||
|
|
||||||
|
|
||||||
# ===== ACCOUNT ENDPOINTS =====
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/accounts")
|
|
||||||
async def api_get_accounts(
|
|
||||||
filter_by_user: bool = False,
|
|
||||||
exclude_virtual: bool = True,
|
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
|
||||||
) -> list[Account] | list[AccountWithPermissions]:
|
|
||||||
"""
|
|
||||||
Get all accounts in the chart of accounts.
|
|
||||||
|
|
||||||
- filter_by_user: If true, only return accounts the user has permissions for
|
|
||||||
- exclude_virtual: If true, exclude virtual parent accounts (default True)
|
|
||||||
- Returns AccountWithPermissions objects when filter_by_user=true, otherwise Account objects
|
|
||||||
"""
|
|
||||||
from lnbits.settings import settings as lnbits_settings
|
|
||||||
from .. import crud
|
|
||||||
|
|
||||||
all_accounts = await get_all_accounts()
|
|
||||||
|
|
||||||
user_id = wallet.wallet.user
|
|
||||||
is_super_user = user_id == lnbits_settings.super_user
|
|
||||||
|
|
||||||
# Auto-assign default role if user has no roles (only for non-super users)
|
|
||||||
if not is_super_user:
|
|
||||||
assigned_role = await crud.auto_assign_default_role(user_id, "system")
|
|
||||||
if assigned_role:
|
|
||||||
logger.info(f"[ACCOUNTS] Auto-assigned role to user {user_id}")
|
|
||||||
|
|
||||||
# Super users bypass permission filtering - they see everything
|
|
||||||
if not filter_by_user or is_super_user:
|
|
||||||
# Filter out virtual accounts if requested (default behavior for user views)
|
|
||||||
if exclude_virtual:
|
|
||||||
all_accounts = [acc for acc in all_accounts if not acc.is_virtual]
|
|
||||||
# Return all accounts without filtering by permissions
|
|
||||||
return all_accounts
|
|
||||||
|
|
||||||
# Filter by user permissions
|
|
||||||
# NOTE: Do NOT filter out virtual accounts yet - they're needed for inheritance logic
|
|
||||||
# Get direct user permissions
|
|
||||||
user_permissions = await get_user_permissions(user_id)
|
|
||||||
|
|
||||||
# Get role-based permissions
|
|
||||||
role_permissions_list = await crud.get_user_permissions_from_roles(user_id)
|
|
||||||
# Flatten role permissions into a single list
|
|
||||||
role_perms = []
|
|
||||||
for role, perms in role_permissions_list:
|
|
||||||
role_perms.extend(perms)
|
|
||||||
|
|
||||||
# Combine direct and role-based permissions
|
|
||||||
all_permissions = list(user_permissions) + role_perms
|
|
||||||
|
|
||||||
logger.info(f"[ACCOUNTS] User {user_id} has {len(user_permissions)} direct permissions and {len(role_perms)} role permissions (total: {len(all_permissions)})")
|
|
||||||
if role_perms:
|
|
||||||
logger.info(f"[ACCOUNTS] Role permissions: {[(p.account_id, p.permission_type) for p in role_perms]}")
|
|
||||||
logger.info(f"[ACCOUNTS] Total accounts in system: {len(all_accounts)}")
|
|
||||||
if len(all_accounts) > 0:
|
|
||||||
logger.info(f"[ACCOUNTS] Sample account IDs: {[acc.id for acc in all_accounts[:5]]}")
|
|
||||||
|
|
||||||
# Get set of account IDs the user has any permission on
|
|
||||||
permitted_account_ids = {perm.account_id for perm in all_permissions}
|
|
||||||
|
|
||||||
# Build list of accounts with permission metadata
|
|
||||||
accounts_with_permissions = []
|
|
||||||
|
|
||||||
for account in all_accounts:
|
|
||||||
# Check if user has permission on this account (direct or from role)
|
|
||||||
account_perms = [
|
|
||||||
perm for perm in all_permissions if perm.account_id == account.id
|
|
||||||
]
|
|
||||||
|
|
||||||
# Check if user has inherited permission from parent account (using combined permissions)
|
|
||||||
# Check both direct and role-based permissions for parent accounts
|
|
||||||
inherited_perms = []
|
|
||||||
for perm in all_permissions:
|
|
||||||
# Get the account for this permission
|
|
||||||
perm_account = await get_account(perm.account_id)
|
|
||||||
if not perm_account:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check if this permission's account is a parent of the current account
|
|
||||||
# e.g., "Expenses:Supplies" is parent of "Expenses:Supplies:Food"
|
|
||||||
if account.name.startswith(perm_account.name + ":"):
|
|
||||||
# Inherited permission from parent account
|
|
||||||
inherited_perms.append((perm, perm_account.name))
|
|
||||||
|
|
||||||
# Determine if account should be included
|
|
||||||
has_access = bool(account_perms) or bool(inherited_perms)
|
|
||||||
|
|
||||||
if has_access:
|
|
||||||
# Parse hierarchical account name to get parent and level
|
|
||||||
parts = account.name.split(":")
|
|
||||||
level = len(parts) - 1
|
|
||||||
parent_account = ":".join(parts[:-1]) if level > 0 else None
|
|
||||||
|
|
||||||
# Determine inherited_from (which parent account gave access)
|
|
||||||
inherited_from = None
|
|
||||||
if inherited_perms and not account_perms:
|
|
||||||
# Permission is inherited, use the parent account name
|
|
||||||
_, parent_name = inherited_perms[0]
|
|
||||||
inherited_from = parent_name
|
|
||||||
|
|
||||||
# Collect permission types for this account
|
|
||||||
permission_types = [perm.permission_type for perm in account_perms]
|
|
||||||
|
|
||||||
# Check if account has children
|
|
||||||
has_children = any(
|
|
||||||
a.name.startswith(account.name + ":") for a in all_accounts
|
|
||||||
)
|
|
||||||
|
|
||||||
accounts_with_permissions.append(
|
|
||||||
AccountWithPermissions(
|
|
||||||
id=account.id,
|
|
||||||
name=account.name,
|
|
||||||
account_type=account.account_type,
|
|
||||||
description=account.description,
|
|
||||||
user_id=account.user_id,
|
|
||||||
created_at=account.created_at,
|
|
||||||
is_active=account.is_active,
|
|
||||||
is_virtual=account.is_virtual,
|
|
||||||
user_permissions=permission_types if permission_types else None,
|
|
||||||
inherited_from=inherited_from,
|
|
||||||
parent_account=parent_account,
|
|
||||||
level=level,
|
|
||||||
has_children=has_children,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Filter out virtual accounts if requested (after permission inheritance logic)
|
|
||||||
if exclude_virtual:
|
|
||||||
accounts_with_permissions = [
|
|
||||||
acc for acc in accounts_with_permissions if not acc.is_virtual
|
|
||||||
]
|
|
||||||
|
|
||||||
logger.info(f"[ACCOUNTS] Returning {len(accounts_with_permissions)} accounts for user {user_id}")
|
|
||||||
return accounts_with_permissions
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/accounts", status_code=HTTPStatus.CREATED)
|
|
||||||
async def api_create_account(
|
|
||||||
data: CreateAccount,
|
|
||||||
auth: AuthContext = Depends(require_super_user),
|
|
||||||
) -> Account:
|
|
||||||
"""Create a new account (super user only)"""
|
|
||||||
from ..crud import AccountExistsError
|
|
||||||
|
|
||||||
try:
|
|
||||||
return await create_account(data)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.BAD_REQUEST, detail=str(e)
|
|
||||||
)
|
|
||||||
except AccountExistsError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.CONFLICT,
|
|
||||||
detail=f"Account {data.name} already exists",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# NOTE: hierarchy must be registered BEFORE /accounts/{account_id} —
|
|
||||||
# FastAPI matches in registration order, and the param route otherwise
|
|
||||||
# swallows the literal path (it resolved as account_id="hierarchy" and
|
|
||||||
# 404'd for as long as the endpoint existed).
|
|
||||||
@router.get("/api/v1/accounts/hierarchy")
|
|
||||||
async def api_get_account_hierarchy(
|
|
||||||
root_account: str | None = None,
|
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
|
||||||
) -> list[AccountWithPermissions]:
|
|
||||||
"""
|
|
||||||
Get hierarchical account structure with user permissions.
|
|
||||||
Optionally filter by root account (e.g., "Expenses" to get all expense sub-accounts).
|
|
||||||
"""
|
|
||||||
all_accounts = await get_all_accounts()
|
|
||||||
user_id = wallet.wallet.user
|
|
||||||
user_permissions = await get_user_permissions(user_id)
|
|
||||||
|
|
||||||
# Filter by root account if specified
|
|
||||||
if root_account:
|
|
||||||
all_accounts = [
|
|
||||||
acc for acc in all_accounts
|
|
||||||
if acc.name == root_account or acc.name.startswith(root_account + ":")
|
|
||||||
]
|
|
||||||
|
|
||||||
# Build hierarchy with permission metadata
|
|
||||||
accounts_with_hierarchy = []
|
|
||||||
|
|
||||||
for account in all_accounts:
|
|
||||||
# Check if user has direct permission on this account
|
|
||||||
account_perms = [
|
|
||||||
perm for perm in user_permissions if perm.account_id == account.id
|
|
||||||
]
|
|
||||||
|
|
||||||
# Check if user has inherited permission from parent account
|
|
||||||
inherited_perms = await get_user_permissions_with_inheritance(
|
|
||||||
user_id, account.name, PermissionType.READ
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse hierarchical account name to get parent and level
|
|
||||||
parts = account.name.split(":")
|
|
||||||
level = len(parts) - 1
|
|
||||||
parent_account = ":".join(parts[:-1]) if level > 0 else None
|
|
||||||
|
|
||||||
# Determine inherited_from (which parent account gave access)
|
|
||||||
inherited_from = None
|
|
||||||
if inherited_perms and not account_perms:
|
|
||||||
# Permission is inherited, use the parent account name
|
|
||||||
_, parent_name = inherited_perms[0]
|
|
||||||
inherited_from = parent_name
|
|
||||||
|
|
||||||
# Collect permission types for this account
|
|
||||||
permission_types = [perm.permission_type for perm in account_perms]
|
|
||||||
|
|
||||||
# Check if account has children
|
|
||||||
has_children = any(
|
|
||||||
a.name.startswith(account.name + ":") for a in all_accounts
|
|
||||||
)
|
|
||||||
|
|
||||||
accounts_with_hierarchy.append(
|
|
||||||
AccountWithPermissions(
|
|
||||||
id=account.id,
|
|
||||||
name=account.name,
|
|
||||||
account_type=account.account_type,
|
|
||||||
description=account.description,
|
|
||||||
user_id=account.user_id,
|
|
||||||
created_at=account.created_at,
|
|
||||||
user_permissions=permission_types if permission_types else None,
|
|
||||||
inherited_from=inherited_from,
|
|
||||||
parent_account=parent_account,
|
|
||||||
level=level,
|
|
||||||
has_children=has_children,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Sort by hierarchical name for natural ordering
|
|
||||||
accounts_with_hierarchy.sort(key=lambda a: a.name)
|
|
||||||
|
|
||||||
return accounts_with_hierarchy
|
|
||||||
|
|
||||||
|
|
||||||
# ===== ACCOUNT SYNC ENDPOINTS =====
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/accounts/{account_id}")
|
|
||||||
async def api_get_account(
|
|
||||||
account_id: str,
|
|
||||||
auth: AuthContext = Depends(require_authenticated),
|
|
||||||
) -> Account:
|
|
||||||
"""Get a specific account (requires authentication and account access)"""
|
|
||||||
account = await get_account(account_id)
|
|
||||||
if not account:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Account not found"
|
|
||||||
)
|
|
||||||
# Check access permission
|
|
||||||
await require_account_access(auth, account_id, PermissionType.READ)
|
|
||||||
return account
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/accounts/{account_id}/balance")
|
|
||||||
async def api_get_account_balance(
|
|
||||||
account_id: str,
|
|
||||||
auth: AuthContext = Depends(require_authenticated),
|
|
||||||
) -> dict:
|
|
||||||
"""Get account balance from Fava/Beancount (requires authentication and account access)"""
|
|
||||||
from ..fava_client import get_fava_client
|
|
||||||
|
|
||||||
# Get account to retrieve its name
|
|
||||||
account = await get_account(account_id)
|
|
||||||
if not account:
|
|
||||||
raise HTTPException(status_code=404, detail="Account not found")
|
|
||||||
|
|
||||||
# Check access permission
|
|
||||||
await require_account_access(auth, account_id, PermissionType.READ)
|
|
||||||
|
|
||||||
# Query Fava for balance
|
|
||||||
fava = get_fava_client()
|
|
||||||
balance_data = await fava.get_account_balance(account.name)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"account_id": account_id,
|
|
||||||
"balance": balance_data["sats"], # Balance in satoshis
|
|
||||||
"fiat": float(balance_data.get("fiat", 0)), # Fiat amount
|
|
||||||
"fiat_currency": balance_data.get("fiat_currency", "EUR")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/accounts/{account_id}/transactions")
|
|
||||||
async def api_get_account_transactions(
|
|
||||||
account_id: str,
|
|
||||||
limit: int = 100,
|
|
||||||
auth: AuthContext = Depends(require_authenticated),
|
|
||||||
) -> list[dict]:
|
|
||||||
"""
|
|
||||||
Get all transactions for an account from Fava/Beancount.
|
|
||||||
|
|
||||||
Returns transactions affecting this account in reverse chronological order.
|
|
||||||
Requires authentication and account access.
|
|
||||||
"""
|
|
||||||
from ..fava_client import get_fava_client
|
|
||||||
|
|
||||||
# Get account details
|
|
||||||
account = await get_account(account_id)
|
|
||||||
if not account:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
|
||||||
detail=f"Account {account_id} not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check access permission
|
|
||||||
await require_account_access(auth, account_id, PermissionType.READ)
|
|
||||||
|
|
||||||
# Query Fava for transactions
|
|
||||||
fava = get_fava_client()
|
|
||||||
transactions = await fava.get_account_transactions(account.name, limit)
|
|
||||||
|
|
||||||
return transactions
|
|
||||||
|
|
||||||
|
|
||||||
# ===== JOURNAL ENTRY ENDPOINTS =====
|
|
||||||
|
|
@ -1,535 +0,0 @@
|
||||||
"""Libra API — admin endpoints (moved verbatim from views_api.py)."""
|
|
||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
from ._shared import * # noqa: F401,F403
|
|
||||||
from ._shared import _VALID_ACCOUNT_PREFIXES, _validate_account_name # noqa: F401
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
@router.post("/api/v1/admin/accounts", status_code=HTTPStatus.CREATED)
|
|
||||||
async def api_admin_add_chart_account(
|
|
||||||
payload: CreateChartAccount,
|
|
||||||
auth: AuthContext = Depends(require_super_user),
|
|
||||||
) -> dict:
|
|
||||||
"""
|
|
||||||
Add a chart-of-accounts entry (super-user only).
|
|
||||||
|
|
||||||
Writes an Open directive to accounts/chart.beancount via Fava's /api/source,
|
|
||||||
then syncs the account into Libra's DB so permissions can be granted on it.
|
|
||||||
Per-user accounts (matching :User-xxxxxxxx) take a different code path via
|
|
||||||
crud.get_or_create_user_account and are not created through this endpoint.
|
|
||||||
"""
|
|
||||||
from ..fava_client import get_fava_client
|
|
||||||
|
|
||||||
if not payload.name.startswith(_VALID_ACCOUNT_PREFIXES):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
|
||||||
detail=(
|
|
||||||
f"Account name must start with one of "
|
|
||||||
f"{', '.join(_VALID_ACCOUNT_PREFIXES)} (got {payload.name!r})"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
_validate_account_name(payload.name)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Admin {auth.user_id[:8]} adding chart account {payload.name} "
|
|
||||||
f"with currencies {payload.currencies}"
|
|
||||||
)
|
|
||||||
|
|
||||||
fava = get_fava_client()
|
|
||||||
metadata: dict = {"added_by": auth.user_id[:8], "source": "admin-ui"}
|
|
||||||
if payload.description:
|
|
||||||
metadata["description"] = payload.description
|
|
||||||
|
|
||||||
result = await fava.add_account(
|
|
||||||
account_name=payload.name,
|
|
||||||
currencies=payload.currencies,
|
|
||||||
target_file="accounts/chart.beancount",
|
|
||||||
metadata=metadata,
|
|
||||||
)
|
|
||||||
|
|
||||||
from ..account_sync import sync_single_account_from_beancount
|
|
||||||
|
|
||||||
if result.get("already_existed"):
|
|
||||||
# The Open directive is already in the ledger. If it's also already
|
|
||||||
# mirrored into libra's DB, it's a true duplicate → 409. If not (a prior
|
|
||||||
# sync failed — there's no cross-DB atomicity — or it was opened out of
|
|
||||||
# band), mirror it now so it becomes grantable instead of being stranded
|
|
||||||
# with no recovery path.
|
|
||||||
from ..crud import get_account_by_name
|
|
||||||
|
|
||||||
if await get_account_by_name(payload.name) is not None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.CONFLICT,
|
|
||||||
detail=f"Account {payload.name} already exists",
|
|
||||||
)
|
|
||||||
|
|
||||||
synced = await sync_single_account_from_beancount(payload.name)
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"account_name": payload.name,
|
|
||||||
"synced_to_libra_db": synced,
|
|
||||||
"already_existed": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Mirror into libra DB so permissions / metadata layer sees it. We just
|
|
||||||
# wrote the Open directive ourselves, so skip the verification
|
|
||||||
# round-trip through Fava (libra-#53).
|
|
||||||
synced = await sync_single_account_from_beancount(
|
|
||||||
payload.name,
|
|
||||||
description=payload.description,
|
|
||||||
assume_exists=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"account_name": payload.name,
|
|
||||||
"synced_to_libra_db": synced,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/admin/accounts/sync")
|
|
||||||
async def api_sync_all_accounts(
|
|
||||||
force_full_sync: bool = False,
|
|
||||||
auth: AuthContext = Depends(require_super_user),
|
|
||||||
) -> dict:
|
|
||||||
"""
|
|
||||||
Sync all accounts from Beancount to Libra DB (admin only).
|
|
||||||
|
|
||||||
This ensures Libra DB has metadata entries for all accounts that exist
|
|
||||||
in Beancount, enabling permissions and user associations to work properly.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
force_full_sync: If True, re-check all accounts. If False, only add new ones.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Sync statistics: {total_beancount_accounts, accounts_added, accounts_skipped, errors}
|
|
||||||
"""
|
|
||||||
from ..account_sync import sync_accounts_from_beancount
|
|
||||||
|
|
||||||
logger.info(f"Admin {auth.user_id[:8]} triggered account sync (force={force_full_sync})")
|
|
||||||
|
|
||||||
try:
|
|
||||||
stats = await sync_accounts_from_beancount(force_full_sync=force_full_sync)
|
|
||||||
logger.info(f"Account sync complete: {stats['accounts_added']} added, {stats['accounts_skipped']} skipped")
|
|
||||||
return stats
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Account sync failed: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Account sync failed: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/admin/accounts/sync/{account_name:path}")
|
|
||||||
async def api_sync_single_account(
|
|
||||||
account_name: str,
|
|
||||||
auth: AuthContext = Depends(require_super_user),
|
|
||||||
) -> dict:
|
|
||||||
"""
|
|
||||||
Sync a single account from Beancount to Libra DB (admin only).
|
|
||||||
|
|
||||||
Useful for ensuring a specific account exists in Libra DB before
|
|
||||||
granting permissions on it.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
account_name: Hierarchical account name (e.g., "Expenses:Food:Groceries")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{success: bool, account_name: str, message: str}
|
|
||||||
"""
|
|
||||||
from ..account_sync import sync_single_account_from_beancount
|
|
||||||
|
|
||||||
logger.info(f"Admin {auth.user_id[:8]} triggered sync for account: {account_name}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
created = await sync_single_account_from_beancount(account_name)
|
|
||||||
|
|
||||||
if created:
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"account_name": account_name,
|
|
||||||
"message": f"Account '{account_name}' synced successfully"
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"account_name": account_name,
|
|
||||||
"message": f"Account '{account_name}' already exists or not found in Beancount"
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Single account sync failed for {account_name}: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Account sync failed: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ===== RBAC (ROLE-BASED ACCESS CONTROL) ENDPOINTS =====
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/admin/roles")
|
|
||||||
async def api_get_all_roles(
|
|
||||||
auth: AuthContext = Depends(require_super_user),
|
|
||||||
) -> list:
|
|
||||||
"""Get all roles (admin only)"""
|
|
||||||
from .. import crud
|
|
||||||
|
|
||||||
roles = await crud.get_all_roles()
|
|
||||||
|
|
||||||
# Enrich each role with user count and permission count
|
|
||||||
enriched_roles = []
|
|
||||||
for role in roles:
|
|
||||||
user_count = await crud.get_user_count_for_role(role.id)
|
|
||||||
permissions = await crud.get_role_permissions(role.id)
|
|
||||||
|
|
||||||
enriched_roles.append({
|
|
||||||
"id": role.id,
|
|
||||||
"name": role.name,
|
|
||||||
"description": role.description,
|
|
||||||
"is_default": role.is_default,
|
|
||||||
"created_by": role.created_by,
|
|
||||||
"created_at": role.created_at.isoformat(),
|
|
||||||
"user_count": user_count,
|
|
||||||
"permission_count": len(permissions),
|
|
||||||
})
|
|
||||||
|
|
||||||
return enriched_roles
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/admin/roles", status_code=HTTPStatus.CREATED)
|
|
||||||
async def api_create_role(
|
|
||||||
data: CreateRole,
|
|
||||||
auth: AuthContext = Depends(require_super_user),
|
|
||||||
):
|
|
||||||
"""Create a new role (admin only)"""
|
|
||||||
from .. import crud
|
|
||||||
|
|
||||||
try:
|
|
||||||
role = await crud.create_role(data, created_by=auth.user_id)
|
|
||||||
return {
|
|
||||||
"id": role.id,
|
|
||||||
"name": role.name,
|
|
||||||
"description": role.description,
|
|
||||||
"is_default": role.is_default,
|
|
||||||
"created_by": role.created_by,
|
|
||||||
"created_at": role.created_at.isoformat(),
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to create role: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to create role: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/admin/roles/{role_id}")
|
|
||||||
async def api_get_role(
|
|
||||||
role_id: str,
|
|
||||||
auth: AuthContext = Depends(require_super_user),
|
|
||||||
):
|
|
||||||
"""Get a specific role with its permissions and users (admin only)"""
|
|
||||||
from .. import crud
|
|
||||||
|
|
||||||
role = await crud.get_role(role_id)
|
|
||||||
if not role:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
|
||||||
detail=f"Role {role_id} not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
permissions = await crud.get_role_permissions(role.id)
|
|
||||||
user_roles = await crud.get_role_users(role.id)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": role.id,
|
|
||||||
"name": role.name,
|
|
||||||
"description": role.description,
|
|
||||||
"is_default": role.is_default,
|
|
||||||
"created_by": role.created_by,
|
|
||||||
"created_at": role.created_at.isoformat(),
|
|
||||||
"permissions": [
|
|
||||||
{
|
|
||||||
"id": p.id,
|
|
||||||
"account_id": p.account_id,
|
|
||||||
"permission_type": p.permission_type.value,
|
|
||||||
"notes": p.notes,
|
|
||||||
"created_at": p.created_at.isoformat(),
|
|
||||||
}
|
|
||||||
for p in permissions
|
|
||||||
],
|
|
||||||
"users": [
|
|
||||||
{
|
|
||||||
"id": ur.id,
|
|
||||||
"user_id": ur.user_id,
|
|
||||||
"granted_by": ur.granted_by,
|
|
||||||
"granted_at": ur.granted_at.isoformat(),
|
|
||||||
"expires_at": ur.expires_at.isoformat() if ur.expires_at else None,
|
|
||||||
"notes": ur.notes,
|
|
||||||
}
|
|
||||||
for ur in user_roles
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/api/v1/admin/roles/{role_id}")
|
|
||||||
async def api_update_role(
|
|
||||||
role_id: str,
|
|
||||||
data: UpdateRole,
|
|
||||||
auth: AuthContext = Depends(require_super_user),
|
|
||||||
):
|
|
||||||
"""Update a role (admin only)"""
|
|
||||||
from .. import crud
|
|
||||||
|
|
||||||
role = await crud.update_role(role_id, data)
|
|
||||||
if not role:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
|
||||||
detail=f"Role {role_id} not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": role.id,
|
|
||||||
"name": role.name,
|
|
||||||
"description": role.description,
|
|
||||||
"is_default": role.is_default,
|
|
||||||
"created_by": role.created_by,
|
|
||||||
"created_at": role.created_at.isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/v1/admin/roles/{role_id}")
|
|
||||||
async def api_delete_role(
|
|
||||||
role_id: str,
|
|
||||||
auth: AuthContext = Depends(require_super_user),
|
|
||||||
):
|
|
||||||
"""Delete a role (admin only) - cascades to role_permissions and user_roles"""
|
|
||||||
from .. import crud
|
|
||||||
|
|
||||||
role = await crud.get_role(role_id)
|
|
||||||
if not role:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
|
||||||
detail=f"Role {role_id} not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
await crud.delete_role(role_id)
|
|
||||||
return {"success": True, "message": f"Role '{role.name}' deleted successfully"}
|
|
||||||
|
|
||||||
|
|
||||||
# ===== ROLE PERMISSION ENDPOINTS =====
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/admin/roles/{role_id}/permissions", status_code=HTTPStatus.CREATED)
|
|
||||||
async def api_add_role_permission(
|
|
||||||
role_id: str,
|
|
||||||
data: CreateRolePermission,
|
|
||||||
auth: AuthContext = Depends(require_super_user),
|
|
||||||
):
|
|
||||||
"""Add a permission to a role (admin only)"""
|
|
||||||
from .. import crud
|
|
||||||
|
|
||||||
# Verify role exists
|
|
||||||
role = await crud.get_role(role_id)
|
|
||||||
if not role:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
|
||||||
detail=f"Role {role_id} not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ensure data has correct role_id
|
|
||||||
data.role_id = role_id
|
|
||||||
|
|
||||||
try:
|
|
||||||
permission = await crud.create_role_permission(data)
|
|
||||||
return {
|
|
||||||
"id": permission.id,
|
|
||||||
"role_id": permission.role_id,
|
|
||||||
"account_id": permission.account_id,
|
|
||||||
"permission_type": permission.permission_type.value,
|
|
||||||
"notes": permission.notes,
|
|
||||||
"created_at": permission.created_at.isoformat(),
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to add role permission: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to add permission: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/v1/admin/roles/{role_id}/permissions/{permission_id}")
|
|
||||||
async def api_delete_role_permission(
|
|
||||||
role_id: str,
|
|
||||||
permission_id: str,
|
|
||||||
auth: AuthContext = Depends(require_super_user),
|
|
||||||
):
|
|
||||||
"""Remove a permission from a role (admin only)"""
|
|
||||||
from .. import crud
|
|
||||||
|
|
||||||
await crud.delete_role_permission(permission_id)
|
|
||||||
return {"success": True, "message": "Permission removed from role"}
|
|
||||||
|
|
||||||
|
|
||||||
# ===== USER ROLE ASSIGNMENT ENDPOINTS =====
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/admin/user-roles", status_code=HTTPStatus.CREATED)
|
|
||||||
async def api_assign_user_role(
|
|
||||||
data: AssignUserRole,
|
|
||||||
auth: AuthContext = Depends(require_super_user),
|
|
||||||
):
|
|
||||||
"""Assign a user to a role (admin only)"""
|
|
||||||
from .. import crud
|
|
||||||
|
|
||||||
# Verify role exists
|
|
||||||
role = await crud.get_role(data.role_id)
|
|
||||||
if not role:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
|
||||||
detail=f"Role {data.role_id} not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
user_role = await crud.assign_user_role(data, granted_by=auth.user_id)
|
|
||||||
return {
|
|
||||||
"id": user_role.id,
|
|
||||||
"user_id": user_role.user_id,
|
|
||||||
"role_id": user_role.role_id,
|
|
||||||
"granted_by": user_role.granted_by,
|
|
||||||
"granted_at": user_role.granted_at.isoformat(),
|
|
||||||
"expires_at": user_role.expires_at.isoformat() if user_role.expires_at else None,
|
|
||||||
"notes": user_role.notes,
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to assign user role: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
detail=f"Failed to assign role: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/admin/user-roles/{user_id}")
|
|
||||||
async def api_get_user_roles(
|
|
||||||
user_id: str,
|
|
||||||
auth: AuthContext = Depends(require_super_user),
|
|
||||||
):
|
|
||||||
"""Get all roles assigned to a user (admin only)"""
|
|
||||||
from .. import crud
|
|
||||||
|
|
||||||
user_roles = await crud.get_user_roles(user_id)
|
|
||||||
|
|
||||||
# Enrich with role details
|
|
||||||
enriched = []
|
|
||||||
for ur in user_roles:
|
|
||||||
role = await crud.get_role(ur.role_id)
|
|
||||||
if role:
|
|
||||||
enriched.append({
|
|
||||||
"user_role_id": ur.id,
|
|
||||||
"user_id": ur.user_id,
|
|
||||||
"role": {
|
|
||||||
"id": role.id,
|
|
||||||
"name": role.name,
|
|
||||||
"description": role.description,
|
|
||||||
"is_default": role.is_default,
|
|
||||||
},
|
|
||||||
"granted_by": ur.granted_by,
|
|
||||||
"granted_at": ur.granted_at.isoformat(),
|
|
||||||
"expires_at": ur.expires_at.isoformat() if ur.expires_at else None,
|
|
||||||
"notes": ur.notes,
|
|
||||||
})
|
|
||||||
|
|
||||||
return enriched
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/v1/admin/user-roles/{user_role_id}")
|
|
||||||
async def api_revoke_user_role(
|
|
||||||
user_role_id: str,
|
|
||||||
auth: AuthContext = Depends(require_super_user),
|
|
||||||
):
|
|
||||||
"""Revoke a user's role assignment (admin only)"""
|
|
||||||
from .. import crud
|
|
||||||
|
|
||||||
await crud.revoke_user_role(user_role_id)
|
|
||||||
return {"success": True, "message": "Role assignment revoked"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/admin/users/roles")
|
|
||||||
async def api_get_all_user_roles(
|
|
||||||
auth: AuthContext = Depends(require_super_user),
|
|
||||||
):
|
|
||||||
"""Get all user role assignments (admin only)"""
|
|
||||||
from .. import crud
|
|
||||||
|
|
||||||
user_roles = await crud.get_all_user_roles()
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": ur.id,
|
|
||||||
"user_id": ur.user_id,
|
|
||||||
"role_id": ur.role_id,
|
|
||||||
"granted_by": ur.granted_by,
|
|
||||||
"granted_at": ur.granted_at.isoformat(),
|
|
||||||
"expires_at": ur.expires_at.isoformat() if ur.expires_at else None,
|
|
||||||
"notes": ur.notes,
|
|
||||||
}
|
|
||||||
for ur in user_roles
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/users/me/roles")
|
|
||||||
async def api_get_my_roles(
|
|
||||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
|
||||||
):
|
|
||||||
"""Get current user's roles and effective permissions"""
|
|
||||||
from .. import crud
|
|
||||||
|
|
||||||
user_id = wallet.wallet.user
|
|
||||||
|
|
||||||
# Get user's roles
|
|
||||||
user_roles = await crud.get_user_roles(user_id)
|
|
||||||
|
|
||||||
# Get permissions from roles
|
|
||||||
role_permissions_list = await crud.get_user_permissions_from_roles(user_id)
|
|
||||||
|
|
||||||
# Get direct permissions
|
|
||||||
direct_permissions = await crud.get_user_permissions(user_id)
|
|
||||||
|
|
||||||
# Build response
|
|
||||||
roles_data = []
|
|
||||||
for ur in user_roles:
|
|
||||||
role = await crud.get_role(ur.role_id)
|
|
||||||
if role:
|
|
||||||
permissions = await crud.get_role_permissions(role.id)
|
|
||||||
roles_data.append({
|
|
||||||
"role": {
|
|
||||||
"id": role.id,
|
|
||||||
"name": role.name,
|
|
||||||
"description": role.description,
|
|
||||||
},
|
|
||||||
"permissions": [
|
|
||||||
{
|
|
||||||
"account_id": p.account_id,
|
|
||||||
"permission_type": p.permission_type.value,
|
|
||||||
}
|
|
||||||
for p in permissions
|
|
||||||
],
|
|
||||||
"granted_at": ur.granted_at.isoformat(),
|
|
||||||
"expires_at": ur.expires_at.isoformat() if ur.expires_at else None,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"roles": roles_data,
|
|
||||||
"direct_permissions": [
|
|
||||||
{
|
|
||||||
"id": p.id,
|
|
||||||
"account_id": p.account_id,
|
|
||||||
"permission_type": p.permission_type.value,
|
|
||||||
"granted_at": p.granted_at.isoformat(),
|
|
||||||
"expires_at": p.expires_at.isoformat() if p.expires_at else None,
|
|
||||||
"notes": p.notes,
|
|
||||||
}
|
|
||||||
for p in direct_permissions
|
|
||||||
],
|
|
||||||
}
|
|
||||||
1209
views_api/entries.py
1209
views_api/entries.py
File diff suppressed because it is too large
Load diff
|
|
@ -1,979 +0,0 @@
|
||||||
"""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 =====
|
|
||||||
|
|
@ -1,210 +0,0 @@
|
||||||
"""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 =====
|
|
||||||
|
|
@ -1,304 +0,0 @@
|
||||||
"""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 =====
|
|
||||||
|
|
@ -1,399 +0,0 @@
|
||||||
"""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,
|
|
||||||
)
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue