fix(cassettes): republish after a dispense the renderer didn't run

Two dispense paths bypassed the refresh-and-publish step that cash-out
does. The kind-21003 management command persisted the transaction and
stopped there, and the operator-command poller runs entirely in the main
process, where the renderer cannot see the bays move at all. In both
cases the renderer kept serving a stale inventory and the operator's
cassette view stayed frozen until the next customer cash-out.

The management handler takes an after-hook, and the main process emits
'cassettes:changed' when it mutates the table so the renderer can catch
up. Both land on one helper that reloads the inventory and republishes
the state document.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-09-22 22:09:23 +02:00
commit db68e6e244
4 changed files with 52 additions and 3 deletions

View file

@ -72,7 +72,13 @@ async function handleManagementCommand(
request: ManagementRequest,
dispenseFn: (amounts: { denomination: number; count: number }[]) => Promise<any>,
machineIdle: boolean,
currency: string
currency: string,
/**
* Called once the dispense has been persisted. Bills left the bays whether or
* not the dispense completed, so the caller refreshes its inventory and
* republishes the operator's view — this path used to do neither.
*/
onCassettesChanged?: () => Promise<void>
): Promise<ManagementResponse | null> {
if (!isMachineDispenseRequest(request)) return null
@ -130,6 +136,7 @@ async function handleManagementCommand(
cassettes: result.cassettes,
error: result.error,
})
await onCassettesChanged?.()
// Only remediate the original tx if ALL requested bills were dispensed
let refRemediated = false
@ -450,6 +457,16 @@ export const useAtmStore = defineStore('atm', () => {
*/
const persistedInventory = ref<Record<number, number>>({})
/**
* The bays moved outside the normal cash-out flow — an operator-command
* dispense, a main-process seed. Refresh the renderer's view and push the
* operator's. Best-effort: a publish failure must not fail the dispense.
*/
async function refreshAndPublishCassettes() {
await reloadPersistedInventory()
await operatorConfigSvc?.publishCassettesState()
}
async function reloadPersistedInventory() {
const inv = await loadInventoryFromDb()
// Only a failed read is ignored. An empty map used to be skipped too,
@ -709,6 +726,7 @@ export const useAtmStore = defineStore('atm', () => {
// Start the machine
actor.value.start()
setupNfcListener()
setupCassettesChangedListener()
console.log('[ATM] State machine initialized')
}
@ -922,6 +940,21 @@ export const useAtmStore = defineStore('atm', () => {
}
}
/**
* The main process can move the bays without the renderer knowing — an
* operator-command dispense runs entirely there, and boot seeding writes the
* table before the store exists. Listen for that and catch up, otherwise the
* renderer serves a stale inventory and the operator's view never updates.
* Idempotent via preload removeAllListeners.
*/
function setupCassettesChangedListener() {
if (!isElectron || !window.electronAPI?.onCassettesChanged) return
window.electronAPI.onCassettesChanged(() => {
console.log('[ATM] Cassettes changed in the main process — refreshing')
void refreshAndPublishCassettes()
})
}
/** Wire the main-process reader once (idempotent via preload removeAllListeners). */
function setupNfcListener() {
if (!isElectron || !window.electronAPI?.onNfcCardTapped) return
@ -1319,7 +1352,8 @@ export const useAtmStore = defineStore('atm', () => {
request,
(amounts) => hal.atmServices.dispenseCash(amounts),
isIdle.value,
fiatCode.value
fiatCode.value,
refreshAndPublishCassettes
)
})
@ -1651,7 +1685,8 @@ export const useAtmStore = defineStore('atm', () => {
request,
(amounts) => api.halDispense(amounts),
isIdle.value,
fiatCode.value
fiatCode.value,
refreshAndPublishCassettes
)
})

View file

@ -217,6 +217,8 @@ declare global {
onHalBillInserted: (callback: (denomination: number) => void) => void
onHalBillRejected: (callback: (reason: string) => void) => void
onHalError: (callback: (error: string) => void) => void
/** The main process mutated the cassettes table; reload + republish. */
onCassettesChanged: (callback: () => void) => void
/** Bolt Card reader: a tapped card's lnurlw voucher. */
onNfcCardTapped: (callback: (lnurlw: string) => void) => void
/** Bolt Card reader status (ready / reading / error / unavailable). */