feat(v2): satmachineclient maintenance pass — v2 admin schema (P7)

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.

.gitignore gains `data/` + sqlite db files + __pycache__ to prevent
LNbits runtime artifacts (auth keys, dev DBs) from being committed.

4 routes registered. Zero ruff errors across the extension.

Refs: aiolabs/satmachineadmin#9 — completes P7

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-14 17:53:13 +02:00
commit ade4e67541
8 changed files with 289 additions and 738 deletions

10
.gitignore vendored
View file

@ -1,4 +1,6 @@
__pycache__ # LNbits runtime data — auth keys, DB files, etc. Never commit.
node_modules data/
.mypy_cache *.sqlite3
.venv *.sqlite3-journal
__pycache__/
*.pyc

View file

@ -1,6 +1,10 @@
# DCA Client Extension for LNBits # 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 ## Overview

665
crud.py
View file

@ -1,132 +1,153 @@
# 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 typing import List, Optional
from datetime import datetime, timedelta
from lnbits.db import Database from lnbits.db import Database
from lnbits.utils.exchange_rates import satoshis_amount_as_fiat 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 ( from .models import (
ClientDashboardSummary, ClientDashboardSummary,
ClientTransaction, ClientTransaction,
ClientAnalytics, PerMachinePosition,
UpdateClientSettings, UpdateClientAutoforward,
ClientRegistrationData,
) )
# Connect to admin extension's database # Same DB schema as the admin extension — we share satoshimachine.* tables.
db = Database("ext_satoshimachine") db = Database("ext_satoshimachine")
################################################### async def _fetch_user_clients(user_id: str) -> List[dict]:
############## CLIENT DASHBOARD CRUD ############## """All dca_clients rows for this LP, joined with their machines for
################################################### per-machine display metadata."""
return await db.fetchall(
async def get_client_dashboard_summary(user_id: str) -> Optional[ClientDashboardSummary]: """
"""Get dashboard summary for a specific user""" SELECT c.id AS client_id,
c.machine_id,
# Get client info c.wallet_id,
client = await db.fetchone( c.dca_mode,
"SELECT * FROM satoshimachine.dca_clients WHERE user_id = :user_id", c.status,
{"user_id": user_id} 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},
) )
if not client:
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:
return None return None
positions = [await _position_for_client(c) for c in clients]
# Get wallet to determine currency total_sats = sum(p.total_sats_accumulated for p in positions)
wallet = await get_wallet(client["wallet_id"]) total_invested = round(sum(p.total_fiat_invested for p in positions), 2)
# TODO: Get currency from wallet; bit more difficult to do in a different total_balance = round(sum(p.current_fiat_balance for p in positions), 2)
# currency than deposit cause of cross exchange rates total_tx = sum(p.total_transactions for p in positions)
# currency = wallet.currency or "GTQ" # Default to GTQ if no currency set last_tx = max(
currency = "GTQ" # Default to GTQ if no currency set (p.last_transaction_date for p in positions if p.last_transaction_date),
default=None,
# 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"]}
) )
# Pending deposits (across all clients of this LP).
# Get total confirmed deposits (this is the "total invested") pending = await db.fetchone(
deposits_result = await db.fetchone(
""" """
SELECT COALESCE(SUM(amount), 0) as confirmed_deposits SELECT COALESCE(SUM(d.amount), 0) AS total
FROM satoshimachine.dca_deposits FROM satoshimachine.dca_deposits d
WHERE client_id = :client_id AND status = 'confirmed' 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},
) )
pending_fiat = float(pending["total"]) if pending else 0.0
# Get total pending deposits (for additional info) # Cost basis = sats / fiat-spent. Use the distributed fiat (deposits
pending_deposits_result = await db.fetchone( # less remaining balance) so we get the cost basis on what's actually
""" # been DCA'd, not the gross deposits.
SELECT COALESCE(SUM(amount), 0) as pending_deposits fiat_spent = max(total_invested - total_balance, 0.0)
FROM satoshimachine.dca_deposits cost_basis = total_sats / fiat_spent if fiat_spent > 0 else 0.0
WHERE client_id = :client_id AND status = 'pending' # Display currency: if all positions share one, use it; else "MIX".
""", currencies = {p.currency for p in positions}
{"client_id": client["id"]} currency = currencies.pop() if len(currencies) == 1 else "MIX"
) # Best-effort current fiat value of the LP's sats stack.
current_value = 0.0
# Get total fiat spent on DCA transactions (to calculate remaining balance) if total_sats > 0 and currency != "MIX":
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: try:
current_sats_fiat_value = await satoshis_amount_as_fiat(total_sats, currency) current_value = await satoshis_amount_as_fiat(total_sats, currency)
except Exception as e: except Exception as exc:
print(f"Warning: Could not fetch exchange rate for {currency}: {e}") logger.warning(
current_sats_fiat_value = 0.0 f"satmachineclient: could not fetch exchange rate for "
f"{currency}: {exc}"
)
return ClientDashboardSummary( return ClientDashboardSummary(
user_id=user_id, user_id=user_id,
total_sats_accumulated=total_sats, total_sats_accumulated=total_sats,
total_fiat_invested=total_invested, # Sum of confirmed deposits total_fiat_invested=total_invested,
pending_fiat_deposits=pending_deposits, # Sum of pending deposits current_fiat_balance=total_balance,
current_sats_fiat_value=current_sats_fiat_value, # Current fiat value of sats pending_fiat_deposits=round(pending_fiat, 2),
average_cost_basis=avg_cost_basis, average_cost_basis=round(cost_basis, 4),
current_fiat_balance=remaining_balance, # Confirmed deposits - DCA spent current_sats_fiat_value=round(current_value, 2),
total_transactions=tx_stats["tx_count"] if tx_stats else 0, total_transactions=total_tx,
dca_mode=client["dca_mode"], total_machines=len(positions),
dca_status=client["status"], last_transaction_date=last_tx,
last_transaction_date=tx_stats["last_tx_date"] if tx_stats else None, currency=currency,
currency=currency # Wallet's currency positions=positions,
) )
@ -134,388 +155,82 @@ async def get_client_transactions(
user_id: str, user_id: str,
limit: int = 50, limit: int = 50,
offset: int = 0, offset: int = 0,
transaction_type: Optional[str] = None, machine_id: Optional[str] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> List[ClientTransaction]: ) -> List[ClientTransaction]:
"""Get client's transaction history with filtering""" """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.
# Get client ID first Optional machine_id narrows to a single machine."""
client = await db.fetchone( params: dict = {
"SELECT id FROM satoshimachine.dca_clients WHERE user_id = :user_id", "uid": user_id,
{"user_id": user_id} "lim": limit,
"off": offset,
}
where = (
"WHERE c.user_id = :uid "
"AND p.leg_type IN ('dca', 'settlement', 'autoforward')"
) )
if machine_id is not None:
if not client: where += " AND p.machine_id = :mid"
return [] params["mid"] = machine_id
rows = await db.fetchall(
# 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""" f"""
SELECT id, amount_sats, amount_fiat, exchange_rate, transaction_type, SELECT p.id, p.machine_id, p.settlement_id, p.leg_type,
status, created_at, transaction_time, lamassu_transaction_id p.amount_sats, p.amount_fiat, p.exchange_rate, p.status,
FROM satoshimachine.dca_payments p.created_at, p.transaction_time,
WHERE {where_clause} m.machine_npub
ORDER BY created_at DESC FROM satoshimachine.dca_payments p
LIMIT :limit OFFSET :offset 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 [ return [
ClientTransaction( ClientTransaction(
id=tx["id"], id=row["id"],
amount_sats=tx["amount_sats"], machine_id=row["machine_id"],
amount_fiat=tx["amount_fiat"], machine_npub=row["machine_npub"],
exchange_rate=tx["exchange_rate"], settlement_id=row["settlement_id"],
transaction_type=tx["transaction_type"], leg_type=row["leg_type"],
status=tx["status"], amount_sats=row["amount_sats"],
created_at=tx["created_at"], amount_fiat=row["amount_fiat"],
transaction_time=tx["transaction_time"], exchange_rate=row["exchange_rate"],
lamassu_transaction_id=tx["lamassu_transaction_id"] 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]: async def update_lp_autoforward(
"""Get client performance analytics""" user_id: str, data: UpdateClientAutoforward
) -> int:
try: """LPs control their own auto-forward. Update applies to ALL of this
from datetime import datetime LP's dca_clients rows (every machine they're on) operator can't
override LP-controlled settings."""
# Get client ID update_data = {k: v for k, v in data.dict().items() if v is not None}
client = await db.fetchone( if not update_data:
"SELECT id FROM satoshimachine.dca_clients WHERE user_id = :user_id", return 0
{"user_id": user_id} update_data["updated_at"] = datetime.now()
) update_data["uid"] = user_id
set_clause = ", ".join(
if not client: f"{k} = :{k}" for k in update_data if k not in ("uid",)
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}
) )
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

119
models.py
View file

@ -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 datetime import datetime
from typing import List, Optional from typing import List, Optional
@ -6,95 +14,60 @@ from typing import List, Optional
from pydantic import BaseModel from pydantic import BaseModel
# API Models for Client Dashboard (Frontend communication in GTQ) class PerMachinePosition(BaseModel):
class ClientDashboardSummaryAPI(BaseModel): """LP's position at a single machine."""
"""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"
machine_id: str
class ClientTransactionAPI(BaseModel): machine_npub: str
"""API model - client transaction in GTQ""" machine_name: Optional[str]
id: str machine_location: Optional[str]
amount_sats: int currency: str
amount_fiat_gtq: float # Amount in GTQ dca_mode: str
exchange_rate: float
transaction_type: str # 'flow', 'fixed', 'manual'
status: str status: str
created_at: datetime total_sats_accumulated: int
transaction_time: Optional[datetime] = None # Original ATM transaction time total_fiat_invested: float
lamassu_transaction_id: Optional[str] = None current_fiat_balance: float
total_transactions: int
last_transaction_date: Optional[datetime]
# Internal Models for Client Dashboard (Database storage in GTQ)
class ClientDashboardSummary(BaseModel): 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 user_id: str
total_sats_accumulated: int total_sats_accumulated: int
total_fiat_invested: float # Confirmed deposits in GTQ total_fiat_invested: float # confirmed deposits across all machines
pending_fiat_deposits: float # Pending deposits awaiting confirmation in GTQ current_fiat_balance: float # confirmed deposits - DCA - settlement legs
current_sats_fiat_value: float # Current fiat value of total sats in GTQ pending_fiat_deposits: float # deposits in 'pending' status
average_cost_basis: float # Average sats per GTQ average_cost_basis: float # total_sats / total_fiat_invested-spent
current_fiat_balance: float # Available balance for DCA in GTQ current_sats_fiat_value: float # current rate × total_sats (best-effort)
total_transactions: int total_transactions: int
dca_mode: str # 'flow' or 'fixed' total_machines: int # how many machines this LP is on
dca_status: str # 'active' or 'inactive'
last_transaction_date: Optional[datetime] last_transaction_date: Optional[datetime]
currency: str = "GTQ" currency: str # display currency; if multi-currency, "MIX"
positions: List[PerMachinePosition] = []
class ClientTransaction(BaseModel): class ClientTransaction(BaseModel):
"""Internal model - client transaction stored in GTQ""" """A single distribution leg landing in the LP's wallet."""
id: str id: str
machine_id: str
machine_npub: str
settlement_id: Optional[str]
leg_type: str # 'dca' | 'settlement' | 'autoforward' (LP-visible)
amount_sats: int amount_sats: int
amount_fiat: float # Amount in GTQ (e.g., 150.75) amount_fiat: Optional[float]
exchange_rate: float exchange_rate: Optional[float]
transaction_type: str # 'flow', 'fixed', 'manual'
status: str status: str
created_at: datetime created_at: datetime
transaction_time: Optional[datetime] = None # Original ATM transaction time transaction_time: datetime
lamassu_transaction_id: Optional[str] = None
class ClientAnalytics(BaseModel): class UpdateClientAutoforward(BaseModel):
"""Performance analytics for client dashboard""" """LPs can manage their own auto-forward setting per #8. Applies to all
user_id: str of the LP's dca_clients rows (across every machine they're on)."""
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
autoforward_enabled: Optional[bool] = None
autoforward_ln_address: Optional[str] = None

View file

@ -1,2 +0,0 @@
# No background tasks needed in client extension
# Client extension is a read-only dashboard

View file

@ -1,2 +0,0 @@
# No transaction processing needed in client extension
# All transaction processing is handled by the admin extension

View file

@ -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 http import HTTPStatus
from typing import List, Optional 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.core.models import WalletTypeInfo
from lnbits.decorators import require_admin_key from lnbits.decorators import require_admin_key
from starlette.exceptions import HTTPException
from .crud import ( from .crud import (
get_client_dashboard_summary, get_client_dashboard_summary,
get_client_transactions, get_client_transactions,
get_client_analytics, update_lp_autoforward,
update_client_dca_settings,
get_client_by_user_id,
register_dca_client,
) )
from .models import ( from .models import (
ClientDashboardSummary, ClientDashboardSummary,
ClientTransaction, ClientTransaction,
ClientAnalytics, UpdateClientAutoforward,
UpdateClientSettings,
ClientRegistrationData,
) )
satmachineclient_api_router = APIRouter() satmachineclient_api_router = APIRouter()
################################################### @satmachineclient_api_router.get(
############## CLIENT REGISTRATION ############### "/api/v1/dca-client/positions", response_model=ClientDashboardSummary
################################################### )
async def api_get_positions(
@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(
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
) -> ClientDashboardSummary: ) -> ClientDashboardSummary:
"""Get client dashboard summary metrics""" """LP's aggregated dashboard across every machine they're registered at,
summary = await get_client_dashboard_summary(wallet.wallet.user) plus a per-machine breakdown in the `positions` field."""
if not summary: user_id = wallet.wallet.user
summary = await get_client_dashboard_summary(user_id)
if summary is None:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, HTTPStatus.NOT_FOUND,
detail="Client data not found" "No DCA positions found for this wallet's owner. "
"An operator must register you at a machine first.",
) )
return summary return summary
@satmachineclient_api_router.get("/api/v1/dashboard/transactions") @satmachineclient_api_router.get(
async def api_get_client_transactions( "/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), 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]: ) -> 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( return await get_client_transactions(
wallet.wallet.user, user_id, limit=limit, offset=offset, machine_id=machine_id
limit=limit,
offset=offset,
transaction_type=transaction_type,
start_date=start_date,
end_date=end_date
) )
@satmachineclient_api_router.get("/api/v1/dashboard/analytics") @satmachineclient_api_router.put(
async def api_get_client_analytics( "/api/v1/dca-client/autoforward", response_model=dict
wallet: WalletTypeInfo = Depends(require_admin_key), )
time_range: str = Query("30d", regex="^(7d|30d|90d|1y|all)$"), async def api_update_autoforward(
) -> ClientAnalytics: data: UpdateClientAutoforward,
"""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), wallet: WalletTypeInfo = Depends(require_admin_key),
) -> dict: ) -> dict:
"""Update client DCA settings (mode, limits, status) """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}
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