diff --git a/migrations.py b/migrations.py index c8147f5..1c06203 100644 --- a/migrations.py +++ b/migrations.py @@ -145,28 +145,281 @@ async def m004_convert_to_gtq_storage(db): # Detect database type db_type = str(type(db)).lower() is_postgres = 'postgres' in db_type or 'asyncpg' in db_type - + if is_postgres: # PostgreSQL: Need to change column types first, then convert data - + # Change column types to DECIMAL(10,2) await db.execute("ALTER TABLE satoshimachine.dca_deposits ALTER COLUMN amount TYPE DECIMAL(10,2)") await db.execute("ALTER TABLE satoshimachine.dca_payments ALTER COLUMN amount_fiat TYPE DECIMAL(10,2)") await db.execute("ALTER TABLE satoshimachine.lamassu_transactions ALTER COLUMN fiat_amount TYPE DECIMAL(10,2)") await db.execute("ALTER TABLE satoshimachine.dca_clients ALTER COLUMN fixed_mode_daily_limit TYPE DECIMAL(10,2)") await db.execute("ALTER TABLE satoshimachine.lamassu_config ALTER COLUMN max_daily_limit_gtq TYPE DECIMAL(10,2)") - + # Convert data from centavos to GTQ await db.execute("UPDATE satoshimachine.dca_deposits SET amount = amount / 100.0 WHERE currency = 'GTQ'") await db.execute("UPDATE satoshimachine.dca_payments SET amount_fiat = amount_fiat / 100.0") await db.execute("UPDATE satoshimachine.lamassu_transactions SET fiat_amount = fiat_amount / 100.0") await db.execute("UPDATE satoshimachine.dca_clients SET fixed_mode_daily_limit = fixed_mode_daily_limit / 100.0 WHERE fixed_mode_daily_limit IS NOT NULL") await db.execute("UPDATE satoshimachine.lamassu_config SET max_daily_limit_gtq = max_daily_limit_gtq / 100.0 WHERE max_daily_limit_gtq > 1000") - + else: # SQLite: Data conversion only (dynamic typing handles the rest) await db.execute("UPDATE satoshimachine.dca_deposits SET amount = CAST(amount AS REAL) / 100.0 WHERE currency = 'GTQ'") await db.execute("UPDATE satoshimachine.dca_payments SET amount_fiat = CAST(amount_fiat AS REAL) / 100.0") await db.execute("UPDATE satoshimachine.lamassu_transactions SET fiat_amount = CAST(fiat_amount AS REAL) / 100.0") await db.execute("UPDATE satoshimachine.dca_clients SET fixed_mode_daily_limit = CAST(fixed_mode_daily_limit AS REAL) / 100.0 WHERE fixed_mode_daily_limit IS NOT NULL") - await db.execute("UPDATE satoshimachine.lamassu_config SET max_daily_limit_gtq = CAST(max_daily_limit_gtq AS REAL) / 100.0 WHERE max_daily_limit_gtq > 1000") \ No newline at end of file + await db.execute("UPDATE satoshimachine.lamassu_config SET max_daily_limit_gtq = CAST(max_daily_limit_gtq AS REAL) / 100.0 WHERE max_daily_limit_gtq > 1000") + + +async def m005_satmachine_v2_overhaul(db): + """ + BREAKING REDESIGN — Satoshi Machine v2 (bitSpire integration + multi-tenant). + + Drops the v1 Lamassu-era tables (SSH/PostgreSQL polling, single-config, super-only) + and creates the v2 schema for: + - Per-operator multi-machine support (1 LNbits user = 1 operator, N machines). + - bitSpire (Nostr kind-21000) settlement subscription instead of SQL polling. + - Two-stage commission split (platform fee first, operator-defined remainder). + - Absolute platform_fee_sats / operator_fee_sats storage on settlements (v1 hook + for v2 customer-discount engine — see plan section "Customer discounts"). + + Operators on the previous schema must wipe & re-onboard. No backwards-compat. + """ + # Drop v1 tables. IF EXISTS is safe both on upgrade and fresh-install paths. + for table in ( + "lamassu_transactions", + "lamassu_config", + "dca_payments", + "dca_deposits", + "dca_clients", + ): + await db.execute(f"DROP TABLE IF EXISTS satoshimachine.{table}") + + # super_config — singleton (id='default') holding super's platform-fee config. + # The only thing the LNbits super has direct DB control over in this extension. + await db.execute( + f""" + CREATE TABLE satoshimachine.super_config ( + id TEXT PRIMARY KEY, + super_fee_pct DECIMAL(10,4) NOT NULL DEFAULT 0.0000, + super_fee_wallet_id TEXT, + updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + await db.execute( + "INSERT INTO satoshimachine.super_config (id, super_fee_pct) " + "VALUES ('default', 0.0000)" + ) + + # dca_machines — one row per bitSpire ATM, owned by exactly one operator. + # fallback_commission_pct kicks in only when bitSpire's settlement Payment.extra + # is missing the (net_sats, fee_sats) split — see plan's lamassu-next ask #1. + await db.execute( + f""" + CREATE TABLE satoshimachine.dca_machines ( + id TEXT PRIMARY KEY, + operator_user_id TEXT NOT NULL, + machine_npub TEXT NOT NULL UNIQUE, + wallet_id TEXT NOT NULL, + name TEXT, + location TEXT, + fiat_code TEXT NOT NULL DEFAULT 'GTQ', + is_active BOOLEAN NOT NULL DEFAULT true, + fallback_commission_pct DECIMAL(10,4) NOT NULL DEFAULT 0.0500, + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + await db.execute( + "CREATE INDEX dca_machines_operator_idx " + "ON satoshimachine.dca_machines (operator_user_id)" + ) + + # dca_clients — LP registrations scoped per (machine, user). One LP can hold + # positions across many machines (and many operators) on the same instance. + await db.execute( + f""" + CREATE TABLE satoshimachine.dca_clients ( + id TEXT PRIMARY KEY, + machine_id TEXT NOT NULL, + user_id TEXT NOT NULL, + wallet_id TEXT NOT NULL, + username TEXT, + dca_mode TEXT NOT NULL DEFAULT 'flow', + fixed_mode_daily_limit DECIMAL(10,2), + autoforward_ln_address TEXT, + autoforward_enabled BOOLEAN NOT NULL DEFAULT false, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + await db.execute( + "CREATE UNIQUE INDEX dca_clients_machine_user_uq " + "ON satoshimachine.dca_clients (machine_id, user_id)" + ) + await db.execute( + "CREATE INDEX dca_clients_user_idx " + "ON satoshimachine.dca_clients (user_id)" + ) + + # dca_deposits — fiat the operator (or super) records against an LP at a machine. + # creator_user_id preserves audit trail (resolves a v1 tech-debt finding). + await db.execute( + f""" + CREATE TABLE satoshimachine.dca_deposits ( + id TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + machine_id TEXT NOT NULL, + creator_user_id TEXT NOT NULL, + amount DECIMAL(10,2) NOT NULL, + currency TEXT NOT NULL DEFAULT 'GTQ', + status TEXT NOT NULL DEFAULT 'pending', + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + confirmed_at TIMESTAMP + ); + """ + ) + await db.execute( + "CREATE INDEX dca_deposits_client_idx " + "ON satoshimachine.dca_deposits (client_id, created_at DESC)" + ) + + # dca_settlements — idempotency table for bitSpire kind-21000 events. + # CRITICAL: platform_fee_sats and operator_fee_sats are stored as absolute BIGINT + # (not as a derived percentage). Today this is just the contractual split. Once + # the v2 promotion engine ships, the two values diverge when discounts fire and + # this row is the only audit-grade record of who forgave what. Do not collapse + # them into a single commission_pct field. See plan section "Customer discounts". + await db.execute( + f""" + CREATE TABLE satoshimachine.dca_settlements ( + id TEXT PRIMARY KEY, + machine_id TEXT NOT NULL, + bitspire_event_id TEXT NOT NULL UNIQUE, + bitspire_txid TEXT, + payment_hash TEXT NOT NULL, + gross_sats BIGINT NOT NULL, + fiat_amount DECIMAL(10,2) NOT NULL, + fiat_code TEXT NOT NULL DEFAULT 'GTQ', + exchange_rate REAL NOT NULL, + net_sats BIGINT NOT NULL, + commission_sats BIGINT NOT NULL, + platform_fee_sats BIGINT NOT NULL, + operator_fee_sats BIGINT NOT NULL, + used_fallback_split BOOLEAN NOT NULL DEFAULT false, + tx_type TEXT NOT NULL, + bills_json TEXT, + cassettes_json TEXT, + status TEXT NOT NULL DEFAULT 'pending', + error_message TEXT, + processed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + await db.execute( + "CREATE INDEX dca_settlements_machine_idx " + "ON satoshimachine.dca_settlements (machine_id, created_at DESC)" + ) + await db.execute( + "CREATE INDEX dca_settlements_payment_hash_idx " + "ON satoshimachine.dca_settlements (payment_hash)" + ) + + # dca_commission_splits — operator's rules for distributing the *remainder* + # of each commission (commission_sats - platform_fee_sats). One row per leg. + # machine_id=NULL means "operator's default rules"; non-null means per-machine + # override. Sum of pct across rows for a given (machine_id, operator_user_id) + # scope must equal 1.0 — enforced at write-time in crud.py. + await db.execute( + f""" + CREATE TABLE satoshimachine.dca_commission_splits ( + id TEXT PRIMARY KEY, + machine_id TEXT, + operator_user_id TEXT NOT NULL, + wallet_id TEXT NOT NULL, + label TEXT, + pct DECIMAL(10,4) NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + await db.execute( + "CREATE INDEX dca_commission_splits_lookup_idx " + "ON satoshimachine.dca_commission_splits (operator_user_id, machine_id)" + ) + + # dca_payments — every leg of every distribution. The leg_type discriminator + # tells the audit story: dca | super_fee | operator_split | settlement (= the + # "settle small remainder at current rate" feature, see satmachineadmin#4) | + # autoforward (see satmachineadmin#8) | refund. + await db.execute( + f""" + CREATE TABLE satoshimachine.dca_payments ( + id TEXT PRIMARY KEY, + settlement_id TEXT, + client_id TEXT, + machine_id TEXT NOT NULL, + operator_user_id TEXT NOT NULL, + leg_type TEXT NOT NULL, + destination_wallet_id TEXT, + destination_ln_address TEXT, + amount_sats BIGINT NOT NULL, + amount_fiat DECIMAL(10,2), + exchange_rate REAL, + transaction_time TIMESTAMP NOT NULL, + external_payment_hash TEXT, + status TEXT NOT NULL DEFAULT 'pending', + error_message TEXT, + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + await db.execute( + "CREATE INDEX dca_payments_client_idx " + "ON satoshimachine.dca_payments (client_id, created_at DESC)" + ) + await db.execute( + "CREATE INDEX dca_payments_settlement_idx " + "ON satoshimachine.dca_payments (settlement_id)" + ) + await db.execute( + "CREATE INDEX dca_payments_operator_idx " + "ON satoshimachine.dca_payments (operator_user_id, leg_type)" + ) + + # dca_telemetry — latest replaceable kind-30078 (public availability beacon) + # and kind-30079 (operator-only fleet telemetry) snapshots per machine. The + # beacon today (lamassu-next/dev @ 2b712af) ships only cash_in/cash_out/ + # cash_level/fiat/model — the post-#43 fields (name, location, geo, fees, + # limits, denominations, version) are nullable until that upstream issue + # lands. Ingest opportunistically; render absent fields gracefully in the UI. + await db.execute( + """ + CREATE TABLE satoshimachine.dca_telemetry ( + machine_id TEXT PRIMARY KEY, + beacon_cash_in BOOLEAN, + beacon_cash_out BOOLEAN, + beacon_cash_level TEXT, + beacon_fiat TEXT, + beacon_model TEXT, + beacon_name TEXT, + beacon_location TEXT, + beacon_geo TEXT, + beacon_fees_json TEXT, + beacon_limits_json TEXT, + beacon_denominations_json TEXT, + beacon_version TEXT, + beacon_received_at TIMESTAMP, + telemetry_json TEXT, + telemetry_received_at TIMESTAMP + ); + """ + )