spirekeeper/static/js/index.js
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

1666 lines
55 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: {
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
},
machineDetail: {
show: false,
loading: false,
machine: null,
settlements: [],
// Cassettes sub-tab state (#29 v1) — see openCassettePublishConfirm /
// submitCassettePublish methods + the cassettes panel in
// templates/spirekeeper/index.html.
activeTab: 'settlements',
cassetteEdits: [], // editable working copy of cassette_configs rows
cassettesPristine: [], // last-known-clean snapshot for revert
cassettesLoading: false,
cassettesPublishing: false,
cassettesDirty: 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', label: 'ATM-reported', field: 'state_denomination', align: 'right'},
{name: 'updated_at', label: 'Updated', field: 'updated_at', align: 'left'}
],
pagination: {rowsPerPage: 0} // hide pagination — cassette count is small
},
cassettePublishConfirm: {
show: false
},
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: {
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: '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?.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.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.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 00.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)
// -----------------------------------------------------------------
openPairDialog(machine) {
this.pairDialog.machine = machine
this.pairDialog.relays = ''
this.pairDialog.durationHours = null
this.pairDialog.result = null
this.pairDialog.show = true
},
async submitPair() {
const relays = (this.pairDialog.relays || '')
.split(/[\s,]+/)
.map(s => s.trim())
.filter(Boolean)
if (!relays.length) {
Quasar.Notify.create({
type: 'negative',
message: 'At least one relay is required'
})
return
}
const body = {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.cassetteEdits = []
this.machineDetail.cassettesPristine = []
this.machineDetail.cassettesDirty = false
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 (#29 v1)
// -----------------------------------------------------------------
async loadMachineCassettes() {
if (!this.machineDetail.machine) return
this.machineDetail.cassettesLoading = true
this.machineDetail.cassettesError = null
try {
const {data} = await LNbits.api.request(
'GET',
`${MACHINES_PATH}/${this.machineDetail.machine.id}/cassettes`
)
const rows = (data || []).map(row => ({...row, _dirty: false}))
this.machineDetail.cassetteEdits = rows
this.machineDetail.cassettesPristine = JSON.parse(JSON.stringify(rows))
this.machineDetail.cassettesDirty = false
} catch (e) {
this._notifyError(e, 'Failed to load cassettes')
} finally {
this.machineDetail.cassettesLoading = false
}
},
markCassetteDirty(row) {
// Find pristine match by position (the row identity) and compare;
// flip _dirty + overall dirty flag accordingly. Editable fields
// are denomination + count; position is the immutable row key.
const pristine = this.machineDetail.cassettesPristine.find(
p => p.position === row.position
)
row._dirty =
!pristine ||
Number(row.denomination) !== Number(pristine.denomination) ||
Number(row.count) !== Number(pristine.count)
this.machineDetail.cassettesDirty =
this.machineDetail.cassetteEdits.some(r => r._dirty)
},
revertCassetteEdits() {
this.machineDetail.cassetteEdits = JSON.parse(
JSON.stringify(this.machineDetail.cassettesPristine)
)
this.machineDetail.cassettesDirty = false
this.machineDetail.cassettesError = null
},
openCassettePublishConfirm() {
if (!this.machineDetail.cassettesDirty) return
this.machineDetail.cassettesError = null
this.cassettePublishConfirm.show = true
},
async submitCassettePublish() {
// Build the PublishCassettesPayload shape (v1.1, position-keyed):
// { positions: { "<pos>": { denomination, count }, ... } }
// The API enforces the position set matches what's stored —
// since we only edit existing rows, this should always pass.
const positions = {}
for (const row of this.machineDetail.cassetteEdits) {
positions[String(row.position)] = {
denomination: Number(row.denomination),
count: Number(row.count)
}
}
const payload = {positions}
this.machineDetail.cassettesPublishing = true
try {
const {data} = await LNbits.api.request(
'POST',
`${MACHINES_PATH}/${this.machineDetail.machine.id}/cassettes/publish`,
null,
payload
)
const fresh = (data || []).map(r => ({...r, _dirty: false}))
this.machineDetail.cassetteEdits = fresh
this.machineDetail.cassettesPristine = JSON.parse(JSON.stringify(fresh))
this.machineDetail.cassettesDirty = false
this.cassettePublishConfirm.show = false
Quasar.Notify.create({
type: 'positive',
message: 'Cassette config published to ATM'
})
} catch (e) {
const detail =
(e && e.response && e.response.data && e.response.data.detail) ||
'Publish failed'
this.machineDetail.cassettesError = detail
this._notifyError(e, 'Publish failed')
} finally {
this.machineDetail.cassettesPublishing = 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'
this.partialDispenseDialog.dispensed_fraction = null
this.partialDispenseDialog.dispensed_sats = null
this.partialDispenseDialog.notes = ''
this.partialDispenseDialog.show = true
},
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})
})
},
_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})
}
}
})