feat(v2): Clients tab — LP management + settle balance modal (P9c)

Adds operator-scoped LP (liquidity provider) management. Operators
register LPs against specific machines, monitor remaining balances, and
settle small remainders via the P3e endpoint.

Template (Clients tab content + dialogs):
  - Table with columns: machine, LP (username/short user_id), wallet,
    DCA mode badge, remaining balance (color-coded: green if positive,
    grey if zero), autoforward icon (with tooltip showing LN address),
    status badge, action menu
  - Empty-state banners: orange if no machines yet (LPs are
    machine-scoped), blue if machines exist but no LPs registered
  - Register-LP dialog: machine select + user_id + wallet_id + display
    name + DCA mode (flow / fixed) + fixed-mode daily limit (conditional)
    + autoforward toggle + autoforward LN address (conditional)
  - Edit-LP dialog: same minus immutable user_id/wallet_id, plus status
    select (active/paused/closed)
  - Settle-balance dialog (closes #4): funding wallet select + exchange
    rate (operator-supplied) + optional amount_fiat (blank = full
    remaining) + notes textarea. Shows the LP's current remaining
    balance prominently before submission.

JS:
  - loadClients pulls all operator's LPs across their fleet
  - Per-LP balance summaries pre-loaded (one GET per LP — N+1, captured
    in review issue #11 M3 for follow-up with a single grouped JOIN)
  - openAddClientDialog / openEditClientDialog with separate cleaner
    helpers (_cleanClientCreate vs _cleanClientUpdate) since the v2
    API immutable-field rules differ between create and update
  - openSettleBalanceDialog refreshes balance immediately before
    showing the modal so the operator sees the up-to-date number
  - confirmDeleteClient + DELETE wired
  - machineNameById helper for displaying which machine an LP is at
  - machineOptions computed for the register-LP machine select
  - machinesById computed cache (avoids O(N*M) lookups in render loop)

Routes wired:
  GET    /api/v1/dca/clients
  GET    /api/v1/dca/clients/{id}/balance
  POST   /api/v1/dca/clients
  PUT    /api/v1/dca/clients/{id}
  DELETE /api/v1/dca/clients/{id}
  POST   /api/v1/dca/clients/{id}/settle

Refs: aiolabs/satmachineadmin#9 — closes the Clients-tab gap in #4 +
exposes the autoforward setting (#8) in operator UI

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-14 18:03:29 +02:00
commit 0800a1acb0
2 changed files with 498 additions and 2 deletions

View file

@ -17,6 +17,7 @@ const SUPER_FEE_PATH = `${API}/super-config`
const MACHINES_PATH = `${API}/machines` const MACHINES_PATH = `${API}/machines`
const SETTLEMENTS_PATH = `${API}/settlements` const SETTLEMENTS_PATH = `${API}/settlements`
const STUCK_PATH = `${API}/settlements/stuck` const STUCK_PATH = `${API}/settlements/stuck`
const CLIENTS_PATH = `${API}/clients`
const SETTLEMENT_STATUS_COLOR = { const SETTLEMENT_STATUS_COLOR = {
pending: 'grey', pending: 'grey',
@ -40,6 +41,8 @@ window.app = Vue.createApp({
// Server state --------------------------------------------------- // Server state ---------------------------------------------------
superConfig: null, superConfig: null,
machines: [], machines: [],
clients: [],
clientBalances: {}, // {client_id: ClientBalanceSummary}
worklistCount: 0, worklistCount: 0,
// UI configuration ----------------------------------------------- // UI configuration -----------------------------------------------
@ -61,6 +64,25 @@ window.app = Vue.createApp({
pagination: {rowsPerPage: 10, sortBy: 'name'} pagination: {rowsPerPage: 10, sortBy: 'name'}
}, },
clientsTable: {
columns: [
{name: 'machine', label: 'Machine', field: 'machine_id', align: 'left'},
{name: 'username', label: 'LP', field: 'username', align: 'left'},
{name: 'wallet_id', label: 'Wallet', field: 'wallet_id', align: 'left'},
{name: 'dca_mode', label: 'Mode', field: 'dca_mode', align: 'left'},
{
name: 'remaining_balance',
label: 'Balance',
field: 'remaining_balance',
align: 'right'
},
{name: 'autoforward', label: '→', field: 'autoforward_enabled', align: 'center'},
{name: 'status', label: 'Status', field: 'status', align: 'left'},
{name: 'actions', label: '', field: 'id', align: 'right'}
],
pagination: {rowsPerPage: 25}
},
settlementsTable: { settlementsTable: {
columns: [ columns: [
{name: 'status', label: 'Status', field: 'status', align: 'left'}, {name: 'status', label: 'Status', field: 'status', align: 'left'},
@ -111,6 +133,24 @@ window.app = Vue.createApp({
saving: false, saving: false,
settlement: null, settlement: null,
note: '' note: ''
},
clientDialog: {
show: false,
saving: false,
mode: 'add', // 'add' | 'edit'
data: this._emptyClientForm()
},
settleBalanceDialog: {
show: false,
saving: false,
client: null,
balance: null,
data: {
funding_wallet_id: null,
exchange_rate: null,
amount_fiat: null,
notes: ''
}
} }
} }
}, },
@ -120,6 +160,17 @@ window.app = Vue.createApp({
// g.user is sometimes null on initial mount in LNbits 1.4 — guard it. // g.user is sometimes null on initial mount in LNbits 1.4 — guard it.
const wallets = this.g?.user?.wallets || [] const wallets = this.g?.user?.wallets || []
return wallets.map(w => ({label: w.name, value: w.id})) 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
}))
},
machinesById() {
const map = {}
for (const m of this.machines) map[m.id] = m
return map
} }
}, },
@ -137,6 +188,7 @@ window.app = Vue.createApp({
await Promise.all([ await Promise.all([
this.loadSuperConfig(), this.loadSuperConfig(),
this.loadMachines(), this.loadMachines(),
this.loadClients(),
this.loadWorklistCount() this.loadWorklistCount()
]) ])
} finally { } finally {
@ -144,6 +196,38 @@ window.app = Vue.createApp({
} }
}, },
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() { async loadSuperConfig() {
try { try {
const {data} = await LNbits.api.request('GET', SUPER_FEE_PATH) const {data} = await LNbits.api.request('GET', SUPER_FEE_PATH)
@ -424,6 +508,125 @@ window.app = Vue.createApp({
} }
}, },
// -----------------------------------------------------------------
// 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,
wallet_id: client.wallet_id,
username: client.username || '',
dca_mode: client.dca_mode,
fixed_mode_daily_limit: client.fixed_mode_daily_limit,
autoforward_enabled: !!client.autoforward_enabled,
autoforward_ln_address: client.autoforward_ln_address || '',
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')
}
})
},
// -----------------------------------------------------------------
// 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) { _replaceSettlement(updated) {
if (!updated) return if (!updated) return
const idx = this.machineDetail.settlements.findIndex( const idx = this.machineDetail.settlements.findIndex(
@ -470,6 +673,52 @@ window.app = Vue.createApp({
}) })
}, },
_emptyClientForm() {
return {
machine_id: null,
user_id: '',
wallet_id: '',
username: '',
dca_mode: 'flow',
fixed_mode_daily_limit: null,
autoforward_enabled: false,
autoforward_ln_address: '',
status: 'active'
}
},
_cleanClientCreate(d) {
return {
machine_id: d.machine_id,
user_id: (d.user_id || '').trim(),
wallet_id: (d.wallet_id || '').trim(),
username: (d.username || '').trim() || null,
dca_mode: d.dca_mode || 'flow',
fixed_mode_daily_limit:
d.dca_mode === 'fixed' && d.fixed_mode_daily_limit
? Number(d.fixed_mode_daily_limit) : null,
autoforward_enabled: !!d.autoforward_enabled,
autoforward_ln_address:
d.autoforward_enabled && d.autoforward_ln_address
? d.autoforward_ln_address.trim() : null
}
},
_cleanClientUpdate(d) {
return {
username: (d.username || '').trim() || null,
dca_mode: d.dca_mode,
fixed_mode_daily_limit:
d.dca_mode === 'fixed' && d.fixed_mode_daily_limit
? Number(d.fixed_mode_daily_limit) : null,
autoforward_enabled: !!d.autoforward_enabled,
autoforward_ln_address:
d.autoforward_enabled && d.autoforward_ln_address
? d.autoforward_ln_address.trim() : null,
status: d.status
}
},
_emptyMachineForm() { _emptyMachineForm() {
return { return {
machine_npub: '', machine_npub: '',

View file

@ -167,9 +167,112 @@
<!-- PLACEHOLDERS for tabs that land in P9bP9g --> <!-- PLACEHOLDERS for tabs that land in P9bP9g -->
<!-- ============================================================= --> <!-- ============================================================= -->
<q-tab-panel name="clients"> <q-tab-panel name="clients">
<q-banner class="bg-grey-2 text-grey-9"> <div class="row items-center q-mb-md">
Clients tab — pending P9c. <div class="col">
<h6 class="q-my-none">Liquidity providers</h6>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
LPs receive proportional DCA distributions from your machines.
Balances reflect deposits less the sats they've been paid.
</p>
</div>
<div class="col-auto">
<q-btn color="primary" icon="person_add"
label="Register LP"
:disable="!machines.length"
@click="openAddClientDialog" />
</div>
</div>
<q-banner v-if="!machines.length" class="bg-orange-1 text-grey-9">
<template v-slot:avatar>
<q-icon name="warning" color="orange" />
</template>
Register at least one machine before adding LPs — an LP is scoped
to a specific machine.
</q-banner> </q-banner>
<q-banner v-else-if="!clients.length" class="bg-blue-1 text-grey-9">
<template v-slot:avatar>
<q-icon name="info" color="blue" />
</template>
No LPs yet. Use <b>Register LP</b> to add one at any of your machines.
</q-banner>
<q-table v-else
dense flat
:rows="clients"
row-key="id"
:columns="clientsTable.columns"
:rows-per-page-options="[10, 25, 50]"
:pagination="clientsTable.pagination">
<template v-slot:body="props">
<q-tr :props="props">
<q-td key="machine">
<span :style="{fontWeight: 500}"
v-text="machineNameById(props.row.machine_id)"></span>
</q-td>
<q-td key="username">
<span v-text="props.row.username || shortId(props.row.user_id)"></span>
</q-td>
<q-td key="wallet_id">
<code :style="{fontSize: '0.8em'}"
v-text="shortId(props.row.wallet_id)"></code>
</q-td>
<q-td key="dca_mode">
<q-badge :color="props.row.dca_mode === 'flow' ? 'blue' : 'purple'"
:label="props.row.dca_mode" />
</q-td>
<q-td key="remaining_balance" class="text-right">
<span v-if="clientBalances[props.row.id]"
:style="{color: clientBalances[props.row.id].remaining_balance > 0 ? '#2e7d32' : '#9e9e9e', fontWeight: 500}"
v-text="formatFiat(clientBalances[props.row.id].remaining_balance, clientBalances[props.row.id].currency)"></span>
<span v-else :style="{opacity: 0.5}"></span>
</q-td>
<q-td key="autoforward">
<q-icon v-if="props.row.autoforward_enabled"
name="forward_to_inbox" color="primary" size="sm">
<q-tooltip>
Auto-forward enabled →
<span v-text="props.row.autoforward_ln_address || '(no address)'"></span>
</q-tooltip>
</q-icon>
<q-icon v-else name="forward" color="grey-5" size="sm">
<q-tooltip>Auto-forward disabled</q-tooltip>
</q-icon>
</q-td>
<q-td key="status">
<q-badge
:color="props.row.status === 'active' ? 'green' : 'grey'"
:label="props.row.status" />
</q-td>
<q-td key="actions" auto-width>
<q-btn-dropdown flat dense size="sm" icon="more_vert">
<q-list dense>
<q-item clickable v-close-popup
@click="openEditClientDialog(props.row)">
<q-item-section avatar><q-icon name="edit" /></q-item-section>
<q-item-section>Edit</q-item-section>
</q-item>
<q-item clickable v-close-popup
@click="openSettleBalanceDialog(props.row)">
<q-item-section avatar>
<q-icon name="payments" color="primary" />
</q-item-section>
<q-item-section>Settle balance…</q-item-section>
</q-item>
<q-item clickable v-close-popup
@click="confirmDeleteClient(props.row)">
<q-item-section avatar>
<q-icon name="delete" color="red-7" />
</q-item-section>
<q-item-section>Delete</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</q-td>
</q-tr>
</template>
</q-table>
</q-tab-panel> </q-tab-panel>
<q-tab-panel name="deposits"> <q-tab-panel name="deposits">
<q-banner class="bg-grey-2 text-grey-9"> <q-banner class="bg-grey-2 text-grey-9">
@ -523,6 +626,150 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
<!-- =============================================================== -->
<!-- ADD / EDIT CLIENT DIALOGS -->
<!-- =============================================================== -->
<q-dialog v-model="clientDialog.show" persistent>
<q-card :style="{minWidth: '520px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6" v-text="clientDialog.mode === 'add' ? 'Register liquidity provider' : 'Edit LP'"></div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-card-section>
<p class="text-caption q-mb-md" :style="{opacity: 0.7}"
v-if="clientDialog.mode === 'add'">
LPs receive DCA distributions proportional to their remaining
balance. Each LP is scoped to a single machine; the same LP user
can register at multiple machines as separate rows.
</p>
<q-select
v-if="clientDialog.mode === 'add'"
v-model="clientDialog.data.machine_id"
:options="machineOptions"
label="At machine"
emit-value map-options
class="q-mb-md"
dense outlined
:rules="[v => !!v || 'Required']" />
<q-input v-if="clientDialog.mode === 'add'"
v-model="clientDialog.data.user_id"
label="LP's LNbits user_id"
hint="The LP shares this from their LNbits account settings"
class="q-mb-md" dense outlined
:rules="[v => !!v || 'Required']" />
<q-input v-if="clientDialog.mode === 'add'"
v-model="clientDialog.data.wallet_id"
label="LP's wallet_id (receives DCA)"
hint="The LP's wallet where their sats land"
class="q-mb-md" dense outlined
:rules="[v => !!v || 'Required']" />
<q-input v-model="clientDialog.data.username"
label="Display name (optional)"
class="q-mb-md" dense outlined />
<q-select v-model="clientDialog.data.dca_mode"
:options="[{label: 'Flow (proportional)', value: 'flow'},
{label: 'Fixed (daily limit)', value: 'fixed'}]"
label="DCA mode"
emit-value map-options
class="q-mb-md" dense outlined />
<q-input v-if="clientDialog.data.dca_mode === 'fixed'"
v-model.number="clientDialog.data.fixed_mode_daily_limit"
label="Fixed mode daily limit (fiat)"
type="number" step="0.01"
class="q-mb-md" dense outlined />
<q-toggle v-model="clientDialog.data.autoforward_enabled"
label="Auto-forward DCA to external LN address"
class="q-mb-md" />
<q-input v-if="clientDialog.data.autoforward_enabled"
v-model="clientDialog.data.autoforward_ln_address"
label="LN address (e.g. user@walletofsatoshi.com)"
hint="LP-controlled; failures leave sats safely in LP's LNbits wallet"
class="q-mb-md" dense outlined />
<q-select v-if="clientDialog.mode === 'edit'"
v-model="clientDialog.data.status"
:options="['active', 'paused', 'closed']"
label="Status"
class="q-mb-md" dense outlined />
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup />
<q-btn color="primary"
:label="clientDialog.mode === 'add' ? 'Register' : 'Save'"
:loading="clientDialog.saving"
@click="submitClient" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== -->
<!-- SETTLE BALANCE DIALOG (closes satmachineadmin#4) -->
<!-- =============================================================== -->
<q-dialog v-model="settleBalanceDialog.show" persistent>
<q-card :style="{minWidth: '480px', maxWidth: '95vw'}">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">Settle LP balance</div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-card-section v-if="settleBalanceDialog.client">
<q-banner class="bg-blue-1 text-grey-9 q-mb-md">
<template v-slot:avatar>
<q-icon name="payments" color="blue" />
</template>
Pay the LP's remaining fiat balance in sats from your wallet at the
rate you choose. Useful to zero out small balances that would
otherwise shrink forever via proportional shares.
</q-banner>
<div v-if="settleBalanceDialog.balance" class="q-mb-md">
<div class="text-caption" :style="{opacity: 0.6}">Remaining balance</div>
<div :style="{fontSize: '1.2em', fontWeight: 500}"
v-text="formatFiat(settleBalanceDialog.balance.remaining_balance, settleBalanceDialog.balance.currency)"></div>
</div>
<q-select v-model="settleBalanceDialog.data.funding_wallet_id"
:options="walletOptions"
label="Funding wallet (yours)"
emit-value map-options
class="q-mb-md" dense outlined
:rules="[v => !!v || 'Pick a wallet']" />
<q-input v-model.number="settleBalanceDialog.data.exchange_rate"
label="Exchange rate (sats per 1 fiat unit)"
hint="You set the rate. Use exchange spot, midpoint, or a favourable gesture."
type="number" step="0.0001" min="0"
class="q-mb-md" dense outlined
:rules="[v => v > 0 || 'Must be > 0']" />
<q-input v-model.number="settleBalanceDialog.data.amount_fiat"
label="Amount (fiat) — leave blank to settle full remaining"
type="number" step="0.01"
class="q-mb-md" dense outlined />
<q-input v-model="settleBalanceDialog.data.notes"
label="Notes (optional, audit memo)"
type="textarea" autogrow
class="q-mb-md" dense outlined />
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup />
<q-btn color="primary" label="Settle balance"
:loading="settleBalanceDialog.saving"
@click="submitSettleBalance" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- =============================================================== --> <!-- =============================================================== -->
<!-- EDIT MACHINE DIALOG (same fields; PUT instead of POST) --> <!-- EDIT MACHINE DIALOG (same fields; PUT instead of POST) -->
<!-- =============================================================== --> <!-- =============================================================== -->