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:
parent
397fd4b002
commit
6e86f53962
4 changed files with 180 additions and 101 deletions
91
models.py
91
models.py
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue