feat(machine): value-confirmed dispense, cash-out hold, fault screens, counts-uncertain on zero-with-error (ADR-005 §3–§5)
HAL glue (electron/hal-service.ts and the renderer-side services/hal.ts): dispenseConfirmed is Σ(denomination × dispensed) === Σ(denomination × requested), computed on value. The driver's tagged error is carried through as errorCode / rawCode / errorClass / human; pre-dispense inventory refusals are errorClass 'inventory' so they route to outOfCash rather than the fault screen. The manual-dispense command result keeps its wire key `dispensed` (spirekeeper's poller reads it) and gains the new fields alongside. Cash-out hold: state-store persists it in meta as one JSON value beside countsUncertainSince, idempotent on set (the first fault's `since` is kept); IPC get/set/clear through preload. The store persists the hold the moment the machine sets it and restores it into the machine on boot. A recount clears it in the store (same gesture that clears counts- uncertain); operator-config also honours a new resume_cash_out op — not a cassette op, split off before applyOperatorCassetteOps, and honoured only when stamped after the hold began so a re-delivered old resume cannot clear a fresh fault. Either release calls back into the store, which sends CASH_OUT_RELEASED. The cassettes-state document carries cash_out_held_since / _reason / _code (additive, like counts_uncertain_since); the availability beacon reports cash_out false while held; the idle Sell button is disabled with the reason. Store watcher: dispenseFault and outOfCash both record dispense_error / partial (the customer has paid either way). A report of zero dispensed WITH a hardware error now sets countsUncertainSince instead of being trusted as zero — a note stopped in the transport completes neither counter (sintra 2026-10-09: bay read 66, held 65, one in the transport). Fault screen: both terminal states show "your payment went through", amount paid, per-denomination dispensed, the txid as QR and text, the payment hash (threaded from the settlement watch through PAYMENT_RECEIVED) and the time, with "keep this reference" and an acknowledge button. The raw dispenser code is not shown; it travels in the report.
This commit is contained in:
parent
3ff86de4ed
commit
888870d01a
12 changed files with 469 additions and 72 deletions
|
|
@ -517,6 +517,69 @@ export function clearCountsUncertain(): void {
|
|||
).run('countsUncertainSince', '')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cash-out hold (ADR-005 §5)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// A terminal dispenser fault latches cash-out off. The latch is machine
|
||||
// health, so it lives in `meta` (one JSON value) and survives restarts; the
|
||||
// renderer restores it into the state machine on boot and the operator
|
||||
// releases it with a `recount` or `resume_cash_out` op. Re-initialising the
|
||||
// dispenser never clears it — re-init does not move a stuck note.
|
||||
|
||||
export interface CashOutHold {
|
||||
reason: string
|
||||
errorCode: string | null
|
||||
rawCode: string | null
|
||||
/** unix seconds of the FIRST fault — kept across repeat faults */
|
||||
since: number
|
||||
}
|
||||
|
||||
export function getCashOutHold(): CashOutHold | null {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('cashOutHeld') as
|
||||
| { value: string }
|
||||
| undefined
|
||||
if (!row || row.value === '') return null
|
||||
try {
|
||||
const parsed = JSON.parse(row.value) as Partial<CashOutHold>
|
||||
if (typeof parsed.since !== 'number' || typeof parsed.reason !== 'string') return null
|
||||
return {
|
||||
reason: parsed.reason,
|
||||
errorCode: typeof parsed.errorCode === 'string' ? parsed.errorCode : null,
|
||||
rawCode: typeof parsed.rawCode === 'string' ? parsed.rawCode : null,
|
||||
since: parsed.since,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Latch cash-out off. Idempotent: an existing hold (and its `since`) is kept. */
|
||||
export function setCashOutHold(hold: CashOutHold): CashOutHold {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const existing = getCashOutHold()
|
||||
if (existing) return existing
|
||||
db.prepare(
|
||||
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
).run('cashOutHeld', JSON.stringify(hold))
|
||||
console.warn(
|
||||
`[StateStore] Cash-out HELD: ${hold.errorCode ?? 'fault'}${hold.rawCode ? ` ${hold.rawCode}` : ''} — ${hold.reason}`
|
||||
)
|
||||
return hold
|
||||
}
|
||||
|
||||
/** Release the latch — an operator has cleared the machine. */
|
||||
export function clearCashOutHold(): boolean {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
const had = getCashOutHold() !== null
|
||||
db.prepare(
|
||||
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
).run('cashOutHeld', '')
|
||||
if (had) console.log('[StateStore] Cash-out hold released')
|
||||
return had
|
||||
}
|
||||
|
||||
/**
|
||||
* A counter bumped on every local change to a bay count, from any cause.
|
||||
*
|
||||
|
|
@ -851,7 +914,12 @@ export function applyOperatorCassetteOps(ops: CassetteOp[]): ApplyOpsResult {
|
|||
// A recount is an operator opening the bay and counting it, which is
|
||||
// exactly what resolves an unverified count. Nothing else does: a refill
|
||||
// adds to a number still known to be wrong.
|
||||
if (sawRecount) upsertMeta.run('countsUncertainSince', '')
|
||||
if (sawRecount) {
|
||||
upsertMeta.run('countsUncertainSince', '')
|
||||
// ADR-005 §5: a recount is an operator at the open machine — the one
|
||||
// gesture that also releases a cash-out hold.
|
||||
upsertMeta.run('cashOutHeld', '')
|
||||
}
|
||||
})()
|
||||
|
||||
console.log(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue