feat(machine): record failed dispenses and per-cassette tracking
Failed dispenses (sats debited, cash not dispensed) were invisible —
transactions only recorded on 'complete'. Now records on 'dispenseError'
with status ('dispense_error'|'partial'|'complete'), error message, and
per-cassette detail.
Also fixes a bug in both HAL services where dispense results were mapped
by amounts-array index instead of cassette position, causing swapped
denomination counts when cassette order differs from request order.
Schema v3→v4: adds status/error columns to transactions, new
cassette_bills table for per-cassette provisioned/dispensed/rejected.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3ab03d05ac
commit
a21865ff20
10 changed files with 287 additions and 53 deletions
17
CLAUDE.md
17
CLAUDE.md
|
|
@ -150,7 +150,7 @@ alice-pay <invoice>
|
||||||
For advanced testing with multiple Lightning implementations, use the regtest environment at `~/dev/local/docker/regtest`. This provides:
|
For advanced testing with multiple Lightning implementations, use the regtest environment at `~/dev/local/docker/regtest`. This provides:
|
||||||
|
|
||||||
| Service | Description |
|
| Service | Description |
|
||||||
| ----------------- | ------------------------------------- |
|
| ------------------ | -------------------------------------------------------------- |
|
||||||
| 4 LND nodes | lnd-1 (hub), lnd-2 (Boltz), lnd-3 (LNbits), lnd-4 (standalone) |
|
| 4 LND nodes | lnd-1 (hub), lnd-2 (Boltz), lnd-3 (LNbits), lnd-4 (standalone) |
|
||||||
| 3 CLN nodes | Core Lightning with REST/gRPC |
|
| 3 CLN nodes | Core Lightning with REST/gRPC |
|
||||||
| 1 Eclair node | ACINQ Eclair implementation |
|
| 1 Eclair node | ACINQ Eclair implementation |
|
||||||
|
|
@ -289,13 +289,26 @@ Drivers are ported from `lamassu-machine/lib/`:
|
||||||
| ---------- | ------------------------------------------------------------ |
|
| ---------- | ------------------------------------------------------------ |
|
||||||
| Validators | id003, ccnet, cashflow_sc, bnr_advance, genmega, hcm2, gsr50 |
|
| Validators | id003, ccnet, cashflow_sc, bnr_advance, genmega, hcm2, gsr50 |
|
||||||
| Dispensers | puloon, f56, genmega, hcm2, gsr50 |
|
| Dispensers | puloon, f56, genmega, hcm2, gsr50 |
|
||||||
|
| Recyclers | MEI SCR (planned — hardware exists in BATM3, no driver yet) |
|
||||||
| Printers | nippon, zebra, genmega |
|
| Printers | nippon, zebra, genmega |
|
||||||
|
|
||||||
|
### BATM3 Hardware Topology
|
||||||
|
|
||||||
|
The GeneralBytes BATM3 has two separate cash-handling units:
|
||||||
|
|
||||||
|
| Unit | Hardware | Role | Cassettes |
|
||||||
|
| ----------------- | ---------- | ---------------------------------- | ------------------------------------ |
|
||||||
|
| MEI Cash Recycler | MEI SCR | Cash-in (accept bills) + recycling | 2 recycler (60 each) + cashbox (600) |
|
||||||
|
| F56 Dispenser | Puloon F56 | Cash-out (dispense only) | 2 dispenser cassettes |
|
||||||
|
|
||||||
|
**Current status**: MEI used as validator-only (`cashflowSc` driver), F56 for dispensing.
|
||||||
|
The MEI's recycler cassettes are untapped — accepted bills go to cashbox, not recycled.
|
||||||
|
|
||||||
When porting:
|
When porting:
|
||||||
|
|
||||||
1. Read JS driver thoroughly
|
1. Read JS driver thoroughly
|
||||||
2. Document protocol from JS code
|
2. Document protocol from JS code
|
||||||
3. Implement Rust version
|
3. Implement TypeScript version
|
||||||
4. Test against same hardware
|
4. Test against same hardware
|
||||||
5. Use `/hal-check port <driver>` to validate
|
5. Use `/hal-check port <driver>` to validate
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,14 @@ export interface DispenseResult {
|
||||||
bills: { denomination: number; dispensed: number; rejected: number }[]
|
bills: { denomination: number; dispensed: number; rejected: number }[]
|
||||||
dispensed: boolean
|
dispensed: boolean
|
||||||
error?: string
|
error?: string
|
||||||
|
cassettes?: {
|
||||||
|
name: string
|
||||||
|
position: number
|
||||||
|
denomination: number
|
||||||
|
provisioned: number
|
||||||
|
dispensed: number
|
||||||
|
rejected: number
|
||||||
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HalInstance {
|
export interface HalInstance {
|
||||||
|
|
@ -175,26 +183,37 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
||||||
|
|
||||||
const result = await dispenser.dispense(notes)
|
const result = await dispenser.dispense(notes)
|
||||||
|
|
||||||
// Update inventory
|
// Build per-cassette results (position-aware, matches result.value ordering)
|
||||||
for (let i = 0; i < result.value.length; i++) {
|
const cassetteResults = cassetteDenominations.map((denom, i) => ({
|
||||||
const denom = cassetteDenominations[i]
|
name: `cassette${i + 1}`,
|
||||||
if (denom !== undefined && inventory[denom] !== undefined) {
|
position: i,
|
||||||
inventory[denom] -= result.value[i]?.dispensed ?? 0
|
denomination: denom,
|
||||||
|
provisioned: notes[i] ?? 0,
|
||||||
|
dispensed: result.value[i]?.dispensed ?? 0,
|
||||||
|
rejected: result.value[i]?.rejected ?? 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Update inventory based on what was ACTUALLY dispensed (per-cassette)
|
||||||
|
for (const c of cassetteResults) {
|
||||||
|
if (c.dispensed > 0 && inventory[c.denomination] !== undefined) {
|
||||||
|
inventory[c.denomination] -= c.dispensed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build per-denomination result
|
// Build per-denomination result (backward compatible, only requested denoms)
|
||||||
const bills = amounts.map((a, i) => ({
|
const bills = cassetteResults
|
||||||
denomination: a.denomination,
|
.filter((c) => c.provisioned > 0)
|
||||||
dispensed: result.value[i]?.dispensed ?? 0,
|
.map((c) => ({
|
||||||
rejected: result.value[i]?.rejected ?? 0,
|
denomination: c.denomination,
|
||||||
|
dispensed: c.dispensed,
|
||||||
|
rejected: c.rejected,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const totalRequested = amounts.reduce((s, a) => s + a.count, 0)
|
const totalRequested = amounts.reduce((s, a) => s + a.count, 0)
|
||||||
const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
|
const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
return { bills, dispensed: false, error: result.error.message }
|
return { bills, cassettes: cassetteResults, dispensed: false, error: result.error.message }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for customer to take bills
|
// Wait for customer to take bills
|
||||||
|
|
@ -203,7 +222,7 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
||||||
console.log('[HAL] Bills removed by customer')
|
console.log('[HAL] Bills removed by customer')
|
||||||
}
|
}
|
||||||
|
|
||||||
return { bills, dispensed: totalRequested === totalDispensed }
|
return { bills, cassettes: cassetteResults, dispensed: totalRequested === totalDispensed }
|
||||||
},
|
},
|
||||||
|
|
||||||
getInventory: () => ({ ...inventory }),
|
getInventory: () => ({ ...inventory }),
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,23 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
recordTransaction: (tx: {
|
recordTransaction: (tx: {
|
||||||
txid: string
|
txid: string
|
||||||
type: 'cash_in' | 'cash_out'
|
type: 'cash_in' | 'cash_out'
|
||||||
|
status: 'complete' | 'dispense_error' | 'partial'
|
||||||
fiatCents: number
|
fiatCents: number
|
||||||
sats: number
|
sats: number
|
||||||
|
feeSats: number
|
||||||
|
feePercent: number
|
||||||
|
exchangeRate: number
|
||||||
|
currency: string
|
||||||
bills: { denomination: number; count: number }[]
|
bills: { denomination: number; count: number }[]
|
||||||
|
cassettes?: {
|
||||||
|
name: string
|
||||||
|
position: number
|
||||||
|
denomination: number
|
||||||
|
provisioned: number
|
||||||
|
dispensed: number
|
||||||
|
rejected: number
|
||||||
|
}[]
|
||||||
|
error?: string | null
|
||||||
}) => ipcRenderer.invoke('state:record-transaction', tx),
|
}) => ipcRenderer.invoke('state:record-transaction', tx),
|
||||||
emptyCashbox: () => ipcRenderer.invoke('state:empty-cashbox'),
|
emptyCashbox: () => ipcRenderer.invoke('state:empty-cashbox'),
|
||||||
|
|
||||||
|
|
@ -114,9 +128,23 @@ declare global {
|
||||||
recordTransaction: (tx: {
|
recordTransaction: (tx: {
|
||||||
txid: string
|
txid: string
|
||||||
type: 'cash_in' | 'cash_out'
|
type: 'cash_in' | 'cash_out'
|
||||||
|
status: 'complete' | 'dispense_error' | 'partial'
|
||||||
fiatCents: number
|
fiatCents: number
|
||||||
sats: number
|
sats: number
|
||||||
|
feeSats: number
|
||||||
|
feePercent: number
|
||||||
|
exchangeRate: number
|
||||||
|
currency: string
|
||||||
bills: { denomination: number; count: number }[]
|
bills: { denomination: number; count: number }[]
|
||||||
|
cassettes?: {
|
||||||
|
name: string
|
||||||
|
position: number
|
||||||
|
denomination: number
|
||||||
|
provisioned: number
|
||||||
|
dispensed: number
|
||||||
|
rejected: number
|
||||||
|
}[]
|
||||||
|
error?: string | null
|
||||||
}) => Promise<void>
|
}) => Promise<void>
|
||||||
emptyCashbox: () => Promise<void>
|
emptyCashbox: () => Promise<void>
|
||||||
// HAL hardware IPC
|
// HAL hardware IPC
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import fs from 'node:fs'
|
||||||
|
|
||||||
let db: Database.Database | null = null
|
let db: Database.Database | null = null
|
||||||
|
|
||||||
const SCHEMA_VERSION = '3'
|
const SCHEMA_VERSION = '4'
|
||||||
|
|
||||||
function getDbPath(): string {
|
function getDbPath(): string {
|
||||||
const prodDir = '/var/lib/lamassu-atm'
|
const prodDir = '/var/lib/lamassu-atm'
|
||||||
|
|
@ -72,6 +72,8 @@ export function initDatabase(dbPath?: string): void {
|
||||||
fee_percent REAL NOT NULL DEFAULT 0,
|
fee_percent REAL NOT NULL DEFAULT 0,
|
||||||
exchange_rate REAL NOT NULL DEFAULT 0,
|
exchange_rate REAL NOT NULL DEFAULT 0,
|
||||||
currency TEXT NOT NULL DEFAULT 'GTQ',
|
currency TEXT NOT NULL DEFAULT 'GTQ',
|
||||||
|
status TEXT NOT NULL DEFAULT 'complete',
|
||||||
|
error TEXT,
|
||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -81,6 +83,17 @@ export function initDatabase(dbPath?: string): void {
|
||||||
denomination INTEGER NOT NULL,
|
denomination INTEGER NOT NULL,
|
||||||
count INTEGER NOT NULL
|
count INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cassette_bills (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
txid TEXT NOT NULL REFERENCES transactions(txid),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
denomination INTEGER NOT NULL,
|
||||||
|
provisioned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
dispensed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
rejected INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// Seed meta + cashbox if first run, or run migrations
|
// Seed meta + cashbox if first run, or run migrations
|
||||||
|
|
@ -108,6 +121,28 @@ export function initDatabase(dbPath?: string): void {
|
||||||
`)
|
`)
|
||||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('3', 'schema_version')
|
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('3', 'schema_version')
|
||||||
console.log('[StateStore] Migrated schema v2 → v3 (added exchange_rate, currency)')
|
console.log('[StateStore] Migrated schema v2 → v3 (added exchange_rate, currency)')
|
||||||
|
existing.value = '3'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing && existing.value === '3') {
|
||||||
|
// Migration v3 → v4: add status/error to transactions, cassette_bills table
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE transactions ADD COLUMN status TEXT NOT NULL DEFAULT 'complete';
|
||||||
|
ALTER TABLE transactions ADD COLUMN error TEXT;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cassette_bills (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
txid TEXT NOT NULL REFERENCES transactions(txid),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
denomination INTEGER NOT NULL,
|
||||||
|
provisioned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
dispensed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
rejected INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('4', 'schema_version')
|
||||||
|
console.log('[StateStore] Migrated schema v3 → v4 (added status, error, cassette_bills)')
|
||||||
}
|
}
|
||||||
|
|
||||||
const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get()
|
const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get()
|
||||||
|
|
@ -235,6 +270,7 @@ export function emptyCashbox(): void {
|
||||||
interface TransactionInput {
|
interface TransactionInput {
|
||||||
txid: string
|
txid: string
|
||||||
type: 'cash_in' | 'cash_out'
|
type: 'cash_in' | 'cash_out'
|
||||||
|
status: 'complete' | 'dispense_error' | 'partial'
|
||||||
fiatCents: number
|
fiatCents: number
|
||||||
sats: number
|
sats: number
|
||||||
feeSats: number
|
feeSats: number
|
||||||
|
|
@ -242,6 +278,15 @@ interface TransactionInput {
|
||||||
exchangeRate: number
|
exchangeRate: number
|
||||||
currency: string
|
currency: string
|
||||||
bills: { denomination: number; count: number }[]
|
bills: { denomination: number; count: number }[]
|
||||||
|
cassettes?: {
|
||||||
|
name: string
|
||||||
|
position: number
|
||||||
|
denomination: number
|
||||||
|
provisioned: number
|
||||||
|
dispensed: number
|
||||||
|
rejected: number
|
||||||
|
}[]
|
||||||
|
error?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -251,11 +296,14 @@ export function recordTransaction(tx: TransactionInput): void {
|
||||||
if (!db) throw new Error('Database not initialized')
|
if (!db) throw new Error('Database not initialized')
|
||||||
|
|
||||||
const insertTx = db.prepare(
|
const insertTx = db.prepare(
|
||||||
'INSERT INTO transactions (txid, type, fiat_cents, sats, fee_sats, fee_percent, exchange_rate, currency, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
'INSERT INTO transactions (txid, type, status, error, fiat_cents, sats, fee_sats, fee_percent, exchange_rate, currency, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
)
|
)
|
||||||
const insertBill = db.prepare(
|
const insertBill = db.prepare(
|
||||||
'INSERT INTO transaction_bills (txid, denomination, count) VALUES (?, ?, ?)'
|
'INSERT INTO transaction_bills (txid, denomination, count) VALUES (?, ?, ?)'
|
||||||
)
|
)
|
||||||
|
const insertCassetteBill = db.prepare(
|
||||||
|
'INSERT INTO cassette_bills (txid, name, position, denomination, provisioned, dispensed, rejected) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
)
|
||||||
const updateCassette = db.prepare(
|
const updateCassette = db.prepare(
|
||||||
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE denomination = ?'
|
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE denomination = ?'
|
||||||
)
|
)
|
||||||
|
|
@ -267,6 +315,8 @@ export function recordTransaction(tx: TransactionInput): void {
|
||||||
insertTx.run(
|
insertTx.run(
|
||||||
t.txid,
|
t.txid,
|
||||||
t.type,
|
t.type,
|
||||||
|
t.status,
|
||||||
|
t.error ?? null,
|
||||||
t.fiatCents,
|
t.fiatCents,
|
||||||
t.sats,
|
t.sats,
|
||||||
t.feeSats,
|
t.feeSats,
|
||||||
|
|
@ -278,12 +328,38 @@ export function recordTransaction(tx: TransactionInput): void {
|
||||||
|
|
||||||
for (const bill of t.bills) {
|
for (const bill of t.bills) {
|
||||||
insertBill.run(t.txid, bill.denomination, bill.count)
|
insertBill.run(t.txid, bill.denomination, bill.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert per-cassette detail when available
|
||||||
|
if (t.cassettes) {
|
||||||
|
for (const c of t.cassettes) {
|
||||||
|
insertCassetteBill.run(
|
||||||
|
t.txid,
|
||||||
|
c.name,
|
||||||
|
c.position,
|
||||||
|
c.denomination,
|
||||||
|
c.provisioned,
|
||||||
|
c.dispensed,
|
||||||
|
c.rejected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (t.type === 'cash_out') {
|
if (t.type === 'cash_out') {
|
||||||
// Dispensed bills: decrement cassettes
|
// Decrement cassettes by ACTUALLY dispensed count (not requested)
|
||||||
|
if (t.cassettes) {
|
||||||
|
for (const c of t.cassettes) {
|
||||||
|
if (c.dispensed > 0) {
|
||||||
|
updateCassette.run(-c.dispensed, c.denomination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: use bill counts (backward compat for mocks without cassette data)
|
||||||
|
for (const bill of t.bills) {
|
||||||
updateCassette.run(-bill.count, bill.denomination)
|
updateCassette.run(-bill.count, bill.denomination)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (t.type === 'cash_in') {
|
if (t.type === 'cash_in') {
|
||||||
// Bills inserted by customer go into cashbox
|
// Bills inserted by customer go into cashbox
|
||||||
|
|
@ -293,7 +369,7 @@ export function recordTransaction(tx: TransactionInput): void {
|
||||||
})
|
})
|
||||||
|
|
||||||
run(tx)
|
run(tx)
|
||||||
console.log('[StateStore] Recorded transaction:', tx.txid, tx.type)
|
console.log('[StateStore] Recorded transaction:', tx.txid, tx.type, `(${tx.status})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -149,26 +149,37 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
||||||
|
|
||||||
const result = await dispenser.dispense(notes)
|
const result = await dispenser.dispense(notes)
|
||||||
|
|
||||||
// Update inventory based on what was ACTUALLY dispensed
|
// Build per-cassette results (position-aware, matches result.value ordering)
|
||||||
for (let i = 0; i < result.value.length; i++) {
|
const cassetteResults = cassetteDenominations.map((denom, i) => ({
|
||||||
const denom = cassetteDenominations[i]
|
name: `cassette${i + 1}`,
|
||||||
if (denom !== undefined && inventory[denom] !== undefined) {
|
position: i,
|
||||||
inventory[denom] -= result.value[i]?.dispensed ?? 0
|
denomination: denom,
|
||||||
|
provisioned: notes[i] ?? 0,
|
||||||
|
dispensed: result.value[i]?.dispensed ?? 0,
|
||||||
|
rejected: result.value[i]?.rejected ?? 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Update inventory based on what was ACTUALLY dispensed (per-cassette)
|
||||||
|
for (const c of cassetteResults) {
|
||||||
|
if (c.dispensed > 0 && c.denomination in inventory) {
|
||||||
|
inventory[c.denomination] = (inventory[c.denomination] ?? 0) - c.dispensed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build per-denomination result
|
// Build per-denomination result (backward compatible, only requested denoms)
|
||||||
const bills = amounts.map((a, i) => ({
|
const bills = cassetteResults
|
||||||
denomination: a.denomination,
|
.filter((c) => c.provisioned > 0)
|
||||||
dispensed: result.value[i]?.dispensed ?? 0,
|
.map((c) => ({
|
||||||
rejected: result.value[i]?.rejected ?? 0,
|
denomination: c.denomination,
|
||||||
|
dispensed: c.dispensed,
|
||||||
|
rejected: c.rejected,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const totalRequested = amounts.reduce((s, a) => s + a.count, 0)
|
const totalRequested = amounts.reduce((s, a) => s + a.count, 0)
|
||||||
const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
|
const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
return { bills, dispensed: false, error: result.error.message }
|
return { bills, cassettes: cassetteResults, dispensed: false, error: result.error.message }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for customer to take bills (only if bills were dispensed)
|
// Wait for customer to take bills (only if bills were dispensed)
|
||||||
|
|
@ -177,7 +188,7 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
||||||
console.log('[HAL] Bills removed by customer')
|
console.log('[HAL] Bills removed by customer')
|
||||||
}
|
}
|
||||||
|
|
||||||
return { bills, dispensed: totalRequested === totalDispensed }
|
return { bills, cassettes: cassetteResults, dispensed: totalRequested === totalDispensed }
|
||||||
},
|
},
|
||||||
|
|
||||||
getInventory: async () => {
|
getInventory: async () => {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,18 @@ import type { TransactionRecord } from '@/types/state'
|
||||||
// Check if we're running in Electron
|
// Check if we're running in Electron
|
||||||
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
|
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute fee in sats from context.
|
||||||
|
* Cash-in: customer gets net (gross - fee). Cash-out: customer pays gross + fee.
|
||||||
|
*/
|
||||||
|
function computeFeeSats(ctx: ATMContext, isCashIn: boolean): number {
|
||||||
|
const grossSats = ctx.exchangeRate > 0 ? Math.floor((ctx.fiatCents / 100) * ctx.exchangeRate) : 0
|
||||||
|
const feeSats = isCashIn
|
||||||
|
? grossSats - ctx.satsAmount // cash-in: customer gets net
|
||||||
|
: ctx.satsAmount - grossSats // cash-out: customer pays gross + fee
|
||||||
|
return Math.max(0, feeSats)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load inventory from SQLite via IPC (Electron only).
|
* Load inventory from SQLite via IPC (Electron only).
|
||||||
* Returns empty object in browser dev mode.
|
* Returns empty object in browser dev mode.
|
||||||
|
|
@ -261,31 +273,67 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
send({ type: 'CASH_DISPENSED' })
|
send({ type: 'CASH_DISPENSED' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentNested === 'complete' && prevNestedState !== 'complete' && prevSnapshot) {
|
// Record failed cash-out dispenses (sats debited but cash not dispensed)
|
||||||
|
if (currentNested === 'dispenseError' && prevNestedState !== 'dispenseError') {
|
||||||
const ctx = newSnapshot.context
|
const ctx = newSnapshot.context
|
||||||
if (ctx.txid) {
|
if (ctx.txid) {
|
||||||
const isCashInTx = typeof state === 'object' && 'cashIn' in state
|
const dr = ctx.dispenseResult
|
||||||
const bills: { denomination: number; count: number }[] = isCashInTx
|
|
||||||
? buildBillsFromInserted(ctx.billsInserted)
|
|
||||||
: ctx.dispenseAmounts
|
|
||||||
|
|
||||||
// Compute fee in sats from context
|
// Determine status from dispense result (if available)
|
||||||
const grossSats =
|
let status: 'dispense_error' | 'partial' = 'dispense_error'
|
||||||
ctx.exchangeRate > 0 ? Math.floor((ctx.fiatCents / 100) * ctx.exchangeRate) : 0
|
let bills: { denomination: number; count: number }[] = []
|
||||||
const feeSats = isCashInTx
|
|
||||||
? grossSats - ctx.satsAmount // cash-in: customer gets net (gross - fee)
|
if (dr) {
|
||||||
: ctx.satsAmount - grossSats // cash-out: customer pays gross + fee
|
const totalDispensed = dr.bills.reduce((s, b) => s + b.dispensed, 0)
|
||||||
|
if (totalDispensed > 0) status = 'partial'
|
||||||
|
bills = dr.bills
|
||||||
|
.filter((b) => b.dispensed > 0)
|
||||||
|
.map((b) => ({ denomination: b.denomination, count: b.dispensed }))
|
||||||
|
}
|
||||||
|
|
||||||
persistTransaction({
|
persistTransaction({
|
||||||
txid: ctx.txid,
|
txid: ctx.txid,
|
||||||
type: isCashInTx ? 'cash_in' : 'cash_out',
|
type: 'cash_out',
|
||||||
|
status,
|
||||||
fiatCents: ctx.fiatCents,
|
fiatCents: ctx.fiatCents,
|
||||||
sats: ctx.satsAmount,
|
sats: ctx.satsAmount,
|
||||||
feeSats: Math.max(0, feeSats),
|
feeSats: computeFeeSats(ctx, false),
|
||||||
feePercent: ctx.feePercent,
|
feePercent: ctx.feePercent,
|
||||||
exchangeRate: ctx.exchangeRate,
|
exchangeRate: ctx.exchangeRate,
|
||||||
currency: ctx.currency,
|
currency: ctx.currency,
|
||||||
bills,
|
bills,
|
||||||
|
cassettes: dr?.cassettes,
|
||||||
|
error: dr?.error ?? ctx.error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentNested === 'complete' && prevNestedState !== 'complete' && prevSnapshot) {
|
||||||
|
const ctx = newSnapshot.context
|
||||||
|
if (ctx.txid) {
|
||||||
|
const isCashInTx = typeof state === 'object' && 'cashIn' in state
|
||||||
|
const dr = ctx.dispenseResult
|
||||||
|
|
||||||
|
// For cash-out with dispense results, use actual dispensed counts
|
||||||
|
const bills: { denomination: number; count: number }[] = isCashInTx
|
||||||
|
? buildBillsFromInserted(ctx.billsInserted)
|
||||||
|
: dr
|
||||||
|
? dr.bills.map((b) => ({ denomination: b.denomination, count: b.dispensed }))
|
||||||
|
: ctx.dispenseAmounts
|
||||||
|
|
||||||
|
persistTransaction({
|
||||||
|
txid: ctx.txid,
|
||||||
|
type: isCashInTx ? 'cash_in' : 'cash_out',
|
||||||
|
status: 'complete',
|
||||||
|
fiatCents: ctx.fiatCents,
|
||||||
|
sats: ctx.satsAmount,
|
||||||
|
feeSats: computeFeeSats(ctx, isCashInTx),
|
||||||
|
feePercent: ctx.feePercent,
|
||||||
|
exchangeRate: ctx.exchangeRate,
|
||||||
|
currency: ctx.currency,
|
||||||
|
bills,
|
||||||
|
cassettes: dr?.cassettes,
|
||||||
|
error: dr?.error,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
apps/machine/src/types/electron.d.ts
vendored
10
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -38,6 +38,7 @@ declare global {
|
||||||
recordTransaction: (tx: {
|
recordTransaction: (tx: {
|
||||||
txid: string
|
txid: string
|
||||||
type: 'cash_in' | 'cash_out'
|
type: 'cash_in' | 'cash_out'
|
||||||
|
status: 'complete' | 'dispense_error' | 'partial'
|
||||||
fiatCents: number
|
fiatCents: number
|
||||||
sats: number
|
sats: number
|
||||||
feeSats: number
|
feeSats: number
|
||||||
|
|
@ -45,6 +46,15 @@ declare global {
|
||||||
exchangeRate: number
|
exchangeRate: number
|
||||||
currency: string
|
currency: string
|
||||||
bills: { denomination: number; count: number }[]
|
bills: { denomination: number; count: number }[]
|
||||||
|
cassettes?: {
|
||||||
|
name: string
|
||||||
|
position: number
|
||||||
|
denomination: number
|
||||||
|
provisioned: number
|
||||||
|
dispensed: number
|
||||||
|
rejected: number
|
||||||
|
}[]
|
||||||
|
error?: string | null
|
||||||
}) => Promise<void>
|
}) => Promise<void>
|
||||||
emptyCashbox: () => Promise<void>
|
emptyCashbox: () => Promise<void>
|
||||||
// HAL hardware IPC
|
// HAL hardware IPC
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export interface CashboxState {
|
||||||
export interface TransactionRecord {
|
export interface TransactionRecord {
|
||||||
txid: string
|
txid: string
|
||||||
type: 'cash_in' | 'cash_out'
|
type: 'cash_in' | 'cash_out'
|
||||||
|
status: 'complete' | 'dispense_error' | 'partial'
|
||||||
fiatCents: number
|
fiatCents: number
|
||||||
sats: number
|
sats: number
|
||||||
feeSats: number
|
feeSats: number
|
||||||
|
|
@ -23,6 +24,15 @@ export interface TransactionRecord {
|
||||||
exchangeRate: number
|
exchangeRate: number
|
||||||
currency: string
|
currency: string
|
||||||
bills: { denomination: number; count: number }[]
|
bills: { denomination: number; count: number }[]
|
||||||
|
cassettes?: {
|
||||||
|
name: string
|
||||||
|
position: number
|
||||||
|
denomination: number
|
||||||
|
provisioned: number
|
||||||
|
dispensed: number
|
||||||
|
rejected: number
|
||||||
|
}[]
|
||||||
|
error?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ATMAvailability {
|
export interface ATMAvailability {
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ export {
|
||||||
type PaymentStatus,
|
type PaymentStatus,
|
||||||
type PaymentMethod,
|
type PaymentMethod,
|
||||||
type DispenseCashResult,
|
type DispenseCashResult,
|
||||||
|
type CassetteBillResult,
|
||||||
initialContext,
|
initialContext,
|
||||||
} from './types.js'
|
} from './types.js'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,32 @@
|
||||||
/** Payment status */
|
/** Payment status */
|
||||||
export type PaymentStatus = 'pending' | 'paid' | 'failed' | null
|
export type PaymentStatus = 'pending' | 'paid' | 'failed' | null
|
||||||
|
|
||||||
|
/** Per-cassette dispense result (position-aware) */
|
||||||
|
export interface CassetteBillResult {
|
||||||
|
/** Cassette name, e.g. "cassette1", "cassette2" */
|
||||||
|
name: string
|
||||||
|
/** 0-indexed physical position */
|
||||||
|
position: number
|
||||||
|
/** Bill denomination in this cassette */
|
||||||
|
denomination: number
|
||||||
|
/** Bills requested from this cassette */
|
||||||
|
provisioned: number
|
||||||
|
/** Bills actually dispensed */
|
||||||
|
dispensed: number
|
||||||
|
/** Bills rejected/jammed */
|
||||||
|
rejected: number
|
||||||
|
}
|
||||||
|
|
||||||
/** Result of a dispense operation (always resolves, never throws) */
|
/** Result of a dispense operation (always resolves, never throws) */
|
||||||
export interface DispenseCashResult {
|
export interface DispenseCashResult {
|
||||||
/** Per-cassette results (what was actually dispensed) */
|
/** Per-denomination results (what was actually dispensed) */
|
||||||
bills: { denomination: number; dispensed: number; rejected: number }[]
|
bills: { denomination: number; dispensed: number; rejected: number }[]
|
||||||
/** Whether the full requested amount was dispensed */
|
/** Whether the full requested amount was dispensed */
|
||||||
dispensed: boolean
|
dispensed: boolean
|
||||||
/** Error message if dispense failed or was partial */
|
/** Error message if dispense failed or was partial */
|
||||||
error?: string
|
error?: string
|
||||||
|
/** Per-cassette detail (position-aware, produced by HAL) */
|
||||||
|
cassettes?: CassetteBillResult[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payment methods supported */
|
/** Payment methods supported */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue