diff --git a/docs/operations.md b/docs/operations.md index 5a9b8fd..813e753 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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 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 A failed period does **not** advance `periods_done`, so the next tick diff --git a/static/js/index.js b/static/js/index.js index abf6f95..4f88d7a 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -15,6 +15,12 @@ const FREQUENCIES = [ {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 = { active: 'positive', paused: 'warning', @@ -45,7 +51,11 @@ function emptyContract() { total_periods: 12, label: '', 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, 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 // cheapest moment to notice a wrong start date or frequency is before // anything is saved. @@ -121,6 +139,7 @@ window.app = Vue.createApp({ align: 'left', field: 'contract_id' }, + {name: 'rate', label: 'Rate used', align: 'right', field: 'rate'}, {name: 'attempt', label: 'Try', align: 'center', field: 'attempt'}, {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), label: d.label, 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 Quasar.Notify.create({type: 'positive', message: 'Payroll line created'}) @@ -354,7 +375,9 @@ window.app = Vue.createApp({ open_ended: contract.total_periods === null, total_periods: contract.total_periods, 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, total_periods: d.open_ended ? null : Number(d.total_periods), 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 @@ -416,33 +441,49 @@ window.app = Vue.createApp({ }, payNow(contract) { - LNbits.utils - .confirmDialog( - `Pay the next period of "${contract.label || contract.id}" now? ` + - 'It is consumed even if its payday has not arrived yet.' + // A dialog rather than a confirm: paying a back-dated period is + // exactly when the operator may want a different rate than the + // contract's own, and that choice has to be made before it settles. + this.payNowDialog = { + show: true, + 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 { + const {data} = await LNbits.api.request( + 'POST', + `${API}/contracts/${d.contract.id}/pay-now?${params.toString()}` ) - .onOk(async () => { - try { - const {data} = await LNbits.api.request( - 'POST', - `${API}/contracts/${contract.id}/pay-now` - ) - // A refused payout still returns a ledger row — surfacing its - // reason is the whole point of triggering one by hand. - Quasar.Notify.create({ - type: data.status === 'paid' ? 'positive' : 'negative', - message: - data.status === 'paid' - ? `Paid ${Math.round(data.amount_msat / 1000)} sat` - : `Payout ${data.status}: ${data.detail}`, - timeout: data.status === 'paid' ? 3000 : 8000 - }) - this.getContracts() - this.getPayouts() - } catch (err) { - this._err(err, 'Could not pay now') - } + // A refused payout still returns a ledger row — surfacing its + // reason is the whole point of triggering one by hand. + Quasar.Notify.create({ + type: data.status === 'paid' ? 'positive' : 'negative', + message: + data.status === 'paid' + ? `Paid ${Math.round(data.amount_msat / 1000)} sat` + + (data.rate ? ` at ${data.rate.toLocaleString()} / BTC` : '') + : `Payout ${data.status}: ${data.detail}`, + timeout: data.status === 'paid' ? 4000 : 9000 }) + d.show = false + this.getContracts() + this.getPayouts() + } catch (err) { + this._err(err, 'Could not pay now') + } finally { + d.loading = false + } }, deleteContract(contract) { diff --git a/templates/payroll/index.html b/templates/payroll/index.html index d28a7a2..0e5ca71 100644 --- a/templates/payroll/index.html +++ b/templates/payroll/index.html @@ -151,6 +151,18 @@ + +