Add Lamassu database configuration: implement CRUD operations, polling tasks, and UI components for managing database settings. Introduce hourly transaction polling and manual poll functionality.
This commit is contained in:
parent
c9f7140d95
commit
1f7999a556
9 changed files with 870 additions and 5 deletions
11
__init__.py
11
__init__.py
|
|
@ -5,7 +5,7 @@ from lnbits.tasks import create_permanent_unique_task
|
|||
from loguru import logger
|
||||
|
||||
from .crud import db
|
||||
from .tasks import wait_for_paid_invoices
|
||||
from .tasks import wait_for_paid_invoices, hourly_transaction_polling
|
||||
from .views import myextension_generic_router
|
||||
from .views_api import myextension_api_router
|
||||
from .views_lnurl import myextension_lnurl_router
|
||||
|
|
@ -40,8 +40,13 @@ def myextension_stop():
|
|||
|
||||
|
||||
def myextension_start():
|
||||
task = create_permanent_unique_task("ext_myextension", wait_for_paid_invoices)
|
||||
scheduled_tasks.append(task)
|
||||
# Start invoice listener task
|
||||
invoice_task = create_permanent_unique_task("ext_myextension", wait_for_paid_invoices)
|
||||
scheduled_tasks.append(invoice_task)
|
||||
|
||||
# Start hourly transaction polling task
|
||||
polling_task = create_permanent_unique_task("ext_myextension_polling", hourly_transaction_polling)
|
||||
scheduled_tasks.append(polling_task)
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
|
|
|||
95
crud.py
95
crud.py
|
|
@ -11,7 +11,8 @@ from .models import (
|
|||
CreateDcaClientData, DcaClient, UpdateDcaClientData,
|
||||
CreateDepositData, DcaDeposit, UpdateDepositStatusData,
|
||||
CreateDcaPaymentData, DcaPayment,
|
||||
ClientBalanceSummary
|
||||
ClientBalanceSummary,
|
||||
CreateLamassuConfigData, LamassuConfig, UpdateLamassuConfigData
|
||||
)
|
||||
|
||||
db = Database("ext_myextension")
|
||||
|
|
@ -292,3 +293,95 @@ async def get_fixed_mode_clients() -> List[DcaClient]:
|
|||
"SELECT * FROM myextension.dca_clients WHERE dca_mode = 'fixed' AND status = 'active'",
|
||||
model=DcaClient,
|
||||
)
|
||||
|
||||
|
||||
# Lamassu Configuration CRUD Operations
|
||||
async def create_lamassu_config(data: CreateLamassuConfigData) -> LamassuConfig:
|
||||
config_id = urlsafe_short_hash()
|
||||
|
||||
# Deactivate any existing configs first (only one active config allowed)
|
||||
await db.execute(
|
||||
"UPDATE myextension.lamassu_config SET is_active = false, updated_at = :updated_at",
|
||||
{"updated_at": datetime.now()}
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO myextension.lamassu_config
|
||||
(id, host, port, database_name, username, password, is_active, created_at, updated_at)
|
||||
VALUES (:id, :host, :port, :database_name, :username, :password, :is_active, :created_at, :updated_at)
|
||||
""",
|
||||
{
|
||||
"id": config_id,
|
||||
"host": data.host,
|
||||
"port": data.port,
|
||||
"database_name": data.database_name,
|
||||
"username": data.username,
|
||||
"password": data.password,
|
||||
"is_active": True,
|
||||
"created_at": datetime.now(),
|
||||
"updated_at": datetime.now()
|
||||
}
|
||||
)
|
||||
return await get_lamassu_config(config_id)
|
||||
|
||||
|
||||
async def get_lamassu_config(config_id: str) -> Optional[LamassuConfig]:
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM myextension.lamassu_config WHERE id = :id",
|
||||
{"id": config_id},
|
||||
LamassuConfig,
|
||||
)
|
||||
|
||||
|
||||
async def get_active_lamassu_config() -> Optional[LamassuConfig]:
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM myextension.lamassu_config WHERE is_active = true ORDER BY created_at DESC LIMIT 1",
|
||||
model=LamassuConfig,
|
||||
)
|
||||
|
||||
|
||||
async def get_all_lamassu_configs() -> List[LamassuConfig]:
|
||||
return await db.fetchall(
|
||||
"SELECT * FROM myextension.lamassu_config ORDER BY created_at DESC",
|
||||
model=LamassuConfig,
|
||||
)
|
||||
|
||||
|
||||
async def update_lamassu_config(config_id: str, data: UpdateLamassuConfigData) -> Optional[LamassuConfig]:
|
||||
update_data = {k: v for k, v in data.dict().items() if v is not None}
|
||||
if not update_data:
|
||||
return await get_lamassu_config(config_id)
|
||||
|
||||
update_data["updated_at"] = datetime.now()
|
||||
set_clause = ", ".join([f"{k} = :{k}" for k in update_data.keys()])
|
||||
update_data["id"] = config_id
|
||||
|
||||
await db.execute(
|
||||
f"UPDATE myextension.lamassu_config SET {set_clause} WHERE id = :id",
|
||||
update_data
|
||||
)
|
||||
return await get_lamassu_config(config_id)
|
||||
|
||||
|
||||
async def update_config_test_result(config_id: str, success: bool) -> None:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE myextension.lamassu_config
|
||||
SET test_connection_last = :test_time, test_connection_success = :success, updated_at = :updated_at
|
||||
WHERE id = :id
|
||||
""",
|
||||
{
|
||||
"id": config_id,
|
||||
"test_time": datetime.now(),
|
||||
"success": success,
|
||||
"updated_at": datetime.now()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def delete_lamassu_config(config_id: str) -> None:
|
||||
await db.execute(
|
||||
"DELETE FROM myextension.lamassu_config WHERE id = :id",
|
||||
{"id": config_id}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -93,3 +93,26 @@ async def m005_create_dca_payments(db):
|
|||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m006_create_lamassu_config(db):
|
||||
"""
|
||||
Create Lamassu database configuration table.
|
||||
"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE myextension.lamassu_config (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 5432,
|
||||
database_name TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
test_connection_last TIMESTAMP,
|
||||
test_connection_success BOOLEAN,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
|
|
|||
32
models.py
32
models.py
|
|
@ -99,6 +99,38 @@ class LamassuTransaction(BaseModel):
|
|||
timestamp: datetime
|
||||
|
||||
|
||||
# Lamassu Configuration Models
|
||||
class CreateLamassuConfigData(BaseModel):
|
||||
host: str
|
||||
port: int = 5432
|
||||
database_name: str
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class LamassuConfig(BaseModel):
|
||||
id: str
|
||||
host: str
|
||||
port: int
|
||||
database_name: str
|
||||
username: str
|
||||
password: str
|
||||
is_active: bool
|
||||
test_connection_last: Optional[datetime]
|
||||
test_connection_success: Optional[bool]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class UpdateLamassuConfigData(BaseModel):
|
||||
host: Optional[str] = None
|
||||
port: Optional[int] = None
|
||||
database_name: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
# Legacy models (keep for backward compatibility during transition)
|
||||
class CreateMyExtensionData(BaseModel):
|
||||
id: Optional[str] = ""
|
||||
|
|
|
|||
|
|
@ -54,6 +54,24 @@ window.app = Vue.createApp({
|
|||
amount: null,
|
||||
notes: ''
|
||||
},
|
||||
|
||||
// Polling status
|
||||
lastPollTime: null,
|
||||
testingConnection: false,
|
||||
runningManualPoll: false,
|
||||
lamassuConfig: null,
|
||||
|
||||
// Config dialog
|
||||
configDialog: {
|
||||
show: false,
|
||||
data: {
|
||||
host: '',
|
||||
port: 5432,
|
||||
database_name: '',
|
||||
username: '',
|
||||
password: ''
|
||||
}
|
||||
},
|
||||
|
||||
// Options
|
||||
currencyOptions: [
|
||||
|
|
@ -108,6 +126,62 @@ window.app = Vue.createApp({
|
|||
return new Date(dateString).toLocaleDateString()
|
||||
},
|
||||
|
||||
// Configuration Methods
|
||||
async getLamassuConfig() {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/myextension/api/v1/dca/config',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
this.lamassuConfig = data
|
||||
} catch (error) {
|
||||
// It's OK if no config exists yet
|
||||
this.lamassuConfig = null
|
||||
}
|
||||
},
|
||||
|
||||
async saveConfiguration() {
|
||||
try {
|
||||
const data = {
|
||||
host: this.configDialog.data.host,
|
||||
port: this.configDialog.data.port,
|
||||
database_name: this.configDialog.data.database_name,
|
||||
username: this.configDialog.data.username,
|
||||
password: this.configDialog.data.password
|
||||
}
|
||||
|
||||
const {data: config} = await LNbits.api.request(
|
||||
'POST',
|
||||
'/myextension/api/v1/dca/config',
|
||||
this.g.user.wallets[0].adminkey,
|
||||
data
|
||||
)
|
||||
|
||||
this.lamassuConfig = config
|
||||
this.closeConfigDialog()
|
||||
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Database configuration saved successfully',
|
||||
timeout: 5000
|
||||
})
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
|
||||
closeConfigDialog() {
|
||||
this.configDialog.show = false
|
||||
this.configDialog.data = {
|
||||
host: '',
|
||||
port: 5432,
|
||||
database_name: '',
|
||||
username: '',
|
||||
password: ''
|
||||
}
|
||||
},
|
||||
|
||||
// DCA Client Methods
|
||||
async getDcaClients() {
|
||||
try {
|
||||
|
|
@ -313,6 +387,53 @@ window.app = Vue.createApp({
|
|||
async exportDepositsCSV() {
|
||||
await LNbits.utils.exportCSV(this.depositsTable.columns, this.deposits)
|
||||
},
|
||||
|
||||
// Polling Methods
|
||||
async testDatabaseConnection() {
|
||||
this.testingConnection = true
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'POST',
|
||||
'/myextension/api/v1/dca/test-connection',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
|
||||
this.$q.notify({
|
||||
type: data.success ? 'positive' : 'negative',
|
||||
message: data.message,
|
||||
timeout: 5000
|
||||
})
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
} finally {
|
||||
this.testingConnection = false
|
||||
}
|
||||
},
|
||||
|
||||
async manualPoll() {
|
||||
this.runningManualPoll = true
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'POST',
|
||||
'/myextension/api/v1/dca/manual-poll',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
|
||||
this.lastPollTime = new Date().toLocaleString()
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: `Manual poll completed. Found ${data.transactions_processed} new transactions.`,
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Refresh data
|
||||
await this.getDeposits()
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
} finally {
|
||||
this.runningManualPoll = false
|
||||
}
|
||||
},
|
||||
|
||||
// Legacy Methods (keep for backward compatibility)
|
||||
async closeFormDialog() {
|
||||
|
|
@ -509,6 +630,7 @@ window.app = Vue.createApp({
|
|||
async created() {
|
||||
// Load DCA admin data
|
||||
await Promise.all([
|
||||
this.getLamassuConfig(),
|
||||
this.getDcaClients(),
|
||||
this.getDeposits()
|
||||
])
|
||||
|
|
|
|||
22
tasks.py
22
tasks.py
|
|
@ -1,11 +1,14 @@
|
|||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.services import websocket_updater
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
from loguru import logger
|
||||
|
||||
from .crud import get_myextension, update_myextension
|
||||
from .models import CreateMyExtensionData
|
||||
from .transaction_processor import poll_lamassu_transactions
|
||||
|
||||
#######################################
|
||||
########## RUN YOUR TASKS HERE ########
|
||||
|
|
@ -22,6 +25,25 @@ async def wait_for_paid_invoices():
|
|||
await on_invoice_paid(payment)
|
||||
|
||||
|
||||
async def hourly_transaction_polling():
|
||||
"""Background task that polls Lamassu database every hour for new transactions"""
|
||||
logger.info("Starting hourly Lamassu transaction polling task")
|
||||
|
||||
while True:
|
||||
try:
|
||||
logger.info(f"Running Lamassu transaction poll at {datetime.now()}")
|
||||
await poll_lamassu_transactions()
|
||||
logger.info("Completed Lamassu transaction poll, sleeping for 1 hour")
|
||||
|
||||
# Sleep for 1 hour (3600 seconds)
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in hourly polling task: {e}")
|
||||
# Sleep for 5 minutes before retrying on error
|
||||
await asyncio.sleep(300)
|
||||
|
||||
|
||||
# Do somethhing when an invoice related top this extension is paid
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -332,6 +332,59 @@
|
|||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
icon="settings"
|
||||
label="Lamassu Database Config"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card-section class="text-caption">
|
||||
<div v-if="lamassuConfig">
|
||||
<p><strong>Database:</strong> ${ lamassuConfig.host }:${ lamassuConfig.port }/${ lamassuConfig.database_name }</p>
|
||||
<p><strong>Status:</strong>
|
||||
<q-badge v-if="lamassuConfig.test_connection_success === true" color="green">Connected</q-badge>
|
||||
<q-badge v-else-if="lamassuConfig.test_connection_success === false" color="red">Failed</q-badge>
|
||||
<q-badge v-else color="grey">Not tested</q-badge>
|
||||
</p>
|
||||
<p><strong>Last Poll:</strong> ${ lastPollTime || 'Not yet run' }</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p><strong>Status:</strong> <q-badge color="orange">Not configured</q-badge></p>
|
||||
</div>
|
||||
|
||||
<div class="q-mt-md">
|
||||
<q-btn
|
||||
size="sm"
|
||||
color="primary"
|
||||
@click="configDialog.show = true"
|
||||
icon="settings"
|
||||
>
|
||||
Configure Database
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="lamassuConfig"
|
||||
size="sm"
|
||||
color="accent"
|
||||
@click="testDatabaseConnection"
|
||||
:loading="testingConnection"
|
||||
class="q-ml-sm"
|
||||
>
|
||||
Test Connection
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="lamassuConfig"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
@click="manualPoll"
|
||||
:loading="runningManualPoll"
|
||||
class="q-ml-sm"
|
||||
>
|
||||
Manual Poll
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
{% include "myextension/_api_docs.html" %}
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
|
|
@ -450,6 +503,85 @@
|
|||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!--/////////////////////////////////////////////////-->
|
||||
<!--//////////////LAMASSU CONFIG DIALOG//////////////-->
|
||||
<!--/////////////////////////////////////////////////-->
|
||||
|
||||
<q-dialog v-model="configDialog.show" position="top" @hide="closeConfigDialog">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 600px; max-width: 90vw">
|
||||
<div class="text-h6 q-mb-md">Lamassu Database Configuration</div>
|
||||
<q-form @submit="saveConfiguration" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="configDialog.data.host"
|
||||
label="Database Host *"
|
||||
placeholder="e.g., localhost or 192.168.1.100"
|
||||
hint="Hostname or IP address of the Lamassu Postgres server"
|
||||
></q-input>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
type="number"
|
||||
v-model.number="configDialog.data.port"
|
||||
label="Database Port *"
|
||||
placeholder="5432"
|
||||
hint="Postgres port (usually 5432)"
|
||||
></q-input>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="configDialog.data.database_name"
|
||||
label="Database Name *"
|
||||
placeholder="lamassu"
|
||||
hint="Name of the Lamassu database"
|
||||
></q-input>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="configDialog.data.username"
|
||||
label="Username *"
|
||||
placeholder="postgres"
|
||||
hint="Database username with read access"
|
||||
></q-input>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
type="password"
|
||||
v-model.trim="configDialog.data.password"
|
||||
label="Password *"
|
||||
placeholder="Enter database password"
|
||||
hint="Database password"
|
||||
></q-input>
|
||||
|
||||
<q-banner v-if="!configDialog.data.id" class="bg-blue-1 text-blue-9">
|
||||
<template v-slot:avatar>
|
||||
<q-icon name="info" color="blue" />
|
||||
</template>
|
||||
This configuration will be securely stored and used for hourly polling.
|
||||
Only read access to the Lamassu database is required.
|
||||
</q-banner>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
:disable="!configDialog.data.host || !configDialog.data.database_name || !configDialog.data.username"
|
||||
>Save Configuration</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!--/////////////////////////////////////////////////-->
|
||||
<!--//////////////QR Code DIALOG/////////////////////-->
|
||||
<!--/////////////////////////////////////////////////-->
|
||||
|
|
|
|||
300
transaction_processor.py
Normal file
300
transaction_processor.py
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
# Transaction processing and polling service for Lamassu ATM integration
|
||||
|
||||
import asyncio
|
||||
import asyncpg
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional, Dict, Any
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.services import create_invoice, pay_invoice
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .crud import (
|
||||
get_flow_mode_clients,
|
||||
get_payments_by_lamassu_transaction,
|
||||
create_dca_payment,
|
||||
get_client_balance_summary,
|
||||
get_active_lamassu_config,
|
||||
update_config_test_result
|
||||
)
|
||||
from .models import CreateDcaPaymentData, LamassuTransaction
|
||||
|
||||
|
||||
class LamassuTransactionProcessor:
|
||||
"""Handles polling Lamassu database and processing transactions for DCA distribution"""
|
||||
|
||||
def __init__(self):
|
||||
self.last_check_time = None
|
||||
self.processed_transaction_ids = set()
|
||||
|
||||
async def get_db_config(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get database configuration from the database"""
|
||||
try:
|
||||
config = await get_active_lamassu_config()
|
||||
if not config:
|
||||
logger.error("No active Lamassu database configuration found")
|
||||
return None
|
||||
|
||||
return {
|
||||
"host": config.host,
|
||||
"port": config.port,
|
||||
"database": config.database_name,
|
||||
"user": config.username,
|
||||
"password": config.password,
|
||||
"config_id": config.id
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting database configuration: {e}")
|
||||
return None
|
||||
|
||||
async def connect_to_lamassu_db(self) -> Optional[asyncpg.Connection]:
|
||||
"""Establish connection to Lamassu Postgres database"""
|
||||
try:
|
||||
db_config = await self.get_db_config()
|
||||
if not db_config:
|
||||
return None
|
||||
|
||||
connection = await asyncpg.connect(
|
||||
host=db_config["host"],
|
||||
port=db_config["port"],
|
||||
database=db_config["database"],
|
||||
user=db_config["user"],
|
||||
password=db_config["password"],
|
||||
timeout=30
|
||||
)
|
||||
logger.info("Successfully connected to Lamassu database")
|
||||
|
||||
# Update test result on successful connection
|
||||
try:
|
||||
await update_config_test_result(db_config["config_id"], True)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not update config test result: {e}")
|
||||
|
||||
return connection
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to Lamassu database: {e}")
|
||||
|
||||
# Update test result on failed connection
|
||||
try:
|
||||
db_config = await self.get_db_config()
|
||||
if db_config:
|
||||
await update_config_test_result(db_config["config_id"], False)
|
||||
except Exception as update_error:
|
||||
logger.warning(f"Could not update config test result: {update_error}")
|
||||
|
||||
return None
|
||||
|
||||
async def fetch_new_transactions(self, connection: asyncpg.Connection) -> List[Dict[str, Any]]:
|
||||
"""Fetch new successful transactions from Lamassu database"""
|
||||
try:
|
||||
# Set the time window - check for transactions in the last hour + 5 minutes buffer
|
||||
time_threshold = datetime.now() - timedelta(hours=1, minutes=5)
|
||||
|
||||
# Query for successful cash-out transactions (people selling BTC for fiat)
|
||||
# These are the transactions that trigger DCA distributions
|
||||
query = """
|
||||
SELECT
|
||||
co.id as transaction_id,
|
||||
co.fiat as fiat_amount,
|
||||
co.crypto as crypto_amount,
|
||||
co.created as transaction_time,
|
||||
co.session_id,
|
||||
co.machine_id,
|
||||
co.status,
|
||||
co.commission_percentage,
|
||||
co.tx_hash
|
||||
FROM cash_out_txs co
|
||||
WHERE co.created >= $1
|
||||
AND co.status = 'confirmed'
|
||||
AND co.id NOT IN (
|
||||
-- Exclude already processed transactions
|
||||
SELECT DISTINCT lamassu_transaction_id
|
||||
FROM myextension.dca_payments
|
||||
WHERE lamassu_transaction_id IS NOT NULL
|
||||
)
|
||||
ORDER BY co.created DESC
|
||||
"""
|
||||
|
||||
rows = await connection.fetch(query, time_threshold)
|
||||
|
||||
transactions = []
|
||||
for row in rows:
|
||||
# Convert asyncpg.Record to dict
|
||||
transaction = {
|
||||
"transaction_id": str(row["transaction_id"]),
|
||||
"fiat_amount": int(row["fiat_amount"]), # Amount in smallest currency unit
|
||||
"crypto_amount": int(row["crypto_amount"]), # Amount in satoshis
|
||||
"transaction_time": row["transaction_time"],
|
||||
"session_id": row["session_id"],
|
||||
"machine_id": row["machine_id"],
|
||||
"status": row["status"],
|
||||
"commission_percentage": float(row["commission_percentage"]) if row["commission_percentage"] else 0.0,
|
||||
"tx_hash": row["tx_hash"]
|
||||
}
|
||||
transactions.append(transaction)
|
||||
|
||||
logger.info(f"Found {len(transactions)} new transactions to process")
|
||||
return transactions
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching transactions from Lamassu database: {e}")
|
||||
return []
|
||||
|
||||
async def calculate_distribution_amounts(self, transaction: Dict[str, Any]) -> Dict[str, int]:
|
||||
"""Calculate how much each Flow Mode client should receive"""
|
||||
try:
|
||||
# Get all active Flow Mode clients
|
||||
flow_clients = await get_flow_mode_clients()
|
||||
|
||||
if not flow_clients:
|
||||
logger.info("No Flow Mode clients found - skipping distribution")
|
||||
return {}
|
||||
|
||||
# Calculate principal amount (total - commission)
|
||||
fiat_amount = transaction["fiat_amount"]
|
||||
commission_percentage = transaction["commission_percentage"]
|
||||
commission_amount = int(fiat_amount * commission_percentage / 100)
|
||||
principal_amount = fiat_amount - commission_amount
|
||||
|
||||
logger.info(f"Transaction: {fiat_amount}, Commission: {commission_amount}, Principal: {principal_amount}")
|
||||
|
||||
# Get balance summaries for all clients to calculate proportions
|
||||
client_balances = {}
|
||||
total_confirmed_deposits = 0
|
||||
|
||||
for client in flow_clients:
|
||||
balance = await get_client_balance_summary(client.id)
|
||||
if balance.remaining_balance > 0: # Only include clients with remaining balance
|
||||
client_balances[client.id] = balance.remaining_balance
|
||||
total_confirmed_deposits += balance.remaining_balance
|
||||
|
||||
if total_confirmed_deposits == 0:
|
||||
logger.info("No clients with remaining DCA balance - skipping distribution")
|
||||
return {}
|
||||
|
||||
# Calculate proportional distribution
|
||||
distributions = {}
|
||||
exchange_rate = transaction["crypto_amount"] / transaction["fiat_amount"] # sats per fiat unit
|
||||
|
||||
for client_id, client_balance in client_balances.items():
|
||||
# Calculate this client's proportion of the principal
|
||||
proportion = client_balance / total_confirmed_deposits
|
||||
client_fiat_amount = int(principal_amount * proportion)
|
||||
client_sats_amount = int(client_fiat_amount * exchange_rate)
|
||||
|
||||
distributions[client_id] = {
|
||||
"fiat_amount": client_fiat_amount,
|
||||
"sats_amount": client_sats_amount,
|
||||
"exchange_rate": exchange_rate
|
||||
}
|
||||
|
||||
logger.info(f"Client {client_id[:8]}... gets {client_fiat_amount} fiat units = {client_sats_amount} sats")
|
||||
|
||||
return distributions
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating distribution amounts: {e}")
|
||||
return {}
|
||||
|
||||
async def distribute_to_clients(self, transaction: Dict[str, Any], distributions: Dict[str, Dict[str, int]]) -> None:
|
||||
"""Send Bitcoin payments to DCA clients"""
|
||||
try:
|
||||
transaction_id = transaction["transaction_id"]
|
||||
|
||||
for client_id, distribution in distributions.items():
|
||||
try:
|
||||
# Get client info
|
||||
flow_clients = await get_flow_mode_clients()
|
||||
client = next((c for c in flow_clients if c.id == client_id), None)
|
||||
|
||||
if not client:
|
||||
logger.error(f"Client {client_id} not found")
|
||||
continue
|
||||
|
||||
# Create DCA payment record
|
||||
payment_data = CreateDcaPaymentData(
|
||||
client_id=client_id,
|
||||
amount_sats=distribution["sats_amount"],
|
||||
amount_fiat=distribution["fiat_amount"],
|
||||
exchange_rate=distribution["exchange_rate"],
|
||||
transaction_type="flow",
|
||||
lamassu_transaction_id=transaction_id
|
||||
)
|
||||
|
||||
# Record the payment in our database
|
||||
dca_payment = await create_dca_payment(payment_data)
|
||||
|
||||
# TODO: Actually send Bitcoin to client's wallet
|
||||
# This will be implemented when we integrate with LNBits payment system
|
||||
logger.info(f"DCA payment recorded for client {client_id[:8]}...: {distribution['sats_amount']} sats")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing distribution for client {client_id}: {e}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error distributing to clients: {e}")
|
||||
|
||||
async def process_transaction(self, transaction: Dict[str, Any]) -> None:
|
||||
"""Process a single transaction - calculate and distribute DCA payments"""
|
||||
try:
|
||||
transaction_id = transaction["transaction_id"]
|
||||
|
||||
# Check if transaction already processed
|
||||
existing_payments = await get_payments_by_lamassu_transaction(transaction_id)
|
||||
if existing_payments:
|
||||
logger.info(f"Transaction {transaction_id} already processed - skipping")
|
||||
return
|
||||
|
||||
logger.info(f"Processing new transaction: {transaction_id}")
|
||||
|
||||
# Calculate distribution amounts
|
||||
distributions = await self.calculate_distribution_amounts(transaction)
|
||||
|
||||
if not distributions:
|
||||
logger.info(f"No distributions calculated for transaction {transaction_id}")
|
||||
return
|
||||
|
||||
# Distribute to clients
|
||||
await self.distribute_to_clients(transaction, distributions)
|
||||
|
||||
logger.info(f"Successfully processed transaction {transaction_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing transaction {transaction.get('transaction_id', 'unknown')}: {e}")
|
||||
|
||||
async def poll_and_process(self) -> None:
|
||||
"""Main polling function - checks for new transactions and processes them"""
|
||||
try:
|
||||
logger.info("Starting Lamassu transaction polling...")
|
||||
|
||||
# Connect to Lamassu database
|
||||
connection = await self.connect_to_lamassu_db()
|
||||
if not connection:
|
||||
logger.error("Could not connect to Lamassu database - skipping this poll")
|
||||
return
|
||||
|
||||
try:
|
||||
# Fetch new transactions
|
||||
new_transactions = await self.fetch_new_transactions(connection)
|
||||
|
||||
# Process each transaction
|
||||
for transaction in new_transactions:
|
||||
await self.process_transaction(transaction)
|
||||
|
||||
logger.info(f"Completed processing {len(new_transactions)} transactions")
|
||||
|
||||
finally:
|
||||
await connection.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in polling cycle: {e}")
|
||||
|
||||
|
||||
# Global processor instance
|
||||
transaction_processor = LamassuTransactionProcessor()
|
||||
|
||||
|
||||
async def poll_lamassu_transactions() -> None:
|
||||
"""Entry point for the polling task"""
|
||||
await transaction_processor.poll_and_process()
|
||||
138
views_api.py
138
views_api.py
|
|
@ -1,6 +1,7 @@
|
|||
# Description: This file contains the extensions API endpoints.
|
||||
|
||||
from http import HTTPStatus
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from lnbits.core.crud import get_user
|
||||
|
|
@ -26,6 +27,14 @@ from .crud import (
|
|||
get_deposit,
|
||||
update_deposit_status,
|
||||
get_client_balance_summary,
|
||||
# Lamassu config CRUD operations
|
||||
create_lamassu_config,
|
||||
get_lamassu_config,
|
||||
get_active_lamassu_config,
|
||||
get_all_lamassu_configs,
|
||||
update_lamassu_config,
|
||||
update_config_test_result,
|
||||
delete_lamassu_config,
|
||||
)
|
||||
from .helpers import lnurler
|
||||
from .models import (
|
||||
|
|
@ -33,7 +42,8 @@ from .models import (
|
|||
# DCA models
|
||||
CreateDcaClientData, DcaClient, UpdateDcaClientData,
|
||||
CreateDepositData, DcaDeposit, UpdateDepositStatusData,
|
||||
ClientBalanceSummary
|
||||
ClientBalanceSummary,
|
||||
CreateLamassuConfigData, LamassuConfig, UpdateLamassuConfigData
|
||||
)
|
||||
|
||||
myextension_api_router = APIRouter()
|
||||
|
|
@ -307,3 +317,129 @@ async def api_update_deposit_status(
|
|||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Failed to update deposit."
|
||||
)
|
||||
return updated_deposit
|
||||
|
||||
|
||||
# Transaction Polling Endpoints
|
||||
|
||||
@myextension_api_router.post("/api/v1/dca/test-connection")
|
||||
async def api_test_database_connection(
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
"""Test connection to Lamassu database"""
|
||||
try:
|
||||
from .transaction_processor import transaction_processor
|
||||
|
||||
connection = await transaction_processor.connect_to_lamassu_db()
|
||||
if connection:
|
||||
await connection.close()
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Successfully connected to Lamassu database"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to connect to Lamassu database. Check configuration."
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Database connection error: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
@myextension_api_router.post("/api/v1/dca/manual-poll")
|
||||
async def api_manual_poll(
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
"""Manually trigger a poll of the Lamassu database"""
|
||||
try:
|
||||
from .transaction_processor import transaction_processor
|
||||
|
||||
# Connect to database
|
||||
connection = await transaction_processor.connect_to_lamassu_db()
|
||||
if not connection:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
detail="Could not connect to Lamassu database"
|
||||
)
|
||||
|
||||
try:
|
||||
# Fetch and process transactions
|
||||
new_transactions = await transaction_processor.fetch_new_transactions(connection)
|
||||
|
||||
transactions_processed = 0
|
||||
for transaction in new_transactions:
|
||||
await transaction_processor.process_transaction(transaction)
|
||||
transactions_processed += 1
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"transactions_processed": transactions_processed,
|
||||
"message": f"Processed {transactions_processed} new transactions"
|
||||
}
|
||||
|
||||
finally:
|
||||
await connection.close()
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error during manual poll: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# Lamassu Configuration Endpoints
|
||||
|
||||
@myextension_api_router.get("/api/v1/dca/config")
|
||||
async def api_get_lamassu_config(
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
) -> Optional[LamassuConfig]:
|
||||
"""Get active Lamassu database configuration"""
|
||||
return await get_active_lamassu_config()
|
||||
|
||||
|
||||
@myextension_api_router.post("/api/v1/dca/config", status_code=HTTPStatus.CREATED)
|
||||
async def api_create_lamassu_config(
|
||||
data: CreateLamassuConfigData,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> LamassuConfig:
|
||||
"""Create/update Lamassu database configuration"""
|
||||
return await create_lamassu_config(data)
|
||||
|
||||
|
||||
@myextension_api_router.put("/api/v1/dca/config/{config_id}")
|
||||
async def api_update_lamassu_config(
|
||||
config_id: str,
|
||||
data: UpdateLamassuConfigData,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> LamassuConfig:
|
||||
"""Update Lamassu database configuration"""
|
||||
config = await get_lamassu_config(config_id)
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Configuration not found."
|
||||
)
|
||||
|
||||
updated_config = await update_lamassu_config(config_id, data)
|
||||
if not updated_config:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Failed to update configuration."
|
||||
)
|
||||
return updated_config
|
||||
|
||||
|
||||
@myextension_api_router.delete("/api/v1/dca/config/{config_id}")
|
||||
async def api_delete_lamassu_config(
|
||||
config_id: str,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
"""Delete Lamassu database configuration"""
|
||||
config = await get_lamassu_config(config_id)
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Configuration not found."
|
||||
)
|
||||
|
||||
await delete_lamassu_config(config_id)
|
||||
return {"message": "Configuration deleted successfully"}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue