feat(v2)(ui): per-direction fee inputs in super-config + machine modals (#38 5/5)
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:
parent
d9e8a04b8b
commit
10f4b50ca5
2 changed files with 81 additions and 19 deletions
|
|
@ -100,7 +100,11 @@ window.app = Vue.createApp({
|
||||||
superFeeDialog: {
|
superFeeDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
saving: 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 -----------------------------------------------
|
// UI configuration -----------------------------------------------
|
||||||
|
|
@ -266,6 +270,17 @@ window.app = Vue.createApp({
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
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() {
|
walletOptions() {
|
||||||
// g.user is sometimes null on initial mount in LNbits 1.4 — guard it.
|
// g.user is sometimes null on initial mount in LNbits 1.4 — guard it.
|
||||||
const wallets = this.g?.user?.wallets || []
|
const wallets = this.g?.user?.wallets || []
|
||||||
|
|
@ -549,7 +564,10 @@ window.app = Vue.createApp({
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
openSuperFeeDialog() {
|
openSuperFeeDialog() {
|
||||||
this.superFeeDialog.data = {
|
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 || ''
|
super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || ''
|
||||||
}
|
}
|
||||||
this.superFeeDialog.show = true
|
this.superFeeDialog.show = true
|
||||||
|
|
@ -562,7 +580,8 @@ window.app = Vue.createApp({
|
||||||
const {data} = await LNbits.api.request(
|
const {data} = await LNbits.api.request(
|
||||||
'PUT', SUPER_FEE_PATH, null,
|
'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
|
super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -705,7 +724,9 @@ window.app = Vue.createApp({
|
||||||
location: machine.location || '',
|
location: machine.location || '',
|
||||||
wallet_id: machine.wallet_id,
|
wallet_id: machine.wallet_id,
|
||||||
fiat_code: machine.fiat_code,
|
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
|
this.editMachineDialog.show = true
|
||||||
},
|
},
|
||||||
|
|
@ -723,7 +744,9 @@ window.app = Vue.createApp({
|
||||||
location: d.location,
|
location: d.location,
|
||||||
wallet_id: d.wallet_id,
|
wallet_id: d.wallet_id,
|
||||||
fiat_code: d.fiat_code,
|
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)
|
const idx = this.machines.findIndex(m => m.id === data.id)
|
||||||
|
|
@ -1475,7 +1498,9 @@ window.app = Vue.createApp({
|
||||||
wallet_id: null,
|
wallet_id: null,
|
||||||
name: '',
|
name: '',
|
||||||
location: '',
|
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,
|
wallet_id: d.wallet_id,
|
||||||
name: (d.name || '').trim() || null,
|
name: (d.name || '').trim() || null,
|
||||||
location: (d.location || '').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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,17 +31,19 @@
|
||||||
<q-banner
|
<q-banner
|
||||||
v-if="superConfig"
|
v-if="superConfig"
|
||||||
class="q-mb-md"
|
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>
|
<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>
|
</template>
|
||||||
<span :style="{fontWeight: 500}">
|
<span :style="{fontWeight: 500}">
|
||||||
LNbits platform fee:
|
LNbits platform fee:
|
||||||
<span :style="{color: '#1976d2'}">${ (superConfig.super_fee_fraction * 100).toFixed(2) }%</span>
|
<span :style="{color: '#1976d2'}">cash-in ${ (superConfig.super_cash_in_fee_fraction * 100).toFixed(2) }%</span>
|
||||||
of each transaction's commission.
|
·
|
||||||
|
<span :style="{color: '#1976d2'}">cash-out ${ (superConfig.super_cash_out_fee_fraction * 100).toFixed(2) }%</span>
|
||||||
|
of each transaction's principal.
|
||||||
</span>
|
</span>
|
||||||
<span :style="{opacity: 0.7, fontSize: '0.85em'}" class="q-ml-sm">
|
<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>
|
</span>
|
||||||
<template v-slot:action>
|
<template v-slot:action>
|
||||||
<q-btn v-if="g?.user?.super_user"
|
<q-btn v-if="g?.user?.super_user"
|
||||||
|
|
@ -792,6 +794,22 @@
|
||||||
hint="Currency the ATM dispenses (GTQ / USD / MXN / etc.)"
|
hint="Currency the ATM dispenses (GTQ / USD / MXN / etc.)"
|
||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
dense outlined></q-input>
|
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-section>
|
||||||
<q-card-actions align="right" class="text-primary">
|
<q-card-actions align="right" class="text-primary">
|
||||||
<q-btn flat label="Cancel" v-close-popup></q-btn>
|
<q-btn flat label="Cancel" v-close-popup></q-btn>
|
||||||
|
|
@ -1226,14 +1244,21 @@
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<p class="text-caption q-mb-md" :style="{opacity: 0.7}">
|
<p class="text-caption q-mb-md" :style="{opacity: 0.7}">
|
||||||
Charged on every operator's commission across the LNbits instance.
|
Charged on every transaction's principal across the LNbits
|
||||||
Operators see this as a read-only banner. Wallet ID is where the
|
instance. Independent per direction. Each direction's total
|
||||||
collected fee lands; typically a wallet you (the super) own.
|
(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>
|
</p>
|
||||||
<q-input v-model.number="superFeeDialog.data.super_fee_fraction"
|
<q-input v-model.number="superFeeDialog.data.super_cash_in_fee_fraction"
|
||||||
label="Fee % (decimal, 0..1)"
|
label="Cash-in fee % (decimal, 0..0.15)"
|
||||||
hint="0.30 = 30% of every operator's commission"
|
hint="0.03 = 3% of principal on cash-in transactions"
|
||||||
type="number" step="0.0001" min="0" max="1"
|
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>
|
class="q-mb-md" dense outlined></q-input>
|
||||||
<q-input v-model="superFeeDialog.data.super_fee_wallet_id"
|
<q-input v-model="superFeeDialog.data.super_fee_wallet_id"
|
||||||
label="Super fee destination wallet_id"
|
label="Super fee destination wallet_id"
|
||||||
|
|
@ -1484,6 +1509,16 @@
|
||||||
dense outlined></q-select>
|
dense outlined></q-select>
|
||||||
<q-input v-model="editMachineDialog.data.fiat_code"
|
<q-input v-model="editMachineDialog.data.fiat_code"
|
||||||
label="Fiat code" class="q-mb-md" dense outlined></q-input>
|
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"
|
<q-toggle v-model="editMachineDialog.data.is_active"
|
||||||
label="Active (receives settlements)" class="q-mb-md"></q-toggle>
|
label="Active (receives settlements)" class="q-mb-md"></q-toggle>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue