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:
parent
d64f6cd9ab
commit
310d0edb99
5 changed files with 65 additions and 4 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
4
apps/machine/src/types/electron.d.ts
vendored
4
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -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<void>
|
||||
emptyCashbox: () => Promise<void>
|
||||
|
|
|
|||
|
|
@ -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 }[]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue