satmachineadmin/models.py
padreug 904b3f1d61 fix: add SQLite compatibility for Decimal types
SQLite doesn't support Decimal natively - it stores DECIMAL columns as
REAL (float). This caused sqlite3.ProgrammingError when writing Decimal
values.

Changes:
- Add prepare_for_db() helper to convert Decimal→float before writes
- Add Pydantic validators to convert float→Decimal on model creation
- Update CRUD layer tests to verify float params for SQLite
- Add SQLite round-trip tests to verify precision is preserved

The data flow is now:
Decimal (calculations) → float (prepare_for_db) → SQLite → float → Decimal (validators)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 15:09:45 +01:00

293 lines
8.6 KiB
Python

# Description: Pydantic data models dictate what is passed between frontend and backend.
from datetime import datetime
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel, validator
def _to_decimal(v):
"""Convert a value to Decimal, handling floats from database."""
if v is None:
return None
if isinstance(v, Decimal):
return v
# Convert via string to avoid float precision issues
return Decimal(str(v))
# DCA Client Models
class CreateDcaClientData(BaseModel):
user_id: str
wallet_id: str
username: str
dca_mode: str = "flow" # 'flow' or 'fixed'
fixed_mode_daily_limit: Optional[Decimal] = None
class DcaClient(BaseModel):
id: str
user_id: str
wallet_id: str
username: Optional[str]
dca_mode: str
fixed_mode_daily_limit: Optional[Decimal]
status: str
created_at: datetime
updated_at: datetime
@validator('fixed_mode_daily_limit', pre=True)
def convert_fixed_mode_daily_limit(cls, v):
return _to_decimal(v)
class Config:
json_encoders = {Decimal: lambda v: float(v)}
class UpdateDcaClientData(BaseModel):
username: Optional[str] = None
dca_mode: Optional[str] = None
fixed_mode_daily_limit: Optional[Decimal] = None
status: Optional[str] = None
# Deposit Models (Now storing GTQ directly)
class CreateDepositData(BaseModel):
client_id: str
amount: Decimal # Amount in GTQ (e.g., 150.75)
currency: str = "GTQ"
notes: Optional[str] = None
@validator('amount', pre=True)
def round_amount_to_cents(cls, v):
"""Ensure amount is rounded to 2 decimal places for DECIMAL(10,2) storage"""
if v is not None:
# Convert to Decimal via string to avoid float precision issues
d = Decimal(str(v)) if not isinstance(v, Decimal) else v
return d.quantize(Decimal("0.01"))
return v
class DcaDeposit(BaseModel):
id: str
client_id: str
amount: Decimal # Amount in GTQ (e.g., 150.75)
currency: str
status: str # 'pending' or 'confirmed'
notes: Optional[str]
created_at: datetime
confirmed_at: Optional[datetime]
@validator('amount', pre=True)
def convert_amount(cls, v):
return _to_decimal(v)
class Config:
json_encoders = {Decimal: lambda v: float(v)}
class UpdateDepositStatusData(BaseModel):
status: str
notes: Optional[str] = None
# Payment Models
class CreateDcaPaymentData(BaseModel):
client_id: str
amount_sats: int
amount_fiat: Decimal # Amount in GTQ (e.g., 150.75)
exchange_rate: Decimal
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
class DcaPayment(BaseModel):
id: str
client_id: str
amount_sats: int
amount_fiat: Decimal # Amount in GTQ (e.g., 150.75)
exchange_rate: Decimal
transaction_type: str
lamassu_transaction_id: Optional[str]
payment_hash: Optional[str]
status: str # 'pending', 'confirmed', 'failed'
created_at: datetime
transaction_time: Optional[datetime] = None # Original ATM transaction time
@validator('amount_fiat', 'exchange_rate', pre=True)
def convert_decimals(cls, v):
return _to_decimal(v)
class Config:
json_encoders = {Decimal: lambda v: float(v)}
# Client Balance Summary (Now storing GTQ directly)
class ClientBalanceSummary(BaseModel):
client_id: str
total_deposits: Decimal # Total confirmed deposits in GTQ
total_payments: Decimal # Total payments made in GTQ
remaining_balance: Decimal # Available balance for DCA in GTQ
currency: str
@validator('total_deposits', 'total_payments', 'remaining_balance', pre=True)
def convert_decimals(cls, v):
return _to_decimal(v)
class Config:
json_encoders = {Decimal: lambda v: float(v)}
# Transaction Processing Models
class LamassuTransaction(BaseModel):
transaction_id: str
amount_fiat: Decimal # Amount in GTQ (e.g., 150.75)
amount_crypto: int
exchange_rate: Decimal
transaction_type: str # 'cash_in' or 'cash_out'
status: str
timestamp: datetime
class Config:
json_encoders = {Decimal: lambda v: float(v)}
# Lamassu Transaction Storage Models
class CreateLamassuTransactionData(BaseModel):
lamassu_transaction_id: str
fiat_amount: Decimal # Amount in GTQ (e.g., 150.75)
crypto_amount: int
commission_percentage: Decimal
discount: Decimal = Decimal("0")
effective_commission: Decimal
commission_amount_sats: int
base_amount_sats: int
exchange_rate: Decimal
crypto_code: str = "BTC"
fiat_code: str = "GTQ"
device_id: Optional[str] = None
transaction_time: datetime
class StoredLamassuTransaction(BaseModel):
id: str
lamassu_transaction_id: str
fiat_amount: Decimal # Amount in GTQ (e.g., 150.75)
crypto_amount: int
commission_percentage: Decimal
discount: Decimal
effective_commission: Decimal
commission_amount_sats: int
base_amount_sats: int
exchange_rate: Decimal
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
@validator(
'fiat_amount', 'commission_percentage', 'discount',
'effective_commission', 'exchange_rate', pre=True
)
def convert_decimals(cls, v):
return _to_decimal(v)
class Config:
json_encoders = {Decimal: lambda v: float(v)}
# 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: Decimal = Decimal("2000") # Maximum daily limit for Fixed mode clients
@validator('max_daily_limit_gtq', pre=True)
def round_max_daily_limit(cls, v):
"""Ensure max daily limit is rounded to 2 decimal places"""
if v is not None:
d = Decimal(str(v)) if not isinstance(v, Decimal) else v
return d.quantize(Decimal("0.01"))
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: Decimal = Decimal("2000") # Maximum daily limit for Fixed mode clients
@validator('max_daily_limit_gtq', pre=True)
def convert_max_daily_limit(cls, v):
return _to_decimal(v)
class Config:
json_encoders = {Decimal: lambda v: float(v)}
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[Decimal] = None