Compare commits
3 commits
backup/dec
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6eb076d5f6 | |||
| 545a0284a7 | |||
| 49f3670bac |
7 changed files with 220 additions and 1131 deletions
|
|
@ -3,34 +3,16 @@ 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 decimal import Decimal, ROUND_HALF_UP
|
from typing import Dict, Tuple
|
||||||
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: DecimalLike,
|
commission_percentage: float,
|
||||||
discount: DecimalLike = Decimal("0")
|
discount: float = 0.0
|
||||||
) -> Tuple[int, int, Decimal]:
|
) -> Tuple[int, int, float]:
|
||||||
"""
|
"""
|
||||||
Calculate commission split from a Lamassu transaction.
|
Calculate commission split from a Lamassu transaction.
|
||||||
|
|
||||||
|
|
@ -52,25 +34,15 @@ 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, Decimal("0.03"), Decimal("0"))
|
>>> calculate_commission(266800, 0.03, 0.0)
|
||||||
(259029, 7771, Decimal('0.03'))
|
(259029, 7771, 0.03)
|
||||||
"""
|
"""
|
||||||
# Convert inputs to Decimal for precise calculations
|
if commission_percentage > 0:
|
||||||
comm_pct = to_decimal(commission_percentage)
|
effective_commission = commission_percentage * (100 - discount) / 100
|
||||||
disc = to_decimal(discount)
|
base_crypto_atoms = round(crypto_atoms / (1 + effective_commission))
|
||||||
|
|
||||||
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 = Decimal("0")
|
effective_commission = 0.0
|
||||||
base_crypto_atoms = crypto_atoms
|
base_crypto_atoms = crypto_atoms
|
||||||
commission_amount_sats = 0
|
commission_amount_sats = 0
|
||||||
|
|
||||||
|
|
@ -79,8 +51,8 @@ def calculate_commission(
|
||||||
|
|
||||||
def calculate_distribution(
|
def calculate_distribution(
|
||||||
base_amount_sats: int,
|
base_amount_sats: int,
|
||||||
client_balances: Dict[str, DecimalLike],
|
client_balances: Dict[str, float],
|
||||||
min_balance_threshold: DecimalLike = Decimal("0.01")
|
min_balance_threshold: float = 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.
|
||||||
|
|
@ -97,18 +69,15 @@ def calculate_distribution(
|
||||||
Dict of {client_id: allocated_sats}
|
Dict of {client_id: allocated_sats}
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
>>> calculate_distribution(100000, {"a": Decimal("500"), "b": Decimal("500")})
|
>>> calculate_distribution(100000, {"a": 500.0, "b": 500.0})
|
||||||
{"a": 50000, "b": 50000}
|
{"a": 50000, "b": 50000}
|
||||||
"""
|
"""
|
||||||
# Convert threshold to Decimal
|
# Filter out clients with balance below threshold
|
||||||
threshold = to_decimal(min_balance_threshold)
|
active_balances = {
|
||||||
|
client_id: balance
|
||||||
# Filter out clients with balance below threshold, converting to Decimal
|
for client_id, balance in client_balances.items()
|
||||||
active_balances: Dict[str, Decimal] = {}
|
if balance >= min_balance_threshold
|
||||||
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 {}
|
||||||
|
|
@ -121,13 +90,11 @@ 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_sats_decimal * proportion
|
exact_share = base_amount_sats * proportion
|
||||||
# Round to nearest integer using ROUND_HALF_UP
|
allocated_sats = round(exact_share)
|
||||||
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,
|
||||||
|
|
@ -142,9 +109,8 @@ 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'] - Decimal(x['allocated_sats']),
|
key=lambda x: x['exact_share'] - x['allocated_sats'],
|
||||||
reverse=True
|
reverse=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -165,7 +131,7 @@ def calculate_distribution(
|
||||||
return distributions
|
return distributions
|
||||||
|
|
||||||
|
|
||||||
def calculate_exchange_rate(base_crypto_atoms: int, fiat_amount: DecimalLike) -> Decimal:
|
def calculate_exchange_rate(base_crypto_atoms: int, fiat_amount: float) -> float:
|
||||||
"""
|
"""
|
||||||
Calculate exchange rate in sats per fiat unit.
|
Calculate exchange rate in sats per fiat unit.
|
||||||
|
|
||||||
|
|
@ -174,9 +140,8 @@ def calculate_exchange_rate(base_crypto_atoms: int, fiat_amount: DecimalLike) ->
|
||||||
fiat_amount: Fiat amount dispensed
|
fiat_amount: Fiat amount dispensed
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Exchange rate as sats per fiat unit (Decimal for precision)
|
Exchange rate as sats per fiat unit
|
||||||
"""
|
"""
|
||||||
fiat = to_decimal(fiat_amount)
|
if fiat_amount <= 0:
|
||||||
if fiat <= 0:
|
return 0.0
|
||||||
return Decimal("0")
|
return base_crypto_atoms / fiat_amount
|
||||||
return Decimal(base_crypto_atoms) / fiat
|
|
||||||
|
|
|
||||||
42
crud.py
42
crud.py
|
|
@ -1,6 +1,5 @@
|
||||||
# Description: This file contains the CRUD operations for talking to the database.
|
# Description: This file contains the CRUD operations for talking to the database.
|
||||||
|
|
||||||
from decimal import Decimal
|
|
||||||
from typing import List, Optional, Union
|
from typing import List, Optional, Union
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
@ -19,23 +18,6 @@ from .models import (
|
||||||
db = Database("ext_satoshimachine")
|
db = Database("ext_satoshimachine")
|
||||||
|
|
||||||
|
|
||||||
def prepare_for_db(values: dict) -> dict:
|
|
||||||
"""
|
|
||||||
Convert Decimal values to float for SQLite compatibility.
|
|
||||||
|
|
||||||
SQLite doesn't support Decimal natively - it stores DECIMAL as REAL (float).
|
|
||||||
This function converts Decimal values to float before database writes.
|
|
||||||
The Pydantic models handle converting float back to Decimal on read.
|
|
||||||
"""
|
|
||||||
result = {}
|
|
||||||
for k, v in values.items():
|
|
||||||
if isinstance(v, Decimal):
|
|
||||||
result[k] = float(v)
|
|
||||||
else:
|
|
||||||
result[k] = v
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# DCA Client CRUD Operations
|
# DCA Client CRUD Operations
|
||||||
async def create_dca_client(data: CreateDcaClientData) -> DcaClient:
|
async def create_dca_client(data: CreateDcaClientData) -> DcaClient:
|
||||||
client_id = urlsafe_short_hash()
|
client_id = urlsafe_short_hash()
|
||||||
|
|
@ -45,7 +27,7 @@ async def create_dca_client(data: CreateDcaClientData) -> DcaClient:
|
||||||
(id, user_id, wallet_id, username, dca_mode, fixed_mode_daily_limit, status, created_at, updated_at)
|
(id, user_id, wallet_id, username, dca_mode, fixed_mode_daily_limit, status, created_at, updated_at)
|
||||||
VALUES (:id, :user_id, :wallet_id, :username, :dca_mode, :fixed_mode_daily_limit, :status, :created_at, :updated_at)
|
VALUES (:id, :user_id, :wallet_id, :username, :dca_mode, :fixed_mode_daily_limit, :status, :created_at, :updated_at)
|
||||||
""",
|
""",
|
||||||
prepare_for_db({
|
{
|
||||||
"id": client_id,
|
"id": client_id,
|
||||||
"user_id": data.user_id,
|
"user_id": data.user_id,
|
||||||
"wallet_id": data.wallet_id,
|
"wallet_id": data.wallet_id,
|
||||||
|
|
@ -55,7 +37,7 @@ async def create_dca_client(data: CreateDcaClientData) -> DcaClient:
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"created_at": datetime.now(),
|
"created_at": datetime.now(),
|
||||||
"updated_at": datetime.now()
|
"updated_at": datetime.now()
|
||||||
})
|
}
|
||||||
)
|
)
|
||||||
return await get_dca_client(client_id)
|
return await get_dca_client(client_id)
|
||||||
|
|
||||||
|
|
@ -94,7 +76,7 @@ async def update_dca_client(client_id: str, data: UpdateDcaClientData) -> Option
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
f"UPDATE satoshimachine.dca_clients SET {set_clause} WHERE id = :id",
|
f"UPDATE satoshimachine.dca_clients SET {set_clause} WHERE id = :id",
|
||||||
prepare_for_db(update_data)
|
update_data
|
||||||
)
|
)
|
||||||
return await get_dca_client(client_id)
|
return await get_dca_client(client_id)
|
||||||
|
|
||||||
|
|
@ -115,7 +97,7 @@ async def create_deposit(data: CreateDepositData) -> DcaDeposit:
|
||||||
(id, client_id, amount, currency, status, notes, created_at)
|
(id, client_id, amount, currency, status, notes, created_at)
|
||||||
VALUES (:id, :client_id, :amount, :currency, :status, :notes, :created_at)
|
VALUES (:id, :client_id, :amount, :currency, :status, :notes, :created_at)
|
||||||
""",
|
""",
|
||||||
prepare_for_db({
|
{
|
||||||
"id": deposit_id,
|
"id": deposit_id,
|
||||||
"client_id": data.client_id,
|
"client_id": data.client_id,
|
||||||
"amount": data.amount,
|
"amount": data.amount,
|
||||||
|
|
@ -123,7 +105,7 @@ async def create_deposit(data: CreateDepositData) -> DcaDeposit:
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"notes": data.notes,
|
"notes": data.notes,
|
||||||
"created_at": datetime.now()
|
"created_at": datetime.now()
|
||||||
})
|
}
|
||||||
)
|
)
|
||||||
return await get_deposit(deposit_id)
|
return await get_deposit(deposit_id)
|
||||||
|
|
||||||
|
|
@ -182,7 +164,7 @@ async def create_dca_payment(data: CreateDcaPaymentData) -> DcaPayment:
|
||||||
VALUES (:id, :client_id, :amount_sats, :amount_fiat, :exchange_rate, :transaction_type,
|
VALUES (:id, :client_id, :amount_sats, :amount_fiat, :exchange_rate, :transaction_type,
|
||||||
:lamassu_transaction_id, :payment_hash, :status, :created_at, :transaction_time)
|
:lamassu_transaction_id, :payment_hash, :status, :created_at, :transaction_time)
|
||||||
""",
|
""",
|
||||||
prepare_for_db({
|
{
|
||||||
"id": payment_id,
|
"id": payment_id,
|
||||||
"client_id": data.client_id,
|
"client_id": data.client_id,
|
||||||
"amount_sats": data.amount_sats,
|
"amount_sats": data.amount_sats,
|
||||||
|
|
@ -194,7 +176,7 @@ async def create_dca_payment(data: CreateDcaPaymentData) -> DcaPayment:
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"created_at": datetime.now(),
|
"created_at": datetime.now(),
|
||||||
"transaction_time": data.transaction_time
|
"transaction_time": data.transaction_time
|
||||||
})
|
}
|
||||||
)
|
)
|
||||||
return await get_dca_payment(payment_id)
|
return await get_dca_payment(payment_id)
|
||||||
|
|
||||||
|
|
@ -328,7 +310,7 @@ async def create_lamassu_config(data: CreateLamassuConfigData) -> LamassuConfig:
|
||||||
VALUES (:id, :host, :port, :database_name, :username, :password, :source_wallet_id, :commission_wallet_id, :is_active, :created_at, :updated_at,
|
VALUES (:id, :host, :port, :database_name, :username, :password, :source_wallet_id, :commission_wallet_id, :is_active, :created_at, :updated_at,
|
||||||
:use_ssh_tunnel, :ssh_host, :ssh_port, :ssh_username, :ssh_password, :ssh_private_key, :max_daily_limit_gtq)
|
:use_ssh_tunnel, :ssh_host, :ssh_port, :ssh_username, :ssh_password, :ssh_private_key, :max_daily_limit_gtq)
|
||||||
""",
|
""",
|
||||||
prepare_for_db({
|
{
|
||||||
"id": config_id,
|
"id": config_id,
|
||||||
"host": data.host,
|
"host": data.host,
|
||||||
"port": data.port,
|
"port": data.port,
|
||||||
|
|
@ -347,7 +329,7 @@ async def create_lamassu_config(data: CreateLamassuConfigData) -> LamassuConfig:
|
||||||
"ssh_password": data.ssh_password,
|
"ssh_password": data.ssh_password,
|
||||||
"ssh_private_key": data.ssh_private_key,
|
"ssh_private_key": data.ssh_private_key,
|
||||||
"max_daily_limit_gtq": data.max_daily_limit_gtq
|
"max_daily_limit_gtq": data.max_daily_limit_gtq
|
||||||
})
|
}
|
||||||
)
|
)
|
||||||
return await get_lamassu_config(config_id)
|
return await get_lamassu_config(config_id)
|
||||||
|
|
||||||
|
|
@ -385,7 +367,7 @@ async def update_lamassu_config(config_id: str, data: UpdateLamassuConfigData) -
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
f"UPDATE satoshimachine.lamassu_config SET {set_clause} WHERE id = :id",
|
f"UPDATE satoshimachine.lamassu_config SET {set_clause} WHERE id = :id",
|
||||||
prepare_for_db(update_data)
|
update_data
|
||||||
)
|
)
|
||||||
return await get_lamassu_config(config_id)
|
return await get_lamassu_config(config_id)
|
||||||
|
|
||||||
|
|
@ -464,7 +446,7 @@ async def create_lamassu_transaction(data: CreateLamassuTransactionData) -> Stor
|
||||||
:exchange_rate, :crypto_code, :fiat_code, :device_id, :transaction_time, :processed_at,
|
:exchange_rate, :crypto_code, :fiat_code, :device_id, :transaction_time, :processed_at,
|
||||||
:clients_count, :distributions_total_sats)
|
:clients_count, :distributions_total_sats)
|
||||||
""",
|
""",
|
||||||
prepare_for_db({
|
{
|
||||||
"id": transaction_id,
|
"id": transaction_id,
|
||||||
"lamassu_transaction_id": data.lamassu_transaction_id,
|
"lamassu_transaction_id": data.lamassu_transaction_id,
|
||||||
"fiat_amount": data.fiat_amount,
|
"fiat_amount": data.fiat_amount,
|
||||||
|
|
@ -482,7 +464,7 @@ async def create_lamassu_transaction(data: CreateLamassuTransactionData) -> Stor
|
||||||
"processed_at": datetime.now(),
|
"processed_at": datetime.now(),
|
||||||
"clients_count": 0, # Will be updated after distributions
|
"clients_count": 0, # Will be updated after distributions
|
||||||
"distributions_total_sats": 0 # Will be updated after distributions
|
"distributions_total_sats": 0 # Will be updated after distributions
|
||||||
})
|
}
|
||||||
)
|
)
|
||||||
return await get_lamassu_transaction(transaction_id)
|
return await get_lamassu_transaction(transaction_id)
|
||||||
|
|
||||||
|
|
|
||||||
124
models.py
124
models.py
|
|
@ -1,29 +1,18 @@
|
||||||
# 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
|
||||||
|
|
||||||
|
|
||||||
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
|
# DCA Client Models
|
||||||
class CreateDcaClientData(BaseModel):
|
class CreateDcaClientData(BaseModel):
|
||||||
user_id: str
|
user_id: str
|
||||||
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[Decimal] = None
|
fixed_mode_daily_limit: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
class DcaClient(BaseModel):
|
class DcaClient(BaseModel):
|
||||||
|
|
@ -32,60 +21,44 @@ 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[Decimal]
|
fixed_mode_daily_limit: Optional[int]
|
||||||
status: str
|
status: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_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):
|
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[Decimal] = None
|
fixed_mode_daily_limit: Optional[float] = 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: Decimal # Amount in GTQ (e.g., 150.75)
|
amount: float # Amount in GTQ (e.g., 150.75)
|
||||||
currency: str = "GTQ"
|
currency: str = "GTQ"
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
|
||||||
@validator('amount', pre=True)
|
@validator('amount')
|
||||||
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:
|
||||||
# Convert to Decimal via string to avoid float precision issues
|
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
|
||||||
|
|
||||||
|
|
||||||
class DcaDeposit(BaseModel):
|
class DcaDeposit(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
client_id: str
|
client_id: str
|
||||||
amount: Decimal # Amount in GTQ (e.g., 150.75)
|
amount: float # 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]
|
||||||
|
|
||||||
@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):
|
class UpdateDepositStatusData(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
|
|
@ -96,8 +69,8 @@ class UpdateDepositStatusData(BaseModel):
|
||||||
class CreateDcaPaymentData(BaseModel):
|
class CreateDcaPaymentData(BaseModel):
|
||||||
client_id: str
|
client_id: str
|
||||||
amount_sats: int
|
amount_sats: int
|
||||||
amount_fiat: Decimal # Amount in GTQ (e.g., 150.75)
|
amount_fiat: float # Amount in GTQ (e.g., 150.75)
|
||||||
exchange_rate: Decimal
|
exchange_rate: float
|
||||||
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
|
||||||
|
|
@ -108,8 +81,8 @@ class DcaPayment(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
client_id: str
|
client_id: str
|
||||||
amount_sats: int
|
amount_sats: int
|
||||||
amount_fiat: Decimal # Amount in GTQ (e.g., 150.75)
|
amount_fiat: float # Amount in GTQ (e.g., 150.75)
|
||||||
exchange_rate: Decimal
|
exchange_rate: float
|
||||||
transaction_type: str
|
transaction_type: str
|
||||||
lamassu_transaction_id: Optional[str]
|
lamassu_transaction_id: Optional[str]
|
||||||
payment_hash: Optional[str]
|
payment_hash: Optional[str]
|
||||||
|
|
@ -117,55 +90,38 @@ 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
|
||||||
|
|
||||||
@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)
|
# Client Balance Summary (Now storing GTQ directly)
|
||||||
class ClientBalanceSummary(BaseModel):
|
class ClientBalanceSummary(BaseModel):
|
||||||
client_id: str
|
client_id: str
|
||||||
total_deposits: Decimal # Total confirmed deposits in GTQ
|
total_deposits: float # Total confirmed deposits in GTQ
|
||||||
total_payments: Decimal # Total payments made in GTQ
|
total_payments: float # Total payments made in GTQ
|
||||||
remaining_balance: Decimal # Available balance for DCA in GTQ
|
remaining_balance: float # Available balance for DCA in GTQ
|
||||||
currency: str
|
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
|
# Transaction Processing Models
|
||||||
class LamassuTransaction(BaseModel):
|
class LamassuTransaction(BaseModel):
|
||||||
transaction_id: str
|
transaction_id: str
|
||||||
amount_fiat: Decimal # Amount in GTQ (e.g., 150.75)
|
amount_fiat: float # Amount in GTQ (e.g., 150.75)
|
||||||
amount_crypto: int
|
amount_crypto: int
|
||||||
exchange_rate: Decimal
|
exchange_rate: float
|
||||||
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: Decimal # Amount in GTQ (e.g., 150.75)
|
fiat_amount: float # Amount in GTQ (e.g., 150.75)
|
||||||
crypto_amount: int
|
crypto_amount: int
|
||||||
commission_percentage: Decimal
|
commission_percentage: float
|
||||||
discount: Decimal = Decimal("0")
|
discount: float = 0.0
|
||||||
effective_commission: Decimal
|
effective_commission: float
|
||||||
commission_amount_sats: int
|
commission_amount_sats: int
|
||||||
base_amount_sats: int
|
base_amount_sats: int
|
||||||
exchange_rate: Decimal
|
exchange_rate: float
|
||||||
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
|
||||||
|
|
@ -175,14 +131,14 @@ class CreateLamassuTransactionData(BaseModel):
|
||||||
class StoredLamassuTransaction(BaseModel):
|
class StoredLamassuTransaction(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
lamassu_transaction_id: str
|
lamassu_transaction_id: str
|
||||||
fiat_amount: Decimal # Amount in GTQ (e.g., 150.75)
|
fiat_amount: float # Amount in GTQ (e.g., 150.75)
|
||||||
crypto_amount: int
|
crypto_amount: int
|
||||||
commission_percentage: Decimal
|
commission_percentage: float
|
||||||
discount: Decimal
|
discount: float
|
||||||
effective_commission: Decimal
|
effective_commission: float
|
||||||
commission_amount_sats: int
|
commission_amount_sats: int
|
||||||
base_amount_sats: int
|
base_amount_sats: int
|
||||||
exchange_rate: Decimal
|
exchange_rate: float
|
||||||
crypto_code: str
|
crypto_code: str
|
||||||
fiat_code: str
|
fiat_code: str
|
||||||
device_id: Optional[str]
|
device_id: Optional[str]
|
||||||
|
|
@ -191,16 +147,6 @@ 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
|
||||||
|
|
||||||
@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
|
# Lamassu Configuration Models
|
||||||
class CreateLamassuConfigData(BaseModel):
|
class CreateLamassuConfigData(BaseModel):
|
||||||
|
|
@ -221,14 +167,13 @@ 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: Decimal = Decimal("2000") # Maximum daily limit for Fixed mode clients
|
max_daily_limit_gtq: float = 2000.0 # Maximum daily limit for Fixed mode clients
|
||||||
|
|
||||||
@validator('max_daily_limit_gtq', pre=True)
|
@validator('max_daily_limit_gtq')
|
||||||
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:
|
||||||
d = Decimal(str(v)) if not isinstance(v, Decimal) else v
|
return round(float(v), 2)
|
||||||
return d.quantize(Decimal("0.01"))
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -259,14 +204,7 @@ 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: Decimal = Decimal("2000") # Maximum daily limit for Fixed mode clients
|
max_daily_limit_gtq: float = 2000.0 # 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):
|
class UpdateLamassuConfigData(BaseModel):
|
||||||
|
|
@ -288,6 +226,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[Decimal] = None
|
max_daily_limit_gtq: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "satmachineadmin"
|
name = "satmachineadmin"
|
||||||
version = "0.0.0"
|
version = "0.0.4"
|
||||||
description = "Eightball is a simple API that allows you to create a random number generator."
|
description = "Eightball is a simple API that allows you to create a random number generator."
|
||||||
authors = ["benarc", "dni <dni@lnbits.com>"]
|
authors = ["benarc", "dni <dni@lnbits.com>"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, to_decimal
|
from ..calculations import calculate_commission, calculate_distribution, calculate_exchange_rate
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -245,12 +245,11 @@ 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(
|
||||||
Decimal(sats) / exchange_rate for sats in distributions.values()
|
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)
|
||||||
fiat_decimal = to_decimal(fiat_amount)
|
assert abs(total_fiat_distributed - fiat_amount) < 0.01, \
|
||||||
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})"
|
||||||
|
|
||||||
|
|
@ -288,12 +287,11 @@ 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 round to 129515 (ROUND_HALF_UP)
|
# 259029 / 2 = 129514.5 → both get 129514 or 129515
|
||||||
# Total = 259030, remainder = -1
|
# With banker's rounding: 129514.5 → 129514 (even)
|
||||||
# Both have same fractional (-0.5), client_a is first alphabetically
|
# Remainder of 1 sat goes to first client by fractional sort
|
||||||
# So client_a gets -1 adjustment
|
"client_a": 129515,
|
||||||
"client_a": 129514,
|
"client_b": 129514,
|
||||||
"client_b": 129515,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
# Add more scenarios from your real data!
|
# Add more scenarios from your real data!
|
||||||
|
|
|
||||||
|
|
@ -1,828 +0,0 @@
|
||||||
"""
|
|
||||||
Integration tests for the full transaction processing flow.
|
|
||||||
|
|
||||||
These tests verify that data flows correctly from:
|
|
||||||
CSV parsing → Decimal conversion → calculations → model creation → CRUD
|
|
||||||
|
|
||||||
This gives us confidence that real Lamassu transactions will be
|
|
||||||
processed correctly end-to-end.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from decimal import Decimal
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
from typing import Dict, Any
|
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
|
|
||||||
from ..calculations import calculate_commission, calculate_distribution, calculate_exchange_rate
|
|
||||||
from ..models import (
|
|
||||||
CreateDcaPaymentData,
|
|
||||||
CreateLamassuTransactionData,
|
|
||||||
ClientBalanceSummary,
|
|
||||||
CreateDepositData,
|
|
||||||
DcaDeposit,
|
|
||||||
DcaPayment,
|
|
||||||
StoredLamassuTransaction,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# TEST DATA: Real Lamassu CSV output format
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# This simulates what execute_ssh_query receives from the database
|
|
||||||
LAMASSU_CSV_DATA = {
|
|
||||||
"8.75pct_large": """transaction_id,fiat_amount,crypto_amount,transaction_time,device_id,status,commission_percentage,discount,crypto_code,fiat_code
|
|
||||||
abc123,2000,309200,2025-01-10 14:30:00+00,device1,confirmed,0.0875,0,BTC,GTQ""",
|
|
||||||
|
|
||||||
"5.5pct_no_discount": """transaction_id,fiat_amount,crypto_amount,transaction_time,device_id,status,commission_percentage,discount,crypto_code,fiat_code
|
|
||||||
def456,2000,309500,2025-01-10 15:00:00+00,device1,confirmed,0.055,0,BTC,GTQ""",
|
|
||||||
|
|
||||||
"5.5pct_90pct_discount": """transaction_id,fiat_amount,crypto_amount,transaction_time,device_id,status,commission_percentage,discount,crypto_code,fiat_code
|
|
||||||
ghi789,800,115000,2025-01-10 16:00:00+00,device1,confirmed,0.055,90,BTC,GTQ""",
|
|
||||||
|
|
||||||
"5.5pct_1300gtq_4clients": """transaction_id,fiat_amount,crypto_amount,transaction_time,device_id,status,commission_percentage,discount,crypto_code,fiat_code
|
|
||||||
jkl012,1300,205600,2025-01-10 17:00:00+00,device1,confirmed,0.055,0,BTC,GTQ""",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def parse_csv_like_transaction_processor(csv_data: str) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Parse CSV data exactly like transaction_processor.execute_ssh_query does.
|
|
||||||
|
|
||||||
This is a copy of the parsing logic to test it in isolation.
|
|
||||||
"""
|
|
||||||
reader = csv.DictReader(io.StringIO(csv_data))
|
|
||||||
results = []
|
|
||||||
for row in reader:
|
|
||||||
processed_row = {}
|
|
||||||
for key, value in row.items():
|
|
||||||
if value == '' or value is None:
|
|
||||||
if key == 'crypto_amount':
|
|
||||||
processed_row[key] = 0
|
|
||||||
elif key == 'fiat_amount':
|
|
||||||
processed_row[key] = Decimal("0")
|
|
||||||
elif key in ['commission_percentage', 'discount']:
|
|
||||||
processed_row[key] = Decimal("0")
|
|
||||||
else:
|
|
||||||
processed_row[key] = None
|
|
||||||
elif key in ['transaction_id', 'device_id', 'crypto_code', 'fiat_code', 'status']:
|
|
||||||
processed_row[key] = str(value)
|
|
||||||
elif key == 'crypto_amount':
|
|
||||||
try:
|
|
||||||
processed_row[key] = int(float(value))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
processed_row[key] = 0
|
|
||||||
elif key == 'fiat_amount':
|
|
||||||
try:
|
|
||||||
processed_row[key] = Decimal(str(value))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
processed_row[key] = Decimal("0")
|
|
||||||
elif key in ['commission_percentage', 'discount']:
|
|
||||||
try:
|
|
||||||
processed_row[key] = Decimal(str(value))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
processed_row[key] = Decimal("0")
|
|
||||||
elif key == 'transaction_time':
|
|
||||||
timestamp_str = value
|
|
||||||
if timestamp_str.endswith('+00'):
|
|
||||||
timestamp_str = timestamp_str + ':00'
|
|
||||||
elif timestamp_str.endswith('Z'):
|
|
||||||
timestamp_str = timestamp_str.replace('Z', '+00:00')
|
|
||||||
try:
|
|
||||||
dt = datetime.fromisoformat(timestamp_str)
|
|
||||||
except ValueError:
|
|
||||||
dt = datetime.now(timezone.utc)
|
|
||||||
if dt.tzinfo is None:
|
|
||||||
dt = dt.replace(tzinfo=timezone.utc)
|
|
||||||
elif dt.tzinfo != timezone.utc:
|
|
||||||
dt = dt.astimezone(timezone.utc)
|
|
||||||
processed_row[key] = dt
|
|
||||||
else:
|
|
||||||
processed_row[key] = value
|
|
||||||
results.append(processed_row)
|
|
||||||
return results[0] if results else {}
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# CSV PARSING TESTS
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
class TestCsvParsing:
|
|
||||||
"""Test that CSV parsing produces correct Decimal types."""
|
|
||||||
|
|
||||||
def test_parse_8_75pct_transaction(self):
|
|
||||||
"""Parse 8.75% commission transaction."""
|
|
||||||
tx = parse_csv_like_transaction_processor(LAMASSU_CSV_DATA["8.75pct_large"])
|
|
||||||
|
|
||||||
assert tx["transaction_id"] == "abc123"
|
|
||||||
assert tx["crypto_amount"] == 309200
|
|
||||||
assert tx["fiat_amount"] == Decimal("2000")
|
|
||||||
assert tx["commission_percentage"] == Decimal("0.0875")
|
|
||||||
assert tx["discount"] == Decimal("0")
|
|
||||||
assert isinstance(tx["fiat_amount"], Decimal)
|
|
||||||
assert isinstance(tx["commission_percentage"], Decimal)
|
|
||||||
assert isinstance(tx["discount"], Decimal)
|
|
||||||
|
|
||||||
def test_parse_5_5pct_with_discount(self):
|
|
||||||
"""Parse 5.5% commission with 90% discount."""
|
|
||||||
tx = parse_csv_like_transaction_processor(LAMASSU_CSV_DATA["5.5pct_90pct_discount"])
|
|
||||||
|
|
||||||
assert tx["crypto_amount"] == 115000
|
|
||||||
assert tx["fiat_amount"] == Decimal("800")
|
|
||||||
assert tx["commission_percentage"] == Decimal("0.055")
|
|
||||||
assert tx["discount"] == Decimal("90")
|
|
||||||
|
|
||||||
def test_timestamp_parsing(self):
|
|
||||||
"""Verify timestamp is parsed to UTC datetime."""
|
|
||||||
tx = parse_csv_like_transaction_processor(LAMASSU_CSV_DATA["8.75pct_large"])
|
|
||||||
|
|
||||||
assert isinstance(tx["transaction_time"], datetime)
|
|
||||||
assert tx["transaction_time"].tzinfo == timezone.utc
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# END-TO-END CALCULATION TESTS
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
class TestEndToEndCalculations:
|
|
||||||
"""
|
|
||||||
Test the full flow: CSV → Decimal → calculations → expected results.
|
|
||||||
|
|
||||||
These use the same empirical data as test_calculations.py but verify
|
|
||||||
the data flows correctly through parsing.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("csv_key,expected_base,expected_commission", [
|
|
||||||
("8.75pct_large", 284322, 24878),
|
|
||||||
("5.5pct_no_discount", 293365, 16135),
|
|
||||||
("5.5pct_90pct_discount", 114371, 629),
|
|
||||||
("5.5pct_1300gtq_4clients", 194882, 10718),
|
|
||||||
])
|
|
||||||
def test_csv_to_commission_calculation(self, csv_key, expected_base, expected_commission):
|
|
||||||
"""Verify CSV parsing → commission calculation produces expected results."""
|
|
||||||
# Parse CSV like transaction_processor does
|
|
||||||
tx = parse_csv_like_transaction_processor(LAMASSU_CSV_DATA[csv_key])
|
|
||||||
|
|
||||||
# Calculate commission using parsed Decimal values
|
|
||||||
base, commission, effective = calculate_commission(
|
|
||||||
tx["crypto_amount"],
|
|
||||||
tx["commission_percentage"],
|
|
||||||
tx["discount"]
|
|
||||||
)
|
|
||||||
|
|
||||||
assert base == expected_base, f"Base mismatch for {csv_key}"
|
|
||||||
assert commission == expected_commission, f"Commission mismatch for {csv_key}"
|
|
||||||
assert base + commission == tx["crypto_amount"], "Invariant: base + commission = total"
|
|
||||||
|
|
||||||
def test_full_distribution_flow_two_equal_clients(self):
|
|
||||||
"""Test full flow with two equal-balance clients."""
|
|
||||||
tx = parse_csv_like_transaction_processor(LAMASSU_CSV_DATA["8.75pct_large"])
|
|
||||||
|
|
||||||
# Calculate commission
|
|
||||||
base_sats, commission_sats, effective = calculate_commission(
|
|
||||||
tx["crypto_amount"],
|
|
||||||
tx["commission_percentage"],
|
|
||||||
tx["discount"]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Simulate client balances (as would come from database)
|
|
||||||
client_balances = {
|
|
||||||
"client_a": Decimal("1000.00"),
|
|
||||||
"client_b": Decimal("1000.00"),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Calculate distribution
|
|
||||||
distributions = calculate_distribution(base_sats, client_balances)
|
|
||||||
|
|
||||||
# Verify results
|
|
||||||
assert sum(distributions.values()) == base_sats
|
|
||||||
assert len(distributions) == 2
|
|
||||||
# With equal balances, should be roughly equal (±1 sat for rounding)
|
|
||||||
assert abs(distributions["client_a"] - distributions["client_b"]) <= 1
|
|
||||||
|
|
||||||
def test_full_distribution_flow_four_clients(self):
|
|
||||||
"""Test the 1300 GTQ transaction with 4 clients of varying balances."""
|
|
||||||
tx = parse_csv_like_transaction_processor(LAMASSU_CSV_DATA["5.5pct_1300gtq_4clients"])
|
|
||||||
|
|
||||||
# Calculate commission
|
|
||||||
base_sats, commission_sats, effective = calculate_commission(
|
|
||||||
tx["crypto_amount"],
|
|
||||||
tx["commission_percentage"],
|
|
||||||
tx["discount"]
|
|
||||||
)
|
|
||||||
|
|
||||||
assert base_sats == 194882
|
|
||||||
assert commission_sats == 10718
|
|
||||||
|
|
||||||
# Use the actual balance proportions from the real scenario
|
|
||||||
client_balances = {
|
|
||||||
"client_a": Decimal("1"),
|
|
||||||
"client_b": Decimal("986"),
|
|
||||||
"client_c": Decimal("14"),
|
|
||||||
"client_d": Decimal("4"),
|
|
||||||
}
|
|
||||||
|
|
||||||
distributions = calculate_distribution(base_sats, client_balances)
|
|
||||||
|
|
||||||
# Verify invariant
|
|
||||||
assert sum(distributions.values()) == base_sats
|
|
||||||
|
|
||||||
# Verify proportions are reasonable
|
|
||||||
total_balance = sum(client_balances.values())
|
|
||||||
for client_id, sats in distributions.items():
|
|
||||||
expected_proportion = client_balances[client_id] / total_balance
|
|
||||||
actual_proportion = Decimal(sats) / Decimal(base_sats)
|
|
||||||
# Allow 1% tolerance for rounding
|
|
||||||
assert abs(actual_proportion - expected_proportion) < Decimal("0.01"), \
|
|
||||||
f"Client {client_id} proportion off: {actual_proportion} vs {expected_proportion}"
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# MODEL CREATION TESTS
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
class TestModelCreation:
|
|
||||||
"""Test that Pydantic models accept Decimal values correctly."""
|
|
||||||
|
|
||||||
def test_create_lamassu_transaction_data_with_decimals(self):
|
|
||||||
"""Verify CreateLamassuTransactionData accepts Decimal values."""
|
|
||||||
tx = parse_csv_like_transaction_processor(LAMASSU_CSV_DATA["8.75pct_large"])
|
|
||||||
|
|
||||||
base_sats, commission_sats, effective = calculate_commission(
|
|
||||||
tx["crypto_amount"],
|
|
||||||
tx["commission_percentage"],
|
|
||||||
tx["discount"]
|
|
||||||
)
|
|
||||||
exchange_rate = calculate_exchange_rate(base_sats, tx["fiat_amount"])
|
|
||||||
|
|
||||||
# This should not raise any validation errors
|
|
||||||
data = CreateLamassuTransactionData(
|
|
||||||
lamassu_transaction_id=tx["transaction_id"],
|
|
||||||
fiat_amount=tx["fiat_amount"],
|
|
||||||
crypto_amount=tx["crypto_amount"],
|
|
||||||
commission_percentage=tx["commission_percentage"],
|
|
||||||
discount=tx["discount"],
|
|
||||||
effective_commission=effective,
|
|
||||||
commission_amount_sats=commission_sats,
|
|
||||||
base_amount_sats=base_sats,
|
|
||||||
exchange_rate=exchange_rate,
|
|
||||||
crypto_code=tx["crypto_code"],
|
|
||||||
fiat_code=tx["fiat_code"],
|
|
||||||
device_id=tx["device_id"],
|
|
||||||
transaction_time=tx["transaction_time"],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert data.fiat_amount == Decimal("2000")
|
|
||||||
assert data.commission_percentage == Decimal("0.0875")
|
|
||||||
assert data.base_amount_sats == 284322
|
|
||||||
assert data.commission_amount_sats == 24878
|
|
||||||
|
|
||||||
def test_create_dca_payment_data_with_decimals(self):
|
|
||||||
"""Verify CreateDcaPaymentData accepts Decimal values."""
|
|
||||||
tx = parse_csv_like_transaction_processor(LAMASSU_CSV_DATA["5.5pct_no_discount"])
|
|
||||||
|
|
||||||
base_sats, _, _ = calculate_commission(
|
|
||||||
tx["crypto_amount"],
|
|
||||||
tx["commission_percentage"],
|
|
||||||
tx["discount"]
|
|
||||||
)
|
|
||||||
exchange_rate = calculate_exchange_rate(base_sats, tx["fiat_amount"])
|
|
||||||
|
|
||||||
# Simulate a client getting half the distribution
|
|
||||||
client_sats = base_sats // 2
|
|
||||||
client_fiat = (Decimal(client_sats) / exchange_rate).quantize(Decimal("0.01"))
|
|
||||||
|
|
||||||
# This should not raise any validation errors
|
|
||||||
data = CreateDcaPaymentData(
|
|
||||||
client_id="test_client_123",
|
|
||||||
amount_sats=client_sats,
|
|
||||||
amount_fiat=client_fiat,
|
|
||||||
exchange_rate=exchange_rate,
|
|
||||||
transaction_type="flow",
|
|
||||||
lamassu_transaction_id=tx["transaction_id"],
|
|
||||||
transaction_time=tx["transaction_time"],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(data.amount_fiat, Decimal)
|
|
||||||
assert isinstance(data.exchange_rate, Decimal)
|
|
||||||
assert data.amount_sats == client_sats
|
|
||||||
|
|
||||||
def test_client_balance_summary_with_decimals(self):
|
|
||||||
"""Verify ClientBalanceSummary accepts Decimal values."""
|
|
||||||
summary = ClientBalanceSummary(
|
|
||||||
client_id="test_client",
|
|
||||||
total_deposits=Decimal("5000.00"),
|
|
||||||
total_payments=Decimal("1234.56"),
|
|
||||||
remaining_balance=Decimal("3765.44"),
|
|
||||||
currency="GTQ",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert summary.remaining_balance == Decimal("3765.44")
|
|
||||||
assert isinstance(summary.total_deposits, Decimal)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# EXCHANGE RATE PRECISION TESTS
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
class TestExchangeRatePrecision:
|
|
||||||
"""Test that exchange rate calculations maintain precision."""
|
|
||||||
|
|
||||||
def test_exchange_rate_round_trip(self):
|
|
||||||
"""Verify sats → fiat → sats round-trip maintains precision."""
|
|
||||||
tx = parse_csv_like_transaction_processor(LAMASSU_CSV_DATA["5.5pct_1300gtq_4clients"])
|
|
||||||
|
|
||||||
base_sats, _, _ = calculate_commission(
|
|
||||||
tx["crypto_amount"],
|
|
||||||
tx["commission_percentage"],
|
|
||||||
tx["discount"]
|
|
||||||
)
|
|
||||||
|
|
||||||
exchange_rate = calculate_exchange_rate(base_sats, tx["fiat_amount"])
|
|
||||||
|
|
||||||
# Convert sats to fiat and back
|
|
||||||
fiat_equivalent = Decimal(base_sats) / exchange_rate
|
|
||||||
sats_back = int((fiat_equivalent * exchange_rate).quantize(Decimal("1")))
|
|
||||||
|
|
||||||
# Should be within 1 sat of original
|
|
||||||
assert abs(sats_back - base_sats) <= 1
|
|
||||||
|
|
||||||
def test_per_client_fiat_sums_to_total(self):
|
|
||||||
"""Verify per-client fiat amounts sum to total fiat (within tolerance)."""
|
|
||||||
tx = parse_csv_like_transaction_processor(LAMASSU_CSV_DATA["5.5pct_1300gtq_4clients"])
|
|
||||||
|
|
||||||
base_sats, _, _ = calculate_commission(
|
|
||||||
tx["crypto_amount"],
|
|
||||||
tx["commission_percentage"],
|
|
||||||
tx["discount"]
|
|
||||||
)
|
|
||||||
|
|
||||||
exchange_rate = calculate_exchange_rate(base_sats, tx["fiat_amount"])
|
|
||||||
|
|
||||||
client_balances = {
|
|
||||||
"client_a": Decimal("1"),
|
|
||||||
"client_b": Decimal("986"),
|
|
||||||
"client_c": Decimal("14"),
|
|
||||||
"client_d": Decimal("4"),
|
|
||||||
}
|
|
||||||
|
|
||||||
distributions = calculate_distribution(base_sats, client_balances)
|
|
||||||
|
|
||||||
# Calculate per-client fiat and sum
|
|
||||||
total_fiat_distributed = Decimal("0")
|
|
||||||
for client_id, sats in distributions.items():
|
|
||||||
client_fiat = (Decimal(sats) / exchange_rate).quantize(Decimal("0.01"))
|
|
||||||
total_fiat_distributed += client_fiat
|
|
||||||
|
|
||||||
# Should be within 0.05 GTQ of original (accounting for per-client rounding)
|
|
||||||
# This is the 0.01 discrepancy we discussed, multiplied by number of clients
|
|
||||||
assert abs(total_fiat_distributed - tx["fiat_amount"]) < Decimal("0.05"), \
|
|
||||||
f"Total distributed {total_fiat_distributed} vs original {tx['fiat_amount']}"
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# CRUD LAYER TESTS (with mocked database)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
class TestCrudLayerDecimalHandling:
|
|
||||||
"""
|
|
||||||
Test that CRUD operations handle Decimal→float conversion for SQLite.
|
|
||||||
|
|
||||||
These tests mock the database layer to verify:
|
|
||||||
1. Decimal values from models are converted to float via prepare_for_db()
|
|
||||||
2. Float values from SQLite are converted back to Decimal via model validators
|
|
||||||
"""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_deposit_passes_float_amount_for_sqlite(self):
|
|
||||||
"""Verify create_deposit passes float amount to database (SQLite compatibility)."""
|
|
||||||
from unittest.mock import patch, AsyncMock
|
|
||||||
|
|
||||||
# Create deposit data with Decimal
|
|
||||||
deposit_data = CreateDepositData(
|
|
||||||
client_id="test_client_123",
|
|
||||||
amount=Decimal("1500.75"),
|
|
||||||
currency="GTQ",
|
|
||||||
notes="Test deposit"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify the model stored it as Decimal
|
|
||||||
assert isinstance(deposit_data.amount, Decimal)
|
|
||||||
assert deposit_data.amount == Decimal("1500.75")
|
|
||||||
|
|
||||||
# Mock the database
|
|
||||||
mock_db = AsyncMock()
|
|
||||||
mock_db.execute = AsyncMock()
|
|
||||||
mock_db.fetchone = AsyncMock(return_value=DcaDeposit(
|
|
||||||
id="deposit_123",
|
|
||||||
client_id="test_client_123",
|
|
||||||
amount=1500.75, # float from SQLite
|
|
||||||
currency="GTQ",
|
|
||||||
status="pending",
|
|
||||||
notes="Test deposit",
|
|
||||||
created_at=datetime.now(timezone.utc),
|
|
||||||
confirmed_at=None
|
|
||||||
))
|
|
||||||
|
|
||||||
with patch('satmachineadmin.crud.db', mock_db):
|
|
||||||
from ..crud import create_deposit
|
|
||||||
result = await create_deposit(deposit_data)
|
|
||||||
|
|
||||||
# Verify db.execute was called
|
|
||||||
mock_db.execute.assert_called_once()
|
|
||||||
|
|
||||||
# Get the parameters passed to execute
|
|
||||||
call_args = mock_db.execute.call_args
|
|
||||||
params = call_args[0][1] # Second positional arg is the params dict
|
|
||||||
|
|
||||||
# Verify the amount parameter is float (for SQLite compatibility)
|
|
||||||
assert isinstance(params["amount"], float)
|
|
||||||
assert params["amount"] == 1500.75
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_dca_payment_passes_float_values_for_sqlite(self):
|
|
||||||
"""Verify create_dca_payment passes float fiat and exchange_rate (SQLite compatibility)."""
|
|
||||||
from unittest.mock import patch, AsyncMock
|
|
||||||
|
|
||||||
# Parse a real transaction
|
|
||||||
tx = parse_csv_like_transaction_processor(LAMASSU_CSV_DATA["5.5pct_no_discount"])
|
|
||||||
base_sats, _, _ = calculate_commission(
|
|
||||||
tx["crypto_amount"],
|
|
||||||
tx["commission_percentage"],
|
|
||||||
tx["discount"]
|
|
||||||
)
|
|
||||||
exchange_rate = calculate_exchange_rate(base_sats, tx["fiat_amount"])
|
|
||||||
|
|
||||||
# Create payment data
|
|
||||||
client_sats = 146682 # Half of base
|
|
||||||
client_fiat = (Decimal(client_sats) / exchange_rate).quantize(Decimal("0.01"))
|
|
||||||
|
|
||||||
payment_data = CreateDcaPaymentData(
|
|
||||||
client_id="test_client",
|
|
||||||
amount_sats=client_sats,
|
|
||||||
amount_fiat=client_fiat,
|
|
||||||
exchange_rate=exchange_rate,
|
|
||||||
transaction_type="flow",
|
|
||||||
lamassu_transaction_id="def456",
|
|
||||||
transaction_time=tx["transaction_time"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify model has Decimal types
|
|
||||||
assert isinstance(payment_data.amount_fiat, Decimal)
|
|
||||||
assert isinstance(payment_data.exchange_rate, Decimal)
|
|
||||||
|
|
||||||
# Mock database - returns float like SQLite would
|
|
||||||
mock_db = AsyncMock()
|
|
||||||
mock_db.execute = AsyncMock()
|
|
||||||
mock_db.fetchone = AsyncMock(return_value=DcaPayment(
|
|
||||||
id="payment_123",
|
|
||||||
client_id="test_client",
|
|
||||||
amount_sats=client_sats,
|
|
||||||
amount_fiat=float(client_fiat), # float from SQLite
|
|
||||||
exchange_rate=float(exchange_rate), # float from SQLite
|
|
||||||
transaction_type="flow",
|
|
||||||
lamassu_transaction_id="def456",
|
|
||||||
payment_hash=None,
|
|
||||||
status="pending",
|
|
||||||
created_at=datetime.now(timezone.utc),
|
|
||||||
transaction_time=tx["transaction_time"],
|
|
||||||
))
|
|
||||||
|
|
||||||
with patch('satmachineadmin.crud.db', mock_db):
|
|
||||||
from ..crud import create_dca_payment
|
|
||||||
result = await create_dca_payment(payment_data)
|
|
||||||
|
|
||||||
# Verify db.execute was called
|
|
||||||
mock_db.execute.assert_called_once()
|
|
||||||
|
|
||||||
# Get params
|
|
||||||
call_args = mock_db.execute.call_args
|
|
||||||
params = call_args[0][1]
|
|
||||||
|
|
||||||
# Verify float types in params (SQLite compatibility)
|
|
||||||
assert isinstance(params["amount_fiat"], float)
|
|
||||||
assert isinstance(params["exchange_rate"], float)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_lamassu_transaction_passes_floats_for_sqlite(self):
|
|
||||||
"""Verify create_lamassu_transaction passes float fields (SQLite compatibility)."""
|
|
||||||
from unittest.mock import patch, AsyncMock
|
|
||||||
|
|
||||||
tx = parse_csv_like_transaction_processor(LAMASSU_CSV_DATA["8.75pct_large"])
|
|
||||||
base_sats, commission_sats, effective = calculate_commission(
|
|
||||||
tx["crypto_amount"],
|
|
||||||
tx["commission_percentage"],
|
|
||||||
tx["discount"]
|
|
||||||
)
|
|
||||||
exchange_rate = calculate_exchange_rate(base_sats, tx["fiat_amount"])
|
|
||||||
|
|
||||||
transaction_data = CreateLamassuTransactionData(
|
|
||||||
lamassu_transaction_id=tx["transaction_id"],
|
|
||||||
fiat_amount=tx["fiat_amount"],
|
|
||||||
crypto_amount=tx["crypto_amount"],
|
|
||||||
commission_percentage=tx["commission_percentage"],
|
|
||||||
discount=tx["discount"],
|
|
||||||
effective_commission=effective,
|
|
||||||
commission_amount_sats=commission_sats,
|
|
||||||
base_amount_sats=base_sats,
|
|
||||||
exchange_rate=exchange_rate,
|
|
||||||
crypto_code=tx["crypto_code"],
|
|
||||||
fiat_code=tx["fiat_code"],
|
|
||||||
device_id=tx["device_id"],
|
|
||||||
transaction_time=tx["transaction_time"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify all Decimal fields in the model
|
|
||||||
assert isinstance(transaction_data.fiat_amount, Decimal)
|
|
||||||
assert isinstance(transaction_data.commission_percentage, Decimal)
|
|
||||||
assert isinstance(transaction_data.discount, Decimal)
|
|
||||||
assert isinstance(transaction_data.effective_commission, Decimal)
|
|
||||||
assert isinstance(transaction_data.exchange_rate, Decimal)
|
|
||||||
|
|
||||||
# Mock database - returns floats like SQLite would
|
|
||||||
mock_db = AsyncMock()
|
|
||||||
mock_db.execute = AsyncMock()
|
|
||||||
mock_db.fetchone = AsyncMock(return_value=StoredLamassuTransaction(
|
|
||||||
id="tx_123",
|
|
||||||
lamassu_transaction_id=tx["transaction_id"],
|
|
||||||
fiat_amount=float(tx["fiat_amount"]), # float from SQLite
|
|
||||||
crypto_amount=tx["crypto_amount"],
|
|
||||||
commission_percentage=float(tx["commission_percentage"]), # float from SQLite
|
|
||||||
discount=float(tx["discount"]), # float from SQLite
|
|
||||||
effective_commission=float(effective), # float from SQLite
|
|
||||||
commission_amount_sats=commission_sats,
|
|
||||||
base_amount_sats=base_sats,
|
|
||||||
exchange_rate=float(exchange_rate), # float from SQLite
|
|
||||||
crypto_code=tx["crypto_code"],
|
|
||||||
fiat_code=tx["fiat_code"],
|
|
||||||
device_id=tx["device_id"],
|
|
||||||
transaction_time=tx["transaction_time"],
|
|
||||||
processed_at=datetime.now(timezone.utc),
|
|
||||||
clients_count=0,
|
|
||||||
distributions_total_sats=0,
|
|
||||||
))
|
|
||||||
|
|
||||||
with patch('satmachineadmin.crud.db', mock_db):
|
|
||||||
from ..crud import create_lamassu_transaction
|
|
||||||
result = await create_lamassu_transaction(transaction_data)
|
|
||||||
|
|
||||||
# Verify db.execute was called
|
|
||||||
mock_db.execute.assert_called_once()
|
|
||||||
|
|
||||||
# Get params
|
|
||||||
call_args = mock_db.execute.call_args
|
|
||||||
params = call_args[0][1]
|
|
||||||
|
|
||||||
# Verify all Decimal fields are converted to float for SQLite
|
|
||||||
assert isinstance(params["fiat_amount"], float), f"fiat_amount is {type(params['fiat_amount'])}"
|
|
||||||
assert isinstance(params["commission_percentage"], float)
|
|
||||||
assert isinstance(params["discount"], float)
|
|
||||||
assert isinstance(params["effective_commission"], float)
|
|
||||||
assert isinstance(params["exchange_rate"], float)
|
|
||||||
|
|
||||||
# Verify values match
|
|
||||||
assert params["fiat_amount"] == 2000.0
|
|
||||||
assert params["commission_percentage"] == 0.0875
|
|
||||||
assert params["base_amount_sats"] == 284322
|
|
||||||
assert params["commission_amount_sats"] == 24878
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_client_balance_summary_returns_decimals(self):
|
|
||||||
"""Verify get_client_balance_summary returns Decimal types."""
|
|
||||||
from unittest.mock import patch, AsyncMock
|
|
||||||
|
|
||||||
# Mock database responses
|
|
||||||
mock_db = AsyncMock()
|
|
||||||
|
|
||||||
# Mock deposits query result
|
|
||||||
mock_db.fetchone = AsyncMock(side_effect=[
|
|
||||||
# First call: deposits sum
|
|
||||||
{"total": Decimal("5000.00"), "currency": "GTQ"},
|
|
||||||
# Second call: payments sum
|
|
||||||
{"total": Decimal("1234.56")},
|
|
||||||
])
|
|
||||||
|
|
||||||
with patch('satmachineadmin.crud.db', mock_db):
|
|
||||||
from ..crud import get_client_balance_summary
|
|
||||||
result = await get_client_balance_summary("test_client")
|
|
||||||
|
|
||||||
# Verify result has Decimal types
|
|
||||||
assert isinstance(result.total_deposits, Decimal)
|
|
||||||
assert isinstance(result.total_payments, Decimal)
|
|
||||||
assert isinstance(result.remaining_balance, Decimal)
|
|
||||||
|
|
||||||
# Verify values
|
|
||||||
assert result.total_deposits == Decimal("5000.00")
|
|
||||||
assert result.total_payments == Decimal("1234.56")
|
|
||||||
assert result.remaining_balance == Decimal("3765.44")
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# SQLITE ROUND-TRIP TESTS
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
class TestSqliteDecimalRoundTrip:
|
|
||||||
"""
|
|
||||||
Test that Decimal values survive the SQLite round-trip:
|
|
||||||
Decimal → float (prepare_for_db) → SQLite REAL → float → Decimal (validator)
|
|
||||||
|
|
||||||
SQLite doesn't support Decimal natively - it stores DECIMAL as REAL (float).
|
|
||||||
We use prepare_for_db() to convert Decimal→float before writing,
|
|
||||||
and Pydantic validators with pre=True to convert float→Decimal on read.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_prepare_for_db_converts_decimals_to_float(self):
|
|
||||||
"""Verify prepare_for_db converts Decimal to float."""
|
|
||||||
from ..crud import prepare_for_db
|
|
||||||
|
|
||||||
values = {
|
|
||||||
"amount": Decimal("1500.75"),
|
|
||||||
"exchange_rate": Decimal("146.6825"),
|
|
||||||
"commission": Decimal("0.0875"),
|
|
||||||
"name": "test", # Non-Decimal should pass through
|
|
||||||
"count": 42, # Non-Decimal should pass through
|
|
||||||
}
|
|
||||||
|
|
||||||
result = prepare_for_db(values)
|
|
||||||
|
|
||||||
# Decimals should be converted to float
|
|
||||||
assert isinstance(result["amount"], float)
|
|
||||||
assert isinstance(result["exchange_rate"], float)
|
|
||||||
assert isinstance(result["commission"], float)
|
|
||||||
|
|
||||||
# Values should be preserved
|
|
||||||
assert result["amount"] == 1500.75
|
|
||||||
assert result["exchange_rate"] == 146.6825
|
|
||||||
assert result["commission"] == 0.0875
|
|
||||||
|
|
||||||
# Non-Decimals should pass through unchanged
|
|
||||||
assert result["name"] == "test"
|
|
||||||
assert result["count"] == 42
|
|
||||||
|
|
||||||
def test_model_validator_converts_float_to_decimal(self):
|
|
||||||
"""Verify model validators convert float back to Decimal."""
|
|
||||||
# Simulate what comes back from SQLite (floats)
|
|
||||||
db_row = {
|
|
||||||
"id": "deposit_123",
|
|
||||||
"client_id": "client_abc",
|
|
||||||
"amount": 1500.75, # float from SQLite
|
|
||||||
"currency": "GTQ",
|
|
||||||
"status": "pending",
|
|
||||||
"notes": "Test",
|
|
||||||
"created_at": datetime.now(timezone.utc),
|
|
||||||
"confirmed_at": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create model from "database" data
|
|
||||||
deposit = DcaDeposit(**db_row)
|
|
||||||
|
|
||||||
# Validator should have converted float → Decimal
|
|
||||||
assert isinstance(deposit.amount, Decimal)
|
|
||||||
assert deposit.amount == Decimal("1500.75")
|
|
||||||
|
|
||||||
def test_payment_model_converts_multiple_floats(self):
|
|
||||||
"""Verify DcaPayment converts all float fields to Decimal."""
|
|
||||||
db_row = {
|
|
||||||
"id": "payment_123",
|
|
||||||
"client_id": "client_abc",
|
|
||||||
"amount_sats": 146682,
|
|
||||||
"amount_fiat": 1000.50, # float from SQLite
|
|
||||||
"exchange_rate": 146.6825, # float from SQLite
|
|
||||||
"transaction_type": "flow",
|
|
||||||
"lamassu_transaction_id": "tx_456",
|
|
||||||
"payment_hash": None,
|
|
||||||
"status": "confirmed",
|
|
||||||
"created_at": datetime.now(timezone.utc),
|
|
||||||
"transaction_time": datetime.now(timezone.utc),
|
|
||||||
}
|
|
||||||
|
|
||||||
payment = DcaPayment(**db_row)
|
|
||||||
|
|
||||||
assert isinstance(payment.amount_fiat, Decimal)
|
|
||||||
assert isinstance(payment.exchange_rate, Decimal)
|
|
||||||
assert payment.amount_fiat == Decimal("1000.50")
|
|
||||||
assert payment.exchange_rate == Decimal("146.6825")
|
|
||||||
|
|
||||||
def test_stored_transaction_converts_all_decimal_fields(self):
|
|
||||||
"""Verify StoredLamassuTransaction converts all float fields."""
|
|
||||||
db_row = {
|
|
||||||
"id": "tx_123",
|
|
||||||
"lamassu_transaction_id": "lamassu_abc",
|
|
||||||
"fiat_amount": 2000.00, # float from SQLite
|
|
||||||
"crypto_amount": 309200,
|
|
||||||
"commission_percentage": 0.0875, # float from SQLite
|
|
||||||
"discount": 0.0, # float from SQLite
|
|
||||||
"effective_commission": 0.0875, # float from SQLite
|
|
||||||
"commission_amount_sats": 24878,
|
|
||||||
"base_amount_sats": 284322,
|
|
||||||
"exchange_rate": 142.161, # float from SQLite
|
|
||||||
"crypto_code": "BTC",
|
|
||||||
"fiat_code": "GTQ",
|
|
||||||
"device_id": "device1",
|
|
||||||
"transaction_time": datetime.now(timezone.utc),
|
|
||||||
"processed_at": datetime.now(timezone.utc),
|
|
||||||
"clients_count": 2,
|
|
||||||
"distributions_total_sats": 284322,
|
|
||||||
}
|
|
||||||
|
|
||||||
tx = StoredLamassuTransaction(**db_row)
|
|
||||||
|
|
||||||
# All Decimal fields should be converted
|
|
||||||
assert isinstance(tx.fiat_amount, Decimal)
|
|
||||||
assert isinstance(tx.commission_percentage, Decimal)
|
|
||||||
assert isinstance(tx.discount, Decimal)
|
|
||||||
assert isinstance(tx.effective_commission, Decimal)
|
|
||||||
assert isinstance(tx.exchange_rate, Decimal)
|
|
||||||
|
|
||||||
# Values should be preserved
|
|
||||||
assert tx.fiat_amount == Decimal("2000.00")
|
|
||||||
assert tx.commission_percentage == Decimal("0.0875")
|
|
||||||
assert tx.discount == Decimal("0.0")
|
|
||||||
assert tx.effective_commission == Decimal("0.0875")
|
|
||||||
|
|
||||||
def test_full_round_trip_preserves_precision(self):
|
|
||||||
"""
|
|
||||||
Test complete round-trip: Decimal → prepare_for_db → float → Decimal.
|
|
||||||
|
|
||||||
This simulates what happens in production:
|
|
||||||
1. We have Decimal values from calculations
|
|
||||||
2. prepare_for_db converts them to float for SQLite
|
|
||||||
3. SQLite stores as REAL
|
|
||||||
4. We read back as float
|
|
||||||
5. Model validators convert back to Decimal
|
|
||||||
"""
|
|
||||||
from ..crud import prepare_for_db
|
|
||||||
|
|
||||||
# Original Decimal values (from calculations)
|
|
||||||
original = {
|
|
||||||
"fiat_amount": Decimal("1300.00"),
|
|
||||||
"commission_percentage": Decimal("0.055"),
|
|
||||||
"discount": Decimal("0"),
|
|
||||||
"effective_commission": Decimal("0.055"),
|
|
||||||
"exchange_rate": Decimal("149.90215490109"), # High precision
|
|
||||||
}
|
|
||||||
|
|
||||||
# Step 1: Convert for database storage
|
|
||||||
for_db = prepare_for_db(original)
|
|
||||||
|
|
||||||
# Verify all are floats
|
|
||||||
for key in original:
|
|
||||||
assert isinstance(for_db[key], float), f"{key} should be float"
|
|
||||||
|
|
||||||
# Step 2: Simulate SQLite storage and retrieval
|
|
||||||
# (SQLite would store these as REAL and return as float)
|
|
||||||
from_db = for_db.copy()
|
|
||||||
|
|
||||||
# Step 3: Create a model (simulating what happens when reading)
|
|
||||||
db_row = {
|
|
||||||
"id": "tx_test",
|
|
||||||
"lamassu_transaction_id": "test_123",
|
|
||||||
"fiat_amount": from_db["fiat_amount"],
|
|
||||||
"crypto_amount": 205600,
|
|
||||||
"commission_percentage": from_db["commission_percentage"],
|
|
||||||
"discount": from_db["discount"],
|
|
||||||
"effective_commission": from_db["effective_commission"],
|
|
||||||
"commission_amount_sats": 10718,
|
|
||||||
"base_amount_sats": 194882,
|
|
||||||
"exchange_rate": from_db["exchange_rate"],
|
|
||||||
"crypto_code": "BTC",
|
|
||||||
"fiat_code": "GTQ",
|
|
||||||
"device_id": "device1",
|
|
||||||
"transaction_time": datetime.now(timezone.utc),
|
|
||||||
"processed_at": datetime.now(timezone.utc),
|
|
||||||
"clients_count": 1,
|
|
||||||
"distributions_total_sats": 194882,
|
|
||||||
}
|
|
||||||
|
|
||||||
tx = StoredLamassuTransaction(**db_row)
|
|
||||||
|
|
||||||
# Step 4: Verify Decimal types restored
|
|
||||||
assert isinstance(tx.fiat_amount, Decimal)
|
|
||||||
assert isinstance(tx.commission_percentage, Decimal)
|
|
||||||
assert isinstance(tx.exchange_rate, Decimal)
|
|
||||||
|
|
||||||
# Step 5: Verify precision for 2 decimal place values
|
|
||||||
assert tx.fiat_amount == Decimal("1300.0") # SQLite preserves this exactly
|
|
||||||
assert tx.commission_percentage == Decimal("0.055")
|
|
||||||
assert tx.discount == Decimal("0.0")
|
|
||||||
|
|
||||||
def test_client_balance_summary_float_conversion(self):
|
|
||||||
"""Verify ClientBalanceSummary converts floats from aggregation queries."""
|
|
||||||
# SQLite SUM() returns float
|
|
||||||
db_row = {
|
|
||||||
"client_id": "client_123",
|
|
||||||
"total_deposits": 5000.00, # float from SUM()
|
|
||||||
"total_payments": 1234.56, # float from SUM()
|
|
||||||
"remaining_balance": 3765.44, # calculated float
|
|
||||||
"currency": "GTQ",
|
|
||||||
}
|
|
||||||
|
|
||||||
summary = ClientBalanceSummary(**db_row)
|
|
||||||
|
|
||||||
assert isinstance(summary.total_deposits, Decimal)
|
|
||||||
assert isinstance(summary.total_payments, Decimal)
|
|
||||||
assert isinstance(summary.remaining_balance, Decimal)
|
|
||||||
assert summary.remaining_balance == Decimal("3765.44")
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
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
|
||||||
|
|
@ -493,38 +492,28 @@ 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 == 'crypto_amount':
|
if key in ['fiat_amount', 'crypto_amount']:
|
||||||
processed_row[key] = 0 # Sats are always int
|
processed_row[key] = 0 # Default numeric fields to 0
|
||||||
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] = Decimal("0") # Percentages as Decimal
|
processed_row[key] = 0.0 # Default percentage fields to 0.0
|
||||||
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 == 'crypto_amount':
|
elif key in ['fiat_amount', 'crypto_amount']:
|
||||||
try:
|
try:
|
||||||
processed_row[key] = int(float(value)) # Sats are always int
|
processed_row[key] = int(float(value))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
processed_row[key] = 0
|
processed_row[key] = 0 # Fallback to 0 for invalid values
|
||||||
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:
|
||||||
# Convert via string to avoid float precision issues
|
processed_row[key] = float(value)
|
||||||
processed_row[key] = Decimal(str(value))
|
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
processed_row[key] = Decimal("0")
|
processed_row[key] = 0.0 # Fallback to 0.0 for invalid values
|
||||||
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
|
||||||
|
|
@ -680,21 +669,27 @@ class LamassuTransactionProcessor:
|
||||||
logger.error(f"Error fetching transactions from Lamassu database: {e}")
|
logger.error(f"Error fetching transactions from Lamassu database: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async def calculate_distribution_amounts(self, transaction: Dict[str, Any]) -> Dict[str, int]:
|
async def calculate_distribution_amounts(self, transaction: Dict[str, Any]) -> tuple[Dict[str, Any], int]:
|
||||||
"""Calculate how much each Flow Mode client should receive"""
|
"""Calculate how much each Flow Mode client should receive.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (distributions dict, orphan_sats int)
|
||||||
|
- distributions: {client_id: {fiat_amount, sats_amount, exchange_rate}}
|
||||||
|
- orphan_sats: sats that couldn't be distributed due to sync mismatch
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Get all active Flow Mode clients
|
# Get all active Flow Mode clients
|
||||||
flow_clients = await get_flow_mode_clients()
|
flow_clients = await get_flow_mode_clients()
|
||||||
|
|
||||||
if not flow_clients:
|
if not flow_clients:
|
||||||
logger.info("No Flow Mode clients found - skipping distribution")
|
logger.info("No Flow Mode clients found - skipping distribution")
|
||||||
return {}
|
return {}, 0
|
||||||
|
|
||||||
# Extract transaction details - guaranteed clean from data ingestion (Decimal types)
|
# Extract transaction details - guaranteed clean from data ingestion
|
||||||
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", Decimal("0")) # Actual fiat dispensed (principal only)
|
fiat_amount = transaction.get("fiat_amount", 0) # Actual fiat dispensed (principal only)
|
||||||
commission_percentage = transaction.get("commission_percentage", Decimal("0")) # Already stored as Decimal (e.g., 0.045)
|
commission_percentage = transaction.get("commission_percentage", 0.0) # Already stored as decimal (e.g., 0.045)
|
||||||
discount = transaction.get("discount", Decimal("0")) # Discount percentage
|
discount = transaction.get("discount", 0.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
|
||||||
|
|
@ -712,16 +707,16 @@ class LamassuTransactionProcessor:
|
||||||
# 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}")
|
||||||
return {}
|
return {}, 0
|
||||||
if fiat_amount is None:
|
if fiat_amount is None:
|
||||||
logger.error(f"Missing fiat_amount in transaction: {transaction}")
|
logger.error(f"Missing fiat_amount in transaction: {transaction}")
|
||||||
return {}
|
return {}, 0
|
||||||
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 = Decimal("0")
|
commission_percentage = 0.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 = Decimal("0")
|
discount = 0.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
|
||||||
|
|
@ -744,16 +739,15 @@ 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: Dict[str, Decimal] = {}
|
client_balances = {}
|
||||||
total_confirmed_deposits = Decimal("0")
|
total_confirmed_deposits = 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 >= min_balance:
|
if balance.remaining_balance >= 0.01:
|
||||||
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")
|
||||||
|
|
@ -762,26 +756,61 @@ class LamassuTransactionProcessor:
|
||||||
|
|
||||||
if total_confirmed_deposits == 0:
|
if total_confirmed_deposits == 0:
|
||||||
logger.info("No clients with remaining DCA balance - skipping distribution")
|
logger.info("No clients with remaining DCA balance - skipping distribution")
|
||||||
return {}
|
return {}, 0
|
||||||
|
|
||||||
# Calculate sat allocations using the extracted pure function
|
# Detect sync mismatch: more money in ATM than tracked client balances
|
||||||
|
sync_mismatch = total_confirmed_deposits < fiat_amount
|
||||||
|
if sync_mismatch:
|
||||||
|
orphan_fiat = fiat_amount - total_confirmed_deposits
|
||||||
|
logger.warning(
|
||||||
|
f"Sync mismatch detected: tracked balances ({total_confirmed_deposits:.2f} GTQ) "
|
||||||
|
f"< transaction ({fiat_amount} GTQ). Orphan amount: {orphan_fiat:.2f} GTQ"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate distribution amounts
|
||||||
|
distributions = {}
|
||||||
|
|
||||||
|
if sync_mismatch:
|
||||||
|
# SYNC MISMATCH MODE: Cap each client's allocation to their remaining fiat balance
|
||||||
|
# Each client gets sats equivalent to their full remaining balance
|
||||||
|
for client_id, client_balance in client_balances.items():
|
||||||
|
# Calculate sats equivalent to this client's remaining fiat balance
|
||||||
|
client_sats_amount = round(client_balance * exchange_rate)
|
||||||
|
proportion = client_balance / 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
|
||||||
|
|
||||||
|
distributions[client_id] = {
|
||||||
|
"fiat_amount": client_fiat_amount,
|
||||||
|
"sats_amount": client_sats_amount,
|
||||||
|
"exchange_rate": exchange_rate
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Client {client_id[:8]}... gets {client_sats_amount} sats (≈{client_fiat_amount:.2f} GTQ, {proportion:.2%} share)")
|
||||||
|
|
||||||
|
# Calculate orphan sats (difference between base amount and distributed)
|
||||||
|
total_distributed = sum(dist["sats_amount"] for dist in distributions.values())
|
||||||
|
orphan_sats = base_crypto_atoms - total_distributed
|
||||||
|
logger.info(
|
||||||
|
f"Sync mismatch distribution: {total_distributed} sats to clients, "
|
||||||
|
f"{orphan_sats} sats orphaned (staying in source wallet)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# NORMAL MODE: Proportional distribution based on transaction amount
|
||||||
sat_allocations = calculate_distribution(base_crypto_atoms, client_balances)
|
sat_allocations = calculate_distribution(base_crypto_atoms, client_balances)
|
||||||
|
|
||||||
if not sat_allocations:
|
if not sat_allocations:
|
||||||
logger.info("No allocations calculated - skipping distribution")
|
logger.info("No allocations calculated - skipping distribution")
|
||||||
return {}
|
return {}, 0
|
||||||
|
|
||||||
# Build final distributions dict with additional tracking fields
|
# Build final distributions dict with additional tracking fields
|
||||||
distributions = {}
|
|
||||||
for client_id, client_sats_amount in sat_allocations.items():
|
for client_id, client_sats_amount in sat_allocations.items():
|
||||||
# Calculate proportion for logging
|
# Calculate proportion for logging
|
||||||
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
|
||||||
if exchange_rate > 0:
|
client_fiat_amount = round(client_sats_amount / exchange_rate, 2) if exchange_rate > 0 else 0.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,
|
||||||
|
|
@ -796,6 +825,7 @@ class LamassuTransactionProcessor:
|
||||||
if total_distributed != base_crypto_atoms:
|
if total_distributed != base_crypto_atoms:
|
||||||
logger.error(f"Distribution mismatch! Expected: {base_crypto_atoms} sats, Distributed: {total_distributed} sats")
|
logger.error(f"Distribution mismatch! Expected: {base_crypto_atoms} sats, Distributed: {total_distributed} sats")
|
||||||
raise ValueError(f"Satoshi distribution calculation error: {base_crypto_atoms} != {total_distributed}")
|
raise ValueError(f"Satoshi distribution calculation error: {base_crypto_atoms} != {total_distributed}")
|
||||||
|
orphan_sats = 0
|
||||||
|
|
||||||
# Safety check: Re-verify all clients still have positive balances before finalizing distributions
|
# Safety check: Re-verify all clients still have positive balances before finalizing distributions
|
||||||
# This prevents race conditions where balances changed during calculation
|
# This prevents race conditions where balances changed during calculation
|
||||||
|
|
@ -815,18 +845,18 @@ class LamassuTransactionProcessor:
|
||||||
# Recalculate proportions if some clients were rejected
|
# Recalculate proportions if some clients were rejected
|
||||||
if len(final_distributions) == 0:
|
if len(final_distributions) == 0:
|
||||||
logger.info("All clients rejected due to negative balances - no distributions")
|
logger.info("All clients rejected due to negative balances - no distributions")
|
||||||
return {}
|
return {}, orphan_sats
|
||||||
|
|
||||||
# For simplicity, we'll still return the original distributions but log the warning
|
# For simplicity, we'll still return the original distributions but log the warning
|
||||||
# In a production system, you might want to recalculate the entire distribution
|
# In a production system, you might want to recalculate the entire distribution
|
||||||
logger.warning("Proceeding with original distribution despite balance warnings - manual review recommended")
|
logger.warning("Proceeding with original distribution despite balance warnings - manual review recommended")
|
||||||
|
|
||||||
logger.info(f"Distribution verified: {total_distributed} sats distributed across {len(distributions)} clients (clients with positive allocations only)")
|
logger.info(f"Distribution verified: {total_distributed} sats distributed across {len(distributions)} clients (clients with positive allocations only)")
|
||||||
return distributions
|
return distributions, orphan_sats
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error calculating distribution amounts: {e}")
|
logger.error(f"Error calculating distribution amounts: {e}")
|
||||||
return {}
|
return {}, 0
|
||||||
|
|
||||||
async def distribute_to_clients(self, transaction: Dict[str, Any], distributions: Dict[str, Dict[str, int]]) -> None:
|
async def distribute_to_clients(self, transaction: Dict[str, Any], distributions: Dict[str, Dict[str, int]]) -> None:
|
||||||
"""Send Bitcoin payments to DCA clients"""
|
"""Send Bitcoin payments to DCA clients"""
|
||||||
|
|
@ -935,7 +965,7 @@ class LamassuTransactionProcessor:
|
||||||
}
|
}
|
||||||
new_payment = await create_invoice(
|
new_payment = await create_invoice(
|
||||||
wallet_id=target_wallet.id,
|
wallet_id=target_wallet.id,
|
||||||
amount=float(amount_sats), # LNBits expects float for amount
|
amount=float(amount_sats), # LNBits create_invoice expects float
|
||||||
internal=True, # Internal transfer within LNBits
|
internal=True, # Internal transfer within LNBits
|
||||||
memo=memo,
|
memo=memo,
|
||||||
extra=extra
|
extra=extra
|
||||||
|
|
@ -1018,11 +1048,11 @@ 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 (Decimal types)
|
# Extract transaction data - guaranteed clean from data ingestion boundary
|
||||||
crypto_atoms = transaction.get("crypto_amount", 0)
|
crypto_atoms = transaction.get("crypto_amount", 0)
|
||||||
fiat_amount = transaction.get("fiat_amount", Decimal("0"))
|
fiat_amount = transaction.get("fiat_amount", 0)
|
||||||
commission_percentage = transaction.get("commission_percentage", Decimal("0"))
|
commission_percentage = transaction.get("commission_percentage", 0.0)
|
||||||
discount = transaction.get("discount", Decimal("0"))
|
discount = transaction.get("discount", 0.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
|
||||||
|
|
@ -1040,12 +1070,10 @@ 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 (Decimal already, just ensure 2 decimal places)
|
# Create transaction data with GTQ amounts
|
||||||
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=fiat_amount_rounded,
|
fiat_amount=round(fiat_amount, 2), # Store GTQ with 2 decimal places
|
||||||
crypto_amount=crypto_atoms,
|
crypto_amount=crypto_atoms,
|
||||||
commission_percentage=commission_percentage,
|
commission_percentage=commission_percentage,
|
||||||
discount=discount,
|
discount=discount,
|
||||||
|
|
@ -1102,7 +1130,7 @@ class LamassuTransactionProcessor:
|
||||||
|
|
||||||
commission_payment = await create_invoice(
|
commission_payment = await create_invoice(
|
||||||
wallet_id=admin_config.commission_wallet_id,
|
wallet_id=admin_config.commission_wallet_id,
|
||||||
amount=float(commission_amount_sats), # LNBits expects float
|
amount=float(commission_amount_sats), # LNbits create_invoice expects float
|
||||||
internal=True,
|
internal=True,
|
||||||
memo=commission_memo,
|
memo=commission_memo,
|
||||||
extra={
|
extra={
|
||||||
|
|
@ -1157,16 +1185,22 @@ class LamassuTransactionProcessor:
|
||||||
stored_transaction = await self.store_lamassu_transaction(transaction)
|
stored_transaction = await self.store_lamassu_transaction(transaction)
|
||||||
|
|
||||||
# Calculate distribution amounts
|
# Calculate distribution amounts
|
||||||
distributions = await self.calculate_distribution_amounts(transaction)
|
distributions, orphan_sats = await self.calculate_distribution_amounts(transaction)
|
||||||
|
|
||||||
if not distributions:
|
if not distributions:
|
||||||
|
if orphan_sats > 0:
|
||||||
|
logger.warning(
|
||||||
|
f"No client distributions for transaction {transaction_id}, "
|
||||||
|
f"but {orphan_sats} orphan sats remain in source wallet"
|
||||||
|
)
|
||||||
|
else:
|
||||||
logger.info(f"No distributions calculated for transaction {transaction_id}")
|
logger.info(f"No distributions calculated for transaction {transaction_id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 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", Decimal("0"))
|
commission_percentage = transaction.get("commission_percentage", 0.0)
|
||||||
discount = transaction.get("discount", Decimal("0"))
|
discount = transaction.get("discount", 0.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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue