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
|
## Implementation Roadmap
|
||||||
|
|
||||||
### Phase 1: Foundation (High Priority)
|
### Phase 1: Foundation (High Priority) ✅ COMPLETE
|
||||||
1. ✅ Switch from `float` to `Decimal` for fiat amounts
|
1. ✅ Switch from `float` to `Decimal` for fiat amounts
|
||||||
2. ✅ Add `meta` field to journal entries for audit trail
|
2. ✅ Add `meta` field to journal entries for audit trail
|
||||||
3. ✅ Add `flag` field for transaction status
|
3. ✅ Add `flag` field for transaction status
|
||||||
4. ✅ Implement hierarchical account naming
|
4. ✅ Implement hierarchical account naming
|
||||||
|
|
||||||
### Phase 2: Core Logic (Medium Priority)
|
### Phase 2: Reconciliation (High Priority) - No dependencies
|
||||||
5. Create `core/` module with pure accounting logic
|
5. Implement balance assertions
|
||||||
6. Implement `CastleInventory` for position tracking
|
6. Add reconciliation API endpoints
|
||||||
7. Move balance calculation to `core/balance.py`
|
7. Build reconciliation UI
|
||||||
8. Add comprehensive validation in `core/validation.py`
|
8. Add automated daily balance checks
|
||||||
|
|
||||||
### Phase 3: Validation (Medium Priority)
|
### Phase 3: Core Logic Refactoring (Medium Priority) - Improves code quality
|
||||||
9. Create plugin system architecture
|
9. Create `core/` module with pure accounting logic
|
||||||
10. Implement `check_balanced` plugin
|
10. Implement `CastleInventory` for position tracking
|
||||||
11. Implement `check_receivables` plugin
|
11. Move balance calculation to `core/balance.py`
|
||||||
12. Add plugin configuration UI
|
12. Add comprehensive validation in `core/validation.py`
|
||||||
|
|
||||||
### Phase 4: Reconciliation (High Priority)
|
### Phase 4: Validation Plugins (Medium Priority) - Works better after Phase 3
|
||||||
13. Implement balance assertions
|
13. Create plugin system architecture
|
||||||
14. Add reconciliation API endpoints
|
14. Implement `check_balanced` plugin
|
||||||
15. Build reconciliation UI
|
15. Implement `check_receivables` plugin
|
||||||
16. Add automated daily balance checks
|
16. Add plugin configuration UI
|
||||||
|
|
||||||
### Phase 5: Advanced Features (Low Priority)
|
### Phase 5: Advanced Features (Low Priority)
|
||||||
17. Add tags and links to entries
|
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 (
|
from .models import (
|
||||||
Account,
|
Account,
|
||||||
AccountType,
|
AccountType,
|
||||||
|
AssertionStatus,
|
||||||
|
BalanceAssertion,
|
||||||
CastleSettings,
|
CastleSettings,
|
||||||
CreateAccount,
|
CreateAccount,
|
||||||
|
CreateBalanceAssertion,
|
||||||
CreateEntryLine,
|
CreateEntryLine,
|
||||||
CreateJournalEntry,
|
CreateJournalEntry,
|
||||||
EntryLine,
|
EntryLine,
|
||||||
|
|
@ -765,3 +768,214 @@ async def reject_manual_payment_request(
|
||||||
)
|
)
|
||||||
|
|
||||||
return await get_manual_payment_request(request_id)
|
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"]}
|
{"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"""
|
"""Record a payment"""
|
||||||
|
|
||||||
payment_hash: str
|
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
|
loading: false
|
||||||
},
|
},
|
||||||
manualPaymentRequests: [],
|
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: {
|
watch: {
|
||||||
|
|
@ -115,6 +126,15 @@ window.app = Vue.createApp({
|
||||||
},
|
},
|
||||||
pendingManualPaymentRequests() {
|
pendingManualPaymentRequests() {
|
||||||
return this.manualPaymentRequests.filter(r => r.status === 'pending')
|
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: {
|
methods: {
|
||||||
|
|
@ -579,6 +599,145 @@ window.app = Vue.createApp({
|
||||||
LNbits.utils.notifyApiError(error)
|
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) {
|
copyToClipboard(text) {
|
||||||
navigator.clipboard.writeText(text)
|
navigator.clipboard.writeText(text)
|
||||||
this.$q.notify({
|
this.$q.notify({
|
||||||
|
|
@ -730,6 +889,7 @@ window.app = Vue.createApp({
|
||||||
if (this.isSuperUser) {
|
if (this.isSuperUser) {
|
||||||
await this.loadUsers()
|
await this.loadUsers()
|
||||||
await this.loadPendingExpenses()
|
await this.loadPendingExpenses()
|
||||||
|
await this.loadBalanceAssertions()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,136 @@
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</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 -->
|
<!-- Quick Actions -->
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
|
|
@ -761,4 +891,110 @@
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</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 %}
|
{% 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 (
|
from .crud import (
|
||||||
approve_manual_payment_request,
|
approve_manual_payment_request,
|
||||||
|
check_balance_assertion,
|
||||||
create_account,
|
create_account,
|
||||||
|
create_balance_assertion,
|
||||||
create_journal_entry,
|
create_journal_entry,
|
||||||
create_manual_payment_request,
|
create_manual_payment_request,
|
||||||
db,
|
db,
|
||||||
|
delete_balance_assertion,
|
||||||
get_account,
|
get_account,
|
||||||
get_account_balance,
|
get_account_balance,
|
||||||
get_account_by_name,
|
get_account_by_name,
|
||||||
|
|
@ -26,6 +29,8 @@ from .crud import (
|
||||||
get_all_manual_payment_requests,
|
get_all_manual_payment_requests,
|
||||||
get_all_user_balances,
|
get_all_user_balances,
|
||||||
get_all_user_wallet_settings,
|
get_all_user_wallet_settings,
|
||||||
|
get_balance_assertion,
|
||||||
|
get_balance_assertions,
|
||||||
get_journal_entries_by_user,
|
get_journal_entries_by_user,
|
||||||
get_journal_entry,
|
get_journal_entry,
|
||||||
get_manual_payment_request,
|
get_manual_payment_request,
|
||||||
|
|
@ -37,8 +42,11 @@ from .crud import (
|
||||||
from .models import (
|
from .models import (
|
||||||
Account,
|
Account,
|
||||||
AccountType,
|
AccountType,
|
||||||
|
AssertionStatus,
|
||||||
|
BalanceAssertion,
|
||||||
CastleSettings,
|
CastleSettings,
|
||||||
CreateAccount,
|
CreateAccount,
|
||||||
|
CreateBalanceAssertion,
|
||||||
CreateEntryLine,
|
CreateEntryLine,
|
||||||
CreateJournalEntry,
|
CreateJournalEntry,
|
||||||
CreateManualPaymentRequest,
|
CreateManualPaymentRequest,
|
||||||
|
|
@ -1051,3 +1059,172 @@ async def api_reject_expense_entry(
|
||||||
|
|
||||||
# Return updated entry
|
# Return updated entry
|
||||||
return await get_journal_entry(entry_id)
|
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