diff --git a/apps/machine/electron/__tests__/state-store-transactions.test.ts b/apps/machine/electron/__tests__/state-store-transactions.test.ts index 4c91a78..cb64aa5 100644 --- a/apps/machine/electron/__tests__/state-store-transactions.test.ts +++ b/apps/machine/electron/__tests__/state-store-transactions.test.ts @@ -14,6 +14,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { closeDatabase, getCashbox, + getInventory, initDatabase, loadCassettes, recordTransaction, @@ -260,3 +261,64 @@ describe('state-store: recordTransaction manual_dispense inventory (#76)', () => expect(countsByPosition()[2]).toBe(49) }) }) + +describe('state-store: getInventory represents a drained machine', () => { + it('keeps configured bays at zero rather than dropping them', () => { + recordTransaction({ + ...TX_BASE, + txid: 'tx-drain-50s', + type: 'cash_out', + status: 'complete', + bills: [{ denomination: 50, count: 30 }], + cassettes: [ + { + name: 'cassette3', + position: 3, + denomination: 50, + provisioned: 30, + dispensed: 30, + rejected: 0, + }, + ], + }) + // The $50 bay is empty but still configured. Dropping the key made this + // look like "no inventory known", and callers then fell back to a stale + // snapshot or to HAL. + expect(getInventory()).toEqual({ 20: 100, 50: 0 }) + }) + + it('reports every bay at zero when the machine is fully drained', () => { + for (const [txid, position, denomination, count] of [ + ['d1', 1, 20, 50], + ['d2', 2, 20, 50], + ['d3', 3, 50, 30], + ] as const) { + recordTransaction({ + ...TX_BASE, + txid, + type: 'cash_out', + status: 'complete', + bills: [{ denomination, count }], + cassettes: [ + { + name: `cassette${position}`, + position, + denomination, + provisioned: count, + dispensed: count, + rejected: 0, + }, + ], + }) + } + expect(getInventory()).toEqual({ 20: 0, 50: 0 }) + }) + + it('returns an empty map only when no cassettes are configured', () => { + // A fresh DB with no bays at all — the one case that should read as + // "nothing known", so callers may legitimately defer to the hardware. + closeDatabase() + initDatabase(':memory:') + expect(getInventory()).toEqual({}) + }) +}) diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index 5fe4d05..39e32b6 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -874,9 +874,14 @@ export function getInventory(): Record { const rows = loadCassettes() const inv: Record = {} for (const row of rows) { - if (row.count > 0) { - inv[row.denomination] = (inv[row.denomination] ?? 0) + row.count - } + // Zero-count bays are KEPT. Dropping them made a drained machine + // indistinguishable from an unconfigured one, and every caller reads an + // empty map as "I don't know, ask the hardware" — so the last non-empty + // snapshot stuck and the availability beacon went on advertising bills + // that had already been dispensed. An empty map now means exactly one + // thing: no cassettes are configured. Consumers already filter for + // `> 0` before offering a denomination (CashOutView, machine.ts). + inv[row.denomination] = (inv[row.denomination] ?? 0) + row.count } return inv } diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 0ed4719..8751861 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -161,10 +161,14 @@ async function handleManagementCommand( } /** - * Load inventory from SQLite via IPC (Electron only). - * Returns empty object in browser dev mode. + * Load inventory from SQLite via IPC. + * + * Returns `null` when the DB could not be asked at all — browser dev mode, or + * a failed IPC call — so callers can tell "no answer" from an answer of "the + * bays are empty". An empty map is a real, actionable reading: cassettes are + * configured and drained, or none are configured. */ -async function loadInventoryFromDb(): Promise> { +async function loadInventoryFromDb(): Promise | null> { if (isElectron && window.electronAPI) { try { const inv = await window.electronAPI.getInventory() @@ -174,7 +178,7 @@ async function loadInventoryFromDb(): Promise> { console.warn('[ATM] Failed to load inventory from DB:', e) } } - return {} + return null } /** @@ -448,10 +452,12 @@ export const useAtmStore = defineStore('atm', () => { async function reloadPersistedInventory() { const inv = await loadInventoryFromDb() - if (Object.keys(inv).length > 0) { - persistedInventory.value = inv - console.log('[ATM] Persisted inventory updated:', inv) - } + // Only a failed read is ignored. An empty map used to be skipped too, + // which meant the last bill out of the machine never updated anything and + // the availability beacon kept advertising a full cassette. + if (inv === null) return + persistedInventory.value = inv + console.log('[ATM] Persisted inventory updated:', inv) } /** Detect Bitcoin network from a BOLT-11 invoice prefix (called once, persisted) */ @@ -1064,7 +1070,8 @@ export const useAtmStore = defineStore('atm', () => { }), getInventory: async () => { const fresh = await loadInventoryFromDb() - return Object.keys(fresh).length > 0 ? fresh : services.atmServices.getInventory() + // null == the DB could not be asked; an empty map is a real reading. + return fresh ?? services.atmServices.getInventory() }, } @@ -1327,7 +1334,8 @@ export const useAtmStore = defineStore('atm', () => { // If DB has inventory, use it; otherwise fall back to HAL getInventory: async () => { const fresh = await loadInventoryFromDb() - return Object.keys(fresh).length > 0 ? fresh : hal.atmServices.getInventory() + // null == the DB could not be asked; an empty map is a real reading. + return fresh ?? hal.atmServices.getInventory() }, } @@ -1611,9 +1619,11 @@ export const useAtmStore = defineStore('atm', () => { return await api.halDispense(amounts) }, getInventory: async () => { - // Priority: DB inventory > HAL hardware inventory > empty + // Priority: DB inventory > HAL hardware inventory > empty. Only a + // null (unreadable) DB defers to HAL — a drained machine reports + // drained rather than borrowing the hardware's view. const fresh = await loadInventoryFromDb() - if (Object.keys(fresh).length > 0) return fresh + if (fresh !== null) return fresh // Fall back to HAL's cassette-based inventory try { const halInv = await api.halGetInventory()