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

@ -841,6 +841,11 @@ function startCommandPoller(): void {
error: result.error, error: result.error,
}) })
// This dispense happened entirely in the main process, so the renderer
// has no idea the bays moved — it would keep serving a stale inventory
// and would never republish the operator's view. Tell it.
mainWindow?.webContents.send('cassettes:changed')
// Only remediate the original tx if ALL requested bills were dispensed // Only remediate the original tx if ALL requested bills were dispensed
let refRemediated = false let refRemediated = false
if (parsed.ref_txid && result.dispensed) { if (parsed.ref_txid && result.dispensed) {

View file

@ -222,6 +222,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Bolt Card reader (main process → renderer). removeAllListeners first: a // Bolt Card reader (main process → renderer). removeAllListeners first: a
// renderer reload re-runs this, and a duplicated card-tap listener would // renderer reload re-runs this, and a duplicated card-tap listener would
// trigger the LNURL-withdraw twice. // trigger the LNURL-withdraw twice.
// The main process changed the cassettes table (an operator-command dispense,
// boot seeding). The renderer reloads its inventory and republishes state.
onCassettesChanged: (callback: () => void) => {
ipcRenderer.removeAllListeners('cassettes:changed')
ipcRenderer.on('cassettes:changed', () => callback())
},
onNfcCardTapped: (callback: (lnurlw: string) => void) => { onNfcCardTapped: (callback: (lnurlw: string) => void) => {
ipcRenderer.removeAllListeners('nfc:card-tapped') ipcRenderer.removeAllListeners('nfc:card-tapped')
ipcRenderer.on('nfc:card-tapped', (_event, lnurlw) => callback(lnurlw)) ipcRenderer.on('nfc:card-tapped', (_event, lnurlw) => callback(lnurlw))

View file

@ -72,7 +72,13 @@ async function handleManagementCommand(
request: ManagementRequest, request: ManagementRequest,
dispenseFn: (amounts: { denomination: number; count: number }[]) => Promise<any>, dispenseFn: (amounts: { denomination: number; count: number }[]) => Promise<any>,
machineIdle: boolean, 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> { ): Promise<ManagementResponse | null> {
if (!isMachineDispenseRequest(request)) return null if (!isMachineDispenseRequest(request)) return null
@ -130,6 +136,7 @@ async function handleManagementCommand(
cassettes: result.cassettes, cassettes: result.cassettes,
error: result.error, error: result.error,
}) })
await onCassettesChanged?.()
// Only remediate the original tx if ALL requested bills were dispensed // Only remediate the original tx if ALL requested bills were dispensed
let refRemediated = false let refRemediated = false
@ -450,6 +457,16 @@ export const useAtmStore = defineStore('atm', () => {
*/ */
const persistedInventory = ref<Record<number, number>>({}) 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() { async function reloadPersistedInventory() {
const inv = await loadInventoryFromDb() const inv = await loadInventoryFromDb()
// Only a failed read is ignored. An empty map used to be skipped too, // 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 // Start the machine
actor.value.start() actor.value.start()
setupNfcListener() setupNfcListener()
setupCassettesChangedListener()
console.log('[ATM] State machine initialized') 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). */ /** Wire the main-process reader once (idempotent via preload removeAllListeners). */
function setupNfcListener() { function setupNfcListener() {
if (!isElectron || !window.electronAPI?.onNfcCardTapped) return if (!isElectron || !window.electronAPI?.onNfcCardTapped) return
@ -1319,7 +1352,8 @@ export const useAtmStore = defineStore('atm', () => {
request, request,
(amounts) => hal.atmServices.dispenseCash(amounts), (amounts) => hal.atmServices.dispenseCash(amounts),
isIdle.value, isIdle.value,
fiatCode.value fiatCode.value,
refreshAndPublishCassettes
) )
}) })
@ -1651,7 +1685,8 @@ export const useAtmStore = defineStore('atm', () => {
request, request,
(amounts) => api.halDispense(amounts), (amounts) => api.halDispense(amounts),
isIdle.value, isIdle.value,
fiatCode.value fiatCode.value,
refreshAndPublishCassettes
) )
}) })

View file

@ -217,6 +217,8 @@ declare global {
onHalBillInserted: (callback: (denomination: number) => void) => void onHalBillInserted: (callback: (denomination: number) => void) => void
onHalBillRejected: (callback: (reason: string) => void) => void onHalBillRejected: (callback: (reason: string) => void) => void
onHalError: (callback: (error: 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. */ /** Bolt Card reader: a tapped card's lnurlw voucher. */
onNfcCardTapped: (callback: (lnurlw: string) => void) => void onNfcCardTapped: (callback: (lnurlw: string) => void) => void
/** Bolt Card reader status (ready / reading / error / unavailable). */ /** Bolt Card reader status (ready / reading / error / unavailable). */