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:
parent
ce4d7e4dd6
commit
5c8e629752
2 changed files with 266 additions and 3 deletions
|
|
@ -19,6 +19,7 @@ const SETTLEMENTS_PATH = `${API}/settlements`
|
||||||
const STUCK_PATH = `${API}/settlements/stuck`
|
const STUCK_PATH = `${API}/settlements/stuck`
|
||||||
const CLIENTS_PATH = `${API}/clients`
|
const CLIENTS_PATH = `${API}/clients`
|
||||||
const DEPOSITS_PATH = `${API}/deposits`
|
const DEPOSITS_PATH = `${API}/deposits`
|
||||||
|
const COMMISSION_SPLITS_PATH = `${API}/commission-splits`
|
||||||
|
|
||||||
const DEPOSIT_STATUS_COLOR = {
|
const DEPOSIT_STATUS_COLOR = {
|
||||||
pending: 'orange',
|
pending: 'orange',
|
||||||
|
|
@ -58,6 +59,14 @@ window.app = Vue.createApp({
|
||||||
client_id: 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,
|
||||||
|
|
||||||
// UI configuration -----------------------------------------------
|
// UI configuration -----------------------------------------------
|
||||||
machinesTable: {
|
machinesTable: {
|
||||||
columns: [
|
columns: [
|
||||||
|
|
@ -217,6 +226,44 @@ window.app = Vue.createApp({
|
||||||
value: c.id
|
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() {
|
filteredDeposits() {
|
||||||
let rows = this.deposits
|
let rows = this.deposits
|
||||||
if (this.depositsFilter.status) {
|
if (this.depositsFilter.status) {
|
||||||
|
|
@ -236,6 +283,7 @@ window.app = Vue.createApp({
|
||||||
|
|
||||||
async created() {
|
async created() {
|
||||||
await this.refreshAll()
|
await this.refreshAll()
|
||||||
|
await this.loadCommissionSplits()
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
|
@ -804,6 +852,87 @@ window.app = Vue.createApp({
|
||||||
if (idx >= 0) this.deposits[idx] = updated
|
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)
|
// Settle balance (P3e — closes #4)
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -409,9 +409,143 @@
|
||||||
</q-table>
|
</q-table>
|
||||||
</q-tab-panel>
|
</q-tab-panel>
|
||||||
<q-tab-panel name="commission">
|
<q-tab-panel name="commission">
|
||||||
<q-banner class="bg-grey-2 text-grey-9">
|
<div class="row items-center q-mb-md">
|
||||||
Commission splits tab — pending P9e.
|
<div class="col">
|
||||||
</q-banner>
|
<h6 class="q-my-none">Commission splits</h6>
|
||||||
|
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row q-col-gutter-md q-mb-md">
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-select v-model="commissionScope"
|
||||||
|
:options="commissionScopeOptions"
|
||||||
|
label="Scope being edited"
|
||||||
|
emit-value map-options
|
||||||
|
dense outlined
|
||||||
|
@update:model-value="loadCommissionSplits" />
|
||||||
|
<div class="text-caption q-mt-xs" :style="{opacity: 0.7}">
|
||||||
|
<span v-if="commissionScope === null">
|
||||||
|
Default ruleset — applies to every machine without an
|
||||||
|
explicit override.
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
Per-machine override for
|
||||||
|
<b v-text="machineNameById(commissionScope)"></b>.
|
||||||
|
Empty/cleared rows fall back to the default.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-card flat bordered>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center q-mb-sm">
|
||||||
|
<div class="col">
|
||||||
|
<span :style="{fontWeight: 500}">Legs</span>
|
||||||
|
<span class="q-ml-md text-caption">
|
||||||
|
Sum:
|
||||||
|
<span :style="{
|
||||||
|
color: commissionSumValid ? '#2e7d32' : '#c62828',
|
||||||
|
fontWeight: 500
|
||||||
|
}"
|
||||||
|
v-text="(commissionSum * 100).toFixed(2) + '%'"></span>
|
||||||
|
<q-icon v-if="commissionSumValid"
|
||||||
|
name="check_circle" color="green" size="xs"
|
||||||
|
class="q-ml-xs" />
|
||||||
|
<q-icon v-else
|
||||||
|
name="error" color="red" size="xs"
|
||||||
|
class="q-ml-xs">
|
||||||
|
<q-tooltip>Must sum to 100% before saving</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-btn flat dense color="primary" icon="add"
|
||||||
|
label="Add leg"
|
||||||
|
@click="addCommissionLeg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-banner v-if="!commissionLegs.length"
|
||||||
|
class="bg-blue-1 text-grey-9">
|
||||||
|
<template v-slot:avatar>
|
||||||
|
<q-icon name="info" color="blue" />
|
||||||
|
</template>
|
||||||
|
<span v-if="commissionScope === null">
|
||||||
|
No default rules. Without a default, all operator
|
||||||
|
commission stays in the machine wallet (audit visible).
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
No override for this machine. The default ruleset applies.
|
||||||
|
</span>
|
||||||
|
</q-banner>
|
||||||
|
|
||||||
|
<div v-for="(leg, idx) in commissionLegs" :key="idx"
|
||||||
|
class="row q-col-gutter-sm q-mb-sm items-center">
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<q-select v-model="leg.wallet_id"
|
||||||
|
:options="walletOptions"
|
||||||
|
label="Wallet"
|
||||||
|
emit-value map-options dense outlined />
|
||||||
|
</div>
|
||||||
|
<div class="col-7 col-md-4">
|
||||||
|
<q-input v-model="leg.label"
|
||||||
|
label="Label (e.g. employee, maintenance)"
|
||||||
|
dense outlined />
|
||||||
|
</div>
|
||||||
|
<div class="col-4 col-md-3">
|
||||||
|
<q-input v-model.number="leg.pct"
|
||||||
|
label="% (0..1)"
|
||||||
|
type="number" step="0.01" min="0" max="1"
|
||||||
|
dense outlined>
|
||||||
|
<template v-slot:append>
|
||||||
|
<span :style="{fontSize: '0.75em', opacity: 0.6}"
|
||||||
|
v-text="((leg.pct || 0) * 100).toFixed(1) + '%'"></span>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 col-md-1">
|
||||||
|
<q-btn flat dense round icon="delete" color="red-7"
|
||||||
|
@click="commissionLegs.splice(idx, 1)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-banner v-if="commissionPreview" class="bg-grey-2 text-grey-9 q-mt-md">
|
||||||
|
<template v-slot:avatar>
|
||||||
|
<q-icon name="visibility" color="grey" />
|
||||||
|
</template>
|
||||||
|
Preview against
|
||||||
|
<b v-text="formatSats(commissionPreviewInput)"></b>
|
||||||
|
sats operator commission →
|
||||||
|
<span v-for="(prev, idx) in commissionPreview" :key="idx"
|
||||||
|
class="q-mr-md">
|
||||||
|
<span :style="{opacity: 0.7}"
|
||||||
|
v-text="prev.label || ('leg ' + (idx + 1))"></span>:
|
||||||
|
<b v-text="formatSats(prev.sats)"></b>
|
||||||
|
</span>
|
||||||
|
</q-banner>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn v-if="commissionScope !== null && commissionLegs.length"
|
||||||
|
flat color="red"
|
||||||
|
label="Remove override"
|
||||||
|
@click="confirmDeleteCommissionOverride" />
|
||||||
|
<q-btn flat label="Reload"
|
||||||
|
:disable="commissionSaving"
|
||||||
|
@click="loadCommissionSplits" />
|
||||||
|
<q-btn color="primary"
|
||||||
|
label="Save"
|
||||||
|
:disable="!commissionSumValid"
|
||||||
|
:loading="commissionSaving"
|
||||||
|
@click="saveCommissionSplits" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
</q-tab-panel>
|
</q-tab-panel>
|
||||||
<q-tab-panel name="worklist">
|
<q-tab-panel name="worklist">
|
||||||
<q-banner class="bg-grey-2 text-grey-9">
|
<q-banner class="bg-grey-2 text-grey-9">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue