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>
1209 lines
46 KiB
Python
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 =====
|