libra/views_api/entries.py
Padreug a7d7740a3a refactor(api): split views_api.py into a package of domain modules
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>
2026-07-12 16:09:58 +02:00

1209 lines
46 KiB
Python

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