fix(cassettes): close the machine-side divergence paths #104
2 changed files with 113 additions and 18 deletions
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>
commit
5b22447dae
|
|
@ -168,3 +168,95 @@ describe('state-store: recordTransaction cash_in cashbox', () => {
|
||||||
expect(countsByPosition()).toEqual({ 1: 50, 2: 50, 3: 30 })
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -295,7 +295,9 @@ export function initDatabase(dbPath?: string): void {
|
||||||
`)
|
`)
|
||||||
db.pragma('foreign_keys = ON')
|
db.pragma('foreign_keys = ON')
|
||||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('9', 'schema_version')
|
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'
|
existing.value = '9'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -397,9 +399,9 @@ export function initDatabase(dbPath?: string): void {
|
||||||
*/
|
*/
|
||||||
export function getLastKnownConfigCreatedAt(): number {
|
export function getLastKnownConfigCreatedAt(): number {
|
||||||
if (!db) throw new Error('Database not initialized')
|
if (!db) throw new Error('Database not initialized')
|
||||||
const row = db
|
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('lastKnownConfigCreatedAt') as
|
||||||
.prepare('SELECT value FROM meta WHERE key = ?')
|
| { value: string }
|
||||||
.get('lastKnownConfigCreatedAt') as { value: string } | undefined
|
| undefined
|
||||||
return row ? Number(row.value) || 0 : 0
|
return row ? Number(row.value) || 0 : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -409,9 +411,9 @@ export function getLastKnownConfigCreatedAt(): number {
|
||||||
*/
|
*/
|
||||||
export function getBootstrapPublishedAt(): number | null {
|
export function getBootstrapPublishedAt(): number | null {
|
||||||
if (!db) throw new Error('Database not initialized')
|
if (!db) throw new Error('Database not initialized')
|
||||||
const row = db
|
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('bootstrapPublishedAt') as
|
||||||
.prepare('SELECT value FROM meta WHERE key = ?')
|
| { value: string }
|
||||||
.get('bootstrapPublishedAt') as { value: string } | undefined
|
| undefined
|
||||||
if (!row || row.value === '') return null
|
if (!row || row.value === '') return null
|
||||||
const n = Number(row.value)
|
const n = Number(row.value)
|
||||||
return Number.isFinite(n) ? n : null
|
return Number.isFinite(n) ? n : null
|
||||||
|
|
@ -570,9 +572,7 @@ export type OperatorCassettesPayload = {
|
||||||
positions: Record<string, { denomination: number; count: number }>
|
positions: Record<string, { denomination: number; count: number }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ApplyResult =
|
export type ApplyResult = { applied: true } | { applied: false; reason: string }
|
||||||
| { applied: true }
|
|
||||||
| { applied: false; reason: string }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atomic apply of an operator-published cassette config (aiolabs/lamassu-next#56).
|
* Atomic apply of an operator-published cassette config (aiolabs/lamassu-next#56).
|
||||||
|
|
@ -613,9 +613,7 @@ export function applyOperatorCassettesConfig(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentRows = db
|
const currentRows = db.prepare('SELECT position FROM cassettes').all() as { position: number }[]
|
||||||
.prepare('SELECT position FROM cassettes')
|
|
||||||
.all() as { position: number }[]
|
|
||||||
const currentPositions = new Set(currentRows.map((r) => r.position))
|
const currentPositions = new Set(currentRows.map((r) => r.position))
|
||||||
const payloadPositions = new Set(Object.keys(payload.positions).map((k) => Number(k)))
|
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
|
const FEE_CAP_PER_DIRECTION = 0.15
|
||||||
|
|
||||||
export function applyFeeConfig(
|
export function applyFeeConfig(payload: FeeConfigPayload, eventCreatedAt: number): ApplyResult {
|
||||||
payload: FeeConfigPayload,
|
|
||||||
eventCreatedAt: number
|
|
||||||
): ApplyResult {
|
|
||||||
if (!db) throw new Error('Database not initialized')
|
if (!db) throw new Error('Database not initialized')
|
||||||
|
|
||||||
const watermark = getLastKnownFeeConfigCreatedAt()
|
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).
|
// Decrement cassettes by ACTUALLY dispensed count (not requested).
|
||||||
// Position is the addressable unit (v9): duplicate denominations
|
// Position is the addressable unit (v9): duplicate denominations
|
||||||
// across bays are legal, so a denomination-keyed UPDATE would
|
// across bays are legal, so a denomination-keyed UPDATE would
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue