Compare commits

..

No commits in common. "cd0d958c2cc0b599c75dbead396e90aad84829f6" and "aa71321c84d6d88779c63becb25144780db0ac1b" have entirely different histories.

11 changed files with 249 additions and 3199 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,

45
currency_utils.py Normal file
View file

@ -0,0 +1,45 @@
# Currency conversion utilities for API boundary
from decimal import Decimal
from typing import Union
def gtq_to_centavos(gtq_amount: Union[float, int, str]) -> int:
"""Convert GTQ to centavos for database storage"""
return int(Decimal(str(gtq_amount)) * 100)
def centavos_to_gtq(centavos: int) -> float:
"""Convert centavos to GTQ for API responses"""
return float(centavos) / 100
def format_gtq_currency(centavos: int) -> str:
"""Format centavos as GTQ currency string"""
gtq_amount = centavos_to_gtq(centavos)
return f"Q{gtq_amount:.2f}"
# Conversion helpers for API responses
def deposit_db_to_api(deposit_db) -> dict:
"""Convert database deposit model to API response"""
return {
"id": deposit_db.id,
"client_id": deposit_db.client_id,
"amount_gtq": centavos_to_gtq(deposit_db.amount),
"currency": deposit_db.currency,
"status": deposit_db.status,
"notes": deposit_db.notes,
"created_at": deposit_db.created_at,
"confirmed_at": deposit_db.confirmed_at
}
def balance_summary_db_to_api(balance_db) -> dict:
"""Convert database balance summary to API response"""
return {
"client_id": balance_db.client_id,
"total_deposits_gtq": centavos_to_gtq(balance_db.total_deposits),
"total_payments_gtq": centavos_to_gtq(balance_db.total_payments),
"remaining_balance_gtq": centavos_to_gtq(balance_db.remaining_balance),
"currency": balance_db.currency
}

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")

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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,45 @@ 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)
class CreateDepositData(BaseModel):
# API Models for Deposits (Frontend <-> Backend communication)
class CreateDepositAPI(BaseModel):
"""API model - frontend sends GTQ amounts"""
client_id: str
amount: float # Amount in GTQ (e.g., 150.75)
amount_gtq: float # Amount in GTQ (e.g., 150.75)
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 DepositAPI(BaseModel):
"""API model - backend returns GTQ amounts"""
id: str
client_id: str
amount_gtq: float # Amount in GTQ (e.g., 150.75)
currency: str
status: str # 'pending' or 'confirmed'
notes: Optional[str]
created_at: datetime
confirmed_at: Optional[datetime]
# Database Models for Deposits (Internal storage in centavos)
class CreateDepositData(BaseModel):
"""Internal model - database stores centavos"""
client_id: str
amount: int # Amount in smallest currency unit (centavos for GTQ)
currency: str = "GTQ"
notes: Optional[str] = None
class DcaDeposit(BaseModel):
"""Internal model - database stores centavos"""
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 +85,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 +97,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 +107,30 @@ class DcaPayment(BaseModel):
transaction_time: Optional[datetime] = None # Original ATM transaction time
# Client Balance Summary (Now storing GTQ directly)
class ClientBalanceSummary(BaseModel):
# API Models for Client Balance Summary
class ClientBalanceSummaryAPI(BaseModel):
"""API model - returns GTQ amounts"""
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_gtq: float # Total confirmed deposits in GTQ
total_payments_gtq: float # Total payments made in GTQ
remaining_balance_gtq: float # Available balance for DCA in GTQ
currency: str
# Internal Models for Client Balance Summary
class ClientBalanceSummary(BaseModel):
"""Internal model - stores centavos"""
client_id: str
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 +141,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 +158,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 +194,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 +224,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

