From fdba2d363f3bb9b5a9f57838f3bd5e8948967fa8 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 10 Oct 2026 21:51:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20owed-cash=20worklist=20bucke?= =?UTF-8?q?ts,=20prefilled=20partial=20dispense,=20resume=20cash-out=20(AD?= =?UTF-8?q?R-005=20=C2=A75=E2=80=93=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- static/js/index.js | 82 ++++++++++++++++++++++++++++++- templates/spirekeeper/index.html | 25 ++++++++++ views_api.py | 84 ++++++++++++++++++++++++++++---- 3 files changed, 179 insertions(+), 12 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index e8a31e2..54b6014 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -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} diff --git a/templates/spirekeeper/index.html b/templates/spirekeeper/index.html index ad01503..77d9612 100644 --- a/templates/spirekeeper/index.html +++ b/templates/spirekeeper/index.html @@ -661,6 +661,12 @@ @click="viewMachineFromWorklist(props.row)"> Open machine detail + + Record the resolution (pre-filled from the machine's report) + + + + Cash-out is held. + The machine latched cash-out off after a terminal dispenser fault + ( + at ): + . + Clear the transport path, then either record a Recount + (which also fixes the count) or release it here. + + + diff --git a/views_api.py b/views_api.py index 33fb2de..7d6bf9f 100644 --- a/views_api.py +++ b/views_api.py @@ -19,6 +19,7 @@ from lnbits.core.services.nsec_bunker import ( ) from lnbits.decorators import check_super_user, check_user_exists from lnbits.utils.nostr import normalize_public_key +from loguru import logger from .calculations import MAX_FEE_FRACTION_PER_DIRECTION from .cassette_transport import ( @@ -28,15 +29,6 @@ from .cassette_transport import ( SignerUnavailable, publish_ops_to_atm, ) -from .fee_transport import publish_fee_config -from .pairing import ( - PairResult, - PairingError, - RevokeResult, - default_relay_endpoint, - pair_spire, - revoke_spire, -) from .crud import ( append_settlement_note, count_completed_legs_for_settlement, @@ -85,6 +77,7 @@ from .distribution import ( process_settlement, settle_lp_balance, ) +from .fee_transport import publish_fee_config from .models import ( AppendSettlementNoteData, CassetteConfig, @@ -112,6 +105,14 @@ from .models import ( UpdateMachineData, UpdateSuperConfigData, ) +from .pairing import ( + PairingError, + PairResult, + RevokeResult, + default_relay_endpoint, + pair_spire, + revoke_spire, +) spirekeeper_api_router = APIRouter() @@ -768,7 +769,13 @@ async def api_list_stuck_settlements( ) -> StuckSettlementsResponse: """Operator worklist of settlements that didn't process cleanly. - Returns four lists: + Returns seven lists. The first three (ADR-005 §6) mean a customer is owed + money and render first: + - cash_owed: the machine reported nothing dispensed; nothing moved + - partial_pending: some notes out, value short; held until resolved + - dispense_unreported: cash-out landed, machine never reported within + the threshold + Then: - rejected: Nostr attribution cross-check failed — signer didn't match the machine identity. Investigate; do not retry. - errored: distribution ran and failed; retry endpoint handles these @@ -783,6 +790,9 @@ async def api_list_stuck_settlements( buckets = await get_stuck_settlements_for_operator(user.id, threshold_minutes) return StuckSettlementsResponse( threshold_minutes=threshold_minutes, + cash_owed=buckets["cash_owed"], + partial_pending=buckets["partial_pending"], + dispense_unreported=buckets["dispense_unreported"], rejected=buckets["rejected"], errored=buckets["errored"], stuck_pending=buckets["stuck_pending"], @@ -1229,3 +1239,57 @@ async def api_create_machine_cassette_op( raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc)) from exc return op +@spirekeeper_api_router.post( + "/api/v1/dca/machines/{machine_id}/resume-cash-out", + response_model=CassetteOp, +) +async def api_resume_cash_out( + machine_id: str, + user: User = Depends(check_user_exists), +) -> CassetteOp: + """Release a machine's cash-out hold (bitspire ADR-005 §5). + + After a terminal dispenser fault the machine refuses cash-out until an + operator has been to it. A `recount` releases the hold as a side effect; + this is for the case where the jam was cleared without touching a bay + count. Recorded as a machine-wide op (position 0) and published on the + same operator channel as the cassette ops — the machine honours it only if + it is stamped after the hold began, so a re-delivered old resume cannot + clear a newer fault. + + Errors mirror the cassette-op endpoint: 400 unpaired, 503 signer/relay + unavailable (the op is recorded and rides out with the next publish). + """ + machine = await _machine_owned_by(machine_id, user.id) + if not machine.machine_npub: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + "machine is not paired — there is no ATM identity to publish to", + ) + if machine.cash_out_held_since is None: + logger.info( + f"spirekeeper: resume_cash_out for machine {machine_id} with no hold " + "on file — publishing anyway (the machine is the authority)" + ) + + op = await create_cassette_op( + machine_id, + CreateCassetteOpData(position=0, op_type="resume_cash_out"), + created_by=user.id, + ) + window = await get_cassette_ops_window(machine_id) + try: + await publish_ops_to_atm(machine, window, user.id) + except OperatorIdentityMissing as exc: + raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc + except (SignerUnavailable, RelayUnavailable) as exc: + raise HTTPException( + HTTPStatus.SERVICE_UNAVAILABLE, + f"{exc} — the resume was recorded and will be delivered with the " + "next publish", + ) from exc + except CassetteTransportError as exc: + raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc)) from exc + return op + +