Fork of satmachineadmin's v2-bitspire line into its own repo. Renames
both identifiers so this extension is fully independent of the original
satmachineadmin install (which remains in service):
- extension id satmachineadmin -> spirekeeper
(router prefix, static path/static_url_for, module symbols, task
names, templates dir, config/manifest paths)
- database name satoshimachine -> spirekeeper
(Database(ext_spirekeeper), all schema-qualified table refs)
Also resets versioning to 0.1.0, sets the display name + manifest to
spirekeeper/aiolabs, and fixes the placeholder pyproject description.
Historical aiolabs/satmachineadmin#N issue references in comments are
left pointing at the original repo where those issues live.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1524 lines
50 KiB
JavaScript
1524 lines
50 KiB
JavaScript
// 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: ''
|
|
}
|
|
},
|
|
|
|
// 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/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 || ''
|
|
}
|
|
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})
|
|
}
|
|
}
|
|
})
|