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,16 +3,34 @@ Pure calculation functions for DCA transaction processing.
|
||||||
|
|
||||||
These functions have no external dependencies (no lnbits, no database)
|
These functions have no external dependencies (no lnbits, no database)
|
||||||
and can be easily tested in isolation.
|
and can be easily tested in isolation.
|
||||||
|
|
||||||
|
All monetary calculations use Decimal for precision. Satoshi amounts
|
||||||
|
remain as int since they are the smallest indivisible unit.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Dict, Tuple
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
from typing import Dict, Tuple, Union
|
||||||
|
|
||||||
|
# Type alias for values that can be Decimal or numeric types that will be converted
|
||||||
|
DecimalLike = Union[Decimal, float, int, str]
|
||||||
|
|
||||||
|
|
||||||
|
def to_decimal(value: DecimalLike) -> Decimal:
|
||||||
|
"""Convert a value to Decimal, handling floats carefully."""
|
||||||
|
if isinstance(value, Decimal):
|
||||||
|
return value
|
||||||
|
# Convert floats via string to avoid binary float precision issues
|
||||||
|
# e.g., Decimal(0.055) gives 0.054999999... but Decimal("0.055") is exact
|
||||||
|
if isinstance(value, float):
|
||||||
|
return Decimal(str(value))
|
||||||
|
return Decimal(value)
|
||||||
|
|
||||||
|
|
||||||
def calculate_commission(
|
def calculate_commission(
|
||||||
crypto_atoms: int,
|
crypto_atoms: int,
|
||||||
commission_percentage: float,
|
commission_percentage: DecimalLike,
|
||||||
discount: float = 0.0
|
discount: DecimalLike = Decimal("0")
|
||||||
) -> Tuple[int, int, float]:
|
) -> Tuple[int, int, Decimal]:
|
||||||
"""
|
"""
|
||||||
Calculate commission split from a Lamassu transaction.
|
Calculate commission split from a Lamassu transaction.
|
||||||
|
|
||||||
|
|
@ -34,15 +52,25 @@ def calculate_commission(
|
||||||
Tuple of (base_amount_sats, commission_amount_sats, effective_commission_rate)
|
Tuple of (base_amount_sats, commission_amount_sats, effective_commission_rate)
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
>>> calculate_commission(266800, 0.03, 0.0)
|
>>> calculate_commission(266800, Decimal("0.03"), Decimal("0"))
|
||||||
(259029, 7771, 0.03)
|
(259029, 7771, Decimal('0.03'))
|
||||||
"""
|
"""
|
||||||
if commission_percentage > 0:
|
# Convert inputs to Decimal for precise calculations
|
||||||
effective_commission = commission_percentage * (100 - discount) / 100
|
comm_pct = to_decimal(commission_percentage)
|
||||||
base_crypto_atoms = round(crypto_atoms / (1 + effective_commission))
|
disc = to_decimal(discount)
|
||||||
|
|
||||||
|
if comm_pct > 0:
|
||||||
|
# effective = commission_percentage * (100 - discount) / 100
|
||||||
|
effective_commission = comm_pct * (Decimal("100") - disc) / Decimal("100")
|
||||||
|
|
||||||
|
# base = crypto_atoms / (1 + effective_commission), rounded to nearest int
|
||||||
|
divisor = Decimal("1") + effective_commission
|
||||||
|
exact_base = Decimal(crypto_atoms) / divisor
|
||||||
|
# Use ROUND_HALF_UP (standard rounding: 0.5 rounds up)
|
||||||
|
base_crypto_atoms = int(exact_base.quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
||||||
commission_amount_sats = crypto_atoms - base_crypto_atoms
|
commission_amount_sats = crypto_atoms - base_crypto_atoms
|
||||||
else:
|
else:
|
||||||
effective_commission = 0.0
|
effective_commission = Decimal("0")
|
||||||
base_crypto_atoms = crypto_atoms
|
base_crypto_atoms = crypto_atoms
|
||||||
commission_amount_sats = 0
|
commission_amount_sats = 0
|
||||||
|
|
||||||
|
|
@ -51,8 +79,8 @@ def calculate_commission(
|
||||||
|
|
||||||
def calculate_distribution(
|
def calculate_distribution(
|
||||||
base_amount_sats: int,
|
base_amount_sats: int,
|
||||||
client_balances: Dict[str, float],
|
client_balances: Dict[str, DecimalLike],
|
||||||
min_balance_threshold: float = 0.01
|
min_balance_threshold: DecimalLike = Decimal("0.01")
|
||||||
) -> Dict[str, int]:
|
) -> Dict[str, int]:
|
||||||
"""
|
"""
|
||||||
Calculate proportional distribution of sats to clients based on their fiat balances.
|
Calculate proportional distribution of sats to clients based on their fiat balances.
|
||||||
|
|
@ -69,15 +97,18 @@ def calculate_distribution(
|
||||||
Dict of {client_id: allocated_sats}
|
Dict of {client_id: allocated_sats}
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
>>> calculate_distribution(100000, {"a": 500.0, "b": 500.0})
|
>>> calculate_distribution(100000, {"a": Decimal("500"), "b": Decimal("500")})
|
||||||
{"a": 50000, "b": 50000}
|
{"a": 50000, "b": 50000}
|
||||||
"""
|
"""
|
||||||
# Filter out clients with balance below threshold
|
# Convert threshold to Decimal
|
||||||
active_balances = {
|
threshold = to_decimal(min_balance_threshold)
|
||||||
client_id: balance
|
|
||||||
for client_id, balance in client_balances.items()
|
# Filter out clients with balance below threshold, converting to Decimal
|
||||||
if balance >= min_balance_threshold
|
active_balances: Dict[str, Decimal] = {}
|
||||||
}
|
for client_id, balance in client_balances.items():
|
||||||
|
bal = to_decimal(balance)
|
||||||
|
if bal >= threshold:
|
||||||
|
active_balances[client_id] = bal
|
||||||
|
|
||||||
if not active_balances:
|
if not active_balances:
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -90,11 +121,13 @@ def calculate_distribution(
|
||||||
# First pass: calculate base allocations and track for remainder distribution
|
# First pass: calculate base allocations and track for remainder distribution
|
||||||
client_calculations = []
|
client_calculations = []
|
||||||
distributed_sats = 0
|
distributed_sats = 0
|
||||||
|
base_sats_decimal = Decimal(base_amount_sats)
|
||||||
|
|
||||||
for client_id, balance in active_balances.items():
|
for client_id, balance in active_balances.items():
|
||||||
proportion = balance / total_balance
|
proportion = balance / total_balance
|
||||||
exact_share = base_amount_sats * proportion
|
exact_share = base_sats_decimal * proportion
|
||||||
allocated_sats = round(exact_share)
|
# Round to nearest integer using ROUND_HALF_UP
|
||||||
|
allocated_sats = int(exact_share.quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
||||||
|
|
||||||
client_calculations.append({
|
client_calculations.append({
|
||||||
'client_id': client_id,
|
'client_id': client_id,
|
||||||
|
|
@ -109,8 +142,9 @@ def calculate_distribution(
|
||||||
|
|
||||||
if remainder != 0:
|
if remainder != 0:
|
||||||
# Sort by largest fractional remainder to distribute fairly
|
# Sort by largest fractional remainder to distribute fairly
|
||||||
|
# The fractional part is exact_share - allocated_sats
|
||||||
client_calculations.sort(
|
client_calculations.sort(
|
||||||
key=lambda x: x['exact_share'] - x['allocated_sats'],
|
key=lambda x: x['exact_share'] - Decimal(x['allocated_sats']),
|
||||||
reverse=True
|
reverse=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -131,7 +165,7 @@ def calculate_distribution(
|
||||||
return distributions
|
return distributions
|
||||||
|
|
||||||
|
|
||||||
def calculate_exchange_rate(base_crypto_atoms: int, fiat_amount: float) -> float:
|
def calculate_exchange_rate(base_crypto_atoms: int, fiat_amount: DecimalLike) -> Decimal:
|
||||||
"""
|
"""
|
||||||
Calculate exchange rate in sats per fiat unit.
|
Calculate exchange rate in sats per fiat unit.
|
||||||
|
|
||||||
|
|
@ -140,8 +174,9 @@ def calculate_exchange_rate(base_crypto_atoms: int, fiat_amount: float) -> float
|
||||||
fiat_amount: Fiat amount dispensed
|
fiat_amount: Fiat amount dispensed
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Exchange rate as sats per fiat unit
|
Exchange rate as sats per fiat unit (Decimal for precision)
|
||||||
"""
|
"""
|
||||||
if fiat_amount <= 0:
|
fiat = to_decimal(fiat_amount)
|
||||||
return 0.0
|
if fiat <= 0:
|
||||||
return base_crypto_atoms / fiat_amount
|
return Decimal("0")
|
||||||
|
return Decimal(base_crypto_atoms) / fiat
|
||||||
|
|
|
||||||
91
models.py
91
models.py
|
|
@ -1,6 +1,7 @@
|
||||||
# Description: Pydantic data models dictate what is passed between frontend and backend.
|
# Description: Pydantic data models dictate what is passed between frontend and backend.
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, validator
|
from pydantic import BaseModel, validator
|
||||||
|
|
@ -12,7 +13,7 @@ class CreateDcaClientData(BaseModel):
|
||||||
wallet_id: str
|
wallet_id: str
|
||||||
username: str
|
username: str
|
||||||
dca_mode: str = "flow" # 'flow' or 'fixed'
|
dca_mode: str = "flow" # 'flow' or 'fixed'
|
||||||
fixed_mode_daily_limit: Optional[float] = None
|
fixed_mode_daily_limit: Optional[Decimal] = None
|
||||||
|
|
||||||
|
|
||||||
class DcaClient(BaseModel):
|
class DcaClient(BaseModel):
|
||||||
|
|
@ -21,44 +22,52 @@ class DcaClient(BaseModel):
|
||||||
wallet_id: str
|
wallet_id: str
|
||||||
username: Optional[str]
|
username: Optional[str]
|
||||||
dca_mode: str
|
dca_mode: str
|
||||||
fixed_mode_daily_limit: Optional[int]
|
fixed_mode_daily_limit: Optional[Decimal]
|
||||||
status: str
|
status: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_encoders = {Decimal: lambda v: float(v)}
|
||||||
|
|
||||||
|
|
||||||
class UpdateDcaClientData(BaseModel):
|
class UpdateDcaClientData(BaseModel):
|
||||||
username: Optional[str] = None
|
username: Optional[str] = None
|
||||||
dca_mode: 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
|
status: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# Deposit Models (Now storing GTQ directly)
|
# Deposit Models (Now storing GTQ directly)
|
||||||
class CreateDepositData(BaseModel):
|
class CreateDepositData(BaseModel):
|
||||||
client_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 = "GTQ"
|
currency: str = "GTQ"
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
|
||||||
@validator('amount')
|
@validator('amount', pre=True)
|
||||||
def round_amount_to_cents(cls, v):
|
def round_amount_to_cents(cls, v):
|
||||||
"""Ensure amount is rounded to 2 decimal places for DECIMAL(10,2) storage"""
|
"""Ensure amount is rounded to 2 decimal places for DECIMAL(10,2) storage"""
|
||||||
if v is not None:
|
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
|
return v
|
||||||
|
|
||||||
|
|
||||||
class DcaDeposit(BaseModel):
|
class DcaDeposit(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
client_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
|
currency: str
|
||||||
status: str # 'pending' or 'confirmed'
|
status: str # 'pending' or 'confirmed'
|
||||||
notes: Optional[str]
|
notes: Optional[str]
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
confirmed_at: Optional[datetime]
|
confirmed_at: Optional[datetime]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_encoders = {Decimal: lambda v: float(v)}
|
||||||
|
|
||||||
|
|
||||||
class UpdateDepositStatusData(BaseModel):
|
class UpdateDepositStatusData(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
|
|
@ -69,8 +78,8 @@ class UpdateDepositStatusData(BaseModel):
|
||||||
class CreateDcaPaymentData(BaseModel):
|
class CreateDcaPaymentData(BaseModel):
|
||||||
client_id: str
|
client_id: str
|
||||||
amount_sats: int
|
amount_sats: int
|
||||||
amount_fiat: float # Amount in GTQ (e.g., 150.75)
|
amount_fiat: Decimal # Amount in GTQ (e.g., 150.75)
|
||||||
exchange_rate: float
|
exchange_rate: Decimal
|
||||||
transaction_type: str # 'flow', 'fixed', 'manual', 'commission'
|
transaction_type: str # 'flow', 'fixed', 'manual', 'commission'
|
||||||
lamassu_transaction_id: Optional[str] = None
|
lamassu_transaction_id: Optional[str] = None
|
||||||
payment_hash: Optional[str] = None
|
payment_hash: Optional[str] = None
|
||||||
|
|
@ -81,8 +90,8 @@ class DcaPayment(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
client_id: str
|
client_id: str
|
||||||
amount_sats: int
|
amount_sats: int
|
||||||
amount_fiat: float # Amount in GTQ (e.g., 150.75)
|
amount_fiat: Decimal # Amount in GTQ (e.g., 150.75)
|
||||||
exchange_rate: float
|
exchange_rate: Decimal
|
||||||
transaction_type: str
|
transaction_type: str
|
||||||
lamassu_transaction_id: Optional[str]
|
lamassu_transaction_id: Optional[str]
|
||||||
payment_hash: Optional[str]
|
payment_hash: Optional[str]
|
||||||
|
|
@ -90,38 +99,47 @@ class DcaPayment(BaseModel):
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
transaction_time: Optional[datetime] = None # Original ATM transaction time
|
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)
|
# Client Balance Summary (Now storing GTQ directly)
|
||||||
class ClientBalanceSummary(BaseModel):
|
class ClientBalanceSummary(BaseModel):
|
||||||
client_id: str
|
client_id: str
|
||||||
total_deposits: float # Total confirmed deposits in GTQ
|
total_deposits: Decimal # Total confirmed deposits in GTQ
|
||||||
total_payments: float # Total payments made in GTQ
|
total_payments: Decimal # Total payments made in GTQ
|
||||||
remaining_balance: float # Available balance for DCA in GTQ
|
remaining_balance: Decimal # Available balance for DCA in GTQ
|
||||||
currency: str
|
currency: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_encoders = {Decimal: lambda v: float(v)}
|
||||||
|
|
||||||
|
|
||||||
# Transaction Processing Models
|
# Transaction Processing Models
|
||||||
class LamassuTransaction(BaseModel):
|
class LamassuTransaction(BaseModel):
|
||||||
transaction_id: str
|
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
|
amount_crypto: int
|
||||||
exchange_rate: float
|
exchange_rate: Decimal
|
||||||
transaction_type: str # 'cash_in' or 'cash_out'
|
transaction_type: str # 'cash_in' or 'cash_out'
|
||||||
status: str
|
status: str
|
||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_encoders = {Decimal: lambda v: float(v)}
|
||||||
|
|
||||||
|
|
||||||
# Lamassu Transaction Storage Models
|
# Lamassu Transaction Storage Models
|
||||||
class CreateLamassuTransactionData(BaseModel):
|
class CreateLamassuTransactionData(BaseModel):
|
||||||
lamassu_transaction_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
|
crypto_amount: int
|
||||||
commission_percentage: float
|
commission_percentage: Decimal
|
||||||
discount: float = 0.0
|
discount: Decimal = Decimal("0")
|
||||||
effective_commission: float
|
effective_commission: Decimal
|
||||||
commission_amount_sats: int
|
commission_amount_sats: int
|
||||||
base_amount_sats: int
|
base_amount_sats: int
|
||||||
exchange_rate: float
|
exchange_rate: Decimal
|
||||||
crypto_code: str = "BTC"
|
crypto_code: str = "BTC"
|
||||||
fiat_code: str = "GTQ"
|
fiat_code: str = "GTQ"
|
||||||
device_id: Optional[str] = None
|
device_id: Optional[str] = None
|
||||||
|
|
@ -131,14 +149,14 @@ class CreateLamassuTransactionData(BaseModel):
|
||||||
class StoredLamassuTransaction(BaseModel):
|
class StoredLamassuTransaction(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
lamassu_transaction_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
|
crypto_amount: int
|
||||||
commission_percentage: float
|
commission_percentage: Decimal
|
||||||
discount: float
|
discount: Decimal
|
||||||
effective_commission: float
|
effective_commission: Decimal
|
||||||
commission_amount_sats: int
|
commission_amount_sats: int
|
||||||
base_amount_sats: int
|
base_amount_sats: int
|
||||||
exchange_rate: float
|
exchange_rate: Decimal
|
||||||
crypto_code: str
|
crypto_code: str
|
||||||
fiat_code: str
|
fiat_code: str
|
||||||
device_id: Optional[str]
|
device_id: Optional[str]
|
||||||
|
|
@ -147,6 +165,9 @@ class StoredLamassuTransaction(BaseModel):
|
||||||
clients_count: int # Number of clients who received distributions
|
clients_count: int # Number of clients who received distributions
|
||||||
distributions_total_sats: int # Total sats distributed to clients
|
distributions_total_sats: int # Total sats distributed to clients
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_encoders = {Decimal: lambda v: float(v)}
|
||||||
|
|
||||||
|
|
||||||
# Lamassu Configuration Models
|
# Lamassu Configuration Models
|
||||||
class CreateLamassuConfigData(BaseModel):
|
class CreateLamassuConfigData(BaseModel):
|
||||||
|
|
@ -167,13 +188,14 @@ class CreateLamassuConfigData(BaseModel):
|
||||||
ssh_password: Optional[str] = None
|
ssh_password: Optional[str] = None
|
||||||
ssh_private_key: Optional[str] = None # Path to private key file or key content
|
ssh_private_key: Optional[str] = None # Path to private key file or key content
|
||||||
# DCA Client Limits
|
# 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
|
||||||
|
|
||||||
@validator('max_daily_limit_gtq')
|
@validator('max_daily_limit_gtq', pre=True)
|
||||||
def round_max_daily_limit(cls, v):
|
def round_max_daily_limit(cls, v):
|
||||||
"""Ensure max daily limit is rounded to 2 decimal places"""
|
"""Ensure max daily limit is rounded to 2 decimal places"""
|
||||||
if v is not None:
|
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
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -204,7 +226,10 @@ class LamassuConfig(BaseModel):
|
||||||
last_poll_time: Optional[datetime] = None
|
last_poll_time: Optional[datetime] = None
|
||||||
last_successful_poll: Optional[datetime] = None
|
last_successful_poll: Optional[datetime] = None
|
||||||
# DCA Client Limits
|
# 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):
|
class UpdateLamassuConfigData(BaseModel):
|
||||||
|
|
@ -226,6 +251,6 @@ class UpdateLamassuConfigData(BaseModel):
|
||||||
ssh_password: Optional[str] = None
|
ssh_password: Optional[str] = None
|
||||||
ssh_private_key: Optional[str] = None
|
ssh_private_key: Optional[str] = None
|
||||||
# DCA Client Limits
|
# DCA Client Limits
|
||||||
max_daily_limit_gtq: Optional[int] = None
|
max_daily_limit_gtq: Optional[Decimal] = None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from decimal import Decimal
|
||||||
from typing import Dict, List, Tuple
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
# Import from the parent package (following lnurlp pattern)
|
# Import from the parent package (following lnurlp pattern)
|
||||||
from ..calculations import calculate_commission, calculate_distribution, calculate_exchange_rate
|
from ..calculations import calculate_commission, calculate_distribution, calculate_exchange_rate, to_decimal
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -245,11 +245,12 @@ class TestDistributionCalculation:
|
||||||
|
|
||||||
# Convert each client's sats back to fiat
|
# Convert each client's sats back to fiat
|
||||||
total_fiat_distributed = sum(
|
total_fiat_distributed = sum(
|
||||||
sats / exchange_rate for sats in distributions.values()
|
Decimal(sats) / exchange_rate for sats in distributions.values()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should equal original fiat amount (within small rounding tolerance)
|
# Should equal original fiat amount (within small rounding tolerance)
|
||||||
assert abs(total_fiat_distributed - fiat_amount) < 0.01, \
|
fiat_decimal = to_decimal(fiat_amount)
|
||||||
|
assert abs(total_fiat_distributed - fiat_decimal) < Decimal("0.01"), \
|
||||||
f"Fiat round-trip failed: {total_fiat_distributed:.2f} != {fiat_amount:.2f} " \
|
f"Fiat round-trip failed: {total_fiat_distributed:.2f} != {fiat_amount:.2f} " \
|
||||||
f"(crypto={crypto_atoms}, comm={comm_pct}, discount={discount})"
|
f"(crypto={crypto_atoms}, comm={comm_pct}, discount={discount})"
|
||||||
|
|
||||||
|
|
@ -287,11 +288,12 @@ class TestEmpiricalTransactions:
|
||||||
"expected_base_sats": 259029,
|
"expected_base_sats": 259029,
|
||||||
"expected_commission_sats": 7771,
|
"expected_commission_sats": 7771,
|
||||||
"expected_distributions": {
|
"expected_distributions": {
|
||||||
# 259029 / 2 = 129514.5 → both get 129514 or 129515
|
# 259029 / 2 = 129514.5 → both round to 129515 (ROUND_HALF_UP)
|
||||||
# With banker's rounding: 129514.5 → 129514 (even)
|
# Total = 259030, remainder = -1
|
||||||
# Remainder of 1 sat goes to first client by fractional sort
|
# Both have same fractional (-0.5), client_a is first alphabetically
|
||||||
"client_a": 129515,
|
# So client_a gets -1 adjustment
|
||||||
"client_b": 129514,
|
"client_a": 129514,
|
||||||
|
"client_b": 129515,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
# Add more scenarios from your real data!
|
# Add more scenarios from your real data!
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import asyncpg
|
import asyncpg
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from decimal import Decimal
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
import socket
|
import socket
|
||||||
|
|
@ -492,28 +493,38 @@ class LamassuTransactionProcessor:
|
||||||
results = []
|
results = []
|
||||||
for row in reader:
|
for row in reader:
|
||||||
# Convert string values to appropriate types
|
# Convert string values to appropriate types
|
||||||
|
# Use Decimal for monetary and percentage values
|
||||||
processed_row = {}
|
processed_row = {}
|
||||||
for key, value in row.items():
|
for key, value in row.items():
|
||||||
# Handle None/empty values consistently at data ingestion boundary
|
# Handle None/empty values consistently at data ingestion boundary
|
||||||
if value == '' or value is None:
|
if value == '' or value is None:
|
||||||
if key in ['fiat_amount', 'crypto_amount']:
|
if key == 'crypto_amount':
|
||||||
processed_row[key] = 0 # Default numeric fields to 0
|
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']:
|
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:
|
else:
|
||||||
processed_row[key] = None # Keep None for non-numeric fields
|
processed_row[key] = None # Keep None for non-numeric fields
|
||||||
elif key in ['transaction_id', 'device_id', 'crypto_code', 'fiat_code']:
|
elif key in ['transaction_id', 'device_id', 'crypto_code', 'fiat_code']:
|
||||||
processed_row[key] = str(value)
|
processed_row[key] = str(value)
|
||||||
elif key in ['fiat_amount', 'crypto_amount']:
|
elif key == 'crypto_amount':
|
||||||
try:
|
try:
|
||||||
processed_row[key] = int(float(value))
|
processed_row[key] = int(float(value)) # Sats are always int
|
||||||
except (ValueError, TypeError):
|
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']:
|
elif key in ['commission_percentage', 'discount']:
|
||||||
try:
|
try:
|
||||||
processed_row[key] = float(value)
|
# Convert via string to avoid float precision issues
|
||||||
|
processed_row[key] = Decimal(str(value))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
processed_row[key] = 0.0 # Fallback to 0.0 for invalid values
|
processed_row[key] = Decimal("0")
|
||||||
elif key == 'transaction_time':
|
elif key == 'transaction_time':
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
# Parse PostgreSQL timestamp format and ensure it's in UTC for consistency
|
# 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")
|
logger.info("No Flow Mode clients found - skipping distribution")
|
||||||
return {}
|
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
|
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)
|
fiat_amount = transaction.get("fiat_amount", Decimal("0")) # Actual fiat dispensed (principal only)
|
||||||
commission_percentage = transaction.get("commission_percentage", 0.0) # Already stored as decimal (e.g., 0.045)
|
commission_percentage = transaction.get("commission_percentage", Decimal("0")) # Already stored as Decimal (e.g., 0.045)
|
||||||
discount = transaction.get("discount", 0.0) # Discount percentage
|
discount = transaction.get("discount", Decimal("0")) # Discount percentage
|
||||||
transaction_time = transaction.get("transaction_time") # ATM transaction timestamp for temporal accuracy
|
transaction_time = transaction.get("transaction_time") # ATM transaction timestamp for temporal accuracy
|
||||||
|
|
||||||
# Normalize transaction_time to UTC if present
|
# Normalize transaction_time to UTC if present
|
||||||
if transaction_time is not None:
|
if transaction_time is not None:
|
||||||
if transaction_time.tzinfo is None:
|
if transaction_time.tzinfo is None:
|
||||||
|
|
@ -697,7 +708,7 @@ class LamassuTransactionProcessor:
|
||||||
original_tz = transaction_time.tzinfo
|
original_tz = transaction_time.tzinfo
|
||||||
transaction_time = transaction_time.astimezone(timezone.utc)
|
transaction_time = transaction_time.astimezone(timezone.utc)
|
||||||
logger.info(f"Converted transaction time from {original_tz} to UTC")
|
logger.info(f"Converted transaction time from {original_tz} to UTC")
|
||||||
|
|
||||||
# Validate required fields
|
# Validate required fields
|
||||||
if crypto_atoms is None:
|
if crypto_atoms is None:
|
||||||
logger.error(f"Missing crypto_amount in transaction: {transaction}")
|
logger.error(f"Missing crypto_amount in transaction: {transaction}")
|
||||||
|
|
@ -707,10 +718,10 @@ class LamassuTransactionProcessor:
|
||||||
return {}
|
return {}
|
||||||
if commission_percentage is None:
|
if commission_percentage is None:
|
||||||
logger.warning(f"Missing commission_percentage in transaction: {transaction}, defaulting to 0")
|
logger.warning(f"Missing commission_percentage in transaction: {transaction}, defaulting to 0")
|
||||||
commission_percentage = 0.0
|
commission_percentage = Decimal("0")
|
||||||
if discount is None:
|
if discount is None:
|
||||||
logger.info(f"Missing discount in transaction: {transaction}, defaulting to 0")
|
logger.info(f"Missing discount in transaction: {transaction}, defaulting to 0")
|
||||||
discount = 0.0
|
discount = Decimal("0")
|
||||||
if transaction_time is None:
|
if transaction_time is None:
|
||||||
logger.warning(f"Missing transaction_time in transaction: {transaction}")
|
logger.warning(f"Missing transaction_time in transaction: {transaction}")
|
||||||
# Could use current time as fallback, but this indicates a data issue
|
# 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)")
|
logger.warning("No transaction time available - using current balances (may be inaccurate)")
|
||||||
|
|
||||||
# Get balance summaries for all clients to calculate proportions
|
# Get balance summaries for all clients to calculate proportions
|
||||||
client_balances = {}
|
client_balances: Dict[str, Decimal] = {}
|
||||||
total_confirmed_deposits = 0
|
total_confirmed_deposits = Decimal("0")
|
||||||
|
min_balance = Decimal("0.01")
|
||||||
|
|
||||||
for client in flow_clients:
|
for client in flow_clients:
|
||||||
# Get balance as of the transaction time for temporal accuracy
|
# Get balance as of the transaction time for temporal accuracy
|
||||||
balance = await get_client_balance_summary(client.id, as_of_time=transaction_time)
|
balance = await get_client_balance_summary(client.id, as_of_time=transaction_time)
|
||||||
# Only include clients with positive remaining balance
|
# Only include clients with positive remaining balance
|
||||||
# NOTE: This works for fiat amounts that use cents
|
# 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
|
client_balances[client.id] = balance.remaining_balance
|
||||||
total_confirmed_deposits += balance.remaining_balance
|
total_confirmed_deposits += balance.remaining_balance
|
||||||
logger.debug(f"Client {client.id[:8]}... included with balance: {balance.remaining_balance:.2f} GTQ")
|
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
|
proportion = client_balances[client_id] / total_confirmed_deposits
|
||||||
|
|
||||||
# Calculate equivalent fiat value in GTQ for tracking purposes
|
# 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] = {
|
distributions[client_id] = {
|
||||||
"fiat_amount": client_fiat_amount,
|
"fiat_amount": client_fiat_amount,
|
||||||
|
|
@ -1003,20 +1018,20 @@ class LamassuTransactionProcessor:
|
||||||
async def store_lamassu_transaction(self, transaction: Dict[str, Any]) -> Optional[str]:
|
async def store_lamassu_transaction(self, transaction: Dict[str, Any]) -> Optional[str]:
|
||||||
"""Store the Lamassu transaction in our database for audit and UI"""
|
"""Store the Lamassu transaction in our database for audit and UI"""
|
||||||
try:
|
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)
|
crypto_atoms = transaction.get("crypto_amount", 0)
|
||||||
fiat_amount = transaction.get("fiat_amount", 0)
|
fiat_amount = transaction.get("fiat_amount", Decimal("0"))
|
||||||
commission_percentage = transaction.get("commission_percentage", 0.0)
|
commission_percentage = transaction.get("commission_percentage", Decimal("0"))
|
||||||
discount = transaction.get("discount", 0.0)
|
discount = transaction.get("discount", Decimal("0"))
|
||||||
transaction_time = transaction.get("transaction_time")
|
transaction_time = transaction.get("transaction_time")
|
||||||
|
|
||||||
# Normalize transaction_time to UTC if present
|
# Normalize transaction_time to UTC if present
|
||||||
if transaction_time is not None:
|
if transaction_time is not None:
|
||||||
if transaction_time.tzinfo is None:
|
if transaction_time.tzinfo is None:
|
||||||
transaction_time = transaction_time.replace(tzinfo=timezone.utc)
|
transaction_time = transaction_time.replace(tzinfo=timezone.utc)
|
||||||
elif transaction_time.tzinfo != timezone.utc:
|
elif transaction_time.tzinfo != timezone.utc:
|
||||||
transaction_time = transaction_time.astimezone(timezone.utc)
|
transaction_time = transaction_time.astimezone(timezone.utc)
|
||||||
|
|
||||||
# Calculate commission metrics using the extracted pure function
|
# Calculate commission metrics using the extracted pure function
|
||||||
base_crypto_atoms, commission_amount_sats, effective_commission = calculate_commission(
|
base_crypto_atoms, commission_amount_sats, effective_commission = calculate_commission(
|
||||||
crypto_atoms, commission_percentage, discount
|
crypto_atoms, commission_percentage, discount
|
||||||
|
|
@ -1024,11 +1039,13 @@ class LamassuTransactionProcessor:
|
||||||
|
|
||||||
# Calculate exchange rate
|
# Calculate exchange rate
|
||||||
exchange_rate = calculate_exchange_rate(base_crypto_atoms, fiat_amount)
|
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(
|
transaction_data = CreateLamassuTransactionData(
|
||||||
lamassu_transaction_id=transaction["transaction_id"],
|
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,
|
crypto_amount=crypto_atoms,
|
||||||
commission_percentage=commission_percentage,
|
commission_percentage=commission_percentage,
|
||||||
discount=discount,
|
discount=discount,
|
||||||
|
|
@ -1148,9 +1165,9 @@ class LamassuTransactionProcessor:
|
||||||
|
|
||||||
# Calculate commission amount for sending to commission wallet
|
# Calculate commission amount for sending to commission wallet
|
||||||
crypto_atoms = transaction.get("crypto_amount", 0)
|
crypto_atoms = transaction.get("crypto_amount", 0)
|
||||||
commission_percentage = transaction.get("commission_percentage", 0.0)
|
commission_percentage = transaction.get("commission_percentage", Decimal("0"))
|
||||||
discount = transaction.get("discount", 0.0)
|
discount = transaction.get("discount", Decimal("0"))
|
||||||
|
|
||||||
# Calculate commission amount using the extracted pure function
|
# Calculate commission amount using the extracted pure function
|
||||||
_, commission_amount_sats, _ = calculate_commission(
|
_, commission_amount_sats, _ = calculate_commission(
|
||||||
crypto_atoms, commission_percentage, discount
|
crypto_atoms, commission_percentage, discount
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue