diff --git a/models.py b/models.py index 2fb60a6..ced9f8d 100644 --- a/models.py +++ b/models.py @@ -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 typing import Optional 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): + machine_id: str user_id: str wallet_id: str - username: str - dca_mode: str = "flow" # 'flow' or 'fixed' + username: Optional[str] = None + dca_mode: str = "flow" # 'flow' | 'fixed' 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): id: str + machine_id: str user_id: str wallet_id: str username: Optional[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 created_at: datetime updated_at: datetime @@ -31,19 +111,34 @@ class UpdateDcaClientData(BaseModel): username: Optional[str] = None dca_mode: Optional[str] = None fixed_mode_daily_limit: Optional[float] = None + autoforward_ln_address: Optional[str] = None + autoforward_enabled: Optional[bool] = 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): client_id: str - amount: float # Amount in GTQ (e.g., 150.75) + machine_id: str + amount: float currency: str = "GTQ" notes: Optional[str] = None - - @validator('amount') - def round_amount_to_cents(cls, v): - """Ensure amount is rounded to 2 decimal places for DECIMAL(10,2) storage""" + + @validator("amount") + def round_amount(cls, v): if v is not None: return round(float(v), 2) return v @@ -52,9 +147,11 @@ class CreateDepositData(BaseModel): class DcaDeposit(BaseModel): 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 - status: str # 'pending' or 'confirmed' + status: str # 'pending' | 'confirmed' | 'rejected' notes: Optional[str] created_at: datetime confirmed_at: Optional[datetime] @@ -65,179 +162,253 @@ class UpdateDepositData(BaseModel): currency: Optional[str] = None notes: Optional[str] = None - @validator('amount') - def round_amount_to_cents(cls, v): + @validator("amount") + def round_amount(cls, v): if v is not None: return round(float(v), 2) return v class UpdateDepositStatusData(BaseModel): - status: str + status: str # 'pending' | 'confirmed' | 'rejected' notes: Optional[str] = None -# Payment Models -class CreateDcaPaymentData(BaseModel): - client_id: str - amount_sats: int - amount_fiat: float # Amount in GTQ (e.g., 150.75) +# ============================================================================= +# Settlements — one per bitSpire kind-21000 event. +# ============================================================================= +# platform_fee_sats and operator_fee_sats are absolute audit-grade values. +# 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 - transaction_type: str # 'flow', 'fixed', 'manual', 'commission' - lamassu_transaction_id: Optional[str] = None - payment_hash: Optional[str] = None - transaction_time: Optional[datetime] = None # Original ATM transaction time + net_sats: int + commission_sats: int + platform_fee_sats: int + 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): 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_fiat: float # Amount in GTQ (e.g., 150.75) - exchange_rate: float - transaction_type: str - lamassu_transaction_id: Optional[str] - payment_hash: Optional[str] - status: str # 'pending', 'confirmed', 'failed' + amount_fiat: Optional[float] + exchange_rate: Optional[float] + transaction_time: datetime + external_payment_hash: Optional[str] + status: str # 'pending' | 'completed' | 'failed' | 'refunded' + error_message: Optional[str] created_at: datetime - transaction_time: Optional[datetime] = None # Original ATM transaction time -# Client Balance Summary (Now storing GTQ directly) -class ClientBalanceSummary(BaseModel): - 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 +# ============================================================================= +# Telemetry — sparse beacon (kind-30078) + fleet snapshot (kind-30079) state. +# ============================================================================= -# Transaction Processing Models -class LamassuTransaction(BaseModel): - transaction_id: str - amount_fiat: float # Amount in GTQ (e.g., 150.75) - amount_crypto: int - exchange_rate: float - transaction_type: str # 'cash_in' or 'cash_out' - status: str - timestamp: datetime +class TelemetrySnapshot(BaseModel): + machine_id: str + # Beacon (kind-30078) — all fields are nullable because the upstream payload + # is sparse today. As lamassu-next#43 lands, the post-#43 columns fill in. + beacon_cash_in: Optional[bool] = None + beacon_cash_out: Optional[bool] = None + beacon_cash_level: Optional[str] = None + beacon_fiat: Optional[str] = None + 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): - 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 +# ============================================================================= +# Super config — singleton row with the platform fee. +# ============================================================================= -class StoredLamassuTransaction(BaseModel): +class SuperConfig(BaseModel): id: str - lamassu_transaction_id: str - fiat_amount: float # Amount in GTQ (e.g., 150.75) - crypto_amount: int - 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 + super_fee_pct: float + super_fee_wallet_id: Optional[str] + updated_at: datetime -# Lamassu Configuration Models -class CreateLamassuConfigData(BaseModel): - host: str - 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') - def round_max_daily_limit(cls, v): - """Ensure max daily limit is rounded to 2 decimal places""" - if v is not None: - return round(float(v), 2) +class UpdateSuperConfigData(BaseModel): + super_fee_pct: Optional[float] = None + super_fee_wallet_id: Optional[str] = None + + @validator("super_fee_pct") + def fee_in_unit_range(cls, v): + if v is None: + return v + 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 -class LamassuConfig(BaseModel): - id: str - host: str - 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 +class SettleBalanceData(BaseModel): + """Resolves satmachineadmin#4 — operator settles small remaining LP balance + from their own wallet at the current exchange rate.""" + 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