From 310d0edb99a2283a0d219d3b994add0bc238c1f1 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Tue, 10 Mar 2026 01:30:18 -0400 Subject: [PATCH] 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 --- apps/machine/electron/state-store.ts | 45 +++++++++++++++++++++++++--- apps/machine/src/stores/atm.ts | 11 +++++++ apps/machine/src/types/electron.d.ts | 4 +++ apps/machine/src/types/state.ts | 4 +++ deploy/nixos/atm-transactions.sh | 5 ++++ 5 files changed, 65 insertions(+), 4 deletions(-) diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index fc0ef98..6606a6b 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -15,7 +15,7 @@ import fs from 'node:fs' let db: Database.Database | null = null -const SCHEMA_VERSION = '1' +const SCHEMA_VERSION = '3' function getDbPath(): string { 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')), fiat_cents 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 ); @@ -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 | { value: string } | undefined if (!existing) { 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() @@ -214,6 +237,10 @@ interface TransactionInput { type: 'cash_in' | 'cash_out' fiatCents: number sats: number + feeSats: number + feePercent: number + exchangeRate: number + currency: string bills: { denomination: number; count: number }[] } @@ -224,7 +251,7 @@ export function recordTransaction(tx: TransactionInput): void { if (!db) throw new Error('Database not initialized') 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( 'INSERT INTO transaction_bills (txid, denomination, count) VALUES (?, ?, ?)' @@ -237,7 +264,17 @@ export function recordTransaction(tx: TransactionInput): void { ) 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) { insertBill.run(t.txid, bill.denomination, bill.count) diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index df29ae8..f6b1793 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -269,11 +269,22 @@ export const useAtmStore = defineStore('atm', () => { ? buildBillsFromInserted(ctx.billsInserted) : 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({ txid: ctx.txid, type: isCashInTx ? 'cash_in' : 'cash_out', fiatCents: ctx.fiatCents, sats: ctx.satsAmount, + feeSats: Math.max(0, feeSats), + feePercent: ctx.feePercent, + exchangeRate: ctx.exchangeRate, + currency: ctx.currency, bills, }) } diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index d9e077c..d2deef3 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -40,6 +40,10 @@ declare global { type: 'cash_in' | 'cash_out' fiatCents: number sats: number + feeSats: number + feePercent: number + exchangeRate: number + currency: string bills: { denomination: number; count: number }[] }) => Promise emptyCashbox: () => Promise diff --git a/apps/machine/src/types/state.ts b/apps/machine/src/types/state.ts index 052bb5f..ef869dc 100644 --- a/apps/machine/src/types/state.ts +++ b/apps/machine/src/types/state.ts @@ -18,6 +18,10 @@ export interface TransactionRecord { type: 'cash_in' | 'cash_out' fiatCents: number sats: number + feeSats: number + feePercent: number + exchangeRate: number + currency: string bills: { denomination: number; count: number }[] } diff --git a/deploy/nixos/atm-transactions.sh b/deploy/nixos/atm-transactions.sh index a81c651..045baec 100644 --- a/deploy/nixos/atm-transactions.sh +++ b/deploy/nixos/atm-transactions.sh @@ -81,6 +81,7 @@ if $SUMMARY; then COUNT(*) AS 'Count', SUM(fiat_cents) / 100 AS 'Total Fiat', SUM(sats) AS 'Total Sats', + SUM(fee_sats) AS 'Total Fee Sats', MIN(fiat_cents) / 100 AS 'Min Fiat', MAX(fiat_cents) / 100 AS 'Max Fiat' FROM transactions @@ -129,8 +130,12 @@ fi sql "SELECT t.txid AS 'TX ID', t.type AS 'Type', + t.currency AS 'Cur', t.fiat_cents / 100 AS 'Fiat', 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)', group_concat('Q' || tb.denomination || 'x' || tb.count) AS 'Bills' FROM transactions t