fix(cassettes): close the machine-side divergence paths #104

Merged
padreug merged 5 commits from fix/cassette-sync-machine into dev 2026-09-22 20:30:24 +00:00
4 changed files with 52 additions and 3 deletions
Showing only changes of commit db68e6e244 - Show all commits

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>
Padreug 2026-09-22 22:09:23 +02:00

View file

@ -841,6 +841,11 @@ function startCommandPoller(): void {
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
let refRemediated = false
if (parsed.ref_txid && result.dispensed) {

View file

@ -222,6 +222,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Bolt Card reader (main process → renderer). removeAllListeners first: a
// renderer reload re-runs this, and a duplicated card-tap listener would
// 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) => {
ipcRenderer.removeAllListeners('nfc:card-tapped')
ipcRenderer.on('nfc:card-tapped', (_event, lnurlw) => callback(lnurlw))

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). */