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
|
|
@ -6,7 +6,8 @@
|
|||
* so it's available at runtime (unlike src/ which is only for Vite).
|
||||
*/
|
||||
|
||||
import type { BillValidator, BillDispenser } from '@bitSpire/hal'
|
||||
import type { BillValidator, BillDispenser, DispenseErrorClass } from '@bitSpire/hal'
|
||||
import { isDispenseError } from '@bitSpire/hal'
|
||||
|
||||
export interface CassetteConfig {
|
||||
/**
|
||||
|
|
@ -48,8 +49,20 @@ export interface ValidatorCallbacks {
|
|||
|
||||
export interface DispenseResult {
|
||||
bills: { denomination: number; dispensed: number; rejected: number }[]
|
||||
dispensed: boolean
|
||||
/**
|
||||
* Σ(denomination × dispensed) === Σ(denomination × requested). Computed
|
||||
* here on VALUE (ADR-005 §3) — never a driver boolean. Only this routes
|
||||
* the state machine to `complete`.
|
||||
*/
|
||||
dispenseConfirmed: boolean
|
||||
/** Human message when not confirmed */
|
||||
error?: string
|
||||
/** The error's NAME — 'F56DispenseError', 'InsufficientInventory', … */
|
||||
errorCode?: string
|
||||
/** Driver-native code, e.g. '78 42' */
|
||||
rawCode?: string
|
||||
/** terminal | recoverable | inventory — see @bitSpire/hal error-codes */
|
||||
errorClass?: DispenseErrorClass
|
||||
cassettes?: {
|
||||
name: string
|
||||
position: number
|
||||
|
|
@ -277,6 +290,8 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
|||
notes[i] = (notes[i] ?? 0) + take
|
||||
remaining -= take
|
||||
}
|
||||
// Nothing has been asked of the hardware in either refusal below:
|
||||
// errorClass 'inventory' routes to outOfCash, not the fault screen.
|
||||
if (!matched) {
|
||||
return {
|
||||
bills: amounts.map((a) => ({
|
||||
|
|
@ -284,8 +299,10 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
|||
dispensed: 0,
|
||||
rejected: 0,
|
||||
})),
|
||||
dispensed: false,
|
||||
dispenseConfirmed: false,
|
||||
error: `No cassette loaded with denomination: ${denomination}`,
|
||||
errorCode: 'NoCassetteForDenomination',
|
||||
errorClass: 'inventory',
|
||||
}
|
||||
}
|
||||
if (remaining > 0) {
|
||||
|
|
@ -295,8 +312,10 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
|||
dispensed: 0,
|
||||
rejected: 0,
|
||||
})),
|
||||
dispensed: false,
|
||||
dispenseConfirmed: false,
|
||||
error: `Insufficient inventory for denomination ${denomination}: short ${remaining}`,
|
||||
errorCode: 'InsufficientInventory',
|
||||
errorClass: 'inventory',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -344,11 +363,44 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
|||
}
|
||||
const bills = Array.from(billsByDenom.values())
|
||||
|
||||
const totalRequested = amounts.reduce((s, a) => s + a.count, 0)
|
||||
// ADR-005 §3: confirmation is VALUE equality — what left the bays is
|
||||
// worth exactly what was asked — not a count, and not the driver's
|
||||
// opinion. lamassu computed the same thing (`tx.fiat.eq(Σ denomination
|
||||
// × dispensed)`); our previous count-based check was only equivalent
|
||||
// while every bay dispensed its own denomination.
|
||||
const requestedValue = amounts.reduce((s, a) => s + a.denomination * a.count, 0)
|
||||
const dispensedValue = cassetteResults.reduce((s, c) => s + c.denomination * c.dispensed, 0)
|
||||
const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
|
||||
const dispenseConfirmed = requestedValue === dispensedValue
|
||||
|
||||
if (result.error) {
|
||||
return { bills, cassettes: cassetteResults, dispensed: false, error: result.error.message }
|
||||
const e = result.error
|
||||
const info = isDispenseError(e)
|
||||
? { errorCode: e.errorCode, rawCode: e.rawCode, errorClass: e.errorClass, human: e.human }
|
||||
: {
|
||||
// Unreachable by type (drivers always tag), kept as a defensive
|
||||
// fallback for a driver that slips an untagged Error through.
|
||||
errorCode: (e as Error).name || 'DispenseError',
|
||||
rawCode: undefined,
|
||||
errorClass: 'terminal' as const,
|
||||
human: (e as Error).message,
|
||||
}
|
||||
console.error(
|
||||
`[HAL] Dispense error ${info.errorCode}${info.rawCode ? ` ${info.rawCode}` : ''} (${info.errorClass}): ${info.human} — requested ${requestedValue}, dispensed ${dispensedValue}`
|
||||
)
|
||||
// A dispensed value of zero WITH an error is not evidence that nothing
|
||||
// left the bay — a note stopped in the transport completes neither
|
||||
// counter (sintra, 2026-10-09). The store reads this combination and
|
||||
// flags counts unverified; we just report faithfully here.
|
||||
return {
|
||||
bills,
|
||||
cassettes: cassetteResults,
|
||||
dispenseConfirmed: false,
|
||||
error: info.human,
|
||||
errorCode: info.errorCode,
|
||||
rawCode: info.rawCode,
|
||||
errorClass: info.errorClass,
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for customer to take bills
|
||||
|
|
@ -357,7 +409,20 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
|||
console.log('[HAL] Bills removed by customer')
|
||||
}
|
||||
|
||||
return { bills, cassettes: cassetteResults, dispensed: totalRequested === totalDispensed }
|
||||
if (!dispenseConfirmed) {
|
||||
// Short with no hardware error — the dispenser simply gave less.
|
||||
console.warn(`[HAL] Dispense short with no error: requested ${requestedValue}, dispensed ${dispensedValue}`)
|
||||
return {
|
||||
bills,
|
||||
cassettes: cassetteResults,
|
||||
dispenseConfirmed: false,
|
||||
error: `Dispensed ${dispensedValue} of ${requestedValue} with no dispenser error`,
|
||||
errorCode: 'DispenseShort',
|
||||
errorClass: 'inventory',
|
||||
}
|
||||
}
|
||||
|
||||
return { bills, cassettes: cassetteResults, dispenseConfirmed: true }
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ import {
|
|||
getCountsUncertainSince,
|
||||
getLastStatePublishedAt,
|
||||
markCountsUncertain,
|
||||
getCashOutHold,
|
||||
setCashOutHold,
|
||||
clearCashOutHold,
|
||||
type CashOutHold,
|
||||
markStatePublished,
|
||||
resetStatePublishWatermark,
|
||||
resetForRepair,
|
||||
|
|
@ -565,6 +569,15 @@ ipcMain.handle('state:get-counts-uncertain-since', (): number | null => getCount
|
|||
ipcMain.handle('state:mark-counts-uncertain', (_event, unixTimestamp: number): void => {
|
||||
markCountsUncertain(unixTimestamp)
|
||||
})
|
||||
// Cash-out hold (ADR-005 §5)
|
||||
ipcMain.handle('state:get-cash-out-hold', (): CashOutHold | null => getCashOutHold())
|
||||
ipcMain.handle('state:set-cash-out-hold', (_event, hold: CashOutHold): CashOutHold => {
|
||||
if (!hold || typeof hold.reason !== 'string' || typeof hold.since !== 'number') {
|
||||
throw new Error('Invalid cash-out hold')
|
||||
}
|
||||
return setCashOutHold(hold)
|
||||
})
|
||||
ipcMain.handle('state:clear-cash-out-hold', (): boolean => clearCashOutHold())
|
||||
ipcMain.handle('state:mark-state-published', (_event, unixTimestamp: number): void => {
|
||||
markStatePublished(unixTimestamp)
|
||||
})
|
||||
|
|
@ -841,7 +854,7 @@ function startCommandPoller(): void {
|
|||
recordTransaction({
|
||||
txid,
|
||||
type: 'manual_dispense',
|
||||
status: result.dispensed ? 'complete' : 'dispense_error',
|
||||
status: result.dispenseConfirmed ? 'complete' : 'dispense_error',
|
||||
fiatCents: totalFiatCents,
|
||||
sats: 0,
|
||||
feeSats: 0,
|
||||
|
|
@ -860,7 +873,7 @@ function startCommandPoller(): void {
|
|||
|
||||
// Only remediate the original tx if ALL requested bills were dispensed
|
||||
let refRemediated = false
|
||||
if (parsed.ref_txid && result.dispensed) {
|
||||
if (parsed.ref_txid && result.dispenseConfirmed) {
|
||||
refRemediated = remediateTransaction(parsed.ref_txid, txid)
|
||||
}
|
||||
|
||||
|
|
@ -868,7 +881,13 @@ function startCommandPoller(): void {
|
|||
cmd.id,
|
||||
JSON.stringify({
|
||||
txid,
|
||||
dispensed: result.dispensed,
|
||||
// Wire key kept as `dispensed` — spirekeeper's command poller
|
||||
// reads it. Value is the ADR-005 value-equality confirmation.
|
||||
dispensed: result.dispenseConfirmed,
|
||||
dispense_confirmed: result.dispenseConfirmed,
|
||||
error_code: result.errorCode,
|
||||
raw_code: result.rawCode,
|
||||
error_class: result.errorClass,
|
||||
ref_remediated: refRemediated,
|
||||
error: result.error,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,6 +7,14 @@
|
|||
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
/** Mirrors state-store.CashOutHold (ADR-005 §5) — preload can't import main-process modules. */
|
||||
interface CashOutHold {
|
||||
reason: string
|
||||
errorCode: string | null
|
||||
rawCode: string | null
|
||||
since: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime configuration interface (public info only)
|
||||
* These values are read from environment variables at runtime (not build time)
|
||||
|
|
@ -114,6 +122,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
ipcRenderer.invoke('state:get-counts-uncertain-since'),
|
||||
markCountsUncertain: (unixTimestamp: number): Promise<void> =>
|
||||
ipcRenderer.invoke('state:mark-counts-uncertain', unixTimestamp),
|
||||
// Cash-out hold (ADR-005 §5)
|
||||
getCashOutHold: (): Promise<CashOutHold | null> => ipcRenderer.invoke('state:get-cash-out-hold'),
|
||||
setCashOutHold: (hold: CashOutHold): Promise<CashOutHold> =>
|
||||
ipcRenderer.invoke('state:set-cash-out-hold', hold),
|
||||
clearCashOutHold: (): Promise<boolean> => ipcRenderer.invoke('state:clear-cash-out-hold'),
|
||||
markStatePublished: (unixTimestamp: number): Promise<void> =>
|
||||
ipcRenderer.invoke('state:mark-state-published', unixTimestamp),
|
||||
|
||||
|
|
@ -307,6 +320,9 @@ declare global {
|
|||
getLastStatePublishedAt: () => Promise<number | null>
|
||||
getCountsUncertainSince: () => Promise<number | null>
|
||||
markCountsUncertain: (unixTimestamp: number) => Promise<void>
|
||||
getCashOutHold: () => Promise<CashOutHold | null>
|
||||
setCashOutHold: (hold: CashOutHold) => Promise<CashOutHold>
|
||||
clearCashOutHold: () => Promise<boolean>
|
||||
markStatePublished: (unixTimestamp: number) => Promise<void>
|
||||
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
||||
clearBunkerBinding: () => Promise<void>
|
||||
|
|
|
|||
|
|
@ -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