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

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