feat(machine): persist exchange rate and currency in transactions

Add exchange_rate (sats per fiat unit) and currency columns to the
transactions table so transaction economics can be audited after the
fact. Includes schema migration v2 (fee columns) and v3 (rate/currency),
updated IPC types, and atm-transactions CLI output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-03-10 01:30:18 -04:00
commit 310d0edb99
5 changed files with 65 additions and 4 deletions

View file

@ -15,7 +15,7 @@ import fs from 'node:fs'
let db: Database.Database | null = null let db: Database.Database | null = null
const SCHEMA_VERSION = '1' const SCHEMA_VERSION = '3'
function getDbPath(): string { function getDbPath(): string {
const prodDir = '/var/lib/lamassu-atm' const prodDir = '/var/lib/lamassu-atm'
@ -68,6 +68,10 @@ export function initDatabase(dbPath?: string): void {
type TEXT NOT NULL CHECK (type IN ('cash_in', 'cash_out')), type TEXT NOT NULL CHECK (type IN ('cash_in', 'cash_out')),
fiat_cents INTEGER NOT NULL, fiat_cents INTEGER NOT NULL,
sats INTEGER NOT NULL, sats INTEGER NOT NULL,
fee_sats INTEGER NOT NULL DEFAULT 0,
fee_percent REAL NOT NULL DEFAULT 0,
exchange_rate REAL NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'GTQ',
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
); );
@ -79,12 +83,31 @@ export function initDatabase(dbPath?: string): void {
); );
`) `)
// Seed meta + cashbox if first run // Seed meta + cashbox if first run, or run migrations
const existing = db.prepare('SELECT value FROM meta WHERE key = ?').get('schema_version') as const existing = db.prepare('SELECT value FROM meta WHERE key = ?').get('schema_version') as
| { value: string } | { value: string }
| undefined | undefined
if (!existing) { if (!existing) {
db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run('schema_version', SCHEMA_VERSION) db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)').run('schema_version', SCHEMA_VERSION)
} else if (existing.value === '1') {
// Migration v1 → v2: add fee columns to transactions
db.exec(`
ALTER TABLE transactions ADD COLUMN fee_sats INTEGER NOT NULL DEFAULT 0;
ALTER TABLE transactions ADD COLUMN fee_percent REAL NOT NULL DEFAULT 0;
`)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('2', 'schema_version')
console.log('[StateStore] Migrated schema v1 → v2 (added fee columns)')
existing.value = '2'
}
if (existing && existing.value === '2') {
// Migration v2 → v3: add exchange rate and currency
db.exec(`
ALTER TABLE transactions ADD COLUMN exchange_rate REAL NOT NULL DEFAULT 0;
ALTER TABLE transactions ADD COLUMN currency TEXT NOT NULL DEFAULT 'GTQ';
`)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('3', 'schema_version')
console.log('[StateStore] Migrated schema v2 → v3 (added exchange_rate, currency)')
} }
const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get() const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get()
@ -214,6 +237,10 @@ interface TransactionInput {
type: 'cash_in' | 'cash_out' type: 'cash_in' | 'cash_out'
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 }[]
} }
@ -224,7 +251,7 @@ 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, created_at) VALUES (?, ?, ?, ?, ?)' 'INSERT INTO transactions (txid, type, 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 (?, ?, ?)'
@ -237,7 +264,17 @@ export function recordTransaction(tx: TransactionInput): void {
) )
const run = db.transaction((t: TransactionInput) => { const run = db.transaction((t: TransactionInput) => {
insertTx.run(t.txid, t.type, t.fiatCents, t.sats, Date.now()) insertTx.run(
t.txid,
t.type,
t.fiatCents,
t.sats,
t.feeSats,
t.feePercent,
t.exchangeRate,
t.currency,
Date.now()
)
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)

View file

@ -269,11 +269,22 @@ export const useAtmStore = defineStore('atm', () => {
? buildBillsFromInserted(ctx.billsInserted) ? buildBillsFromInserted(ctx.billsInserted)
: ctx.dispenseAmounts : ctx.dispenseAmounts
// Compute fee in sats from context
const grossSats =
ctx.exchangeRate > 0 ? Math.floor((ctx.fiatCents / 100) * ctx.exchangeRate) : 0
const feeSats = isCashInTx
? grossSats - ctx.satsAmount // cash-in: customer gets net (gross - fee)
: ctx.satsAmount - grossSats // cash-out: customer pays gross + fee
persistTransaction({ persistTransaction({
txid: ctx.txid, txid: ctx.txid,
type: isCashInTx ? 'cash_in' : 'cash_out', type: isCashInTx ? 'cash_in' : 'cash_out',
fiatCents: ctx.fiatCents, fiatCents: ctx.fiatCents,
sats: ctx.satsAmount, sats: ctx.satsAmount,
feeSats: Math.max(0, feeSats),
feePercent: ctx.feePercent,
exchangeRate: ctx.exchangeRate,
currency: ctx.currency,
bills, bills,
}) })
} }

View file

@ -40,6 +40,10 @@ declare global {
type: 'cash_in' | 'cash_out' type: 'cash_in' | 'cash_out'
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 }[]
}) => Promise<void> }) => Promise<void>
emptyCashbox: () => Promise<void> emptyCashbox: () => Promise<void>

View file

@ -18,6 +18,10 @@ export interface TransactionRecord {
type: 'cash_in' | 'cash_out' type: 'cash_in' | 'cash_out'
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 }[]
} }

View file

@ -81,6 +81,7 @@ if $SUMMARY; then
COUNT(*) AS 'Count', COUNT(*) AS 'Count',
SUM(fiat_cents) / 100 AS 'Total Fiat', SUM(fiat_cents) / 100 AS 'Total Fiat',
SUM(sats) AS 'Total Sats', SUM(sats) AS 'Total Sats',
SUM(fee_sats) AS 'Total Fee Sats',
MIN(fiat_cents) / 100 AS 'Min Fiat', MIN(fiat_cents) / 100 AS 'Min Fiat',
MAX(fiat_cents) / 100 AS 'Max Fiat' MAX(fiat_cents) / 100 AS 'Max Fiat'
FROM transactions FROM transactions
@ -129,8 +130,12 @@ fi
sql "SELECT sql "SELECT
t.txid AS 'TX ID', t.txid AS 'TX ID',
t.type AS 'Type', t.type AS 'Type',
t.currency AS 'Cur',
t.fiat_cents / 100 AS 'Fiat', t.fiat_cents / 100 AS 'Fiat',
t.sats AS 'Sats', t.sats AS 'Sats',
t.fee_sats AS 'Fee Sats',
printf('%.1f%%', t.fee_percent * 100) AS 'Fee %',
CASE WHEN t.exchange_rate > 0 THEN printf('%.0f', t.exchange_rate) ELSE '-' END AS 'Rate',
datetime(t.created_at / 1000, 'unixepoch') AS 'Time (UTC)', datetime(t.created_at / 1000, 'unixepoch') AS 'Time (UTC)',
group_concat('Q' || tb.denomination || 'x' || tb.count) AS 'Bills' group_concat('Q' || tb.denomination || 'x' || tb.count) AS 'Bills'
FROM transactions t FROM transactions t