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:
parent
1a9c91d042
commit
0257b7807c
7 changed files with 890 additions and 17 deletions
214
crud.py
214
crud.py
|
|
@ -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},
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue