diff --git a/crud.py b/crud.py index 94c1d20..60bad39 100644 --- a/crud.py +++ b/crud.py @@ -267,7 +267,7 @@ async def get_client_balance_summary(client_id: str, as_of_time: Optional[dateti from loguru import logger # Verify timezone consistency for temporal filtering tz_info = "UTC" if as_of_time.tzinfo == timezone.utc else f"TZ: {as_of_time.tzinfo}" - logger.info(f"Client {client_id[:8]}... balance as of {as_of_time} ({tz_info}): deposits.confirmed_at <= cutoff, payments.transaction_time <= cutoff → {total_deposits - total_payments:.2f} GTQ remaining") + logger.info(f"Client {client_id[:8]}... balance as of {as_of_time} ({tz_info}): deposits.confirmed_at <= cutoff, payments.transaction_time <= cutoff → {total_deposits - total_payments} centavos remaining") return ClientBalanceSummary( client_id=client_id, diff --git a/migrations.py b/migrations.py index c8147f5..0456f43 100644 --- a/migrations.py +++ b/migrations.py @@ -134,39 +134,4 @@ async def m003_add_max_daily_limit_config(db): ALTER TABLE satoshimachine.lamassu_config ADD COLUMN max_daily_limit_gtq INTEGER NOT NULL DEFAULT 2000 """ - ) - - -async def m004_convert_to_gtq_storage(db): - """ - Convert centavo storage to GTQ storage by changing data types and converting existing data. - Handles both SQLite (data conversion only) and PostgreSQL (data + schema changes). - """ - # Detect database type - db_type = str(type(db)).lower() - is_postgres = 'postgres' in db_type or 'asyncpg' in db_type - - if is_postgres: - # PostgreSQL: Need to change column types first, then convert data - - # Change column types to DECIMAL(10,2) - await db.execute("ALTER TABLE satoshimachine.dca_deposits ALTER COLUMN amount TYPE DECIMAL(10,2)") - await db.execute("ALTER TABLE satoshimachine.dca_payments ALTER COLUMN amount_fiat TYPE DECIMAL(10,2)") - await db.execute("ALTER TABLE satoshimachine.lamassu_transactions ALTER COLUMN fiat_amount TYPE DECIMAL(10,2)") - await db.execute("ALTER TABLE satoshimachine.dca_clients ALTER COLUMN fixed_mode_daily_limit TYPE DECIMAL(10,2)") - await db.execute("ALTER TABLE satoshimachine.lamassu_config ALTER COLUMN max_daily_limit_gtq TYPE DECIMAL(10,2)") - - # Convert data from centavos to GTQ - await db.execute("UPDATE satoshimachine.dca_deposits SET amount = amount / 100.0 WHERE currency = 'GTQ'") - await db.execute("UPDATE satoshimachine.dca_payments SET amount_fiat = amount_fiat / 100.0") - await db.execute("UPDATE satoshimachine.lamassu_transactions SET fiat_amount = fiat_amount / 100.0") - await db.execute("UPDATE satoshimachine.dca_clients SET fixed_mode_daily_limit = fixed_mode_daily_limit / 100.0 WHERE fixed_mode_daily_limit IS NOT NULL") - await db.execute("UPDATE satoshimachine.lamassu_config SET max_daily_limit_gtq = max_daily_limit_gtq / 100.0 WHERE max_daily_limit_gtq > 1000") - - else: - # SQLite: Data conversion only (dynamic typing handles the rest) - await db.execute("UPDATE satoshimachine.dca_deposits SET amount = CAST(amount AS REAL) / 100.0 WHERE currency = 'GTQ'") - await db.execute("UPDATE satoshimachine.dca_payments SET amount_fiat = CAST(amount_fiat AS REAL) / 100.0") - await db.execute("UPDATE satoshimachine.lamassu_transactions SET fiat_amount = CAST(fiat_amount AS REAL) / 100.0") - await db.execute("UPDATE satoshimachine.dca_clients SET fixed_mode_daily_limit = CAST(fixed_mode_daily_limit AS REAL) / 100.0 WHERE fixed_mode_daily_limit IS NOT NULL") - await db.execute("UPDATE satoshimachine.lamassu_config SET max_daily_limit_gtq = CAST(max_daily_limit_gtq AS REAL) / 100.0 WHERE max_daily_limit_gtq > 1000") \ No newline at end of file + ) \ No newline at end of file diff --git a/models.py b/models.py index e4bf64d..9d0e8e7 100644 --- a/models.py +++ b/models.py @@ -3,7 +3,7 @@ from datetime import datetime from typing import Optional -from pydantic import BaseModel, validator +from pydantic import BaseModel # DCA Client Models @@ -12,7 +12,7 @@ class CreateDcaClientData(BaseModel): wallet_id: str username: str dca_mode: str = "flow" # 'flow' or 'fixed' - fixed_mode_daily_limit: Optional[float] = None + fixed_mode_daily_limit: Optional[int] = None class DcaClient(BaseModel): @@ -30,29 +30,22 @@ class DcaClient(BaseModel): class UpdateDcaClientData(BaseModel): username: Optional[str] = None dca_mode: Optional[str] = None - fixed_mode_daily_limit: Optional[float] = None + fixed_mode_daily_limit: Optional[int] = None status: Optional[str] = None -# Deposit Models (Now storing GTQ directly) +# Deposit Models class CreateDepositData(BaseModel): client_id: str - amount: float # Amount in GTQ (e.g., 150.75) + amount: int # Amount in smallest currency unit (centavos for GTQ) currency: str = "GTQ" notes: Optional[str] = None - - @validator('amount') - def round_amount_to_cents(cls, v): - """Ensure amount is rounded to 2 decimal places for DECIMAL(10,2) storage""" - if v is not None: - return round(float(v), 2) - return v class DcaDeposit(BaseModel): id: str client_id: str - amount: float # Amount in GTQ (e.g., 150.75) + amount: int currency: str status: str # 'pending' or 'confirmed' notes: Optional[str] @@ -69,7 +62,7 @@ class UpdateDepositStatusData(BaseModel): class CreateDcaPaymentData(BaseModel): client_id: str amount_sats: int - amount_fiat: float # Amount in GTQ (e.g., 150.75) + amount_fiat: int # Stored in centavos (GTQ * 100) for precision exchange_rate: float transaction_type: str # 'flow', 'fixed', 'manual', 'commission' lamassu_transaction_id: Optional[str] = None @@ -81,7 +74,7 @@ class DcaPayment(BaseModel): id: str client_id: str amount_sats: int - amount_fiat: float # Amount in GTQ (e.g., 150.75) + amount_fiat: int # Stored in centavos (GTQ * 100) for precision exchange_rate: float transaction_type: str lamassu_transaction_id: Optional[str] @@ -91,19 +84,19 @@ class DcaPayment(BaseModel): transaction_time: Optional[datetime] = None # Original ATM transaction time -# Client Balance Summary (Now storing GTQ directly) +# Client Balance Summary class ClientBalanceSummary(BaseModel): client_id: str - total_deposits: float # Total confirmed deposits in GTQ - total_payments: float # Total payments made in GTQ - remaining_balance: float # Available balance for DCA in GTQ + total_deposits: int # Total confirmed deposits + total_payments: int # Total payments made + remaining_balance: int # Available balance for DCA currency: str # Transaction Processing Models class LamassuTransaction(BaseModel): transaction_id: str - amount_fiat: float # Amount in GTQ (e.g., 150.75) + amount_fiat: int # Stored in centavos (GTQ * 100) for precision amount_crypto: int exchange_rate: float transaction_type: str # 'cash_in' or 'cash_out' @@ -114,7 +107,7 @@ class LamassuTransaction(BaseModel): # Lamassu Transaction Storage Models class CreateLamassuTransactionData(BaseModel): lamassu_transaction_id: str - fiat_amount: float # Amount in GTQ (e.g., 150.75) + fiat_amount: int # Stored in centavos (GTQ * 100) for precision crypto_amount: int commission_percentage: float discount: float = 0.0 @@ -131,7 +124,7 @@ class CreateLamassuTransactionData(BaseModel): class StoredLamassuTransaction(BaseModel): id: str lamassu_transaction_id: str - fiat_amount: float # Amount in GTQ (e.g., 150.75) + fiat_amount: int crypto_amount: int commission_percentage: float discount: float @@ -167,14 +160,7 @@ class CreateLamassuConfigData(BaseModel): ssh_password: Optional[str] = None ssh_private_key: Optional[str] = None # Path to private key file or key content # DCA Client Limits - max_daily_limit_gtq: float = 2000.0 # Maximum daily limit for Fixed mode clients - - @validator('max_daily_limit_gtq') - def round_max_daily_limit(cls, v): - """Ensure max daily limit is rounded to 2 decimal places""" - if v is not None: - return round(float(v), 2) - return v + max_daily_limit_gtq: int = 2000 # Maximum daily limit for Fixed mode clients class LamassuConfig(BaseModel): @@ -204,7 +190,7 @@ class LamassuConfig(BaseModel): last_poll_time: Optional[datetime] = None last_successful_poll: Optional[datetime] = None # DCA Client Limits - max_daily_limit_gtq: float = 2000.0 # Maximum daily limit for Fixed mode clients + max_daily_limit_gtq: int = 2000 # Maximum daily limit for Fixed mode clients class UpdateLamassuConfigData(BaseModel): diff --git a/static/js/index.js b/static/js/index.js index 1989e9b..cb3e281 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -134,11 +134,13 @@ window.app = Vue.createApp({ formatCurrency(amount) { if (!amount) return 'Q 0.00'; - // Amount is now stored as GTQ directly in database + // Convert centavos to GTQ for display + const gtqAmount = amount / 100; + return new Intl.NumberFormat('es-GT', { style: 'currency', currency: 'GTQ', - }).format(amount); + }).format(gtqAmount); }, formatDate(dateString) { @@ -303,7 +305,7 @@ window.app = Vue.createApp({ try { const data = { client_id: this.quickDepositForm.selectedClient?.value, - amount: this.quickDepositForm.amount, // Send GTQ directly - now stored as GTQ + amount: Math.round(this.quickDepositForm.amount * 100), // Convert GTQ to centavos currency: 'GTQ', notes: this.quickDepositForm.notes } @@ -376,7 +378,7 @@ window.app = Vue.createApp({ try { const data = { client_id: this.depositFormDialog.data.client_id, - amount: this.depositFormDialog.data.amount, // Send GTQ directly - now stored as GTQ + amount: Math.round(this.depositFormDialog.data.amount * 100), // Convert GTQ to centavos currency: this.depositFormDialog.data.currency, notes: this.depositFormDialog.data.notes } diff --git a/transaction_processor.py b/transaction_processor.py index 9a58408..7e161f7 100644 --- a/transaction_processor.py +++ b/transaction_processor.py @@ -493,26 +493,14 @@ class LamassuTransactionProcessor: # Convert string values to appropriate types processed_row = {} for key, value in row.items(): - # Handle None/empty values consistently at data ingestion boundary - if value == '' or value is None: - if key in ['fiat_amount', 'crypto_amount']: - processed_row[key] = 0 # Default numeric fields to 0 - elif key in ['commission_percentage', 'discount']: - processed_row[key] = 0.0 # Default percentage fields to 0.0 - else: - processed_row[key] = None # Keep None for non-numeric fields + if value == '': + processed_row[key] = None elif key in ['transaction_id', 'device_id', 'crypto_code', 'fiat_code']: processed_row[key] = str(value) elif key in ['fiat_amount', 'crypto_amount']: - try: - processed_row[key] = int(float(value)) - except (ValueError, TypeError): - processed_row[key] = 0 # Fallback to 0 for invalid values + processed_row[key] = int(float(value)) if value else 0 elif key in ['commission_percentage', 'discount']: - try: - processed_row[key] = float(value) - except (ValueError, TypeError): - processed_row[key] = 0.0 # Fallback to 0.0 for invalid values + processed_row[key] = float(value) if value else 0.0 elif key == 'transaction_time': from datetime import datetime # Parse PostgreSQL timestamp format and ensure it's in UTC for consistency @@ -603,7 +591,7 @@ class LamassuTransactionProcessor: AND co.status IN ('confirmed', 'authorized') AND co.dispense = 't' AND co.dispense_confirmed = 't' - ORDER BY co.confirmed_at ASC + ORDER BY co.confirmed_at DESC """ all_transactions = await self.execute_ssh_query(db_config, lamassu_query) @@ -640,11 +628,11 @@ class LamassuTransactionProcessor: logger.info("No Flow Mode clients found - skipping distribution") return {} - # Extract transaction details - guaranteed clean from data ingestion - crypto_atoms = transaction.get("crypto_amount", 0) # Total sats with commission baked in - fiat_amount = transaction.get("fiat_amount", 0) # Actual fiat dispensed (principal only) - commission_percentage = transaction.get("commission_percentage", 0.0) # Already stored as decimal (e.g., 0.045) - discount = transaction.get("discount", 0.0) # Discount percentage + # Extract transaction details with None-safe defaults + crypto_atoms = transaction.get("crypto_amount") # Total sats with commission baked in + fiat_amount = transaction.get("fiat_amount") # Actual fiat dispensed (principal only) + commission_percentage = transaction.get("commission_percentage") # Already stored as decimal (e.g., 0.045) + discount = transaction.get("discount") # Discount percentage transaction_time = transaction.get("transaction_time") # ATM transaction timestamp for temporal accuracy # Normalize transaction_time to UTC if present @@ -683,7 +671,7 @@ class LamassuTransactionProcessor: # 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)) + base_crypto_atoms = int(crypto_atoms / (1 + effective_commission)) commission_amount_sats = crypto_atoms - base_crypto_atoms else: effective_commission = 0.0 @@ -708,14 +696,9 @@ class LamassuTransactionProcessor: for client in flow_clients: # Get balance as of the transaction time for temporal accuracy balance = await get_client_balance_summary(client.id, as_of_time=transaction_time) - # Only include clients with positive remaining balance - # NOTE: This works for fiat amounts that use cents - if balance.remaining_balance >= 0.01: + if balance.remaining_balance > 0: # Only include clients with remaining balance client_balances[client.id] = balance.remaining_balance total_confirmed_deposits += balance.remaining_balance - logger.debug(f"Client {client.id[:8]}... included with balance: {balance.remaining_balance:.2f} GTQ") - else: - logger.info(f"Client {client.id[:8]}... excluded - zero/negative balance: {balance.remaining_balance:.2f} GTQ") if total_confirmed_deposits == 0: logger.info("No clients with remaining DCA balance - skipping distribution") @@ -772,8 +755,9 @@ class LamassuTransactionProcessor: client_sats_amount = calc['allocated_sats'] proportion = calc['proportion'] - # 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 + # Calculate equivalent fiat value in centavos for tracking purposes (industry standard) + # Store as centavos to maintain precision and avoid floating-point errors + client_fiat_amount = round(client_sats_amount * 100 / exchange_rate) if exchange_rate > 0 else 0 distributions[client_id] = { "fiat_amount": client_fiat_amount, @@ -781,7 +765,7 @@ class LamassuTransactionProcessor: "exchange_rate": exchange_rate } - logger.info(f"Client {client_id[:8]}... gets {client_sats_amount} sats (≈{client_fiat_amount:.2f} GTQ, {proportion:.2%} share)") + logger.info(f"Client {client_id[:8]}... gets {client_sats_amount} sats (≈{client_fiat_amount/100:.2f} GTQ, {proportion:.2%} share)") # Verification: ensure total distribution equals base amount total_distributed = sum(dist["sats_amount"] for dist in distributions.values()) @@ -789,31 +773,7 @@ class LamassuTransactionProcessor: 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}") - # Safety check: Re-verify all clients still have positive balances before finalizing distributions - # This prevents race conditions where balances changed during calculation - final_distributions = {} - for client_id, distribution in distributions.items(): - # Re-check current balance (without temporal filtering to get most recent state) - current_balance = await get_client_balance_summary(client_id) - if current_balance.remaining_balance > 0: - final_distributions[client_id] = distribution - logger.info(f"Client {client_id[:8]}... final balance check: {current_balance.remaining_balance:.2f} GTQ - APPROVED for {distribution['sats_amount']} sats") - else: - logger.warning(f"Client {client_id[:8]}... final balance check: {current_balance.remaining_balance:.2f} GTQ - REJECTED (negative balance)") - - if len(final_distributions) != len(distributions): - logger.warning(f"Rejected {len(distributions) - len(final_distributions)} clients due to negative balances during final check") - - # Recalculate proportions if some clients were rejected - if len(final_distributions) == 0: - logger.info("All clients rejected due to negative balances - no distributions") - return {} - - # 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 - 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") return distributions except Exception as e: @@ -843,28 +803,11 @@ class LamassuTransactionProcessor: logger.error(f"Client {client_id} not found") continue - # Final safety check: Verify client still has positive balance before payment - current_balance = await get_client_balance_summary(client_id) - if current_balance.remaining_balance <= 0: - logger.error(f"CRITICAL: Client {client_id[:8]}... has negative balance ({current_balance.remaining_balance:.2f} GTQ) - REFUSING payment of {distribution['sats_amount']} sats") - continue - - # Verify balance is sufficient for this distribution (round to 2 decimal places to match DECIMAL(10,2) precision) - fiat_equivalent = distribution["fiat_amount"] # Amount in GTQ - # Round both values to 2 decimal places to match database precision and avoid floating point comparison issues - balance_rounded = round(current_balance.remaining_balance, 2) - amount_rounded = round(fiat_equivalent, 2) - if balance_rounded < amount_rounded: - logger.error(f"CRITICAL: Client {client_id[:8]}... insufficient balance ({balance_rounded:.2f} < {amount_rounded:.2f} GTQ) - REFUSING payment") - continue - - logger.info(f"Client {client_id[:8]}... pre-payment balance check: {current_balance.remaining_balance:.2f} GTQ - SUFFICIENT for {fiat_equivalent:.2f} GTQ payment") - # Create DCA payment record payment_data = CreateDcaPaymentData( client_id=client_id, amount_sats=distribution["sats_amount"], - amount_fiat=distribution["fiat_amount"], # Amount in GTQ + amount_fiat=distribution["fiat_amount"], # Still store centavos in DB exchange_rate=distribution["exchange_rate"], transaction_type="flow", lamassu_transaction_id=transaction_id, @@ -907,9 +850,12 @@ class LamassuTransactionProcessor: return False # Create descriptive memo with DCA metrics - fiat_amount_gtq = distribution.get("fiat_amount", 0.0) + fiat_amount_centavos = distribution.get("fiat_amount", 0) exchange_rate = distribution.get("exchange_rate", 0) + # Convert centavos to GTQ for display + fiat_amount_gtq = fiat_amount_centavos / 100 + # Calculate cost basis (fiat per BTC) if exchange_rate > 0: # exchange_rate is sats per fiat unit, so convert to fiat per BTC @@ -1010,11 +956,11 @@ class LamassuTransactionProcessor: async def store_lamassu_transaction(self, transaction: Dict[str, Any]) -> Optional[str]: """Store the Lamassu transaction in our database for audit and UI""" try: - # Extract transaction data - guaranteed clean from data ingestion boundary + # Extract and validate transaction data crypto_atoms = transaction.get("crypto_amount", 0) fiat_amount = transaction.get("fiat_amount", 0) - commission_percentage = transaction.get("commission_percentage", 0.0) - discount = transaction.get("discount", 0.0) + commission_percentage = transaction.get("commission_percentage") or 0.0 + discount = transaction.get("discount") or 0.0 transaction_time = transaction.get("transaction_time") # Normalize transaction_time to UTC if present @@ -1027,7 +973,7 @@ class LamassuTransactionProcessor: # Calculate commission metrics if commission_percentage > 0: effective_commission = commission_percentage * (100 - discount) / 100 - base_crypto_atoms = round(crypto_atoms / (1 + effective_commission)) + base_crypto_atoms = int(crypto_atoms / (1 + effective_commission)) commission_amount_sats = crypto_atoms - base_crypto_atoms else: effective_commission = 0.0 @@ -1037,10 +983,10 @@ class LamassuTransactionProcessor: # Calculate exchange rate exchange_rate = base_crypto_atoms / fiat_amount if fiat_amount > 0 else 0 - # Create transaction data with GTQ amounts + # Create transaction data (store fiat_amount in centavos for consistency) transaction_data = CreateLamassuTransactionData( lamassu_transaction_id=transaction["transaction_id"], - fiat_amount=round(fiat_amount, 2), # Store GTQ with 2 decimal places + fiat_amount=int(fiat_amount * 100), # Convert GTQ to centavos crypto_amount=crypto_atoms, commission_percentage=commission_percentage, discount=discount, @@ -1081,19 +1027,7 @@ class LamassuTransactionProcessor: # Create invoice in commission wallet with DCA metrics fiat_amount = transaction.get("fiat_amount", 0) commission_percentage = transaction.get("commission_percentage", 0) * 100 # Convert to percentage - discount = transaction.get("discount", 0.0) # Discount percentage - - # Calculate effective commission for display - if commission_percentage > 0: - effective_commission_percentage = commission_percentage * (100 - discount) / 100 - else: - effective_commission_percentage = 0.0 - - # Create detailed memo showing discount if applied - if discount > 0: - commission_memo = f"DCA Commission: {commission_amount_sats:,} sats • {commission_percentage:.1f}% - {discount:.1f}% discount = {effective_commission_percentage:.1f}% effective • {fiat_amount:,} GTQ transaction" - else: - commission_memo = f"DCA Commission: {commission_amount_sats:,} sats • {commission_percentage:.1f}% • {fiat_amount:,} GTQ transaction" + commission_memo = f"DCA Commission: {commission_amount_sats:,} sats • {commission_percentage:.1f}% • {fiat_amount:,} GTQ transaction" commission_payment = await create_invoice( wallet_id=admin_config.commission_wallet_id, @@ -1160,12 +1094,12 @@ class LamassuTransactionProcessor: # Calculate commission amount for sending to commission wallet crypto_atoms = transaction.get("crypto_amount", 0) - commission_percentage = transaction.get("commission_percentage", 0.0) - discount = transaction.get("discount", 0.0) + commission_percentage = transaction.get("commission_percentage") or 0.0 + discount = transaction.get("discount") or 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)) + base_crypto_atoms = int(crypto_atoms / (1 + effective_commission)) commission_amount_sats = crypto_atoms - base_crypto_atoms else: commission_amount_sats = 0