spirekeeper/static/js/index.js
Padreug fdba2d363f feat(dashboard): owed-cash worklist buckets, prefilled partial dispense, resume cash-out (ADR-005 §5–§6)
Three buckets render first on the worklist — cash_owed, partial_pending,
dispense_unreported (awaiting_dispense older than the threshold) — the
only ones whose meaning is "a customer is owed money". partial_pending
rows open the partial-dispense dialog pre-filled from the machine's
report: the fraction from dispensed_fiat_cents / fiat_amount and the
dispenser's error in the note, so the operator confirms a number the
hardware produced rather than typing one.

Machine detail shows a held-cash-out banner (code, time, reason) with a
Resume button; POST /machines/{id}/resume-cash-out records a
resume_cash_out op and publishes the window. The machine clears the hold
on receipt and the banner clears on its next state report.
2026-10-10 21:51:52 +02:00

1825 lines
62 KiB
JavaScript
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.

// Satoshi Machine v2 — operator dashboard (P9a foundation).
//
// Vue 3 + Quasar UMD app. Talks to the v2 spirekeeper REST surface
// (machines / clients / deposits / settlements / commission-splits /
// super-config). All endpoints are operator-scoped via the LNbits session.
//
// LNbits UMD/Quasar conventions in play:
// - Vue delimiters are `${ ... }` because Jinja owns `{{ }}` in the
// template file. Use v-text / :attr binding rather than mustache.
// - For per-element typography overrides, prefer :style — Quasar's
// .text-grey-* / .text-caption utilities collide with LNbits' theme.
// - For pale backgrounds (bg-*-1), pair with explicit dark text class
// so dark-mode users don't get unreadable white-on-cream.
const API = '/spirekeeper/api/v1/dca'
const SUPER_FEE_PATH = `${API}/super-config`
const MACHINES_PATH = `${API}/machines`
const SETTLEMENTS_PATH = `${API}/settlements`
const STUCK_PATH = `${API}/settlements/stuck`
const CLIENTS_PATH = `${API}/clients`
const DEPOSITS_PATH = `${API}/deposits`
const COMMISSION_SPLITS_PATH = `${API}/commission-splits`
const DEPOSIT_STATUS_COLOR = {
pending: 'orange',
confirmed: 'green',
rejected: 'red'
}
const SETTLEMENT_STATUS_COLOR = {
pending: 'grey',
processing: 'blue',
processed: 'green',
partial: 'orange',
refunded: 'purple',
errored: 'red',
rejected: 'deep-orange'
}
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
delimiters: ['${', '}'],
data() {
return {
activeTab: 'fleet',
refreshing: false,
// Server state ---------------------------------------------------
superConfig: null,
machines: [],
clients: [],
clientBalances: {}, // {client_id: ClientBalanceSummary}
deposits: [],
worklistCount: 0,
depositsFilter: {
status: null,
client_id: null
},
// Commission splits editor (P9e) -- null scope = default ruleset.
commissionScope: null,
commissionLegs: [],
commissionSaving: false,
// Preview shows how an example commission-sats input would split
// across the current legs (purely visual; doesn't hit the server).
commissionPreviewInput: 1000,
// Worklist (P9g)
worklist: {
// ADR-005 §6 — owed-cash buckets first
cash_owed: [],
partial_pending: [],
dispense_unreported: [],
rejected: [],
errored: [],
stuck_pending: [],
stuck_processing: [],
totalCount: 0
},
worklistLoading: false,
worklistThreshold: 30,
worklistTable: {
columns: [
{name: 'machine', label: 'Machine', field: 'machine_id', align: 'left'},
{name: 'created_at', label: 'Created', field: 'created_at', align: 'left'},
{name: 'wire_sats', label: 'Wire', field: 'wire_sats', align: 'right'},
{
name: 'error_message',
label: 'Error',
field: 'error_message',
align: 'left'
},
{name: 'actions', label: '', field: 'id', align: 'right'}
]
},
// Reports
reportsBusy: false,
// Super-fee edit dialog (super-only)
superFeeDialog: {
show: false,
saving: false,
data: {
super_cash_in_fee_fraction: 0,
super_cash_out_fee_fraction: 0,
super_fee_wallet_id: '',
max_cash_in_sats: null
}
},
// UI configuration -----------------------------------------------
machinesTable: {
columns: [
{name: 'status', label: '', field: 'is_active', align: 'center'},
{name: 'name', label: 'Name / Location', field: 'name', align: 'left'},
{name: 'machine_npub', label: 'npub', field: 'machine_npub', align: 'left'},
{name: 'wallet_id', label: 'Wallet', field: 'wallet_id', align: 'left'},
{name: 'fiat_code', label: 'Fiat', field: 'fiat_code', align: 'left'},
{name: 'actions', label: 'Actions', field: 'id', align: 'right'}
],
pagination: {rowsPerPage: 10, sortBy: 'name'}
},
depositsTable: {
columns: [
{name: 'status', label: 'Status', field: 'status', align: 'left'},
{name: 'client', label: 'LP / Machine', field: 'client_id', align: 'left'},
{name: 'amount', label: 'Amount', field: 'amount', align: 'right'},
{name: 'created_at', label: 'Created', field: 'created_at', align: 'left'},
{
name: 'confirmed_at',
label: 'Confirmed',
field: 'confirmed_at',
align: 'left'
},
{name: 'notes', label: 'Notes', field: 'notes', align: 'left'},
{name: 'actions', label: '', field: 'id', align: 'right'}
],
pagination: {rowsPerPage: 25, sortBy: 'created_at', descending: true}
},
clientsTable: {
// Wallet / mode / autoforward dropped — those are LP-controlled
// via satmachineclient, not the operator's concern. `onboarded`
// surfaces the dca_lp existence flag (lp_onboarded) so operators
// can see at a glance which LPs still need to register before
// deposits can be recorded against them.
columns: [
{name: 'machine', label: 'Machine', field: 'machine_id', align: 'left'},
{name: 'username', label: 'LP', field: 'username', align: 'left'},
{name: 'onboarded', label: 'Onboarded', field: 'lp_onboarded', align: 'center'},
{
name: 'remaining_balance',
label: 'Balance',
field: 'remaining_balance',
align: 'right'
},
{name: 'status', label: 'Status', field: 'status', align: 'left'},
{name: 'actions', label: '', field: 'id', align: 'right'}
],
pagination: {rowsPerPage: 25}
},
settlementsTable: {
columns: [
{name: 'status', label: 'Status', field: 'status', align: 'left'},
{name: 'tx_type', label: 'Direction', field: 'tx_type', align: 'left'},
{name: 'created_at', label: 'Time', field: 'created_at', align: 'left'},
{name: 'wire_sats', label: 'Wire', field: 'wire_sats', align: 'right'},
{name: 'principal_sats', label: 'Principal (→ LPs)', field: 'principal_sats', align: 'right'},
{
name: 'fee_sats',
label: 'Fee',
field: 'fee_sats',
align: 'right'
},
{name: 'fiat_amount', label: 'Fiat', field: 'fiat_amount', align: 'right'},
{name: 'payment_hash', label: 'Hash', field: 'payment_hash', align: 'left'},
{name: 'actions', label: '', field: 'id', align: 'right'}
],
pagination: {rowsPerPage: 25, sortBy: 'created_at', descending: true}
},
// Dialog state ---------------------------------------------------
addMachineDialog: {
show: false,
saving: false,
data: this._emptyMachineForm()
},
editMachineDialog: {
show: false,
saving: false,
data: {}
},
pairDialog: {
show: false,
saving: false,
machine: null,
relays: '',
durationHours: null,
result: null
},
// Fullscreen QR overlay — maximises a QR's on-screen size so a spire
// camera can lock onto it. openQrZoom() sizes it to the viewport.
qrZoom: {
show: false,
value: '',
size: 280
},
machineDetail: {
show: false,
loading: false,
machine: null,
settlements: [],
// Cassettes sub-tab state (v2, bitspire ADR-004) — see
// openCassetteOpDialog / submitCassetteOp + the cassettes panel in
// templates/spirekeeper/index.html. Read-only by design: the
// operator records operations, the machine owns the counts.
activeTab: 'settlements',
cassettes: [], // machine-reported rows, not editable
cassetteOps: [], // recent operations, newest first
cassettesLoading: false,
cassettesError: null
},
cassettesTable: {
columns: [
{name: 'position', label: 'Bay', field: 'position', align: 'right'},
{name: 'denomination', label: 'Denomination', field: 'denomination', align: 'right'},
{name: 'count', label: 'Count', field: 'count', align: 'right'},
{name: 'state_at', label: 'Machine reported', field: 'state_at', align: 'left'},
{name: 'actions', label: '', field: 'position', align: 'right'}
],
pagination: {rowsPerPage: 0} // hide pagination — cassette count is small
},
cassetteOpDialog: {
show: false,
saving: false,
error: null,
position: null,
op_type: 'refill',
bills: null,
count: null,
denomination: null
},
cassetteOpTypeOptions: [
{label: 'Refill — notes added', value: 'refill'},
{label: 'Empty — bay emptied', value: 'empty'},
{label: 'Recount — notes counted', value: 'recount'},
{label: 'Set denomination', value: 'set_denomination'}
],
partialDispenseDialog: {
show: false,
saving: false,
settlement: null,
mode: 'fraction',
dispensed_fraction: null,
dispensed_sats: null,
notes: ''
},
noteDialog: {
show: false,
saving: false,
settlement: null,
note: ''
},
clientDialog: {
show: false,
saving: false,
mode: 'add', // 'add' | 'edit'
data: this._emptyClientForm()
},
depositDialog: {
show: false,
saving: false,
mode: 'add',
data: this._emptyDepositForm()
},
rejectDepositDialog: {
show: false,
saving: false,
deposit: null,
notes: ''
},
settleBalanceDialog: {
show: false,
saving: false,
client: null,
balance: null,
data: {
funding_wallet_id: null,
exchange_rate: null,
amount_fiat: null,
notes: ''
}
}
}
},
computed: {
cassetteBayOptions() {
return this.machineDetail.cassettes.map(row => ({
label: `Bay ${row.position} — ${row.denomination} ${
(this.machineDetail.machine || {}).fiat_code || ''
} ×${row.count}`,
value: row.position
}))
},
cassetteOpIsComplete() {
// Mirrors CreateCassetteOpData's root validator: exactly one value
// field, decided by the type. Enforced here only to keep the button
// honest — the server rejects a malformed op regardless.
const d = this.cassetteOpDialog
if (!d.position) return false
if (d.op_type === 'empty') return true
if (d.op_type === 'refill') return Number(d.bills) > 0
if (d.op_type === 'recount') return d.count !== null && Number(d.count) >= 0
if (d.op_type === 'set_denomination') return Number(d.denomination) > 0
return false
},
superAnyFee() {
// Banner styling key — true when either directional super fee is
// non-zero, so the banner reads as "active platform fee" instead
// of the muted grey "free instance" state.
const c = this.superConfig
if (!c) return 0
return (
Number(c.super_cash_in_fee_fraction || 0) +
Number(c.super_cash_out_fee_fraction || 0)
)
},
walletOptions() {
// g.user is sometimes null on initial mount in LNbits 1.4 — guard it.
const wallets = this.g?.user?.wallets || []
return wallets.map(w => ({label: w.name, value: w.id}))
},
machineOptions() {
return this.machines.map(m => ({
label: m.name || this.shortNpub(m.machine_npub),
value: m.id
}))
},
depositClientOptions() {
// Annotate each LP option with onboarding state so the operator
// sees at-pick time which LPs can accept deposits. We don't hide
// un-onboarded LPs — the operator might want to know they exist
// and chase them — but submission is gated below by
// `selectedDepositClient.lp_onboarded`.
return this.clients.map(c => ({
label:
`${c.username || this.shortId(c.user_id)} @ ` +
`${this.machineNameById(c.machine_id)}` +
(c.lp_onboarded ? '' : ' — pending onboarding'),
value: c.id,
disable: !c.lp_onboarded
}))
},
selectedDepositClient() {
const id = this.depositDialog.data.client_id
return id ? this.clients.find(c => c.id === id) : null
},
depositMachineFiatCode() {
// Currency the deposit will land in — bound to the machine the
// selected LP is enrolled at. Resolved entirely client-side from
// already-loaded data, but the server has the final say (#26).
const c = this.selectedDepositClient
if (!c) return null
const m = this.machines.find(m => m.id === c.machine_id)
return m ? m.fiat_code : null
},
worklistBuckets() {
return [
{
key: 'cash_owed',
label:
'Cash owed — customer paid, machine dispensed nothing. ' +
'Legs never ran; funds are in the machine wallet.',
icon: 'money_off',
color: 'negative',
rows: this.worklist.cash_owed
},
{
key: 'partial_pending',
label:
'Partial dispense — some notes out, value short. Held whole ' +
'until you record how the shortfall was resolved.',
icon: 'call_split',
color: 'deep-orange',
rows: this.worklist.partial_pending
},
{
key: 'dispense_unreported',
label:
'Unreported — cash-out paid, machine never reported the dispense. ' +
'Check the machine; an old build lands here too.',
icon: 'help_outline',
color: 'amber',
rows: this.worklist.dispense_unreported
},
{
key: 'rejected',
label: 'Rejected — Nostr attribution failed; investigate machine',
icon: 'gpp_bad',
color: 'deep-orange',
rows: this.worklist.rejected
},
{
key: 'errored',
label: 'Errored — needs retry',
icon: 'error',
color: 'red',
rows: this.worklist.errored
},
{
key: 'stuck_pending',
label: 'Stuck pending — listener crashed before processing?',
icon: 'hourglass_top',
color: 'orange',
rows: this.worklist.stuck_pending
},
{
key: 'stuck_processing',
label: 'Stuck processing — processor crashed mid-flight?',
icon: 'sync_problem',
color: 'purple',
rows: this.worklist.stuck_processing
}
]
},
commissionScopeOptions() {
const opts = [{label: 'Default ruleset (operator-wide)', value: null}]
for (const m of this.machines) {
opts.push({
label: `Override: ${m.name || this.shortNpub(m.machine_npub)}`,
value: m.id
})
}
return opts
},
commissionSum() {
return this.commissionLegs.reduce(
(acc, leg) => acc + (Number(leg.fraction) || 0), 0
)
},
commissionSumValid() {
// Allow ZERO legs (empty ruleset = no rules; valid). Else must sum to 1.
if (!this.commissionLegs.length) return true
return Math.abs(this.commissionSum - 1.0) < 0.0001
},
commissionPreview() {
if (!this.commissionLegs.length) return null
// Last-leg-absorbs-rounding mirrors calculations.allocate_operator_split_legs.
const total = this.commissionPreviewInput
let remaining = total
const out = []
this.commissionLegs.forEach((leg, idx) => {
let sats
if (idx === this.commissionLegs.length - 1) {
sats = remaining
} else {
sats = Math.round(total * (Number(leg.fraction) || 0))
remaining -= sats
}
out.push({label: leg.label, sats})
})
return out
},
filteredDeposits() {
let rows = this.deposits
if (this.depositsFilter.status) {
rows = rows.filter(d => d.status === this.depositsFilter.status)
}
if (this.depositsFilter.client_id) {
rows = rows.filter(d => d.client_id === this.depositsFilter.client_id)
}
return rows
},
machinesById() {
const map = {}
for (const m of this.machines) map[m.id] = m
return map
}
},
async created() {
await this.refreshAll()
await this.loadCommissionSplits()
await this.loadWorklist()
},
methods: {
// -----------------------------------------------------------------
// Loaders
// -----------------------------------------------------------------
async refreshAll() {
this.refreshing = true
try {
await Promise.all([
this.loadSuperConfig(),
this.loadMachines(),
this.loadClients(),
this.loadDeposits(),
this.loadWorklistCount()
])
} finally {
this.refreshing = false
}
},
async loadDeposits() {
try {
const {data} = await LNbits.api.request('GET', DEPOSITS_PATH)
this.deposits = data || []
} catch (e) {
this.deposits = []
this._notifyError(e, 'Failed to load deposits')
}
},
depositStatusColor(status) {
return DEPOSIT_STATUS_COLOR[status] || 'grey'
},
clientUsernameById(clientId) {
const c = this.clients.find(x => x.id === clientId)
if (!c) return this.shortId(clientId)
return c.username || this.shortId(c.user_id)
},
async loadClients() {
try {
const {data} = await LNbits.api.request('GET', CLIENTS_PATH)
this.clients = data || []
// N+1 acceptable for fleet sizes ~50; review #11 captures the
// single-grouped-JOIN follow-up (M3).
await Promise.all(
this.clients.map(c => this._loadClientBalance(c.id))
)
} catch (e) {
this.clients = []
this._notifyError(e, 'Failed to load LPs')
}
},
async _loadClientBalance(clientId) {
try {
const {data} = await LNbits.api.request(
'GET', `${CLIENTS_PATH}/${clientId}/balance`
)
this.clientBalances[clientId] = data
} catch (e) {
delete this.clientBalances[clientId]
}
},
machineNameById(machineId) {
const m = this.machinesById[machineId]
if (!m) return this.shortId(machineId)
return m.name || this.shortNpub(m.machine_npub)
},
async loadSuperConfig() {
try {
const {data} = await LNbits.api.request('GET', SUPER_FEE_PATH)
this.superConfig = data
} catch (e) {
this.superConfig = null
}
},
async loadMachines() {
try {
const {data} = await LNbits.api.request('GET', MACHINES_PATH)
this.machines = data || []
} catch (e) {
this.machines = []
this._notifyError(e, 'Failed to load machines')
}
},
async loadWorklistCount() {
// Light read for the tab badge — Worklist tab fetches the full
// payload via loadWorklist when opened.
try {
const {data} = await LNbits.api.request('GET', STUCK_PATH)
this.worklistCount =
(data?.cash_owed?.length || 0) +
(data?.partial_pending?.length || 0) +
(data?.dispense_unreported?.length || 0) +
(data?.rejected?.length || 0) +
(data?.errored?.length || 0) +
(data?.stuck_pending?.length || 0) +
(data?.stuck_processing?.length || 0)
} catch (e) {
this.worklistCount = 0
}
},
async loadWorklist() {
this.worklistLoading = true
try {
const {data} = await LNbits.api.request(
'GET', `${STUCK_PATH}?threshold_minutes=${this.worklistThreshold}`
)
this.worklist.cash_owed = data?.cash_owed || []
this.worklist.partial_pending = data?.partial_pending || []
this.worklist.dispense_unreported = data?.dispense_unreported || []
this.worklist.rejected = data?.rejected || []
this.worklist.errored = data?.errored || []
this.worklist.stuck_pending = data?.stuck_pending || []
this.worklist.stuck_processing = data?.stuck_processing || []
this.worklist.totalCount =
this.worklist.cash_owed.length +
this.worklist.partial_pending.length +
this.worklist.dispense_unreported.length +
this.worklist.rejected.length +
this.worklist.errored.length +
this.worklist.stuck_pending.length +
this.worklist.stuck_processing.length
this.worklistCount = this.worklist.totalCount
} catch (e) {
this._notifyError(e, 'Failed to load worklist')
} finally {
this.worklistLoading = false
}
},
async viewMachineFromWorklist(settlement) {
const machine = this.machinesById[settlement.machine_id]
if (!machine) return
await this.viewMachine(machine)
},
confirmRetryFromWorklist(settlement) {
this.confirmRetrySettlement(settlement)
// Drop from worklist on success (optimistic; reload covers re-eval).
setTimeout(() => this.loadWorklist(), 500)
},
confirmForceResetFromWorklist(settlement) {
this.confirmForceReset(settlement)
setTimeout(() => this.loadWorklist(), 500)
},
// -----------------------------------------------------------------
// Super-fee edit (P9f — super-only)
// -----------------------------------------------------------------
openSuperFeeDialog() {
this.superFeeDialog.data = {
super_cash_in_fee_fraction:
this.superConfig?.super_cash_in_fee_fraction ?? 0,
super_cash_out_fee_fraction:
this.superConfig?.super_cash_out_fee_fraction ?? 0,
super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || '',
max_cash_in_sats: this.superConfig?.max_cash_in_sats ?? null
}
this.superFeeDialog.show = true
},
// Guard the decimal-vs-percent trap shared by the super + operator fee
// forms: fees are decimal fractions (3% = 0.03), capped at 0.15. A value
// > 0.15 almost always means a percent was typed (3 instead of 0.03).
// Returns false + shows a clear toast so the operator never sees a raw 400.
_assertFeesDecimal(...fracs) {
if (fracs.some((v) => !Number.isFinite(v) || v < 0 || v > 0.15)) {
Quasar.Notify.create({
type: 'negative',
message: 'Enter each fee as a decimal fraction (e.g. 3% = 0.03)',
caption:
'Range 0–0.15. A value above 0.15 usually means a percent was typed (3 instead of 0.03).'
})
return false
}
return true
},
async submitSuperFee() {
const d = this.superFeeDialog.data
if (!this._assertFeesDecimal(
Number(d.super_cash_in_fee_fraction),
Number(d.super_cash_out_fee_fraction)
)) return
// Blank cap field -> null (the PUT skips null, so the existing value is
// preserved rather than cleared — set 0 to reject every cash-in).
const cap =
d.max_cash_in_sats === '' ||
d.max_cash_in_sats === null ||
d.max_cash_in_sats === undefined
? null
: Number(d.max_cash_in_sats)
if (cap !== null && (!Number.isInteger(cap) || cap < 0)) {
Quasar.Notify.create({
type: 'negative',
message: 'Max cash-in must be a non-negative whole number of sats'
})
return
}
this.superFeeDialog.saving = true
try {
const {data} = await LNbits.api.request(
'PUT', SUPER_FEE_PATH, null,
{
super_cash_in_fee_fraction: Number(d.super_cash_in_fee_fraction),
super_cash_out_fee_fraction: Number(d.super_cash_out_fee_fraction),
super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null,
max_cash_in_sats: cap
}
)
this.superConfig = data
this.superFeeDialog.show = false
Quasar.Notify.create({type: 'positive', message: 'Platform fee updated'})
} catch (e) {
this._notifyError(e, 'Save failed')
} finally {
this.superFeeDialog.saving = false
}
},
// -----------------------------------------------------------------
// Reports / CSV exports (P9g)
// -----------------------------------------------------------------
downloadMachinesCsv() {
this._downloadCsv(
'machines.csv',
['id', 'machine_npub', 'wallet_id', 'name', 'location', 'fiat_code',
'is_active', 'created_at'],
this.machines
)
},
downloadClientsCsv() {
const rows = this.clients.map(c => {
const bal = this.clientBalances[c.id] || {}
return {
...c,
machine_name: this.machineNameById(c.machine_id),
remaining_balance: bal.remaining_balance ?? '',
total_deposits: bal.total_deposits ?? '',
total_payments: bal.total_payments ?? '',
balance_currency: bal.currency ?? ''
}
})
this._downloadCsv(
'clients.csv',
['id', 'machine_id', 'machine_name', 'user_id',
'username', 'lp_onboarded', 'status',
'total_deposits', 'total_payments',
'remaining_balance', 'balance_currency', 'created_at'],
rows
)
},
downloadDepositsCsv() {
this._downloadCsv(
'deposits.csv',
['id', 'client_id', 'machine_id', 'creator_user_id', 'amount',
'currency', 'status', 'notes', 'created_at', 'confirmed_at'],
this.deposits
)
},
async downloadPaymentsCsv() {
// Payments are not pre-loaded; fetch on demand.
this.reportsBusy = true
try {
const {data} = await LNbits.api.request('GET', `${API}/payments`)
this._downloadCsv(
'payments.csv',
['id', 'settlement_id', 'client_id', 'machine_id', 'leg_type',
'destination_wallet_id', 'destination_ln_address', 'amount_sats',
'amount_fiat', 'exchange_rate', 'status', 'external_payment_hash',
'transaction_time', 'created_at', 'error_message'],
data || []
)
} catch (e) {
this._notifyError(e, 'Failed to fetch payments')
} finally {
this.reportsBusy = false
}
},
_downloadCsv(filename, columns, rows) {
const escape = v => {
if (v == null) return ''
const s = String(v)
if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"'
return s
}
const header = columns.join(',')
const body = rows.map(
row => columns.map(col => escape(row[col])).join(',')
).join('\n')
const csv = header + '\n' + body
const blob = new Blob([csv], {type: 'text/csv;charset=utf-8'})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
},
// -----------------------------------------------------------------
// Add machine
// -----------------------------------------------------------------
openAddMachineDialog() {
this.addMachineDialog.data = this._emptyMachineForm()
this.addMachineDialog.show = true
},
async submitAddMachine() {
const body = this._cleanMachineForm(this.addMachineDialog.data)
if (!body.wallet_id) {
Quasar.Notify.create({
type: 'negative',
message: 'A wallet is required'
})
return
}
if (!this._assertFeesDecimal(
Number(body.operator_cash_in_fee_fraction) || 0,
Number(body.operator_cash_out_fee_fraction) || 0
)) return
this.addMachineDialog.saving = true
try {
const {data} = await LNbits.api.request('POST', MACHINES_PATH, null, body)
this.machines.unshift(data)
this.addMachineDialog.show = false
Quasar.Notify.create({
type: 'positive',
message: `Machine ${data.name || (data.machine_npub || 'unpaired').slice(0, 12)} added`
})
} catch (e) {
this._notifyError(e, 'Failed to add machine')
} finally {
this.addMachineDialog.saving = false
}
},
// -----------------------------------------------------------------
// Edit / delete machine
// -----------------------------------------------------------------
openEditMachineDialog(machine) {
this.editMachineDialog.data = {
id: machine.id,
name: machine.name || '',
location: machine.location || '',
wallet_id: machine.wallet_id,
fiat_code: machine.fiat_code,
is_active: machine.is_active,
operator_cash_in_fee_fraction: machine.operator_cash_in_fee_fraction ?? 0,
operator_cash_out_fee_fraction: machine.operator_cash_out_fee_fraction ?? 0
}
this.editMachineDialog.show = true
},
async submitEditMachine() {
const d = this.editMachineDialog.data
if (!this._assertFeesDecimal(
Number(d.operator_cash_in_fee_fraction) || 0,
Number(d.operator_cash_out_fee_fraction) || 0
)) return
this.editMachineDialog.saving = true
try {
const {data} = await LNbits.api.request(
'PUT',
`${MACHINES_PATH}/${d.id}`,
null,
{
name: d.name,
location: d.location,
wallet_id: d.wallet_id,
fiat_code: d.fiat_code,
is_active: d.is_active,
operator_cash_in_fee_fraction: Number(d.operator_cash_in_fee_fraction) || 0,
operator_cash_out_fee_fraction: Number(d.operator_cash_out_fee_fraction) || 0
}
)
const idx = this.machines.findIndex(m => m.id === data.id)
if (idx >= 0) this.machines[idx] = data
this.editMachineDialog.show = false
Quasar.Notify.create({type: 'positive', message: 'Machine updated'})
} catch (e) {
this._notifyError(e, 'Failed to update machine')
} finally {
this.editMachineDialog.saving = false
}
},
confirmDeleteMachine(machine) {
Quasar.Dialog.create({
title: 'Delete machine?',
message:
`This removes <b>${machine.name || (machine.machine_npub || 'unpaired').slice(0, 12)}</b>` +
' from your fleet. Existing settlements and payment history are preserved' +
' — only the machine row itself is removed. Continue?',
html: true,
cancel: true,
persistent: true
}).onOk(async () => {
try {
await LNbits.api.request('DELETE', `${MACHINES_PATH}/${machine.id}`)
this.machines = this.machines.filter(m => m.id !== machine.id)
Quasar.Notify.create({type: 'positive', message: 'Machine deleted'})
} catch (e) {
this._notifyError(e, 'Failed to delete machine')
}
})
},
// -----------------------------------------------------------------
// Pair / revoke spire (S0 / #9, #12)
// -----------------------------------------------------------------
async openPairDialog(machine) {
this.pairDialog.machine = machine
this.pairDialog.relays = ''
this.pairDialog.durationHours = null
this.pairDialog.result = null
this.pairDialog.show = true
// Pre-fill with this lnbits' default relay (the relay its nostr transport
// listens on, derived from lnbits_baseurl). The operator can override or
// clear it; if left blank the backend fills in the same default.
try {
const {data} = await LNbits.api.request('GET', `${API}/default-relay`)
if (data && data.relay) this.pairDialog.relays = data.relay
} catch (e) {
// Non-fatal: leave blank; the backend still defaults on submit.
}
},
async submitPair() {
const relays = (this.pairDialog.relays || '')
.split(/[\s,]+/)
.map(s => s.trim())
.filter(Boolean)
// relays is optional — blank falls back to the transport relay
// server-side (the pre-filled default), so no client-side requirement.
const body = {}
if (relays.length) {
body.relays = relays
}
if (this.pairDialog.durationHours) {
body.duration_hours = Number(this.pairDialog.durationHours)
}
this.pairDialog.saving = true
try {
const {data} = await LNbits.api.request(
'POST',
`${MACHINES_PATH}/${this.pairDialog.machine.id}/pair`,
null,
body
)
this.pairDialog.result = data
// The bunker-minted key becomes the machine identity; reflect it +
// the paired state in the row immediately.
const m = this.machines.find(x => x.id === this.pairDialog.machine.id)
if (m) {
m.machine_npub = data.spire_pubkey_hex
m.bunker_spire_key_name = data.bunker_key_name
m.paired_at = new Date().toISOString()
}
Quasar.Notify.create({
type: 'positive',
message: 'Spire paired — hand the seed URL to the device'
})
} catch (e) {
this._notifyError(e, 'Pairing failed')
} finally {
this.pairDialog.saving = false
}
},
confirmRevokeMachine(machine) {
Quasar.Dialog.create({
title: 'Revoke spire access?',
message:
`This cuts <b>${machine.name || (machine.machine_npub || 'unpaired').slice(0, 12)}</b>'s` +
' signing access at the bunker — the spire can no longer submit' +
' cash-outs until you re-pair it. Continue?',
html: true,
cancel: true,
persistent: true
}).onOk(async () => {
try {
const {data} = await LNbits.api.request(
'POST',
`${MACHINES_PATH}/${machine.id}/revoke`,
null
)
const m = this.machines.find(x => x.id === machine.id)
if (m) m.paired_at = null
Quasar.Notify.create({
type: data.revoked_count >= 1 ? 'positive' : 'warning',
message:
data.revoked_count >= 1
? 'Spire access revoked'
: 'Nothing was bound (the spire never connected)'
})
} catch (e) {
this._notifyError(e, 'Revoke failed')
}
})
},
// -----------------------------------------------------------------
// Machine detail dialog (P9b)
// -----------------------------------------------------------------
async viewMachine(machine) {
this.machineDetail.machine = machine
this.machineDetail.settlements = []
this.machineDetail.cassettes = []
this.machineDetail.cassetteOps = []
this.machineDetail.cassettesError = null
this.machineDetail.activeTab = 'settlements'
this.machineDetail.show = true
await this.reloadMachineDetail()
},
async reloadMachineDetail() {
if (!this.machineDetail.machine) return
this.machineDetail.loading = true
try {
const {data} = await LNbits.api.request(
'GET',
`${MACHINES_PATH}/${this.machineDetail.machine.id}/settlements`
)
this.machineDetail.settlements = data || []
} catch (e) {
this._notifyError(e, 'Failed to load settlements')
} finally {
this.machineDetail.loading = false
}
// Cassettes load in parallel; UI only renders them when the tab
// is active, but pre-loading means no flicker on tab switch.
await this.loadMachineCassettes()
},
// -----------------------------------------------------------------
// Cassette inventory + operations (v2, bitspire ADR-004)
// -----------------------------------------------------------------
async loadMachineCassettes() {
if (!this.machineDetail.machine) return
this.machineDetail.cassettesLoading = true
this.machineDetail.cassettesError = null
const base = `${MACHINES_PATH}/${this.machineDetail.machine.id}`
try {
// The machine row is re-read too: counts_uncertain_since lives on it
// and is set by the consumer as state events land, so the row the
// machines table handed us goes stale while this dialog is open.
const [machine, bays, ops] = await Promise.all([
LNbits.api.request('GET', base),
LNbits.api.request('GET', `${base}/cassettes`),
LNbits.api.request('GET', `${base}/cassettes/ops`)
])
if (machine.data) this.machineDetail.machine = machine.data
this.machineDetail.cassettes = bays.data || []
this.machineDetail.cassetteOps = ops.data || []
} catch (e) {
this._notifyError(e, 'Failed to load cassettes')
} finally {
this.machineDetail.cassettesLoading = false
}
},
cassetteOpIcon(opType) {
return (
{
refill: 'add_circle_outline',
empty: 'remove_circle_outline',
recount: 'fact_check',
set_denomination: 'sell'
}[opType] || 'help_outline'
)
},
cassetteOpSummary(op) {
const fiat = (this.machineDetail.machine || {}).fiat_code || ''
const bay = `Bay ${op.position}`
if (op.op_type === 'refill') return `${bay} — added ${op.bills} notes`
if (op.op_type === 'empty') return `${bay} — emptied`
if (op.op_type === 'recount') return `${bay} — recounted to ${op.count}`
if (op.op_type === 'set_denomination') {
return `${bay} — denomination set to ${op.denomination} ${fiat}`.trim()
}
return `${bay} — ${op.op_type}`
},
openCassetteOpDialog(position) {
const bays = this.machineDetail.cassettes
if (!bays.length) return
Object.assign(this.cassetteOpDialog, {
show: true,
saving: false,
error: null,
position: position || bays[0].position,
op_type: 'refill',
bills: null,
count: null,
denomination: null
})
},
resetCassetteOpValue() {
// Each type carries exactly one value field and the server rejects an
// op that carries a foreign one. Clearing on every type switch means a
// number typed under the previous type can't ride along invisibly.
Object.assign(this.cassetteOpDialog, {
bills: null,
count: null,
denomination: null,
error: null
})
},
async submitCassetteOp() {
const d = this.cassetteOpDialog
if (!this.cassetteOpIsComplete) return
const payload = {position: Number(d.position), op_type: d.op_type}
if (d.op_type === 'refill') payload.bills = Number(d.bills)
if (d.op_type === 'recount') payload.count = Number(d.count)
if (d.op_type === 'set_denomination') {
payload.denomination = Number(d.denomination)
}
d.saving = true
d.error = null
try {
await LNbits.api.request(
'POST',
`${MACHINES_PATH}/${this.machineDetail.machine.id}/cassettes/ops`,
null,
payload
)
d.show = false
Quasar.Notify.create({
type: 'positive',
message: 'Operation recorded and published to the ATM'
})
await this.loadMachineCassettes()
} catch (e) {
const detail =
(e && e.response && e.response.data && e.response.data.detail) ||
'Could not record the operation'
// A 503 means the op IS recorded and will ride out with the next
// publish, so reload either way — the list should show it pending.
d.error = detail
this._notifyError(e, 'Operation failed')
await this.loadMachineCassettes()
} finally {
d.saving = false
}
},
settlementStatusColor(status) {
return SETTLEMENT_STATUS_COLOR[status] || 'grey'
},
txTypeChip(txType) {
// Direction at the ATM (business semantics), not at the operator's
// wallet (Lightning protocol semantics). See the canonical mapping
// in tasks.py:_handle_payment — cash_out ↔ inbound Lightning,
// cash_in ↔ outbound Lightning.
if (txType === 'cash_in') {
return {
color: 'orange-8',
icon: 'north_east',
label: 'cash-in',
tooltip:
'Cash-in: customer deposited fiat at the ATM, operator wallet ' +
'sent sats (LNURL-withdraw). No DCA distribution; liquidity ' +
'stays in the operator wallet.'
}
}
// Default to cash_out — both the only direction shipped pre-S8 and
// the safer "unknown means cash_out" fallback for legacy rows.
return {
color: 'green-8',
icon: 'south_west',
label: 'cash-out',
tooltip:
'Cash-out: customer paid the ATM\'s invoice in BTC, operator ' +
'wallet received sats. Principal is distributed to LPs.'
}
},
// -----------------------------------------------------------------
// Settlement actions: retry, partial-dispense, force-reset, note
// -----------------------------------------------------------------
confirmRetrySettlement(settlement) {
Quasar.Dialog.create({
title: 'Retry distribution?',
message:
'Voids any failed legs and re-runs the distribution chain. ' +
'Completed legs are never re-paid.',
cancel: true,
persistent: true
}).onOk(async () => {
try {
const {data} = await LNbits.api.request(
'POST',
`${SETTLEMENTS_PATH}/${settlement.id}/retry`
)
this._replaceSettlement(data)
Quasar.Notify.create({
type: 'positive',
message: `Settlement ${this.shortId(settlement.id)} re-run`
})
} catch (e) {
this._notifyError(e, 'Retry failed')
}
})
},
confirmForceReset(settlement) {
Quasar.Dialog.create({
title: 'Force-reset stuck settlement?',
message:
`Flips status '${settlement.status}' → 'errored' so you can then ` +
'retry. Only use if the processor truly crashed mid-flight — fresh ' +
'settlements are refused (default 30-minute age guard).',
cancel: true,
persistent: true
}).onOk(async () => {
try {
const {data} = await LNbits.api.request(
'POST',
`${SETTLEMENTS_PATH}/${settlement.id}/force-reset`
)
this._replaceSettlement(data)
Quasar.Notify.create({
type: 'warning',
message: `Settlement marked errored — hit Retry next`
})
} catch (e) {
this._notifyError(e, 'Force-reset failed')
}
})
},
openPartialDispense(settlement) {
this.partialDispenseDialog.settlement = settlement
this.partialDispenseDialog.mode = 'fraction'
// ADR-005: pre-fill from the machine's report — the hardware's own count
// of what left — so the operator confirms a number rather than typing one.
const dispensedCents = settlement.dispensed_fiat_cents
const fiat = Number(settlement.fiat_amount)
this.partialDispenseDialog.dispensed_fraction =
dispensedCents != null && fiat > 0
? Math.round((dispensedCents / 100 / fiat) * 10000) / 10000
: null
this.partialDispenseDialog.dispensed_sats = null
this.partialDispenseDialog.notes = settlement.dispense_error
? `Machine reported: ${settlement.dispense_error_code || ''} ${settlement.dispense_raw_code || ''} — ${settlement.dispense_error}`.trim()
: ''
this.partialDispenseDialog.show = true
},
// ADR-005 §5 — release a machine's cash-out hold after a terminal
// dispenser fault, when the jam was cleared without a recount.
confirmResumeCashOut(machine) {
Quasar.Dialog.create({
title: 'Resume cash-out?',
message:
'The machine latched cash-out off after a dispenser fault' +
(machine.cash_out_held_code ? ` (${machine.cash_out_held_code})` : '') +
'. Only do this after the transport path has been physically cleared. ' +
'A recount releases the hold too, and also fixes the bay count.',
cancel: true,
persistent: true
}).onOk(async () => {
try {
await LNbits.api.request(
'POST',
`/spirekeeper/api/v1/dca/machines/${machine.id}/resume-cash-out`
)
Quasar.Notify.create({
type: 'positive',
message: 'Resume published — the machine clears the hold on receipt'
})
if (this.machineDetail && this.machineDetail.machine) await this.reloadMachineDetail()
} catch (e) {
this._notifyError(e, 'Resume cash-out failed')
}
})
},
async submitPartialDispense() {
const d = this.partialDispenseDialog
const body = {notes: d.notes || null}
if (d.mode === 'fraction') {
body.dispensed_fraction = Number(d.dispensed_fraction)
} else {
body.dispensed_sats = Number(d.dispensed_sats)
}
d.saving = true
try {
const {data} = await LNbits.api.request(
'POST',
`${SETTLEMENTS_PATH}/${d.settlement.id}/partial-dispense`,
null,
body
)
this._replaceSettlement(data)
d.show = false
Quasar.Notify.create({
type: 'positive',
message: 'Partial dispense applied; distribution re-running'
})
} catch (e) {
this._notifyError(e, 'Partial dispense failed')
} finally {
d.saving = false
}
},
openSettlementNote(settlement) {
this.noteDialog.settlement = settlement
this.noteDialog.note = ''
this.noteDialog.show = true
},
async submitNote() {
const d = this.noteDialog
if (!d.note || !d.note.trim()) return
d.saving = true
try {
const {data} = await LNbits.api.request(
'POST',
`${SETTLEMENTS_PATH}/${d.settlement.id}/notes`,
null,
{note: d.note.trim()}
)
this._replaceSettlement(data)
d.show = false
Quasar.Notify.create({type: 'positive', message: 'Note added'})
} catch (e) {
this._notifyError(e, 'Failed to add note')
} finally {
d.saving = false
}
},
// -----------------------------------------------------------------
// Client (LP) management (P9c)
// -----------------------------------------------------------------
openAddClientDialog() {
this.clientDialog.mode = 'add'
this.clientDialog.data = this._emptyClientForm()
this.clientDialog.show = true
},
openEditClientDialog(client) {
this.clientDialog.mode = 'edit'
this.clientDialog.data = {
id: client.id,
machine_id: client.machine_id,
user_id: client.user_id,
username: client.username || '',
status: client.status
}
this.clientDialog.show = true
},
async submitClient() {
const d = this.clientDialog.data
this.clientDialog.saving = true
try {
if (this.clientDialog.mode === 'add') {
const body = this._cleanClientCreate(d)
const {data} = await LNbits.api.request('POST', CLIENTS_PATH, null, body)
this.clients.unshift(data)
await this._loadClientBalance(data.id)
Quasar.Notify.create({type: 'positive', message: 'LP registered'})
} else {
const body = this._cleanClientUpdate(d)
const {data} = await LNbits.api.request(
'PUT', `${CLIENTS_PATH}/${d.id}`, null, body
)
const idx = this.clients.findIndex(c => c.id === data.id)
if (idx >= 0) this.clients[idx] = data
Quasar.Notify.create({type: 'positive', message: 'LP updated'})
}
this.clientDialog.show = false
} catch (e) {
this._notifyError(e, 'Save failed')
} finally {
this.clientDialog.saving = false
}
},
confirmDeleteClient(client) {
Quasar.Dialog.create({
title: 'Delete LP?',
message:
`Remove <b>${client.username || this.shortId(client.user_id)}</b> from this machine. ` +
'Their existing deposits and payment history are preserved — only the registration row goes. Continue?',
html: true,
cancel: true,
persistent: true
}).onOk(async () => {
try {
await LNbits.api.request('DELETE', `${CLIENTS_PATH}/${client.id}`)
this.clients = this.clients.filter(c => c.id !== client.id)
delete this.clientBalances[client.id]
Quasar.Notify.create({type: 'positive', message: 'LP deleted'})
} catch (e) {
this._notifyError(e, 'Delete failed')
}
})
},
// -----------------------------------------------------------------
// Deposits (P9d)
// -----------------------------------------------------------------
openAddDepositDialog() {
this.depositDialog.mode = 'add'
this.depositDialog.data = this._emptyDepositForm()
this.depositDialog.show = true
},
openEditDepositDialog(deposit) {
this.depositDialog.mode = 'edit'
this.depositDialog.data = {
id: deposit.id,
client_id: deposit.client_id,
amount: deposit.amount,
notes: deposit.notes || ''
}
this.depositDialog.show = true
},
async submitDeposit() {
const d = this.depositDialog.data
this.depositDialog.saving = true
try {
if (this.depositDialog.mode === 'add') {
// machine_id is server-cross-checked but we send it explicitly.
// currency is server-resolved from the machine's fiat_code
// (#26); not in the request body.
const client = this.clients.find(c => c.id === d.client_id)
if (!client) throw new Error('client not found')
const body = {
client_id: d.client_id,
machine_id: client.machine_id,
amount: Number(d.amount),
notes: (d.notes || '').trim() || null
}
const {data} = await LNbits.api.request('POST', DEPOSITS_PATH, null, body)
this.deposits.unshift(data)
Quasar.Notify.create({type: 'positive', message: 'Deposit recorded'})
} else {
const body = {
amount: Number(d.amount),
notes: (d.notes || '').trim() || null
}
const {data} = await LNbits.api.request(
'PUT', `${DEPOSITS_PATH}/${d.id}`, null, body
)
this._replaceDeposit(data)
Quasar.Notify.create({type: 'positive', message: 'Deposit updated'})
}
this.depositDialog.show = false
} catch (e) {
this._notifyError(e, 'Save failed')
} finally {
this.depositDialog.saving = false
}
},
confirmDepositStatus(deposit, newStatus) {
const verb = newStatus === 'confirmed' ? 'Confirm' : 'Reject'
Quasar.Dialog.create({
title: `${verb} deposit?`,
message:
newStatus === 'confirmed'
? `Confirming will count this toward the LP's DCA balance.`
: `Rejecting marks it ignored; it won't affect balances.`,
cancel: true,
persistent: true
}).onOk(async () => {
try {
const {data} = await LNbits.api.request(
'PUT',
`${DEPOSITS_PATH}/${deposit.id}/status`,
null,
{status: newStatus, notes: deposit.notes || null}
)
this._replaceDeposit(data)
// Confirming changes the LP balance — refresh it.
if (newStatus === 'confirmed') {
await this._loadClientBalance(deposit.client_id)
}
Quasar.Notify.create({
type: 'positive',
message: `Deposit ${newStatus}`
})
} catch (e) {
this._notifyError(e, 'Status update failed')
}
})
},
openRejectDepositDialog(deposit) {
this.rejectDepositDialog.deposit = deposit
this.rejectDepositDialog.notes = ''
this.rejectDepositDialog.show = true
},
async submitRejectDeposit() {
const d = this.rejectDepositDialog
d.saving = true
try {
const {data} = await LNbits.api.request(
'PUT',
`${DEPOSITS_PATH}/${d.deposit.id}/status`,
null,
{status: 'rejected', notes: d.notes || null}
)
this._replaceDeposit(data)
d.show = false
Quasar.Notify.create({type: 'positive', message: 'Deposit rejected'})
} catch (e) {
this._notifyError(e, 'Reject failed')
} finally {
d.saving = false
}
},
confirmDeleteDeposit(deposit) {
Quasar.Dialog.create({
title: 'Delete deposit?',
message: 'Only pending deposits can be deleted.',
cancel: true,
persistent: true
}).onOk(async () => {
try {
await LNbits.api.request('DELETE', `${DEPOSITS_PATH}/${deposit.id}`)
this.deposits = this.deposits.filter(x => x.id !== deposit.id)
Quasar.Notify.create({type: 'positive', message: 'Deposit deleted'})
} catch (e) {
this._notifyError(e, 'Delete failed')
}
})
},
_replaceDeposit(updated) {
if (!updated) return
const idx = this.deposits.findIndex(d => d.id === updated.id)
if (idx >= 0) this.deposits[idx] = updated
},
// -----------------------------------------------------------------
// Commission splits editor (P9e)
// -----------------------------------------------------------------
async loadCommissionSplits() {
const params = this.commissionScope
? `?machine_id=${this.commissionScope}`
: ''
try {
const {data} = await LNbits.api.request(
'GET', `${COMMISSION_SPLITS_PATH}${params}`
)
// targetKind is a UI-only hint derived from the stored target string.
// It's not persisted server-side; the server resolves the target
// at payment time regardless.
this.commissionLegs = (data || []).map(leg => ({
target: leg.target || '',
targetKind: this._inferTargetKind(leg.target),
label: leg.label || '',
fraction: Number(leg.fraction) || 0
}))
} catch (e) {
this.commissionLegs = []
this._notifyError(e, 'Failed to load commission splits')
}
},
_inferTargetKind(target) {
// If the value matches one of the operator's own wallet ids, render
// the row in 'wallet' mode (q-select). Otherwise treat as external
// (free-text q-input).
if (!target) return 'wallet'
const ownIds = new Set(this.walletOptions.map(w => w.value))
return ownIds.has(target) ? 'wallet' : 'external'
},
addCommissionLeg() {
this.commissionLegs.push({
target: this.walletOptions[0]?.value || '',
targetKind: 'wallet',
label: '',
fraction: 0
})
},
async saveCommissionSplits() {
if (!this.commissionSumValid) {
Quasar.Notify.create({
type: 'negative',
message: 'Legs must sum to 100% before saving'
})
return
}
const body = {
machine_id: this.commissionScope,
legs: this.commissionLegs.map((leg, idx) => ({
target: (leg.target || '').toString().trim(),
label: leg.label || null,
fraction: Number(leg.fraction),
sort_order: idx
}))
}
this.commissionSaving = true
try {
await LNbits.api.request('PUT', COMMISSION_SPLITS_PATH, null, body)
await this.loadCommissionSplits()
Quasar.Notify.create({type: 'positive', message: 'Saved'})
} catch (e) {
this._notifyError(e, 'Save failed')
} finally {
this.commissionSaving = false
}
},
confirmDeleteCommissionOverride() {
Quasar.Dialog.create({
title: 'Remove per-machine override?',
message:
'The default operator ruleset will apply to this machine again. ' +
'No legs are deleted from your default.',
cancel: true,
persistent: true
}).onOk(async () => {
const params = `?machine_id=${this.commissionScope}`
try {
await LNbits.api.request(
'DELETE', `${COMMISSION_SPLITS_PATH}${params}`
)
await this.loadCommissionSplits()
Quasar.Notify.create({type: 'positive', message: 'Override removed'})
} catch (e) {
this._notifyError(e, 'Remove failed')
}
})
},
// -----------------------------------------------------------------
// Settle balance (P3e — closes #4)
// -----------------------------------------------------------------
async openSettleBalanceDialog(client) {
this.settleBalanceDialog.client = client
this.settleBalanceDialog.balance = this.clientBalances[client.id] || null
this.settleBalanceDialog.data = {
funding_wallet_id: null,
exchange_rate: null,
amount_fiat: null,
notes: ''
}
// Refresh balance to make sure we're showing the latest before settling.
await this._loadClientBalance(client.id)
this.settleBalanceDialog.balance = this.clientBalances[client.id] || null
this.settleBalanceDialog.show = true
},
async submitSettleBalance() {
const d = this.settleBalanceDialog
const body = {
funding_wallet_id: d.data.funding_wallet_id,
exchange_rate: Number(d.data.exchange_rate),
amount_fiat: d.data.amount_fiat ? Number(d.data.amount_fiat) : null,
notes: d.data.notes || null
}
d.saving = true
try {
await LNbits.api.request(
'POST',
`${CLIENTS_PATH}/${d.client.id}/settle`,
null,
body
)
// Refresh this client's balance so the table reflects the new remaining.
await this._loadClientBalance(d.client.id)
d.show = false
Quasar.Notify.create({type: 'positive', message: 'Balance settled'})
} catch (e) {
this._notifyError(e, 'Settle failed')
} finally {
d.saving = false
}
},
_replaceSettlement(updated) {
if (!updated) return
const idx = this.machineDetail.settlements.findIndex(
s => s.id === updated.id
)
if (idx >= 0) this.machineDetail.settlements[idx] = updated
},
// -----------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------
shortNpub(npub) {
if (!npub) return 'unpaired'
if (npub.length <= 16) return npub
return npub.slice(0, 8) + '…' + npub.slice(-6)
},
shortId(id) {
if (!id) return ''
return id.length <= 12 ? id : id.slice(0, 8) + '…'
},
formatSats(n) {
if (n == null) return '—'
return Number(n).toLocaleString()
},
formatFiat(amount, code) {
if (amount == null) return '—'
return `${Number(amount).toFixed(2)} ${code || ''}`.trim()
},
formatTime(ts) {
if (!ts) return ''
const d = new Date(ts)
if (isNaN(d.getTime())) return String(ts)
return d.toLocaleString()
},
copy(text) {
if (!text) return
Quasar.copyToClipboard(text).then(() => {
Quasar.Notify.create({type: 'info', message: 'Copied', timeout: 800})
})
},
// Open a fullscreen QR sized to nearly the full shorter viewport edge
// so it renders as large as possible for a spire's camera. The QR's
// wrapper caps at 96vw, so an over-estimate here is fine — it just
// means "fill the width". Feeds the lnbits-qrcode `max-width` prop.
openQrZoom(value) {
if (!value) return
const edge = Math.min(window.innerWidth, window.innerHeight)
this.qrZoom.size = Math.max(240, Math.floor(edge - 16))
this.qrZoom.value = value
this.qrZoom.show = true
},
_emptyDepositForm() {
// currency is server-resolved from the selected client's machine
// fiat_code (see #26); not stored on the form, just displayed in
// the dialog via depositMachineFiatCode() computed.
return {
client_id: null,
amount: null,
notes: ''
}
},
_emptyClientForm() {
// Operator-side LP enrolment is just (machine, user, optional
// display name). Wallet / mode / autoforward are LP-controlled
// via satmachineclient — operator can't pick or change them.
return {
machine_id: null,
user_id: '',
username: '',
status: 'active'
}
},
_cleanClientCreate(d) {
return {
machine_id: d.machine_id,
user_id: (d.user_id || '').trim(),
username: (d.username || '').trim() || null
}
},
_cleanClientUpdate(d) {
return {
username: (d.username || '').trim() || null,
status: d.status
}
},
_emptyMachineForm() {
return {
machine_npub: '',
wallet_id: null,
name: '',
location: '',
fiat_code: 'GTQ',
operator_cash_in_fee_fraction: 0,
operator_cash_out_fee_fraction: 0
}
},
_cleanMachineForm(d) {
return {
machine_npub: (d.machine_npub || '').trim() || null,
wallet_id: d.wallet_id,
name: (d.name || '').trim() || null,
location: (d.location || '').trim() || null,
fiat_code: (d.fiat_code || 'GTQ').trim(),
operator_cash_in_fee_fraction: Number(d.operator_cash_in_fee_fraction) || 0,
operator_cash_out_fee_fraction: Number(d.operator_cash_out_fee_fraction) || 0
}
},
_notifyError(err, fallback) {
const msg = err?.response?.data?.detail || err?.message || fallback
Quasar.Notify.create({type: 'negative', message: msg, timeout: 5000})
}
}
})