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

View file

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

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},
)

View file

@ -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);
"""
)

View file

@ -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")

View file

@ -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()
}
}
})

View file

@ -251,6 +251,136 @@
</q-card-section>
</q-card>
<!-- Balance Assertions (Super User Only) -->
<q-card v-if="isSuperUser">
<q-card-section>
<div class="row items-center justify-between q-mb-md">
<h6 class="q-my-none">Balance Assertions</h6>
<q-btn
size="sm"
color="primary"
@click="assertionDialog.show = true"
icon="add"
label="Create Assertion"
>
<q-tooltip>Create a new balance assertion for reconciliation</q-tooltip>
</q-btn>
</div>
<!-- Failed Assertions -->
<div v-if="failedAssertions.length > 0" class="q-mb-md">
<q-banner class="bg-negative text-white" rounded>
<template v-slot:avatar>
<q-icon name="error" color="white" />
</template>
<div class="text-weight-bold">{% raw %}{{ failedAssertions.length }}{% endraw %} Failed Assertion{% raw %}{{ failedAssertions.length > 1 ? 's' : '' }}{% endraw %}</div>
</q-banner>
<q-list bordered separator class="q-mt-sm">
<q-item v-for="assertion in failedAssertions" :key="assertion.id">
<q-item-section avatar>
<q-icon name="error" color="negative" size="sm">
<q-tooltip>Assertion failed</q-tooltip>
</q-icon>
</q-item-section>
<q-item-section>
<q-item-label>Account: {% raw %}{{ getAccountName(assertion.account_id) }}{% endraw %}</q-item-label>
<q-item-label caption>Expected: {% raw %}{{ formatSats(assertion.expected_balance_sats) }}{% endraw %} sats</q-item-label>
<q-item-label caption>Actual: {% raw %}{{ formatSats(assertion.checked_balance_sats) }}{% endraw %} sats</q-item-label>
<q-item-label caption class="text-negative">Difference: {% raw %}{{ formatSats(assertion.difference_sats) }}{% endraw %} sats</q-item-label>
<q-item-label caption v-if="assertion.fiat_currency">
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 %}
</q-item-label>
<q-item-label caption>Checked: {% raw %}{{ formatDate(assertion.checked_at) }}{% endraw %}</q-item-label>
</q-item-section>
<q-item-section side>
<div class="q-gutter-xs">
<q-btn
size="sm"
flat
round
icon="refresh"
@click="recheckAssertion(assertion.id)"
:loading="assertion.rechecking"
>
<q-tooltip>Re-check assertion</q-tooltip>
</q-btn>
<q-btn
size="sm"
flat
round
icon="delete"
@click="deleteAssertion(assertion.id)"
:loading="assertion.deleting"
>
<q-tooltip>Delete assertion</q-tooltip>
</q-btn>
</div>
</q-item-section>
</q-item>
</q-list>
</div>
<!-- Passed Assertions -->
<div v-if="passedAssertions.length > 0">
<q-expansion-item
label="Passed Assertions"
:caption="`${passedAssertions.length} assertion${passedAssertions.length > 1 ? 's' : ''} passed`"
icon="check_circle"
header-class="text-positive"
>
<q-list bordered separator>
<q-item v-for="assertion in passedAssertions" :key="assertion.id">
<q-item-section avatar>
<q-icon name="check_circle" color="positive" size="sm">
<q-tooltip>Assertion passed</q-tooltip>
</q-icon>
</q-item-section>
<q-item-section>
<q-item-label>{% raw %}{{ getAccountName(assertion.account_id) }}{% endraw %}</q-item-label>
<q-item-label caption>Balance: {% raw %}{{ formatSats(assertion.checked_balance_sats) }}{% endraw %} sats</q-item-label>
<q-item-label caption v-if="assertion.fiat_currency">
Fiat: {% raw %}{{ assertion.checked_balance_fiat }} {{ assertion.fiat_currency }}{% endraw %}
</q-item-label>
<q-item-label caption>Checked: {% raw %}{{ formatDate(assertion.checked_at) }}{% endraw %}</q-item-label>
</q-item-section>
<q-item-section side>
<div class="q-gutter-xs">
<q-btn
size="sm"
flat
round
icon="refresh"
@click="recheckAssertion(assertion.id)"
:loading="assertion.rechecking"
>
<q-tooltip>Re-check assertion</q-tooltip>
</q-btn>
<q-btn
size="sm"
flat
round
icon="delete"
@click="deleteAssertion(assertion.id)"
:loading="assertion.deleting"
>
<q-tooltip>Delete assertion</q-tooltip>
</q-btn>
</div>
</q-item-section>
</q-item>
</q-list>
</q-expansion-item>
</div>
<!-- No assertions message -->
<div v-if="balanceAssertions.length === 0" class="text-center text-grey q-pa-md">
No balance assertions yet. Create one to verify your accounting accuracy.
</div>
</q-card-section>
</q-card>
<!-- Quick Actions -->
<q-card>
<q-card-section>
@ -761,4 +891,110 @@
</q-card>
</q-dialog>
<!-- Balance Assertion Dialog -->
<q-dialog v-model="assertionDialog.show" position="top">
<q-card v-if="assertionDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="submitAssertion" class="q-gutter-md">
<div class="text-h6 q-mb-md">Create Balance Assertion</div>
<div class="text-caption text-grey q-mb-md">
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.
</div>
<q-select
filled
dense
v-model="assertionDialog.account_id"
:options="allAccounts"
option-label="name"
option-value="id"
emit-value
map-options
label="Account *"
:rules="[val => !!val || 'Account is required']"
>
<template v-slot:option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section>
<q-item-label>{% raw %}{{ scope.opt.name }}{% endraw %}</q-item-label>
<q-item-label caption>{% raw %}{{ scope.opt.account_type }}{% endraw %}</q-item-label>
</q-item-section>
</q-item>
</template>
</q-select>
<q-input
filled
dense
v-model.number="assertionDialog.expected_balance_sats"
type="number"
label="Expected Balance (sats) *"
:rules="[val => val !== null && val !== undefined || 'Expected balance is required']"
>
<template v-slot:hint>
The balance you expect this account to have in satoshis
</template>
</q-input>
<q-input
filled
dense
v-model.number="assertionDialog.tolerance_sats"
type="number"
label="Tolerance (sats)"
min="0"
>
<template v-slot:hint>
Allow the actual balance to differ by ± this amount (default: 0)
</template>
</q-input>
<q-separator />
<div class="text-subtitle2">Optional: Fiat Balance Check</div>
<q-select
filled
dense
v-model="assertionDialog.fiat_currency"
:options="currencyOptions"
option-label="label"
option-value="value"
emit-value
map-options
label="Fiat Currency (optional)"
clearable
></q-select>
<q-input
v-if="assertionDialog.fiat_currency"
filled
dense
v-model.number="assertionDialog.expected_balance_fiat"
type="number"
step="0.01"
:label="`Expected Fiat Balance (${assertionDialog.fiat_currency})`"
></q-input>
<q-input
v-if="assertionDialog.fiat_currency"
filled
dense
v-model.number="assertionDialog.tolerance_fiat"
type="number"
step="0.01"
label="Fiat Tolerance"
min="0"
></q-input>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit" :loading="assertionDialog.loading">
Create & Check
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
{% endblock %}

View file

@ -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"}