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

@ -409,9 +409,143 @@
</q-table>
</q-tab-panel>
<q-tab-panel name="commission">
<q-banner class="bg-grey-2 text-grey-9">
Commission splits tab — pending P9e.
</q-banner>
<div class="row items-center q-mb-md">
<div class="col">
<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 name="worklist">
<q-banner class="bg-grey-2 text-grey-9">