refactor: use Decimal instead of float for monetary calculations

- calculations.py: Use Decimal for commission percentages, exchange rates,
  and client balances. Added to_decimal() helper for safe float conversion.
  Changed from banker's rounding to ROUND_HALF_UP.

- models.py: Changed all fiat amounts, percentages, and exchange rates to
  Decimal. Added json_encoders for API serialization.

- transaction_processor.py: Convert to Decimal at data ingestion boundary
  (CSV parsing). Updated all defaults and calculations to use Decimal.

- tests: Updated to work with Decimal return types.

This prevents floating-point precision issues in financial calculations.
All 23 tests pass.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
padreug 2026-01-11 14:47:56 +01:00
parent 397fd4b002
commit 6e86f53962
4 changed files with 180 additions and 101 deletions

View file

@ -1,6 +1,7 @@
# 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
@ -12,7 +13,7 @@ class CreateDcaClientData(BaseModel):
wallet_id: str
username: str
dca_mode: str = "flow" # 'flow' or 'fixed'
fixed_mode_daily_limit: Optional[float] = None
fixed_mode_daily_limit: Optional[Decimal] = None
class DcaClient(BaseModel):
@ -21,44 +22,52 @@ class DcaClient(BaseModel):
wallet_id: str
username: Optional[str]
dca_mode: str
fixed_mode_daily_limit: Optional[int]
fixed_mode_daily_limit: Optional[Decimal]
status: str
created_at: datetime
updated_at: datetime
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[float] = 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: float # Amount in GTQ (e.g., 150.75)
amount: Decimal # Amount in GTQ (e.g., 150.75)
currency: str = "GTQ"
notes: Optional[str] = None
@validator('amount')
@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:
return round(float(v), 2)
# 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: float # Amount in GTQ (e.g., 150.75)
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]
class Config:
json_encoders = {Decimal: lambda v: float(v)}
class UpdateDepositStatusData(BaseModel):
status: str
@ -69,8 +78,8 @@ class UpdateDepositStatusData(BaseModel):
class CreateDcaPaymentData(BaseModel):
client_id: str
amount_sats: int
amount_fiat: float # Amount in GTQ (e.g., 150.75)
exchange_rate: float
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
@ -81,8 +90,8 @@ class DcaPayment(BaseModel):
id: str
client_id: str
amount_sats: int
amount_fiat: float # Amount in GTQ (e.g., 150.75)
exchange_rate: float
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]
@ -90,38 +99,47 @@ class DcaPayment(BaseModel):
created_at: datetime
transaction_time: Optional[datetime] = None # Original ATM transaction time
class Config:
json_encoders = {Decimal: lambda v: float(v)}
# 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
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
class Config:
json_encoders = {Decimal: lambda v: float(v)}
# Transaction Processing Models
class LamassuTransaction(BaseModel):
transaction_id: str
amount_fiat: float # Amount in GTQ (e.g., 150.75)
amount_fiat: Decimal # Amount in GTQ (e.g., 150.75)
amount_crypto: int
exchange_rate: float
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: float # Amount in GTQ (e.g., 150.75)
fiat_amount: Decimal # Amount in GTQ (e.g., 150.75)
crypto_amount: int
commission_percentage: float
discount: float = 0.0
effective_commission: float
commission_percentage: Decimal
discount: Decimal = Decimal("0")
effective_commission: Decimal
commission_amount_sats: int
base_amount_sats: int
exchange_rate: float
exchange_rate: Decimal
crypto_code: str = "BTC"
fiat_code: str = "GTQ"
device_id: Optional[str] = None
@ -131,14 +149,14 @@ class CreateLamassuTransactionData(BaseModel):
class StoredLamassuTransaction(BaseModel):
id: str
lamassu_transaction_id: str
fiat_amount: float # Amount in GTQ (e.g., 150.75)
fiat_amount: Decimal # Amount in GTQ (e.g., 150.75)
crypto_amount: int
commission_percentage: float
discount: float
effective_commission: float
commission_percentage: Decimal
discount: Decimal
effective_commission: Decimal
commission_amount_sats: int
base_amount_sats: int
exchange_rate: float
exchange_rate: Decimal
crypto_code: str
fiat_code: str
device_id: Optional[str]
@ -147,6 +165,9 @@ class StoredLamassuTransaction(BaseModel):
clients_count: int # Number of clients who received distributions
distributions_total_sats: int # Total sats distributed to clients
class Config:
json_encoders = {Decimal: lambda v: float(v)}
# Lamassu Configuration Models
class CreateLamassuConfigData(BaseModel):
@ -167,13 +188,14 @@ class CreateLamassuConfigData(BaseModel):
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')
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:
return round(float(v), 2)
d = Decimal(str(v)) if not isinstance(v, Decimal) else v
return d.quantize(Decimal("0.01"))
return v
@ -204,7 +226,10 @@ class LamassuConfig(BaseModel):
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
max_daily_limit_gtq: Decimal = Decimal("2000") # Maximum daily limit for Fixed mode clients
class Config:
json_encoders = {Decimal: lambda v: float(v)}
class UpdateLamassuConfigData(BaseModel):
@ -226,6 +251,6 @@ class UpdateLamassuConfigData(BaseModel):
ssh_password: Optional[str] = None
ssh_private_key: Optional[str] = None
# DCA Client Limits
max_daily_limit_gtq: Optional[int] = None
max_daily_limit_gtq: Optional[Decimal] = None