feat(dashboard): owed-cash worklist buckets, prefilled partial dispense, resume cash-out (ADR-005 §5–§6)

Three buckets render first on the worklist — cash_owed, partial_pending,
dispense_unreported (awaiting_dispense older than the threshold) — the
only ones whose meaning is "a customer is owed money". partial_pending
rows open the partial-dispense dialog pre-filled from the machine's
report: the fraction from dispensed_fiat_cents / fiat_amount and the
dispenser's error in the note, so the operator confirms a number the
hardware produced rather than typing one.

Machine detail shows a held-cash-out banner (code, time, reason) with a
Resume button; POST /machines/{id}/resume-cash-out records a
resume_cash_out op and publishes the window. The machine clears the hold
on receipt and the banner clears on its next state report.
This commit is contained in:
Padreug 2026-10-10 21:51:52 +02:00
commit fdba2d363f
3 changed files with 179 additions and 12 deletions

View file

@ -70,6 +70,10 @@ window.app = Vue.createApp({
// Worklist (P9g)
worklist: {
// ADR-005 §6 — owed-cash buckets first
cash_owed: [],
partial_pending: [],
dispense_unreported: [],
rejected: [],
errored: [],
stuck_pending: [],
@ -372,6 +376,33 @@ window.app = Vue.createApp({
},
worklistBuckets() {
return [
{
key: 'cash_owed',
label:
'Cash owed — customer paid, machine dispensed nothing. ' +
'Legs never ran; funds are in the machine wallet.',
icon: 'money_off',
color: 'negative',
rows: this.worklist.cash_owed
},
{
key: 'partial_pending',
label:
'Partial dispense — some notes out, value short. Held whole ' +
'until you record how the shortfall was resolved.',
icon: 'call_split',
color: 'deep-orange',
rows: this.worklist.partial_pending
},
{
key: 'dispense_unreported',
label:
'Unreported — cash-out paid, machine never reported the dispense. ' +
'Check the machine; an old build lands here too.',
icon: 'help_outline',
color: 'amber',
rows: this.worklist.dispense_unreported
},
{
key: 'rejected',
label: 'Rejected — Nostr attribution failed; investigate machine',
@ -560,6 +591,9 @@ window.app = Vue.createApp({
try {
const {data} = await LNbits.api.request('GET', STUCK_PATH)
this.worklistCount =
(data?.cash_owed?.length || 0) +
(data?.partial_pending?.length || 0) +
(data?.dispense_unreported?.length || 0) +
(data?.rejected?.length || 0) +
(data?.errored?.length || 0) +
(data?.stuck_pending?.length || 0) +
@ -575,11 +609,17 @@ window.app = Vue.createApp({
const {data} = await LNbits.api.request(
'GET', `${STUCK_PATH}?threshold_minutes=${this.worklistThreshold}`
)
this.worklist.cash_owed = data?.cash_owed || []
this.worklist.partial_pending = data?.partial_pending || []
this.worklist.dispense_unreported = data?.dispense_unreported || []
this.worklist.rejected = data?.rejected || []
this.worklist.errored = data?.errored || []
this.worklist.stuck_pending = data?.stuck_pending || []
this.worklist.stuck_processing = data?.stuck_processing || []
this.worklist.totalCount =
this.worklist.cash_owed.length +
this.worklist.partial_pending.length +
this.worklist.dispense_unreported.length +
this.worklist.rejected.length +
this.worklist.errored.length +
this.worklist.stuck_pending.length +
@ -1208,12 +1248,50 @@ window.app = Vue.createApp({
openPartialDispense(settlement) {
this.partialDispenseDialog.settlement = settlement
this.partialDispenseDialog.mode = 'fraction'
this.partialDispenseDialog.dispensed_fraction = null
// ADR-005: pre-fill from the machine's report — the hardware's own count
// of what left — so the operator confirms a number rather than typing one.
const dispensedCents = settlement.dispensed_fiat_cents
const fiat = Number(settlement.fiat_amount)
this.partialDispenseDialog.dispensed_fraction =
dispensedCents != null && fiat > 0
? Math.round((dispensedCents / 100 / fiat) * 10000) / 10000
: null
this.partialDispenseDialog.dispensed_sats = null
this.partialDispenseDialog.notes = ''
this.partialDispenseDialog.notes = settlement.dispense_error
? `Machine reported: ${settlement.dispense_error_code || ''} ${settlement.dispense_raw_code || ''} — ${settlement.dispense_error}`.trim()
: ''
this.partialDispenseDialog.show = true
},
// ADR-005 §5 — release a machine's cash-out hold after a terminal
// dispenser fault, when the jam was cleared without a recount.
confirmResumeCashOut(machine) {
Quasar.Dialog.create({
title: 'Resume cash-out?',
message:
'The machine latched cash-out off after a dispenser fault' +
(machine.cash_out_held_code ? ` (${machine.cash_out_held_code})` : '') +
'. Only do this after the transport path has been physically cleared. ' +
'A recount releases the hold too, and also fixes the bay count.',
cancel: true,
persistent: true
}).onOk(async () => {
try {
await LNbits.api.request(
'POST',
`/spirekeeper/api/v1/dca/machines/${machine.id}/resume-cash-out`
)
Quasar.Notify.create({
type: 'positive',
message: 'Resume published — the machine clears the hold on receipt'
})
if (this.machineDetail && this.machineDetail.machine) await this.reloadMachineDetail()
} catch (e) {
this._notifyError(e, 'Resume cash-out failed')
}
})
},
async submitPartialDispense() {
const d = this.partialDispenseDialog
const body = {notes: d.notes || null}