fix(cassettes): decrement bays on an operator remediation dispense

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 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-09-22 22:05:42 +02:00
commit 5b22447dae
2 changed files with 113 additions and 18 deletions

View file

@ -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<string, { denomination: number; count: number }>
}
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