Found by tracing what happens when a back-dated backfill contract cannot fetch a historical rate. The failure handling itself was fine — period 0 fails, the backlog halts so nothing settles out of order, five ledger rows record the reason, no money moves, and the contract auto-pauses once the retry budget is spent. The recovery was not. The operator fixes the cause (switches to a stated rate, or to current), clicks Resume, and periods_done jumps 0 -> 6: every unpaid payday silently written off, contract back to looking healthy, employee never paid. The confirm dialog even asserted the missed paydays "are written off" — true of one kind of pause and a lie about the other. Two features colliding. "Do not backfill a deliberate pause" is right when the operator paused: the pause *was* the decision not to pay. It is wrong when payroll paused, because nobody decided anything — the money is still owed and the operator has just removed whatever blocked it. Contracts now carry `paused_reason`, set only when payroll pauses them and cleared by a deliberate pause. Resume infers from it, and an explicit `catch_up` still overrides either way. The console asks a different question for each, quoting the reason, and flags a payroll-paused contract in the table so the distinction is visible before anyone clicks. Verified end to end: five failing ticks leave periods_done at 0 and pause with "period 0 (2026-08-01) failed 5 times: no historical EUR rate available for 2026-08-01"; resuming after switching to a manual rate keeps the position at 0, and the next tick settles all seven owed periods. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
553 lines
17 KiB
JavaScript
553 lines
17 KiB
JavaScript
// Payroll operator console — LNbits page shell (Vue 3 + Quasar 2 UMD, no
|
|
// build step). Gotchas honored: no self-closing tags in the template,
|
|
// `${ }` interpolation so Jinja never sees a moustache, and `:style`
|
|
// bindings rather than <style> blocks for typography, which LNbits themes
|
|
// override with !important. See workspace CLAUDE.md.
|
|
|
|
const API = '/payroll/api/v1'
|
|
|
|
const FREQUENCIES = [
|
|
{value: 'daily', label: 'Daily'},
|
|
{value: 'weekly', label: 'Weekly'},
|
|
{value: 'biweekly', label: 'Every two weeks'},
|
|
{value: 'monthly', label: 'Monthly'},
|
|
{value: 'quarterly', label: 'Quarterly'},
|
|
{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',
|
|
completed: 'grey',
|
|
cancelled: 'grey-7',
|
|
paid: 'positive',
|
|
skipped: 'grey',
|
|
failed: 'negative'
|
|
}
|
|
|
|
function today() {
|
|
return new Date().toISOString().slice(0, 10)
|
|
}
|
|
|
|
function emptyContract() {
|
|
return {
|
|
employee_id: null,
|
|
employee_wallet: null,
|
|
source_wallet: null,
|
|
amount: null,
|
|
currency: 'sat',
|
|
frequency: 'monthly',
|
|
start_date: today(),
|
|
// The form models duration as a toggle plus a count, because
|
|
// "open-ended" and "12 payments" are the two things an operator
|
|
// actually says; the API takes total_periods = null for the former.
|
|
open_ended: false,
|
|
total_periods: 12,
|
|
label: '',
|
|
memo: '',
|
|
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
|
|
}
|
|
}
|
|
|
|
window.app = Vue.createApp({
|
|
el: '#vue',
|
|
mixins: [windowMixin],
|
|
delimiters: ['${', '}'],
|
|
|
|
data() {
|
|
return {
|
|
tab: 'contracts',
|
|
|
|
contracts: [],
|
|
contractsLoading: false,
|
|
payouts: [],
|
|
payoutsLoading: false,
|
|
|
|
users: [],
|
|
currencyOptions: ['sat'],
|
|
currencyFilter: ['sat'],
|
|
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.
|
|
preview: null,
|
|
editDialog: {show: false, id: null, data: {}},
|
|
scheduleDialog: {show: false, contract: null, preview: null},
|
|
|
|
ledgerFilter: {contract_id: null, status: null, since: '', until: ''},
|
|
|
|
contractsColumns: [
|
|
{
|
|
name: 'employee',
|
|
label: 'Employee',
|
|
align: 'left',
|
|
field: row => row.label || row.employee_username || row.employee_id
|
|
},
|
|
{
|
|
name: 'amount',
|
|
label: 'Amount',
|
|
align: 'right',
|
|
field: row => `${row.amount} ${row.currency}`
|
|
},
|
|
{name: 'frequency', label: 'Every', align: 'left', field: 'frequency'},
|
|
{name: 'next', label: 'Next payday', align: 'left', field: 'id'},
|
|
{name: 'progress', label: 'Paid', align: 'center', field: 'id'},
|
|
{name: 'status', label: 'Status', align: 'center', field: 'status'},
|
|
{name: 'actions', label: '', align: 'right', field: 'id'}
|
|
],
|
|
payoutsColumns: [
|
|
{name: 'payday', label: 'Payday', align: 'left', field: 'payday'},
|
|
{name: 'status', label: 'Status', align: 'center', field: 'status'},
|
|
{
|
|
name: 'amount',
|
|
label: 'Amount',
|
|
align: 'right',
|
|
field: row =>
|
|
row.amount_msat === null
|
|
? '—'
|
|
: `${Math.round(row.amount_msat / 1000)} sat`
|
|
},
|
|
{
|
|
name: 'instruction',
|
|
label: 'Contract terms',
|
|
align: 'right',
|
|
field: row => `${row.amount} ${row.currency}`
|
|
},
|
|
{
|
|
name: 'contract',
|
|
label: 'Contract',
|
|
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'}
|
|
]
|
|
}
|
|
},
|
|
|
|
computed: {
|
|
userOptions() {
|
|
return this.users.map(u => ({
|
|
label: u.username || u.email || u.id,
|
|
value: u.id,
|
|
caption: `${u.wallets.length} wallet(s)`
|
|
}))
|
|
},
|
|
|
|
// Wallets belonging to the employee currently selected in the dialog.
|
|
// The API rejects a wallet that is not theirs, so narrowing the picker
|
|
// is about not offering the mistake in the first place.
|
|
employeeWalletOptions() {
|
|
const user = this.users.find(
|
|
u => u.id === this.contractDialog.data.employee_id
|
|
)
|
|
if (!user) return []
|
|
return user.wallets.map(w => ({
|
|
label: `${w.name} — ${Math.round(w.balance_msat / 1000)} sat`,
|
|
value: w.id
|
|
}))
|
|
},
|
|
|
|
sourceWalletOptions() {
|
|
return (this.g.user.wallets || []).map(w => ({
|
|
label: w.name,
|
|
value: w.id
|
|
}))
|
|
},
|
|
|
|
contractOptions() {
|
|
return this.contracts.map(c => ({
|
|
label: `${c.label || c.employee_username || c.id} (${c.id})`,
|
|
value: c.id
|
|
}))
|
|
},
|
|
|
|
canSaveContract() {
|
|
const d = this.contractDialog.data
|
|
return !!(
|
|
d.employee_id &&
|
|
d.employee_wallet &&
|
|
d.source_wallet &&
|
|
d.amount > 0 &&
|
|
d.start_date
|
|
)
|
|
},
|
|
|
|
// Which period pay-now would settle, and whether it is actually in the
|
|
// past. "Rate on the payday" cannot mean anything for a date that has
|
|
// not happened, and the dialog should say so rather than offer a choice
|
|
// that quietly does nothing.
|
|
payNowPayday() {
|
|
const c = this.payNowDialog.contract
|
|
return c ? c.next_payday : null
|
|
},
|
|
|
|
payNowIsBackdated() {
|
|
return !!this.payNowPayday && this.payNowPayday < today()
|
|
},
|
|
|
|
ledgerCsvHref() {
|
|
const f = this.ledgerFilter
|
|
const params = new URLSearchParams()
|
|
if (f.contract_id) params.append('contract_id', f.contract_id)
|
|
if (f.status) params.append('status', f.status)
|
|
if (f.since) params.append('since', f.since)
|
|
if (f.until) params.append('until', f.until)
|
|
return `${API}/payouts.csv?${params.toString()}`
|
|
}
|
|
},
|
|
|
|
watch: {
|
|
// Redraw the calendar whenever a term that shapes it changes.
|
|
'contractDialog.data.start_date': 'refreshPreview',
|
|
'contractDialog.data.frequency': 'refreshPreview',
|
|
'contractDialog.data.total_periods': 'refreshPreview',
|
|
'contractDialog.data.open_ended': 'refreshPreview'
|
|
},
|
|
|
|
methods: {
|
|
statusColour(status) {
|
|
return STATUS_COLOUR[status] || 'grey'
|
|
},
|
|
|
|
_err(err, fallback) {
|
|
if (err && err.response) return LNbits.utils.notifyApiError(err)
|
|
Quasar.Notify.create({type: 'negative', message: fallback})
|
|
},
|
|
|
|
// --- loading ----------------------------------------------------------
|
|
|
|
async getUsers() {
|
|
try {
|
|
const {data} = await LNbits.api.request('GET', `${API}/users`)
|
|
this.users = data
|
|
} catch (err) {
|
|
this._err(err, 'Could not load the account directory')
|
|
}
|
|
},
|
|
|
|
async getCurrencies() {
|
|
try {
|
|
const {data} = await LNbits.api.request('GET', `${API}/currencies`)
|
|
this.currencyOptions = data
|
|
this.currencyFilter = data
|
|
} catch (err) {
|
|
this._err(err, 'Could not load currencies')
|
|
}
|
|
},
|
|
|
|
filterCurrencies(needle, update) {
|
|
update(() => {
|
|
const term = (needle || '').toLowerCase()
|
|
this.currencyFilter = this.currencyOptions.filter(c =>
|
|
c.toLowerCase().includes(term)
|
|
)
|
|
})
|
|
},
|
|
|
|
async getContracts() {
|
|
this.contractsLoading = true
|
|
try {
|
|
const {data} = await LNbits.api.request('GET', `${API}/contracts`)
|
|
this.contracts = data
|
|
// Next payday is derived server-side; fetch it per row so the table
|
|
// never shows a date this page computed for itself.
|
|
await Promise.all(this.contracts.map(c => this.attachNextPayday(c)))
|
|
} catch (err) {
|
|
this._err(err, 'Could not load contracts')
|
|
} finally {
|
|
this.contractsLoading = false
|
|
}
|
|
},
|
|
|
|
async attachNextPayday(contract) {
|
|
try {
|
|
const {data} = await LNbits.api.request(
|
|
'GET',
|
|
`${API}/contracts/${contract.id}/schedule?count=1`
|
|
)
|
|
contract.next_payday = data.paydays[0] || null
|
|
contract.ends_on = data.ends_on
|
|
} catch (err) {
|
|
contract.next_payday = null
|
|
}
|
|
},
|
|
|
|
async getPayouts() {
|
|
this.payoutsLoading = true
|
|
const f = this.ledgerFilter
|
|
const params = new URLSearchParams()
|
|
if (f.contract_id) params.append('contract_id', f.contract_id)
|
|
if (f.status) params.append('status', f.status)
|
|
if (f.since) params.append('since', f.since)
|
|
if (f.until) params.append('until', f.until)
|
|
try {
|
|
const {data} = await LNbits.api.request(
|
|
'GET',
|
|
`${API}/payouts?${params.toString()}`
|
|
)
|
|
this.payouts = data
|
|
} catch (err) {
|
|
this._err(err, 'Could not load the payout ledger')
|
|
} finally {
|
|
this.payoutsLoading = false
|
|
}
|
|
},
|
|
|
|
// --- create / edit ----------------------------------------------------
|
|
|
|
openContractDialog() {
|
|
this.contractDialog.data = emptyContract()
|
|
this.preview = null
|
|
this.contractDialog.show = true
|
|
this.refreshPreview()
|
|
},
|
|
|
|
async refreshPreview() {
|
|
const d = this.contractDialog.data
|
|
if (!d.start_date || !/^\d{4}-\d{2}-\d{2}$/.test(d.start_date)) {
|
|
this.preview = null
|
|
return
|
|
}
|
|
try {
|
|
const {data} = await LNbits.api.request(
|
|
'POST',
|
|
`${API}/schedule/preview`,
|
|
null,
|
|
{
|
|
start_date: d.start_date,
|
|
frequency: d.frequency,
|
|
total_periods: d.open_ended ? null : Number(d.total_periods),
|
|
count: 6
|
|
}
|
|
)
|
|
this.preview = data
|
|
} catch (err) {
|
|
this.preview = null
|
|
}
|
|
},
|
|
|
|
async saveContract() {
|
|
const d = this.contractDialog.data
|
|
this.contractDialog.saving = true
|
|
try {
|
|
await LNbits.api.request('POST', `${API}/contracts`, null, {
|
|
employee_id: d.employee_id,
|
|
employee_wallet: d.employee_wallet,
|
|
source_wallet: d.source_wallet,
|
|
amount: Number(d.amount),
|
|
currency: d.currency,
|
|
frequency: d.frequency,
|
|
start_date: d.start_date,
|
|
total_periods: d.open_ended ? null : Number(d.total_periods),
|
|
label: d.label,
|
|
memo: d.memo,
|
|
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'})
|
|
this.getContracts()
|
|
} catch (err) {
|
|
this._err(err, 'Could not create the payroll line')
|
|
} finally {
|
|
this.contractDialog.saving = false
|
|
}
|
|
},
|
|
|
|
openEditDialog(contract) {
|
|
this.editDialog = {
|
|
show: true,
|
|
id: contract.id,
|
|
data: {
|
|
amount: contract.amount,
|
|
currency: contract.currency,
|
|
frequency: contract.frequency,
|
|
open_ended: contract.total_periods === null,
|
|
total_periods: contract.total_periods,
|
|
label: contract.label,
|
|
memo: contract.memo,
|
|
pricing_mode: contract.pricing_mode,
|
|
manual_rate: contract.manual_rate
|
|
}
|
|
}
|
|
},
|
|
|
|
async saveEdit() {
|
|
const d = this.editDialog.data
|
|
try {
|
|
await LNbits.api.request(
|
|
'PUT',
|
|
`${API}/contracts/${this.editDialog.id}`,
|
|
null,
|
|
{
|
|
amount: Number(d.amount),
|
|
currency: d.currency,
|
|
frequency: d.frequency,
|
|
total_periods: d.open_ended ? null : Number(d.total_periods),
|
|
label: d.label,
|
|
memo: d.memo,
|
|
pricing_mode: d.pricing_mode,
|
|
manual_rate: d.pricing_mode === 'manual' ? Number(d.manual_rate) : null
|
|
}
|
|
)
|
|
this.editDialog.show = false
|
|
Quasar.Notify.create({type: 'positive', message: 'Terms updated'})
|
|
this.getContracts()
|
|
} catch (err) {
|
|
this._err(err, 'Could not update the contract')
|
|
}
|
|
},
|
|
|
|
// --- lifecycle --------------------------------------------------------
|
|
|
|
async transition(contract, action, params = '') {
|
|
try {
|
|
await LNbits.api.request(
|
|
'POST',
|
|
`${API}/contracts/${contract.id}/${action}${params}`
|
|
)
|
|
this.getContracts()
|
|
} catch (err) {
|
|
this._err(err, `Could not ${action} the contract`)
|
|
}
|
|
},
|
|
|
|
resumeContract(contract) {
|
|
// Who paused it decides what happens to the missed paydays, so the
|
|
// question has to be asked differently for each. Telling an operator
|
|
// that a backlog payroll could not pay is "written off" would be
|
|
// false, and acting on it would destroy money that is still owed.
|
|
const auto = !!contract.paused_reason
|
|
const message = auto
|
|
? `Payroll paused this contract: ${contract.paused_reason}. ` +
|
|
'Resuming keeps the unpaid periods — they settle on the next tick. ' +
|
|
'Fix the cause first, or they will just fail again.'
|
|
: 'Resume this contract? The paydays missed while it was paused ' +
|
|
'are written off — they are not paid retroactively.'
|
|
LNbits.utils
|
|
.confirmDialog(message)
|
|
.onOk(() => this.transition(contract, 'resume'))
|
|
},
|
|
|
|
cancelContract(contract) {
|
|
LNbits.utils
|
|
.confirmDialog(
|
|
'Cancel this contract for good? It stops paying but keeps its ' +
|
|
'history. This cannot be undone.'
|
|
)
|
|
.onOk(() => this.transition(contract, 'cancel'))
|
|
},
|
|
|
|
payNow(contract) {
|
|
// 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()}`
|
|
)
|
|
// 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) {
|
|
LNbits.utils
|
|
.confirmDialog(
|
|
'Delete this contract row outright? Its schedule position goes ' +
|
|
'with it. To stop a running payroll, cancel it instead — that ' +
|
|
'keeps the record.'
|
|
)
|
|
.onOk(async () => {
|
|
try {
|
|
await LNbits.api.request('DELETE', `${API}/contracts/${contract.id}`)
|
|
this.getContracts()
|
|
} catch (err) {
|
|
this._err(err, 'Could not delete the contract')
|
|
}
|
|
})
|
|
},
|
|
|
|
async showSchedule(contract) {
|
|
this.scheduleDialog = {show: true, contract, preview: null}
|
|
try {
|
|
const {data} = await LNbits.api.request(
|
|
'GET',
|
|
`${API}/contracts/${contract.id}/schedule?count=24`
|
|
)
|
|
this.scheduleDialog.preview = data
|
|
} catch (err) {
|
|
this._err(err, 'Could not load the schedule')
|
|
}
|
|
},
|
|
|
|
showLedgerFor(contract) {
|
|
this.ledgerFilter.contract_id = contract.id
|
|
this.tab = 'ledger'
|
|
this.getPayouts()
|
|
}
|
|
},
|
|
|
|
created() {
|
|
this.getUsers()
|
|
this.getCurrencies()
|
|
this.getContracts()
|
|
this.getPayouts()
|
|
}
|
|
})
|