feat(v2): rewrite models.py for v2 schema

Replaces the Lamassu-era data models (LamassuConfig, StoredLamassuTransaction,
single-config CRUD carriers) with the v2 Pydantic surface matching m005:

- Machine / CreateMachineData / UpdateMachineData: per-operator multi-machine
  registry keyed by Nostr npub. Replaces single-row LamassuConfig + SSH fields.
- DcaClient now scoped per (machine_id, user_id). Includes autoforward fields
  for satmachineadmin#8 (best-effort LN-address forwarding for LPs).
- DcaDeposit gains machine_id + creator_user_id (audit trail finding from v1).
- DcaSettlement: idempotency carrier for bitSpire kind-21000 events. Carries
  platform_fee_sats + operator_fee_sats as absolute ints (v1 hook for the v2
  customer-discount engine — see plan).
- CommissionSplitLeg / CommissionSplit / SetCommissionSplitsData: operator's
  remainder-distribution rules. SetCommissionSplitsData validates legs sum
  to 1.0 at the boundary so crud.py only sees valid sets.
- DcaPayment dropped transaction_type in favor of leg_type discriminator
  (dca | super_fee | operator_split | settlement | autoforward | refund).
  Gains settlement_id + machine_id + operator_user_id + destination_*.
- TelemetrySnapshot: sparse beacon + fleet snapshot fields, all nullable so
  we degrade gracefully against today's minimal kind-30078 payload.
