From 5c8e629752b6135368ca0fad247938452e22234e Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 14 May 2026 18:07:08 +0200 Subject: [PATCH] feat(v2): Commission splits editor (P9e) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- static/js/index.js | 129 ++++++++++++++++++++++++ templates/satmachineadmin/index.html | 140 ++++++++++++++++++++++++++- 2 files changed, 266 insertions(+), 3 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index 73c8525..483df51 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -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) // ----------------------------------------------------------------- diff --git a/templates/satmachineadmin/index.html b/templates/satmachineadmin/index.html index db05a71..74e38b3 100644 --- a/templates/satmachineadmin/index.html +++ b/templates/satmachineadmin/index.html @@ -409,9 +409,143 @@ - - Commission splits tab — pending P9e. - +
+
+
Commission splits
+

+ After the LNbits platform fee is taken, the remainder is + distributed across the wallets you configure here. Per-machine + overrides take precedence over your default rules. +

+
+
+ +
+
+ +
+ + Default ruleset — applies to every machine without an + explicit override. + + + Per-machine override for + . + Empty/cleared rows fall back to the default. + +
+
+
+ + + +
+
+ Legs + + Sum: + + + + Must sum to 100% before saving + + +
+
+ +
+
+ + + + + No default rules. Without a default, all operator + commission stays in the machine wallet (audit visible). + + + No override for this machine. The default ruleset applies. + + + +
+
+ +
+
+ +
+
+ + + +
+
+ +
+
+ + + + Preview against + + sats operator commission → + + : + + + +
+ + + + + + +