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:
parent
0d43c4e033
commit
db68e6e244
4 changed files with 52 additions and 3 deletions
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
2
apps/machine/src/types/electron.d.ts
vendored
2
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -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). */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue