PHASE 2: Implements balance assertions for reconciliation

Adds balance assertion functionality to enable admins to verify accounting accuracy.

This includes:
- A new `balance_assertions` table in the database
- CRUD operations for balance assertions (create, get, list, check, delete)
- API endpoints for managing balance assertions (admin only)
- UI elements for creating, viewing, and re-checking assertions

Also, reorders the implementation roadmap in the documentation to reflect better the dependencies between phases.
This commit is contained in:
padreug 2025-10-23 01:36:09 +02:00
parent 1a9c91d042
commit 0257b7807c
7 changed files with 890 additions and 17 deletions

214
crud.py
View file

@ -8,8 +8,11 @@ from lnbits.helpers import urlsafe_short_hash
from .models import (
Account,
AccountType,
AssertionStatus,
BalanceAssertion,
CastleSettings,
CreateAccount,
CreateBalanceAssertion,
CreateEntryLine,
CreateJournalEntry,
EntryLine,
@ -765,3 +768,214 @@ async def reject_manual_payment_request(
)
return await get_manual_payment_request(request_id)
# ===== BALANCE ASSERTION OPERATIONS =====
async def create_balance_assertion(
data: CreateBalanceAssertion, created_by: str
) -> BalanceAssertion:
"""Create a new balance assertion"""
from decimal import Decimal
assertion_id = urlsafe_short_hash()
assertion_date = data.date if data.date else datetime.now()
assertion = BalanceAssertion(
id=assertion_id,
date=assertion_date,
account_id=data.account_id,
expected_balance_sats=data.expected_balance_sats,
expected_balance_fiat=data.expected_balance_fiat,
fiat_currency=data.fiat_currency,
tolerance_sats=data.tolerance_sats,
tolerance_fiat=data.tolerance_fiat,
status=AssertionStatus.PENDING,
created_by=created_by,
created_at=datetime.now(),
)
await db.insert("balance_assertions", assertion, convert_decimal=True)
return assertion
async def get_balance_assertion(assertion_id: str) -> Optional[BalanceAssertion]:
"""Get a balance assertion by ID"""
from decimal import Decimal
row = await db.fetchone(
"SELECT * FROM balance_assertions WHERE id = :id",
{"id": assertion_id},
)
if not row:
return None
# Parse Decimal fields from TEXT storage
return BalanceAssertion(
id=row["id"],
date=row["date"],
account_id=row["account_id"],
expected_balance_sats=row["expected_balance_sats"],
expected_balance_fiat=Decimal(row["expected_balance_fiat"]) if row["expected_balance_fiat"] else None,
fiat_currency=row["fiat_currency"],
tolerance_sats=row["tolerance_sats"],
tolerance_fiat=Decimal(row["tolerance_fiat"]) if row["tolerance_fiat"] else Decimal("0"),
checked_balance_sats=row["checked_balance_sats"],
checked_balance_fiat=Decimal(row["checked_balance_fiat"]) if row["checked_balance_fiat"] else None,
difference_sats=row["difference_sats"],
difference_fiat=Decimal(row["difference_fiat"]) if row["difference_fiat"] else None,
status=AssertionStatus(row["status"]),
created_by=row["created_by"],
created_at=row["created_at"],
checked_at=row["checked_at"],
)
async def get_balance_assertions(
account_id: Optional[str] = None,
status: Optional[AssertionStatus] = None,
limit: int = 100,
) -> list[BalanceAssertion]:
"""Get balance assertions with optional filters"""
from decimal import Decimal
if account_id and status:
rows = await db.fetchall(
"""
SELECT * FROM balance_assertions
WHERE account_id = :account_id AND status = :status
ORDER BY date DESC
LIMIT :limit
""",
{"account_id": account_id, "status": status.value, "limit": limit},
)
elif account_id:
rows = await db.fetchall(
"""
SELECT * FROM balance_assertions
WHERE account_id = :account_id
ORDER BY date DESC
LIMIT :limit
""",
{"account_id": account_id, "limit": limit},
)
elif status:
rows = await db.fetchall(
"""
SELECT * FROM balance_assertions
WHERE status = :status
ORDER BY date DESC
LIMIT :limit
""",
{"status": status.value, "limit": limit},
)
else:
rows = await db.fetchall(
"""
SELECT * FROM balance_assertions
ORDER BY date DESC
LIMIT :limit
""",
{"limit": limit},
)
assertions = []
for row in rows:
assertions.append(
BalanceAssertion(
id=row["id"],
date=row["date"],
account_id=row["account_id"],
expected_balance_sats=row["expected_balance_sats"],
expected_balance_fiat=Decimal(row["expected_balance_fiat"]) if row["expected_balance_fiat"] else None,
fiat_currency=row["fiat_currency"],
tolerance_sats=row["tolerance_sats"],
tolerance_fiat=Decimal(row["tolerance_fiat"]) if row["tolerance_fiat"] else Decimal("0"),
checked_balance_sats=row["checked_balance_sats"],
checked_balance_fiat=Decimal(row["checked_balance_fiat"]) if row["checked_balance_fiat"] else None,
difference_sats=row["difference_sats"],
difference_fiat=Decimal(row["difference_fiat"]) if row["difference_fiat"] else None,
status=AssertionStatus(row["status"]),
created_by=row["created_by"],
created_at=row["created_at"],
checked_at=row["checked_at"],
)
)
return assertions
async def check_balance_assertion(assertion_id: str) -> BalanceAssertion:
"""
Check a balance assertion by comparing expected vs actual balance.
Updates the assertion with the check results.
"""
from decimal import Decimal
assertion = await get_balance_assertion(assertion_id)
if not assertion:
raise ValueError(f"Balance assertion {assertion_id} not found")
# Get actual account balance
account = await get_account(assertion.account_id)
if not account:
raise ValueError(f"Account {assertion.account_id} not found")
# Calculate balance at the assertion date
actual_balance = await get_account_balance(assertion.account_id)
# Get fiat balance if needed
actual_fiat_balance = None
if assertion.fiat_currency and account.user_id:
user_balance = await get_user_balance(account.user_id)
actual_fiat_balance = user_balance.fiat_balances.get(assertion.fiat_currency, Decimal("0"))
# Check sats balance
difference_sats = actual_balance - assertion.expected_balance_sats
sats_match = abs(difference_sats) <= assertion.tolerance_sats
# Check fiat balance if applicable
fiat_match = True
difference_fiat = None
if assertion.expected_balance_fiat is not None and actual_fiat_balance is not None:
difference_fiat = actual_fiat_balance - assertion.expected_balance_fiat
fiat_match = abs(difference_fiat) <= assertion.tolerance_fiat
# Determine overall status
status = AssertionStatus.PASSED if (sats_match and fiat_match) else AssertionStatus.FAILED
# Update assertion with check results
await db.execute(
"""
UPDATE balance_assertions
SET checked_balance_sats = :checked_sats,
checked_balance_fiat = :checked_fiat,
difference_sats = :diff_sats,
difference_fiat = :diff_fiat,
status = :status,
checked_at = :checked_at
WHERE id = :id
""",
{
"id": assertion_id,
"checked_sats": actual_balance,
"checked_fiat": str(actual_fiat_balance) if actual_fiat_balance is not None else None,
"diff_sats": difference_sats,
"diff_fiat": str(difference_fiat) if difference_fiat is not None else None,
"status": status.value,
"checked_at": datetime.now(),
},
)
# Return updated assertion
return await get_balance_assertion(assertion_id)
async def delete_balance_assertion(assertion_id: str) -> None:
"""Delete a balance assertion"""
await db.execute(
"DELETE FROM balance_assertions WHERE id = :id",
{"id": assertion_id},
)