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
+
+