spirekeeper/templates/spirekeeper/index.html
Padreug 9abf695fd5
Some checks failed
ci.yml / feat(cash-in): super_config.max_cash_in_sats per-tx cap + UI (#31) (pull_request) Failing after 0s
feat(cash-in): super_config.max_cash_in_sats per-tx cap + UI (#31)
Wires the server-side per-transaction cash-in ceiling the `create_withdraw`
handler already enforces (it read the value defensively via getattr; this
makes it a first-class config field).

- migrations.py m012: ADD COLUMN super_config.max_cash_in_sats INTEGER (NULL
  = no cap).
- models.py: SuperConfig.max_cash_in_sats + UpdateSuperConfigData field with a
  >= 0 validator.
- super-fee dialog: a "Max cash-in per transaction (sats)" input; blank sends
  null (the PUT skips null, preserving the current value — set 0 to reject
  every cash-in). crud `update_super_config` and the PUT endpoint flow the
  field through automatically (dynamic dict update; check_super_user gated).

Why a sats cap and not the bunker ACL: the ACL / usage caps (#28) gate call
*rate*, not *sats*, and `principal_sats` is necessarily ATM-attested — so a
single in-rate call could request an arbitrarily large payout. This bounds a
compromised/buggy machine to one capped transaction.

Verified on the dev stack: m012 runs, the model round-trips the column
(GET returns the set value), and a negative value is rejected.
2026-06-22 12:51:59 +02:00

1659 lines
75 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% from "macros.jinja" import window_vars with context %}
{% block scripts %}
{{ window_vars(user) }}
<script src="{{ static_url_for('spirekeeper/static', path='js/index.js') }}"></script>
{% endblock %}
{% block page %}
<div class="row q-col-gutter-md" id="dcaAdmin">
<div class="col-12">
<!-- Header bar -->
<div class="row items-center q-mb-md">
<div class="col">
<h4 class="q-my-none">Satoshi Machine — Operator</h4>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
Manage your bitSpire fleet, liquidity providers, and commission distribution.
</p>
</div>
<div class="col-auto">
<q-btn
flat dense round icon="refresh" color="grey"
@click="refreshAll" :loading="refreshing">
<q-tooltip>Refresh all data</q-tooltip>
</q-btn>
</div>
</div>
<!-- Platform fee banner (read-only for operators; editable inline if super) -->
<q-banner
v-if="superConfig"
class="q-mb-md"
:class="superAnyFee > 0 ? 'bg-blue-1 text-grey-9' : 'bg-grey-2 text-grey-9'">
<template v-slot:avatar>
<q-icon name="account_balance" :color="superAnyFee > 0 ? 'blue' : 'grey'"></q-icon>
</template>
<span :style="{fontWeight: 500}">
LNbits platform fee:
<span :style="{color: '#1976d2'}">cash-in ${ (superConfig.super_cash_in_fee_fraction * 100).toFixed(2) }%</span>
·
<span :style="{color: '#1976d2'}">cash-out ${ (superConfig.super_cash_out_fee_fraction * 100).toFixed(2) }%</span>
of each transaction's principal.
</span>
<span :style="{opacity: 0.7, fontSize: '0.85em'}" class="q-ml-sm">
Operator's per-machine fee rides on top of these.
</span>
<template v-slot:action>
<q-btn v-if="g?.user?.super_user"
flat dense color="blue" icon="edit"
label="Edit"
@click="openSuperFeeDialog">
<q-tooltip>Super-only: set platform fee + destination wallet</q-tooltip>
</q-btn>
</template>
</q-banner>
<!-- Main tab strip -->
<q-card flat bordered>
<q-tabs
v-model="activeTab"
dense
align="left"
class="text-grey-7"
active-color="primary"
indicator-color="primary"
narrow-indicator>
<q-tab name="fleet" icon="precision_manufacturing" label="Fleet"></q-tab>
<q-tab name="clients" icon="group" label="Clients"></q-tab>
<q-tab name="deposits" icon="receipt_long" label="Deposits"></q-tab>
<q-tab name="commission" icon="call_split" label="Commission"></q-tab>
<q-tab name="worklist" icon="warning" label="Worklist">
<q-badge
v-if="worklistCount > 0"
color="red" floating>${ worklistCount }
</q-badge>
</q-tab>
<q-tab name="reports" icon="download" label="Reports"></q-tab>
</q-tabs>
<q-separator ></q-separator>
<q-tab-panels v-model="activeTab" animated>
<!-- ============================================================= -->
<!-- FLEET TAB -->
<!-- ============================================================= -->
<q-tab-panel name="fleet">
<div class="row items-center q-mb-md">
<div class="col">
<h6 class="q-my-none">Your machines</h6>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
Each ATM is paired with one dedicated wallet. Inbound payments to
that wallet trigger automatic distribution.
</p>
</div>
<div class="col-auto">
<q-btn
color="primary" icon="add"
label="Add machine"
@click="openAddMachineDialog"></q-btn>
</div>
</div>
<q-banner v-if="!machines.length" class="bg-blue-1 text-grey-9">
<template v-slot:avatar>
<q-icon name="info" color="blue"></q-icon>
</template>
You haven't registered any machines yet. Click <b>Add machine</b> to
register a bitSpire ATM by its Nostr npub.
</q-banner>
<q-table
v-else
dense flat
:rows="machines"
row-key="id"
:columns="machinesTable.columns"
:pagination="machinesTable.pagination"
:rows-per-page-options="[10, 25, 50]">
<template v-slot:body="props">
<q-tr :props="props">
<q-td key="status">
<q-icon
:name="props.row.is_active ? 'check_circle' : 'pause_circle'"
:color="props.row.is_active ? 'green' : 'grey'"
size="sm">
<q-tooltip>${ props.row.is_active ? 'Active' : 'Paused' }</q-tooltip>
</q-icon>
</q-td>
<q-td key="name">
<div :style="{fontWeight: 500}" v-text="props.row.name || 'Unnamed'"></div>
<div :style="{fontSize: '0.8em', opacity: 0.6}"
v-text="props.row.location || 'No location set'"></div>
<q-chip v-if="props.row.paired_at"
dense size="sm" color="green-2" text-color="green-9"
icon="link" :style="{marginTop: '2px'}">
paired
<q-tooltip>Bunker key minted; paired ${ new Date(props.row.paired_at).toLocaleString() }</q-tooltip>
</q-chip>
<q-chip v-else
dense size="sm" color="grey-3" text-color="grey-8"
icon="link_off" :style="{marginTop: '2px'}">
not paired
</q-chip>
</q-td>
<q-td key="machine_npub">
<code :style="{fontSize: '0.85em'}"
v-text="shortNpub(props.row.machine_npub)"></code>
<q-btn flat dense round size="xs" icon="content_copy"
@click="copy(props.row.machine_npub)">
<q-tooltip>Copy full npub</q-tooltip>
</q-btn>
</q-td>
<q-td key="wallet_id">
<code :style="{fontSize: '0.85em'}"
v-text="shortId(props.row.wallet_id)"></code>
</q-td>
<q-td key="fiat_code" v-text="props.row.fiat_code"></q-td>
<q-td key="actions" auto-width>
<q-btn flat dense round size="sm" icon="visibility"
color="primary"
@click="viewMachine(props.row)">
<q-tooltip>View settlements</q-tooltip>
</q-btn>
<q-btn flat dense round size="sm" icon="edit"
color="grey"
@click="openEditMachineDialog(props.row)">
<q-tooltip>Edit</q-tooltip>
</q-btn>
<q-btn flat dense round size="sm" icon="qr_code_2"
color="teal"
@click="openPairDialog(props.row)">
<q-tooltip>${ props.row.paired_at ? 'Re-pair (new seed URL)' : 'Pair (seed URL)' }</q-tooltip>
</q-btn>
<q-btn v-if="props.row.paired_at"
flat dense round size="sm" icon="link_off"
color="orange-8"
@click="confirmRevokeMachine(props.row)">
<q-tooltip>Revoke spire access</q-tooltip>
</q-btn>
<q-btn flat dense round size="sm" icon="delete"
color="red-7"
@click="confirmDeleteMachine(props.row)">
<q-tooltip>Delete</q-tooltip>
</q-btn>
</q-td>
</q-tr>
</template>
</q-table>
</q-tab-panel>
<!-- ============================================================= -->
<!-- PLACEHOLDERS for tabs that land in P9bP9g -->
<!-- ============================================================= -->
<q-tab-panel name="clients">
<div class="row items-center q-mb-md">
<div class="col">
<h6 class="q-my-none">Liquidity providers</h6>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
LPs receive proportional DCA distributions from your machines.
Balances reflect deposits less the sats they've been paid.
</p>
</div>
<div class="col-auto">
<q-btn color="primary" icon="person_add"
label="Register LP"
:disable="!machines.length"
@click="openAddClientDialog"></q-btn>
</div>
</div>
<q-banner v-if="!machines.length" class="bg-orange-1 text-grey-9">
<template v-slot:avatar>
<q-icon name="warning" color="orange"></q-icon>
</template>
Register at least one machine before adding LPs — an LP is scoped
to a specific machine.
</q-banner>
<q-banner v-else-if="!clients.length" class="bg-blue-1 text-grey-9">
<template v-slot:avatar>
<q-icon name="info" color="blue"></q-icon>
</template>
No LPs yet. Use <b>Register LP</b> to add one at any of your machines.
</q-banner>
<q-table v-else
dense flat
:rows="clients"
row-key="id"
:columns="clientsTable.columns"
:rows-per-page-options="[10, 25, 50]"
:pagination="clientsTable.pagination">
<template v-slot:body="props">
<q-tr :props="props">
<q-td key="machine">
<span :style="{fontWeight: 500}"
v-text="machineNameById(props.row.machine_id)"></span>
</q-td>
<q-td key="username">
<span v-text="props.row.username || shortId(props.row.user_id)"></span>
</q-td>
<q-td key="onboarded">
<q-icon v-if="props.row.lp_onboarded"
name="check_circle" color="green" size="sm">
<q-tooltip>
LP has onboarded via satmachineclient.
Deposits and DCA distributions can proceed.
</q-tooltip>
</q-icon>
<q-icon v-else name="pending" color="deep-orange" size="sm">
<q-tooltip>
LP hasn't installed/opened satmachineclient yet.
Deposits will be refused until they register and
select a DCA wallet.
</q-tooltip>
</q-icon>
</q-td>
<q-td key="remaining_balance" class="text-right">
<span v-if="clientBalances[props.row.id]"
:style="{color: clientBalances[props.row.id].remaining_balance > 0 ? '#2e7d32' : '#9e9e9e', fontWeight: 500}"
v-text="formatFiat(clientBalances[props.row.id].remaining_balance, clientBalances[props.row.id].currency)"></span>
<span v-else :style="{opacity: 0.5}"></span>
</q-td>
<q-td key="status">
<q-badge
:color="props.row.status === 'active' ? 'green' : 'grey'"
:label="props.row.status"></q-badge>
</q-td>
<q-td key="actions" auto-width>
<q-btn-dropdown flat dense size="sm" icon="more_vert">
<q-list dense>
<q-item clickable v-close-popup
@click="openEditClientDialog(props.row)">
<q-item-section avatar><q-icon name="edit"></q-icon></q-item-section>
<q-item-section>Edit</q-item-section>
</q-item>
<q-item clickable v-close-popup
@click="openSettleBalanceDialog(props.row)">
<q-item-section avatar>
<q-icon name="payments" color="primary"></q-icon>
</q-item-section>
<q-item-section>Settle balance…</q-item-section>
</q-item>
<q-item clickable v-close-popup
@click="confirmDeleteClient(props.row)">
<q-item-section avatar>
<q-icon name="delete" color="red-7"></q-icon>
</q-item-section>
<q-item-section>Delete</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</q-td>
</q-tr>
</template>
</q-table>
</q-tab-panel>
<q-tab-panel name="deposits">
<div class="row items-center q-mb-md">
<div class="col">
<h6 class="q-my-none">Deposits</h6>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
Record fiat handed in by LPs. Confirmed deposits increase the
LP's balance and feed proportional DCA distribution.
</p>
</div>
<div class="col-auto">
<q-btn color="primary" icon="add"
label="Record deposit"
:disable="!clients.length"
@click="openAddDepositDialog"></q-btn>
</div>
</div>
<div class="row q-col-gutter-sm q-mb-md">
<div class="col-12 col-md-3">
<q-select v-model="depositsFilter.status"
:options="[
{label: 'All statuses', value: null},
{label: 'Pending', value: 'pending'},
{label: 'Confirmed', value: 'confirmed'},
{label: 'Rejected', value: 'rejected'}]"
label="Status" emit-value map-options dense outlined></q-select>
</div>
<div class="col-12 col-md-4">
<q-select v-model="depositsFilter.client_id"
:options="depositClientOptions"
label="LP" emit-value map-options dense outlined clearable></q-select>
</div>
</div>
<q-banner v-if="!clients.length" class="bg-orange-1 text-grey-9">
<template v-slot:avatar>
<q-icon name="warning" color="orange"></q-icon>
</template>
Register at least one LP before recording deposits.
</q-banner>
<q-banner v-else-if="!filteredDeposits.length && !deposits.length"
class="bg-blue-1 text-grey-9">
<template v-slot:avatar>
<q-icon name="info" color="blue"></q-icon>
</template>
No deposits yet. Use <b>Record deposit</b> to log a new one.
</q-banner>
<q-banner v-else-if="!filteredDeposits.length"
class="bg-grey-2 text-grey-9">
No deposits match the current filters.
</q-banner>
<q-table v-else
dense flat
:rows="filteredDeposits"
row-key="id"
:columns="depositsTable.columns"
:rows-per-page-options="[10, 25, 50]"
:pagination="depositsTable.pagination">
<template v-slot:body="props">
<q-tr :props="props">
<q-td key="status">
<q-badge :color="depositStatusColor(props.row.status)"
:label="props.row.status"></q-badge>
</q-td>
<q-td key="client">
<span v-text="clientUsernameById(props.row.client_id)"></span>
<div :style="{fontSize: '0.75em', opacity: 0.6}"
v-text="machineNameById(props.row.machine_id)"></div>
</q-td>
<q-td key="amount" class="text-right">
<span :style="{fontWeight: 500}"
v-text="formatFiat(props.row.amount, props.row.currency)"></span>
</q-td>
<q-td key="created_at">
<span :style="{fontSize: '0.85em'}"
v-text="formatTime(props.row.created_at)"></span>
</q-td>
<q-td key="confirmed_at">
<span v-if="props.row.confirmed_at"
:style="{fontSize: '0.85em'}"
v-text="formatTime(props.row.confirmed_at)"></span>
<span v-else :style="{opacity: 0.4}"></span>
</q-td>
<q-td key="notes">
<span v-if="props.row.notes"
:style="{fontSize: '0.85em'}"
v-text="props.row.notes"></span>
<span v-else :style="{opacity: 0.4}"></span>
</q-td>
<q-td key="actions" auto-width>
<q-btn-dropdown flat dense size="sm" icon="more_vert">
<q-list dense>
<q-item v-if="props.row.status === 'pending'"
clickable v-close-popup
@click="confirmDepositStatus(props.row, 'confirmed')">
<q-item-section avatar>
<q-icon name="check_circle" color="green"></q-icon>
</q-item-section>
<q-item-section>Confirm</q-item-section>
</q-item>
<q-item v-if="props.row.status === 'pending'"
clickable v-close-popup
@click="openRejectDepositDialog(props.row)">
<q-item-section avatar>
<q-icon name="cancel" color="red"></q-icon>
</q-item-section>
<q-item-section>Reject…</q-item-section>
</q-item>
<q-item v-if="props.row.status === 'pending'"
clickable v-close-popup
@click="openEditDepositDialog(props.row)">
<q-item-section avatar>
<q-icon name="edit"></q-icon>
</q-item-section>
<q-item-section>Edit</q-item-section>
</q-item>
<q-item v-if="props.row.status === 'pending'"
clickable v-close-popup
@click="confirmDeleteDeposit(props.row)">
<q-item-section avatar>
<q-icon name="delete" color="red-7"></q-icon>
</q-item-section>
<q-item-section>Delete</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</q-td>
</q-tr>
</template>
</q-table>
</q-tab-panel>
<q-tab-panel name="commission">
<div class="row items-center q-mb-md">
<div class="col">
<h6 class="q-my-none">Commission splits</h6>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
After the LNbits platform fee is taken, the remainder is
distributed across the wallets you configure here. Per-machine
overrides take precedence over your default rules.
</p>
</div>
</div>
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12 col-md-6">
<q-select v-model="commissionScope"
:options="commissionScopeOptions"
label="Scope being edited"
emit-value map-options
dense outlined
@update:model-value="loadCommissionSplits"></q-select>
<div class="text-caption q-mt-xs" :style="{opacity: 0.7}">
<span v-if="commissionScope === null">
Default ruleset — applies to every machine without an
explicit override.
</span>
<span v-else>
Per-machine override for
<b v-text="machineNameById(commissionScope)"></b>.
Empty/cleared rows fall back to the default.
</span>
</div>
</div>
</div>
<q-card flat bordered>
<q-card-section>
<div class="row items-center q-mb-sm">
<div class="col">
<span :style="{fontWeight: 500}">Legs</span>
<span class="q-ml-md text-caption">
Sum:
<span :style="{
color: commissionSumValid ? '#2e7d32' : '#c62828',
fontWeight: 500
}"
v-text="(commissionSum * 100).toFixed(2) + '%'"></span>
<q-icon v-if="commissionSumValid"
name="check_circle" color="green" size="xs"
class="q-ml-xs"></q-icon>
<q-icon v-else
name="error" color="red" size="xs"
class="q-ml-xs">
<q-tooltip>Must sum to 100% before saving</q-tooltip>
</q-icon>
</span>
</div>
<div class="col-auto">
<q-btn flat dense color="primary" icon="add"
label="Add leg"
@click="addCommissionLeg"></q-btn>
</div>
</div>
<q-banner v-if="!commissionLegs.length"
class="bg-blue-1 text-grey-9">
<template v-slot:avatar>
<q-icon name="info" color="blue"></q-icon>
</template>
<span v-if="commissionScope === null">
No default rules. Without a default, all operator
commission stays in the machine wallet (audit visible).
</span>
<span v-else>
No override for this machine. The default ruleset applies.
</span>
</q-banner>
<div v-for="(leg, idx) in commissionLegs" :key="idx"
class="q-mb-md q-pa-sm"
:style="{border: '1px solid rgba(255,255,255,0.08)', borderRadius: '4px'}">
<div class="row q-col-gutter-sm items-center q-mb-sm">
<div class="col">
<q-btn-toggle v-model="leg.targetKind"
:options="[
{label: 'My wallet', value: 'wallet'},
{label: 'Lightning address / LNURL / invoice key', value: 'external'}
]"
no-caps dense flat
toggle-color="primary"
color="grey-8"></q-btn-toggle>
</div>
<div class="col-auto">
<q-btn flat dense round icon="delete" color="red-7"
@click="commissionLegs.splice(idx, 1)">
<q-tooltip>Remove leg</q-tooltip>
</q-btn>
</div>
</div>
<div class="row q-col-gutter-sm items-start">
<div class="col-12 col-md-6">
<q-select v-if="leg.targetKind === 'wallet'"
v-model="leg.target"
:options="walletOptions"
label="Wallet (one of yours)"
emit-value map-options dense outlined></q-select>
<q-input v-else
v-model.trim="leg.target"
label="LN address, LNURL, or invoice key"
hint="user@domain · LNURL1... · or an LP's invoice key"
dense outlined></q-input>
</div>
<div class="col-7 col-md-3">
<q-input v-model="leg.label"
label="Label (employee, maintenance, ...)"
dense outlined></q-input>
</div>
<div class="col-5 col-md-3">
<q-input v-model.number="leg.fraction"
label="% (0..1)"
type="number" step="0.01" min="0" max="1"
dense outlined>
<template v-slot:append>
<span :style="{fontSize: '0.75em', opacity: 0.6}"
v-text="((leg.fraction || 0) * 100).toFixed(1) + '%'"></span>
</template>
</q-input>
</div>
</div>
</div>
<q-banner v-if="commissionPreview" class="bg-grey-2 text-grey-9 q-mt-md">
<template v-slot:avatar>
<q-icon name="visibility" color="grey"></q-icon>
</template>
Preview against
<b v-text="formatSats(commissionPreviewInput)"></b>
sats operator commission →
<span v-for="(prev, idx) in commissionPreview" :key="idx"
class="q-mr-md">
<span :style="{opacity: 0.7}"
v-text="prev.label || ('leg ' + (idx + 1))"></span>:
<b v-text="formatSats(prev.sats)"></b>
</span>
</q-banner>
</q-card-section>
<q-card-actions align="right">
<q-btn v-if="commissionScope !== null && commissionLegs.length"
flat color="red"
label="Remove override"
@click="confirmDeleteCommissionOverride"></q-btn>
<q-btn flat label="Reload"
:disable="commissionSaving"
@click="loadCommissionSplits"></q-btn>
<q-btn color="primary"
label="Save"
:disable="!commissionSumValid"
:loading="commissionSaving"
@click="saveCommissionSplits"></q-btn>
</q-card-actions>
</q-card>
</q-tab-panel>
<q-tab-panel name="worklist">
<div class="row items-center q-mb-md">
<div class="col">
<h6 class="q-my-none">Worklist</h6>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
Settlements that didn't process cleanly. Errored ones need
retry; stuck ones may need force-reset (processor crashed
mid-flight).
</p>
</div>
<div class="col-auto row items-center q-gutter-sm">
<q-input v-model.number="worklistThreshold"
label="Threshold (min)" dense outlined
:style="{width: '120px'}"
type="number" min="1"></q-input>
<q-btn flat dense color="primary" icon="refresh"
:loading="worklistLoading"
@click="loadWorklist"></q-btn>
</div>
</div>
<q-banner v-if="worklist.totalCount === 0"
class="bg-green-1 text-grey-9">
<template v-slot:avatar>
<q-icon name="check_circle" color="green"></q-icon>
</template>
All clear — no errored or stuck settlements.
</q-banner>
<div v-for="bucket in worklistBuckets" :key="bucket.key"
v-show="bucket.rows.length"
class="q-mb-lg">
<div class="row items-center q-mb-sm">
<q-icon :name="bucket.icon" :color="bucket.color" size="sm"
class="q-mr-sm"></q-icon>
<span :style="{fontWeight: 500}" v-text="bucket.label"></span>
<q-chip dense
:color="bucket.color" text-color="white"
class="q-ml-sm"
v-text="bucket.rows.length"></q-chip>
</div>
<q-table dense flat
:rows="bucket.rows"
row-key="id"
:columns="worklistTable.columns"
hide-pagination
:pagination="{rowsPerPage: 0}">
<template v-slot:body="props">
<q-tr :props="props">
<q-td key="machine">
<span v-text="machineNameById(props.row.machine_id)"></span>
</q-td>
<q-td key="created_at">
<span :style="{fontSize: '0.85em'}"
v-text="formatTime(props.row.created_at)"></span>
</q-td>
<q-td key="wire_sats" class="text-right">
<span v-text="formatSats(props.row.wire_sats)"></span>
</q-td>
<q-td key="error_message">
<span :style="{fontSize: '0.85em', opacity: 0.8}"
v-text="props.row.error_message || '—'"></span>
</q-td>
<q-td key="actions" auto-width>
<q-btn flat dense size="sm" icon="open_in_new"
color="primary"
@click="viewMachineFromWorklist(props.row)">
<q-tooltip>Open machine detail</q-tooltip>
</q-btn>
<q-btn v-if="bucket.key === 'errored'"
flat dense size="sm" icon="restart_alt"
color="primary"
@click="confirmRetryFromWorklist(props.row)">
<q-tooltip>Retry distribution</q-tooltip>
</q-btn>
<q-btn v-if="bucket.key === 'stuck_pending' || bucket.key === 'stuck_processing'"
flat dense size="sm" icon="local_fire_department"
color="red"
@click="confirmForceResetFromWorklist(props.row)">
<q-tooltip>Force-reset (stuck)</q-tooltip>
</q-btn>
</q-td>
</q-tr>
</template>
</q-table>
</div>
</q-tab-panel>
<q-tab-panel name="reports">
<div class="row items-center q-mb-md">
<div class="col">
<h6 class="q-my-none">Reports</h6>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
Client-side CSV exports of the data currently loaded in the
dashboard. For larger date ranges or server-side filters,
use the LNbits API directly.
</p>
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6 col-lg-4">
<q-card flat bordered>
<q-card-section>
<div :style="{fontWeight: 500}">Machines</div>
<div class="text-caption" :style="{opacity: 0.7}">
<span v-text="machines.length"></span> rows
</div>
</q-card-section>
<q-card-actions>
<q-btn flat color="primary" icon="download"
label="machines.csv"
@click="downloadMachinesCsv"></q-btn>
</q-card-actions>
</q-card>
</div>
<div class="col-12 col-md-6 col-lg-4">
<q-card flat bordered>
<q-card-section>
<div :style="{fontWeight: 500}">Clients (LPs)</div>
<div class="text-caption" :style="{opacity: 0.7}">
<span v-text="clients.length"></span> rows, balances included
</div>
</q-card-section>
<q-card-actions>
<q-btn flat color="primary" icon="download"
label="clients.csv"
@click="downloadClientsCsv"></q-btn>
</q-card-actions>
</q-card>
</div>
<div class="col-12 col-md-6 col-lg-4">
<q-card flat bordered>
<q-card-section>
<div :style="{fontWeight: 500}">Deposits</div>
<div class="text-caption" :style="{opacity: 0.7}">
<span v-text="deposits.length"></span> rows
</div>
</q-card-section>
<q-card-actions>
<q-btn flat color="primary" icon="download"
label="deposits.csv"
@click="downloadDepositsCsv"></q-btn>
</q-card-actions>
</q-card>
</div>
<div class="col-12 col-md-6 col-lg-4">
<q-card flat bordered>
<q-card-section>
<div :style="{fontWeight: 500}">Payments (legs)</div>
<div class="text-caption" :style="{opacity: 0.7}">
Distribution audit (dca / super_fee / operator_split / etc)
</div>
</q-card-section>
<q-card-actions>
<q-btn flat color="primary" icon="download"
label="payments.csv"
:loading="reportsBusy"
@click="downloadPaymentsCsv"></q-btn>
</q-card-actions>
</q-card>
</div>
</div>
</q-tab-panel>
</q-tab-panels>
</q-card>
<!-- =============================================================== -->
<!-- ADD MACHINE DIALOG -->
<!-- =============================================================== -->
<q-dialog v-model="addMachineDialog.show" persistent>
<q-card :style="{minWidth: '480px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Add bitSpire machine</div>
<q-space ></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<q-card-section>
<p class="text-caption q-mb-md" :style="{opacity: 0.7}">
Register an ATM by its Nostr public key. Choose the LNbits wallet that
will receive cash-out payments from this machine — settlements there
trigger the automatic distribution chain.
</p>
<q-input
v-model="addMachineDialog.data.name"
label="Machine name"
hint="Operator-friendly label (e.g. ATM-Antigua-1)"
class="q-mb-md"
dense outlined></q-input>
<q-input
v-model="addMachineDialog.data.location"
label="Location (optional)"
hint="Physical address or city; shown on operator dashboard"
class="q-mb-md"
dense outlined></q-input>
<q-input
v-model="addMachineDialog.data.machine_npub"
label="Machine npub — DEVELOPMENT ONLY (blank = normal bunker pairing)"
hint="⚠ Leave blank for normal operation: the bunker mints this machine's key when you pair it (no nsec ever lands on the machine). Only fill this to register a machine that holds its OWN signing key — development / self-signing. Hex or npub1…"
color="orange"
class="q-mb-md"
dense outlined
:rules="[
v => !v || v.length >= 32 || 'Looks too short — use a full hex/npub, or leave blank'
]"></q-input>
<q-select
v-model="addMachineDialog.data.wallet_id"
:options="walletOptions"
label="Wallet (receives ATM payments)"
emit-value map-options
class="q-mb-md"
dense outlined
:rules="[v => !!v || 'Pick a wallet']"></q-select>
<q-input
v-model="addMachineDialog.data.fiat_code"
label="Fiat code"
hint="Currency the ATM dispenses (GTQ / USD / MXN / etc.)"
class="q-mb-md"
dense outlined></q-input>
<q-input
v-model.number="addMachineDialog.data.operator_cash_in_fee_fraction"
label="Operator cash-in fee (decimal fraction, 0-0.15)"
hint="Your per-machine cut on cash-in. Sits on top of the platform fee; cap is 15% total per direction."
type="number" step="0.0001" min="0" max="0.15"
class="q-mb-md"
dense outlined></q-input>
<q-input
v-model.number="addMachineDialog.data.operator_cash_out_fee_fraction"
label="Operator cash-out fee (decimal fraction, 0-0.15)"
hint="Your per-machine cut on cash-out. Sits on top of the platform fee; cap is 15% total per direction."
type="number" step="0.0001" min="0" max="0.15"
class="q-mb-md"
dense outlined></q-input>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn
color="primary" label="Add machine"
:loading="addMachineDialog.saving"
@click="submitAddMachine"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- PAIR SPIRE DIALOG — mint bunker key + one-shot seed URL (S0/#9) -->
<!-- =============================================================== -->
<q-dialog v-model="pairDialog.show" persistent>
<q-card :style="{minWidth: '480px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Pair spire</div>
<q-space></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<!-- Step 1 — configure + generate -->
<template v-if="!pairDialog.result">
<q-card-section>
<p class="text-caption q-mb-md" :style="{opacity: 0.7}">
Mints a dedicated signing key for
<b v-text="(pairDialog.machine && pairDialog.machine.name) || 'this spire'"></b>
inside the operator bunker and issues a one-shot seed URL. The
spire's key never touches its disk; its cash-outs route to this
machine's wallet. Re-pairing issues a fresh seed.
</p>
<q-input
v-model="pairDialog.relays"
label="Relay(s) for the spire's events"
hint="One per line. The same relay the spire publishes to (its VITE_RELAY_URL), e.g. wss://your-host/nostrrelay/<id>"
type="textarea" autogrow
class="q-mb-md"
dense outlined></q-input>
<q-input
v-model.number="pairDialog.durationHours"
label="Token lifetime in hours (optional)"
hint="Blank = non-expiring. Set e.g. 720 (30 days) to force periodic re-pairing."
type="number" min="1"
class="q-mb-md"
dense outlined></q-input>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn
color="primary" label="Generate seed URL" icon="vpn_key"
:loading="pairDialog.saving"
@click="submitPair"></q-btn>
</q-card-actions>
</template>
<!-- Step 2 — show the seed URL -->
<template v-else>
<q-card-section>
<q-banner dense rounded class="bg-green-1 text-grey-9 q-mb-md">
<template v-slot:avatar>
<q-icon name="check_circle" color="green"></q-icon>
</template>
Paired. Scan this on the spire at first boot, or paste the seed URL
into <code>provision-atm</code>. Shown once — copy it now.
</q-banner>
<div class="row justify-center q-mb-md">
<lnbits-qrcode
:value="pairDialog.result.seed_url"
:options="{width: 280}"></lnbits-qrcode>
</div>
<q-input
v-model="pairDialog.result.seed_url"
label="Seed URL"
type="textarea" autogrow readonly
class="q-mb-sm"
dense outlined>
<template v-slot:append>
<q-btn flat dense round icon="content_copy"
@click="copy(pairDialog.result.seed_url)">
<q-tooltip>Copy seed URL</q-tooltip>
</q-btn>
</template>
</q-input>
<div class="text-caption" :style="{opacity: 0.7}">
Spire identity:
<code :style="{fontSize: '0.85em'}"
v-text="shortNpub(pairDialog.result.spire_npub)"></code>
<q-btn flat dense round size="xs" icon="content_copy"
@click="copy(pairDialog.result.spire_npub)">
<q-tooltip>Copy npub</q-tooltip>
</q-btn>
</div>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat label="Done" color="primary" v-close-popup></q-btn>
</q-card-actions>
</template>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- MACHINE DETAIL DIALOG — settlements list + per-row actions -->
<!-- =============================================================== -->
<q-dialog v-model="machineDetail.show" maximized
transition-show="slide-up" transition-hide="slide-down">
<q-card v-if="machineDetail.machine">
<q-bar class="bg-primary text-white">
<div class="text-h6 q-mr-md"
v-text="machineDetail.machine.name || 'Unnamed machine'"></div>
<q-chip dense color="white" text-color="primary"
v-text="machineDetail.machine.fiat_code"></q-chip>
<q-space ></q-space>
<q-btn flat dense round icon="refresh"
@click="reloadMachineDetail"
:loading="machineDetail.loading">
<q-tooltip>Reload</q-tooltip>
</q-btn>
<q-btn flat dense round icon="close" v-close-popup>
<q-tooltip>Close</q-tooltip>
</q-btn>
</q-bar>
<q-card-section>
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12 col-md-4">
<div class="text-caption" :style="{opacity: 0.6}">npub</div>
<code :style="{fontSize: '0.85em', wordBreak: 'break-all'}"
v-text="machineDetail.machine.machine_npub"></code>
</div>
<div class="col-12 col-md-3">
<div class="text-caption" :style="{opacity: 0.6}">Wallet</div>
<code :style="{fontSize: '0.85em'}"
v-text="machineDetail.machine.wallet_id"></code>
</div>
<div class="col-6 col-md-2">
<div class="text-caption" :style="{opacity: 0.6}">Location</div>
<span v-text="machineDetail.machine.location || '—'"></span>
</div>
</div>
<q-separator class="q-mb-md"></q-separator>
<q-tabs v-model="machineDetail.activeTab" dense
align="left" class="text-grey-7"
active-color="primary" indicator-color="primary"
narrow-indicator>
<q-tab name="settlements" icon="receipt_long"
label="Settlements"></q-tab>
<q-tab name="cassettes" icon="precision_manufacturing"
label="Cassettes"></q-tab>
</q-tabs>
<q-separator></q-separator>
<q-tab-panels v-model="machineDetail.activeTab" animated>
<q-tab-panel name="settlements" class="q-px-none">
<div class="row items-center q-mt-md q-mb-sm">
<div class="col">
<h6 class="q-my-none">Settlements</h6>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
Every bitSpire transaction lands here. Click a row's menu for
retry / partial-dispense / notes.
</p>
</div>
</div>
<q-banner v-if="!machineDetail.settlements.length"
class="bg-grey-2 text-grey-9">
No settlements yet. They'll appear when bitSpire pays this machine's
wallet.
</q-banner>
<q-table v-else
dense flat
:rows="machineDetail.settlements"
row-key="id"
:columns="settlementsTable.columns"
:rows-per-page-options="[10, 25, 50]"
:pagination="settlementsTable.pagination">
<template v-slot:body="props">
<q-tr :props="props">
<q-td key="status">
<q-badge :color="settlementStatusColor(props.row.status)"
:label="props.row.status"></q-badge>
</q-td>
<q-td key="tx_type">
<q-chip dense square size="sm"
:color="txTypeChip(props.row.tx_type).color"
text-color="white"
:icon="txTypeChip(props.row.tx_type).icon">
<span v-text="txTypeChip(props.row.tx_type).label"></span>
<q-tooltip>
<span v-text="txTypeChip(props.row.tx_type).tooltip"></span>
</q-tooltip>
</q-chip>
</q-td>
<q-td key="created_at">
<span :style="{fontSize: '0.85em'}"
v-text="formatTime(props.row.created_at)"></span>
</q-td>
<q-td key="wire_sats" class="text-right">
<span v-text="formatSats(props.row.wire_sats)"></span>
</q-td>
<q-td key="principal_sats" class="text-right">
<span v-text="formatSats(props.row.principal_sats)"></span>
</q-td>
<q-td key="fee_sats" class="text-right">
<span v-text="formatSats(props.row.fee_sats)"></span>
<div :style="{fontSize: '0.75em', opacity: 0.6}">
super
<span v-text="formatSats(props.row.platform_fee_sats)"></span>
/ op
<span v-text="formatSats(props.row.operator_fee_sats)"></span>
</div>
</q-td>
<q-td key="fiat_amount" class="text-right">
<span v-text="formatFiat(props.row.fiat_amount, props.row.fiat_code)"></span>
</q-td>
<q-td key="payment_hash">
<code :style="{fontSize: '0.8em'}"
v-text="shortId(props.row.payment_hash)"></code>
</q-td>
<q-td key="actions" auto-width>
<q-btn-dropdown flat dense size="sm" label="" icon="more_vert">
<q-list dense>
<q-item clickable v-close-popup
@click="openSettlementNote(props.row)">
<q-item-section avatar>
<q-icon name="edit_note"></q-icon>
</q-item-section>
<q-item-section>Add note</q-item-section>
</q-item>
<q-item v-if="props.row.status === 'errored'"
clickable v-close-popup
@click="confirmRetrySettlement(props.row)">
<q-item-section avatar>
<q-icon name="restart_alt" color="primary"></q-icon>
</q-item-section>
<q-item-section>Retry distribution</q-item-section>
</q-item>
<q-item
v-if="['pending','errored'].includes(props.row.status)"
clickable v-close-popup
@click="openPartialDispense(props.row)">
<q-item-section avatar>
<q-icon name="precision_manufacturing" color="warning"></q-icon>
</q-item-section>
<q-item-section>Partial dispense…</q-item-section>
</q-item>
<q-item
v-if="['pending','processing'].includes(props.row.status)"
clickable v-close-popup
@click="confirmForceReset(props.row)">
<q-item-section avatar>
<q-icon name="local_fire_department" color="red"></q-icon>
</q-item-section>
<q-item-section>Force-reset (stuck)…</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</q-td>
</q-tr>
<q-tr v-if="props.row.notes" :props="props" no-hover>
<q-td colspan="100%" class="bg-grey-2">
<pre :style="{whiteSpace: 'pre-wrap', fontSize: '0.8em', margin: 0}"
v-text="props.row.notes"></pre>
</q-td>
</q-tr>
</template>
</q-table>
</q-tab-panel>
<q-tab-panel name="cassettes" class="q-px-none">
<div class="row items-center q-mt-md q-mb-sm">
<div class="col">
<h6 class="q-my-none">Cassettes</h6>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
Per-cassette count and physical bay position. Denomination
set is hardware-determined (re-provision via atm-tui to
change). "Publish to ATM" encrypts + signs + sends the new
config to the machine via Nostr.
</p>
</div>
<div class="col-auto">
<q-btn flat dense icon="undo" label="Revert"
:disable="!machineDetail.cassettesDirty"
@click="revertCassetteEdits">
<q-tooltip>Discard unsaved edits</q-tooltip>
</q-btn>
<q-btn color="primary" icon="cloud_upload"
label="Publish to ATM"
:disable="!machineDetail.cassettesDirty"
:loading="machineDetail.cassettesPublishing"
@click="openCassettePublishConfirm"></q-btn>
</div>
</div>
<q-banner v-if="machineDetail.cassettesError"
class="bg-red-1 text-grey-9 q-mb-md">
<template v-slot:avatar>
<q-icon name="warning" color="negative"></q-icon>
</template>
<span v-text="machineDetail.cassettesError"></span>
</q-banner>
<q-banner v-if="!machineDetail.cassetteEdits.length
&& !machineDetail.cassettesLoading"
class="bg-blue-1 text-grey-9">
<template v-slot:avatar>
<q-icon name="hourglass_empty" color="blue"></q-icon>
</template>
Waiting for the ATM's bootstrap state event. Power on the ATM
and confirm it has reached the configured relay; cassette
rows will auto-populate on receipt.
</q-banner>
<q-table v-if="machineDetail.cassetteEdits.length"
dense flat
:rows="machineDetail.cassetteEdits"
row-key="position"
:columns="cassettesTable.columns"
:pagination="cassettesTable.pagination"
hide-pagination>
<template v-slot:body="props">
<q-tr :props="props"
:style="props.row._dirty
? {boxShadow: 'inset 4px 0 0 0 #fdd835'}
: {}">
<q-td key="position" class="text-right">
<b v-text="'Bay ' + props.row.position"></b>
</q-td>
<q-td key="denomination" class="text-right">
<q-input v-model.number="props.row.denomination"
type="number" min="1" step="1" dense outlined
:suffix="machineDetail.machine.fiat_code || ''"
:style="{width: '140px', display: 'inline-block'}"
@update:model-value="markCassetteDirty(props.row)"></q-input>
</q-td>
<q-td key="count" class="text-right">
<q-input v-model.number="props.row.count" type="number"
min="0" step="1" dense outlined
:style="{width: '120px', display: 'inline-block'}"
@update:model-value="markCassetteDirty(props.row)"></q-input>
</q-td>
<q-td key="state" class="text-right">
<span v-if="props.row.state_denomination !== null"
:style="{fontSize: '0.85em', opacity: 0.7}">
<span v-text="props.row.state_denomination"></span>
<span :style="{opacity: 0.6}"
v-text="' ' + (machineDetail.machine.fiat_code || '')"></span>
<span :style="{opacity: 0.6}"> · </span>
<span v-text="'×' + props.row.state_count"></span>
</span>
<span v-else :style="{opacity: 0.4}"></span>
</q-td>
<q-td key="updated_at">
<span :style="{fontSize: '0.85em', opacity: 0.7}"
v-text="formatTime(props.row.updated_at)"></span>
</q-td>
</q-tr>
</template>
</q-table>
</q-tab-panel>
</q-tab-panels>
</q-card-section>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- CASSETTE PUBLISH CONFIRM DIALOG -->
<!-- =============================================================== -->
<q-dialog v-model="cassettePublishConfirm.show" persistent>
<q-card :style="{minWidth: '480px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Publish cassette config to ATM</div>
<q-space ></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<q-card-section>
<q-banner class="bg-orange-1 text-grey-9 q-mb-md">
<template v-slot:avatar>
<q-icon name="warning" color="warning"></q-icon>
</template>
<b>This publish will overwrite the ATM's currently-tracked
counts.</b> If the ATM has dispensed cash since your last
refill or count baseline, those decrements will be lost.
Publish only after a physical refill (a known total), not to
"tweak" counts mid-day. v2 reconciliation will replace this
modal with reconciled state display.
</q-banner>
<p class="q-mb-sm">Sending to ATM:</p>
<q-list dense bordered>
<q-item v-for="row in machineDetail.cassetteEdits"
:key="row.position">
<q-item-section>
<q-item-label>
<b v-text="'Bay ' + row.position"></b>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label caption>
<b v-text="row.denomination + ' ' +
(machineDetail.machine.fiat_code || '')"></b>
· count
<b v-text="row.count"></b>
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="primary"
label="Publish to ATM"
:loading="machineDetail.cassettesPublishing"
@click="submitCassettePublish"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- PARTIAL-DISPENSE DIALOG -->
<!-- =============================================================== -->
<q-dialog v-model="partialDispenseDialog.show" persistent>
<q-card :style="{minWidth: '480px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Apply partial dispense</div>
<q-space ></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<q-card-section v-if="partialDispenseDialog.settlement">
<q-banner class="bg-blue-1 text-grey-9 q-mb-md">
<template v-slot:avatar>
<q-icon name="info" color="blue"></q-icon>
</template>
Original gross:
<b v-text="formatSats(partialDispenseDialog.settlement.wire_sats)"></b>.
Provide what was actually dispensed. Sat amounts will scale linearly,
the commission split will recompute, and distribution will re-run.
</q-banner>
<q-tabs v-model="partialDispenseDialog.mode" dense
active-color="primary" indicator-color="primary"
align="justify">
<q-tab name="fraction" label="By fraction (0..1)"></q-tab>
<q-tab name="sats" label="By exact sats"></q-tab>
</q-tabs>
<q-tab-panels v-model="partialDispenseDialog.mode" animated>
<q-tab-panel name="fraction" class="q-pa-sm">
<q-input v-model.number="partialDispenseDialog.dispensed_fraction"
label="Dispensed fraction"
hint="e.g. 0.6 means 60% of the original tx was dispensed"
type="number" step="0.01" min="0" max="1"
dense outlined></q-input>
</q-tab-panel>
<q-tab-panel name="sats" class="q-pa-sm">
<q-input v-model.number="partialDispenseDialog.dispensed_sats"
label="Dispensed sats"
hint="Exact sat amount actually dispensed (≤ original gross)"
type="number" step="1" min="0"
:max="partialDispenseDialog.settlement.wire_sats"
dense outlined></q-input>
</q-tab-panel>
</q-tab-panels>
<q-input v-model="partialDispenseDialog.notes"
label="Reason (recorded in audit memo)"
type="textarea" autogrow
class="q-mt-md"
dense outlined></q-input>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="warning"
label="Apply partial dispense"
:loading="partialDispenseDialog.saving"
@click="submitPartialDispense"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- ADD-NOTE DIALOG -->
<!-- =============================================================== -->
<q-dialog v-model="noteDialog.show" persistent>
<q-card :style="{minWidth: '420px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Add note to settlement</div>
<q-space ></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<q-card-section>
<p class="text-caption q-mb-sm" :style="{opacity: 0.7}">
Notes are append-only and timestamped. Use for reconciliation context,
off-LN refund records, dispute narrative, etc.
</p>
<q-input v-model="noteDialog.note"
label="Note"
type="textarea" autogrow
:rules="[v => (v && v.trim().length > 0) || 'Note cannot be empty']"
dense outlined></q-input>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="primary" label="Save note"
:loading="noteDialog.saving"
@click="submitNote"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- SUPER-FEE EDIT DIALOG (super-only) -->
<!-- =============================================================== -->
<q-dialog v-model="superFeeDialog.show" persistent>
<q-card :style="{minWidth: '460px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Platform fee (super-only)</div>
<q-space ></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<q-card-section>
<p class="text-caption q-mb-md" :style="{opacity: 0.7}">
Charged on every transaction's principal across the LNbits
instance. Independent per direction. Each direction's total
(super + operator) is capped at 15%. Operators see these as a
read-only banner. Wallet ID is where the collected fee lands;
typically a wallet you (the super) own.
</p>
<q-input v-model.number="superFeeDialog.data.super_cash_in_fee_fraction"
label="Cash-in fee (decimal fraction, 0-0.15)"
hint="0.03 = 3% of principal on cash-in transactions"
type="number" step="0.0001" min="0" max="0.15"
class="q-mb-md" dense outlined></q-input>
<q-input v-model.number="superFeeDialog.data.super_cash_out_fee_fraction"
label="Cash-out fee (decimal fraction, 0-0.15)"
hint="0.03 = 3% of principal on cash-out transactions"
type="number" step="0.0001" min="0" max="0.15"
class="q-mb-md" dense outlined></q-input>
<q-input v-model="superFeeDialog.data.super_fee_wallet_id"
label="Super fee destination wallet_id"
hint="LNbits wallet that collects the platform fee"
class="q-mb-md" dense outlined></q-input>
<q-input v-model.number="superFeeDialog.data.max_cash_in_sats"
label="Max cash-in per transaction (sats — blank = no cap)"
hint="Server-side ceiling on a single cash-in's principal. The ATM attests the amount; this bounds a compromised/buggy machine to one capped tx."
type="number" step="1" min="0"
class="q-mb-md" dense outlined></q-input>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="primary" label="Save"
:loading="superFeeDialog.saving"
@click="submitSuperFee"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- ADD / EDIT DEPOSIT DIALOG -->
<!-- =============================================================== -->
<q-dialog v-model="depositDialog.show" persistent>
<q-card :style="{minWidth: '460px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6"
v-text="depositDialog.mode === 'add' ? 'Record deposit' : 'Edit deposit'"></div>
<q-space ></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<q-card-section>
<q-select v-if="depositDialog.mode === 'add'"
v-model="depositDialog.data.client_id"
:options="depositClientOptions"
label="Liquidity provider"
emit-value map-options
class="q-mb-md" dense outlined
:rules="[v => !!v || 'Pick an LP']"></q-select>
<q-banner v-if="depositDialog.mode === 'add' && selectedDepositClient && !selectedDepositClient.lp_onboarded"
class="bg-deep-orange-1 text-grey-9 q-mb-md">
<template v-slot:avatar>
<q-icon name="pending" color="deep-orange"></q-icon>
</template>
This LP hasn't onboarded via <b>satmachineclient</b> yet, so
their DCA wallet isn't configured. Ask them to open the
satmachineclient extension once and the deposit will be
accepted next time.
</q-banner>
<q-input v-model.number="depositDialog.data.amount"
:label="depositMachineFiatCode ? `Amount (${depositMachineFiatCode})` : 'Amount (fiat)'"
type="number" step="0.01" min="0"
class="q-mb-md" dense outlined
:rules="[v => v > 0 || 'Must be > 0']">
<template v-slot:append>
<q-chip v-if="depositMachineFiatCode"
dense color="grey-3" text-color="grey-9"
:style="{fontWeight: 500}">
<span v-text="depositMachineFiatCode"></span>
<q-tooltip>
Currency is set by the selected LP's machine
(<code v-text="depositMachineFiatCode"></code>) — not
operator-editable. See aiolabs/satmachineadmin#26.
</q-tooltip>
</q-chip>
</template>
</q-input>
<q-input v-model="depositDialog.data.notes"
label="Notes (optional)"
type="textarea" autogrow
class="q-mb-md" dense outlined></q-input>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="primary"
:label="depositDialog.mode === 'add' ? 'Record' : 'Save'"
:loading="depositDialog.saving"
:disable="depositDialog.mode === 'add' && (!selectedDepositClient || !selectedDepositClient.lp_onboarded)"
@click="submitDeposit"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- REJECT DEPOSIT DIALOG (status update with notes) -->
<!-- =============================================================== -->
<q-dialog v-model="rejectDepositDialog.show" persistent>
<q-card :style="{minWidth: '420px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Reject deposit</div>
<q-space ></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<q-card-section>
<p class="text-caption q-mb-sm" :style="{opacity: 0.7}">
The deposit will be marked rejected and won't count toward the LP's
balance. Optional reason for the audit trail.
</p>
<q-input v-model="rejectDepositDialog.notes"
label="Reason (optional)"
type="textarea" autogrow
dense outlined></q-input>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="red" label="Reject"
:loading="rejectDepositDialog.saving"
@click="submitRejectDeposit"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- ADD / EDIT CLIENT DIALOGS -->
<!-- =============================================================== -->
<q-dialog v-model="clientDialog.show" persistent>
<q-card :style="{minWidth: '520px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6" v-text="clientDialog.mode === 'add' ? 'Register liquidity provider' : 'Edit LP'"></div>
<q-space ></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<q-card-section>
<p class="text-caption q-mb-md" :style="{opacity: 0.7}"
v-if="clientDialog.mode === 'add'">
Enrol an LP at one of your machines. Wallet, DCA mode, and
autoforward are configured by the LP themselves via the
<b>satmachineclient</b> extension — you can't set them here.
Deposits are refused until the LP has registered.
</p>
<q-select
v-if="clientDialog.mode === 'add'"
v-model="clientDialog.data.machine_id"
:options="machineOptions"
label="At machine"
emit-value map-options
class="q-mb-md"
dense outlined
:rules="[v => !!v || 'Required']"></q-select>
<q-input v-if="clientDialog.mode === 'add'"
v-model="clientDialog.data.user_id"
label="LP's LNbits user_id"
hint="The LP shares this from their LNbits account settings"
class="q-mb-md" dense outlined
:rules="[v => !!v || 'Required']"></q-input>
<q-input v-model="clientDialog.data.username"
label="Display name (optional)"
hint="Operator-facing label only; doesn't affect distribution"
class="q-mb-md" dense outlined></q-input>
<q-select v-if="clientDialog.mode === 'edit'"
v-model="clientDialog.data.status"
:options="['active', 'paused', 'closed']"
label="Status"
class="q-mb-md" dense outlined></q-select>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="primary"
:label="clientDialog.mode === 'add' ? 'Register' : 'Save'"
:loading="clientDialog.saving"
@click="submitClient"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- SETTLE BALANCE DIALOG (closes spirekeeper#2) -->
<!-- =============================================================== -->
<q-dialog v-model="settleBalanceDialog.show" persistent>
<q-card :style="{minWidth: '480px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Settle LP balance</div>
<q-space ></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<q-card-section v-if="settleBalanceDialog.client">
<q-banner class="bg-blue-1 text-grey-9 q-mb-md">
<template v-slot:avatar>
<q-icon name="payments" color="blue"></q-icon>
</template>
Pay the LP's remaining fiat balance in sats from your wallet at the
rate you choose. Useful to zero out small balances that would
otherwise shrink forever via proportional shares.
</q-banner>
<div v-if="settleBalanceDialog.balance" class="q-mb-md">
<div class="text-caption" :style="{opacity: 0.6}">Remaining balance</div>
<div :style="{fontSize: '1.2em', fontWeight: 500}"
v-text="formatFiat(settleBalanceDialog.balance.remaining_balance, settleBalanceDialog.balance.currency)"></div>
</div>
<q-select v-model="settleBalanceDialog.data.funding_wallet_id"
:options="walletOptions"
label="Funding wallet (yours)"
emit-value map-options
class="q-mb-md" dense outlined
:rules="[v => !!v || 'Pick a wallet']"></q-select>
<q-input v-model.number="settleBalanceDialog.data.exchange_rate"
label="Exchange rate (sats per 1 fiat unit)"
hint="You set the rate. Use exchange spot, midpoint, or a favourable gesture."
type="number" step="0.0001" min="0"
class="q-mb-md" dense outlined
:rules="[v => v > 0 || 'Must be > 0']"></q-input>
<q-input v-model.number="settleBalanceDialog.data.amount_fiat"
label="Amount (fiat) — leave blank to settle full remaining"
type="number" step="0.01"
class="q-mb-md" dense outlined></q-input>
<q-input v-model="settleBalanceDialog.data.notes"
label="Notes (optional, audit memo)"
type="textarea" autogrow
class="q-mb-md" dense outlined></q-input>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="primary" label="Settle balance"
:loading="settleBalanceDialog.saving"
@click="submitSettleBalance"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- EDIT MACHINE DIALOG (same fields; PUT instead of POST) -->
<!-- =============================================================== -->
<q-dialog v-model="editMachineDialog.show" persistent>
<q-card :style="{minWidth: '480px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Edit machine</div>
<q-space ></q-space>
<q-btn icon="close" flat round dense v-close-popup></q-btn>
</q-card-section>
<q-card-section>
<q-input v-model="editMachineDialog.data.name"
label="Machine name" class="q-mb-md" dense outlined></q-input>
<q-input v-model="editMachineDialog.data.location"
label="Location" class="q-mb-md" dense outlined></q-input>
<q-select
v-model="editMachineDialog.data.wallet_id"
:options="walletOptions"
label="Wallet"
emit-value map-options
class="q-mb-md"
dense outlined></q-select>
<q-input v-model="editMachineDialog.data.fiat_code"
label="Fiat code" class="q-mb-md" dense outlined></q-input>
<q-input v-model.number="editMachineDialog.data.operator_cash_in_fee_fraction"
label="Operator cash-in fee (decimal fraction, 0-0.15)"
hint="Sits on top of the platform cash-in fee. Cap 15% total per direction."
type="number" step="0.0001" min="0" max="0.15"
class="q-mb-md" dense outlined></q-input>
<q-input v-model.number="editMachineDialog.data.operator_cash_out_fee_fraction"
label="Operator cash-out fee (decimal fraction, 0-0.15)"
hint="Sits on top of the platform cash-out fee. Cap 15% total per direction."
type="number" step="0.0001" min="0" max="0.15"
class="q-mb-md" dense outlined></q-input>
<q-toggle v-model="editMachineDialog.data.is_active"
label="Active (receives settlements)" class="q-mb-md"></q-toggle>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn
color="primary" label="Save"
:loading="editMachineDialog.saving"
@click="submitEditMachine"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
</div>
</div>
{% endblock %}