feat(v2): m007 cassette_configs schema + Pydantic models (#29 v1)
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:<atm_pubkey_hex> 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) <noreply@anthropic.com>
This commit is contained in:
parent
58a0974117
commit
13684e7134
2 changed files with 161 additions and 0 deletions
|
|
@ -538,3 +538,40 @@ async def m005_lock_deposit_currency_to_machine_fiat_code(db):
|
||||||
AND m.fiat_code != d.currency
|
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:<atm_pubkey_hex> 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)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
|
||||||
124
models.py
124
models.py
|
|
@ -546,3 +546,127 @@ class SettleBalanceData(BaseModel):
|
||||||
if v <= 0:
|
if v <= 0:
|
||||||
raise ValueError("amount_fiat must be > 0 if specified")
|
raise ValueError("amount_fiat must be > 0 if specified")
|
||||||
return round(float(v), 2)
|
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:<atm_pubkey_hex>). 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": {"<denom>": {"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_pubkey_hex>`)
|
||||||
|
- ATM → operator (d-tag `bitspire-cassettes-state:<atm_pubkey_hex>`)
|
||||||
|
|
||||||
|
Wire shape: `{"denominations": {"<denom_str>": {"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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue