diff --git a/deploy/nixos/atm-reconcile.sh b/deploy/nixos/atm-reconcile.sh new file mode 100755 index 0000000..f25c10b --- /dev/null +++ b/deploy/nixos/atm-reconcile.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# atm-reconcile — Check each cassette's ledger count against recorded history +# +# The cassettes table is a running total maintained by the machine: operator +# ops add to it (refill) or set it (recount / empty), and dispenses subtract +# from it. That means the count can be re-derived, and a derived value that +# disagrees with the stored one is evidence of something the ledger never saw. +# +# Reconciliation runs forward from each bay's last ABSOLUTE truth point — a +# `recount` (someone opened the bay and counted it) or an `empty` (set to +# zero). Refills are deltas and cannot serve as a baseline: a refill applied +# on top of a wrong number just carries the error forward, which is exactly +# how a bad count survives for weeks. +# +# expected = base + refills_since_base - dispensed_since_base +# gap = ledger - expected +# +# A non-zero gap means either notes moved without an op recording it, or a +# dispense moved notes the dispenser's counters did not report. The second is +# real: a note that leaves the bay and jams in the transport completes neither +# the `dispensed` nor the `rejected` counter, so the bay silently reads one +# high while the transaction row says nothing was dispensed (aiolabs/bitspire#122). +# +# A bay with NO baseline cannot be reconciled at all — its starting number +# came from somewhere unrecorded. Publish a recount for it; that is the only +# op that establishes ground truth (and the only one that clears the +# counts-uncertain flag). +# +# Usage: +# atm-reconcile # reconcile every bay +# atm-reconcile --csv # machine-readable +# atm-reconcile --quiet # exit status only, no output +# +# Exit status: +# 0 every bay reconciles +# 1 at least one bay has a non-zero gap or no baseline +# 2 database missing / unreadable + +set -euo pipefail + +DB="${ATM_STATE_DB:-/var/lib/bitspire/state.db}" + +FORMAT="-column -header" +QUIET=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --csv) FORMAT="-csv -header"; shift ;; + --quiet) QUIET=true; shift ;; + -h|--help) + sed -n '2,36p' "$0" | sed 's/^# \{0,1\}//' + exit 0 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +if [ ! -r "$DB" ]; then + echo "ERROR: state database not readable at $DB" >&2 + exit 2 +fi + +# Per-bay baseline, then the deltas since it. Written as scalar subqueries +# rather than joins on purpose: joining transaction_bills to cassettes fans +# out across bays, and a LEFT JOIN whose rows are all filtered out by the +# baseline cutoff collapses to NULL and poisons the arithmetic downstream. +RECONCILE_SQL=" +WITH bay AS ( + SELECT + c.position AS position, + c.denomination AS denomination, + c.count AS ledger, + (SELECT o.op_at FROM cassette_ops o + WHERE o.position = c.position AND o.op_type IN ('recount','empty') + ORDER BY o.op_at DESC, o.id DESC LIMIT 1) AS base_at, + (SELECT CASE o.op_type WHEN 'empty' THEN 0 ELSE o.count END FROM cassette_ops o + WHERE o.position = c.position AND o.op_type IN ('recount','empty') + ORDER BY o.op_at DESC, o.id DESC LIMIT 1) AS base_count + FROM cassettes c +), +delta AS ( + SELECT + bay.*, + (SELECT COALESCE(SUM(o.bills), 0) FROM cassette_ops o + WHERE o.position = bay.position AND o.op_type = 'refill' + AND o.op_at > COALESCE(bay.base_at, -1)) AS added, + (SELECT COALESCE(SUM(tb.count), 0) + FROM transaction_bills tb + JOIN transactions t ON t.txid = tb.txid + WHERE tb.denomination = bay.denomination + AND t.type IN ('cash_out','manual_dispense') + AND t.created_at / 1000 > COALESCE(bay.base_at, -1)) AS dispensed + FROM bay +) +SELECT + position AS 'Bay', + denomination AS 'Denom', + CASE WHEN base_at IS NULL THEN '(none)' ELSE datetime(base_at,'unixepoch') END AS 'Baseline', + CASE WHEN base_at IS NULL THEN NULL ELSE base_count END AS 'Base', + added AS 'Refilled', + dispensed AS 'Dispensed', + ledger AS 'Ledger', + CASE WHEN base_at IS NULL THEN NULL + ELSE base_count + added - dispensed END AS 'Expected', + CASE WHEN base_at IS NULL THEN 'NO BASELINE' + ELSE printf('%+d', ledger - (base_count + added - dispensed)) END AS 'Gap' +FROM delta +ORDER BY position; +" + +# Bays sharing a denomination break per-bay attribution: transaction_bills +# records what denomination went out, never which bay it came from, so the +# dispensed figure lands on every matching bay. +AMBIGUOUS=$(sqlite3 "$DB" \ + "SELECT group_concat(denomination) FROM ( + SELECT denomination FROM cassettes GROUP BY denomination HAVING COUNT(*) > 1);") + +PROBLEMS=$(sqlite3 "$DB" " +WITH bay AS ( + SELECT c.position AS position, c.denomination AS denomination, c.count AS ledger, + (SELECT o.op_at FROM cassette_ops o + WHERE o.position = c.position AND o.op_type IN ('recount','empty') + ORDER BY o.op_at DESC, o.id DESC LIMIT 1) AS base_at, + (SELECT CASE o.op_type WHEN 'empty' THEN 0 ELSE o.count END FROM cassette_ops o + WHERE o.position = c.position AND o.op_type IN ('recount','empty') + ORDER BY o.op_at DESC, o.id DESC LIMIT 1) AS base_count + FROM cassettes c +) +SELECT COUNT(*) FROM bay WHERE base_at IS NULL OR ledger <> ( + base_count + + (SELECT COALESCE(SUM(o.bills),0) FROM cassette_ops o + WHERE o.position = bay.position AND o.op_type = 'refill' AND o.op_at > bay.base_at) + - (SELECT COALESCE(SUM(tb.count),0) FROM transaction_bills tb + JOIN transactions t ON t.txid = tb.txid + WHERE tb.denomination = bay.denomination + AND t.type IN ('cash_out','manual_dispense') + AND t.created_at / 1000 > bay.base_at));") + +if ! $QUIET; then + # shellcheck disable=SC2086 # $FORMAT must word-split into two sqlite3 flags + sqlite3 $FORMAT "$DB" "$RECONCILE_SQL" + echo + + UNCERTAIN=$(sqlite3 "$DB" \ + "SELECT COALESCE(NULLIF(value,''),'') FROM meta WHERE key = 'countsUncertainSince';") + if [ -n "$UNCERTAIN" ]; then + echo "Counts flagged UNVERIFIED since $(date -u -d "@$UNCERTAIN" '+%F %T UTC' 2>/dev/null || echo "$UNCERTAIN")" + echo " A dispense ended without a trustworthy report. Only a recount clears this." + else + echo "Counts not flagged unverified." + fi + + echo + echo "=== Unresolved dispense failures ===" + # shellcheck disable=SC2086 # $FORMAT must word-split into two sqlite3 flags + sqlite3 $FORMAT "$DB" " + SELECT txid AS 'TX ID', status AS 'Status', + printf('%.2f', fiat_cents / 100.0) AS 'Fiat', currency AS 'Cur', + COALESCE(error,'') AS 'Error', + datetime(created_at / 1000,'unixepoch') AS 'Time (UTC)' + FROM transactions + WHERE status IN ('dispense_error','partial') + ORDER BY created_at DESC;" + + if [ -n "$AMBIGUOUS" ]; then + echo + echo "WARNING: denomination(s) $AMBIGUOUS are loaded in more than one bay." + echo " Dispenses are recorded by denomination, not by bay, so the Dispensed" + echo " column double-counts across those bays. Reconcile them as a group." + fi +fi + +[ "${PROBLEMS:-0}" -eq 0 ] || exit 1 diff --git a/deploy/nixos/configuration.nix b/deploy/nixos/configuration.nix index eeabe5a..458a239 100644 --- a/deploy/nixos/configuration.nix +++ b/deploy/nixos/configuration.nix @@ -376,6 +376,7 @@ in # ATM operations sqlite (writeShellScriptBin "atm-transactions" (builtins.readFile ./atm-transactions.sh)) + (writeShellScriptBin "atm-reconcile" (builtins.readFile ./atm-reconcile.sh)) ]; # Enable SSH for remote administration