feat(ui): choose how a late payday is priced
Surfaces the pricing modes in the console: a selector plus a rate field on
both the create and edit dialogs, and a "Rate used" column in the ledger
showing the figure alongside where it came from, so a payout can be
explained without opening the database.
Pay-now becomes a dialog rather than a confirm. That is the moment an
operator is most likely to want a rate other than the contract's own —
entering a payment that happened weeks ago at a figure they already know —
and the choice has to be made before it settles, not after. It defaults to
the contract's mode, disables the rate field unless "Rate I enter" is
picked, and refuses to submit a manual mode with no rate. The success toast
now names the rate applied, and a refused payout still shows its reason.
The mode selector is disabled for sat-denominated contracts, where there is
no conversion to have an opinion about, and its hint says the setting only
applies to a payday already in the past — otherwise it reads as though it
governs every payout, which it does not.
Quasar UMD rules honoured: no self-closing tags, `${ }` delimiters,
`:style` bindings rather than a <style> block.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
This commit is contained in:
parent
18a17cd693
commit
3504bf2b02
3 changed files with 179 additions and 29 deletions
|
|
@ -42,6 +42,42 @@ left on the employee's wallet. It was never settled and expires on its own;
|
||||||
that is the deliberate price of not pricing the period a second time just
|
that is the deliberate price of not pricing the period a second time just
|
||||||
to run a balance check.
|
to run a balance check.
|
||||||
|
|
||||||
|
## Pricing a back-dated period
|
||||||
|
|
||||||
|
A period whose payday is **today or ahead** is priced by LNbits itself —
|
||||||
|
`create_invoice(amount, currency)` does the conversion, and nothing here
|
||||||
|
touches the network. Every pricing mode agrees in that case.
|
||||||
|
|
||||||
|
A period whose payday is **in the past** is where the modes diverge:
|
||||||
|
|
||||||
|
| mode | converts at | use when |
|
||||||
|
|---|---|---|
|
||||||
|
| `payday` (default) | BTC's value on the payday | the expense belongs to that date |
|
||||||
|
| `current` | today's rate | the debt reads "we owe EUR 800, whenever it settles" |
|
||||||
|
| `manual` | a rate you state (`100000` EUR/BTC) | the figure was agreed, not looked up |
|
||||||
|
|
||||||
|
Set per contract; overridable per payout via `pricing_mode` / `manual_rate`
|
||||||
|
on `pay-now`, which is how you enter one back-dated payment without editing
|
||||||
|
the contract.
|
||||||
|
|
||||||
|
Historical rates come from `rates.py` — Kraken's daily OHLC series first
|
||||||
|
(one request covers every date in a backfill), CoinGecko by date as the
|
||||||
|
fallback for currencies Kraken does not quote. **Not** from LNbits' own
|
||||||
|
exchange providers: those are spot tickers with no date parameter, and the
|
||||||
|
rate history behind the admin chart is RAM-only, single-currency and wiped
|
||||||
|
on restart.
|
||||||
|
|
||||||
|
Where payroll converts, it hands `create_invoice` a **sat** amount, because
|
||||||
|
`create_invoice` always prices fiat itself and cannot be told a rate. The
|
||||||
|
rate used and its source are recorded on the payout row either way — in
|
||||||
|
`current` mode read back off the invoice LNbits priced, not recomputed.
|
||||||
|
|
||||||
|
**A rate that cannot be established fails the period.** There is deliberately
|
||||||
|
no fallback to today's rate: one that moved 30% since the payday would pay
|
||||||
|
30% off and record nothing about it. The failure goes through the same
|
||||||
|
retry-then-pause path as an underfunded wallet, and names the date and
|
||||||
|
currency it could not price.
|
||||||
|
|
||||||
## What a failure does
|
## What a failure does
|
||||||
|
|
||||||
A failed period does **not** advance `periods_done`, so the next tick
|
A failed period does **not** advance `periods_done`, so the next tick
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,12 @@ const FREQUENCIES = [
|
||||||
{value: 'yearly', label: 'Yearly'}
|
{value: 'yearly', label: 'Yearly'}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const PRICING_MODES = [
|
||||||
|
{value: 'payday', label: "Rate on the payday"},
|
||||||
|
{value: 'current', label: "Rate at settlement"},
|
||||||
|
{value: 'manual', label: 'Rate I enter'}
|
||||||
|
]
|
||||||
|
|
||||||
const STATUS_COLOUR = {
|
const STATUS_COLOUR = {
|
||||||
active: 'positive',
|
active: 'positive',
|
||||||
paused: 'warning',
|
paused: 'warning',
|
||||||
|
|
@ -45,7 +51,11 @@ function emptyContract() {
|
||||||
total_periods: 12,
|
total_periods: 12,
|
||||||
label: '',
|
label: '',
|
||||||
memo: '',
|
memo: '',
|
||||||
backfill: false
|
backfill: false,
|
||||||
|
// Only ever consulted for a payday in the past; for a payday today or
|
||||||
|
// ahead every mode collapses to LNbits' own live pricing.
|
||||||
|
pricing_mode: 'payday',
|
||||||
|
manual_rate: null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,6 +79,14 @@ window.app = Vue.createApp({
|
||||||
frequencyOptions: FREQUENCIES,
|
frequencyOptions: FREQUENCIES,
|
||||||
|
|
||||||
contractDialog: {show: false, data: emptyContract(), saving: false},
|
contractDialog: {show: false, data: emptyContract(), saving: false},
|
||||||
|
payNowDialog: {
|
||||||
|
show: false,
|
||||||
|
contract: null,
|
||||||
|
pricing_mode: 'payday',
|
||||||
|
manual_rate: null,
|
||||||
|
loading: false
|
||||||
|
},
|
||||||
|
pricingModes: PRICING_MODES,
|
||||||
// Live calendar of whatever is currently typed into the dialog. The
|
// Live calendar of whatever is currently typed into the dialog. The
|
||||||
// cheapest moment to notice a wrong start date or frequency is before
|
// cheapest moment to notice a wrong start date or frequency is before
|
||||||
// anything is saved.
|
// anything is saved.
|
||||||
|
|
@ -121,6 +139,7 @@ window.app = Vue.createApp({
|
||||||
align: 'left',
|
align: 'left',
|
||||||
field: 'contract_id'
|
field: 'contract_id'
|
||||||
},
|
},
|
||||||
|
{name: 'rate', label: 'Rate used', align: 'right', field: 'rate'},
|
||||||
{name: 'attempt', label: 'Try', align: 'center', field: 'attempt'},
|
{name: 'attempt', label: 'Try', align: 'center', field: 'attempt'},
|
||||||
{name: 'detail', label: 'Detail', align: 'left', field: 'detail'}
|
{name: 'detail', label: 'Detail', align: 'left', field: 'detail'}
|
||||||
]
|
]
|
||||||
|
|
@ -331,7 +350,9 @@ window.app = Vue.createApp({
|
||||||
total_periods: d.open_ended ? null : Number(d.total_periods),
|
total_periods: d.open_ended ? null : Number(d.total_periods),
|
||||||
label: d.label,
|
label: d.label,
|
||||||
memo: d.memo,
|
memo: d.memo,
|
||||||
backfill: d.backfill
|
backfill: d.backfill,
|
||||||
|
pricing_mode: d.pricing_mode,
|
||||||
|
manual_rate: d.pricing_mode === 'manual' ? Number(d.manual_rate) : null
|
||||||
})
|
})
|
||||||
this.contractDialog.show = false
|
this.contractDialog.show = false
|
||||||
Quasar.Notify.create({type: 'positive', message: 'Payroll line created'})
|
Quasar.Notify.create({type: 'positive', message: 'Payroll line created'})
|
||||||
|
|
@ -354,7 +375,9 @@ window.app = Vue.createApp({
|
||||||
open_ended: contract.total_periods === null,
|
open_ended: contract.total_periods === null,
|
||||||
total_periods: contract.total_periods,
|
total_periods: contract.total_periods,
|
||||||
label: contract.label,
|
label: contract.label,
|
||||||
memo: contract.memo
|
memo: contract.memo,
|
||||||
|
pricing_mode: contract.pricing_mode,
|
||||||
|
manual_rate: contract.manual_rate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -372,7 +395,9 @@ window.app = Vue.createApp({
|
||||||
frequency: d.frequency,
|
frequency: d.frequency,
|
||||||
total_periods: d.open_ended ? null : Number(d.total_periods),
|
total_periods: d.open_ended ? null : Number(d.total_periods),
|
||||||
label: d.label,
|
label: d.label,
|
||||||
memo: d.memo
|
memo: d.memo,
|
||||||
|
pricing_mode: d.pricing_mode,
|
||||||
|
manual_rate: d.pricing_mode === 'manual' ? Number(d.manual_rate) : null
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
this.editDialog.show = false
|
this.editDialog.show = false
|
||||||
|
|
@ -416,16 +441,29 @@ window.app = Vue.createApp({
|
||||||
},
|
},
|
||||||
|
|
||||||
payNow(contract) {
|
payNow(contract) {
|
||||||
LNbits.utils
|
// A dialog rather than a confirm: paying a back-dated period is
|
||||||
.confirmDialog(
|
// exactly when the operator may want a different rate than the
|
||||||
`Pay the next period of "${contract.label || contract.id}" now? ` +
|
// contract's own, and that choice has to be made before it settles.
|
||||||
'It is consumed even if its payday has not arrived yet.'
|
this.payNowDialog = {
|
||||||
)
|
show: true,
|
||||||
.onOk(async () => {
|
contract,
|
||||||
|
pricing_mode: contract.pricing_mode || 'payday',
|
||||||
|
manual_rate: contract.manual_rate,
|
||||||
|
loading: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async confirmPayNow() {
|
||||||
|
const d = this.payNowDialog
|
||||||
|
const params = new URLSearchParams({pricing_mode: d.pricing_mode})
|
||||||
|
if (d.pricing_mode === 'manual' && d.manual_rate) {
|
||||||
|
params.append('manual_rate', String(d.manual_rate))
|
||||||
|
}
|
||||||
|
d.loading = true
|
||||||
try {
|
try {
|
||||||
const {data} = await LNbits.api.request(
|
const {data} = await LNbits.api.request(
|
||||||
'POST',
|
'POST',
|
||||||
`${API}/contracts/${contract.id}/pay-now`
|
`${API}/contracts/${d.contract.id}/pay-now?${params.toString()}`
|
||||||
)
|
)
|
||||||
// A refused payout still returns a ledger row — surfacing its
|
// A refused payout still returns a ledger row — surfacing its
|
||||||
// reason is the whole point of triggering one by hand.
|
// reason is the whole point of triggering one by hand.
|
||||||
|
|
@ -433,16 +471,19 @@ window.app = Vue.createApp({
|
||||||
type: data.status === 'paid' ? 'positive' : 'negative',
|
type: data.status === 'paid' ? 'positive' : 'negative',
|
||||||
message:
|
message:
|
||||||
data.status === 'paid'
|
data.status === 'paid'
|
||||||
? `Paid ${Math.round(data.amount_msat / 1000)} sat`
|
? `Paid ${Math.round(data.amount_msat / 1000)} sat` +
|
||||||
|
(data.rate ? ` at ${data.rate.toLocaleString()} / BTC` : '')
|
||||||
: `Payout ${data.status}: ${data.detail}`,
|
: `Payout ${data.status}: ${data.detail}`,
|
||||||
timeout: data.status === 'paid' ? 3000 : 8000
|
timeout: data.status === 'paid' ? 4000 : 9000
|
||||||
})
|
})
|
||||||
|
d.show = false
|
||||||
this.getContracts()
|
this.getContracts()
|
||||||
this.getPayouts()
|
this.getPayouts()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._err(err, 'Could not pay now')
|
this._err(err, 'Could not pay now')
|
||||||
|
} finally {
|
||||||
|
d.loading = false
|
||||||
}
|
}
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteContract(contract) {
|
deleteContract(contract) {
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,18 @@
|
||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:body-cell-rate="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<span v-if="props.row.rate">
|
||||||
|
${ Math.round(props.row.rate).toLocaleString() }
|
||||||
|
<span :style="{opacity: 0.6, fontSize: '0.8em'}">
|
||||||
|
${ props.row.rate_source }
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span v-else :style="{opacity: 0.4}">—</span>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-slot:body-cell-detail="props">
|
<template v-slot:body-cell-detail="props">
|
||||||
<q-td :props="props">
|
<q-td :props="props">
|
||||||
<span :style="{opacity: 0.75, fontSize: '0.85em'}">
|
<span :style="{opacity: 0.75, fontSize: '0.85em'}">
|
||||||
|
|
@ -252,6 +264,22 @@
|
||||||
<q-input outlined dense v-model="contractDialog.data.memo"
|
<q-input outlined dense v-model="contractDialog.data.memo"
|
||||||
label="Payment memo" hint="What the employee sees on the payment"></q-input>
|
label="Payment memo" hint="What the employee sees on the payment"></q-input>
|
||||||
|
|
||||||
|
<div class="row q-col-gutter-sm">
|
||||||
|
<div class="col-7">
|
||||||
|
<q-select outlined dense v-model="contractDialog.data.pricing_mode"
|
||||||
|
:options="pricingModes" emit-value map-options
|
||||||
|
:disable="contractDialog.data.currency === 'sat'"
|
||||||
|
label="Price a late payday at"
|
||||||
|
hint="Only applies to a payday already in the past"></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-5">
|
||||||
|
<q-input outlined dense type="number" step="any"
|
||||||
|
v-model.number="contractDialog.data.manual_rate"
|
||||||
|
:disable="contractDialog.data.pricing_mode !== 'manual'"
|
||||||
|
:label="'Rate (' + contractDialog.data.currency + '/BTC)'"></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<q-toggle v-model="contractDialog.data.backfill"
|
<q-toggle v-model="contractDialog.data.backfill"
|
||||||
label="Pay paydays that already passed">
|
label="Pay paydays that already passed">
|
||||||
<q-tooltip>
|
<q-tooltip>
|
||||||
|
|
@ -324,6 +352,19 @@
|
||||||
label="Number of payments"></q-input>
|
label="Number of payments"></q-input>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row q-col-gutter-sm">
|
||||||
|
<div class="col-7">
|
||||||
|
<q-select outlined dense v-model="editDialog.data.pricing_mode"
|
||||||
|
:options="pricingModes" emit-value map-options
|
||||||
|
label="Price a late payday at"></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-5">
|
||||||
|
<q-input outlined dense type="number" step="any"
|
||||||
|
v-model.number="editDialog.data.manual_rate"
|
||||||
|
:disable="editDialog.data.pricing_mode !== 'manual'"
|
||||||
|
label="Rate per BTC"></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<q-input outlined dense v-model="editDialog.data.label" label="Label"></q-input>
|
<q-input outlined dense v-model="editDialog.data.label" label="Label"></q-input>
|
||||||
<q-input outlined dense v-model="editDialog.data.memo"
|
<q-input outlined dense v-model="editDialog.data.memo"
|
||||||
label="Payment memo"></q-input>
|
label="Payment memo"></q-input>
|
||||||
|
|
@ -335,6 +376,38 @@
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
|
<!-- ==================================================== pay now -->
|
||||||
|
<q-dialog v-model="payNowDialog.show">
|
||||||
|
<q-card style="width: 460px; max-width: 95vw">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6">Pay the next period now</div>
|
||||||
|
<div v-if="payNowDialog.contract" class="text-caption"
|
||||||
|
:style="{opacity: 0.7}">
|
||||||
|
${ payNowDialog.contract.label || payNowDialog.contract.employee_username }
|
||||||
|
— ${ payNowDialog.contract.amount } ${ payNowDialog.contract.currency }.
|
||||||
|
The period is consumed even if its payday has not arrived yet.
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-gutter-md">
|
||||||
|
<q-select outlined dense v-model="payNowDialog.pricing_mode"
|
||||||
|
:options="pricingModes" emit-value map-options
|
||||||
|
label="Price it at"
|
||||||
|
hint="Ignored unless the payday is already in the past"></q-select>
|
||||||
|
<q-input outlined dense type="number" step="any"
|
||||||
|
v-model.number="payNowDialog.manual_rate"
|
||||||
|
:disable="payNowDialog.pricing_mode !== 'manual'"
|
||||||
|
label="Rate per BTC"
|
||||||
|
hint="e.g. 100000 for 100,000 per BTC"></q-input>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn flat label="Cancel" v-close-popup></q-btn>
|
||||||
|
<q-btn color="primary" label="Pay now" :loading="payNowDialog.loading"
|
||||||
|
:disable="payNowDialog.pricing_mode === 'manual' && !payNowDialog.manual_rate"
|
||||||
|
@click="confirmPayNow"></q-btn>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
<!-- =================================================== schedule -->
|
<!-- =================================================== schedule -->
|
||||||
<q-dialog v-model="scheduleDialog.show">
|
<q-dialog v-model="scheduleDialog.show">
|
||||||
<q-card style="width: 420px; max-width: 95vw">
|
<q-card style="width: 420px; max-width: 95vw">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue