feat(v2)(ui): per-direction fee inputs in super-config + machine modals (#38 5/5)
Some checks failed
ci.yml / feat(v2)(ui): per-direction fee inputs in super-config + machine modals (#38 5/5) (pull_request) Failing after 0s

Surfaces the new directional fee fields in the admin dashboard so
operators + the LNbits super can configure cash-in and cash-out fees
independently:

Templates (`templates/satmachineadmin/index.html`):
- Platform fee banner now shows both directional super fractions
  side-by-side ("cash-in X% · cash-out Y% of each transaction's
  principal"). Wording updated to "principal" not "commission" since
  the math is now principal-based.
- Super-fee edit dialog: replaces the single q-input with two
  (super_cash_in_fee_fraction + super_cash_out_fee_fraction); each
  capped at 0.15 via max attr (visual hint; server enforces).
- Add-machine + edit-machine dialogs both gain operator_cash_in_fee_
  fraction + operator_cash_out_fee_fraction inputs with the same 0.15
  cap hint. Hint text mentions the "sits on top of platform fee, total
  capped at 15% per direction" semantics so operators understand the
  layering.

JS (`static/js/index.js`):
- superFeeDialog.data shape switches to the new directional fields.
- openSuperFeeDialog / submitSuperFee load + POST the new shape.
- _emptyMachineForm / _cleanMachineForm pass through operator
  directional fields (Number-coerced, default 0).
- openEditMachineDialog / submitEditMachine include the operator fee
  fields in the form data + PUT body.
- New computed `superAnyFee` drives the banner styling (sum of both
  directional fractions — non-zero → blue active banner; zero → muted
  grey "free instance" banner).

All Quasar UMD components use explicit close tags per the UMD-mode
parsing rule.

Migration carry-over verified in dev container: pre-m009
super_fee_fraction=0.33 backfilled to super_cash_in=0.33 +
super_cash_out=0.33 on migrate-up. Note this puts existing dev
instances above the new 15% cap; operators will see the cap
validation error on their next super-config save and must adjust to
≤0.15 per direction. Production aiolabs/server-deploy will land at
0.03 on both directions (well under cap).

164/164 tests green. Layer 1 (#38) complete; Layer 2 (#39) wire-
format publisher is the next milestone.

Refs: aiolabs/satmachineadmin#37 (parent), #38 (closes Layer 1).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-01 14:46:27 +02:00
commit 10f4b50ca5
2 changed files with 81 additions and 19 deletions

View file

@ -100,7 +100,11 @@ window.app = Vue.createApp({
superFeeDialog: {
show: false,
saving: false,
data: {super_fee_fraction: 0, super_fee_wallet_id: ''}
data: {
super_cash_in_fee_fraction: 0,
super_cash_out_fee_fraction: 0,
super_fee_wallet_id: ''
}
},
// UI configuration -----------------------------------------------
@ -266,6 +270,17 @@ window.app = Vue.createApp({
},
computed: {
superAnyFee() {
// Banner styling key — true when either directional super fee is
// non-zero, so the banner reads as "active platform fee" instead
// of the muted grey "free instance" state.
const c = this.superConfig
if (!c) return 0
return (
Number(c.super_cash_in_fee_fraction || 0) +
Number(c.super_cash_out_fee_fraction || 0)
)
},
walletOptions() {
// g.user is sometimes null on initial mount in LNbits 1.4 — guard it.
const wallets = this.g?.user?.wallets || []
@ -549,7 +564,10 @@ window.app = Vue.createApp({
// -----------------------------------------------------------------
openSuperFeeDialog() {
this.superFeeDialog.data = {
super_fee_fraction: this.superConfig?.super_fee_fraction ?? 0,
super_cash_in_fee_fraction:
this.superConfig?.super_cash_in_fee_fraction ?? 0,
super_cash_out_fee_fraction:
this.superConfig?.super_cash_out_fee_fraction ?? 0,
super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || ''
}
this.superFeeDialog.show = true
@ -562,7 +580,8 @@ window.app = Vue.createApp({
const {data} = await LNbits.api.request(
'PUT', SUPER_FEE_PATH, null,
{
super_fee_fraction: Number(d.super_fee_fraction),
super_cash_in_fee_fraction: Number(d.super_cash_in_fee_fraction),
super_cash_out_fee_fraction: Number(d.super_cash_out_fee_fraction),
super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null
}
)
@ -705,7 +724,9 @@ window.app = Vue.createApp({
location: machine.location || '',
wallet_id: machine.wallet_id,
fiat_code: machine.fiat_code,
is_active: machine.is_active
is_active: machine.is_active,
operator_cash_in_fee_fraction: machine.operator_cash_in_fee_fraction ?? 0,
operator_cash_out_fee_fraction: machine.operator_cash_out_fee_fraction ?? 0
}
this.editMachineDialog.show = true
},
@ -723,7 +744,9 @@ window.app = Vue.createApp({
location: d.location,
wallet_id: d.wallet_id,
fiat_code: d.fiat_code,
is_active: d.is_active
is_active: d.is_active,
operator_cash_in_fee_fraction: Number(d.operator_cash_in_fee_fraction) || 0,
operator_cash_out_fee_fraction: Number(d.operator_cash_out_fee_fraction) || 0
}
)
const idx = this.machines.findIndex(m => m.id === data.id)
@ -1475,7 +1498,9 @@ window.app = Vue.createApp({
wallet_id: null,
name: '',
location: '',
fiat_code: 'GTQ'
fiat_code: 'GTQ',
operator_cash_in_fee_fraction: 0,
operator_cash_out_fee_fraction: 0
}
},
@ -1485,7 +1510,9 @@ window.app = Vue.createApp({
wallet_id: d.wallet_id,
name: (d.name || '').trim() || null,
location: (d.location || '').trim() || null,
fiat_code: (d.fiat_code || 'GTQ').trim()
fiat_code: (d.fiat_code || 'GTQ').trim(),
operator_cash_in_fee_fraction: Number(d.operator_cash_in_fee_fraction) || 0,
operator_cash_out_fee_fraction: Number(d.operator_cash_out_fee_fraction) || 0
}
},

View file

@ -31,17 +31,19 @@
<q-banner
v-if="superConfig"
class="q-mb-md"
:class="superConfig.super_fee_fraction > 0 ? 'bg-blue-1 text-grey-9' : 'bg-grey-2 text-grey-9'">
:class="superAnyFee > 0 ? 'bg-blue-1 text-grey-9' : 'bg-grey-2 text-grey-9'">
<template v-slot:avatar>
<q-icon name="account_balance" :color="superConfig.super_fee_fraction > 0 ? 'blue' : 'grey'"></q-icon>
<q-icon name="account_balance" :color="superAnyFee > 0 ? 'blue' : 'grey'"></q-icon>
</template>
<span :style="{fontWeight: 500}">
LNbits platform fee:
<span :style="{color: '#1976d2'}">${ (superConfig.super_fee_fraction * 100).toFixed(2) }%</span>
of each transaction's commission.
<span :style="{color: '#1976d2'}">cash-in ${ (superConfig.super_cash_in_fee_fraction * 100).toFixed(2) }%</span>
·
<span :style="{color: '#1976d2'}">cash-out ${ (superConfig.super_cash_out_fee_fraction * 100).toFixed(2) }%</span>
of each transaction's principal.
</span>
<span :style="{opacity: 0.7, fontSize: '0.85em'}" class="q-ml-sm">
Your remainder splits per the rules below.
Operator's per-machine fee rides on top of these.
</span>
<template v-slot:action>
<q-btn v-if="g?.user?.super_user"
@ -792,6 +794,22 @@
hint="Currency the ATM dispenses (GTQ / USD / MXN / etc.)"
class="q-mb-md"
dense outlined></q-input>
<q-input
v-model.number="addMachineDialog.data.operator_cash_in_fee_fraction"
label="Operator cash-in fee % (decimal, 0..0.15)"
hint="Your per-machine cut on cash-in. Sits on top of the platform fee; cap is 15% total per direction."
type="number" step="0.0001" min="0" max="0.15"
class="q-mb-md"
dense outlined></q-input>
<q-input
v-model.number="addMachineDialog.data.operator_cash_out_fee_fraction"
label="Operator cash-out fee % (decimal, 0..0.15)"
hint="Your per-machine cut on cash-out. Sits on top of the platform fee; cap is 15% total per direction."
type="number" step="0.0001" min="0" max="0.15"
class="q-mb-md"
dense outlined></q-input>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat label="Cancel" v-close-popup></q-btn>
@ -1226,14 +1244,21 @@
</q-card-section>
<q-card-section>
<p class="text-caption q-mb-md" :style="{opacity: 0.7}">
Charged on every operator's commission across the LNbits instance.
Operators see this as a read-only banner. Wallet ID is where the
collected fee lands; typically a wallet you (the super) own.
Charged on every transaction's principal across the LNbits
instance. Independent per direction. Each direction's total
(super + operator) is capped at 15%. Operators see these as a
read-only banner. Wallet ID is where the collected fee lands;
typically a wallet you (the super) own.
</p>
<q-input v-model.number="superFeeDialog.data.super_fee_fraction"
label="Fee % (decimal, 0..1)"
hint="0.30 = 30% of every operator's commission"
type="number" step="0.0001" min="0" max="1"
<q-input v-model.number="superFeeDialog.data.super_cash_in_fee_fraction"
label="Cash-in fee % (decimal, 0..0.15)"
hint="0.03 = 3% of principal on cash-in transactions"
type="number" step="0.0001" min="0" max="0.15"
class="q-mb-md" dense outlined></q-input>
<q-input v-model.number="superFeeDialog.data.super_cash_out_fee_fraction"
label="Cash-out fee % (decimal, 0..0.15)"
hint="0.03 = 3% of principal on cash-out transactions"
type="number" step="0.0001" min="0" max="0.15"
class="q-mb-md" dense outlined></q-input>
<q-input v-model="superFeeDialog.data.super_fee_wallet_id"
label="Super fee destination wallet_id"
@ -1484,6 +1509,16 @@
dense outlined></q-select>
<q-input v-model="editMachineDialog.data.fiat_code"
label="Fiat code" class="q-mb-md" dense outlined></q-input>
<q-input v-model.number="editMachineDialog.data.operator_cash_in_fee_fraction"
label="Operator cash-in fee % (decimal, 0..0.15)"
hint="Sits on top of the platform cash-in fee. Cap 15% total per direction."
type="number" step="0.0001" min="0" max="0.15"
class="q-mb-md" dense outlined></q-input>
<q-input v-model.number="editMachineDialog.data.operator_cash_out_fee_fraction"
label="Operator cash-out fee % (decimal, 0..0.15)"
hint="Sits on top of the platform cash-out fee. Cap 15% total per direction."
type="number" step="0.0001" min="0" max="0.15"
class="q-mb-md" dense outlined></q-input>
<q-toggle v-model="editMachineDialog.data.is_active"
label="Active (receives settlements)" class="q-mb-md"></q-toggle>
</q-card-section>