From 13684e7134a7e7956f3c990acdb55a6e6965145d Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 30 May 2026 18:00:11 +0200 Subject: [PATCH] feat(v2): m007 cassette_configs schema + Pydantic models (#29 v1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the operator-side schema for per-machine ATM cassette inventory (aiolabs/satmachineadmin#29). Schema choice mirrors the ATM-side denomination-as-key invariant audited at coord-log 2026-05-30T06:40Z across bitspire/atm-tui/src/db.zig:31, lamassu-next state-store.ts:54, and hal-service.ts:116/189 — every ATM layer keys on denomination, so the operator-side PK is (machine_id, denomination) to make duplicate-denomination payloads impossible at the schema boundary. Reserved nullable columns (state_count, state_at, state_event_id) hold the latest bitspire-cassettes-state: event the ATM publishes. v1 populates them on bootstrap-event receipt; v1 UI doesn't render reconciliation. v2 reconciliation UI consumes them without a migration. Pydantic models in this commit: - CassetteConfig — read model for a stored row - UpsertCassetteConfigData — operator-edit form (count and/or position) - CassettePayloadRow — one denomination's wire-format values - PublishCassettesPayload — the full kind-30078 content payload, bidirectional (operator → ATM and ATM → operator share the shape). Validates int-coerced denomination keys, positive ints, no duplicate positions, and exposes to_wire_dict() that re-stringifies keys for JSON compatibility. CRUD + transport + API + UI land in subsequent commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- migrations.py | 37 +++++++++++++++ models.py | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) diff --git a/migrations.py b/migrations.py index 38b29d0..807292f 100644 --- a/migrations.py +++ b/migrations.py @@ -538,3 +538,40 @@ async def m005_lock_deposit_currency_to_machine_fiat_code(db): AND m.fiat_code != d.currency ) """) + + +async def m007_add_cassette_configs(db): + """Add cassette_configs table for operator-driven ATM cassette inventory. + + Tracks per-machine cassette state (denomination, count, position) editable + via the satmachineadmin dashboard and published to the ATM as encrypted + kind-30078 events. See aiolabs/satmachineadmin#29 + lamassu-next#56. + + Schema choice: PK (machine_id, denomination) mirrors the ATM-side + denomination-as-key invariant in + bitspire/atm-tui/src/db.zig:31 and + lamassu-next/apps/machine/electron/state-store.ts:54 + (the cassettes table PK is denomination; HAL inventory map keys on + denomination; dispense lookup is cassetteDenominations.indexOf — + duplicates collapse silently). Position is operator-assignable display + order, not the addressable unit. + + Reserved nullable columns (state_count, state_at, state_event_id) hold + the latest bitspire-cassettes-state: event the ATM + publishes (one-shot bootstrap in v1; continuous in v2). v1 UI doesn't + render them; v2 reconciliation UI consumes them without a migration. + """ + await db.execute(f""" + CREATE TABLE IF NOT EXISTS satoshimachine.cassette_configs ( + machine_id TEXT NOT NULL, + denomination INTEGER NOT NULL, + count INTEGER NOT NULL, + position INTEGER NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + updated_by TEXT, + state_count INTEGER, + state_at TIMESTAMP, + state_event_id TEXT, + PRIMARY KEY (machine_id, denomination) + ); + """) diff --git a/models.py b/models.py index d683cac..5f88067 100644 --- a/models.py +++ b/models.py @@ -546,3 +546,127 @@ class SettleBalanceData(BaseModel): if v <= 0: raise ValueError("amount_fiat must be > 0 if specified") return round(float(v), 2) + + +# ============================================================================= +# Cassette configs — operator-driven ATM cassette inventory (#29). +# ============================================================================= +# Schema is denomination-keyed per the locked design (#29 body + the +# 06:40Z coord-log audit): every ATM-side layer below the wire keys on +# denomination (state-store.ts:54, hal-service.ts:116/189). The +# satmachineadmin schema mirrors this so the operator UI can't author a +# duplicate-denomination payload that the ATM would silently collapse. +# +# Position is operator-assignable display order (and used by the ATM as +# the HAL slot-index assignment), not the addressable unit. +# +# state_count / state_at / state_event_id are reserved nullable from day 1 +# for the v2 reverse-channel reconciliation consumer (bitspire-cassettes- +# state:). v1 populates them on bootstrap-event receipt +# but the UI doesn't render reconciliation. + + +class CassetteConfig(BaseModel): + machine_id: str + denomination: int + count: int + position: int + updated_at: datetime + updated_by: Optional[str] + state_count: Optional[int] + state_at: Optional[datetime] + state_event_id: Optional[str] + + +class UpsertCassetteConfigData(BaseModel): + """Operator edits a single cassette row's count or position from the + dashboard. Both fields optional; pass only those changed.""" + + count: Optional[int] = None + position: Optional[int] = None + + @validator("count") + def count_non_negative(cls, v): + if v is None: + return v + if v < 0: + raise ValueError("count must be >= 0") + return v + + @validator("position") + def position_positive(cls, v): + if v is None: + return v + if v <= 0: + raise ValueError("position must be > 0") + return v + + +class CassettePayloadRow(BaseModel): + """One denomination's payload values in the wire-format + `{"denominations": {"": {"position", "count"}}}`.""" + + position: int + count: int + + @validator("position") + def position_positive(cls, v): + if v <= 0: + raise ValueError("position must be > 0") + return v + + @validator("count") + def count_non_negative(cls, v): + if v < 0: + raise ValueError("count must be >= 0") + return v + + +class PublishCassettesPayload(BaseModel): + """The decrypted JSON content of a kind-30078 cassette event, both + directions: + - operator → ATM (d-tag `bitspire-cassettes:`) + - ATM → operator (d-tag `bitspire-cassettes-state:`) + + Wire shape: `{"denominations": {"": {"position", "count"}}}`. + JSON object keys are always strings; the validator coerces back to + int on parse. The denomination key set MUST match what the receiver + already has (no add / no remove from this payload). + """ + + denominations: dict[int, CassettePayloadRow] + + @validator("denominations", pre=True) + def coerce_string_keys_to_int(cls, v): + if not isinstance(v, dict): + raise ValueError("denominations must be a dict") + out = {} + for k, val in v.items(): + try: + key_int = int(k) + except (TypeError, ValueError) as exc: + raise ValueError( + f"denomination key {k!r} is not an int" + ) from exc + if key_int <= 0: + raise ValueError(f"denomination must be > 0 (got {key_int})") + out[key_int] = val + return out + + @validator("denominations") + def no_duplicate_positions(cls, v): + positions = [row.position for row in v.values()] + if len(set(positions)) != len(positions): + raise ValueError("duplicate position values in payload") + return v + + def to_wire_dict(self) -> dict: + """Serialise back to the wire format with string keys for JSON + object compatibility. Used by the publisher to build the kind-30078 + event content before NIP-44 v2 encryption.""" + return { + "denominations": { + str(denom): {"position": row.position, "count": row.count} + for denom, row in self.denominations.items() + } + }