@ -27,7 +27,7 @@ window.app = Vue.createApp({
depositsTable: {
columns: [
{ name: 'client_id', align: 'left', label: 'Client', field: 'client_id' },
{ name: 'amount', align: 'left', label: 'Amount', field: 'amount' },
{ name: 'amount_gtq', align: 'left', label: 'Amount', field: 'amount_gtq' },
{ name: 'currency', align: 'left', label: 'Currency', field: 'currency' },
{ name: 'status', align: 'left', label: 'Status', field: 'status' },
{ name: 'created_at', align: 'left', label: 'Created', field: 'created_at' },
@ -92,15 +92,8 @@ window.app = Vue.createApp({
testingConnection: false,
runningManualPoll: false,
runningTestTransaction: false,
processingSpecificTransaction: false,
lamassuConfig: null,
// Manual transaction processing
manualTransactionDialog: {
show: false,
transactionId: ''
},
// Config dialog
configDialog: {
show: false,
@ -137,11 +130,11 @@ window.app = Vue.createApp({
///////////////////////////////////////////////////
methods: {
// Utility Methods
// Utility Methods - Simplified since API handles conversion
formatCurrency(amount) {
if (!amount) return 'Q 0.00';
// Amount is now stored as GTQ directly in database
// Amount is already in GTQ from API
return new Intl.NumberFormat('es-GT', {
style: 'currency',
currency: 'GTQ',
@ -286,7 +279,7 @@ window.app = Vue.createApp({
)
return {
...client,
remaining_balance: balance.remaining_balance
remaining_balance: balance.remaining_balance_gtq
}
} catch (error) {
console.error(`Error fetching balance for client ${client.id}:`, error)
@ -310,7 +303,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_gtq: this.quickDepositForm.amount, // Send GTQ directly - API handles conversion
currency: 'GTQ',
notes: this.quickDepositForm.notes
}
@ -383,7 +376,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_gtq: this.depositFormDialog.data.amount, // Send GTQ directly - API handles conversion
currency: this.depositFormDialog.data.currency,
notes: this.depositFormDialog.data.notes
}
@ -593,79 +586,6 @@ window.app = Vue.createApp({
await this.getDeposits()
await this.getLamassuTransactions()
await this.getLamassuConfig()
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.runningTestTransaction = false
}
},
openManualTransactionDialog() {
this.manualTransactionDialog.transactionId = ''
this.manualTransactionDialog.show = true
},
async processSpecificTransaction() {
if (!this.manualTransactionDialog.transactionId) {
this.$q.notify({
type: 'warning',
message: 'Please enter a transaction ID',
timeout: 3000
})
return
}
this.processingSpecificTransaction = true
try {
const { data } = await LNbits.api.request(
'POST',
`/satmachineadmin/api/v1/dca/process-transaction/${this.manualTransactionDialog.transactionId}`,
null
)
if (data.already_processed) {
this.$q.notify({
type: 'warning',
message: `Transaction already processed with ${data.payment_count} distributions`,
timeout: 5000
})
this.manualTransactionDialog.show = false
return
}
// Show detailed results
const details = data.transaction_details
let dialogContent = `<strong>Manual Transaction Processing Results</strong><br/><br/>`
dialogContent += `<strong>Transaction ID:</strong> ${details.transaction_id}<br/>`
dialogContent += `<strong>Status:</strong> ${details.status}<br/>`
dialogContent += `<strong>Dispense:</strong> ${details.dispense ? 'Yes' : 'No'}<br/>`
dialogContent += `<strong>Dispense Confirmed:</strong> ${details.dispense_confirmed ? 'Yes' : 'No'}<br/>`
dialogContent += `<strong>Crypto Amount:</strong> ${details.crypto_amount} sats<br/>`
dialogContent += `<strong>Fiat Amount:</strong> ${details.fiat_amount}<br/>`
dialogContent += `<br/><strong>Transaction processed successfully!</strong>`
this.$q.dialog({
title: 'Transaction Processed',
message: dialogContent,
html: true,
ok: {
color: 'positive',
label: 'Great!'
}
})
this.$q.notify({
type: 'positive',
message: `Transaction ${details.transaction_id} processed successfully`,
timeout: 5000
})
// Close dialog and refresh data
this.manualTransactionDialog.show = false
await this.getDcaClients()
await this.getDeposits()
await this.getLamassuTransactions()
await this.getLamassuConfig()
} catch (error) {
LNbits.utils.notifyApiError(error)

View file

@ -173,7 +173,7 @@
<q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<div v-if="col.field == 'client_id'">${ getClientUsername(col.value) }</div>
<div v-else-if="col.field == 'amount'">${ formatCurrency(col.value) }</div>
<div v-else-if="col.field == 'amount_gtq'">${ formatCurrency(col.value) }</div>
<div v-else-if="col.field == 'status'">
<q-badge :color="col.value === 'confirmed' ? 'green' : 'orange'">
${ col.value }
@ -346,15 +346,14 @@
Manual Poll
</q-btn>
<q-btn
v-if="lamassuConfig"
v-if="lamassuConfig && lamassuConfig.source_wallet_id"
size="sm"
color="deep-orange"
@click="openManualTransactionDialog"
color="warning"
@click="testTransaction"
:loading="runningTestTransaction"
class="q-ml-sm"
icon="build"
>
<q-tooltip>Process specific transaction by ID (bypasses dispense checks)</q-tooltip>
Manual TX
Test Transaction
</q-btn>
</div>
</q-card-section>
@ -471,9 +470,9 @@
<q-item-section>
<q-item-label caption>Balance Summary</q-item-label>
<q-item-label v-if="clientDetailsDialog.balance">
Deposits: ${ formatCurrency(clientDetailsDialog.balance.total_deposits) } |
Payments: ${ formatCurrency(clientDetailsDialog.balance.total_payments) } |
Remaining: ${ formatCurrency(clientDetailsDialog.balance.remaining_balance) }
Deposits: ${ formatCurrency(clientDetailsDialog.balance.total_deposits_gtq) } |
Payments: ${ formatCurrency(clientDetailsDialog.balance.total_payments_gtq) } |
Remaining: ${ formatCurrency(clientDetailsDialog.balance.remaining_balance_gtq) }
</q-item-label>
</q-item-section>
</q-item>
@ -779,64 +778,5 @@
</q-card>
</q-dialog>
<!--/////////////////////////////////////////////////-->
<!--//////////////MANUAL TRANSACTION DIALOG///////////-->
<!--/////////////////////////////////////////////////-->
<q-dialog v-model="manualTransactionDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px; max-width: 90vw">
<div class="text-h6 q-mb-md">Process Specific Transaction</div>
<q-banner class="bg-orange-1 text-orange-9 q-mb-md">
<template v-slot:avatar>
<q-icon name="warning" color="orange" />
</template>
<div class="text-caption">
<strong>Use with caution:</strong> This bypasses all dispense status checks and will process the transaction even if dispense_confirmed is false. Only use this for manually settled transactions.
</div>
</q-banner>
<q-form @submit="processSpecificTransaction" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="manualTransactionDialog.transactionId"
label="Lamassu Transaction ID *"
placeholder="e.g., 82746dfb-674d-4d7e-b008-7507caa02773"
hint="Enter the transaction/session ID from Lamassu database"
>
<template v-slot:prepend>
<q-icon name="receipt" />
</template>
</q-input>
<div class="text-caption text-grey-7">
This will:
<ul class="q-my-sm">
<li>Fetch the transaction from Lamassu regardless of dispense status</li>
<li>Process it through the normal DCA distribution flow</li>
<li>Credit the source wallet and distribute to clients</li>
<li>Send commission to the commission wallet (if configured)</li>
</ul>
</div>
<div class="row q-mt-lg">
<q-btn
unelevated
color="deep-orange"
type="submit"
:loading="processingSpecificTransaction"
:disable="!manualTransactionDialog.transactionId"
>
Process Transaction
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">
Cancel
</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %}

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
@ -560,44 +548,6 @@ class LamassuTransactionProcessor:
logger.error(f"Error executing SSH query: {e}")
return []
async def fetch_transaction_by_id(self, db_config: Dict[str, Any], transaction_id: str) -> Optional[Dict[str, Any]]:
"""Fetch a specific transaction by ID from Lamassu database, bypassing all status filters"""
try:
logger.info(f"Fetching transaction {transaction_id} from Lamassu database (bypass all filters)")
# Query for specific transaction ID without any status/dispense filters
lamassu_query = f"""
SELECT
co.id as transaction_id,
co.fiat as fiat_amount,
co.crypto_atoms as crypto_amount,
co.confirmed_at as transaction_time,
co.device_id,
co.status,
co.commission_percentage,
co.discount,
co.crypto_code,
co.fiat_code,
co.dispense,
co.dispense_confirmed
FROM cash_out_txs co
WHERE co.id = '{transaction_id}'
"""
results = await self.execute_ssh_query(db_config, lamassu_query)
if not results:
logger.warning(f"Transaction {transaction_id} not found in Lamassu database")
return None
transaction = results[0]
logger.info(f"Found transaction {transaction_id}: status={transaction.get('status')}, dispense={transaction.get('dispense')}, dispense_confirmed={transaction.get('dispense_confirmed')}")
return transaction
except Exception as e:
logger.error(f"Error fetching transaction {transaction_id} from Lamassu database: {e}")
return None
async def fetch_new_transactions(self, db_config: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Fetch new successful transactions from Lamassu database since last poll"""
try:
@ -678,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
@ -721,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
@ -746,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")
@ -810,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,
@ -819,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())
@ -835,9 +781,9 @@ class LamassuTransactionProcessor:
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")
logger.info(f"Client {client_id[:8]}... final balance check: {current_balance.remaining_balance} centavos - 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)")
logger.warning(f"Client {client_id[:8]}... final balance check: {current_balance.remaining_balance} centavos - 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")
@ -851,8 +797,8 @@ class LamassuTransactionProcessor:
# 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)")
return distributions
logger.info(f"Distribution verified: {total_distributed} sats distributed across {len(final_distributions)} clients")
return final_distributions
except Exception as e:
logger.error(f"Error calculating distribution amounts: {e}")
@ -884,25 +830,22 @@ class LamassuTransactionProcessor:
# 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")
logger.error(f"CRITICAL: Client {client_id[:8]}... has negative balance ({current_balance.remaining_balance} centavos) - 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")
# Verify balance is sufficient for this distribution
fiat_equivalent = distribution["fiat_amount"] # Already in centavos
if current_balance.remaining_balance < fiat_equivalent:
logger.error(f"CRITICAL: Client {client_id[:8]}... insufficient balance ({current_balance.remaining_balance} < {fiat_equivalent} centavos) - 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")
logger.info(f"Client {client_id[:8]}... pre-payment balance check: {current_balance.remaining_balance} centavos - SUFFICIENT for {fiat_equivalent} centavos 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,
@ -945,9 +888,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
@ -1048,11 +994,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
@ -1065,7 +1011,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
@ -1075,10 +1021,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,
@ -1119,18 +1065,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(
@ -1198,12 +1132,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

View file

@ -38,14 +38,23 @@ from .models import (
DcaClient,
UpdateDcaClientData,
CreateDepositData,
CreateDepositAPI,
DepositAPI,
DcaDeposit,
UpdateDepositStatusData,
ClientBalanceSummary,
ClientBalanceSummaryAPI,
CreateLamassuConfigData,
LamassuConfig,
UpdateLamassuConfigData,
StoredLamassuTransaction,
)
from .currency_utils import (
gtq_to_centavos,
centavos_to_gtq,
deposit_db_to_api,
balance_summary_db_to_api,
)
satmachineadmin_api_router = APIRouter()
@ -87,7 +96,7 @@ async def api_get_dca_client(
async def api_get_client_balance(
client_id: str,
wallet: WalletTypeInfo = Depends(check_super_user),
) -> ClientBalanceSummary:
) -> ClientBalanceSummaryAPI:
"""Get client balance summary"""
client = await get_dca_client(client_id)
if not client:
@ -95,7 +104,8 @@ async def api_get_client_balance(
status_code=HTTPStatus.NOT_FOUND, detail="DCA client not found."
)
return await get_client_balance_summary(client_id)
balance_db = await get_client_balance_summary(client_id)
return ClientBalanceSummaryAPI(**balance_summary_db_to_api(balance_db))
# DCA Deposit Endpoints
@ -104,30 +114,31 @@ async def api_get_client_balance(
@satmachineadmin_api_router.get("/api/v1/dca/deposits")
async def api_get_deposits(
wallet: WalletTypeInfo = Depends(check_super_user),
) -> list[DcaDeposit]:
) -> list[DepositAPI]:
"""Get all deposits"""
return await get_all_deposits()
deposits_db = await get_all_deposits()
return [DepositAPI(**deposit_db_to_api(deposit)) for deposit in deposits_db]
@satmachineadmin_api_router.get("/api/v1/dca/deposits/{deposit_id}")
async def api_get_deposit(
deposit_id: str,
wallet: WalletTypeInfo = Depends(check_super_user),
) -> DcaDeposit:
) -> DepositAPI:
"""Get a specific deposit"""
deposit = await get_deposit(deposit_id)
if not deposit:
deposit_db = await get_deposit(deposit_id)
if not deposit_db:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Deposit not found."
)
return deposit
return DepositAPI(**deposit_db_to_api(deposit_db))
@satmachineadmin_api_router.post("/api/v1/dca/deposits", status_code=HTTPStatus.CREATED)
async def api_create_deposit(
data: CreateDepositData,
data: CreateDepositAPI,
user: User = Depends(check_super_user),
) -> DcaDeposit:
) -> DepositAPI:
"""Create a new deposit"""
# Verify client exists
client = await get_dca_client(data.client_id)
@ -136,7 +147,16 @@ async def api_create_deposit(
status_code=HTTPStatus.NOT_FOUND, detail="DCA client not found."
)
return await create_deposit(data)
# Convert GTQ to centavos at API boundary
deposit_data = CreateDepositData(
client_id=data.client_id,
amount=gtq_to_centavos(data.amount_gtq),
currency=data.currency,
notes=data.notes
)
deposit_db = await create_deposit(deposit_data)
return DepositAPI(**deposit_db_to_api(deposit_db))
@satmachineadmin_api_router.put("/api/v1/dca/deposits/{deposit_id}/status")
@ -144,7 +164,7 @@ async def api_update_deposit_status(
deposit_id: str,
data: UpdateDepositStatusData,
user: User = Depends(check_super_user),
) -> DcaDeposit:
) -> DepositAPI:
"""Update deposit status (e.g., confirm deposit)"""
deposit = await get_deposit(deposit_id)
if not deposit:
@ -152,13 +172,13 @@ async def api_update_deposit_status(
status_code=HTTPStatus.NOT_FOUND, detail="Deposit not found."
)
updated_deposit = await update_deposit_status(deposit_id, data)
if not updated_deposit:
updated_deposit_db = await update_deposit_status(deposit_id, data)
if not updated_deposit_db:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Failed to update deposit.",
)
return updated_deposit
return DepositAPI(**deposit_db_to_api(updated_deposit_db))
# Transaction Polling Endpoints
@ -233,139 +253,69 @@ async def api_manual_poll(
)
@satmachineadmin_api_router.post("/api/v1/dca/process-transaction/{transaction_id}")
async def api_process_specific_transaction(
transaction_id: str,
@satmachineadmin_api_router.post("/api/v1/dca/test-transaction")
async def api_test_transaction(
user: User = Depends(check_super_user),
):
"""
Manually process a specific Lamassu transaction by ID, bypassing all status filters.
This endpoint is useful for processing transactions that were manually settled
or had dispense issues but need to be included in DCA distribution.
"""
crypto_atoms: int = 103,
commission_percentage: float = 0.03,
discount: float = 0.0,
) -> dict:
"""Test transaction processing with simulated Lamassu transaction data"""
try:
from .transaction_processor import transaction_processor
from .crud import get_payments_by_lamassu_transaction
import uuid
from datetime import datetime, timezone
# Get database configuration
db_config = await transaction_processor.connect_to_lamassu_db()
if not db_config:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail="Could not get Lamassu database configuration",
)
# Check if transaction was already processed
existing_payments = await get_payments_by_lamassu_transaction(transaction_id)
if existing_payments:
return {
"success": False,
"already_processed": True,
"message": f"Transaction {transaction_id} was already processed with {len(existing_payments)} distributions",
"payment_count": len(existing_payments),
# Create a mock transaction that mimics Lamassu database structure
mock_transaction = {
"transaction_id": str(uuid.uuid4())[:8], # Short ID for testing
"crypto_amount": crypto_atoms, # Total sats including commission
"fiat_amount": 100, # Mock fiat amount (100 centavos = 1 GTQ)
"commission_percentage": commission_percentage, # Already as decimal
"discount": discount,
"transaction_time": datetime.now(timezone.utc),
"crypto_code": "BTC",
"fiat_code": "GTQ",
"device_id": "test_device",
"status": "confirmed",
}
# Fetch the specific transaction from Lamassu (bypassing all filters)
transaction = await transaction_processor.fetch_transaction_by_id(db_config, transaction_id)
# Process the mock transaction through the complete DCA flow
await transaction_processor.process_transaction(mock_transaction)
if not transaction:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Transaction {transaction_id} not found in Lamassu database",
)
# Process the transaction through normal DCA flow
await transaction_processor.process_transaction(transaction)
# Calculate commission for response
if commission_percentage > 0:
effective_commission = commission_percentage * (100 - discount) / 100
base_crypto_atoms = int(crypto_atoms / (1 + effective_commission))
commission_amount_sats = crypto_atoms - base_crypto_atoms
else:
base_crypto_atoms = crypto_atoms
commission_amount_sats = 0
return {
"success": True,
"message": f"Transaction {transaction_id} processed successfully",
"message": "Test transaction processed successfully",
"transaction_details": {
"transaction_id": transaction_id,
"status": transaction.get("status"),
"dispense": transaction.get("dispense"),
"dispense_confirmed": transaction.get("dispense_confirmed"),
"crypto_amount": transaction.get("crypto_amount"),
"fiat_amount": transaction.get("fiat_amount"),
"transaction_id": mock_transaction["transaction_id"],
"total_amount_sats": crypto_atoms,
"base_amount_sats": base_crypto_atoms,
"commission_amount_sats": commission_amount_sats,
"commission_percentage": commission_percentage
* 100, # Show as percentage
"effective_commission": effective_commission * 100
if commission_percentage > 0
else 0,
"discount": discount,
},
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Error processing transaction {transaction_id}: {str(e)}",
detail=f"Error processing test transaction: {str(e)}",
)
# COMMENTED OUT FOR PRODUCTION - Test transaction endpoint disabled
# Uncomment only for development/debugging purposes
#
# @satmachineadmin_api_router.post("/api/v1/dca/test-transaction")
# async def api_test_transaction(
# user: User = Depends(check_super_user),
# crypto_atoms: int = 103,
# commission_percentage: float = 0.03,
# discount: float = 0.0,
# ) -> dict:
# """Test transaction processing with simulated Lamassu transaction data"""
# try:
# from .transaction_processor import transaction_processor
# import uuid
# from datetime import datetime, timezone
#
# # Create a mock transaction that mimics Lamassu database structure
# mock_transaction = {
# "transaction_id": str(uuid.uuid4())[:8], # Short ID for testing
# "crypto_amount": crypto_atoms, # Total sats including commission
# "fiat_amount": 100, # Mock fiat amount (100 centavos = 1 GTQ)
# "commission_percentage": commission_percentage, # Already as decimal
# "discount": discount,
# "transaction_time": datetime.now(timezone.utc),
# "crypto_code": "BTC",
# "fiat_code": "GTQ",
# "device_id": "test_device",
# "status": "confirmed",
# }
#
# # Process the mock transaction through the complete DCA flow
# await transaction_processor.process_transaction(mock_transaction)
#
# # Calculate commission for response
# if commission_percentage > 0:
# effective_commission = commission_percentage * (100 - discount) / 100
# base_crypto_atoms = int(crypto_atoms / (1 + effective_commission))
# commission_amount_sats = crypto_atoms - base_crypto_atoms
# else:
# base_crypto_atoms = crypto_atoms
# commission_amount_sats = 0
#
# return {
# "success": True,
# "message": "Test transaction processed successfully",
# "transaction_details": {
# "transaction_id": mock_transaction["transaction_id"],
# "total_amount_sats": crypto_atoms,
# "base_amount_sats": base_crypto_atoms,
# "commission_amount_sats": commission_amount_sats,
# "commission_percentage": commission_percentage
# * 100, # Show as percentage
# "effective_commission": effective_commission * 100
# if commission_percentage > 0
# else 0,
# "discount": discount,
# },
# }
#
# except Exception as e:
# raise HTTPException(
# status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
# detail=f"Error processing test transaction: {str(e)}",
# )
# Lamassu Transaction Endpoints