feat: add unit tests for DCA calculations with empirical Lamassu data
Some checks are pending
CI / lint (push) Waiting to run
CI / tests (3.10) (push) Blocked by required conditions
CI / tests (3.9) (push) Blocked by required conditions

- Extract pure calculation functions to calculations.py (no lnbits deps)
- transaction_processor.py now imports from calculations.py (DRY)
- Add 22 tests covering commission, distribution, and fiat round-trip
- Include real Lamassu transaction data (8.75%, 5.5% commission rates)
- Test edge cases: discounts (90%, 100%), zero commission, small amounts

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
padreug 2026-01-11 14:14:18 +01:00
parent 8d94dcc2b7
commit 397fd4b002
3 changed files with 560 additions and 84 deletions

View file

@ -26,6 +26,7 @@ from lnbits.core.crud.wallets import get_wallet
from lnbits.core.services import update_wallet_balance
from lnbits.settings import settings
from .calculations import calculate_commission, calculate_distribution, calculate_exchange_rate
from .crud import (
get_flow_mode_clients,
get_payments_by_lamassu_transaction,
@ -715,21 +716,13 @@ class LamassuTransactionProcessor:
# Could use current time as fallback, but this indicates a data issue
# transaction_time = datetime.now(timezone.utc)
# Calculate effective commission percentage after discount (following the reference logic)
if commission_percentage > 0:
effective_commission = commission_percentage * (100 - discount) / 100
# Since crypto_atoms already includes commission, we need to extract the base amount
# Formula: crypto_atoms = base_amount * (1 + effective_commission)
# Therefore: base_amount = crypto_atoms / (1 + effective_commission)
base_crypto_atoms = round(crypto_atoms / (1 + effective_commission))
commission_amount_sats = crypto_atoms - base_crypto_atoms
else:
effective_commission = 0.0
base_crypto_atoms = crypto_atoms
commission_amount_sats = 0
# Calculate commission split using the extracted pure function
base_crypto_atoms, commission_amount_sats, effective_commission = calculate_commission(
crypto_atoms, commission_percentage, discount
)
# Calculate exchange rate based on base amounts
exchange_rate = base_crypto_atoms / fiat_amount if fiat_amount > 0 else 0 # sats per fiat unit
exchange_rate = calculate_exchange_rate(base_crypto_atoms, fiat_amount)
logger.info(f"Transaction - Total crypto: {crypto_atoms} sats")
logger.info(f"Commission: {commission_percentage*100:.1f}% - {discount:.1f}% discount = {effective_commission*100:.1f}% effective ({commission_amount_sats} sats)")
@ -758,67 +751,29 @@ class LamassuTransactionProcessor:
if total_confirmed_deposits == 0:
logger.info("No clients with remaining DCA balance - skipping distribution")
return {}
# Calculate proportional distribution with remainder allocation
# Calculate sat allocations using the extracted pure function
sat_allocations = calculate_distribution(base_crypto_atoms, client_balances)
if not sat_allocations:
logger.info("No allocations calculated - skipping distribution")
return {}
# Build final distributions dict with additional tracking fields
distributions = {}
distributed_sats = 0
client_calculations = []
# First pass: calculate base amounts and track remainders
for client_id, client_balance in client_balances.items():
# Calculate this client's proportion of the total DCA pool
proportion = client_balance / total_confirmed_deposits
# Calculate exact share (with decimals)
exact_share = base_crypto_atoms * proportion
# Use banker's rounding for base allocation
client_sats_amount = round(exact_share)
client_calculations.append({
'client_id': client_id,
'proportion': proportion,
'exact_share': exact_share,
'allocated_sats': client_sats_amount,
'client_balance': client_balance
})
distributed_sats += client_sats_amount
# Handle any remainder due to rounding (should be small)
remainder = base_crypto_atoms - distributed_sats
if remainder != 0:
logger.info(f"Distributing remainder: {remainder} sats among {len(client_calculations)} clients")
# Sort clients by largest fractional remainder to distribute fairly
client_calculations.sort(
key=lambda x: x['exact_share'] - x['allocated_sats'],
reverse=True
)
# Distribute remainder one sat at a time to clients with largest fractional parts
for i in range(abs(remainder)):
if remainder > 0:
client_calculations[i % len(client_calculations)]['allocated_sats'] += 1
else:
client_calculations[i % len(client_calculations)]['allocated_sats'] -= 1
# Second pass: create distributions with final amounts
for calc in client_calculations:
client_id = calc['client_id']
client_sats_amount = calc['allocated_sats']
proportion = calc['proportion']
for client_id, client_sats_amount in sat_allocations.items():
# Calculate proportion for logging
proportion = client_balances[client_id] / total_confirmed_deposits
# Calculate equivalent fiat value in GTQ for tracking purposes
client_fiat_amount = round(client_sats_amount / exchange_rate, 2) if exchange_rate > 0 else 0.0
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)")
# Verification: ensure total distribution equals base amount
@ -1062,18 +1017,13 @@ class LamassuTransactionProcessor:
elif transaction_time.tzinfo != timezone.utc:
transaction_time = transaction_time.astimezone(timezone.utc)
# Calculate commission metrics
if commission_percentage > 0:
effective_commission = commission_percentage * (100 - discount) / 100
base_crypto_atoms = round(crypto_atoms / (1 + effective_commission))
commission_amount_sats = crypto_atoms - base_crypto_atoms
else:
effective_commission = 0.0
base_crypto_atoms = crypto_atoms
commission_amount_sats = 0
# Calculate commission metrics using the extracted pure function
base_crypto_atoms, commission_amount_sats, effective_commission = calculate_commission(
crypto_atoms, commission_percentage, discount
)
# Calculate exchange rate
exchange_rate = base_crypto_atoms / fiat_amount if fiat_amount > 0 else 0
exchange_rate = calculate_exchange_rate(base_crypto_atoms, fiat_amount)
# Create transaction data with GTQ amounts
transaction_data = CreateLamassuTransactionData(
@ -1201,12 +1151,10 @@ class LamassuTransactionProcessor:
commission_percentage = transaction.get("commission_percentage", 0.0)
discount = transaction.get("discount", 0.0)
if commission_percentage and commission_percentage > 0:
effective_commission = commission_percentage * (100 - discount) / 100
base_crypto_atoms = round(crypto_atoms / (1 + effective_commission))
commission_amount_sats = crypto_atoms - base_crypto_atoms
else:
commission_amount_sats = 0
# Calculate commission amount using the extracted pure function
_, commission_amount_sats, _ = calculate_commission(
crypto_atoms, commission_percentage, discount
)
# Distribute to clients
await self.distribute_to_clients(transaction, distributions)