From 5b22447dae27dae3f11e0881eca5e83c930f43b1 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 22 Sep 2026 22:05:42 +0200 Subject: [PATCH] fix(cassettes): decrement bays on an operator remediation dispense MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recordTransaction only debited the cassette rows for type 'cash_out'. An operator remediation is recorded as 'manual_dispense', so HAL's in-memory bays went down while the persisted rows did not — and HAL re-seeds from those rows on the next boot, so the machine came back believing it still held bills a customer had already been handed. A remediation against a partly-dispensed original debits again on purpose: the original only ever debited what physically left, and this is a second lot of bills leaving the bay. Closes #76 Co-Authored-By: Claude Fable 5.1 --- .../state-store-transactions.test.ts | 92 +++++++++++++++++++ apps/machine/electron/state-store.ts | 39 ++++---- 2 files changed, 113 insertions(+), 18 deletions(-) diff --git a/apps/machine/electron/__tests__/state-store-transactions.test.ts b/apps/machine/electron/__tests__/state-store-transactions.test.ts index fbb9a87..4c91a78 100644 --- a/apps/machine/electron/__tests__/state-store-transactions.test.ts +++ b/apps/machine/electron/__tests__/state-store-transactions.test.ts @@ -168,3 +168,95 @@ describe('state-store: recordTransaction cash_in cashbox', () => { expect(countsByPosition()).toEqual({ 1: 50, 2: 50, 3: 30 }) }) }) + +describe('state-store: recordTransaction manual_dispense inventory (#76)', () => { + it('decrements the bays an operator remediation actually emptied', () => { + recordTransaction({ + ...TX_BASE, + txid: 'tx-manual', + type: 'manual_dispense', + status: 'complete', + bills: [{ denomination: 20, count: 2 }], + cassettes: [ + { + name: 'cassette1', + position: 1, + denomination: 20, + provisioned: 2, + dispensed: 2, + rejected: 0, + }, + ], + }) + // Bills physically left bay 1; before #76 this row was untouched and the + // inflated count became truth on the next boot. + expect(countsByPosition()).toEqual({ 1: 48, 2: 50, 3: 30 }) + }) + + it('decrements again when remediating a partly-dispensed cash-out', () => { + // Original cash-out managed 1 of the 2 notes it provisioned. + recordTransaction({ + ...TX_BASE, + txid: 'tx-partial', + type: 'cash_out', + status: 'partial', + bills: [{ denomination: 50, count: 1 }], + cassettes: [ + { + name: 'cassette3', + position: 3, + denomination: 50, + provisioned: 2, + dispensed: 1, + rejected: 0, + }, + ], + }) + expect(countsByPosition()[3]).toBe(29) + + // The operator dispenses the missing note by hand. That is a second lot of + // bills leaving the bay, so it debits again — the original only ever + // debited what physically left. + recordTransaction({ + ...TX_BASE, + txid: 'tx-remediate', + type: 'manual_dispense', + status: 'complete', + bills: [{ denomination: 50, count: 1 }], + cassettes: [ + { + name: 'cassette3', + position: 3, + denomination: 50, + provisioned: 1, + dispensed: 1, + rejected: 0, + }, + ], + }) + expect(countsByPosition()[3]).toBe(28) + }) + + it('leaves the cashbox alone (bills leave, they do not arrive)', () => { + const before = getCashbox() + recordTransaction({ + ...TX_BASE, + txid: 'tx-manual-cashbox', + type: 'manual_dispense', + status: 'complete', + bills: [{ denomination: 20, count: 1 }], + cassettes: [ + { + name: 'cassette2', + position: 2, + denomination: 20, + provisioned: 1, + dispensed: 1, + rejected: 0, + }, + ], + }) + expect(getCashbox()).toEqual(before) + expect(countsByPosition()[2]).toBe(49) + }) +}) diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index cfbfd71..5fe4d05 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -295,7 +295,9 @@ export function initDatabase(dbPath?: string): void { `) db.pragma('foreign_keys = ON') db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('9', 'schema_version') - console.log('[StateStore] Migrated schema v8 → v9 (cassettes PK position; allow duplicate denominations)') + console.log( + '[StateStore] Migrated schema v8 → v9 (cassettes PK position; allow duplicate denominations)' + ) existing.value = '9' } @@ -397,9 +399,9 @@ export function initDatabase(dbPath?: string): void { */ export function getLastKnownConfigCreatedAt(): number { if (!db) throw new Error('Database not initialized') - const row = db - .prepare('SELECT value FROM meta WHERE key = ?') - .get('lastKnownConfigCreatedAt') as { value: string } | undefined + const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('lastKnownConfigCreatedAt') as + | { value: string } + | undefined return row ? Number(row.value) || 0 : 0 } @@ -409,9 +411,9 @@ export function getLastKnownConfigCreatedAt(): number { */ export function getBootstrapPublishedAt(): number | null { if (!db) throw new Error('Database not initialized') - const row = db - .prepare('SELECT value FROM meta WHERE key = ?') - .get('bootstrapPublishedAt') as { value: string } | undefined + const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('bootstrapPublishedAt') as + | { value: string } + | undefined if (!row || row.value === '') return null const n = Number(row.value) return Number.isFinite(n) ? n : null @@ -570,9 +572,7 @@ export type OperatorCassettesPayload = { positions: Record } -export type ApplyResult = - | { applied: true } - | { applied: false; reason: string } +export type ApplyResult = { applied: true } | { applied: false; reason: string } /** * Atomic apply of an operator-published cassette config (aiolabs/lamassu-next#56). @@ -613,9 +613,7 @@ export function applyOperatorCassettesConfig( } } - const currentRows = db - .prepare('SELECT position FROM cassettes') - .all() as { position: number }[] + const currentRows = db.prepare('SELECT position FROM cassettes').all() as { position: number }[] const currentPositions = new Set(currentRows.map((r) => r.position)) const payloadPositions = new Set(Object.keys(payload.positions).map((k) => Number(k))) @@ -752,10 +750,7 @@ export interface FeeConfigPayload { */ const FEE_CAP_PER_DIRECTION = 0.15 -export function applyFeeConfig( - payload: FeeConfigPayload, - eventCreatedAt: number -): ApplyResult { +export function applyFeeConfig(payload: FeeConfigPayload, eventCreatedAt: number): ApplyResult { if (!db) throw new Error('Database not initialized') const watermark = getLastKnownFeeConfigCreatedAt() @@ -1026,7 +1021,15 @@ export function recordTransaction(tx: TransactionInput): void { } } - if (t.type === 'cash_out') { + // Any dispense empties bays, whoever asked for it. `manual_dispense` + // (operator remediation, via the command poller or a kind-21003 command) + // used to fall outside this branch: HAL decremented its in-memory bays but + // the rows here did not move, and on the next boot HAL re-seeds from these + // rows — so the machine came back believing it still held bills a customer + // had already been handed (#76). A remediation against a partly-dispensed + // original decrements again on purpose: the original only ever debited what + // physically left, and this is a second lot of bills leaving. + if (t.type === 'cash_out' || t.type === 'manual_dispense') { // Decrement cassettes by ACTUALLY dispensed count (not requested). // Position is the addressable unit (v9): duplicate denominations // across bays are legal, so a denomination-keyed UPDATE would