diff --git a/.gitignore b/.gitignore index d32bb81..0152b6e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,4 @@ -# LNbits runtime data — auth keys, DB files, etc. Never commit. -data/ -*.sqlite3 -*.sqlite3-journal -__pycache__/ -*.pyc +__pycache__ +node_modules +.mypy_cache +.venv diff --git a/README.md b/README.md index 7c9d294..7513bf9 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,6 @@ # DCA Client Extension for LNBits -> **Status: maintenance-mode (v2).** This extension provides a minimal LP-facing -> read view over the v2 satmachineadmin schema. The richer LP UI is migrating to -> `~/dev/webapp`. Keep this surface stable; new LP features should land in webapp. - -A Dollar Cost Averaging (DCA) administration extension for LNBits that integrates with bitSpire ATMs (formerly Lamassu) to automatically distribute Bitcoin to registered liquidity providers (LPs) based on their deposit balances. +A Dollar Cost Averaging (DCA) administration extension for LNBits that integrates with Lamassu ATM machines to automatically distribute Bitcoin to registered clients based on their deposit balances. ## Overview diff --git a/crud.py b/crud.py index 16ac206..26cc755 100644 --- a/crud.py +++ b/crud.py @@ -1,277 +1,521 @@ -# Satoshi Machine Client v2 — CRUD over admin schema. -# -# Cross-extension reads of satoshimachine.dca_* tables, filtered by the -# LP's user_id. The admin extension owns writes to dca_clients/deposits/ -# settlements; this extension owns writes to satoshimachine.dca_lp -# (the LP's per-user preferences row — wallet, mode, autoforward). +# Description: Client extension CRUD operations - reads from admin extension database -from datetime import datetime from typing import List, Optional +from datetime import datetime, timedelta from lnbits.db import Database from lnbits.utils.exchange_rates import satoshis_amount_as_fiat -from loguru import logger +from lnbits.core.crud.wallets import get_wallet from .models import ( ClientDashboardSummary, ClientTransaction, - LpPreferences, - PerMachinePosition, - UpdateLpPreferences, + ClientAnalytics, + UpdateClientSettings, + ClientRegistrationData, ) -# Same DB schema as the admin extension — we share satoshimachine.* tables. +# Connect to admin extension's database db = Database("ext_satoshimachine") -async def _fetch_user_clients(user_id: str) -> List[dict]: - """All dca_clients rows for this LP, joined with their machines for - per-machine display metadata. +################################################### +############## CLIENT DASHBOARD CRUD ############## +################################################### - `dca_mode` lives on the LP's per-user `dca_lp` row now, not per - enrolment — INNER JOIN dca_lp so positions only return when the LP - has actually onboarded (otherwise distribution can't pay them either, - so listing them in the dashboard would be misleading). - """ - return await db.fetchall( - """ - SELECT c.id AS client_id, - c.machine_id, - c.status, - m.machine_npub, - m.name AS machine_name, - m.location AS machine_location, - m.fiat_code AS machine_fiat_code, - lp.default_dca_mode AS dca_mode - FROM satoshimachine.dca_clients c - JOIN satoshimachine.dca_machines m ON m.id = c.machine_id - JOIN satoshimachine.dca_lp lp ON lp.user_id = c.user_id - WHERE c.user_id = :user_id - ORDER BY c.created_at DESC - """, - {"user_id": user_id}, +async def get_client_dashboard_summary(user_id: str) -> Optional[ClientDashboardSummary]: + """Get dashboard summary for a specific user""" + + # Get client info + client = await db.fetchone( + "SELECT * FROM satoshimachine.dca_clients WHERE user_id = :user_id", + {"user_id": user_id} ) - - -async def _position_for_client(client_row: dict) -> PerMachinePosition: - cid = client_row["client_id"] - confirmed_deposits = await db.fetchone( - """ - SELECT COALESCE(SUM(amount), 0) AS total - FROM satoshimachine.dca_deposits - WHERE client_id = :cid AND status = 'confirmed' - """, - {"cid": cid}, - ) - # DCA + settlement legs both count against the LP's balance. - distributed = await db.fetchone( - """ - SELECT COALESCE(SUM(amount_fiat), 0) AS total_fiat, - COALESCE(SUM(amount_sats), 0) AS total_sats, - COUNT(*) AS tx_count, - MAX(created_at) AS last_tx - FROM satoshimachine.dca_payments - WHERE client_id = :cid - AND leg_type IN ('dca', 'settlement') - AND status = 'completed' - """, - {"cid": cid}, - ) - total_deposits = float(confirmed_deposits["total"]) if confirmed_deposits else 0.0 - total_fiat = float(distributed["total_fiat"]) if distributed else 0.0 - total_sats = int(distributed["total_sats"]) if distributed else 0 - tx_count = int(distributed["tx_count"]) if distributed else 0 - last_tx = distributed["last_tx"] if distributed else None - return PerMachinePosition( - machine_id=client_row["machine_id"], - machine_npub=client_row["machine_npub"], - machine_name=client_row["machine_name"], - machine_location=client_row["machine_location"], - currency=client_row["machine_fiat_code"], - dca_mode=client_row["dca_mode"], - status=client_row["status"], - total_sats_accumulated=total_sats, - total_fiat_invested=round(total_deposits, 2), - current_fiat_balance=round(total_deposits - total_fiat, 2), - total_transactions=tx_count, - last_transaction_date=last_tx, - ) - - -async def get_client_dashboard_summary( - user_id: str, -) -> Optional[ClientDashboardSummary]: - """Aggregated LP dashboard. Returns None if the LP has no positions.""" - clients = await _fetch_user_clients(user_id) - if not clients: + + if not client: return None - positions = [await _position_for_client(c) for c in clients] - total_sats = sum(p.total_sats_accumulated for p in positions) - total_invested = round(sum(p.total_fiat_invested for p in positions), 2) - total_balance = round(sum(p.current_fiat_balance for p in positions), 2) - total_tx = sum(p.total_transactions for p in positions) - last_tx = max( - (p.last_transaction_date for p in positions if p.last_transaction_date), - default=None, - ) - # Pending deposits (across all clients of this LP). - pending = await db.fetchone( + + # Get wallet to determine currency + wallet = await get_wallet(client["wallet_id"]) + # TODO: Get currency from wallet; bit more difficult to do in a different + # currency than deposit cause of cross exchange rates + # currency = wallet.currency or "GTQ" # Default to GTQ if no currency set + currency = "GTQ" # Default to GTQ if no currency set + + # Get total sats accumulated from DCA transactions + sats_result = await db.fetchone( """ - SELECT COALESCE(SUM(d.amount), 0) AS total - FROM satoshimachine.dca_deposits d - JOIN satoshimachine.dca_clients c ON c.id = d.client_id - WHERE c.user_id = :uid AND d.status = 'pending' + SELECT COALESCE(SUM(amount_sats), 0) as total_sats + FROM satoshimachine.dca_payments + WHERE client_id = :client_id AND status = 'confirmed' """, - {"uid": user_id}, + {"client_id": client["id"]} ) - pending_fiat = float(pending["total"]) if pending else 0.0 - # Cost basis = sats / fiat-spent. Use the distributed fiat (deposits - # less remaining balance) so we get the cost basis on what's actually - # been DCA'd, not the gross deposits. - fiat_spent = max(total_invested - total_balance, 0.0) - cost_basis = total_sats / fiat_spent if fiat_spent > 0 else 0.0 - # Display currency: if all positions share one, use it; else "MIX". - currencies = {p.currency for p in positions} - currency = currencies.pop() if len(currencies) == 1 else "MIX" - # Best-effort current fiat value of the LP's sats stack. - current_value = 0.0 - if total_sats > 0 and currency != "MIX": + + # Get total confirmed deposits (this is the "total invested") + deposits_result = await db.fetchone( + """ + SELECT COALESCE(SUM(amount), 0) as confirmed_deposits + FROM satoshimachine.dca_deposits + WHERE client_id = :client_id AND status = 'confirmed' + """, + {"client_id": client["id"]} + ) + + # Get total pending deposits (for additional info) + pending_deposits_result = await db.fetchone( + """ + SELECT COALESCE(SUM(amount), 0) as pending_deposits + FROM satoshimachine.dca_deposits + WHERE client_id = :client_id AND status = 'pending' + """, + {"client_id": client["id"]} + ) + + # Get total fiat spent on DCA transactions (to calculate remaining balance) + dca_spent_result = await db.fetchone( + """ + SELECT COALESCE(SUM(amount_fiat), 0) as dca_spent + FROM satoshimachine.dca_payments + WHERE client_id = :client_id AND status = 'confirmed' + """, + {"client_id": client["id"]} + ) + + # Get transaction count and last transaction date + tx_stats = await db.fetchone( + """ + SELECT + COUNT(*) as tx_count, + MAX(created_at) as last_tx_date + FROM satoshimachine.dca_payments + WHERE client_id = :client_id AND status = 'confirmed' + """, + {"client_id": client["id"]} + ) + + # Extract values from query results + total_sats = sats_result["total_sats"] if sats_result else 0 + confirmed_deposits = deposits_result["confirmed_deposits"] if deposits_result else 0 + pending_deposits = pending_deposits_result["pending_deposits"] if pending_deposits_result else 0 + dca_spent = dca_spent_result["dca_spent"] if dca_spent_result else 0 + + # Calculate metrics + total_invested = confirmed_deposits # Total invested = all confirmed deposits + remaining_balance = confirmed_deposits - dca_spent # Remaining = deposits - DCA spending + avg_cost_basis = total_sats / dca_spent if dca_spent > 0 else 0 # Cost basis = sats / GTQ + + # Calculate current fiat value of total sats + current_sats_fiat_value = 0.0 + if total_sats > 0: try: - current_value = await satoshis_amount_as_fiat(total_sats, currency) - except Exception as exc: - logger.warning( - f"satmachineclient: could not fetch exchange rate for " - f"{currency}: {exc}" - ) + current_sats_fiat_value = await satoshis_amount_as_fiat(total_sats, currency) + except Exception as e: + print(f"Warning: Could not fetch exchange rate for {currency}: {e}") + current_sats_fiat_value = 0.0 + return ClientDashboardSummary( user_id=user_id, total_sats_accumulated=total_sats, - total_fiat_invested=total_invested, - current_fiat_balance=total_balance, - pending_fiat_deposits=round(pending_fiat, 2), - average_cost_basis=round(cost_basis, 4), - current_sats_fiat_value=round(current_value, 2), - total_transactions=total_tx, - total_machines=len(positions), - last_transaction_date=last_tx, - currency=currency, - positions=positions, + total_fiat_invested=total_invested, # Sum of confirmed deposits + pending_fiat_deposits=pending_deposits, # Sum of pending deposits + current_sats_fiat_value=current_sats_fiat_value, # Current fiat value of sats + average_cost_basis=avg_cost_basis, + current_fiat_balance=remaining_balance, # Confirmed deposits - DCA spent + total_transactions=tx_stats["tx_count"] if tx_stats else 0, + dca_mode=client["dca_mode"], + dca_status=client["status"], + last_transaction_date=tx_stats["last_tx_date"] if tx_stats else None, + currency=currency # Wallet's currency ) async def get_client_transactions( - user_id: str, + user_id: str, limit: int = 50, offset: int = 0, - machine_id: Optional[str] = None, + transaction_type: Optional[str] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None ) -> List[ClientTransaction]: - """LP's transaction history. Filters to 'dca' / 'settlement' / - 'autoforward' legs since those are the ones the LP cares about — super_fee - and operator_split legs are operator-internal. - - Optional machine_id narrows to a single machine.""" - params: dict = { - "uid": user_id, - "lim": limit, - "off": offset, - } - where = ( - "WHERE c.user_id = :uid " - "AND p.leg_type IN ('dca', 'settlement', 'autoforward')" + """Get client's transaction history with filtering""" + + # Get client ID first + client = await db.fetchone( + "SELECT id FROM satoshimachine.dca_clients WHERE user_id = :user_id", + {"user_id": user_id} ) - if machine_id is not None: - where += " AND p.machine_id = :mid" - params["mid"] = machine_id - rows = await db.fetchall( + + if not client: + return [] + + # Build query with filters + where_conditions = ["client_id = :client_id"] + params = {"client_id": client["id"], "limit": limit, "offset": offset} + + if transaction_type: + where_conditions.append("transaction_type = :transaction_type") + params["transaction_type"] = transaction_type + + if start_date: + where_conditions.append("created_at >= :start_date") + params["start_date"] = start_date + + if end_date: + where_conditions.append("created_at <= :end_date") + params["end_date"] = end_date + + where_clause = " AND ".join(where_conditions) + + transactions = await db.fetchall( f""" - SELECT p.id, p.machine_id, p.settlement_id, p.leg_type, - p.amount_sats, p.amount_fiat, p.exchange_rate, p.status, - p.created_at, p.transaction_time, - m.machine_npub - FROM satoshimachine.dca_payments p - JOIN satoshimachine.dca_clients c ON c.id = p.client_id - JOIN satoshimachine.dca_machines m ON m.id = p.machine_id - {where} - ORDER BY p.created_at DESC - LIMIT :lim OFFSET :off + SELECT id, amount_sats, amount_fiat, exchange_rate, transaction_type, + status, created_at, transaction_time, lamassu_transaction_id + FROM satoshimachine.dca_payments + WHERE {where_clause} + ORDER BY created_at DESC + LIMIT :limit OFFSET :offset """, - params, + params ) + return [ ClientTransaction( - id=row["id"], - machine_id=row["machine_id"], - machine_npub=row["machine_npub"], - settlement_id=row["settlement_id"], - leg_type=row["leg_type"], - amount_sats=row["amount_sats"], - amount_fiat=row["amount_fiat"], - exchange_rate=row["exchange_rate"], - status=row["status"], - created_at=row["created_at"], - transaction_time=row["transaction_time"], + id=tx["id"], + amount_sats=tx["amount_sats"], + amount_fiat=tx["amount_fiat"], + exchange_rate=tx["exchange_rate"], + transaction_type=tx["transaction_type"], + status=tx["status"], + created_at=tx["created_at"], + transaction_time=tx["transaction_time"], + lamassu_transaction_id=tx["lamassu_transaction_id"] ) - for row in rows + for tx in transactions ] -async def get_lp_preferences(user_id: str) -> Optional[LpPreferences]: - """Read this LP's preferences row from `dca_lp`. Returns None if the - LP hasn't onboarded yet (no row). Callers in this extension generally - use `ensure_lp_preferences` instead, which auto-creates on first - access.""" +async def get_client_analytics(user_id: str, time_range: str = "30d") -> Optional[ClientAnalytics]: + """Get client performance analytics""" + + try: + from datetime import datetime + + # Get client ID + client = await db.fetchone( + "SELECT id FROM satoshimachine.dca_clients WHERE user_id = :user_id", + {"user_id": user_id} + ) + + if not client: + print(f"No client found for user_id: {user_id}") + return None + + print(f"Found client {client['id']} for user {user_id}, loading analytics for time_range: {time_range}") + + # Calculate date range + if time_range == "7d": + start_date = datetime.now() - timedelta(days=7) + elif time_range == "30d": + start_date = datetime.now() - timedelta(days=30) + elif time_range == "90d": + start_date = datetime.now() - timedelta(days=90) + elif time_range == "1y": + start_date = datetime.now() - timedelta(days=365) + else: # "all" + start_date = datetime(2020, 1, 1) # Arbitrary early date + + # Get cost basis history (running average) + cost_basis_data = await db.fetchall( + """ + SELECT + COALESCE(transaction_time, created_at) as transaction_date, + amount_sats, + amount_fiat, + exchange_rate, + SUM(amount_sats) OVER (ORDER BY COALESCE(transaction_time, created_at)) as cumulative_sats, + SUM(amount_fiat) OVER (ORDER BY COALESCE(transaction_time, created_at)) as cumulative_fiat + FROM satoshimachine.dca_payments + WHERE client_id = :client_id + AND status = 'confirmed' + AND COALESCE(transaction_time, created_at) IS NOT NULL + AND COALESCE(transaction_time, created_at) >= :start_date + ORDER BY COALESCE(transaction_time, created_at) + """, + {"client_id": client["id"], "start_date": start_date} + ) + + # Build cost basis history + cost_basis_history = [] + for record in cost_basis_data: + avg_cost_basis = record["cumulative_sats"] / record["cumulative_fiat"] if record["cumulative_fiat"] > 0 else 0 # Cost basis = sats / GTQ + # Use transaction_date (which is COALESCE(transaction_time, created_at)) + date_to_use = record["transaction_date"] + if date_to_use is None: + print(f"Warning: Null date in cost basis data, skipping record") + continue + elif hasattr(date_to_use, 'isoformat'): + # This is a datetime object + date_str = date_to_use.isoformat() + elif hasattr(date_to_use, 'strftime'): + # This is a date object + date_str = date_to_use.strftime('%Y-%m-%d') + elif isinstance(date_to_use, (int, float)): + # This might be a Unix timestamp - check if it's in a reasonable range + timestamp = float(date_to_use) + # Check if this looks like a timestamp (between 1970 and 2100) + if 0 < timestamp < 4102444800: # Jan 1, 2100 + # Could be seconds or milliseconds + if timestamp > 1000000000000: # Likely milliseconds + timestamp = timestamp / 1000 + date_str = datetime.fromtimestamp(timestamp).isoformat() + else: + # Not a timestamp, treat as string + date_str = str(date_to_use) + print(f"Warning: Numeric date value out of timestamp range: {date_to_use}") + elif isinstance(date_to_use, str) and date_to_use.isdigit(): + # This is a numeric string - might be a timestamp + timestamp = float(date_to_use) + # Check if this looks like a timestamp + if 0 < timestamp < 4102444800: # Jan 1, 2100 + # Could be seconds or milliseconds + if timestamp > 1000000000000: # Likely milliseconds + timestamp = timestamp / 1000 + date_str = datetime.fromtimestamp(timestamp).isoformat() + else: + # Not a timestamp, treat as string + date_str = str(date_to_use) + print(f"Warning: Numeric date string out of timestamp range: {date_to_use}") + else: + # Convert string representation to proper format + date_str = str(date_to_use) + print(f"Warning: Unexpected date format: {date_to_use} (type: {type(date_to_use)})") + + cost_basis_history.append({ + "date": date_str, + "average_cost_basis": avg_cost_basis, + "cumulative_sats": record["cumulative_sats"], + "cumulative_fiat": record["cumulative_fiat"] + }) + + # Get accumulation timeline (daily/weekly aggregation) + accumulation_data = await db.fetchall( + """ + SELECT + DATE(COALESCE(transaction_time, created_at)) as date, + SUM(amount_sats) as daily_sats, + SUM(amount_fiat) as daily_fiat, + COUNT(*) as daily_transactions + FROM satoshimachine.dca_payments + WHERE client_id = :client_id + AND status = 'confirmed' + AND COALESCE(transaction_time, created_at) IS NOT NULL + AND COALESCE(transaction_time, created_at) >= :start_date + GROUP BY DATE(COALESCE(transaction_time, created_at)) + ORDER BY date + """, + {"client_id": client["id"], "start_date": start_date} + ) + + accumulation_timeline = [] + for record in accumulation_data: + # Handle date conversion safely + date_value = record["date"] + if date_value is None: + print(f"Warning: Null date in accumulation data, skipping record") + continue + elif hasattr(date_value, 'isoformat'): + # This is a datetime object + date_str = date_value.isoformat() + elif hasattr(date_value, 'strftime'): + # This is a date object (from DATE() function) + date_str = date_value.strftime('%Y-%m-%d') + elif isinstance(date_value, (int, float)): + # This might be a Unix timestamp - check if it's in a reasonable range + timestamp = float(date_value) + # Check if this looks like a timestamp (between 1970 and 2100) + if 0 < timestamp < 4102444800: # Jan 1, 2100 + # Could be seconds or milliseconds + if timestamp > 1000000000000: # Likely milliseconds + timestamp = timestamp / 1000 + date_str = datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d') + else: + # Not a timestamp, treat as string + date_str = str(date_value) + print(f"Warning: Numeric accumulation date out of timestamp range: {date_value}") + elif isinstance(date_value, str) and date_value.isdigit(): + # This is a numeric string - might be a timestamp + timestamp = float(date_value) + # Check if this looks like a timestamp + if 0 < timestamp < 4102444800: # Jan 1, 2100 + # Could be seconds or milliseconds + if timestamp > 1000000000000: # Likely milliseconds + timestamp = timestamp / 1000 + date_str = datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d') + else: + # Not a timestamp, treat as string + date_str = str(date_value) + print(f"Warning: Numeric accumulation date string out of timestamp range: {date_value}") + else: + # Convert string representation to proper format + date_str = str(date_value) + print(f"Warning: Unexpected accumulation date format: {date_value} (type: {type(date_value)})") + + accumulation_timeline.append({ + "date": date_str, + "sats": record["daily_sats"], + "fiat": record["daily_fiat"], + "transactions": record["daily_transactions"] + }) + + # Get transaction frequency metrics + frequency_stats = await db.fetchone( + """ + SELECT + COUNT(*) as total_transactions, + AVG(amount_sats) as avg_sats_per_tx, + AVG(amount_fiat) as avg_fiat_per_tx, + MIN(COALESCE(transaction_time, created_at)) as first_tx, + MAX(COALESCE(transaction_time, created_at)) as last_tx + FROM satoshimachine.dca_payments + WHERE client_id = :client_id AND status = 'confirmed' + """, + {"client_id": client["id"]} + ) + + # Build transaction frequency with safe date handling + transaction_frequency = { + "total_transactions": frequency_stats["total_transactions"] if frequency_stats else 0, + "avg_sats_per_transaction": frequency_stats["avg_sats_per_tx"] if frequency_stats else 0, + "avg_fiat_per_transaction": frequency_stats["avg_fiat_per_tx"] if frequency_stats else 0, + "first_transaction": None, + "last_transaction": None + } + + # Handle first_tx date safely + if frequency_stats and frequency_stats["first_tx"]: + first_tx = frequency_stats["first_tx"] + if hasattr(first_tx, 'isoformat'): + transaction_frequency["first_transaction"] = first_tx.isoformat() + else: + transaction_frequency["first_transaction"] = str(first_tx) + + # Handle last_tx date safely + if frequency_stats and frequency_stats["last_tx"]: + last_tx = frequency_stats["last_tx"] + if hasattr(last_tx, 'isoformat'): + transaction_frequency["last_transaction"] = last_tx.isoformat() + else: + transaction_frequency["last_transaction"] = str(last_tx) + + return ClientAnalytics( + user_id=user_id, + cost_basis_history=cost_basis_history, + accumulation_timeline=accumulation_timeline, + transaction_frequency=transaction_frequency + ) + + except Exception as e: + print(f"Error in get_client_analytics for user {user_id}: {str(e)}") + import traceback + traceback.print_exc() + return None + + +async def get_client_by_user_id(user_id: str): + """Get client record by user_id""" return await db.fetchone( - "SELECT * FROM satoshimachine.dca_lp WHERE user_id = :uid", - {"uid": user_id}, - LpPreferences, + "SELECT * FROM satoshimachine.dca_clients WHERE user_id = :user_id", + {"user_id": user_id} ) -async def ensure_lp_preferences(user_id: str, default_wallet_id: str) -> LpPreferences: - """Get-or-create the LP's preferences row. - - First call (no row): seed with `default_wallet_id` (passed by the - caller — typically the wallet the LP authenticated through). LP can - change it later via `update_lp_preferences`. - - This is the structural enforcement of the "LP must onboard before - deposits work" gate: the act of opening satmachineclient and hitting - any endpoint creates the dca_lp row, which unlocks deposit creation - on the operator side. - """ - existing = await get_lp_preferences(user_id) - if existing is not None: - return existing - now = datetime.now() - await db.execute( - """ - INSERT INTO satoshimachine.dca_lp - (user_id, dca_wallet_id, default_dca_mode, - autoforward_enabled, created_at, updated_at) - VALUES (:uid, :wallet, 'flow', false, :now, :now) - """, - {"uid": user_id, "wallet": default_wallet_id, "now": now}, - ) - created = await get_lp_preferences(user_id) - assert created is not None - return created +async def update_client_dca_settings(client_id: str, settings: UpdateClientSettings) -> bool: + """Update client DCA settings (mode, limits, status)""" + try: + update_data = {k: v for k, v in settings.dict().items() if v is not None} + if not update_data: + return True # Nothing to update + + 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 satoshimachine.dca_clients SET {set_clause} WHERE id = :id", + update_data + ) + return True + except Exception: + return False -async def update_lp_preferences( - user_id: str, data: UpdateLpPreferences -) -> Optional[LpPreferences]: - """LP-side update of their `dca_lp` row. Caller must ensure the row - exists first (typically via `ensure_lp_preferences` on dashboard - load). Operator cannot reach this path — it requires the LP's wallet - admin key per the API auth dependency.""" - update_data = {k: v for k, v in data.dict().items() if v is not None} - if not update_data: - return await get_lp_preferences(user_id) - update_data["updated_at"] = datetime.now() - update_data["uid"] = user_id - set_clause = ", ".join(f"{k} = :{k}" for k in update_data if k not in ("uid",)) - await db.execute( - f"UPDATE satoshimachine.dca_lp SET {set_clause} WHERE user_id = :uid", - update_data, - ) - return await get_lp_preferences(user_id) +################################################### +############## CLIENT REGISTRATION ############### +################################################### + +async def register_dca_client(user_id: str, wallet_id: str, registration_data: ClientRegistrationData) -> Optional[dict]: + """Register a new DCA client - special permission for self-registration""" + from lnbits.helpers import urlsafe_short_hash + from lnbits.core.crud import get_user + + try: + # Verify user exists and get username + user = await get_user(user_id) + username = registration_data.username or (user.username if user else f"user_{user_id[:8]}") + + # Check if client already exists + existing_client = await db.fetchone( + "SELECT id FROM satoshimachine.dca_clients WHERE user_id = :user_id", + {"user_id": user_id} + ) + + if existing_client: + return {"error": "Client already registered", "client_id": existing_client[0]} + + # Create new client + client_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO satoshimachine.dca_clients + (id, user_id, wallet_id, username, dca_mode, fixed_mode_daily_limit, status, created_at, updated_at) + VALUES (:id, :user_id, :wallet_id, :username, :dca_mode, :fixed_mode_daily_limit, :status, :created_at, :updated_at) + """, + { + "id": client_id, + "user_id": user_id, + "wallet_id": wallet_id, + "username": username, + "dca_mode": registration_data.dca_mode, + "fixed_mode_daily_limit": registration_data.fixed_mode_daily_limit, + "status": "active", + "created_at": datetime.now(), + "updated_at": datetime.now() + } + ) + + return { + "success": True, + "client_id": client_id, + "message": f"DCA client registered successfully with {registration_data.dca_mode} mode" + } + + except Exception as e: + print(f"Error registering DCA client: {e}") + return {"error": f"Registration failed: {str(e)}"} + + +async def get_client_by_user_id(user_id: str) -> Optional[dict]: + """Get client by user_id - returns dict instead of model for easier access""" + try: + client = await db.fetchone( + "SELECT * FROM satoshimachine.dca_clients WHERE user_id = :user_id", + {"user_id": user_id} + ) + return dict(client) if client else None + except Exception: + return None + + +# Removed get_active_lamassu_config - client should not access sensitive admin config +# Client limits are now fetched via secure public API endpoint \ No newline at end of file diff --git a/migrations.py b/migrations.py index 562bb28..b531f42 100644 --- a/migrations.py +++ b/migrations.py @@ -1,2 +1,2 @@ # No database migrations needed for client extension -# Client extension reads from admin extension's database (ext_satoshimachine schema) +# Client extension reads from admin extension's database (ext_satoshimachine schema) \ No newline at end of file diff --git a/models.py b/models.py index fbc168e..57592ac 100644 --- a/models.py +++ b/models.py @@ -1,12 +1,4 @@ -# Satoshi Machine Client v2 — Pydantic models. -# -# LP-facing read view over the admin extension's v2 schema. An LP can hold -# DCA positions across multiple machines (and across multiple operators on -# the same LNbits instance); summary endpoints aggregate, with an optional -# per-machine breakdown for filtering. -# -# NOTE: this extension is in maintenance mode. The richer LP UI is moving -# to ~/dev/webapp; this surface stays minimal and read-mostly. +# Description: Pydantic data models for client extension API responses from datetime import datetime from typing import List, Optional @@ -14,91 +6,95 @@ from typing import List, Optional from pydantic import BaseModel -class PerMachinePosition(BaseModel): - """LP's position at a single machine. +# API Models for Client Dashboard (Frontend communication in GTQ) +class ClientDashboardSummaryAPI(BaseModel): + """API model - client dashboard summary in GTQ""" + user_id: str + total_sats_accumulated: int + total_fiat_invested_gtq: float # Confirmed deposits in GTQ + pending_fiat_deposits_gtq: float # Pending deposits in GTQ + current_sats_fiat_value_gtq: float # Current fiat value of total sats in GTQ + average_cost_basis: float # Average sats per GTQ + current_fiat_balance_gtq: float # Available balance for DCA in GTQ + total_transactions: int + dca_mode: str # 'flow' or 'fixed' + dca_status: str # 'active' or 'inactive' + last_transaction_date: Optional[datetime] + currency: str = "GTQ" - `dca_mode` was previously per-(machine, LP) and is now LP-wide (lives - on `dca_lp.default_dca_mode`). Echoed here for legacy UI display only - — every position for a given LP shares the same value. - """ - machine_id: str - machine_npub: str - machine_name: Optional[str] - machine_location: Optional[str] - currency: str - dca_mode: str +class ClientTransactionAPI(BaseModel): + """API model - client transaction in GTQ""" + id: str + amount_sats: int + amount_fiat_gtq: float # Amount in GTQ + exchange_rate: float + transaction_type: str # 'flow', 'fixed', 'manual' status: str - total_sats_accumulated: int - total_fiat_invested: float - current_fiat_balance: float - total_transactions: int - last_transaction_date: Optional[datetime] - - -class ClientDashboardSummary(BaseModel): - """LP's aggregated dashboard across all machines they're registered at.""" - - user_id: str - total_sats_accumulated: int - total_fiat_invested: float # confirmed deposits across all machines - current_fiat_balance: float # confirmed deposits - DCA - settlement legs - pending_fiat_deposits: float # deposits in 'pending' status - average_cost_basis: float # total_sats / total_fiat_invested-spent - current_sats_fiat_value: float # current rate × total_sats (best-effort) - total_transactions: int - total_machines: int # how many machines this LP is on - last_transaction_date: Optional[datetime] - currency: str # display currency; if multi-currency, "MIX" - positions: List[PerMachinePosition] = [] - - -class LpPreferences(BaseModel): - """LP-controlled DCA preferences (one row per user in `dca_lp`). - - Auto-created on first satmachineclient dashboard access with the LP's - authenticated wallet as the default `dca_wallet_id`; they can change - any field via `PUT /api/v1/dca-client/preferences`. Distribution - reads from here at payout time — operator cannot override. - """ - - user_id: str - dca_wallet_id: str - default_dca_mode: str # 'flow' | 'fixed' - fixed_mode_daily_limit: Optional[float] - autoforward_ln_address: Optional[str] - autoforward_enabled: bool created_at: datetime - updated_at: datetime + transaction_time: Optional[datetime] = None # Original ATM transaction time + lamassu_transaction_id: Optional[str] = None -class UpdateLpPreferences(BaseModel): - """LP-side preference updates. All fields optional; only ones provided - are touched. Use to switch DCA wallet, change mode, toggle autoforward.""" - - dca_wallet_id: Optional[str] = None - default_dca_mode: Optional[str] = None - fixed_mode_daily_limit: Optional[float] = None - autoforward_ln_address: Optional[str] = None - autoforward_enabled: Optional[bool] = None +# Internal Models for Client Dashboard (Database storage in GTQ) +class ClientDashboardSummary(BaseModel): + """Internal model - client dashboard summary stored in GTQ""" + user_id: str + total_sats_accumulated: int + total_fiat_invested: float # Confirmed deposits in GTQ + pending_fiat_deposits: float # Pending deposits awaiting confirmation in GTQ + current_sats_fiat_value: float # Current fiat value of total sats in GTQ + average_cost_basis: float # Average sats per GTQ + current_fiat_balance: float # Available balance for DCA in GTQ + total_transactions: int + dca_mode: str # 'flow' or 'fixed' + dca_status: str # 'active' or 'inactive' + last_transaction_date: Optional[datetime] + currency: str = "GTQ" class ClientTransaction(BaseModel): - """A single distribution leg landing in the LP's wallet.""" - + """Internal model - client transaction stored in GTQ""" id: str - machine_id: str - machine_npub: str - settlement_id: Optional[str] - leg_type: str # 'dca' | 'settlement' | 'autoforward' (LP-visible) amount_sats: int - amount_fiat: Optional[float] - exchange_rate: Optional[float] + amount_fiat: float # Amount in GTQ (e.g., 150.75) + exchange_rate: float + transaction_type: str # 'flow', 'fixed', 'manual' status: str created_at: datetime - transaction_time: datetime + transaction_time: Optional[datetime] = None # Original ATM transaction time + lamassu_transaction_id: Optional[str] = None + + +class ClientAnalytics(BaseModel): + """Performance analytics for client dashboard""" + user_id: str + cost_basis_history: List[dict] # Historical cost basis data points + accumulation_timeline: List[dict] # Sats accumulated over time + transaction_frequency: dict # Transaction frequency metrics + performance_vs_market: Optional[dict] = None # Market comparison data + + +class ClientPreferences(BaseModel): + """Client dashboard preferences and settings""" + user_id: str + preferred_currency: str = "GTQ" + dashboard_theme: str = "light" + chart_time_range: str = "30d" # Default chart time range + notification_preferences: dict = {} + + +class UpdateClientSettings(BaseModel): + """Settings that client can modify""" + dca_mode: Optional[str] = None # 'flow' or 'fixed' + fixed_mode_daily_limit: Optional[int] = None + status: Optional[str] = None # 'active' or 'inactive' + + +class ClientRegistrationData(BaseModel): + """Data for client self-registration""" + dca_mode: str = "flow" # Default to flow mode + fixed_mode_daily_limit: Optional[int] = None + username: Optional[str] = None -# `UpdateClientAutoforward` removed in the dca_lp refactor; preferences -# (autoforward, wallet, mode) now flow through `UpdateLpPreferences` -# against `PUT /api/v1/dca-client/preferences`. diff --git a/static/js/index.js b/static/js/index.js index 3e06911..1c850bd 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,28 +1,12 @@ -// Satoshi Machine Client v2 — LP dashboard JS. -// -// Maintenance-mode reminder: the rich LP UI is moving to ~/dev/webapp. -// This file is the lightweight in-LNbits dashboard kept functional for -// dev / E2E testing on the v2-bitspire branch. Endpoints: -// GET /satmachineclient/api/v1/dca-client/preferences (auto-onboards) -// PUT /satmachineclient/api/v1/dca-client/preferences -// GET /satmachineclient/api/v1/dca-client/positions -// GET /satmachineclient/api/v1/dca-client/transactions -// -// The "registration / welcome" wizard in the template is dead — every -// call to GET /preferences auto-creates the LP's dca_lp row on first -// hit, so `isRegistered` is always true after the initial load and the -// wizard never shows. Cleanup of the template is deferred. - window.app = Vue.createApp({ el: '#vue', mixins: [windowMixin], delimiters: ['${', '}'], data: function () { return { - // Registration / onboarding (legacy; always true after initial - // /preferences call because the endpoint auto-creates). - isRegistered: true, - registrationChecked: true, + // Registration state + isRegistered: false, + registrationChecked: false, registrationForm: { selectedWallet: null, dca_mode: 'flow', @@ -30,11 +14,7 @@ window.app = Vue.createApp({ username: '' }, - // LP preferences (loaded from dca_lp on first call). - preferences: null, - - // Admin configuration (legacy: no longer fetched — kept so the - // legacy template doesn't undefined-error on `adminConfig.*`). + // Admin configuration adminConfig: { max_daily_limit_gtq: 2000, currency: 'GTQ' @@ -45,165 +25,260 @@ window.app = Vue.createApp({ transactions: [], loading: true, error: null, - showFiatValues: false, - // Stubs for legacy template bindings (chart + registration form - // are dead branches but still in the .html — keep these defined - // so Vue doesn't emit warnings on initial render). - chartLoading: false, - chartTimeRange: '30d', - analyticsData: null, + showFiatValues: false, // Hide fiat values by default transactionColumns: [ - {name: 'date', label: 'Date', align: 'left', - field: row => row.transaction_time || row.created_at, sortable: false}, - {name: 'amount_sats', label: 'Bitcoin', align: 'right', - field: 'amount_sats', sortable: false}, - {name: 'amount_fiat', label: 'Fiat Amount', align: 'right', - field: 'amount_fiat', sortable: false}, - {name: 'type', label: 'Type', align: 'center', - field: 'leg_type', sortable: false}, - {name: 'status', label: 'Status', align: 'center', - field: 'status', sortable: false} + { + name: 'date', + label: 'Date', + align: 'left', + field: row => row.transaction_time || row.created_at, + sortable: false + }, + { + name: 'amount_sats', + label: 'Bitcoin', + align: 'right', + field: 'amount_sats', + sortable: false + }, + { + name: 'amount_fiat', + label: 'Fiat Amount', + align: 'right', + field: 'amount_fiat', + sortable: false + }, + { + name: 'type', + label: 'Type', + align: 'center', + field: 'transaction_type', + sortable: false + }, + { + name: 'status', + label: 'Status', + align: 'center', + field: 'status', + sortable: false + } ], transactionPagination: { sortBy: 'date', descending: true, page: 1, rowsPerPage: 10 - } + }, + chartTimeRange: '30d', + dcaChart: null, + analyticsData: null, + chartLoading: false } }, methods: { - // ----------------------------------------------------------------- - // Onboarding + preferences - // ----------------------------------------------------------------- - async loadPreferences() { - // GET /preferences auto-creates the LP's dca_lp row with the - // authenticated wallet as the default DCA destination. This is - // the structural enforcement of the "LP must onboard before - // deposits work" gate on the operator side. + // Configuration Methods + async loadClientLimits() { try { - const {data} = await LNbits.api.request( + const { data } = await LNbits.api.request( 'GET', - '/satmachineclient/api/v1/dca-client/preferences', + '/satmachineadmin/api/v1/dca/client-limits' + // No authentication required - public endpoint with safe data only + ) + + this.adminConfig = data + console.log('Client limits loaded:', this.adminConfig) + } catch (error) { + console.error('Error loading client limits:', error) + // Keep default values if client limits fail to load + } + }, + + // Registration Methods + async checkRegistrationStatus() { + try { + const { data } = await LNbits.api.request( + 'GET', + '/satmachineclient/api/v1/registration-status', this.g.user.wallets[0].adminkey ) - this.preferences = data - this.isRegistered = true + + this.isRegistered = data.is_registered this.registrationChecked = true + + if (!this.isRegistered) { + // Fetch current user info to get the username + await this.loadCurrentUser() + this.registrationForm.selectedWallet = this.g.user.wallets[0]?.id || null + } + + return data } catch (error) { - console.error('Error loading preferences:', error) - this.error = 'Failed to load DCA preferences' + console.error('Error checking registration status:', error) + this.error = 'Failed to check registration status' this.registrationChecked = true } }, - // ----------------------------------------------------------------- - // Formatting helpers - // ----------------------------------------------------------------- + async loadCurrentUser() { + try { + const { data } = await LNbits.api.getAuthenticatedUser() + + // Set username from API response with priority: display_name > username > email > fallback + const username = data.extra?.display_name || data.username || data.email + this.registrationForm.username = (username !== null && username !== undefined && username !== '') + ? username + : `user_${this.g.user.id.substring(0, 8)}` + } catch (error) { + console.error('Error loading current user:', error) + // Fallback to generated username + this.registrationForm.username = `user_${this.g.user.id.substring(0, 8)}` + } + }, + + async registerClient() { + try { + // Prepare registration data using the form's username (already loaded from API) + const registrationData = { + dca_mode: this.registrationForm.dca_mode, + fixed_mode_daily_limit: this.registrationForm.fixed_mode_daily_limit, + username: this.registrationForm.username || `user_${this.g.user.id.substring(0, 8)}` + } + + // Find the selected wallet object to get the adminkey + const selectedWallet = this.g.user.wallets.find(w => w.id === this.registrationForm.selectedWallet) + if (!selectedWallet) { + throw new Error('Selected wallet not found') + } + + const { data } = await LNbits.api.request( + 'POST', + '/satmachineclient/api/v1/register', + selectedWallet.adminkey, + registrationData + ) + + this.isRegistered = true + + this.$q.notify({ + type: 'positive', + message: data.message || 'Successfully registered for DCA!', + icon: 'check_circle', + position: 'top' + }) + + // Load dashboard data after successful registration + await this.loadDashboardData() + + } catch (error) { + console.error('Error registering client:', error) + this.$q.notify({ + type: 'negative', + message: error.detail || 'Failed to register for DCA', + position: 'top' + }) + } + }, + + // Dashboard Methods formatCurrency(amount) { - if (!amount) return 'Q 0.00' + if (!amount) return 'Q 0.00'; + // Amount is already in GTQ + const gtqAmount = amount; return new Intl.NumberFormat('es-GT', { - style: 'currency', currency: 'GTQ' - }).format(amount) + style: 'currency', + currency: 'GTQ', + }).format(gtqAmount); }, formatCurrencyWithCode(amount, currencyCode) { - if (!amount) return `${currencyCode} 0.00` + if (!amount) return `${currencyCode} 0.00`; + // Amount is already in GTQ + const currencyAmount = amount; try { return new Intl.NumberFormat('en-US', { - style: 'currency', currency: currencyCode - }).format(amount) + style: 'currency', + currency: currencyCode, + }).format(currencyAmount); } catch (error) { - return `${currencyCode} ${amount.toFixed(2)}` + // Fallback if currency code is not supported + return `${currencyCode} ${currencyAmount.toFixed(2)}`; } }, formatDate(dateString) { if (!dateString) return '' const date = new Date(dateString) - if (isNaN(date.getTime())) return 'Invalid Date' + if (isNaN(date.getTime())) { + console.warn('Invalid date string:', dateString) + return 'Invalid Date' + } return date.toLocaleDateString() }, formatTime(dateString) { if (!dateString) return '' const date = new Date(dateString) - if (isNaN(date.getTime())) return 'Invalid Time' - return date.toLocaleTimeString('en-US', {hour: '2-digit', minute: '2-digit'}) + if (isNaN(date.getTime())) { + console.warn('Invalid time string:', dateString) + return 'Invalid Time' + } + return date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' + }) }, formatSats(amount) { if (!amount) return '0 sats' const formatted = new Intl.NumberFormat('en-US').format(amount) - if (amount >= 100000000) return formatted + ' sats 🏆' - if (amount >= 50000000) return formatted + ' sats 🎆' - if (amount >= 10000000) return formatted + ' sats 👑' - if (amount >= 5000000) return formatted + ' sats 🏆' - if (amount >= 1000000) return formatted + ' sats 🌟' - if (amount >= 500000) return formatted + ' sats 🔥' - if (amount >= 100000) return formatted + ' sats 🚀' - if (amount >= 50000) return formatted + ' sats ⚡' - if (amount >= 10000) return formatted + ' sats 🎯' + // Add some excitement for larger amounts with consistent 5x→2x progression + if (amount >= 100000000) return formatted + ' sats 🏆' // Full coiner (1 BTC) + if (amount >= 50000000) return formatted + ' sats 🎆' // Bitcoin baron + if (amount >= 10000000) return formatted + ' sats 👑' // Bitcoin royalty + if (amount >= 5000000) return formatted + ' sats 🏆' // Verified bag holder + if (amount >= 1000000) return formatted + ' sats 🌟' // Millionaire + if (amount >= 500000) return formatted + ' sats 🔥' // Half million + if (amount >= 100000) return formatted + ' sats 🚀' // Getting serious + if (amount >= 50000) return formatted + ' sats ⚡' // Lightning quick + if (amount >= 10000) return formatted + ' sats 🎯' // First milestone return formatted + ' sats' }, - // ----------------------------------------------------------------- - // Dashboard data - // ----------------------------------------------------------------- async loadDashboardData() { try { - const {data} = await LNbits.api.request( + const { data } = await LNbits.api.request( 'GET', - '/satmachineclient/api/v1/dca-client/positions', + '/satmachineclient/api/v1/dashboard/summary', this.g.user.wallets[0].adminkey ) - // Backend returns ClientDashboardSummary with no dca_mode field - // at the top level any more (it's LP-wide and lives on - // preferences); echo `default_dca_mode` from prefs so the - // legacy template renderers (`dashboardData.dca_mode`) keep - // working until the template is rewritten. - data.dca_mode = this.preferences?.default_dca_mode || 'flow' - data.dca_status = 'active' this.dashboardData = data } catch (error) { - // 404 from /positions = "LP isn't enrolled at any machine yet", - // which is a valid state, not an error. Show an empty dashboard. - if (error?.response?.status === 404) { - this.dashboardData = { - user_id: this.g.user.id, - total_sats_accumulated: 0, - total_fiat_invested: 0, - current_fiat_balance: 0, - pending_fiat_deposits: 0, - average_cost_basis: 0, - current_sats_fiat_value: 0, - total_transactions: 0, - total_machines: 0, - last_transaction_date: null, - currency: this.adminConfig.currency, - positions: [], - dca_mode: this.preferences?.default_dca_mode || 'flow', - dca_status: 'awaiting_enrolment' - } - } else { - console.error('Error loading dashboard data:', error) - this.error = 'Failed to load dashboard data' - } + console.error('Error loading dashboard data:', error) + this.error = 'Failed to load dashboard data' } }, async loadTransactions() { try { - const {data} = await LNbits.api.request( + const { data } = await LNbits.api.request( 'GET', - '/satmachineclient/api/v1/dca-client/transactions?limit=50', + '/satmachineclient/api/v1/dashboard/transactions?limit=50', this.g.user.wallets[0].adminkey ) + + // Debug: Log the first transaction to see date format + if (data.length > 0) { + console.log('Sample transaction data:', data[0]) + console.log('transaction_time:', data[0].transaction_time) + console.log('created_at:', data[0].created_at) + } + + // Sort by most recent first and store this.transactions = data.sort((a, b) => { const dateA = new Date(a.transaction_time || a.created_at) const dateB = new Date(b.transaction_time || b.created_at) - return dateB - dateA + return dateB - dateA // Most recent first }) } catch (error) { console.error('Error loading transactions:', error) @@ -219,7 +294,6 @@ window.app = Vue.createApp({ try { this.loading = true await Promise.all([ - this.loadPreferences(), this.loadDashboardData(), this.loadTransactions() ]) @@ -241,67 +315,502 @@ window.app = Vue.createApp({ } }, - // ----------------------------------------------------------------- - // Milestone widget (purely cosmetic) - // ----------------------------------------------------------------- getNextMilestone() { - if (!this.dashboardData) return {target: 10000, name: '10k sats'} + if (!this.dashboardData) return { target: 10000, name: '10k sats' } const sats = this.dashboardData.total_sats_accumulated - if (sats < 10000) return {target: 10000, name: '10k sats'} - if (sats < 50000) return {target: 50000, name: '50k sats'} - if (sats < 100000) return {target: 100000, name: '100k sats'} - if (sats < 500000) return {target: 500000, name: '500k sats'} - if (sats < 1000000) return {target: 1000000, name: '1M sats'} - if (sats < 5000000) return {target: 5000000, name: '5M sats'} - if (sats < 10000000) return {target: 10000000, name: '10M sats'} - if (sats < 50000000) return {target: 50000000, name: '50M sats'} - if (sats < 100000000) return {target: 100000000, name: '100M sats (1 BTC!)'} - return {target: 500000000, name: '500M sats (5 BTC)'} + + // Consistent 5x→2x progression pattern + if (sats < 10000) return { target: 10000, name: '10k sats' } + if (sats < 50000) return { target: 50000, name: '50k sats' } + if (sats < 100000) return { target: 100000, name: '100k sats' } + if (sats < 500000) return { target: 500000, name: '500k sats' } + if (sats < 1000000) return { target: 1000000, name: '1M sats' } + if (sats < 5000000) return { target: 5000000, name: '5M sats' } + if (sats < 10000000) return { target: 10000000, name: '10M sats' } + if (sats < 50000000) return { target: 50000000, name: '50M sats' } + if (sats < 100000000) return { target: 100000000, name: '100M sats (1 BTC!)' } + return { target: 500000000, name: '500M sats (5 BTC)' } }, getMilestoneProgress() { - if (!this.dashboardData) return 0 + if (!this.dashboardData) { + console.log('getMilestoneProgress: no dashboard data') + return 0 + } const sats = this.dashboardData.total_sats_accumulated const milestone = this.getNextMilestone() + + // Show total progress toward the next milestone (from 0) const progress = (sats / milestone.target) * 100 - return Math.min(Math.max(progress, 0), 100) + const result = Math.min(Math.max(progress, 0), 100) + console.log('getMilestoneProgress:', { sats, milestone, progress, result }) + return result + }, + async loadChartData() { + // Prevent multiple simultaneous requests + if (this.chartLoading) { + console.log('Chart already loading, ignoring request') + return + } + + try { + this.chartLoading = true + + // Destroy existing chart immediately to prevent conflicts + if (this.dcaChart) { + console.log('Destroying existing chart before loading new data') + this.dcaChart.destroy() + this.dcaChart = null + } + + const { data } = await LNbits.api.request( + 'GET', + `/satmachineclient/api/v1/dashboard/analytics?time_range=${this.chartTimeRange}`, + this.g.user.wallets[0].adminkey + ) + + // Debug: Log analytics data + console.log('Analytics data received:', data) + if (data && data.cost_basis_history && data.cost_basis_history.length > 0) { + console.log('Sample cost basis point:', data.cost_basis_history[0]) + } + + this.analyticsData = data + + // Wait for DOM update and ensure we're still in loading state + await this.$nextTick() + + // Double-check we're still the active loading request + if (this.chartLoading) { + this.initDCAChart() + } else { + console.log('Chart loading was cancelled, skipping initialization') + this.chartLoading = false + } + } catch (error) { + console.error('Error loading chart data:', error) + this.chartLoading = false + } }, - // ----------------------------------------------------------------- - // Stubs for the legacy registration wizard + chart panel. - // Those template branches are dead (the wizard never shows because - // isRegistered is true after auto-onboard; the chart panel has no - // backend analytics endpoint to feed it) but the .html still - // references these handlers. Keep stubs so a stray click doesn't - // throw an uncaught error. - // ----------------------------------------------------------------- - async registerClient() { - // Old "register / pick wallet & mode" form is gone. Preferences - // are auto-created and editable via a separate path (TODO: add - // an editor card to this dashboard once the template gets a - // proper rewrite). - await this.loadPreferences() - this.$q.notify({ - type: 'info', - message: 'Your DCA account is already set up — refreshed.', - position: 'top' + initDCAChart() { + console.log('initDCAChart called') + console.log('analyticsData:', this.analyticsData) + console.log('dcaChart ref:', this.$refs.dcaChart) + console.log('chartLoading state:', this.chartLoading) + + // Skip if we're not in a loading state (indicates this is a stale call) + if (!this.chartLoading && this.dcaChart) { + console.log('Chart already exists and not loading, skipping initialization') + return + } + + if (!this.analyticsData) { + console.log('No analytics data available') + return + } + + if (!this.$refs.dcaChart) { + console.log('No chart ref available, waiting for DOM...') + // Try again after DOM update, but only if still loading + this.$nextTick(() => { + if (this.$refs.dcaChart && this.chartLoading) { + this.initDCAChart() + } + }) + return + } + + // Check if Chart.js is loaded + if (typeof Chart === 'undefined') { + console.error('Chart.js is not loaded') + return + } + + console.log('Chart.js version:', Chart.version || 'unknown') + console.log('Chart.js available:', typeof Chart) + + // Destroy existing chart (redundant safety check) + if (this.dcaChart) { + console.log('Destroying existing chart in initDCAChart') + this.dcaChart.destroy() + this.dcaChart = null + } + + const ctx = this.$refs.dcaChart.getContext('2d') + + // Use accumulation_timeline data which is already grouped by day + const timelineData = this.analyticsData.accumulation_timeline || [] + console.log('Timeline data sample:', timelineData.slice(0, 2)) // Debug first 2 records + + // If we have timeline data, use it (already grouped by day) + if (timelineData.length > 0) { + // Calculate running totals from daily data + let runningSats = 0 + const labels = [] + const cumulativeSats = [] + + timelineData.forEach(point => { + // Ensure sats is a valid number + const sats = point.sats || 0 + const validSats = typeof sats === 'number' ? sats : parseFloat(sats) || 0 + runningSats += validSats + + const date = new Date(point.date) + if (!isNaN(date.getTime())) { + labels.push(date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric' + })) + cumulativeSats.push(runningSats) + } + }) + + console.log('Timeline chart data:', { labels, cumulativeSats }) + + this.createChart(labels, cumulativeSats) + return + } + + // Fallback to cost_basis_history but group by date to avoid duplicates + console.log('No timeline data, using cost_basis_history as fallback') + const chartData = this.analyticsData.cost_basis_history || [] + console.log('Chart data sample:', chartData.slice(0, 2)) // Debug first 2 records + + // Handle empty data case + if (chartData.length === 0) { + console.log('No chart data available') + // Create gradient for placeholder chart + const placeholderGradient = ctx.createLinearGradient(0, 0, 0, 300) + placeholderGradient.addColorStop(0, 'rgba(255, 149, 0, 0.3)') + placeholderGradient.addColorStop(1, 'rgba(255, 149, 0, 0.05)') + + // Show placeholder chart with enhanced styling + this.dcaChart = new Chart(ctx, { + type: 'line', + data: { + labels: ['Start Your DCA Journey'], + datasets: [{ + label: 'Total Sats Accumulated', + data: [0], + borderColor: '#FF9500', + backgroundColor: placeholderGradient, + borderWidth: 3, + fill: true, + tension: 0.4, + pointRadius: 8, + pointBackgroundColor: '#FFFFFF', + pointBorderColor: '#FF9500', + pointBorderWidth: 3, + pointHoverRadius: 10 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: 'rgba(0, 0, 0, 0.8)', + titleColor: '#FFFFFF', + bodyColor: '#FFFFFF', + borderColor: '#FF9500', + borderWidth: 2, + cornerRadius: 8, + callbacks: { + label: function (context) { + return `${context.parsed.y.toLocaleString()} sats` + } + } + } + }, + scales: { + x: { + grid: { display: false }, + ticks: { + color: '#666666', + font: { size: 12, weight: '500' } + } + }, + y: { + beginAtZero: true, + grid: { + color: 'rgba(255, 149, 0, 0.1)', + drawBorder: false + }, + ticks: { + color: '#666666', + font: { size: 12, weight: '500' }, + callback: function (value) { + return value.toLocaleString() + ' sats' + } + } + } + } + } + }) + // Clear loading state after creating placeholder chart + this.chartLoading = false + return + } + + // Group cost_basis_history by date to eliminate duplicates + const groupedData = new Map() + chartData.forEach(point => { + const dateStr = new Date(point.date).toDateString() + if (!groupedData.has(dateStr)) { + groupedData.set(dateStr, point) + } else { + // Use the latest cumulative values for the same date + const existing = groupedData.get(dateStr) + if (point.cumulative_sats > existing.cumulative_sats) { + groupedData.set(dateStr, point) + } + } }) + + const uniqueChartData = Array.from(groupedData.values()).sort((a, b) => + new Date(a.date).getTime() - new Date(b.date).getTime() + ) + + const labels = uniqueChartData.map(point => { + // Handle different date formats with enhanced timezone handling + let date; + if (point.date) { + console.log('Raw date from API:', point.date); // Debug the actual date string + + // If it's an ISO string with timezone info, parse it correctly + if (typeof point.date === 'string' && point.date.includes('T')) { + // ISO string - parse and convert to local date + date = new Date(point.date); + // For display purposes, use the date part only to avoid timezone shifts + const localDateStr = date.getFullYear() + '-' + + String(date.getMonth() + 1).padStart(2, '0') + '-' + + String(date.getDate()).padStart(2, '0'); + date = new Date(localDateStr + 'T00:00:00'); // Force local midnight + } else { + date = new Date(point.date); + } + + // Check if date is valid + if (isNaN(date.getTime())) { + date = new Date(); + } + } else { + date = new Date(); + } + + console.log('Formatted date:', date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })); + + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric' + }) + }) + const cumulativeSats = uniqueChartData.map(point => { + // Ensure cumulative_sats is a valid number + const sats = point.cumulative_sats || 0 + return typeof sats === 'number' ? sats : parseFloat(sats) || 0 + }) + + console.log('Final chart data:', { labels, cumulativeSats }) + console.log('Labels array:', labels) + console.log('CumulativeSats array:', cumulativeSats) + + // Validate data before creating chart + if (labels.length === 0 || cumulativeSats.length === 0) { + console.warn('No valid data for chart, skipping creation') + return + } + + if (labels.length !== cumulativeSats.length) { + console.warn('Mismatched data arrays:', { labelsLength: labels.length, dataLength: cumulativeSats.length }) + return + } + + // Check for any invalid values in cumulativeSats + const hasInvalidValues = cumulativeSats.some(val => val === null || val === undefined || isNaN(val)) + if (hasInvalidValues) { + console.warn('Invalid values found in cumulative sats:', cumulativeSats) + return + } + + this.createChart(labels, cumulativeSats) }, - loadChartData() { - // No backend analytics endpoint; chart panel is dead. + + createChart(labels, cumulativeSats) { + console.log('createChart called with loading state:', this.chartLoading) + + if (!this.$refs.dcaChart) { + console.log('Chart ref not available for createChart') + return + } + + // Skip if we're not in a loading state (indicates this is a stale call) + if (!this.chartLoading) { + console.log('Not in loading state, skipping createChart') + return + } + + // Destroy existing chart + if (this.dcaChart) { + console.log('Destroying existing chart in createChart') + this.dcaChart.destroy() + this.dcaChart = null + } + + const ctx = this.$refs.dcaChart.getContext('2d') + + try { + // Create gradient for the area fill + const gradient = ctx.createLinearGradient(0, 0, 0, 300) + gradient.addColorStop(0, 'rgba(255, 149, 0, 0.4)') + gradient.addColorStop(0.5, 'rgba(255, 149, 0, 0.2)') + gradient.addColorStop(1, 'rgba(255, 149, 0, 0.05)') + + // Small delay to ensure Chart.js is fully initialized + setTimeout(() => { + try { + // Final check to ensure we're still in the correct loading state + if (!this.chartLoading) { + console.log('Loading state changed during timeout, aborting chart creation') + return + } + + this.dcaChart = new Chart(ctx, { + type: 'line', + data: { + labels: labels, + datasets: [{ + label: 'Total Sats Accumulated', + data: cumulativeSats, + borderColor: '#FF9500', + backgroundColor: gradient, + borderWidth: 3, + fill: true, + tension: 0.4, + pointBackgroundColor: '#FFFFFF', + pointBorderColor: '#FF9500', + pointBorderWidth: 3, + pointRadius: 6, + pointHoverRadius: 8, + pointHoverBackgroundColor: '#FFFFFF', + pointHoverBorderColor: '#FF7700', + pointHoverBorderWidth: 4 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: false + }, + tooltip: { + mode: 'index', + intersect: false, + backgroundColor: 'rgba(0, 0, 0, 0.8)', + titleColor: '#FFFFFF', + bodyColor: '#FFFFFF', + borderColor: '#FF9500', + borderWidth: 2, + cornerRadius: 8, + displayColors: false, + callbacks: { + title: function (context) { + return `📅 ${context[0].label}` + }, + label: function (context) { + return `⚡ ${context.parsed.y.toLocaleString()} sats accumulated` + } + } + } + }, + scales: { + x: { + display: true, + grid: { + display: false + }, + ticks: { + color: '#666666', + font: { + size: 12, + weight: '500' + } + } + }, + y: { + display: true, + beginAtZero: true, + grid: { + color: 'rgba(255, 149, 0, 0.1)', + drawBorder: false + }, + ticks: { + color: '#666666', + font: { + size: 12, + weight: '500' + }, + callback: function (value) { + if (value >= 1000000) { + return (value / 1000000).toFixed(1) + 'M sats' + } else if (value >= 1000) { + return (value / 1000).toFixed(0) + 'k sats' + } + return value.toLocaleString() + ' sats' + } + } + } + }, + interaction: { + mode: 'nearest', + axis: 'x', + intersect: false + }, + elements: { + point: { + hoverRadius: 8 + } + } + } + }) + console.log('Chart created successfully in createChart!') + // Chart is now created, clear loading state + this.chartLoading = false + } catch (error) { + console.error('Error in createChart setTimeout:', error) + this.chartLoading = false + } + }, 50) + } catch (error) { + console.error('Error creating Chart.js chart in createChart:', error) + console.log('Chart data that failed:', { labels, cumulativeSats }) + // Clear loading state on error + this.chartLoading = false + } } }, async created() { try { this.loading = true - // Auto-onboard on load (creates dca_lp row if missing). - await this.loadPreferences() - // Then load dashboard + transactions. - await Promise.all([ - this.loadDashboardData(), - this.loadTransactions() - ]) + + // Load client limits first + await this.loadClientLimits() + + // Check registration status + await this.checkRegistrationStatus() + + // Only load dashboard data if registered + if (this.isRegistered) { + await Promise.all([ + this.loadDashboardData(), + this.loadTransactions(), + this.loadChartData() + ]) + } } catch (error) { console.error('Error initializing dashboard:', error) this.error = 'Failed to initialize dashboard' @@ -310,6 +819,23 @@ window.app = Vue.createApp({ } }, + mounted() { + // Initialize chart after DOM is ready and data is loaded + this.$nextTick(() => { + console.log('Component mounted, checking for chart initialization') + console.log('Loading state:', this.loading) + console.log('Chart ref available:', !!this.$refs.dcaChart) + console.log('Analytics data available:', !!this.analyticsData) + + if (this.analyticsData && this.$refs.dcaChart) { + console.log('Initializing chart from mounted hook') + this.initDCAChart() + } else { + console.log('Chart will initialize after data loads') + } + }) + }, + computed: { hasData() { return this.dashboardData && !this.loading && this.isRegistered @@ -322,5 +848,23 @@ window.app = Vue.createApp({ value: wallet.id })) } + }, + + watch: { + analyticsData: { + handler(newData) { + if (newData && !this.chartLoading && !this.dcaChart) { + console.log('Analytics data changed and no chart exists, initializing chart...') + this.$nextTick(() => { + // Only initialize if we don't have a chart and aren't currently loading + if (!this.dcaChart && !this.chartLoading) { + this.chartLoading = true + this.initDCAChart() + } + }) + } + }, + immediate: false + } } }) diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..ac8fd55 --- /dev/null +++ b/tasks.py @@ -0,0 +1,2 @@ +# No background tasks needed in client extension +# Client extension is a read-only dashboard \ No newline at end of file diff --git a/transaction_processor.py b/transaction_processor.py new file mode 100644 index 0000000..a847d96 --- /dev/null +++ b/transaction_processor.py @@ -0,0 +1,2 @@ +# No transaction processing needed in client extension +# All transaction processing is handled by the admin extension \ No newline at end of file diff --git a/views_api.py b/views_api.py index 56af15b..f23e32d 100644 --- a/views_api.py +++ b/views_api.py @@ -1,109 +1,220 @@ -# Satoshi Machine Client v2 — API. -# -# Read-mostly LP surface over the admin extension's v2 schema. Auth via -# wallet admin key (LP must be an LNbits user; the admin key identifies -# them as the wallet's owner). -# -# This extension owns writes to `satoshimachine.dca_lp` — the LP's -# per-user preferences row (DCA wallet, mode, autoforward). Reads on -# any endpoint auto-init the row using the authenticated wallet as the -# default DCA destination, which is the act that satisfies the -# operator-side "must onboard before deposits accepted" gate. -# -# Maintenance-mode note: richer LP UI is moving to ~/dev/webapp. Keep -# this surface stable + minimal. +# Description: Client-focused API endpoints for DCA dashboard from http import HTTPStatus from typing import List, Optional +from datetime import datetime, timedelta -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, Query from lnbits.core.models import WalletTypeInfo from lnbits.decorators import require_admin_key +from starlette.exceptions import HTTPException from .crud import ( - ensure_lp_preferences, get_client_dashboard_summary, get_client_transactions, - update_lp_preferences, + get_client_analytics, + update_client_dca_settings, + get_client_by_user_id, + register_dca_client, ) from .models import ( ClientDashboardSummary, ClientTransaction, - LpPreferences, - UpdateLpPreferences, + ClientAnalytics, + UpdateClientSettings, + ClientRegistrationData, ) satmachineclient_api_router = APIRouter() -@satmachineclient_api_router.get( - "/api/v1/dca-client/preferences", response_model=LpPreferences -) -async def api_get_preferences( +################################################### +############## CLIENT REGISTRATION ############### +################################################### + +@satmachineclient_api_router.post("/api/v1/register", status_code=HTTPStatus.CREATED) +async def api_register_client( + registration_data: ClientRegistrationData, wallet: WalletTypeInfo = Depends(require_admin_key), -) -> LpPreferences: - """Return the LP's DCA preferences. Auto-creates the `dca_lp` row on - first call, seeded with the authenticated wallet as the default DCA - destination. The act of hitting this endpoint is what marks the LP - as "onboarded" on the operator side.""" - return await ensure_lp_preferences( - wallet.wallet.user, default_wallet_id=wallet.wallet.id +) -> dict: + """Register a new DCA client + + Clients can self-register using their wallet admin key. + Creates a new client entry in the satoshimachine database. + """ + result = await register_dca_client( + wallet.wallet.user, + wallet.wallet.id, + registration_data ) + + if "error" in result: + if "already registered" in result["error"]: + raise HTTPException( + status_code=HTTPStatus.CONFLICT, + detail=result["error"] + ) + else: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=result["error"] + ) + + return result -@satmachineclient_api_router.put( - "/api/v1/dca-client/preferences", response_model=LpPreferences -) -async def api_update_preferences( - data: UpdateLpPreferences, +@satmachineclient_api_router.get("/api/v1/registration-status") +async def api_check_registration_status( wallet: WalletTypeInfo = Depends(require_admin_key), -) -> LpPreferences: - """LP-side update of DCA wallet / mode / autoforward. Operator can't - reach this path — it requires the LP's wallet admin key.""" - # Ensure the row exists before update so this endpoint is safe to - # call even if the LP somehow hits PUT before GET (they shouldn't, - # but the dashboard and any caller order shouldn't matter). - await ensure_lp_preferences(wallet.wallet.user, default_wallet_id=wallet.wallet.id) - updated = await update_lp_preferences(wallet.wallet.user, data) - assert updated is not None - return updated +) -> dict: + """Check if user is already registered as a DCA client""" + client = await get_client_by_user_id(wallet.wallet.user) + + return { + "is_registered": client is not None, + "client_id": client["id"] if client else None, + "dca_mode": client["dca_mode"] if client else None, + "status": client["status"] if client else None, + } -@satmachineclient_api_router.get( - "/api/v1/dca-client/positions", response_model=ClientDashboardSummary -) -async def api_get_positions( +################################################### +############## CLIENT DASHBOARD API ############### +################################################### + +@satmachineclient_api_router.get("/api/v1/dashboard/summary") +async def api_get_dashboard_summary( wallet: WalletTypeInfo = Depends(require_admin_key), ) -> ClientDashboardSummary: - """LP's aggregated dashboard across every machine they're registered at, - plus a per-machine breakdown in the `positions` field. - - Also auto-creates the LP's `dca_lp` row on first access (so opening - the dashboard is itself the onboarding gesture).""" - user_id = wallet.wallet.user - await ensure_lp_preferences(user_id, default_wallet_id=wallet.wallet.id) - summary = await get_client_dashboard_summary(user_id) - if summary is None: + """Get client dashboard summary metrics""" + summary = await get_client_dashboard_summary(wallet.wallet.user) + if not summary: raise HTTPException( - HTTPStatus.NOT_FOUND, - "No DCA positions found for this wallet's owner. " - "An operator must register you at a machine first.", + status_code=HTTPStatus.NOT_FOUND, + detail="Client data not found" ) return summary -@satmachineclient_api_router.get( - "/api/v1/dca-client/transactions", response_model=List[ClientTransaction] -) -async def api_get_transactions( - limit: int = 50, - offset: int = 0, - machine_id: Optional[str] = None, +@satmachineclient_api_router.get("/api/v1/dashboard/transactions") +async def api_get_client_transactions( wallet: WalletTypeInfo = Depends(require_admin_key), + limit: int = Query(50, ge=1, le=1000), + offset: int = Query(0, ge=0), + transaction_type: Optional[str] = Query(None), + start_date: Optional[datetime] = Query(None), + end_date: Optional[datetime] = Query(None), ) -> List[ClientTransaction]: - """LP's distribution history. Returns only legs the LP can see (DCA, - balance-settle, auto-forward). Optionally filter by ?machine_id=X.""" - user_id = wallet.wallet.user + """Get client's DCA transaction history with filtering""" return await get_client_transactions( - user_id, limit=limit, offset=offset, machine_id=machine_id + wallet.wallet.user, + limit=limit, + offset=offset, + transaction_type=transaction_type, + start_date=start_date, + end_date=end_date ) + + +@satmachineclient_api_router.get("/api/v1/dashboard/analytics") +async def api_get_client_analytics( + wallet: WalletTypeInfo = Depends(require_admin_key), + time_range: str = Query("30d", regex="^(7d|30d|90d|1y|all)$"), +) -> ClientAnalytics: + """Get client performance analytics and cost basis data""" + try: + analytics = await get_client_analytics(wallet.wallet.user, time_range) + if not analytics: + # Return empty analytics data instead of error + return ClientAnalytics( + user_id=wallet.wallet.user, + cost_basis_history=[], + accumulation_timeline=[], + transaction_frequency={} + ) + return analytics + except Exception as e: + print(f"Analytics error: {e}") + # Return empty analytics data as fallback + return ClientAnalytics( + user_id=wallet.wallet.user, + cost_basis_history=[], + accumulation_timeline=[], + transaction_frequency={} + ) + + +@satmachineclient_api_router.put("/api/v1/dashboard/settings") +async def api_update_client_settings( + settings: UpdateClientSettings, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> dict: + """Update client DCA settings (mode, limits, status) + + Security: Users can only modify their own DCA settings. + Validated by user_id lookup from wallet.wallet.user. + """ + client = await get_client_by_user_id(wallet.wallet.user) + if not client: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Client profile not found" + ) + + success = await update_client_dca_settings(client.id, settings) + if not success: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Failed to update settings" + ) + + return {"message": "Settings updated successfully"} + + +@satmachineclient_api_router.get("/api/v1/dashboard/export/transactions") +async def api_export_transactions( + wallet: WalletTypeInfo = Depends(require_admin_key), + format: str = Query("csv", regex="^(csv|json)$"), + start_date: Optional[datetime] = Query(None), + end_date: Optional[datetime] = Query(None), +): + """Export client transaction history""" + transactions = await get_client_transactions( + wallet.wallet.user, + limit=10000, # Large limit for export + start_date=start_date, + end_date=end_date + ) + + if format == "csv": + # Return CSV response + from io import StringIO + import csv + + output = StringIO() + writer = csv.writer(output) + writer.writerow(['Date', 'Amount (Sats)', 'Amount (Fiat)', 'Exchange Rate', 'Type', 'Status']) + + for tx in transactions: + writer.writerow([ + tx.created_at.isoformat(), + tx.amount_sats, + tx.amount_fiat, # Amount already in GTQ + tx.exchange_rate, + tx.transaction_type, + tx.status + ]) + + from fastapi.responses import StreamingResponse + output.seek(0) + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=dca_transactions.csv"} + ) + else: + return {"transactions": transactions} + + +# Removed local client-limits endpoint +# Client should call admin extension's public endpoint directly