Balance assertions now use a hybrid architecture where Beancount is the source of truth for validation, while Castle stores metadata for UI convenience. Backend changes: - Add format_balance() function to beancount_format.py for formatting balance directives - Update POST /api/v1/assertions to write balance directive to Beancount first (via Fava) - Store metadata in Castle DB (created_by, tolerance, notes) for UI features - Validate assertions immediately by querying Fava for actual balance Frontend changes: - Update dialog description to explain Beancount validation - Update button tooltip to clarify balance assertions are written to Beancount - Update empty state message to mention Beancount checkpoints Benefits: - Single source of truth (Beancount ledger file) - Automatic validation by Beancount - Best of both worlds: robust validation + friendly UI See misc-docs/BALANCE-ASSERTIONS-HYBRID-APPROACH.md for full documentation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1536 lines
56 KiB
HTML
1536 lines
56 KiB
HTML
{% extends "base.html" %}
|
|
{% from "macros.jinja" import window_vars with context %}
|
|
|
|
{% block scripts %}
|
|
{{ window_vars(user) }}
|
|
<script src="{{ static_url_for('castle/static', path='js/index.js') }}"></script>
|
|
{% endblock %}
|
|
|
|
{% block page %}
|
|
<div class="row q-col-gutter-md">
|
|
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
|
<q-card>
|
|
<q-card-section>
|
|
<div class="row items-center no-wrap">
|
|
<div class="col">
|
|
<h5 class="q-my-none">🏰 Castle Accounting</h5>
|
|
<p class="q-mb-none">Track expenses, receivables, and balances for the collective</p>
|
|
</div>
|
|
<div class="col-auto q-gutter-xs">
|
|
<q-btn v-if="!isSuperUser" flat round icon="account_balance_wallet" @click="showUserWalletDialog">
|
|
<q-tooltip>Configure Your Wallet</q-tooltip>
|
|
</q-btn>
|
|
<q-btn v-if="isSuperUser" flat round icon="admin_panel_settings" :href="'/castle/permissions'">
|
|
<q-tooltip>Manage Permissions (Admin)</q-tooltip>
|
|
</q-btn>
|
|
<q-btn v-if="isSuperUser" flat round icon="settings" @click="showSettingsDialog">
|
|
<q-tooltip>Castle Settings (Super User Only)</q-tooltip>
|
|
</q-btn>
|
|
</div>
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
|
|
<!-- Setup Warning -->
|
|
<q-banner v-if="!castleWalletConfigured && isSuperUser" class="bg-warning text-white" rounded>
|
|
<template v-slot:avatar>
|
|
<q-icon name="warning" color="white"></q-icon>
|
|
</template>
|
|
<div>
|
|
<strong>Setup Required:</strong> Castle Wallet ID must be configured before the extension can function.
|
|
</div>
|
|
<template v-slot:action>
|
|
<q-btn flat color="white" label="Configure Now" @click="showSettingsDialog"></q-btn>
|
|
</template>
|
|
</q-banner>
|
|
|
|
<q-banner v-if="!castleWalletConfigured && !isSuperUser" class="bg-info text-white" rounded>
|
|
<template v-slot:avatar>
|
|
<q-icon name="info" color="white"></q-icon>
|
|
</template>
|
|
<div>
|
|
<strong>Setup Required:</strong> This extension requires configuration by the super user before it can be used.
|
|
</div>
|
|
</q-banner>
|
|
|
|
<q-banner v-if="castleWalletConfigured && !userWalletConfigured && !isSuperUser" class="bg-orange text-white" rounded>
|
|
<template v-slot:avatar>
|
|
<q-icon name="account_balance_wallet" color="white"></q-icon>
|
|
</template>
|
|
<div>
|
|
<strong>Wallet Setup Required:</strong> You must configure your wallet before using this extension.
|
|
</div>
|
|
<template v-slot:action>
|
|
<q-btn flat color="white" label="Configure Wallet" @click="showUserWalletDialog"></q-btn>
|
|
</template>
|
|
</q-banner>
|
|
|
|
<!-- Pending Expense Entries (Super User Only) -->
|
|
<q-card v-if="isSuperUser && pendingExpenses.length > 0">
|
|
<q-card-section>
|
|
<h6 class="q-my-none q-mb-md">Pending Expense Approvals</h6>
|
|
<q-list separator>
|
|
<q-item v-for="entry in pendingExpenses" :key="entry.id">
|
|
<q-item-section avatar>
|
|
<q-icon name="pending" color="orange" size="sm">
|
|
<q-tooltip>Pending approval</q-tooltip>
|
|
</q-icon>
|
|
</q-item-section>
|
|
<q-item-section>
|
|
<q-item-label>{% raw %}{{ entry.description }}{% endraw %}</q-item-label>
|
|
<q-item-label caption>
|
|
{% raw %}{{ formatDate(entry.entry_date) }}{% endraw %}
|
|
</q-item-label>
|
|
<q-item-label caption v-if="entry.username">
|
|
User: {% raw %}{{ entry.username }}{% endraw %}
|
|
</q-item-label>
|
|
<q-item-label caption v-if="entry.reference" class="text-grey">
|
|
Ref: {% raw %}{{ entry.reference }}{% endraw %}
|
|
</q-item-label>
|
|
</q-item-section>
|
|
<q-item-section side>
|
|
<q-item-label>{% raw %}{{ formatSats(getTotalAmount(entry)) }} sats{% endraw %}</q-item-label>
|
|
<q-item-label caption v-if="getEntryFiatAmount(entry)">
|
|
{% raw %}{{ getEntryFiatAmount(entry) }}{% endraw %}
|
|
</q-item-label>
|
|
</q-item-section>
|
|
<q-item-section side>
|
|
<div class="q-gutter-xs">
|
|
<q-btn
|
|
size="sm"
|
|
color="positive"
|
|
@click="approveExpense(entry.id)"
|
|
:loading="entry.approving"
|
|
>
|
|
Approve
|
|
</q-btn>
|
|
<q-btn
|
|
size="sm"
|
|
color="negative"
|
|
@click="rejectExpense(entry.id)"
|
|
:loading="entry.rejecting"
|
|
>
|
|
Reject
|
|
</q-btn>
|
|
</div>
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</q-card-section>
|
|
</q-card>
|
|
|
|
<!-- Quick Actions -->
|
|
<q-card>
|
|
<q-card-section>
|
|
<h6 class="q-my-none q-mb-md">Quick Actions</h6>
|
|
<div class="row q-gutter-sm">
|
|
<q-btn
|
|
color="primary"
|
|
@click="expenseDialog.show = true"
|
|
:disable="!castleWalletConfigured || (!userWalletConfigured && !isSuperUser)"
|
|
>
|
|
Add Expense
|
|
<q-tooltip v-if="!castleWalletConfigured">
|
|
Castle wallet must be configured first
|
|
</q-tooltip>
|
|
<q-tooltip v-if="castleWalletConfigured && !userWalletConfigured && !isSuperUser">
|
|
You must configure your wallet first
|
|
</q-tooltip>
|
|
</q-btn>
|
|
<q-btn
|
|
v-if="isSuperUser"
|
|
color="orange"
|
|
@click="showReceivableDialog"
|
|
:disable="!castleWalletConfigured"
|
|
>
|
|
Add Receivable
|
|
<q-tooltip v-if="!castleWalletConfigured">
|
|
Castle wallet must be configured first
|
|
</q-tooltip>
|
|
<q-tooltip v-else>
|
|
Record when a user owes the Castle
|
|
</q-tooltip>
|
|
</q-btn>
|
|
<q-btn color="secondary" @click="loadTransactions">
|
|
View Transactions
|
|
</q-btn>
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
|
|
<!-- User Balances Breakdown (Super User Only) -->
|
|
<q-card v-if="isSuperUser && outstandingUserBalances.length > 0">
|
|
<q-card-section>
|
|
<h6 class="q-my-none q-mb-md">Outstanding Balances by User</h6>
|
|
<q-table
|
|
flat
|
|
:rows="outstandingUserBalances"
|
|
:columns="[
|
|
{name: 'user', label: 'User', field: 'username', align: 'left'},
|
|
{name: 'balance', label: 'Amount Owed', field: 'balance', align: 'right'},
|
|
{name: 'actions', label: 'Actions', align: 'center'}
|
|
]"
|
|
row-key="user_id"
|
|
hide-pagination
|
|
:rows-per-page-options="[0]"
|
|
>
|
|
<template v-slot:body-cell-user="props">
|
|
<q-td :props="props">
|
|
<div>{% raw %}{{ props.row.username }}{% endraw %}</div>
|
|
<div class="text-caption text-grey">{% raw %}{{ props.row.user_id.substring(0, 16) }}...{% endraw %}</div>
|
|
</q-td>
|
|
</template>
|
|
<template v-slot:body-cell-balance="props">
|
|
<q-td :props="props">
|
|
<div :class="props.row.balance > 0 ? 'text-positive' : 'text-negative'">
|
|
{% raw %}{{ formatSats(Math.abs(props.row.balance)) }} sats{% endraw %}
|
|
</div>
|
|
<div v-if="props.row.fiat_balances && Object.keys(props.row.fiat_balances).length > 0" class="text-caption">
|
|
<span v-for="(amount, currency) in props.row.fiat_balances" :key="currency" class="q-mr-sm">
|
|
{% raw %}{{ formatFiat(Math.abs(amount), currency) }}{% endraw %}
|
|
</span>
|
|
</div>
|
|
<div class="text-caption text-grey">
|
|
{% raw %}{{ props.row.balance > 0 ? 'Owes you' : 'You owe' }}{% endraw %}
|
|
</div>
|
|
</q-td>
|
|
</template>
|
|
<template v-slot:body-cell-actions="props">
|
|
<q-td :props="props">
|
|
<!-- User owes Castle (positive balance) - Castle receives payment -->
|
|
<q-btn
|
|
v-if="props.row.balance > 0"
|
|
flat
|
|
dense
|
|
size="sm"
|
|
color="primary"
|
|
icon="payments"
|
|
@click="showSettleReceivableDialog(props.row)"
|
|
>
|
|
<q-tooltip>Settle receivable (user pays castle)</q-tooltip>
|
|
</q-btn>
|
|
<!-- Castle owes User (negative balance) - Castle pays user -->
|
|
<q-btn
|
|
v-if="props.row.balance < 0"
|
|
flat
|
|
dense
|
|
size="sm"
|
|
color="positive"
|
|
icon="send"
|
|
@click="showPayUserDialog(props.row)"
|
|
>
|
|
<q-tooltip>Pay user (castle pays user)</q-tooltip>
|
|
</q-btn>
|
|
</q-td>
|
|
</template>
|
|
</q-table>
|
|
</q-card-section>
|
|
</q-card>
|
|
|
|
<!-- User Balance Card -->
|
|
<q-card>
|
|
<q-card-section>
|
|
<div class="row items-center no-wrap q-mb-sm">
|
|
<div class="col">
|
|
<h6 class="q-my-none">Your Balance</h6>
|
|
</div>
|
|
<div class="col-auto">
|
|
<q-btn flat round icon="refresh" @click="loadBalance">
|
|
<q-tooltip>Refresh balance</q-tooltip>
|
|
</q-btn>
|
|
</div>
|
|
</div>
|
|
<div v-if="balance !== null">
|
|
<div class="text-h4" :class="isSuperUser ? (balance.balance >= 0 ? 'text-positive' : 'text-negative') : (balance.balance >= 0 ? 'text-negative' : 'text-positive')">
|
|
{% raw %}{{ formatSats(Math.abs(balance.balance)) }} sats{% endraw %}
|
|
</div>
|
|
<div v-if="balance.fiat_balances && Object.keys(balance.fiat_balances).length > 0" class="text-h6 q-mt-sm">
|
|
<span v-for="(amount, currency) in balance.fiat_balances" :key="currency" class="q-mr-md">
|
|
{% raw %}{{ formatFiat(Math.abs(amount), currency) }}{% endraw %}
|
|
</span>
|
|
</div>
|
|
<div class="text-subtitle2" v-if="isSuperUser">
|
|
{% raw %}{{ balance.balance > 0 ? 'Total owed to you' : balance.balance < 0 ? 'Total you owe' : 'No outstanding balances' }}{% endraw %}
|
|
</div>
|
|
<div class="text-subtitle2" v-else>
|
|
{% raw %}{{ balance.balance >= 0 ? 'You owe Castle' : 'Castle owes you' }}{% endraw %}
|
|
</div>
|
|
<div class="q-mt-md q-gutter-sm">
|
|
<q-btn
|
|
v-if="balance.balance > 0 && !isSuperUser"
|
|
color="primary"
|
|
@click="showPayBalanceDialog"
|
|
>
|
|
Pay Balance
|
|
</q-btn>
|
|
<q-btn
|
|
v-if="balance.balance < 0 && !isSuperUser"
|
|
color="secondary"
|
|
@click="showManualPaymentDialog"
|
|
>
|
|
Request Manual Payment
|
|
</q-btn>
|
|
</div>
|
|
</div>
|
|
<div v-else>
|
|
<q-spinner color="primary" size="md"></q-spinner>
|
|
Loading balance...
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
|
|
<!-- Pending Manual Payment Requests (Super User Only) -->
|
|
<q-card v-if="isSuperUser && pendingManualPaymentRequests.length > 0">
|
|
<q-card-section>
|
|
<h6 class="q-my-none q-mb-md">Pending Manual Payment Requests</h6>
|
|
<q-list separator>
|
|
<q-item v-for="request in pendingManualPaymentRequests" :key="request.id">
|
|
<q-item-section>
|
|
<q-item-label>{% raw %}{{ request.description }}{% endraw %}</q-item-label>
|
|
<q-item-label caption>
|
|
User: {% raw %}{{ getUserName(request.user_id) }}{% endraw %}
|
|
</q-item-label>
|
|
<q-item-label caption>
|
|
Requested: {% raw %}{{ formatDate(request.created_at) }}{% endraw %}
|
|
</q-item-label>
|
|
</q-item-section>
|
|
<q-item-section side>
|
|
<q-item-label>{% raw %}{{ formatSats(request.amount) }} sats{% endraw %}</q-item-label>
|
|
</q-item-section>
|
|
<q-item-section side>
|
|
<div class="q-gutter-xs">
|
|
<q-btn
|
|
size="sm"
|
|
color="positive"
|
|
@click="approveManualPaymentRequest(request.id)"
|
|
:loading="request.approving"
|
|
>
|
|
Approve
|
|
</q-btn>
|
|
<q-btn
|
|
size="sm"
|
|
color="negative"
|
|
@click="rejectManualPaymentRequest(request.id)"
|
|
:loading="request.rejecting"
|
|
>
|
|
Reject
|
|
</q-btn>
|
|
</div>
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</q-card-section>
|
|
</q-card>
|
|
|
|
<!-- Recent Transactions -->
|
|
<q-card>
|
|
<q-card-section>
|
|
<div class="row items-center no-wrap q-mb-sm">
|
|
<div class="col">
|
|
<h6 class="q-my-none">Recent Transactions</h6>
|
|
</div>
|
|
<div class="col-auto">
|
|
<q-btn flat round icon="refresh" @click="loadTransactions">
|
|
<q-tooltip>Refresh transactions</q-tooltip>
|
|
</q-btn>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filter Bar (Super User Only) -->
|
|
<div v-if="isSuperUser" class="row q-gutter-sm q-mb-md items-center">
|
|
<div class="col-auto" style="min-width: 200px;">
|
|
<q-select
|
|
v-model="transactionFilter.user_id"
|
|
:options="allUserBalances"
|
|
option-value="user_id"
|
|
option-label="username"
|
|
emit-value
|
|
map-options
|
|
clearable
|
|
label="Filter by User"
|
|
dense
|
|
outlined
|
|
@update:model-value="applyTransactionFilter"
|
|
>
|
|
<template v-slot:prepend>
|
|
<q-icon name="person" />
|
|
</template>
|
|
</q-select>
|
|
</div>
|
|
<div class="col-auto" style="min-width: 250px;">
|
|
<q-select
|
|
v-model="transactionFilter.account_type"
|
|
:options="accountTypeOptions"
|
|
option-value="value"
|
|
option-label="label"
|
|
emit-value
|
|
map-options
|
|
clearable
|
|
label="Filter by Type"
|
|
dense
|
|
outlined
|
|
@update:model-value="applyTransactionFilter"
|
|
>
|
|
<template v-slot:prepend>
|
|
<q-icon name="account_balance" />
|
|
</template>
|
|
</q-select>
|
|
</div>
|
|
<div class="col-auto" v-if="transactionFilter.user_id || transactionFilter.account_type">
|
|
<q-btn
|
|
flat
|
|
dense
|
|
icon="clear"
|
|
label="Clear Filters"
|
|
@click="clearTransactionFilter"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Transactions Table -->
|
|
<q-table
|
|
v-if="transactions.length > 0"
|
|
:rows="transactions"
|
|
:columns="transactionColumns"
|
|
row-key="id"
|
|
flat
|
|
:pagination="{ rowsPerPage: 0 }"
|
|
hide-pagination
|
|
>
|
|
<!-- Status Flag Column -->
|
|
<template v-slot:body-cell-flag="props">
|
|
<q-td :props="props">
|
|
<q-icon v-if="props.row.flag === '*'" name="check_circle" color="positive" size="sm">
|
|
<q-tooltip>Cleared</q-tooltip>
|
|
</q-icon>
|
|
<q-icon v-else-if="props.row.flag === '!'" name="pending" color="orange" size="sm">
|
|
<q-tooltip>Pending</q-tooltip>
|
|
</q-icon>
|
|
<q-icon v-else-if="props.row.flag === '#'" name="flag" color="red" size="sm">
|
|
<q-tooltip>Flagged</q-tooltip>
|
|
</q-icon>
|
|
<q-icon v-else-if="props.row.flag === 'x'" name="cancel" color="grey" size="sm">
|
|
<q-tooltip>Voided</q-tooltip>
|
|
</q-icon>
|
|
</q-td>
|
|
</template>
|
|
|
|
<!-- Date Column -->
|
|
<template v-slot:body-cell-date="props">
|
|
<q-td :props="props">
|
|
{% raw %}{{ formatDate(props.row.entry_date) }}{% endraw %}
|
|
</q-td>
|
|
</template>
|
|
|
|
<!-- Description Column -->
|
|
<template v-slot:body-cell-description="props">
|
|
<q-td :props="props">
|
|
<div>
|
|
{% raw %}{{ props.row.description }}{% endraw %}
|
|
<q-badge v-if="isSuperUser && isEquity(props.row)" color="blue" class="q-ml-sm">
|
|
Equity
|
|
</q-badge>
|
|
<q-badge v-else-if="isSuperUser && isReceivable(props.row)" color="positive" class="q-ml-sm">
|
|
Receivable
|
|
</q-badge>
|
|
<q-badge v-else-if="isSuperUser && isPayable(props.row)" color="negative" class="q-ml-sm">
|
|
Payable
|
|
</q-badge>
|
|
</div>
|
|
<div v-if="props.row.meta && Object.keys(props.row.meta).length > 0" class="text-caption text-grey">
|
|
<q-icon name="info" size="xs"></q-icon>
|
|
<span v-if="props.row.meta.source">{% raw %}{{ props.row.meta.source }}{% endraw %}</span>
|
|
</div>
|
|
</q-td>
|
|
</template>
|
|
|
|
<!-- Username Column -->
|
|
<template v-slot:body-cell-username="props">
|
|
<q-td :props="props">
|
|
{% raw %}{{ props.row.username || '-' }}{% endraw %}
|
|
</q-td>
|
|
</template>
|
|
|
|
<!-- Amount Column -->
|
|
<template v-slot:body-cell-amount="props">
|
|
<q-td :props="props">
|
|
{% raw %}{{ formatSats(getTotalAmount(props.row)) }}{% endraw %}
|
|
</q-td>
|
|
</template>
|
|
|
|
<!-- Fiat Amount Column -->
|
|
<template v-slot:body-cell-fiat="props">
|
|
<q-td :props="props">
|
|
{% raw %}{{ getEntryFiatAmount(props.row) || '-' }}{% endraw %}
|
|
</q-td>
|
|
</template>
|
|
|
|
<!-- Reference Column -->
|
|
<template v-slot:body-cell-reference="props">
|
|
<q-td :props="props">
|
|
<span class="text-grey">{% raw %}{{ props.row.reference || '-' }}{% endraw %}</span>
|
|
</q-td>
|
|
</template>
|
|
</q-table>
|
|
|
|
<div v-else class="text-center q-pa-md text-grey">
|
|
No transactions yet
|
|
</div>
|
|
</q-card-section>
|
|
|
|
<!-- Pagination Controls -->
|
|
<q-card-section v-if="transactionPagination.total > transactionPagination.limit" class="q-pt-none">
|
|
<div class="row items-center justify-between">
|
|
<div class="col-auto">
|
|
<q-btn
|
|
flat
|
|
dense
|
|
icon="chevron_left"
|
|
label="Previous"
|
|
:disable="!transactionPagination.has_prev"
|
|
@click="prevTransactionsPage"
|
|
/>
|
|
</div>
|
|
<div class="col text-center text-grey">
|
|
{% raw %}{{ transactionPagination.offset + 1 }} - {{ Math.min(transactionPagination.offset + transactionPagination.limit, transactionPagination.total) }} of {{ transactionPagination.total }}{% endraw %}
|
|
</div>
|
|
<div class="col-auto">
|
|
<q-btn
|
|
flat
|
|
dense
|
|
icon-right="chevron_right"
|
|
label="Next"
|
|
:disable="!transactionPagination.has_next"
|
|
@click="nextTransactionsPage"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</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>Write a balance assertion to Beancount ledger for automatic validation</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"></q-icon>
|
|
</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)"
|
|
>
|
|
<q-tooltip>Re-check assertion</q-tooltip>
|
|
</q-btn>
|
|
<q-btn
|
|
size="sm"
|
|
flat
|
|
round
|
|
icon="delete"
|
|
@click="deleteAssertion(assertion.id)"
|
|
>
|
|
<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)"
|
|
>
|
|
<q-tooltip>Re-check assertion</q-tooltip>
|
|
</q-btn>
|
|
<q-btn
|
|
size="sm"
|
|
flat
|
|
round
|
|
icon="delete"
|
|
@click="deleteAssertion(assertion.id)"
|
|
>
|
|
<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 add checkpoints to your Beancount ledger and verify accounting accuracy.
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
|
|
<!-- Reconciliation Dashboard (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">Reconciliation Dashboard</h6>
|
|
<q-btn
|
|
size="sm"
|
|
color="primary"
|
|
@click="runFullReconciliation"
|
|
:loading="reconciliation.checking"
|
|
icon="sync"
|
|
label="Check All"
|
|
>
|
|
<q-tooltip>Re-check all balance assertions</q-tooltip>
|
|
</q-btn>
|
|
</div>
|
|
|
|
<!-- Summary Stats -->
|
|
<div v-if="reconciliation.summary" class="row q-gutter-md q-mb-md">
|
|
<!-- Assertions Stats -->
|
|
<q-card flat bordered class="col">
|
|
<q-card-section>
|
|
<div class="text-caption text-grey">Balance Assertions</div>
|
|
<div class="text-h6">{% raw %}{{ reconciliation.summary.assertions.total }}{% endraw %}</div>
|
|
<div class="text-caption">
|
|
<span class="text-positive">{% raw %}{{ reconciliation.summary.assertions.passed }}{% endraw %} passed</span> |
|
|
<span class="text-negative">{% raw %}{{ reconciliation.summary.assertions.failed }}{% endraw %} failed</span> |
|
|
<span class="text-grey">{% raw %}{{ reconciliation.summary.assertions.pending }}{% endraw %} pending</span>
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
|
|
<!-- Journal Entries Stats -->
|
|
<q-card flat bordered class="col">
|
|
<q-card-section>
|
|
<div class="text-caption text-grey">Journal Entries</div>
|
|
<div class="text-h6">{% raw %}{{ reconciliation.summary.entries.total }}{% endraw %}</div>
|
|
<div class="text-caption">
|
|
<span class="text-positive">{% raw %}{{ reconciliation.summary.entries.cleared }}{% endraw %} cleared</span> |
|
|
<span class="text-orange">{% raw %}{{ reconciliation.summary.entries.pending }}{% endraw %} pending</span> |
|
|
<span class="text-warning">{% raw %}{{ reconciliation.summary.entries.flagged }}{% endraw %} flagged</span>
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
|
|
<!-- Accounts Stats -->
|
|
<q-card flat bordered class="col">
|
|
<q-card-section>
|
|
<div class="text-caption text-grey">Total Accounts</div>
|
|
<div class="text-h6">{% raw %}{{ reconciliation.summary.accounts.total }}{% endraw %}</div>
|
|
<div class="text-caption text-grey">
|
|
Last checked: {% raw %}{{ reconciliation.summary.last_checked ? formatDate(reconciliation.summary.last_checked) : 'Never' }}{% endraw %}
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
</div>
|
|
|
|
<!-- Discrepancies Alert -->
|
|
<q-banner v-if="reconciliation.discrepancies && reconciliation.discrepancies.total_discrepancies > 0" class="bg-warning text-dark q-mb-md" rounded>
|
|
<template v-slot:avatar>
|
|
<q-icon name="warning" color="orange"></q-icon>
|
|
</template>
|
|
<div class="text-weight-bold">
|
|
{% raw %}{{ reconciliation.discrepancies.total_discrepancies }}{% endraw %} Discrepancy(ies) Found
|
|
</div>
|
|
<div class="text-caption">
|
|
{% raw %}{{ reconciliation.discrepancies.failed_assertions.length }}{% endraw %} failed assertions,
|
|
{% raw %}{{ reconciliation.discrepancies.flagged_entries.length }}{% endraw %} flagged entries
|
|
</div>
|
|
<template v-slot:action>
|
|
<q-btn flat label="View Details" @click="reconciliation.showDiscrepancies = !reconciliation.showDiscrepancies"></q-btn>
|
|
</template>
|
|
</q-banner>
|
|
|
|
<!-- Discrepancies Details -->
|
|
<div v-if="reconciliation.showDiscrepancies && reconciliation.discrepancies">
|
|
<!-- Failed Assertions -->
|
|
<div v-if="reconciliation.discrepancies.failed_assertions.length > 0" class="q-mb-md">
|
|
<div class="text-subtitle2 q-mb-sm">Failed Assertions</div>
|
|
<q-list bordered separator dense>
|
|
<q-item v-for="assertion in reconciliation.discrepancies.failed_assertions" :key="assertion.id">
|
|
<q-item-section avatar>
|
|
<q-icon name="error" color="negative" size="sm"></q-icon>
|
|
</q-item-section>
|
|
<q-item-section>
|
|
<q-item-label>{% raw %}{{ getAccountName(assertion.account_id) }}{% endraw %}</q-item-label>
|
|
<q-item-label caption>
|
|
Expected: {% raw %}{{ formatSats(assertion.expected_balance_sats) }}{% endraw %} |
|
|
Actual: {% raw %}{{ formatSats(assertion.checked_balance_sats) }}{% endraw %} |
|
|
Diff: {% raw %}{{ formatSats(assertion.difference_sats) }}{% endraw %}
|
|
</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</div>
|
|
|
|
<!-- Flagged Entries -->
|
|
<div v-if="reconciliation.discrepancies.flagged_entries.length > 0">
|
|
<div class="text-subtitle2 q-mb-sm">Flagged Entries</div>
|
|
<q-list bordered separator dense>
|
|
<q-item v-for="entry in reconciliation.discrepancies.flagged_entries" :key="entry.id">
|
|
<q-item-section avatar>
|
|
<q-icon name="flag" color="warning" size="sm"></q-icon>
|
|
</q-item-section>
|
|
<q-item-section>
|
|
<q-item-label>{% raw %}{{ entry.description }}{% endraw %}</q-item-label>
|
|
<q-item-label caption>{% raw %}{{ formatDate(entry.entry_date) }}{% endraw %}</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- No issues message -->
|
|
<div v-if="reconciliation.summary && (!reconciliation.discrepancies || reconciliation.discrepancies.total_discrepancies === 0)" class="text-center text-positive q-pa-md">
|
|
<q-icon name="check_circle" size="lg" color="positive"></q-icon>
|
|
<div class="q-mt-sm">All accounts reconciled successfully!</div>
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
</div>
|
|
|
|
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
|
|
<!-- Chart of Accounts -->
|
|
<q-card>
|
|
<q-card-section>
|
|
<h6 class="q-my-none q-mb-md">Chart of Accounts</h6>
|
|
<q-list dense v-if="accounts.length > 0">
|
|
<q-item v-for="account in accounts" :key="account.id">
|
|
<q-item-section>
|
|
<q-item-label>{% raw %}{{ account.name }}{% endraw %}</q-item-label>
|
|
<q-item-label caption>{% raw %}{{ account.account_type }}{% endraw %}</q-item-label>
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
<div v-else>
|
|
<q-spinner color="primary" size="sm"></q-spinner>
|
|
Loading accounts...
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add Expense Dialog -->
|
|
<q-dialog v-model="expenseDialog.show" position="top">
|
|
<q-card v-if="expenseDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
|
<q-form @submit="submitExpense" class="q-gutter-md">
|
|
<div class="text-h6 q-mb-md">Add Expense</div>
|
|
|
|
<q-input
|
|
filled
|
|
dense
|
|
v-model.trim="expenseDialog.description"
|
|
label="Description *"
|
|
placeholder="e.g., Groceries for the house"
|
|
></q-input>
|
|
|
|
<q-input
|
|
filled
|
|
dense
|
|
v-model="expenseDialog.date"
|
|
type="date"
|
|
label="Date *"
|
|
></q-input>
|
|
|
|
<q-select
|
|
filled
|
|
dense
|
|
v-model="expenseDialog.currency"
|
|
:options="currencyOptions"
|
|
option-label="label"
|
|
option-value="value"
|
|
emit-value
|
|
map-options
|
|
label="Currency"
|
|
></q-select>
|
|
|
|
<q-input
|
|
filled
|
|
dense
|
|
v-model.number="expenseDialog.amount"
|
|
type="number"
|
|
:label="amountLabel"
|
|
min="0.01"
|
|
step="0.01"
|
|
></q-input>
|
|
|
|
<q-select
|
|
filled
|
|
dense
|
|
v-model="expenseDialog.expenseAccount"
|
|
:options="expenseAccounts"
|
|
option-label="name"
|
|
option-value="id"
|
|
emit-value
|
|
map-options
|
|
label="Expense Category *"
|
|
></q-select>
|
|
|
|
<q-select
|
|
v-if="userInfo && userInfo.is_equity_eligible"
|
|
filled
|
|
dense
|
|
v-model="expenseDialog.isEquity"
|
|
:options="[
|
|
{label: 'Liability (Castle owes me)', value: false},
|
|
{label: 'Equity (My contribution)', value: true}
|
|
]"
|
|
option-label="label"
|
|
option-value="value"
|
|
emit-value
|
|
map-options
|
|
label="Type *"
|
|
hint="Choose whether this is a liability (Castle owes you) or an equity contribution"
|
|
></q-select>
|
|
|
|
<!-- If user is not equity eligible, force liability -->
|
|
<div v-else>
|
|
<q-input
|
|
filled
|
|
dense
|
|
readonly
|
|
:model-value="'Liability (Castle owes me)'"
|
|
label="Type"
|
|
hint="This expense will be recorded as a liability (Castle owes you)"
|
|
>
|
|
<template v-slot:prepend>
|
|
<q-icon name="info" color="blue-grey-7"></q-icon>
|
|
</template>
|
|
</q-input>
|
|
</div>
|
|
|
|
<q-input
|
|
filled
|
|
dense
|
|
v-model.trim="expenseDialog.reference"
|
|
label="Reference (optional)"
|
|
placeholder="e.g., Receipt #123"
|
|
></q-input>
|
|
|
|
<div class="row q-mt-lg">
|
|
<q-btn unelevated color="primary" type="submit" :loading="expenseDialog.loading">
|
|
Submit Expense
|
|
</q-btn>
|
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
|
</div>
|
|
</q-form>
|
|
</q-card>
|
|
</q-dialog>
|
|
|
|
<!-- Pay Balance Dialog -->
|
|
<q-dialog v-model="payDialog.show" position="top">
|
|
<q-card v-if="payDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card" style="min-width: 400px">
|
|
<div class="text-h6 q-mb-md">Pay Balance</div>
|
|
|
|
<div v-if="balance" class="q-mb-md">
|
|
<div>
|
|
Amount owed: <strong>{% raw %}{{ formatSats(Math.abs(balance.balance)) }}{% endraw %} sats</strong>
|
|
</div>
|
|
<div v-if="balance.fiat_balances && Object.keys(balance.fiat_balances).length > 0" class="text-body2 q-mt-xs">
|
|
<span v-for="(amount, currency) in balance.fiat_balances" :key="currency" class="q-mr-md">
|
|
<strong>{% raw %}{{ formatFiat(Math.abs(amount), currency) }}{% endraw %}</strong>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="!payDialog.paymentRequest">
|
|
<q-form @submit="submitPayment" class="q-gutter-md">
|
|
<q-input
|
|
filled
|
|
dense
|
|
v-model.number="payDialog.amount"
|
|
type="number"
|
|
label="Amount to pay (sats) *"
|
|
min="1"
|
|
:max="balance ? Math.abs(balance.balance) : 0"
|
|
></q-input>
|
|
|
|
<div class="row q-mt-lg q-gutter-sm">
|
|
<q-btn unelevated color="primary" type="submit" :loading="payDialog.loading">
|
|
Generate Lightning Invoice
|
|
</q-btn>
|
|
<q-btn unelevated color="orange" @click="showManualPaymentOption" :loading="payDialog.loading">
|
|
Pay Manually (Cash/Bank)
|
|
</q-btn>
|
|
</div>
|
|
<div class="row q-mt-sm">
|
|
<q-btn v-close-popup flat color="grey">Cancel</q-btn>
|
|
</div>
|
|
</q-form>
|
|
</div>
|
|
|
|
<div v-else>
|
|
<div class="q-mb-md text-center">
|
|
<lnbits-qrcode :value="payDialog.paymentRequest" :options="{width: 280}"></lnbits-qrcode>
|
|
</div>
|
|
|
|
<div class="q-mb-md">
|
|
<q-input
|
|
filled
|
|
dense
|
|
readonly
|
|
v-model="payDialog.paymentRequest"
|
|
label="Lightning Invoice"
|
|
>
|
|
<template v-slot:append>
|
|
<q-btn
|
|
flat
|
|
dense
|
|
icon="content_copy"
|
|
@click="copyToClipboard(payDialog.paymentRequest)"
|
|
>
|
|
<q-tooltip>Copy invoice</q-tooltip>
|
|
</q-btn>
|
|
</template>
|
|
</q-input>
|
|
</div>
|
|
|
|
<div class="text-caption text-grey q-mb-md">
|
|
Scan the QR code or copy the invoice to pay with your Lightning wallet.
|
|
Your balance will update automatically after payment.
|
|
</div>
|
|
|
|
<div class="row">
|
|
<q-btn v-close-popup flat color="grey">Close</q-btn>
|
|
</div>
|
|
</div>
|
|
</q-card>
|
|
</q-dialog>
|
|
|
|
<!-- Manual Payment Request Dialog -->
|
|
<q-dialog v-model="manualPaymentDialog.show" position="top">
|
|
<q-card v-if="manualPaymentDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card" style="min-width: 400px">
|
|
<q-form @submit="submitManualPaymentRequest" class="q-gutter-md">
|
|
<div class="text-h6 q-mb-md">Request Manual Payment</div>
|
|
|
|
<div class="text-caption text-grey q-mb-md">
|
|
Request the Castle to pay you manually (cash, bank transfer, etc.) to settle your balance.
|
|
</div>
|
|
|
|
<div v-if="balance" class="q-mb-md">
|
|
<div>
|
|
Current balance: <strong>{% raw %}{{ formatSats(Math.abs(balance.balance)) }}{% endraw %} sats</strong>
|
|
</div>
|
|
<div v-if="balance.fiat_balances && Object.keys(balance.fiat_balances).length > 0" class="text-body2 q-mt-xs">
|
|
<span v-for="(amount, currency) in balance.fiat_balances" :key="currency" class="q-mr-md">
|
|
<strong>{% raw %}{{ formatFiat(Math.abs(amount), currency) }}{% endraw %}</strong>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<q-input
|
|
filled
|
|
dense
|
|
v-model.number="manualPaymentDialog.amount"
|
|
type="number"
|
|
label="Amount to request (sats) *"
|
|
min="1"
|
|
:max="balance ? Math.abs(balance.balance) : 0"
|
|
:rules="[val => !!val || 'Amount is required', val => val > 0 || 'Amount must be positive']"
|
|
></q-input>
|
|
|
|
<q-input
|
|
filled
|
|
dense
|
|
v-model="manualPaymentDialog.description"
|
|
type="text"
|
|
label="Description *"
|
|
:rules="[val => !!val || 'Description is required']"
|
|
></q-input>
|
|
|
|
<div class="row q-mt-lg">
|
|
<q-btn unelevated color="primary" type="submit" :loading="manualPaymentDialog.loading">
|
|
Submit Request
|
|
</q-btn>
|
|
<q-btn v-close-popup flat color="grey" class="q-ml-sm">Cancel</q-btn>
|
|
</div>
|
|
</q-form>
|
|
</q-card>
|
|
</q-dialog>
|
|
|
|
<!-- Settings Dialog -->
|
|
<q-dialog v-model="settingsDialog.show" position="top">
|
|
<q-card v-if="settingsDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
|
<q-form @submit="submitSettings" class="q-gutter-md">
|
|
<div class="text-h6 q-mb-md">Castle Settings</div>
|
|
|
|
<q-banner v-if="!isSuperUser" class="bg-warning text-dark q-mb-md" dense rounded>
|
|
<template v-slot:avatar>
|
|
<q-icon name="lock" color="orange"></q-icon>
|
|
</template>
|
|
<div class="text-caption">
|
|
<strong>Super User Only:</strong> Only the super user can modify these settings.
|
|
</div>
|
|
</q-banner>
|
|
|
|
<q-select
|
|
filled
|
|
dense
|
|
emit-value
|
|
v-model="settingsDialog.castleWalletId"
|
|
:options="g.user.walletOptions"
|
|
label="Castle Wallet *"
|
|
:readonly="!isSuperUser"
|
|
:disable="!isSuperUser"
|
|
></q-select>
|
|
|
|
<div class="text-caption text-grey">
|
|
Select the wallet that will be used for Castle operations and transactions.
|
|
</div>
|
|
|
|
<div class="row q-mt-lg">
|
|
<q-btn
|
|
v-if="isSuperUser"
|
|
unelevated
|
|
color="primary"
|
|
type="submit"
|
|
:loading="settingsDialog.loading"
|
|
:disable="!settingsDialog.castleWalletId"
|
|
>
|
|
Save Settings
|
|
</q-btn>
|
|
<q-btn v-close-popup flat color="grey" :class="isSuperUser ? 'q-ml-auto' : ''">
|
|
{% raw %}{{ isSuperUser ? 'Cancel' : 'Close' }}{% endraw %}
|
|
</q-btn>
|
|
</div>
|
|
</q-form>
|
|
</q-card>
|
|
</q-dialog>
|
|
|
|
<!-- User Wallet Dialog -->
|
|
<q-dialog v-model="userWalletDialog.show" position="top">
|
|
<q-card v-if="userWalletDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
|
<q-form @submit="submitUserWallet" class="q-gutter-md">
|
|
<div class="text-h6 q-mb-md">Configure Your Wallet</div>
|
|
|
|
<q-select
|
|
filled
|
|
dense
|
|
emit-value
|
|
v-model="userWalletDialog.userWalletId"
|
|
:options="g.user.walletOptions"
|
|
label="Your Wallet *"
|
|
></q-select>
|
|
|
|
<div class="text-caption text-grey">
|
|
Select the wallet you'll use for Castle transactions.
|
|
</div>
|
|
|
|
<div class="row q-mt-lg">
|
|
<q-btn
|
|
unelevated
|
|
color="primary"
|
|
type="submit"
|
|
:loading="userWalletDialog.loading"
|
|
:disable="!userWalletDialog.userWalletId"
|
|
>
|
|
Save Wallet
|
|
</q-btn>
|
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
|
</div>
|
|
</q-form>
|
|
</q-card>
|
|
</q-dialog>
|
|
|
|
<!-- Receivable Dialog -->
|
|
<q-dialog v-model="receivableDialog.show" position="top">
|
|
<q-card v-if="receivableDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
|
<q-form @submit="submitReceivable" class="q-gutter-md">
|
|
<div class="text-h6 q-mb-md">Add Receivable</div>
|
|
|
|
<q-select
|
|
filled
|
|
dense
|
|
v-model="receivableDialog.selectedUser"
|
|
:options="userOptions"
|
|
option-label="label"
|
|
option-value="value"
|
|
emit-value
|
|
map-options
|
|
label="User *"
|
|
></q-select>
|
|
|
|
<q-input
|
|
filled
|
|
dense
|
|
v-model.trim="receivableDialog.description"
|
|
label="Description *"
|
|
placeholder="e.g., Room rental for 5 days"
|
|
></q-input>
|
|
|
|
<q-select
|
|
filled
|
|
dense
|
|
v-model="receivableDialog.currency"
|
|
:options="currencyOptions"
|
|
option-label="label"
|
|
option-value="value"
|
|
emit-value
|
|
map-options
|
|
label="Currency"
|
|
></q-select>
|
|
|
|
<q-input
|
|
filled
|
|
dense
|
|
v-model.number="receivableDialog.amount"
|
|
type="number"
|
|
:label="receivableAmountLabel"
|
|
min="0.01"
|
|
step="0.01"
|
|
></q-input>
|
|
|
|
<q-select
|
|
filled
|
|
dense
|
|
v-model="receivableDialog.revenueAccount"
|
|
:options="revenueAccounts"
|
|
option-label="name"
|
|
option-value="id"
|
|
emit-value
|
|
map-options
|
|
label="Revenue Category *"
|
|
></q-select>
|
|
|
|
<q-input
|
|
filled
|
|
dense
|
|
v-model.trim="receivableDialog.reference"
|
|
label="Reference (optional)"
|
|
placeholder="e.g., Invoice #456"
|
|
></q-input>
|
|
|
|
<div class="row q-mt-lg">
|
|
<q-btn unelevated color="primary" type="submit" :loading="receivableDialog.loading">
|
|
Submit Receivable
|
|
</q-btn>
|
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
|
</div>
|
|
</q-form>
|
|
</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 are written to your Beancount ledger and validated automatically by Beancount.
|
|
This verifies that an account's actual balance matches your expected balance at a specific date.
|
|
If the assertion fails, Beancount will alert you to investigate the discrepancy. Castle stores
|
|
metadata (tolerance, notes) for your convenience.
|
|
</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) *"
|
|
hint="The balance you expect this account to have in satoshis"
|
|
:rules="[val => val !== null && val !== undefined && val !== '' || 'Expected balance is required']"
|
|
></q-input>
|
|
|
|
<q-input
|
|
filled
|
|
dense
|
|
v-model.number="assertionDialog.tolerance_sats"
|
|
type="number"
|
|
label="Tolerance (sats)"
|
|
min="0"
|
|
hint="Allow the actual balance to differ by ± this amount (default: 0)"
|
|
></q-input>
|
|
|
|
<q-separator class="q-my-md"></q-separator>
|
|
|
|
<div class="text-subtitle2 q-mb-sm">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-md q-gutter-sm">
|
|
<q-btn unelevated color="primary" type="submit" :loading="assertionDialog.loading">
|
|
Create & Check
|
|
</q-btn>
|
|
<q-btn v-close-popup flat color="grey">Cancel</q-btn>
|
|
</div>
|
|
</q-form>
|
|
</q-card>
|
|
</q-dialog>
|
|
|
|
<!-- Settle Receivable Dialog (Super User Only) -->
|
|
<q-dialog v-model="settleReceivableDialog.show" position="top">
|
|
<q-card class="q-pa-md" style="min-width: 400px">
|
|
<q-form @submit="submitSettleReceivable">
|
|
<h6 class="q-my-none q-mb-md">Settle Receivable</h6>
|
|
|
|
<div class="q-mb-md">
|
|
<div class="text-subtitle2">User</div>
|
|
<div>{% raw %}{{ settleReceivableDialog.username }}{% endraw %}</div>
|
|
<div class="text-caption text-grey">{% raw %}{{ settleReceivableDialog.user_id }}{% endraw %}</div>
|
|
</div>
|
|
|
|
<div class="q-mb-md">
|
|
<div class="text-subtitle2">Amount Owed</div>
|
|
<div class="text-negative text-h6">
|
|
{% raw %}{{ formatSats(settleReceivableDialog.maxAmount) }}{% endraw %} sats
|
|
</div>
|
|
<div v-if="settleReceivableDialog.fiatCurrency && settleReceivableDialog.maxAmountFiat" class="text-caption">
|
|
{% raw %}{{ formatFiat(settleReceivableDialog.maxAmountFiat, settleReceivableDialog.fiatCurrency) }}{% endraw %}
|
|
</div>
|
|
</div>
|
|
|
|
<q-input
|
|
filled
|
|
dense
|
|
v-model.number="settleReceivableDialog.amount"
|
|
type="number"
|
|
:label="settlementAmountLabel"
|
|
hint="Amount user is paying (max: owed amount)"
|
|
:max="settlementMaxAmount"
|
|
:step="settlementAmountStep"
|
|
:rules="[
|
|
val => val !== null && val !== undefined && val !== '' || 'Amount is required',
|
|
val => val > 0 || 'Amount must be positive',
|
|
val => val <= settlementMaxAmount || 'Cannot exceed owed amount'
|
|
]"
|
|
></q-input>
|
|
|
|
<q-select
|
|
filled
|
|
dense
|
|
v-model="settleReceivableDialog.payment_method"
|
|
:options="[
|
|
{label: 'Lightning Invoice', value: 'lightning'},
|
|
{label: 'Cash', value: 'cash'},
|
|
{label: 'Bank Transfer', value: 'bank_transfer'},
|
|
{label: 'Check', value: 'check'},
|
|
{label: 'Other', value: 'other'}
|
|
]"
|
|
option-label="label"
|
|
option-value="value"
|
|
emit-value
|
|
map-options
|
|
label="Payment Method *"
|
|
:rules="[val => !!val || 'Payment method is required']"
|
|
></q-select>
|
|
|
|
<!-- Show invoice if lightning method selected and generated -->
|
|
<div v-if="settleReceivableDialog.payment_method === 'lightning' && settleReceivableDialog.invoice">
|
|
<q-separator class="q-my-md"></q-separator>
|
|
|
|
<div class="text-center q-mb-md">
|
|
<div class="text-subtitle2 q-mb-sm">Lightning Invoice Generated</div>
|
|
<lnbits-qrcode
|
|
:value="settleReceivableDialog.invoice"
|
|
:options="{width: 280}"
|
|
></lnbits-qrcode>
|
|
|
|
<div class="text-caption text-grey q-mt-sm">
|
|
Waiting for payment...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<q-input
|
|
v-if="settleReceivableDialog.payment_method !== 'lightning'"
|
|
filled
|
|
dense
|
|
v-model="settleReceivableDialog.description"
|
|
type="text"
|
|
label="Description (optional)"
|
|
:placeholder="settleReceivableDialog.payment_method === 'cash' ?
|
|
`Cash payment from ${settleReceivableDialog.username}` :
|
|
settleReceivableDialog.payment_method === 'bank_transfer' ?
|
|
`Bank transfer from ${settleReceivableDialog.username}` :
|
|
settleReceivableDialog.payment_method === 'check' ?
|
|
`Check payment from ${settleReceivableDialog.username}` :
|
|
`Payment from ${settleReceivableDialog.username}`"
|
|
hint="Auto-generated if left empty"
|
|
></q-input>
|
|
|
|
<q-input
|
|
v-if="settleReceivableDialog.payment_method !== 'lightning'"
|
|
filled
|
|
dense
|
|
v-model="settleReceivableDialog.reference"
|
|
type="text"
|
|
label="Reference (optional)"
|
|
hint="Receipt number, transaction ID, etc."
|
|
></q-input>
|
|
|
|
<div class="row q-mt-md q-gutter-sm">
|
|
<!-- For lightning: generate invoice button, then it auto-settles on payment -->
|
|
<q-btn
|
|
v-if="settleReceivableDialog.payment_method === 'lightning' && !settleReceivableDialog.invoice"
|
|
unelevated
|
|
color="primary"
|
|
@click="generateSettlementInvoice"
|
|
:loading="settleReceivableDialog.loading"
|
|
>
|
|
Generate Invoice
|
|
</q-btn>
|
|
|
|
<!-- For non-lightning: manual settle button -->
|
|
<q-btn
|
|
v-if="settleReceivableDialog.payment_method !== 'lightning'"
|
|
unelevated
|
|
color="primary"
|
|
type="submit"
|
|
:loading="settleReceivableDialog.loading"
|
|
>
|
|
Settle Receivable
|
|
</q-btn>
|
|
|
|
<q-btn v-close-popup flat color="grey">Cancel</q-btn>
|
|
</div>
|
|
</q-form>
|
|
</q-card>
|
|
</q-dialog>
|
|
|
|
<!-- Pay User Dialog (Castle pays user - Super User Only) -->
|
|
<q-dialog v-model="payUserDialog.show" position="top">
|
|
<q-card class="q-pa-md" style="min-width: 400px">
|
|
<q-form @submit="submitPayUser">
|
|
<h6 class="q-my-none q-mb-md">Pay User</h6>
|
|
|
|
<div class="q-mb-md">
|
|
<div class="text-subtitle2">User</div>
|
|
<div>{% raw %}{{ payUserDialog.username }}{% endraw %}</div>
|
|
<div class="text-caption text-grey">{% raw %}{{ payUserDialog.user_id }}{% endraw %}</div>
|
|
</div>
|
|
|
|
<div class="q-mb-md">
|
|
<div class="text-subtitle2">Amount Castle Owes</div>
|
|
<div class="text-positive text-h6">
|
|
{% raw %}{{ formatSats(payUserDialog.maxAmount) }}{% endraw %} sats
|
|
</div>
|
|
<div v-if="payUserDialog.fiatCurrency && payUserDialog.maxAmountFiat" class="text-caption">
|
|
{% raw %}{{ formatFiat(payUserDialog.maxAmountFiat, payUserDialog.fiatCurrency) }}{% endraw %}
|
|
</div>
|
|
</div>
|
|
|
|
<q-input
|
|
filled
|
|
dense
|
|
v-model.number="payUserDialog.amount"
|
|
type="number"
|
|
:label="paymentAmountLabel"
|
|
hint="Amount castle is paying (max: owed amount)"
|
|
:max="paymentMaxAmount"
|
|
:step="paymentAmountStep"
|
|
:rules="[
|
|
val => val !== null && val !== undefined && val !== '' || 'Amount is required',
|
|
val => val > 0 || 'Amount must be positive',
|
|
val => val <= paymentMaxAmount || 'Cannot exceed owed amount'
|
|
]"
|
|
></q-input>
|
|
|
|
<q-select
|
|
filled
|
|
dense
|
|
v-model="payUserDialog.payment_method"
|
|
:options="[
|
|
{label: 'Lightning Payment', value: 'lightning'},
|
|
{label: 'Cash', value: 'cash'},
|
|
{label: 'Bank Transfer', value: 'bank_transfer'},
|
|
{label: 'Check', value: 'check'},
|
|
{label: 'Other', value: 'other'}
|
|
]"
|
|
option-label="label"
|
|
option-value="value"
|
|
emit-value
|
|
map-options
|
|
label="Payment Method *"
|
|
:rules="[val => !!val || 'Payment method is required']"
|
|
></q-select>
|
|
|
|
<q-input
|
|
v-if="payUserDialog.payment_method !== 'lightning'"
|
|
filled
|
|
dense
|
|
v-model="payUserDialog.description"
|
|
type="text"
|
|
label="Description (optional)"
|
|
:placeholder="payUserDialog.payment_method === 'cash' ?
|
|
`Cash payment to ${payUserDialog.username}` :
|
|
payUserDialog.payment_method === 'bank_transfer' ?
|
|
`Bank transfer to ${payUserDialog.username}` :
|
|
payUserDialog.payment_method === 'check' ?
|
|
`Check payment to ${payUserDialog.username}` :
|
|
`Payment to ${payUserDialog.username}`"
|
|
hint="Auto-generated if left empty"
|
|
></q-input>
|
|
|
|
<q-input
|
|
v-if="payUserDialog.payment_method !== 'lightning'"
|
|
filled
|
|
dense
|
|
v-model="payUserDialog.reference"
|
|
type="text"
|
|
label="Reference (optional)"
|
|
hint="Receipt number, transaction ID, etc."
|
|
></q-input>
|
|
|
|
<!-- Show success message if lightning payment was made -->
|
|
<div v-if="payUserDialog.paymentSuccess" class="q-mt-md q-pa-md bg-positive text-white rounded-borders">
|
|
<q-icon name="check_circle" size="md" class="q-mr-sm"></q-icon>
|
|
Payment sent successfully!
|
|
</div>
|
|
|
|
<div class="row q-mt-md q-gutter-sm">
|
|
<!-- For lightning: send payment button -->
|
|
<q-btn
|
|
v-if="payUserDialog.payment_method === 'lightning'"
|
|
unelevated
|
|
color="positive"
|
|
@click="sendLightningPayment"
|
|
:loading="payUserDialog.loading"
|
|
:disable="payUserDialog.paymentSuccess"
|
|
>
|
|
Send Lightning Payment
|
|
</q-btn>
|
|
|
|
<!-- For non-lightning: record manual payment button -->
|
|
<q-btn
|
|
v-if="payUserDialog.payment_method !== 'lightning'"
|
|
unelevated
|
|
color="positive"
|
|
type="submit"
|
|
:loading="payUserDialog.loading"
|
|
>
|
|
Record Payment
|
|
</q-btn>
|
|
|
|
<q-btn v-close-popup flat color="grey">
|
|
{% raw %}{{ payUserDialog.paymentSuccess ? 'Close' : 'Cancel' }}{% endraw %}
|
|
</q-btn>
|
|
</div>
|
|
</q-form>
|
|
</q-card>
|
|
</q-dialog>
|
|
|
|
{% endblock %}
|