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
|
|
@ -3,6 +3,7 @@
|
|||
import asyncio
|
||||
import asyncpg
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional, Dict, Any
|
||||
from loguru import logger
|
||||
import socket
|
||||
|
|
@ -492,28 +493,38 @@ class LamassuTransactionProcessor:
|
|||
results = []
|
||||
for row in reader:
|
||||
# Convert string values to appropriate types
|
||||
# Use Decimal for monetary and percentage values
|
||||
processed_row = {}
|
||||
for key, value in row.items():
|
||||
# Handle None/empty values consistently at data ingestion boundary
|
||||
if value == '' or value is None:
|
||||
if key in ['fiat_amount', 'crypto_amount']:
|
||||
processed_row[key] = 0 # Default numeric fields to 0
|
||||
if key == 'crypto_amount':
|
||||
processed_row[key] = 0 # Sats are always int
|
||||
elif key == 'fiat_amount':
|
||||
processed_row[key] = Decimal("0") # Fiat as Decimal
|
||||
elif key in ['commission_percentage', 'discount']:
|
||||
processed_row[key] = 0.0 # Default percentage fields to 0.0
|
||||
processed_row[key] = Decimal("0") # Percentages as Decimal
|
||||
else:
|
||||
processed_row[key] = None # Keep None for non-numeric fields
|
||||
elif key in ['transaction_id', 'device_id', 'crypto_code', 'fiat_code']:
|
||||
processed_row[key] = str(value)
|
||||
elif key in ['fiat_amount', 'crypto_amount']:
|
||||
elif key == 'crypto_amount':
|
||||
try:
|
||||
processed_row[key] = int(float(value))
|
||||
processed_row[key] = int(float(value)) # Sats are always int
|
||||
except (ValueError, TypeError):
|
||||
processed_row[key] = 0 # Fallback to 0 for invalid values
|
||||
processed_row[key] = 0
|
||||
elif key == 'fiat_amount':
|
||||
try:
|
||||
# Convert via string to avoid float precision issues
|
||||
processed_row[key] = Decimal(str(value))
|
||||
except (ValueError, TypeError):
|
||||
processed_row[key] = Decimal("0")
|
||||
elif key in ['commission_percentage', 'discount']:
|
||||
try:
|
||||
processed_row[key] = float(value)
|
||||
# Convert via string to avoid float precision issues
|
||||
processed_row[key] = Decimal(str(value))
|
||||
except (ValueError, TypeError):
|
||||
processed_row[key] = 0.0 # Fallback to 0.0 for invalid values
|
||||
processed_row[key] = Decimal("0")
|
||||
elif key == 'transaction_time':
|
||||
from datetime import datetime
|
||||
# Parse PostgreSQL timestamp format and ensure it's in UTC for consistency
|
||||
|
|
@ -679,13 +690,13 @@ class LamassuTransactionProcessor:
|
|||
logger.info("No Flow Mode clients found - skipping distribution")
|
||||
return {}
|
||||
|
||||
# Extract transaction details - guaranteed clean from data ingestion
|
||||
# Extract transaction details - guaranteed clean from data ingestion (Decimal types)
|
||||
crypto_atoms = transaction.get("crypto_amount", 0) # Total sats with commission baked in
|
||||
fiat_amount = transaction.get("fiat_amount", 0) # Actual fiat dispensed (principal only)
|
||||
commission_percentage = transaction.get("commission_percentage", 0.0) # Already stored as decimal (e.g., 0.045)
|
||||
discount = transaction.get("discount", 0.0) # Discount percentage
|
||||
fiat_amount = transaction.get("fiat_amount", Decimal("0")) # Actual fiat dispensed (principal only)
|
||||
commission_percentage = transaction.get("commission_percentage", Decimal("0")) # Already stored as Decimal (e.g., 0.045)
|
||||
discount = transaction.get("discount", Decimal("0")) # Discount percentage
|
||||
transaction_time = transaction.get("transaction_time") # ATM transaction timestamp for temporal accuracy
|
||||
|
||||
|
||||
# Normalize transaction_time to UTC if present
|
||||
if transaction_time is not None:
|
||||
if transaction_time.tzinfo is None:
|
||||
|
|
@ -697,7 +708,7 @@ class LamassuTransactionProcessor:
|
|||
original_tz = transaction_time.tzinfo
|
||||
transaction_time = transaction_time.astimezone(timezone.utc)
|
||||
logger.info(f"Converted transaction time from {original_tz} to UTC")
|
||||
|
||||
|
||||
# Validate required fields
|
||||
if crypto_atoms is None:
|
||||
logger.error(f"Missing crypto_amount in transaction: {transaction}")
|
||||
|
|
@ -707,10 +718,10 @@ class LamassuTransactionProcessor:
|
|||
return {}
|
||||
if commission_percentage is None:
|
||||
logger.warning(f"Missing commission_percentage in transaction: {transaction}, defaulting to 0")
|
||||
commission_percentage = 0.0
|
||||
commission_percentage = Decimal("0")
|
||||
if discount is None:
|
||||
logger.info(f"Missing discount in transaction: {transaction}, defaulting to 0")
|
||||
discount = 0.0
|
||||
discount = Decimal("0")
|
||||
if transaction_time is None:
|
||||
logger.warning(f"Missing transaction_time in transaction: {transaction}")
|
||||
# Could use current time as fallback, but this indicates a data issue
|
||||
|
|
@ -733,15 +744,16 @@ class LamassuTransactionProcessor:
|
|||
logger.warning("No transaction time available - using current balances (may be inaccurate)")
|
||||
|
||||
# Get balance summaries for all clients to calculate proportions
|
||||
client_balances = {}
|
||||
total_confirmed_deposits = 0
|
||||
|
||||
client_balances: Dict[str, Decimal] = {}
|
||||
total_confirmed_deposits = Decimal("0")
|
||||
min_balance = Decimal("0.01")
|
||||
|
||||
for client in flow_clients:
|
||||
# Get balance as of the transaction time for temporal accuracy
|
||||
balance = await get_client_balance_summary(client.id, as_of_time=transaction_time)
|
||||
# Only include clients with positive remaining balance
|
||||
# NOTE: This works for fiat amounts that use cents
|
||||
if balance.remaining_balance >= 0.01:
|
||||
if balance.remaining_balance >= min_balance:
|
||||
client_balances[client.id] = balance.remaining_balance
|
||||
total_confirmed_deposits += balance.remaining_balance
|
||||
logger.debug(f"Client {client.id[:8]}... included with balance: {balance.remaining_balance:.2f} GTQ")
|
||||
|
|
@ -766,7 +778,10 @@ class LamassuTransactionProcessor:
|
|||
proportion = client_balances[client_id] / total_confirmed_deposits
|
||||
|
||||
# Calculate equivalent fiat value in GTQ for tracking purposes
|
||||
client_fiat_amount = round(client_sats_amount / exchange_rate, 2) if exchange_rate > 0 else 0.0
|
||||
if exchange_rate > 0:
|
||||
client_fiat_amount = (Decimal(client_sats_amount) / exchange_rate).quantize(Decimal("0.01"))
|
||||
else:
|
||||
client_fiat_amount = Decimal("0")
|
||||
|
||||
distributions[client_id] = {
|
||||
"fiat_amount": client_fiat_amount,
|
||||
|
|
@ -1003,20 +1018,20 @@ class LamassuTransactionProcessor:
|
|||
async def store_lamassu_transaction(self, transaction: Dict[str, Any]) -> Optional[str]:
|
||||
"""Store the Lamassu transaction in our database for audit and UI"""
|
||||
try:
|
||||
# Extract transaction data - guaranteed clean from data ingestion boundary
|
||||
# Extract transaction data - guaranteed clean from data ingestion boundary (Decimal types)
|
||||
crypto_atoms = transaction.get("crypto_amount", 0)
|
||||
fiat_amount = transaction.get("fiat_amount", 0)
|
||||
commission_percentage = transaction.get("commission_percentage", 0.0)
|
||||
discount = transaction.get("discount", 0.0)
|
||||
fiat_amount = transaction.get("fiat_amount", Decimal("0"))
|
||||
commission_percentage = transaction.get("commission_percentage", Decimal("0"))
|
||||
discount = transaction.get("discount", Decimal("0"))
|
||||
transaction_time = transaction.get("transaction_time")
|
||||
|
||||
|
||||
# Normalize transaction_time to UTC if present
|
||||
if transaction_time is not None:
|
||||
if transaction_time.tzinfo is None:
|
||||
transaction_time = transaction_time.replace(tzinfo=timezone.utc)
|
||||
elif transaction_time.tzinfo != timezone.utc:
|
||||
transaction_time = transaction_time.astimezone(timezone.utc)
|
||||
|
||||
|
||||
# Calculate commission metrics using the extracted pure function
|
||||
base_crypto_atoms, commission_amount_sats, effective_commission = calculate_commission(
|
||||
crypto_atoms, commission_percentage, discount
|
||||
|
|
@ -1024,11 +1039,13 @@ class LamassuTransactionProcessor:
|
|||
|
||||
# Calculate exchange rate
|
||||
exchange_rate = calculate_exchange_rate(base_crypto_atoms, fiat_amount)
|
||||
|
||||
# Create transaction data with GTQ amounts
|
||||
|
||||
# Create transaction data with GTQ amounts (Decimal already, just ensure 2 decimal places)
|
||||
fiat_amount_rounded = fiat_amount.quantize(Decimal("0.01")) if isinstance(fiat_amount, Decimal) else Decimal(str(fiat_amount)).quantize(Decimal("0.01"))
|
||||
|
||||
transaction_data = CreateLamassuTransactionData(
|
||||
lamassu_transaction_id=transaction["transaction_id"],
|
||||
fiat_amount=round(fiat_amount, 2), # Store GTQ with 2 decimal places
|
||||
fiat_amount=fiat_amount_rounded,
|
||||
crypto_amount=crypto_atoms,
|
||||
commission_percentage=commission_percentage,
|
||||
discount=discount,
|
||||
|
|
@ -1148,9 +1165,9 @@ class LamassuTransactionProcessor:
|
|||
|
||||
# Calculate commission amount for sending to commission wallet
|
||||
crypto_atoms = transaction.get("crypto_amount", 0)
|
||||
commission_percentage = transaction.get("commission_percentage", 0.0)
|
||||
discount = transaction.get("discount", 0.0)
|
||||
|
||||
commission_percentage = transaction.get("commission_percentage", Decimal("0"))
|
||||
discount = transaction.get("discount", Decimal("0"))
|
||||
|
||||
# Calculate commission amount using the extracted pure function
|
||||
_, commission_amount_sats, _ = calculate_commission(
|
||||
crypto_atoms, commission_percentage, discount
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue