Pure move — no logic changes. The 4,100-line single file becomes views_api/ with one module per domain (accounts, entries, payments, settings_reports, reconciliation, permissions, admin), each registering full literal paths on its own APIRouter; __init__ builds the combined libra_api_router so libra/__init__.py is untouched. Shared imports/helpers live in views_api/_shared.py; _extract_entry_id and _SYSTEM_LINK_PREFIXES move to beancount_format.py (they are pure entry-dict parsing). Route behavior is pinned by tests/test_route_table.py: the 73-route set is unchanged, and the two order-sensitive families (the shadowed /accounts/hierarchy wart — deliberately preserved here, fixed in the next commit — and the admin sync literal/param pair) keep their relative order inside a single module each. Addresses CODE-REVIEW-2026-06 structure findings (views_api monolith). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
399 lines
12 KiB
Python
399 lines
12 KiB
Python
"""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,
|
|
)
|