Compare commits

...

10 commits

Author SHA1 Message Date
cd0d958c2c consolidate docs
Some checks failed
CI / lint (push) Has been cancelled
CI / tests (3.10) (push) Has been cancelled
CI / tests (3.9) (push) Has been cancelled
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-11-03 22:23:10 +01:00
1b7374fa70 Removes test transaction UI button
Removes the test transaction button from the admin UI.

The test transaction endpoint is still available in the API for development and debugging purposes.
2025-11-03 22:23:10 +01:00
fe38e08d4e Adds manual transaction processing feature
Implements functionality to manually process specific Lamassu transactions by ID, bypassing dispense checks.

This allows administrators to handle transactions that may have failed due to dispense issues or were settled manually outside of the automated process.

The feature includes a new UI dialog for entering the transaction ID and an API endpoint to fetch and process the transaction, crediting wallets and distributing funds according to the DCA configuration.
2025-11-03 22:23:08 +01:00
230beccc37 Improve balance verification in LamassuTransactionProcessor: Added rounding to two decimal places for balance and fiat amounts to ensure precision in comparisons. Enhanced logging for insufficient balance scenarios, improving clarity in transaction processing and error reporting. 2025-11-03 22:23:04 +01:00
077e097fc2 Refactor transaction data handling in LamassuTransactionProcessor: Improved consistency in processing None and empty values during data ingestion. Defaulted numeric fields to 0 and percentage fields to 0.0 for better error handling. Ensured clean extraction of transaction details, enhancing reliability in transaction processing. 2025-11-03 22:23:04 +01:00
4843b43147 FIX: exclude flow_clients remaining_balance values less than 0.01 2025-07-06 02:04:11 +02:00
5d41e0c50e Refine distribution logic in transaction processing: Updated the LamassuTransactionProcessor to only create distributions for clients with positive allocated sats. Enhanced logging to indicate when clients are skipped due to zero amounts, improving clarity and accuracy in transaction reporting. 2025-07-06 01:15:26 +02:00
bca39b91cd Enhance commission memo generation in transaction processing: Added discount handling to the commission memo in LamassuTransactionProcessor, allowing for the calculation of effective commission percentages when discounts are applied. This improves clarity in transaction details by providing a more accurate representation of commissions after discounts. 2025-07-06 00:54:19 +02:00
a864f285e4 Refactor m004_convert_to_gtq_storage migration: Streamlined the conversion of centavo amounts to GTQ by detecting the database type (PostgreSQL or SQLite) and applying appropriate data type changes and updates. This enhances clarity and ensures proper handling of data conversions across relevant tables. 2025-07-06 00:24:49 +02:00
c83ebf43ab 01 Refactor currency handling to store amounts in GTQ: Removed currency conversion utilities, updated models and API endpoints to directly handle GTQ amounts, and modified transaction processing logic for consistency. Enhanced frontend to reflect these changes, ensuring accurate display and submission of GTQ values across the application.
Refactor GTQ storage migration: Moved the conversion logic for centavo amounts to GTQ into a new migration function, m004_convert_to_gtq_storage, ensuring proper data type changes and updates across relevant tables. This enhances clarity and maintains the integrity of the migration process.
2025-07-06 00:13:03 +02:00
11 changed files with 3197 additions and 247 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} centavos 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:.2f} GTQ remaining")
return ClientBalanceSummary(
client_id=client_id,

View file

@ -1,45 +0,0 @@
# 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

@ -134,4 +134,39 @@ 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")

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
from pydantic import BaseModel, validator
# 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[int] = None
fixed_mode_daily_limit: Optional[float] = None
class DcaClient(BaseModel):
@ -30,45 +30,29 @@ class DcaClient(BaseModel):
class UpdateDcaClientData(BaseModel):
username: Optional[str] = None
dca_mode: Optional[str] = None
fixed_mode_daily_limit: Optional[int] = None
fixed_mode_daily_limit: Optional[float] = None
status: Optional[str] = None
# API Models for Deposits (Frontend <-> Backend communication)
class CreateDepositAPI(BaseModel):
"""API model - frontend sends GTQ amounts"""
client_id: str
amount_gtq: float # Amount in GTQ (e.g., 150.75)
currency: str = "GTQ"
notes: Optional[str] = None
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)
# Deposit Models (Now storing GTQ directly)
class CreateDepositData(BaseModel):
"""Internal model - database stores centavos"""
client_id: str
amount: int # Amount in smallest currency unit (centavos for GTQ)
amount: 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 DcaDeposit(BaseModel):
"""Internal model - database stores centavos"""
id: str
client_id: str
amount: int
amount: float # Amount in GTQ (e.g., 150.75)
currency: str
status: str # 'pending' or 'confirmed'
notes: Optional[str]
@ -85,7 +69,7 @@ class UpdateDepositStatusData(BaseModel):
class CreateDcaPaymentData(BaseModel):
client_id: str
amount_sats: int
amount_fiat: int # Stored in centavos (GTQ * 100) for precision
amount_fiat: float # Amount in GTQ (e.g., 150.75)
exchange_rate: float
transaction_type: str # 'flow', 'fixed', 'manual', 'commission'
lamassu_transaction_id: Optional[str] = None
@ -97,7 +81,7 @@ class DcaPayment(BaseModel):
id: str
client_id: str
amount_sats: int
amount_fiat: int # Stored in centavos (GTQ * 100) for precision
amount_fiat: float # Amount in GTQ (e.g., 150.75)
exchange_rate: float
transaction_type: str
lamassu_transaction_id: Optional[str]
@ -107,30 +91,19 @@ class DcaPayment(BaseModel):
transaction_time: Optional[datetime] = None # Original ATM transaction time
# API Models for Client Balance Summary
class ClientBalanceSummaryAPI(BaseModel):
"""API model - returns GTQ amounts"""
client_id: str
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
# Client Balance Summary (Now storing GTQ directly)
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
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
currency: str
# Transaction Processing Models
class LamassuTransaction(BaseModel):
transaction_id: str
amount_fiat: int # Stored in centavos (GTQ * 100) for precision
amount_fiat: float # Amount in GTQ (e.g., 150.75)
amount_crypto: int
exchange_rate: float
transaction_type: str # 'cash_in' or 'cash_out'
@ -141,7 +114,7 @@ class LamassuTransaction(BaseModel):
# Lamassu Transaction Storage Models
class CreateLamassuTransactionData(BaseModel):
lamassu_transaction_id: str
fiat_amount: int # Stored in centavos (GTQ * 100) for precision
fiat_amount: float # Amount in GTQ (e.g., 150.75)
crypto_amount: int
commission_percentage: float
discount: float = 0.0
@ -158,7 +131,7 @@ class CreateLamassuTransactionData(BaseModel):
class StoredLamassuTransaction(BaseModel):
id: str
lamassu_transaction_id: str
fiat_amount: int
fiat_amount: float # Amount in GTQ (e.g., 150.75)
crypto_amount: int
commission_percentage: float
discount: float
@ -194,7 +167,14 @@ 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: int = 2000 # Maximum daily limit for Fixed mode clients
max_daily_limit_gtq: float = 2000.0 # Maximum daily limit for Fixed mode clients
@validator('max_daily_limit_gtq')
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
class LamassuConfig(BaseModel):
@ -224,7 +204,7 @@ class LamassuConfig(BaseModel):
last_poll_time: Optional[datetime] = None
last_successful_poll: Optional[datetime] = None
# DCA Client Limits
max_daily_limit_gtq: int = 2000 # Maximum daily limit for Fixed mode clients
max_daily_limit_gtq: float = 2000.0 # 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_gtq', align: 'left', label: 'Amount', field: 'amount_gtq' },
{ name: 'amount', align: 'left', label: 'Amount', field: 'amount' },
{ 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,8 +92,15 @@ 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,
@ -130,11 +137,11 @@ window.app = Vue.createApp({
///////////////////////////////////////////////////
methods: {
// Utility Methods - Simplified since API handles conversion
// Utility Methods
formatCurrency(amount) {
if (!amount) return 'Q 0.00';
// Amount is already in GTQ from API
// Amount is now stored as GTQ directly in database
return new Intl.NumberFormat('es-GT', {
style: 'currency',
currency: 'GTQ',
@ -279,7 +286,7 @@ window.app = Vue.createApp({
)
return {
...client,
remaining_balance: balance.remaining_balance_gtq
remaining_balance: balance.remaining_balance
}
} catch (error) {
console.error(`Error fetching balance for client ${client.id}:`, error)
@ -303,7 +310,7 @@ window.app = Vue.createApp({
try {
const data = {
client_id: this.quickDepositForm.selectedClient?.value,
amount_gtq: this.quickDepositForm.amount, // Send GTQ directly - API handles conversion
amount: this.quickDepositForm.amount, // Send GTQ directly - now stored as GTQ
currency: 'GTQ',
notes: this.quickDepositForm.notes
}
@ -376,7 +383,7 @@ window.app = Vue.createApp({
try {
const data = {
client_id: this.depositFormDialog.data.client_id,
amount_gtq: this.depositFormDialog.data.amount, // Send GTQ directly - API handles conversion
amount: this.depositFormDialog.data.amount, // Send GTQ directly - now stored as GTQ
currency: this.depositFormDialog.data.currency,
notes: this.depositFormDialog.data.notes
}
@ -586,6 +593,79 @@ 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_gtq'">${ formatCurrency(col.value) }</div>
<div v-else-if="col.field == 'amount'">${ formatCurrency(col.value) }</div>
<div v-else-if="col.field == 'status'">
<q-badge :color="col.value === 'confirmed' ? 'green' : 'orange'">
${ col.value }
@ -335,25 +335,26 @@
>
Test Connection
</q-btn>
<q-btn
<q-btn
v-if="lamassuConfig"
size="sm"
color="secondary"
@click="manualPoll"
size="sm"
color="secondary"
@click="manualPoll"
:loading="runningManualPoll"
class="q-ml-sm"
>
Manual Poll
</q-btn>
<q-btn
v-if="lamassuConfig && lamassuConfig.source_wallet_id"
size="sm"
color="warning"
@click="testTransaction"
:loading="runningTestTransaction"
<q-btn
v-if="lamassuConfig"
size="sm"
color="deep-orange"
@click="openManualTransactionDialog"
class="q-ml-sm"
icon="build"
>
Test Transaction
<q-tooltip>Process specific transaction by ID (bypasses dispense checks)</q-tooltip>
Manual TX
</q-btn>
</div>
</q-card-section>
@ -470,9 +471,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_gtq) } |
Payments: ${ formatCurrency(clientDetailsDialog.balance.total_payments_gtq) } |
Remaining: ${ formatCurrency(clientDetailsDialog.balance.remaining_balance_gtq) }
Deposits: ${ formatCurrency(clientDetailsDialog.balance.total_deposits) } |
Payments: ${ formatCurrency(clientDetailsDialog.balance.total_payments) } |
Remaining: ${ formatCurrency(clientDetailsDialog.balance.remaining_balance) }
</q-item-label>
</q-item-section>
</q-item>
@ -690,7 +691,7 @@
<q-dialog v-model="distributionDialog.show" position="top" maximized>
<q-card class="q-pa-lg">
<div class="text-h6 q-mb-md">Transaction Distribution Details</div>
<div v-if="distributionDialog.transaction" class="q-mb-lg">
<q-list>
<q-item>
@ -709,7 +710,7 @@
<q-item-section>
<q-item-label caption>Total Amount</q-item-label>
<q-item-label>
${ formatCurrency(distributionDialog.transaction.fiat_amount) }
${ formatCurrency(distributionDialog.transaction.fiat_amount) }
(${ formatSats(distributionDialog.transaction.crypto_amount) })
</q-item-label>
</q-item-section>
@ -718,7 +719,7 @@
<q-item-section>
<q-item-label caption>Commission</q-item-label>
<q-item-label>
${ (distributionDialog.transaction.commission_percentage * 100).toFixed(1) }%
${ (distributionDialog.transaction.commission_percentage * 100).toFixed(1) }%
<span v-if="distributionDialog.transaction.discount > 0">
(with ${ distributionDialog.transaction.discount }% discount = ${ (distributionDialog.transaction.effective_commission * 100).toFixed(1) }% effective)
</span>
@ -740,11 +741,11 @@
</q-item>
</q-list>
</div>
<q-separator class="q-my-md"></q-separator>
<div class="text-h6 q-mb-md">Client Distributions</div>
<q-table
dense
flat
@ -771,12 +772,71 @@
</q-tr>
</template>
</q-table>
<div class="row q-mt-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</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,14 +493,26 @@ class LamassuTransactionProcessor:
# Convert string values to appropriate types
processed_row = {}
for key, value in row.items():
if value == '':
processed_row[key] = None
# 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
elif key in ['transaction_id', 'device_id', 'crypto_code', 'fiat_code']:
processed_row[key] = str(value)
elif key in ['fiat_amount', 'crypto_amount']:
processed_row[key] = int(float(value)) if value else 0
try:
processed_row[key] = int(float(value))
except (ValueError, TypeError):
processed_row[key] = 0 # Fallback to 0 for invalid values
elif key in ['commission_percentage', 'discount']:
processed_row[key] = float(value) if value else 0.0
try:
processed_row[key] = float(value)
except (ValueError, TypeError):
processed_row[key] = 0.0 # Fallback to 0.0 for invalid values
elif key == 'transaction_time':
from datetime import datetime
# Parse PostgreSQL timestamp format and ensure it's in UTC for consistency
@ -548,6 +560,44 @@ 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:
@ -561,13 +611,13 @@ class LamassuTransactionProcessor:
# Fallback to last 24 hours for first run or if no previous poll
time_threshold = datetime.now(timezone.utc) - timedelta(hours=24)
logger.info(f"No previous poll found, checking last 24 hours since: {time_threshold}")
# Convert to UTC if not already timezone-aware
if time_threshold.tzinfo is None:
time_threshold = time_threshold.replace(tzinfo=timezone.utc)
elif time_threshold.tzinfo != timezone.utc:
time_threshold = time_threshold.astimezone(timezone.utc)
# Format as UTC for database query
time_threshold_str = time_threshold.strftime('%Y-%m-%d %H:%M:%S UTC')
@ -628,11 +678,11 @@ class LamassuTransactionProcessor:
logger.info("No Flow Mode clients found - skipping distribution")
return {}
# 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
# 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
transaction_time = transaction.get("transaction_time") # ATM transaction timestamp for temporal accuracy
# Normalize transaction_time to UTC if present
@ -671,7 +721,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 = int(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
@ -696,9 +746,14 @@ 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)
if balance.remaining_balance > 0: # Only include clients with remaining balance
# Only include clients with positive remaining balance
# NOTE: This works for fiat amounts that use cents
if balance.remaining_balance >= 0.01:
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")
@ -755,9 +810,8 @@ class LamassuTransactionProcessor:
client_sats_amount = calc['allocated_sats']
proportion = calc['proportion']
# 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
# 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,
@ -765,7 +819,7 @@ class LamassuTransactionProcessor:
"exchange_rate": exchange_rate
}
logger.info(f"Client {client_id[:8]}... gets {client_sats_amount} sats (≈{client_fiat_amount/100:.2f} GTQ, {proportion:.2%} share)")
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
total_distributed = sum(dist["sats_amount"] for dist in distributions.values())
@ -781,9 +835,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} centavos - APPROVED for {distribution['sats_amount']} sats")
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} centavos - REJECTED (negative balance)")
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")
@ -797,8 +851,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(final_distributions)} clients")
return final_distributions
logger.info(f"Distribution verified: {total_distributed} sats distributed across {len(distributions)} clients (clients with positive allocations only)")
return distributions
except Exception as e:
logger.error(f"Error calculating distribution amounts: {e}")
@ -830,22 +884,25 @@ 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} centavos) - REFUSING payment of {distribution['sats_amount']} sats")
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
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")
# 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} centavos - SUFFICIENT for {fiat_equivalent} centavos payment")
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"], # Still store centavos in DB
amount_fiat=distribution["fiat_amount"], # Amount in GTQ
exchange_rate=distribution["exchange_rate"],
transaction_type="flow",
lamassu_transaction_id=transaction_id,
@ -888,12 +945,9 @@ class LamassuTransactionProcessor:
return False
# Create descriptive memo with DCA metrics
fiat_amount_centavos = distribution.get("fiat_amount", 0)
fiat_amount_gtq = distribution.get("fiat_amount", 0.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
@ -994,11 +1048,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 and validate transaction data
# Extract transaction data - guaranteed clean from data ingestion boundary
crypto_atoms = transaction.get("crypto_amount", 0)
fiat_amount = transaction.get("fiat_amount", 0)
commission_percentage = transaction.get("commission_percentage") or 0.0
discount = transaction.get("discount") or 0.0
commission_percentage = transaction.get("commission_percentage", 0.0)
discount = transaction.get("discount", 0.0)
transaction_time = transaction.get("transaction_time")
# Normalize transaction_time to UTC if present
@ -1011,7 +1065,7 @@ class LamassuTransactionProcessor:
# Calculate commission metrics
if commission_percentage > 0:
effective_commission = commission_percentage * (100 - discount) / 100
base_crypto_atoms = int(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
@ -1021,10 +1075,10 @@ class LamassuTransactionProcessor:
# Calculate exchange rate
exchange_rate = base_crypto_atoms / fiat_amount if fiat_amount > 0 else 0
# Create transaction data (store fiat_amount in centavos for consistency)
# Create transaction data with GTQ amounts
transaction_data = CreateLamassuTransactionData(
lamassu_transaction_id=transaction["transaction_id"],
fiat_amount=int(fiat_amount * 100), # Convert GTQ to centavos
fiat_amount=round(fiat_amount, 2), # Store GTQ with 2 decimal places
crypto_amount=crypto_atoms,
commission_percentage=commission_percentage,
discount=discount,
@ -1065,7 +1119,19 @@ 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
commission_memo = f"DCA Commission: {commission_amount_sats:,} sats • {commission_percentage:.1f}% • {fiat_amount:,} GTQ transaction"
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(
wallet_id=admin_config.commission_wallet_id,
@ -1132,12 +1198,12 @@ class LamassuTransactionProcessor:
# Calculate commission amount for sending to commission wallet
crypto_atoms = transaction.get("crypto_amount", 0)
commission_percentage = transaction.get("commission_percentage") or 0.0
discount = transaction.get("discount") or 0.0
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 = int(crypto_atoms / (1 + effective_commission))
base_crypto_atoms = round(crypto_atoms / (1 + effective_commission))
commission_amount_sats = crypto_atoms - base_crypto_atoms
else:
commission_amount_sats = 0

View file

@ -38,23 +38,14 @@ 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()
@ -96,7 +87,7 @@ async def api_get_dca_client(
async def api_get_client_balance(
client_id: str,
wallet: WalletTypeInfo = Depends(check_super_user),
) -> ClientBalanceSummaryAPI:
) -> ClientBalanceSummary:
"""Get client balance summary"""
client = await get_dca_client(client_id)
if not client:
@ -104,8 +95,7 @@ async def api_get_client_balance(
status_code=HTTPStatus.NOT_FOUND, detail="DCA client not found."
)
balance_db = await get_client_balance_summary(client_id)
return ClientBalanceSummaryAPI(**balance_summary_db_to_api(balance_db))
return await get_client_balance_summary(client_id)
# DCA Deposit Endpoints
@ -114,31 +104,30 @@ 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[DepositAPI]:
) -> list[DcaDeposit]:
"""Get all deposits"""
deposits_db = await get_all_deposits()
return [DepositAPI(**deposit_db_to_api(deposit)) for deposit in deposits_db]
return await get_all_deposits()
@satmachineadmin_api_router.get("/api/v1/dca/deposits/{deposit_id}")
async def api_get_deposit(
deposit_id: str,
wallet: WalletTypeInfo = Depends(check_super_user),
) -> DepositAPI:
) -> DcaDeposit:
"""Get a specific deposit"""
deposit_db = await get_deposit(deposit_id)
if not deposit_db:
deposit = await get_deposit(deposit_id)
if not deposit:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Deposit not found."
)
return DepositAPI(**deposit_db_to_api(deposit_db))
return deposit
@satmachineadmin_api_router.post("/api/v1/dca/deposits", status_code=HTTPStatus.CREATED)
async def api_create_deposit(
data: CreateDepositAPI,
data: CreateDepositData,
user: User = Depends(check_super_user),
) -> DepositAPI:
) -> DcaDeposit:
"""Create a new deposit"""
# Verify client exists
client = await get_dca_client(data.client_id)
@ -147,16 +136,7 @@ async def api_create_deposit(
status_code=HTTPStatus.NOT_FOUND, detail="DCA client not found."
)
# 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))
return await create_deposit(data)
@satmachineadmin_api_router.put("/api/v1/dca/deposits/{deposit_id}/status")
@ -164,7 +144,7 @@ async def api_update_deposit_status(
deposit_id: str,
data: UpdateDepositStatusData,
user: User = Depends(check_super_user),
) -> DepositAPI:
) -> DcaDeposit:
"""Update deposit status (e.g., confirm deposit)"""
deposit = await get_deposit(deposit_id)
if not deposit:
@ -172,13 +152,13 @@ async def api_update_deposit_status(
status_code=HTTPStatus.NOT_FOUND, detail="Deposit not found."
)
updated_deposit_db = await update_deposit_status(deposit_id, data)
if not updated_deposit_db:
updated_deposit = await update_deposit_status(deposit_id, data)
if not updated_deposit:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Failed to update deposit.",
)
return DepositAPI(**deposit_db_to_api(updated_deposit_db))
return updated_deposit
# Transaction Polling Endpoints
@ -253,69 +233,139 @@ async def api_manual_poll(
)
@satmachineadmin_api_router.post("/api/v1/dca/test-transaction")
async def api_test_transaction(
@satmachineadmin_api_router.post("/api/v1/dca/process-transaction/{transaction_id}")
async def api_process_specific_transaction(
transaction_id: str,
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"""
):
"""
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.
"""
try:
from .transaction_processor import transaction_processor
import uuid
from datetime import datetime, timezone
from .crud import get_payments_by_lamassu_transaction
# 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",
}
# 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",
)
# Process the mock transaction through the complete DCA flow
await transaction_processor.process_transaction(mock_transaction)
# 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),
}
# 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
# Fetch the specific transaction from Lamassu (bypassing all filters)
transaction = await transaction_processor.fetch_transaction_by_id(db_config, transaction_id)
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)
return {
"success": True,
"message": "Test transaction processed successfully",
"message": f"Transaction {transaction_id} 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,
"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"),
},
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Error processing test transaction: {str(e)}",
detail=f"Error processing transaction {transaction_id}: {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