diff --git a/crud.py b/crud.py index 2dfd90d..48f6285 100644 --- a/crud.py +++ b/crud.py @@ -1,11 +1,18 @@ # Description: This file contains the CRUD operations for talking to the database. from typing import List, Optional, Union +from datetime import datetime from lnbits.db import Database from lnbits.helpers import urlsafe_short_hash -from .models import CreateMyExtensionData, MyExtension +from .models import ( + CreateMyExtensionData, MyExtension, + CreateDcaClientData, DcaClient, UpdateDcaClientData, + CreateDepositData, DcaDeposit, UpdateDepositStatusData, + CreateDcaPaymentData, DcaPayment, + ClientBalanceSummary +) db = Database("ext_myextension") @@ -43,3 +50,245 @@ async def delete_myextension(myextension_id: str) -> None: await db.execute( "DELETE FROM myextension.maintable WHERE id = :id", {"id": myextension_id} ) + + +# DCA Client CRUD Operations +async def create_dca_client(data: CreateDcaClientData) -> DcaClient: + client_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO myextension.dca_clients + (id, user_id, wallet_id, dca_mode, fixed_mode_daily_limit, status, created_at, updated_at) + VALUES (:id, :user_id, :wallet_id, :dca_mode, :fixed_mode_daily_limit, :status, :created_at, :updated_at) + """, + { + "id": client_id, + "user_id": data.user_id, + "wallet_id": data.wallet_id, + "dca_mode": data.dca_mode, + "fixed_mode_daily_limit": data.fixed_mode_daily_limit, + "status": "active", + "created_at": datetime.now(), + "updated_at": datetime.now() + } + ) + return await get_dca_client(client_id) + + +async def get_dca_client(client_id: str) -> Optional[DcaClient]: + return await db.fetchone( + "SELECT * FROM myextension.dca_clients WHERE id = :id", + {"id": client_id}, + DcaClient, + ) + + +async def get_dca_clients() -> List[DcaClient]: + return await db.fetchall( + "SELECT * FROM myextension.dca_clients ORDER BY created_at DESC", + model=DcaClient, + ) + + +async def get_dca_client_by_user(user_id: str) -> Optional[DcaClient]: + return await db.fetchone( + "SELECT * FROM myextension.dca_clients WHERE user_id = :user_id", + {"user_id": user_id}, + DcaClient, + ) + + +async def update_dca_client(client_id: str, data: UpdateDcaClientData) -> Optional[DcaClient]: + update_data = {k: v for k, v in data.dict().items() if v is not None} + if not update_data: + return await get_dca_client(client_id) + + update_data["updated_at"] = datetime.now() + set_clause = ", ".join([f"{k} = :{k}" for k in update_data.keys()]) + update_data["id"] = client_id + + await db.execute( + f"UPDATE myextension.dca_clients SET {set_clause} WHERE id = :id", + update_data + ) + return await get_dca_client(client_id) + + +async def delete_dca_client(client_id: str) -> None: + await db.execute( + "DELETE FROM myextension.dca_clients WHERE id = :id", + {"id": client_id} + ) + + +# DCA Deposit CRUD Operations +async def create_deposit(data: CreateDepositData) -> DcaDeposit: + deposit_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO myextension.dca_deposits + (id, client_id, amount, currency, status, notes, created_at) + VALUES (:id, :client_id, :amount, :currency, :status, :notes, :created_at) + """, + { + "id": deposit_id, + "client_id": data.client_id, + "amount": data.amount, + "currency": data.currency, + "status": "pending", + "notes": data.notes, + "created_at": datetime.now() + } + ) + return await get_deposit(deposit_id) + + +async def get_deposit(deposit_id: str) -> Optional[DcaDeposit]: + return await db.fetchone( + "SELECT * FROM myextension.dca_deposits WHERE id = :id", + {"id": deposit_id}, + DcaDeposit, + ) + + +async def get_deposits_by_client(client_id: str) -> List[DcaDeposit]: + return await db.fetchall( + "SELECT * FROM myextension.dca_deposits WHERE client_id = :client_id ORDER BY created_at DESC", + {"client_id": client_id}, + DcaDeposit, + ) + + +async def get_all_deposits() -> List[DcaDeposit]: + return await db.fetchall( + "SELECT * FROM myextension.dca_deposits ORDER BY created_at DESC", + model=DcaDeposit, + ) + + +async def update_deposit_status(deposit_id: str, data: UpdateDepositStatusData) -> Optional[DcaDeposit]: + update_data = { + "status": data.status, + "notes": data.notes + } + + if data.status == "confirmed": + update_data["confirmed_at"] = datetime.now() + + set_clause = ", ".join([f"{k} = :{k}" for k, v in update_data.items() if v is not None]) + filtered_data = {k: v for k, v in update_data.items() if v is not None} + filtered_data["id"] = deposit_id + + await db.execute( + f"UPDATE myextension.dca_deposits SET {set_clause} WHERE id = :id", + filtered_data + ) + return await get_deposit(deposit_id) + + +# DCA Payment CRUD Operations +async def create_dca_payment(data: CreateDcaPaymentData) -> DcaPayment: + payment_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO myextension.dca_payments + (id, client_id, amount_sats, amount_fiat, exchange_rate, transaction_type, + lamassu_transaction_id, payment_hash, status, created_at) + VALUES (:id, :client_id, :amount_sats, :amount_fiat, :exchange_rate, :transaction_type, + :lamassu_transaction_id, :payment_hash, :status, :created_at) + """, + { + "id": payment_id, + "client_id": data.client_id, + "amount_sats": data.amount_sats, + "amount_fiat": data.amount_fiat, + "exchange_rate": data.exchange_rate, + "transaction_type": data.transaction_type, + "lamassu_transaction_id": data.lamassu_transaction_id, + "payment_hash": data.payment_hash, + "status": "pending", + "created_at": datetime.now() + } + ) + return await get_dca_payment(payment_id) + + +async def get_dca_payment(payment_id: str) -> Optional[DcaPayment]: + return await db.fetchone( + "SELECT * FROM myextension.dca_payments WHERE id = :id", + {"id": payment_id}, + DcaPayment, + ) + + +async def get_payments_by_client(client_id: str) -> List[DcaPayment]: + return await db.fetchall( + "SELECT * FROM myextension.dca_payments WHERE client_id = :client_id ORDER BY created_at DESC", + {"client_id": client_id}, + DcaPayment, + ) + + +async def get_all_payments() -> List[DcaPayment]: + return await db.fetchall( + "SELECT * FROM myextension.dca_payments ORDER BY created_at DESC", + model=DcaPayment, + ) + + +async def get_payments_by_lamassu_transaction(lamassu_transaction_id: str) -> List[DcaPayment]: + return await db.fetchall( + "SELECT * FROM myextension.dca_payments WHERE lamassu_transaction_id = :transaction_id", + {"transaction_id": lamassu_transaction_id}, + DcaPayment, + ) + + +# Balance and Summary Operations +async def get_client_balance_summary(client_id: str) -> ClientBalanceSummary: + # Get total confirmed deposits + total_deposits_result = await db.fetchone( + """ + SELECT COALESCE(SUM(amount), 0) as total, currency + FROM myextension.dca_deposits + WHERE client_id = :client_id AND status = 'confirmed' + GROUP BY currency + """, + {"client_id": client_id} + ) + + # Get total payments made + total_payments_result = await db.fetchone( + """ + SELECT COALESCE(SUM(amount_fiat), 0) as total + FROM myextension.dca_payments + WHERE client_id = :client_id AND status = 'confirmed' + """, + {"client_id": client_id} + ) + + total_deposits = total_deposits_result["total"] if total_deposits_result else 0 + total_payments = total_payments_result["total"] if total_payments_result else 0 + currency = total_deposits_result["currency"] if total_deposits_result else "GTQ" + + return ClientBalanceSummary( + client_id=client_id, + total_deposits=total_deposits, + total_payments=total_payments, + remaining_balance=total_deposits - total_payments, + currency=currency + ) + + +async def get_flow_mode_clients() -> List[DcaClient]: + return await db.fetchall( + "SELECT * FROM myextension.dca_clients WHERE dca_mode = 'flow' AND status = 'active'", + model=DcaClient, + ) + + +async def get_fixed_mode_clients() -> List[DcaClient]: + return await db.fetchall( + "SELECT * FROM myextension.dca_clients WHERE dca_mode = 'fixed' AND status = 'active'", + model=DcaClient, + ) diff --git a/migrations.py b/migrations.py index 9fb142b..0105773 100644 --- a/migrations.py +++ b/migrations.py @@ -31,3 +31,67 @@ async def m002_add_timestamp(db): ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}; """ ) + + +async def m003_create_dca_clients(db): + """ + Create DCA clients table. + """ + await db.execute( + f""" + CREATE TABLE myextension.dca_clients ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL, + wallet_id TEXT NOT NULL, + dca_mode TEXT NOT NULL DEFAULT 'flow', + fixed_mode_daily_limit INTEGER, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + ) + + +async def m004_create_dca_deposits(db): + """ + Create DCA deposits table. + """ + await db.execute( + f""" + CREATE TABLE myextension.dca_deposits ( + id TEXT PRIMARY KEY NOT NULL, + client_id TEXT NOT NULL, + amount INTEGER NOT NULL, + currency TEXT NOT NULL DEFAULT 'GTQ', + status TEXT NOT NULL DEFAULT 'pending', + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + confirmed_at TIMESTAMP, + FOREIGN KEY (client_id) REFERENCES myextension.dca_clients(id) + ); + """ + ) + + +async def m005_create_dca_payments(db): + """ + Create DCA payments table. + """ + await db.execute( + f""" + CREATE TABLE myextension.dca_payments ( + id TEXT PRIMARY KEY NOT NULL, + client_id TEXT NOT NULL, + amount_sats INTEGER NOT NULL, + amount_fiat INTEGER NOT NULL, + exchange_rate REAL NOT NULL, + transaction_type TEXT NOT NULL, + lamassu_transaction_id TEXT, + payment_hash TEXT, + status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + FOREIGN KEY (client_id) REFERENCES myextension.dca_clients(id) + ); + """ + ) diff --git a/models.py b/models.py index e836524..8fa9903 100644 --- a/models.py +++ b/models.py @@ -1,10 +1,105 @@ # Description: Pydantic data models dictate what is passed between frontend and backend. +from datetime import datetime from typing import Optional from pydantic import BaseModel +# DCA Client Models +class CreateDcaClientData(BaseModel): + user_id: str + wallet_id: str + dca_mode: str = "flow" # 'flow' or 'fixed' + fixed_mode_daily_limit: Optional[int] = None + + +class DcaClient(BaseModel): + id: str + user_id: str + wallet_id: str + dca_mode: str + fixed_mode_daily_limit: Optional[int] + status: str + created_at: datetime + updated_at: datetime + + +class UpdateDcaClientData(BaseModel): + dca_mode: Optional[str] = None + fixed_mode_daily_limit: Optional[int] = None + status: Optional[str] = None + + +# Deposit Models +class CreateDepositData(BaseModel): + client_id: str + amount: int # Amount in smallest currency unit (centavos for GTQ) + currency: str = "GTQ" + notes: Optional[str] = None + + +class DcaDeposit(BaseModel): + id: str + client_id: str + amount: int + currency: str + status: str # 'pending' or 'confirmed' + notes: Optional[str] + created_at: datetime + confirmed_at: Optional[datetime] + + +class UpdateDepositStatusData(BaseModel): + status: str + notes: Optional[str] = None + + +# Payment Models +class CreateDcaPaymentData(BaseModel): + client_id: str + amount_sats: int + amount_fiat: int + exchange_rate: float + transaction_type: str # 'flow', 'fixed', 'manual', 'commission' + lamassu_transaction_id: Optional[str] = None + payment_hash: Optional[str] = None + + +class DcaPayment(BaseModel): + id: str + client_id: str + amount_sats: int + amount_fiat: int + exchange_rate: float + transaction_type: str + lamassu_transaction_id: Optional[str] + payment_hash: Optional[str] + status: str # 'pending', 'confirmed', 'failed' + created_at: datetime + + +# Client Balance Summary +class ClientBalanceSummary(BaseModel): + 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: int + amount_crypto: int + exchange_rate: float + transaction_type: str # 'cash_in' or 'cash_out' + status: str + timestamp: datetime + + +# Legacy models (keep for backward compatibility during transition) class CreateMyExtensionData(BaseModel): id: Optional[str] = "" name: str