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>
This commit is contained in:
parent
9e06fa0b2a
commit
a7d7740a3a
12 changed files with 4233 additions and 4130 deletions
304
views_api/reconciliation.py
Normal file
304
views_api/reconciliation.py
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
"""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 =====
|
||||
Loading…
Add table
Add a link
Reference in a new issue