satmachineadmin/static/js/index.js
Padreug 10f4b50ca5
Some checks failed
ci.yml / feat(v2)(ui): per-direction fee inputs in super-config + machine modals (#38 5/5) (pull_request) Failing after 0s
feat(v2)(ui): per-direction fee inputs in super-config + machine modals (#38 5/5)
Surfaces the new directional fee fields in the admin dashboard so
operators + the LNbits super can configure cash-in and cash-out fees
independently:

Templates (`templates/satmachineadmin/index.html`):
- Platform fee banner now shows both directional super fractions
  side-by-side ("cash-in X% · cash-out Y% of each transaction's
  principal"). Wording updated to "principal" not "commission" since
  the math is now principal-based.
- Super-fee edit dialog: replaces the single q-input with two
  (super_cash_in_fee_fraction + super_cash_out_fee_fraction); each
  capped at 0.15 via max attr (visual hint; server enforces).
- Add-machine + edit-machine dialogs both gain operator_cash_in_fee_
  fraction + operator_cash_out_fee_fraction inputs with the same 0.15
  cap hint. Hint text mentions the "sits on top of platform fee, total
  capped at 15% per direction" semantics so operators understand the
  layering.

JS (`static/js/index.js`):
- superFeeDialog.data shape switches to the new directional fields.
- openSuperFeeDialog / submitSuperFee load + POST the new shape.
- _emptyMachineForm / _cleanMachineForm pass through operator
  directional fields (Number-coerced, default 0).
- openEditMachineDialog / submitEditMachine include the operator fee
  fields in the form data + PUT body.
- New computed `superAnyFee` drives the banner styling (sum of both
  directional fractions — non-zero → blue active banner; zero → muted
  grey "free instance" banner).

All Quasar UMD components use explicit close tags per the UMD-mode
parsing rule.

Migration carry-over verified in dev container: pre-m009
super_fee_fraction=0.33 backfilled to super_cash_in=0.33 +
super_cash_out=0.33 on migrate-up. Note this puts existing dev
instances above the new 15% cap; operators will see the cap
validation error on their next super-config save and must adjust to
≤0.15 per direction. Production aiolabs/server-deploy will land at
0.03 on both directions (well under cap).

164/164 tests green. Layer 1 (#38) complete; Layer 2 (#39) wire-
format publisher is the next milestone.

Refs: aiolabs/satmachineadmin#37 (parent), #38 (closes Layer 1).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 14:46:27 +02:00

1524 lines
50 KiB
JavaScript

// Satoshi Machine v2 — operator dashboard (P9a foundation).
//
// Vue 3 + Quasar UMD app. Talks to the v2 satmachineadmin 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 = '/satmachineadmin/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: ''
}
},
// 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: {}
},
machineDetail: {
show: false,
loading: false,
machine: null,
settlements: [],
// Cassettes sub-tab state (#29 v1) — see openCassettePublishConfirm /
// submitCassettePublish methods + the cassettes panel in
// templates/satmachineadmin/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 || ''
}
this.superFeeDialog.show = true
},
async submitSuperFee() {
const d = this.superFeeDialog.data
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
}
)
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.machine_npub || !body.wallet_id) {
Quasar.Notify.create({
type: 'negative',
message: 'machine_npub and wallet_id are required'
})
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.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
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.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')
}
})
},
// -----------------------------------------------------------------
// 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 ''
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(),
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})
}
}
})