feat(v2): add m005 satmachine_v2 schema for bitSpire + multi-tenant
Breaking redesign. Drops the v1 Lamassu-era tables (lamassu_config, lamassu_transactions, plus the singular-config dca_clients/deposits/payments) and creates the v2 schema: - dca_machines: per-operator multi-machine registry, keyed by Nostr npub. Replaces the single-row lamassu_config pattern. - dca_settlements: bitSpire kind-21000 idempotency. platform_fee_sats and operator_fee_sats stored as absolute BIGINT — v1 hook so the v2 customer- discount engine can record who-forgave-what without a migration. - dca_commission_splits: operator-defined remainder rules (per-machine or default; sum-to-1.0 invariant enforced at write). - dca_payments: leg-typed (dca | super_fee | operator_split | settlement | autoforward | refund). Drops the old transaction_type field. - dca_clients: now scoped per (machine_id, user_id) so an LP can hold positions across machines/operators on the same instance. - dca_deposits: gains machine_id + creator_user_id for audit. - dca_telemetry: sparse kind-30078 / kind-30079 snapshots; post-#43 fields nullable until lamassu-next enriches the beacon. - super_config: singleton row for super_fee_pct + super_fee_wallet_id. No backwards compatibility — operators on the previous schema must wipe and re-onboard. Old migrations m001-m004 remain so fresh installs still walk the versioned path; m005 drops their tables before creating the v2 schema. Incidental: stripped trailing whitespace in m004 (W291/W293 hygiene). Refs: plan at ~/.claude/plans/snug-gliding-shamir.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
28241e70c3
commit
ae4e241d1c
1 changed files with 258 additions and 5 deletions
253
migrations.py
253
migrations.py
|
|
@ -170,3 +170,256 @@ async def m004_convert_to_gtq_storage(db):
|
||||||
await db.execute("UPDATE satoshimachine.lamassu_transactions SET fiat_amount = CAST(fiat_amount 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.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")
|
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
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue