From 32c4e5d05abaca04362881462bfbe70ca0a9c5c7 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 14 May 2026 17:50:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(v2):=20satmachineclient=20maintenance=20pa?= =?UTF-8?q?ss=20=E2=80=94=20v2=20admin=20schema=20(P7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates the LP-facing client extension to work against the v2 admin schema (per-machine dca_clients, leg_type-discriminated dca_payments, settlement audit trail) and strips the dead v1 code paths. Maintenance-mode framing: the richer LP UI is migrating to ~/dev/webapp. This extension now provides a minimal stable read-mostly surface so existing LP installs don't break against the v2 admin schema. models.py — rewrite: + PerMachinePosition: LP's position at a single machine ~ ClientDashboardSummary: now aggregates across all the LP's machines and exposes per-machine breakdown in `positions` field ~ ClientTransaction: gains machine_id + machine_npub + settlement_id; drops transaction_type / lamassu_transaction_id (v1 fields) ~ leg_type discriminator on ClientTransaction so the LP can tell DCA, operator-initiated balance-settle, and auto-forward legs apart + UpdateClientAutoforward: LPs control their own autoforward setting - ClientDashboardSummaryAPI, ClientTransactionAPI: deleted (GTQ-only parallel "API" models that duplicated the internal ones) - ClientPreferences, ClientRegistrationData, UpdateClientSettings: deleted (registration + settings are operator-controlled in v2) crud.py — rewrite: + _fetch_user_clients: all dca_clients rows for the LP, joined with dca_machines for display metadata + _position_for_client: per-machine aggregation helper + get_client_dashboard_summary: aggregates positions + sums sats/fiat + computes cost basis on fiat-spent (not gross deposits) + get_client_transactions: filters to LP-visible leg_types (dca / settlement / autoforward); optional machine_id filter + update_lp_autoforward: LP self-manages autoforward across all their machine positions - get_client_analytics (300+ lines of date-parsing pretzels): deleted; webapp will own analytics - register_dca_client: deleted (admin owns registration in v2) - update_client_dca_settings: deleted (admin owns DCA mode/limits) - Lamassu-era status='confirmed' (now 'completed'), transaction_type column refs, lamassu_transaction_id column refs: all gone views_api.py — rewrite to 3 endpoints: GET /api/v1/dca-client/positions (aggregated dashboard) GET /api/v1/dca-client/transactions (filterable history) PUT /api/v1/dca-client/autoforward (LP self-manages forwarding) All require_admin_key on the LP's wallet. Old registration / settings / analytics endpoints removed. Files deleted: tasks.py — empty stub ("No background tasks needed") transaction_processor.py — empty stub ("No transaction processing") README.md gains a status banner pointing LP audience at webapp. 4 routes registered. Zero ruff errors across the extension. Refs: aiolabs/satmachineadmin#9 — completes P7 Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 6 +- crud.py | 669 +++++++++++---------------------------- data/.lnbits_auth_key | 1 + migrations.py | 2 +- models.py | 119 +++---- tasks.py | 2 - transaction_processor.py | 2 - views_api.py | 231 +++----------- 8 files changed, 291 insertions(+), 741 deletions(-) create mode 100644 data/.lnbits_auth_key delete mode 100644 tasks.py delete mode 100644 transaction_processor.py diff --git a/README.md b/README.md index 7513bf9..7c9d294 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ # DCA Client Extension for LNBits -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. +> **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. ## Overview diff --git a/crud.py b/crud.py index 26cc755..4798599 100644 --- a/crud.py +++ b/crud.py @@ -1,521 +1,236 @@ -# Description: Client extension CRUD operations - reads from admin extension database +# 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; this client surface is +# strictly read + LP-self autoforward toggle. +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 lnbits.core.crud.wallets import get_wallet +from loguru import logger from .models import ( ClientDashboardSummary, ClientTransaction, - ClientAnalytics, - UpdateClientSettings, - ClientRegistrationData, + PerMachinePosition, + UpdateClientAutoforward, ) -# Connect to admin extension's database +# Same DB schema as the admin extension — we share satoshimachine.* tables. db = Database("ext_satoshimachine") -################################################### -############## CLIENT DASHBOARD CRUD ############## -################################################### +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.""" + return await db.fetchall( + """ + SELECT c.id AS client_id, + c.machine_id, + c.wallet_id, + c.dca_mode, + c.status, + m.machine_npub, + m.name AS machine_name, + m.location AS machine_location, + m.fiat_code AS machine_fiat_code + FROM satoshimachine.dca_clients c + JOIN satoshimachine.dca_machines m ON m.id = c.machine_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}, ) - - if not client: + # 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: return None - - # 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(amount_sats), 0) as total_sats - FROM satoshimachine.dca_payments - WHERE client_id = :client_id AND status = 'confirmed' - """, - {"client_id": client["id"]} + 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, ) - - # Get total confirmed deposits (this is the "total invested") - deposits_result = await db.fetchone( + # Pending deposits (across all clients of this LP). + pending = await db.fetchone( """ - SELECT COALESCE(SUM(amount), 0) as confirmed_deposits - FROM satoshimachine.dca_deposits - WHERE client_id = :client_id AND status = 'confirmed' + 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' """, - {"client_id": client["id"]} + {"uid": user_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: + 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": try: - 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 - + 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}" + ) return ClientDashboardSummary( user_id=user_id, total_sats_accumulated=total_sats, - 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 + 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, ) async def get_client_transactions( - user_id: str, + user_id: str, limit: int = 50, offset: int = 0, - transaction_type: Optional[str] = None, - start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None + machine_id: Optional[str] = None, ) -> List[ClientTransaction]: - """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} + """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')" ) - - 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( + if machine_id is not None: + where += " AND p.machine_id = :mid" + params["mid"] = machine_id + rows = await db.fetchall( f""" - 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 + 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 """, - params + params, ) - return [ ClientTransaction( - 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"] + 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"], ) - for tx in transactions + for row in rows ] -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_clients WHERE user_id = :user_id", - {"user_id": user_id} +async def update_lp_autoforward( + user_id: str, data: UpdateClientAutoforward +) -> int: + """LPs control their own auto-forward. Update applies to ALL of this + LP's dca_clients rows (every machine they're on) — operator can't + override LP-controlled settings.""" + update_data = {k: v for k, v in data.dict().items() if v is not None} + if not update_data: + return 0 + 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",) ) + result = await db.execute( + f"UPDATE satoshimachine.dca_clients SET {set_clause} WHERE user_id = :uid", + update_data, + ) + # db.execute return varies by backend; just return count of rows that + # exist (best-effort indicator of rows touched). + rows = await db.fetchone( + "SELECT COUNT(*) AS n FROM satoshimachine.dca_clients WHERE user_id = :uid", + {"uid": user_id}, + ) + _ = result + return int(rows["n"]) if rows else 0 - -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 - - -################################################### -############## 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/data/.lnbits_auth_key b/data/.lnbits_auth_key new file mode 100644 index 0000000..7254c05 --- /dev/null +++ b/data/.lnbits_auth_key @@ -0,0 +1 @@ +998ce48821b746c78ec676409ee9efc6 \ No newline at end of file diff --git a/migrations.py b/migrations.py index b531f42..562bb28 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) \ No newline at end of file +# Client extension reads from admin extension's database (ext_satoshimachine schema) diff --git a/models.py b/models.py index 57592ac..d0906c9 100644 --- a/models.py +++ b/models.py @@ -1,4 +1,12 @@ -# Description: Pydantic data models for client extension API responses +# 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. from datetime import datetime from typing import List, Optional @@ -6,95 +14,60 @@ from typing import List, Optional from pydantic import BaseModel -# 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" +class PerMachinePosition(BaseModel): + """LP's position at a single machine.""" - -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' + machine_id: str + machine_npub: str + machine_name: Optional[str] + machine_location: Optional[str] + currency: str + dca_mode: str status: str - created_at: datetime - transaction_time: Optional[datetime] = None # Original ATM transaction time - lamassu_transaction_id: Optional[str] = None + total_sats_accumulated: int + total_fiat_invested: float + current_fiat_balance: float + total_transactions: int + last_transaction_date: Optional[datetime] -# Internal Models for Client Dashboard (Database storage in GTQ) class ClientDashboardSummary(BaseModel): - """Internal model - client dashboard summary stored in GTQ""" + """LP's aggregated dashboard across all machines they're registered at.""" + 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_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 - dca_mode: str # 'flow' or 'fixed' - dca_status: str # 'active' or 'inactive' + total_machines: int # how many machines this LP is on last_transaction_date: Optional[datetime] - currency: str = "GTQ" + currency: str # display currency; if multi-currency, "MIX" + positions: List[PerMachinePosition] = [] class ClientTransaction(BaseModel): - """Internal model - client transaction stored in GTQ""" + """A single distribution leg landing in the LP's wallet.""" + 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: float # Amount in GTQ (e.g., 150.75) - exchange_rate: float - transaction_type: str # 'flow', 'fixed', 'manual' + amount_fiat: Optional[float] + exchange_rate: Optional[float] status: str created_at: datetime - transaction_time: Optional[datetime] = None # Original ATM transaction time - lamassu_transaction_id: Optional[str] = None + transaction_time: datetime -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 +class UpdateClientAutoforward(BaseModel): + """LPs can manage their own auto-forward setting per #8. Applies to all + of the LP's dca_clients rows (across every machine they're on).""" + autoforward_enabled: Optional[bool] = None + autoforward_ln_address: Optional[str] = None diff --git a/tasks.py b/tasks.py deleted file mode 100644 index ac8fd55..0000000 --- a/tasks.py +++ /dev/null @@ -1,2 +0,0 @@ -# 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 deleted file mode 100644 index a847d96..0000000 --- a/transaction_processor.py +++ /dev/null @@ -1,2 +0,0 @@ -# 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 f23e32d..494c83a 100644 --- a/views_api.py +++ b/views_api.py @@ -1,220 +1,81 @@ -# Description: Client-focused API endpoints for DCA dashboard +# 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). +# +# Maintenance-mode note: richer LP UI is moving to ~/dev/webapp. Keep this +# surface stable + minimal. from http import HTTPStatus from typing import List, Optional -from datetime import datetime, timedelta -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException from lnbits.core.models import WalletTypeInfo from lnbits.decorators import require_admin_key -from starlette.exceptions import HTTPException from .crud import ( get_client_dashboard_summary, get_client_transactions, - get_client_analytics, - update_client_dca_settings, - get_client_by_user_id, - register_dca_client, + update_lp_autoforward, ) from .models import ( ClientDashboardSummary, ClientTransaction, - ClientAnalytics, - UpdateClientSettings, - ClientRegistrationData, + UpdateClientAutoforward, ) satmachineclient_api_router = APIRouter() -################################################### -############## 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), -) -> 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.get("/api/v1/registration-status") -async def api_check_registration_status( - wallet: WalletTypeInfo = Depends(require_admin_key), -) -> 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, - } - - -################################################### -############## CLIENT DASHBOARD API ############### -################################################### - -@satmachineclient_api_router.get("/api/v1/dashboard/summary") -async def api_get_dashboard_summary( +@satmachineclient_api_router.get( + "/api/v1/dca-client/positions", response_model=ClientDashboardSummary +) +async def api_get_positions( wallet: WalletTypeInfo = Depends(require_admin_key), ) -> ClientDashboardSummary: - """Get client dashboard summary metrics""" - summary = await get_client_dashboard_summary(wallet.wallet.user) - if not summary: + """LP's aggregated dashboard across every machine they're registered at, + plus a per-machine breakdown in the `positions` field.""" + user_id = wallet.wallet.user + summary = await get_client_dashboard_summary(user_id) + if summary is None: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="Client data not found" + HTTPStatus.NOT_FOUND, + "No DCA positions found for this wallet's owner. " + "An operator must register you at a machine first.", ) return summary -@satmachineclient_api_router.get("/api/v1/dashboard/transactions") -async def api_get_client_transactions( +@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, 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]: - """Get client's DCA transaction history with filtering""" + """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 return await get_client_transactions( - wallet.wallet.user, - limit=limit, - offset=offset, - transaction_type=transaction_type, - start_date=start_date, - end_date=end_date + user_id, limit=limit, offset=offset, machine_id=machine_id ) -@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, +@satmachineclient_api_router.put( + "/api/v1/dca-client/autoforward", response_model=dict +) +async def api_update_autoforward( + data: UpdateClientAutoforward, 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"} + """LP controls their own auto-forward setting (where DCA distributions + get forwarded to externally, per satmachineadmin#8). Applies to all of + the LP's dca_clients rows; operator can't override LP-controlled + settings.""" + user_id = wallet.wallet.user + n = await update_lp_autoforward(user_id, data) + return {"updated_clients": n} - -@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