Compare commits

..

No commits in common. "bd7a72f3c0ad9bda558c80c0e53e5af02c75e6b5" and "f0f38f73bff55bde9bd07ff4ce3c7fef8f41bd84" have entirely different histories.

5 changed files with 57 additions and 170 deletions

View file

@ -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,

View file

@ -135,38 +135,3 @@ async def m003_add_max_daily_limit_config(db):
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")

View file

@ -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):

View file

@ -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
}

View file

@ -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,18 +1027,6 @@ 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_payment = await create_invoice(
@ -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