feat(v2): Commission splits editor (P9e)

Operator configures how the post-platform-fee commission remainder is
sliced across their wallets. Default ruleset applies fleet-wide; optional
per-machine overrides take precedence for that machine only.

Template (Commission tab content):
  - Scope selector: "Default ruleset" or one option per operator machine
    (override). Switching reloads the legs from the API.
  - Live sum indicator (green ✓ if 100%, red ✗ otherwise). Save button
    is disabled until the sum is valid.
  - Editable row per leg: wallet select + label input + pct input.
    Each row shows the % equivalent inline (e.g. 0.30 → 30.0%).
  - Add-leg button appends an empty row.
  - Preview banner: shows how an example 1000-sat operator commission
    would split across the current legs, mirroring the server-side
    last-leg-absorbs-rounding rule (calculations.allocate_operator_split_legs).
  - "Remove override" button on per-machine scopes: deletes the override
    so the default applies again (default legs untouched).
  - Empty-state banner explains the consequence of no rules: operator
    commission stays in the machine wallet.

JS:
  - commissionScope state: null = default, else machine_id
  - commissionScopeOptions computed: default + one per machine
  - commissionLegs[] mirror the server's CommissionSplitLeg shape
  - commissionSum / commissionSumValid: client-side invariant check
    matching the SetCommissionSplitsData validator (within 0.0001)
  - commissionPreview: pure JS port of allocate_operator_split_legs,
    so the visualization matches what the server actually does
  - saveCommissionSplits sends machine_id=null for default, else the
    machine id; legs sort_order set from array index
  - confirmDeleteCommissionOverride calls DELETE with ?machine_id=X to
    clear just the override (no body)
  - loadCommissionSplits called on created() so the tab is ready when
    the operator clicks it

Routes wired:
  GET    /api/v1/dca/commission-splits
  GET    /api/v1/dca/commission-splits?machine_id=X
  PUT    /api/v1/dca/commission-splits
  DELETE /api/v1/dca/commission-splits?machine_id=X

Refs: aiolabs/satmachineadmin#9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-14 18:07:08 +02:00
commit 5c8e629752
2 changed files with 266 additions and 3 deletions

View file

@ -19,6 +19,7 @@ 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',
@ -58,6 +59,14 @@ window.app = Vue.createApp({
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,
// UI configuration -----------------------------------------------
machinesTable: {
columns: [
@ -217,6 +226,44 @@ window.app = Vue.createApp({
value: c.id
}))
},
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.pct) || 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.pct) || 0))
remaining -= sats
}
out.push({label: leg.label, sats})
})
return out
},
filteredDeposits() {
let rows = this.deposits
if (this.depositsFilter.status) {
@ -236,6 +283,7 @@ window.app = Vue.createApp({
async created() {
await this.refreshAll()
await this.loadCommissionSplits()
},
methods: {
@ -804,6 +852,87 @@ window.app = Vue.createApp({
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}`
)
this.commissionLegs = (data || []).map(leg => ({
wallet_id: leg.wallet_id,
label: leg.label || '',
pct: Number(leg.pct) || 0
}))
} catch (e) {
this.commissionLegs = []
this._notifyError(e, 'Failed to load commission splits')
}
},
addCommissionLeg() {
this.commissionLegs.push({
wallet_id: this.walletOptions[0]?.value || null,
label: '',
pct: 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) => ({
wallet_id: leg.wallet_id,
label: leg.label || null,
pct: Number(leg.pct),
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)
// -----------------------------------------------------------------