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
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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'
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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 }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue