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>
293 lines
8.6 KiB
Python
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
|
|
|
|
|