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>
304 lines
9.8 KiB
Python
304 lines
9.8 KiB
Python
"""Libra API — reconciliation endpoints (moved verbatim from views_api.py)."""
|
|
from fastapi import APIRouter
|
|
|
|
from ._shared import * # noqa: F401,F403
|
|
|
|
router = APIRouter()
|
|
|
|
@router.post("/api/v1/assertions")
|
|
async def api_create_balance_assertion(
|
|
data: CreateBalanceAssertion,
|
|
auth: AuthContext = Depends(require_super_user),
|
|
) -> BalanceAssertion:
|
|
"""
|
|
Create a balance assertion for reconciliation (super user only).
|
|
|
|
Uses hybrid approach:
|
|
1. Writes balance assertion to Beancount (via Fava) - source of truth
|
|
2. Stores metadata in Libra DB for UI convenience (created_by, notes, tolerance)
|
|
3. Lets Beancount validate the assertion automatically
|
|
|
|
The assertion will be checked immediately upon creation.
|
|
"""
|
|
from ..fava_client import get_fava_client
|
|
from ..beancount_format import format_balance
|
|
|
|
# Verify account exists
|
|
account = await get_account(data.account_id)
|
|
if not account:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND,
|
|
detail=f"Account {data.account_id} not found",
|
|
)
|
|
|
|
assertion_date = data.date or datetime.now()
|
|
|
|
# HYBRID APPROACH: Write to Beancount first (source of truth)
|
|
balance_directive = format_balance(
|
|
date_val=assertion_date.date() if isinstance(assertion_date, datetime) else assertion_date,
|
|
account=account.name,
|
|
amount=data.expected_balance_sats,
|
|
currency="SATS"
|
|
)
|
|
|
|
# Submit to Fava/Beancount
|
|
try:
|
|
fava = get_fava_client()
|
|
result = await fava.add_entry(balance_directive)
|
|
logger.info(f"Balance assertion submitted to Fava: {result}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to write balance assertion to Fava: {e}")
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to write balance assertion to Beancount: {str(e)}"
|
|
)
|
|
|
|
# Store metadata in Libra DB for UI convenience
|
|
assertion = await create_balance_assertion(data, auth.user_id)
|
|
|
|
# Check it immediately (queries Fava for actual balance)
|
|
try:
|
|
assertion = await check_balance_assertion(assertion.id)
|
|
except ValueError as e:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST,
|
|
detail=str(e),
|
|
)
|
|
|
|
# If assertion failed, return 409 Conflict with details
|
|
if assertion.status == AssertionStatus.FAILED:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.CONFLICT,
|
|
detail={
|
|
"message": "Balance assertion failed (validated by Beancount)",
|
|
"expected_sats": assertion.expected_balance_sats,
|
|
"actual_sats": assertion.checked_balance_sats,
|
|
"difference_sats": assertion.difference_sats,
|
|
"expected_fiat": float(assertion.expected_balance_fiat) if assertion.expected_balance_fiat else None,
|
|
"actual_fiat": float(assertion.checked_balance_fiat) if assertion.checked_balance_fiat else None,
|
|
"difference_fiat": float(assertion.difference_fiat) if assertion.difference_fiat else None,
|
|
"fiat_currency": assertion.fiat_currency,
|
|
},
|
|
)
|
|
|
|
return assertion
|
|
|
|
|
|
@router.get("/api/v1/assertions")
|
|
async def api_get_balance_assertions(
|
|
account_id: str = None,
|
|
status: str = None,
|
|
limit: int = 100,
|
|
auth: AuthContext = Depends(require_super_user),
|
|
) -> list[BalanceAssertion]:
|
|
"""Get balance assertions with optional filters (super user only)"""
|
|
|
|
# Parse status enum if provided
|
|
status_enum = None
|
|
if status:
|
|
try:
|
|
status_enum = AssertionStatus(status)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST,
|
|
detail=f"Invalid status: {status}. Must be one of: pending, passed, failed",
|
|
)
|
|
|
|
return await get_balance_assertions(
|
|
account_id=account_id,
|
|
status=status_enum,
|
|
limit=limit,
|
|
)
|
|
|
|
|
|
@router.get("/api/v1/assertions/{assertion_id}")
|
|
async def api_get_balance_assertion(
|
|
assertion_id: str,
|
|
auth: AuthContext = Depends(require_super_user),
|
|
) -> BalanceAssertion:
|
|
"""Get a specific balance assertion (super user only)"""
|
|
assertion = await get_balance_assertion(assertion_id)
|
|
if not assertion:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND,
|
|
detail="Balance assertion not found",
|
|
)
|
|
|
|
return assertion
|
|
|
|
|
|
@router.post("/api/v1/assertions/{assertion_id}/check")
|
|
async def api_check_balance_assertion(
|
|
assertion_id: str,
|
|
auth: AuthContext = Depends(require_super_user),
|
|
) -> BalanceAssertion:
|
|
"""Re-check a balance assertion (super user only)"""
|
|
try:
|
|
assertion = await check_balance_assertion(assertion_id)
|
|
except ValueError as e:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND,
|
|
detail=str(e),
|
|
)
|
|
|
|
return assertion
|
|
|
|
|
|
@router.delete("/api/v1/assertions/{assertion_id}")
|
|
async def api_delete_balance_assertion(
|
|
assertion_id: str,
|
|
auth: AuthContext = Depends(require_super_user),
|
|
) -> dict:
|
|
"""Delete a balance assertion (super user only)"""
|
|
# Verify it exists
|
|
assertion = await get_balance_assertion(assertion_id)
|
|
if not assertion:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND,
|
|
detail="Balance assertion not found",
|
|
)
|
|
|
|
await delete_balance_assertion(assertion_id)
|
|
|
|
return {"success": True, "message": "Balance assertion deleted"}
|
|
|
|
|
|
# ===== RECONCILIATION ENDPOINTS =====
|
|
|
|
|
|
@router.get("/api/v1/reconciliation/summary")
|
|
async def api_get_reconciliation_summary(
|
|
auth: AuthContext = Depends(require_super_user),
|
|
) -> dict:
|
|
"""Get reconciliation summary (super user only)"""
|
|
|
|
# Get all assertions
|
|
all_assertions = await get_balance_assertions(limit=1000)
|
|
|
|
# Count by status
|
|
passed = len([a for a in all_assertions if a.status == AssertionStatus.PASSED])
|
|
failed = len([a for a in all_assertions if a.status == AssertionStatus.FAILED])
|
|
pending = len([a for a in all_assertions if a.status == AssertionStatus.PENDING])
|
|
|
|
# Get all journal entries from Fava
|
|
from ..fava_client import get_fava_client
|
|
fava = get_fava_client()
|
|
all_entries = await fava.query_transactions(limit=1000, include_pending=True)
|
|
|
|
# Count entries by flag (Beancount only supports * and !)
|
|
cleared = len([e for e in all_entries if e.get("flag") == "*"])
|
|
pending_entries = len([e for e in all_entries if e.get("flag") == "!"])
|
|
|
|
# Count entries with special tags
|
|
voided = len([e for e in all_entries if "voided" in e.get("tags", [])])
|
|
flagged = len([e for e in all_entries if "review" in e.get("tags", []) or "flagged" in e.get("tags", [])])
|
|
|
|
# Get all accounts
|
|
accounts = await get_all_accounts()
|
|
|
|
return {
|
|
"assertions": {
|
|
"total": len(all_assertions),
|
|
"passed": passed,
|
|
"failed": failed,
|
|
"pending": pending,
|
|
},
|
|
"entries": {
|
|
"total": len(all_entries),
|
|
"cleared": cleared,
|
|
"pending": pending_entries,
|
|
"flagged": flagged,
|
|
"voided": voided,
|
|
},
|
|
"accounts": {
|
|
"total": len(accounts),
|
|
},
|
|
"last_checked": datetime.now().isoformat(),
|
|
}
|
|
|
|
|
|
@router.post("/api/v1/reconciliation/check-all")
|
|
async def api_check_all_assertions(
|
|
auth: AuthContext = Depends(require_super_user),
|
|
) -> dict:
|
|
"""Re-check all balance assertions (super user only)"""
|
|
|
|
# Get all assertions
|
|
all_assertions = await get_balance_assertions(limit=1000)
|
|
|
|
results = {
|
|
"total": len(all_assertions),
|
|
"checked": 0,
|
|
"passed": 0,
|
|
"failed": 0,
|
|
"errors": 0,
|
|
}
|
|
|
|
for assertion in all_assertions:
|
|
try:
|
|
checked = await check_balance_assertion(assertion.id)
|
|
results["checked"] += 1
|
|
if checked.status == AssertionStatus.PASSED:
|
|
results["passed"] += 1
|
|
elif checked.status == AssertionStatus.FAILED:
|
|
results["failed"] += 1
|
|
except Exception as e:
|
|
results["errors"] += 1
|
|
|
|
return results
|
|
|
|
|
|
@router.get("/api/v1/reconciliation/discrepancies")
|
|
async def api_get_discrepancies(
|
|
auth: AuthContext = Depends(require_super_user),
|
|
) -> dict:
|
|
"""Get all discrepancies (failed assertions, flagged entries) (super user only)"""
|
|
|
|
# Get failed assertions
|
|
failed_assertions = await get_balance_assertions(
|
|
status=AssertionStatus.FAILED,
|
|
limit=1000,
|
|
)
|
|
|
|
# Get flagged entries from Fava
|
|
from ..fava_client import get_fava_client
|
|
fava = get_fava_client()
|
|
all_entries = await fava.query_transactions(limit=1000, include_pending=True)
|
|
flagged_entries = [e for e in all_entries if e.get("flag") == "#"]
|
|
pending_entries = [e for e in all_entries if e.get("flag") == "!"]
|
|
|
|
return {
|
|
"failed_assertions": failed_assertions,
|
|
"flagged_entries": flagged_entries,
|
|
"pending_entries": pending_entries,
|
|
"total_discrepancies": len(failed_assertions) + len(flagged_entries),
|
|
}
|
|
|
|
|
|
# ===== AUTOMATED TASKS ENDPOINTS =====
|
|
|
|
|
|
@router.post("/api/v1/tasks/daily-reconciliation")
|
|
async def api_run_daily_reconciliation(
|
|
auth: AuthContext = Depends(require_super_user),
|
|
) -> dict:
|
|
"""
|
|
Manually trigger the daily reconciliation check (super user only).
|
|
This endpoint can also be called via cron job.
|
|
|
|
Returns a summary of the reconciliation check results.
|
|
"""
|
|
|
|
from ..tasks import check_all_balance_assertions
|
|
|
|
try:
|
|
results = await check_all_balance_assertions()
|
|
return results
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
detail=f"Error running daily reconciliation: {str(e)}",
|
|
)
|
|
|
|
|
|
# ===== USER EQUITY ELIGIBILITY ENDPOINTS =====
|