Some checks failed
ci.yml / feat(cash-in): super_config.max_cash_in_sats per-tx cap + UI (#31) (pull_request) Failing after 0s
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.
1666 lines
55 KiB
JavaScript
1666 lines
55 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: '',
|
||
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 0–0.15. A value above 0.15 usually means a percent was typed (3 instead of 0.03).'
|
||
})
|
||
return false
|
||
}
|
||
return true
|
||
},
|
||
|
||
async submitSuperFee() {
|
||
const d = this.superFeeDialog.data
|
||
if (!this._assertFeesDecimal(
|
||
Number(d.super_cash_in_fee_fraction),
|
||
Number(d.super_cash_out_fee_fraction)
|
||
)) return
|
||
// Blank cap field -> null (the PUT skips null, so the existing value is
|
||
// preserved rather than cleared — set 0 to reject every cash-in).
|
||
const cap =
|
||
d.max_cash_in_sats === '' ||
|
||
d.max_cash_in_sats === null ||
|
||
d.max_cash_in_sats === undefined
|
||
? null
|
||
: Number(d.max_cash_in_sats)
|
||
if (cap !== null && (!Number.isInteger(cap) || cap < 0)) {
|
||
Quasar.Notify.create({
|
||
type: 'negative',
|
||
message: 'Max cash-in must be a non-negative whole number of sats'
|
||
})
|
||
return
|
||
}
|
||
this.superFeeDialog.saving = true
|
||
try {
|
||
const {data} = await LNbits.api.request(
|
||
'PUT', SUPER_FEE_PATH, null,
|
||
{
|
||
super_cash_in_fee_fraction: Number(d.super_cash_in_fee_fraction),
|
||
super_cash_out_fee_fraction: Number(d.super_cash_out_fee_fraction),
|
||
super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null,
|
||
max_cash_in_sats: cap
|
||
}
|
||
)
|
||
this.superConfig = data
|
||
this.superFeeDialog.show = false
|
||
Quasar.Notify.create({type: 'positive', message: 'Platform fee updated'})
|
||
} catch (e) {
|
||
this._notifyError(e, 'Save failed')
|
||
} finally {
|
||
this.superFeeDialog.saving = false
|
||
}
|
||
},
|
||
|
||
// -----------------------------------------------------------------
|
||
// Reports / CSV exports (P9g)
|
||
// -----------------------------------------------------------------
|
||
downloadMachinesCsv() {
|
||
this._downloadCsv(
|
||
'machines.csv',
|
||
['id', 'machine_npub', 'wallet_id', 'name', 'location', 'fiat_code',
|
||
'is_active', 'created_at'],
|
||
this.machines
|
||
)
|
||
},
|
||
|
||
downloadClientsCsv() {
|
||
const rows = this.clients.map(c => {
|
||
const bal = this.clientBalances[c.id] || {}
|
||
return {
|
||
...c,
|
||
machine_name: this.machineNameById(c.machine_id),
|
||
remaining_balance: bal.remaining_balance ?? '',
|
||
total_deposits: bal.total_deposits ?? '',
|
||
total_payments: bal.total_payments ?? '',
|
||
balance_currency: bal.currency ?? ''
|
||
}
|
||
})
|
||
this._downloadCsv(
|
||
'clients.csv',
|
||
['id', 'machine_id', 'machine_name', 'user_id',
|
||
'username', 'lp_onboarded', 'status',
|
||
'total_deposits', 'total_payments',
|
||
'remaining_balance', 'balance_currency', 'created_at'],
|
||
rows
|
||
)
|
||
},
|
||
|
||
downloadDepositsCsv() {
|
||
this._downloadCsv(
|
||
'deposits.csv',
|
||
['id', 'client_id', 'machine_id', 'creator_user_id', 'amount',
|
||
'currency', 'status', 'notes', 'created_at', 'confirmed_at'],
|
||
this.deposits
|
||
)
|
||
},
|
||
|
||
async downloadPaymentsCsv() {
|
||
// Payments are not pre-loaded; fetch on demand.
|
||
this.reportsBusy = true
|
||
try {
|
||
const {data} = await LNbits.api.request('GET', `${API}/payments`)
|
||
this._downloadCsv(
|
||
'payments.csv',
|
||
['id', 'settlement_id', 'client_id', 'machine_id', 'leg_type',
|
||
'destination_wallet_id', 'destination_ln_address', 'amount_sats',
|
||
'amount_fiat', 'exchange_rate', 'status', 'external_payment_hash',
|
||
'transaction_time', 'created_at', 'error_message'],
|
||
data || []
|
||
)
|
||
} catch (e) {
|
||
this._notifyError(e, 'Failed to fetch payments')
|
||
} finally {
|
||
this.reportsBusy = false
|
||
}
|
||
},
|
||
|
||
_downloadCsv(filename, columns, rows) {
|
||
const escape = v => {
|
||
if (v == null) return ''
|
||
const s = String(v)
|
||
if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"'
|
||
return s
|
||
}
|
||
const header = columns.join(',')
|
||
const body = rows.map(
|
||
row => columns.map(col => escape(row[col])).join(',')
|
||
).join('\n')
|
||
const csv = header + '\n' + body
|
||
const blob = new Blob([csv], {type: 'text/csv;charset=utf-8'})
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = filename
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
document.body.removeChild(a)
|
||
URL.revokeObjectURL(url)
|
||
},
|
||
|
||
// -----------------------------------------------------------------
|
||
// Add machine
|
||
// -----------------------------------------------------------------
|
||
openAddMachineDialog() {
|
||
this.addMachineDialog.data = this._emptyMachineForm()
|
||
this.addMachineDialog.show = true
|
||
},
|
||
|
||
async submitAddMachine() {
|
||
const body = this._cleanMachineForm(this.addMachineDialog.data)
|
||
if (!body.wallet_id) {
|
||
Quasar.Notify.create({
|
||
type: 'negative',
|
||
message: 'A wallet is required'
|
||
})
|
||
return
|
||
}
|
||
if (!this._assertFeesDecimal(
|
||
Number(body.operator_cash_in_fee_fraction) || 0,
|
||
Number(body.operator_cash_out_fee_fraction) || 0
|
||
)) return
|
||
this.addMachineDialog.saving = true
|
||
try {
|
||
const {data} = await LNbits.api.request('POST', MACHINES_PATH, null, body)
|
||
this.machines.unshift(data)
|
||
this.addMachineDialog.show = false
|
||
Quasar.Notify.create({
|
||
type: 'positive',
|
||
message: `Machine ${data.name || (data.machine_npub || 'unpaired').slice(0, 12)} added`
|
||
})
|
||
} catch (e) {
|
||
this._notifyError(e, 'Failed to add machine')
|
||
} finally {
|
||
this.addMachineDialog.saving = false
|
||
}
|
||
},
|
||
|
||
// -----------------------------------------------------------------
|
||
// Edit / delete machine
|
||
// -----------------------------------------------------------------
|
||
openEditMachineDialog(machine) {
|
||
this.editMachineDialog.data = {
|
||
id: machine.id,
|
||
name: machine.name || '',
|
||
location: machine.location || '',
|
||
wallet_id: machine.wallet_id,
|
||
fiat_code: machine.fiat_code,
|
||
is_active: machine.is_active,
|
||
operator_cash_in_fee_fraction: machine.operator_cash_in_fee_fraction ?? 0,
|
||
operator_cash_out_fee_fraction: machine.operator_cash_out_fee_fraction ?? 0
|
||
}
|
||
this.editMachineDialog.show = true
|
||
},
|
||
|
||
async submitEditMachine() {
|
||
const d = this.editMachineDialog.data
|
||
if (!this._assertFeesDecimal(
|
||
Number(d.operator_cash_in_fee_fraction) || 0,
|
||
Number(d.operator_cash_out_fee_fraction) || 0
|
||
)) return
|
||
this.editMachineDialog.saving = true
|
||
try {
|
||
const {data} = await LNbits.api.request(
|
||
'PUT',
|
||
`${MACHINES_PATH}/${d.id}`,
|
||
null,
|
||
{
|
||
name: d.name,
|
||
location: d.location,
|
||
wallet_id: d.wallet_id,
|
||
fiat_code: d.fiat_code,
|
||
is_active: d.is_active,
|
||
operator_cash_in_fee_fraction: Number(d.operator_cash_in_fee_fraction) || 0,
|
||
operator_cash_out_fee_fraction: Number(d.operator_cash_out_fee_fraction) || 0
|
||
}
|
||
)
|
||
const idx = this.machines.findIndex(m => m.id === data.id)
|
||
if (idx >= 0) this.machines[idx] = data
|
||
this.editMachineDialog.show = false
|
||
Quasar.Notify.create({type: 'positive', message: 'Machine updated'})
|
||
} catch (e) {
|
||
this._notifyError(e, 'Failed to update machine')
|
||
} finally {
|
||
this.editMachineDialog.saving = false
|
||
}
|
||
},
|
||
|
||
confirmDeleteMachine(machine) {
|
||
Quasar.Dialog.create({
|
||
title: 'Delete machine?',
|
||
message:
|
||
`This removes <b>${machine.name || (machine.machine_npub || 'unpaired').slice(0, 12)}</b>` +
|
||
' from your fleet. Existing settlements and payment history are preserved' +
|
||
' — only the machine row itself is removed. Continue?',
|
||
html: true,
|
||
cancel: true,
|
||
persistent: true
|
||
}).onOk(async () => {
|
||
try {
|
||
await LNbits.api.request('DELETE', `${MACHINES_PATH}/${machine.id}`)
|
||
this.machines = this.machines.filter(m => m.id !== machine.id)
|
||
Quasar.Notify.create({type: 'positive', message: 'Machine deleted'})
|
||
} catch (e) {
|
||
this._notifyError(e, 'Failed to delete machine')
|
||
}
|
||
})
|
||
},
|
||
|
||
// -----------------------------------------------------------------
|
||
// Pair / revoke spire (S0 / #9, #12)
|
||
// -----------------------------------------------------------------
|
||
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})
|
||
}
|
||
}
|
||
})
|