ADR-005 rollout step 2 (slice 1): capture cash-out settlements on the machine's dispense report #49
3 changed files with 179 additions and 12 deletions
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.
commit
fdba2d363f
|
|
@ -70,6 +70,10 @@ window.app = Vue.createApp({
|
||||||
|
|
||||||
// Worklist (P9g)
|
// Worklist (P9g)
|
||||||
worklist: {
|
worklist: {
|
||||||
|
// ADR-005 §6 — owed-cash buckets first
|
||||||
|
cash_owed: [],
|
||||||
|
partial_pending: [],
|
||||||
|
dispense_unreported: [],
|
||||||
rejected: [],
|
rejected: [],
|
||||||
errored: [],
|
errored: [],
|
||||||
stuck_pending: [],
|
stuck_pending: [],
|
||||||
|
|
@ -372,6 +376,33 @@ window.app = Vue.createApp({
|
||||||
},
|
},
|
||||||
worklistBuckets() {
|
worklistBuckets() {
|
||||||
return [
|
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',
|
key: 'rejected',
|
||||||
label: 'Rejected — Nostr attribution failed; investigate machine',
|
label: 'Rejected — Nostr attribution failed; investigate machine',
|
||||||
|
|
@ -560,6 +591,9 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
const {data} = await LNbits.api.request('GET', STUCK_PATH)
|
const {data} = await LNbits.api.request('GET', STUCK_PATH)
|
||||||
this.worklistCount =
|
this.worklistCount =
|
||||||
|
(data?.cash_owed?.length || 0) +
|
||||||
|
(data?.partial_pending?.length || 0) +
|
||||||
|
(data?.dispense_unreported?.length || 0) +
|
||||||
(data?.rejected?.length || 0) +
|
(data?.rejected?.length || 0) +
|
||||||
(data?.errored?.length || 0) +
|
(data?.errored?.length || 0) +
|
||||||
(data?.stuck_pending?.length || 0) +
|
(data?.stuck_pending?.length || 0) +
|
||||||
|
|
@ -575,11 +609,17 @@ window.app = Vue.createApp({
|
||||||
const {data} = await LNbits.api.request(
|
const {data} = await LNbits.api.request(
|
||||||
'GET', `${STUCK_PATH}?threshold_minutes=${this.worklistThreshold}`
|
'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.rejected = data?.rejected || []
|
||||||
this.worklist.errored = data?.errored || []
|
this.worklist.errored = data?.errored || []
|
||||||
this.worklist.stuck_pending = data?.stuck_pending || []
|
this.worklist.stuck_pending = data?.stuck_pending || []
|
||||||
this.worklist.stuck_processing = data?.stuck_processing || []
|
this.worklist.stuck_processing = data?.stuck_processing || []
|
||||||
this.worklist.totalCount =
|
this.worklist.totalCount =
|
||||||
|
this.worklist.cash_owed.length +
|
||||||
|
this.worklist.partial_pending.length +
|
||||||
|
this.worklist.dispense_unreported.length +
|
||||||
this.worklist.rejected.length +
|
this.worklist.rejected.length +
|
||||||
this.worklist.errored.length +
|
this.worklist.errored.length +
|
||||||
this.worklist.stuck_pending.length +
|
this.worklist.stuck_pending.length +
|
||||||
|
|
@ -1208,12 +1248,50 @@ window.app = Vue.createApp({
|
||||||
openPartialDispense(settlement) {
|
openPartialDispense(settlement) {
|
||||||
this.partialDispenseDialog.settlement = settlement
|
this.partialDispenseDialog.settlement = settlement
|
||||||
this.partialDispenseDialog.mode = 'fraction'
|
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.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
|
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() {
|
async submitPartialDispense() {
|
||||||
const d = this.partialDispenseDialog
|
const d = this.partialDispenseDialog
|
||||||
const body = {notes: d.notes || null}
|
const body = {notes: d.notes || null}
|
||||||
|
|
|
||||||
|
|
@ -661,6 +661,12 @@
|
||||||
@click="viewMachineFromWorklist(props.row)">
|
@click="viewMachineFromWorklist(props.row)">
|
||||||
<q-tooltip>Open machine detail</q-tooltip>
|
<q-tooltip>Open machine detail</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
|
<q-btn v-if="bucket.key === 'partial_pending'"
|
||||||
|
flat dense size="sm" icon="call_split"
|
||||||
|
color="deep-orange"
|
||||||
|
@click="openPartialDispense(props.row)">
|
||||||
|
<q-tooltip>Record the resolution (pre-filled from the machine's report)</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
<q-btn v-if="bucket.key === 'errored'"
|
<q-btn v-if="bucket.key === 'errored'"
|
||||||
flat dense size="sm" icon="restart_alt"
|
flat dense size="sm" icon="restart_alt"
|
||||||
color="primary"
|
color="primary"
|
||||||
|
|
@ -1178,6 +1184,25 @@
|
||||||
<span v-text="machineDetail.cassettesError"></span>
|
<span v-text="machineDetail.cassettesError"></span>
|
||||||
</q-banner>
|
</q-banner>
|
||||||
|
|
||||||
|
<q-banner v-if="machineDetail.machine
|
||||||
|
&& machineDetail.machine.cash_out_held_since"
|
||||||
|
class="bg-red-1 text-grey-9 q-mb-md">
|
||||||
|
<template v-slot:avatar>
|
||||||
|
<q-icon name="block" color="negative"></q-icon>
|
||||||
|
</template>
|
||||||
|
<b>Cash-out is held.</b>
|
||||||
|
The machine latched cash-out off after a terminal dispenser fault
|
||||||
|
(<span v-text="machineDetail.machine.cash_out_held_code || 'fault'"></span>
|
||||||
|
at <span v-text="formatTime(machineDetail.machine.cash_out_held_since)"></span>):
|
||||||
|
<span v-text="machineDetail.machine.cash_out_held_reason"></span>.
|
||||||
|
Clear the transport path, then either record a <b>Recount</b>
|
||||||
|
(which also fixes the count) or release it here.
|
||||||
|
<template v-slot:action>
|
||||||
|
<q-btn flat color="negative" label="Resume cash-out"
|
||||||
|
@click="confirmResumeCashOut(machineDetail.machine)"></q-btn>
|
||||||
|
</template>
|
||||||
|
</q-banner>
|
||||||
|
|
||||||
<q-banner v-if="machineDetail.machine
|
<q-banner v-if="machineDetail.machine
|
||||||
&& machineDetail.machine.counts_uncertain_since"
|
&& machineDetail.machine.counts_uncertain_since"
|
||||||
class="bg-orange-1 text-grey-9 q-mb-md">
|
class="bg-orange-1 text-grey-9 q-mb-md">
|
||||||
|
|
|
||||||
84
views_api.py
84
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.decorators import check_super_user, check_user_exists
|
||||||
from lnbits.utils.nostr import normalize_public_key
|
from lnbits.utils.nostr import normalize_public_key
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from .calculations import MAX_FEE_FRACTION_PER_DIRECTION
|
from .calculations import MAX_FEE_FRACTION_PER_DIRECTION
|
||||||
from .cassette_transport import (
|
from .cassette_transport import (
|
||||||
|
|
@ -28,15 +29,6 @@ from .cassette_transport import (
|
||||||
SignerUnavailable,
|
SignerUnavailable,
|
||||||
publish_ops_to_atm,
|
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 (
|
from .crud import (
|
||||||
append_settlement_note,
|
append_settlement_note,
|
||||||
count_completed_legs_for_settlement,
|
count_completed_legs_for_settlement,
|
||||||
|
|
@ -85,6 +77,7 @@ from .distribution import (
|
||||||
process_settlement,
|
process_settlement,
|
||||||
settle_lp_balance,
|
settle_lp_balance,
|
||||||
)
|
)
|
||||||
|
from .fee_transport import publish_fee_config
|
||||||
from .models import (
|
from .models import (
|
||||||
AppendSettlementNoteData,
|
AppendSettlementNoteData,
|
||||||
CassetteConfig,
|
CassetteConfig,
|
||||||
|
|
@ -112,6 +105,14 @@ from .models import (
|
||||||
UpdateMachineData,
|
UpdateMachineData,
|
||||||
UpdateSuperConfigData,
|
UpdateSuperConfigData,
|
||||||
)
|
)
|
||||||
|
from .pairing import (
|
||||||
|
PairingError,
|
||||||
|
PairResult,
|
||||||
|
RevokeResult,
|
||||||
|
default_relay_endpoint,
|
||||||
|
pair_spire,
|
||||||
|
revoke_spire,
|
||||||
|
)
|
||||||
|
|
||||||
spirekeeper_api_router = APIRouter()
|
spirekeeper_api_router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -768,7 +769,13 @@ async def api_list_stuck_settlements(
|
||||||
) -> StuckSettlementsResponse:
|
) -> StuckSettlementsResponse:
|
||||||
"""Operator worklist of settlements that didn't process cleanly.
|
"""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
|
- rejected: Nostr attribution cross-check failed — signer didn't
|
||||||
match the machine identity. Investigate; do not retry.
|
match the machine identity. Investigate; do not retry.
|
||||||
- errored: distribution ran and failed; retry endpoint handles these
|
- 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)
|
buckets = await get_stuck_settlements_for_operator(user.id, threshold_minutes)
|
||||||
return StuckSettlementsResponse(
|
return StuckSettlementsResponse(
|
||||||
threshold_minutes=threshold_minutes,
|
threshold_minutes=threshold_minutes,
|
||||||
|
cash_owed=buckets["cash_owed"],
|
||||||
|
partial_pending=buckets["partial_pending"],
|
||||||
|
dispense_unreported=buckets["dispense_unreported"],
|
||||||
rejected=buckets["rejected"],
|
rejected=buckets["rejected"],
|
||||||
errored=buckets["errored"],
|
errored=buckets["errored"],
|
||||||
stuck_pending=buckets["stuck_pending"],
|
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
|
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc)) from exc
|
||||||
|
|
||||||
return op
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue