libra/views_api/reconciliation.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

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 =====