- SuperConfig / UpdateSuperConfigData: super-only platform-fee carrier.
- PartialDispenseData (satmachineadmin#3), SettleBalanceData (satmachineadmin#4):
  operator UX action carriers.

Stays on pydantic v1 @validator pattern + Optional[X] hints to match the
rest of the codebase. The UP045 / N805 lint noise is pre-existing tech debt
across the repo, not introduced here.

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:
Padreug 2026-05-14 14:33:16 +02:00
commit 013e3d5f6b

481
models.py
View file

@ -1,27 +1,107 @@
# Description: Pydantic data models dictate what is passed between frontend and backend. # Satoshi Machine v2 — Pydantic data models.
#
# The v2 schema replaces the Lamassu-era single-config, super-only data model
# with a per-operator multi-machine layout that ingests bitSpire settlements
# over Nostr kind-21000. See migrations.py::m005_satmachine_v2_overhaul and
# the plan at ~/.claude/plans/snug-gliding-shamir.md.
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from pydantic import BaseModel, validator from pydantic import BaseModel, validator
# =============================================================================
# Machines — one row per bitSpire ATM, owned by exactly one operator.
# =============================================================================
class CreateMachineData(BaseModel):
"""Operator adds a machine to their fleet by Nostr npub.
`wallet_id` is the LNbits wallet that will receive bitSpire settlements
for this machine. The same operator can own multiple machines; each
machine gets its own wallet so per-machine accounting via Payment.tag
(set to "satmachine:{machine_npub}") works natively.
"""
machine_npub: str
wallet_id: str
name: Optional[str] = None
location: Optional[str] = None
fiat_code: str = "GTQ"
# Used only when bitSpire's settlement event omits net_sats/fee_sats
# in Payment.extra (older bitSpire or edge cases). See plan's
# lamassu-next informational issue #1.
fallback_commission_pct: float = 0.05
@validator("fallback_commission_pct")
def commission_in_unit_range(cls, v):
if v is None:
return v
if v < 0 or v > 1:
raise ValueError("fallback_commission_pct must be between 0 and 1")
return round(float(v), 4)
class Machine(BaseModel):
id: str
operator_user_id: str
machine_npub: str
wallet_id: str
name: Optional[str]
location: Optional[str]
fiat_code: str
is_active: bool
fallback_commission_pct: float
created_at: datetime
updated_at: datetime
class UpdateMachineData(BaseModel):
name: Optional[str] = None
location: Optional[str] = None
fiat_code: Optional[str] = None
is_active: Optional[bool] = None
wallet_id: Optional[str] = None
fallback_commission_pct: Optional[float] = None
@validator("fallback_commission_pct")
def commission_in_unit_range(cls, v):
if v is None:
return v
if v < 0 or v > 1:
raise ValueError("fallback_commission_pct must be between 0 and 1")
return round(float(v), 4)
# =============================================================================
# DCA Clients — LP registrations, scoped per (machine, user).
# =============================================================================
# DCA Client Models
class CreateDcaClientData(BaseModel): class CreateDcaClientData(BaseModel):
machine_id: str
user_id: str user_id: str
wallet_id: str wallet_id: str
username: str username: Optional[str] = None
dca_mode: str = "flow" # 'flow' or 'fixed' dca_mode: str = "flow" # 'flow' | 'fixed'
fixed_mode_daily_limit: Optional[float] = None fixed_mode_daily_limit: Optional[float] = None
# Auto-forward DCA distributions to an external LN address (best-effort;
# sats stay in LNbits wallet on forward failure — see satmachineadmin#8).
autoforward_ln_address: Optional[str] = None
autoforward_enabled: bool = False
class DcaClient(BaseModel): class DcaClient(BaseModel):
id: str id: str
machine_id: str
user_id: str user_id: str
wallet_id: str wallet_id: str
username: Optional[str] username: Optional[str]
dca_mode: str dca_mode: str
fixed_mode_daily_limit: Optional[int] fixed_mode_daily_limit: Optional[float]
autoforward_ln_address: Optional[str]
autoforward_enabled: bool
status: str status: str
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@ -31,19 +111,34 @@ class UpdateDcaClientData(BaseModel):
username: Optional[str] = None username: Optional[str] = None
dca_mode: Optional[str] = None dca_mode: Optional[str] = None
fixed_mode_daily_limit: Optional[float] = None fixed_mode_daily_limit: Optional[float] = None
autoforward_ln_address: Optional[str] = None
autoforward_enabled: Optional[bool] = None
status: Optional[str] = None status: Optional[str] = None
# Deposit Models (Now storing GTQ directly) class ClientBalanceSummary(BaseModel):
client_id: str
machine_id: str
total_deposits: float # confirmed deposits in fiat
total_payments: float # DCA fiat-equivalent distributed
remaining_balance: float # deposits - payments
currency: str
# =============================================================================
# Deposits — fiat the operator (or super) records against an LP at a machine.
# =============================================================================
class CreateDepositData(BaseModel): class CreateDepositData(BaseModel):
client_id: str client_id: str
amount: float # Amount in GTQ (e.g., 150.75) machine_id: str
amount: float
currency: str = "GTQ" currency: str = "GTQ"
notes: Optional[str] = None notes: Optional[str] = None
@validator('amount') @validator("amount")
def round_amount_to_cents(cls, v): def round_amount(cls, v):
"""Ensure amount is rounded to 2 decimal places for DECIMAL(10,2) storage"""
if v is not None: if v is not None:
return round(float(v), 2) return round(float(v), 2)
return v return v
@ -52,9 +147,11 @@ class CreateDepositData(BaseModel):
class DcaDeposit(BaseModel): class DcaDeposit(BaseModel):
id: str id: str
client_id: str client_id: str
amount: float # Amount in GTQ (e.g., 150.75) machine_id: str
creator_user_id: str
amount: float
currency: str currency: str
status: str # 'pending' or 'confirmed' status: str # 'pending' | 'confirmed' | 'rejected'
notes: Optional[str] notes: Optional[str]
created_at: datetime created_at: datetime
confirmed_at: Optional[datetime] confirmed_at: Optional[datetime]
@ -65,179 +162,253 @@ class UpdateDepositData(BaseModel):
currency: Optional[str] = None currency: Optional[str] = None
notes: Optional[str] = None notes: Optional[str] = None
@validator('amount') @validator("amount")
def round_amount_to_cents(cls, v): def round_amount(cls, v):
if v is not None: if v is not None:
return round(float(v), 2) return round(float(v), 2)
return v return v
class UpdateDepositStatusData(BaseModel): class UpdateDepositStatusData(BaseModel):
status: str status: str # 'pending' | 'confirmed' | 'rejected'
notes: Optional[str] = None notes: Optional[str] = None
# Payment Models # =============================================================================
class CreateDcaPaymentData(BaseModel): # Settlements — one per bitSpire kind-21000 event.
client_id: str # =============================================================================
amount_sats: int # platform_fee_sats and operator_fee_sats are absolute audit-grade values.
amount_fiat: float # Amount in GTQ (e.g., 150.75) # Today they equal the contractual split; tomorrow (post-v1 promo engine)
# they record who-forgave-what. DO NOT collapse them into a single pct.
# See plan section "Customer discounts & promotions (post-v1)".
class CreateDcaSettlementData(BaseModel):
machine_id: str
bitspire_event_id: str # nostr event id — the idempotency key
bitspire_txid: Optional[str] = None
payment_hash: str
gross_sats: int
fiat_amount: float
fiat_code: str = "GTQ"
exchange_rate: float exchange_rate: float
transaction_type: str # 'flow', 'fixed', 'manual', 'commission' net_sats: int
lamassu_transaction_id: Optional[str] = None commission_sats: int
payment_hash: Optional[str] = None platform_fee_sats: int
transaction_time: Optional[datetime] = None # Original ATM transaction time operator_fee_sats: int
used_fallback_split: bool = False
tx_type: str # 'cash_out' | 'cash_in'
bills_json: Optional[str] = None
cassettes_json: Optional[str] = None
class DcaSettlement(BaseModel):
id: str
machine_id: str
bitspire_event_id: str
bitspire_txid: Optional[str]
payment_hash: str
gross_sats: int
fiat_amount: float
fiat_code: str
exchange_rate: float
net_sats: int
commission_sats: int
platform_fee_sats: int
operator_fee_sats: int
used_fallback_split: bool
tx_type: str
bills_json: Optional[str]
cassettes_json: Optional[str]
status: str # 'pending' | 'processed' | 'partial' | 'refunded' | 'errored'
error_message: Optional[str]
processed_at: Optional[datetime]
created_at: datetime
# =============================================================================
# Commission splits — operator-defined remainder allocation per machine.
# =============================================================================
# machine_id=NULL means operator's default; non-null means per-machine override.
# Sum of pct across rows for a (operator_user_id, machine_id) scope must be 1.0,
# enforced at write-time in crud.py.
class CommissionSplitLeg(BaseModel):
"""Single leg of an operator's commission-split rule set."""
wallet_id: str
label: Optional[str] = None
pct: float
sort_order: int = 0
@validator("pct")
def pct_in_unit_range(cls, v):
if v < 0 or v > 1:
raise ValueError("pct must be between 0 and 1")
return round(float(v), 4)
class CommissionSplit(BaseModel):
id: str
machine_id: Optional[str] # None = operator's default ruleset
operator_user_id: str
wallet_id: str
label: Optional[str]
pct: float
sort_order: int
created_at: datetime
class SetCommissionSplitsData(BaseModel):
"""Replaces the entire ruleset for a given scope.
`machine_id=None` writes the operator's default ruleset (applies to any
machine without an explicit override). Otherwise scoped per machine.
"""
machine_id: Optional[str] = None
legs: list[CommissionSplitLeg]
@validator("legs")
def legs_sum_to_one(cls, v):
total = round(sum(leg.pct for leg in v), 4)
if abs(total - 1.0) > 0.0001:
raise ValueError(f"split percentages must sum to 1.0 (got {total})")
return v
# =============================================================================
# Payments — every distribution leg (DCA / super_fee / split / settle / etc.)
# =============================================================================
class CreateDcaPaymentData(BaseModel):
settlement_id: Optional[str] = None
client_id: Optional[str] = None
machine_id: str
operator_user_id: str
leg_type: str
# 'dca' | 'super_fee' | 'operator_split' | 'settlement' | 'autoforward' | 'refund'
destination_wallet_id: Optional[str] = None
destination_ln_address: Optional[str] = None
amount_sats: int
amount_fiat: Optional[float] = None
exchange_rate: Optional[float] = None
transaction_time: datetime
external_payment_hash: Optional[str] = None
class DcaPayment(BaseModel): class DcaPayment(BaseModel):
id: str id: str
client_id: str settlement_id: Optional[str]
client_id: Optional[str]
machine_id: str
operator_user_id: str
leg_type: str
destination_wallet_id: Optional[str]
destination_ln_address: Optional[str]
amount_sats: int amount_sats: int
amount_fiat: float # Amount in GTQ (e.g., 150.75) amount_fiat: Optional[float]
exchange_rate: float exchange_rate: Optional[float]
transaction_type: str transaction_time: datetime
lamassu_transaction_id: Optional[str] external_payment_hash: Optional[str]
payment_hash: Optional[str] status: str # 'pending' | 'completed' | 'failed' | 'refunded'
status: str # 'pending', 'confirmed', 'failed' error_message: Optional[str]
created_at: datetime created_at: datetime
transaction_time: Optional[datetime] = None # Original ATM transaction time
# Client Balance Summary (Now storing GTQ directly) # =============================================================================
class ClientBalanceSummary(BaseModel): # Telemetry — sparse beacon (kind-30078) + fleet snapshot (kind-30079) state.
client_id: str # =============================================================================
total_deposits: float # Total confirmed deposits in GTQ
total_payments: float # Total payments made in GTQ
remaining_balance: float # Available balance for DCA in GTQ
currency: str
# Transaction Processing Models class TelemetrySnapshot(BaseModel):
class LamassuTransaction(BaseModel): machine_id: str
transaction_id: str # Beacon (kind-30078) — all fields are nullable because the upstream payload
amount_fiat: float # Amount in GTQ (e.g., 150.75) # is sparse today. As lamassu-next#43 lands, the post-#43 columns fill in.
amount_crypto: int beacon_cash_in: Optional[bool] = None
exchange_rate: float beacon_cash_out: Optional[bool] = None
transaction_type: str # 'cash_in' or 'cash_out' beacon_cash_level: Optional[str] = None
status: str beacon_fiat: Optional[str] = None
timestamp: datetime beacon_model: Optional[str] = None
beacon_name: Optional[str] = None
beacon_location: Optional[str] = None
beacon_geo: Optional[str] = None
beacon_fees_json: Optional[str] = None
beacon_limits_json: Optional[str] = None
beacon_denominations_json: Optional[str] = None
beacon_version: Optional[str] = None
beacon_received_at: Optional[datetime] = None
# Fleet telemetry (kind-30079) — operator-only, awaits lamassu-next#42.
telemetry_json: Optional[str] = None
telemetry_received_at: Optional[datetime] = None
# Lamassu Transaction Storage Models # =============================================================================
class CreateLamassuTransactionData(BaseModel): # Super config — singleton row with the platform fee.
lamassu_transaction_id: str # =============================================================================
fiat_amount: float # Amount in GTQ (e.g., 150.75)
crypto_amount: int
commission_percentage: float
discount: float = 0.0
effective_commission: float
commission_amount_sats: int
base_amount_sats: int
exchange_rate: float
crypto_code: str = "BTC"
fiat_code: str = "GTQ"
device_id: Optional[str] = None
transaction_time: datetime
class StoredLamassuTransaction(BaseModel): class SuperConfig(BaseModel):
id: str id: str
lamassu_transaction_id: str super_fee_pct: float
fiat_amount: float # Amount in GTQ (e.g., 150.75) super_fee_wallet_id: Optional[str]
crypto_amount: int updated_at: datetime
commission_percentage: float
discount: float
effective_commission: float
commission_amount_sats: int
base_amount_sats: int
exchange_rate: float
crypto_code: str
fiat_code: str
device_id: Optional[str]
transaction_time: datetime
processed_at: datetime
clients_count: int # Number of clients who received distributions
distributions_total_sats: int # Total sats distributed to clients
# Lamassu Configuration Models class UpdateSuperConfigData(BaseModel):
class CreateLamassuConfigData(BaseModel): super_fee_pct: Optional[float] = None
host: str super_fee_wallet_id: Optional[str] = None
port: int = 5432
database_name: str
username: str
password: str
# Source wallet for DCA distributions
source_wallet_id: Optional[str] = None
# Commission wallet for storing commission earnings
commission_wallet_id: Optional[str] = None
# SSH Tunnel settings
use_ssh_tunnel: bool = False
ssh_host: Optional[str] = None
ssh_port: int = 22
ssh_username: Optional[str] = None
ssh_password: Optional[str] = None
ssh_private_key: Optional[str] = None # Path to private key file or key content
# DCA Client Limits
max_daily_limit_gtq: float = 2000.0 # Maximum daily limit for Fixed mode clients
@validator('max_daily_limit_gtq') @validator("super_fee_pct")
def round_max_daily_limit(cls, v): def fee_in_unit_range(cls, v):
"""Ensure max daily limit is rounded to 2 decimal places""" if v is None:
if v is not None: return v
return round(float(v), 2) if v < 0 or v > 1:
raise ValueError("super_fee_pct must be between 0 and 1")
return round(float(v), 4)
# =============================================================================
# Operator UX action carriers — partial-tx and balance-settlement features.
# =============================================================================
class PartialDispenseData(BaseModel):
"""Resolves satmachineadmin#3 — operator confirms actual bills dispensed
when bitSpire reports an error mid-dispense.
Either `dispensed_fraction` (0..1) for ratio-based recompute, or
`dispensed_sats` for explicit recompute. Exactly one must be set.
"""
settlement_id: str
dispensed_fraction: Optional[float] = None
dispensed_sats: Optional[int] = None
notes: Optional[str] = None
@validator("dispensed_fraction")
def fraction_in_unit_range(cls, v):
if v is None:
return v
if v < 0 or v > 1:
raise ValueError("dispensed_fraction must be between 0 and 1")
return v return v
class LamassuConfig(BaseModel): class SettleBalanceData(BaseModel):
id: str """Resolves satmachineadmin#4 — operator settles small remaining LP balance
host: str from their own wallet at the current exchange rate."""
port: int
database_name: str
username: str
password: str
is_active: bool
test_connection_last: Optional[datetime]
test_connection_success: Optional[bool]
created_at: datetime
updated_at: datetime
# Source wallet for DCA distributions
source_wallet_id: Optional[str] = None
# Commission wallet for storing commission earnings
commission_wallet_id: Optional[str] = None
# SSH Tunnel settings
use_ssh_tunnel: bool = False
ssh_host: Optional[str] = None
ssh_port: int = 22
ssh_username: Optional[str] = None
ssh_password: Optional[str] = None
ssh_private_key: Optional[str] = None
# Poll tracking
last_poll_time: Optional[datetime] = None
last_successful_poll: Optional[datetime] = None
# DCA Client Limits
max_daily_limit_gtq: float = 2000.0 # Maximum daily limit for Fixed mode clients
class UpdateLamassuConfigData(BaseModel):
host: Optional[str] = None
port: Optional[int] = None
database_name: Optional[str] = None
username: Optional[str] = None
password: Optional[str] = None
is_active: Optional[bool] = None
# Source wallet for DCA distributions
source_wallet_id: Optional[str] = None
# Commission wallet for storing commission earnings
commission_wallet_id: Optional[str] = None
# SSH Tunnel settings
use_ssh_tunnel: Optional[bool] = None
ssh_host: Optional[str] = None
ssh_port: Optional[int] = None
ssh_username: Optional[str] = None
ssh_password: Optional[str] = None
ssh_private_key: Optional[str] = None
# DCA Client Limits
max_daily_limit_gtq: Optional[int] = None
client_id: str
funding_wallet_id: str
# If None, settle the full remaining balance.
amount_fiat: Optional[float] = None
notes: Optional[str] = None
@validator("amount_fiat")
def round_amount(cls, v):
if v is not None:
return round(float(v), 2)
return v