diff --git a/BEANCOUNT_PATTERNS.md b/BEANCOUNT_PATTERNS.md index 13b0519..d3e4c5b 100644 --- a/BEANCOUNT_PATTERNS.md +++ b/BEANCOUNT_PATTERNS.md @@ -866,29 +866,29 @@ async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError] ## Implementation Roadmap -### Phase 1: Foundation (High Priority) +### Phase 1: Foundation (High Priority) ✅ COMPLETE 1. ✅ Switch from `float` to `Decimal` for fiat amounts 2. ✅ Add `meta` field to journal entries for audit trail 3. ✅ Add `flag` field for transaction status 4. ✅ Implement hierarchical account naming -### Phase 2: Core Logic (Medium Priority) -5. Create `core/` module with pure accounting logic -6. Implement `CastleInventory` for position tracking -7. Move balance calculation to `core/balance.py` -8. Add comprehensive validation in `core/validation.py` +### Phase 2: Reconciliation (High Priority) - No dependencies +5. Implement balance assertions +6. Add reconciliation API endpoints +7. Build reconciliation UI +8. Add automated daily balance checks -### Phase 3: Validation (Medium Priority) -9. Create plugin system architecture -10. Implement `check_balanced` plugin -11. Implement `check_receivables` plugin -12. Add plugin configuration UI +### Phase 3: Core Logic Refactoring (Medium Priority) - Improves code quality +9. Create `core/` module with pure accounting logic +10. Implement `CastleInventory` for position tracking +11. Move balance calculation to `core/balance.py` +12. Add comprehensive validation in `core/validation.py` -### Phase 4: Reconciliation (High Priority) -13. Implement balance assertions -14. Add reconciliation API endpoints -15. Build reconciliation UI -16. Add automated daily balance checks +### Phase 4: Validation Plugins (Medium Priority) - Works better after Phase 3 +13. Create plugin system architecture +14. Implement `check_balanced` plugin +15. Implement `check_receivables` plugin +16. Add plugin configuration UI ### Phase 5: Advanced Features (Low Priority) 17. Add tags and links to entries diff --git a/crud.py b/crud.py index be8f830..6d5285a 100644 --- a/crud.py +++ b/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}, + ) diff --git a/migrations.py b/migrations.py index cc3fb38..611848c 100644 --- a/migrations.py +++ b/migrations.py @@ -270,3 +270,51 @@ async def m006_hierarchical_account_names(db): """, {"new_name": new_name, "id": account["id"]} ) + + +async def m007_balance_assertions(db): + """ + Create balance_assertions table for reconciliation. + Allows admins to assert expected balances at specific dates. + """ + await db.execute( + f""" + CREATE TABLE balance_assertions ( + id TEXT PRIMARY KEY, + date TIMESTAMP NOT NULL, + account_id TEXT NOT NULL, + expected_balance_sats INTEGER NOT NULL, + expected_balance_fiat TEXT, + fiat_currency TEXT, + tolerance_sats INTEGER DEFAULT 0, + tolerance_fiat TEXT DEFAULT '0', + checked_balance_sats INTEGER, + checked_balance_fiat TEXT, + difference_sats INTEGER, + difference_fiat TEXT, + status TEXT NOT NULL DEFAULT 'pending', + created_by TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + checked_at TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES accounts (id) + ); + """ + ) + + await db.execute( + """ + CREATE INDEX idx_balance_assertions_account_id ON balance_assertions (account_id); + """ + ) + + await db.execute( + """ + CREATE INDEX idx_balance_assertions_status ON balance_assertions (status); + """ + ) + + await db.execute( + """ + CREATE INDEX idx_balance_assertions_date ON balance_assertions (date); + """ + ) diff --git a/models.py b/models.py index ef5dfc9..904b648 100644 --- a/models.py +++ b/models.py @@ -179,3 +179,41 @@ class RecordPayment(BaseModel): """Record a payment""" payment_hash: str + + +class AssertionStatus(str, Enum): + """Status of a balance assertion""" + PENDING = "pending" # Not yet checked + PASSED = "passed" # Assertion passed (balance matches) + FAILED = "failed" # Assertion failed (balance mismatch) + + +class BalanceAssertion(BaseModel): + """Assert expected balance at a specific date for reconciliation""" + id: str + date: datetime + account_id: str + expected_balance_sats: int # Expected balance in satoshis + expected_balance_fiat: Optional[Decimal] = None # Optional fiat balance + fiat_currency: Optional[str] = None # Currency for fiat balance (EUR, USD, etc.) + tolerance_sats: int = 0 # Allow +/- this much difference in sats + tolerance_fiat: Decimal = Decimal("0") # Allow +/- this much difference in fiat + checked_balance_sats: Optional[int] = None # Actual balance found + checked_balance_fiat: Optional[Decimal] = None # Actual fiat balance found + difference_sats: Optional[int] = None # Difference in sats + difference_fiat: Optional[Decimal] = None # Difference in fiat + status: AssertionStatus = AssertionStatus.PENDING + created_by: str + created_at: datetime + checked_at: Optional[datetime] = None + + +class CreateBalanceAssertion(BaseModel): + """Create a balance assertion""" + account_id: str + date: Optional[datetime] = None # If None, use current time + expected_balance_sats: int + expected_balance_fiat: Optional[Decimal] = None + fiat_currency: Optional[str] = None + tolerance_sats: int = 0 + tolerance_fiat: Decimal = Decimal("0") diff --git a/static/js/index.js b/static/js/index.js index 09d4904..f6c75cd 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -65,7 +65,18 @@ window.app = Vue.createApp({ loading: false }, manualPaymentRequests: [], - pendingExpenses: [] + pendingExpenses: [], + balanceAssertions: [], + assertionDialog: { + show: false, + account_id: '', + expected_balance_sats: null, + expected_balance_fiat: null, + fiat_currency: null, + tolerance_sats: 0, + tolerance_fiat: 0, + loading: false + } } }, watch: { @@ -115,6 +126,15 @@ window.app = Vue.createApp({ }, pendingManualPaymentRequests() { return this.manualPaymentRequests.filter(r => r.status === 'pending') + }, + failedAssertions() { + return this.balanceAssertions.filter(a => a.status === 'failed') + }, + passedAssertions() { + return this.balanceAssertions.filter(a => a.status === 'passed') + }, + allAccounts() { + return this.accounts } }, methods: { @@ -579,6 +599,145 @@ window.app = Vue.createApp({ LNbits.utils.notifyApiError(error) } }, + async loadBalanceAssertions() { + if (!this.isSuperUser) return + + try { + const response = await LNbits.api.request( + 'GET', + '/castle/api/v1/assertions', + this.g.user.wallets[0].adminkey + ) + this.balanceAssertions = response.data + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + async submitAssertion() { + this.assertionDialog.loading = true + try { + const payload = { + account_id: this.assertionDialog.account_id, + expected_balance_sats: this.assertionDialog.expected_balance_sats, + tolerance_sats: this.assertionDialog.tolerance_sats || 0 + } + + // Add fiat balance check if currency selected + if (this.assertionDialog.fiat_currency) { + payload.fiat_currency = this.assertionDialog.fiat_currency + payload.expected_balance_fiat = this.assertionDialog.expected_balance_fiat + payload.tolerance_fiat = this.assertionDialog.tolerance_fiat || 0 + } + + await LNbits.api.request( + 'POST', + '/castle/api/v1/assertions', + this.g.user.wallets[0].adminkey, + payload + ) + + this.$q.notify({ + type: 'positive', + message: 'Balance assertion passed!', + timeout: 3000 + }) + + // Reset dialog + this.assertionDialog = { + show: false, + account_id: '', + expected_balance_sats: null, + expected_balance_fiat: null, + fiat_currency: null, + tolerance_sats: 0, + tolerance_fiat: 0, + loading: false + } + + // Reload assertions + await this.loadBalanceAssertions() + } catch (error) { + // Check if it's a 409 Conflict (assertion failed) + if (error.response && error.response.status === 409) { + const detail = error.response.data.detail + this.$q.notify({ + type: 'negative', + message: `Assertion Failed! Expected: ${this.formatSats(detail.expected_sats)} sats, Got: ${this.formatSats(detail.actual_sats)} sats (diff: ${this.formatSats(detail.difference_sats)} sats)`, + timeout: 5000, + html: true + }) + // Still reload to show the failed assertion + await this.loadBalanceAssertions() + this.assertionDialog.show = false + } else { + LNbits.utils.notifyApiError(error) + } + } finally { + this.assertionDialog.loading = false + } + }, + async recheckAssertion(assertionId) { + // Set loading state + const assertion = this.balanceAssertions.find(a => a.id === assertionId) + if (assertion) { + this.$set(assertion, 'rechecking', true) + } + + try { + await LNbits.api.request( + 'POST', + `/castle/api/v1/assertions/${assertionId}/check`, + this.g.user.wallets[0].adminkey + ) + + this.$q.notify({ + type: 'positive', + message: 'Assertion re-checked', + timeout: 2000 + }) + + await this.loadBalanceAssertions() + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + if (assertion) { + this.$set(assertion, 'rechecking', false) + } + } + }, + async deleteAssertion(assertionId) { + // Set loading state + const assertion = this.balanceAssertions.find(a => a.id === assertionId) + if (assertion) { + this.$set(assertion, 'deleting', true) + } + + try { + await LNbits.api.request( + 'DELETE', + `/castle/api/v1/assertions/${assertionId}`, + this.g.user.wallets[0].adminkey + ) + + this.$q.notify({ + type: 'positive', + message: 'Assertion deleted', + timeout: 2000 + }) + + await this.loadBalanceAssertions() + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + if (assertion) { + this.$set(assertion, 'deleting', false) + } + } + }, + getAccountName(accountId) { + const account = this.accounts.find(a => a.id === accountId) + return account ? account.name : accountId + }, copyToClipboard(text) { navigator.clipboard.writeText(text) this.$q.notify({ @@ -730,6 +889,7 @@ window.app = Vue.createApp({ if (this.isSuperUser) { await this.loadUsers() await this.loadPendingExpenses() + await this.loadBalanceAssertions() } } }) diff --git a/templates/castle/index.html b/templates/castle/index.html index fb722ac..2290d88 100644 --- a/templates/castle/index.html +++ b/templates/castle/index.html @@ -251,6 +251,136 @@ + + + +
+
Balance Assertions
+ + Create a new balance assertion for reconciliation + +
+ + +
+ + +
{% raw %}{{ failedAssertions.length }}{% endraw %} Failed Assertion{% raw %}{{ failedAssertions.length > 1 ? 's' : '' }}{% endraw %}
+
+ + + + + Assertion failed + + + + Account: {% raw %}{{ getAccountName(assertion.account_id) }}{% endraw %} + Expected: {% raw %}{{ formatSats(assertion.expected_balance_sats) }}{% endraw %} sats + Actual: {% raw %}{{ formatSats(assertion.checked_balance_sats) }}{% endraw %} sats + Difference: {% raw %}{{ formatSats(assertion.difference_sats) }}{% endraw %} sats + + Expected: {% raw %}{{ assertion.expected_balance_fiat }} {{ assertion.fiat_currency }}{% endraw %} | + Actual: {% raw %}{{ assertion.checked_balance_fiat }} {{ assertion.fiat_currency }}{% endraw %} | + Difference: {% raw %}{{ assertion.difference_fiat }} {{ assertion.fiat_currency }}{% endraw %} + + Checked: {% raw %}{{ formatDate(assertion.checked_at) }}{% endraw %} + + +
+ + Re-check assertion + + + Delete assertion + +
+
+
+
+
+ + +
+ + + + + + Assertion passed + + + + {% raw %}{{ getAccountName(assertion.account_id) }}{% endraw %} + Balance: {% raw %}{{ formatSats(assertion.checked_balance_sats) }}{% endraw %} sats + + Fiat: {% raw %}{{ assertion.checked_balance_fiat }} {{ assertion.fiat_currency }}{% endraw %} + + Checked: {% raw %}{{ formatDate(assertion.checked_at) }}{% endraw %} + + +
+ + Re-check assertion + + + Delete assertion + +
+
+
+
+
+
+ + +
+ No balance assertions yet. Create one to verify your accounting accuracy. +
+
+
+ @@ -761,4 +891,110 @@ + + + + +
Create Balance Assertion
+ +
+ Balance assertions help you verify accounting accuracy by checking if an account's actual balance matches your expected balance. If the assertion fails, you'll be alerted to investigate the discrepancy. +
+ + + + + + + + + + + + + + + +
Optional: Fiat Balance Check
+ + + + + + + +
+ + Create & Check + + Cancel +
+
+
+
+ {% endblock %} diff --git a/views_api.py b/views_api.py index f709d4a..1ea10b3 100644 --- a/views_api.py +++ b/views_api.py @@ -13,10 +13,13 @@ from lnbits.utils.exchange_rates import allowed_currencies, fiat_amount_as_satos from .crud import ( approve_manual_payment_request, + check_balance_assertion, create_account, + create_balance_assertion, create_journal_entry, create_manual_payment_request, db, + delete_balance_assertion, get_account, get_account_balance, get_account_by_name, @@ -26,6 +29,8 @@ from .crud import ( get_all_manual_payment_requests, get_all_user_balances, get_all_user_wallet_settings, + get_balance_assertion, + get_balance_assertions, get_journal_entries_by_user, get_journal_entry, get_manual_payment_request, @@ -37,8 +42,11 @@ from .crud import ( from .models import ( Account, AccountType, + AssertionStatus, + BalanceAssertion, CastleSettings, CreateAccount, + CreateBalanceAssertion, CreateEntryLine, CreateJournalEntry, CreateManualPaymentRequest, @@ -1051,3 +1059,172 @@ async def api_reject_expense_entry( # Return updated entry return await get_journal_entry(entry_id) + + +# ===== BALANCE ASSERTION ENDPOINTS ===== + + +@castle_api_router.post("/api/v1/assertions") +async def api_create_balance_assertion( + data: CreateBalanceAssertion, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> BalanceAssertion: + """ + Create a balance assertion for reconciliation (admin only). + The assertion will be checked immediately upon creation. + """ + from lnbits.settings import settings as lnbits_settings + + if wallet.wallet.user != lnbits_settings.super_user: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Only super user can create balance assertions", + ) + + # 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", + ) + + # Create the assertion + assertion = await create_balance_assertion(data, wallet.wallet.user) + + # Check it immediately + 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", + "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 + + +@castle_api_router.get("/api/v1/assertions") +async def api_get_balance_assertions( + account_id: str = None, + status: str = None, + limit: int = 100, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> list[BalanceAssertion]: + """Get balance assertions with optional filters (admin only)""" + from lnbits.settings import settings as lnbits_settings + + if wallet.wallet.user != lnbits_settings.super_user: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Only super user can view balance assertions", + ) + + # 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, + ) + + +@castle_api_router.get("/api/v1/assertions/{assertion_id}") +async def api_get_balance_assertion( + assertion_id: str, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> BalanceAssertion: + """Get a specific balance assertion (admin only)""" + from lnbits.settings import settings as lnbits_settings + + if wallet.wallet.user != lnbits_settings.super_user: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Only super user can view balance assertions", + ) + + assertion = await get_balance_assertion(assertion_id) + if not assertion: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Balance assertion not found", + ) + + return assertion + + +@castle_api_router.post("/api/v1/assertions/{assertion_id}/check") +async def api_check_balance_assertion( + assertion_id: str, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> BalanceAssertion: + """Re-check a balance assertion (admin only)""" + from lnbits.settings import settings as lnbits_settings + + if wallet.wallet.user != lnbits_settings.super_user: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Only super user can check balance assertions", + ) + + try: + assertion = await check_balance_assertion(assertion_id) + except ValueError as e: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=str(e), + ) + + return assertion + + +@castle_api_router.delete("/api/v1/assertions/{assertion_id}") +async def api_delete_balance_assertion( + assertion_id: str, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> dict: + """Delete a balance assertion (admin only)""" + from lnbits.settings import settings as lnbits_settings + + if wallet.wallet.user != lnbits_settings.super_user: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Only super user can delete balance assertions", + ) + + # 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"}