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
|
|
@ -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
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},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
"""
|
||||
)
|
||||
|
|
|
|||
38
models.py
38
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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
177
views_api.py
177
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"}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue