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:
parent
98f82beb28
commit
ade4e67541
8 changed files with 289 additions and 738 deletions
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -1,4 +1,6 @@
|
|||
__pycache__
|
||||
node_modules
|
||||
.mypy_cache
|
||||
.venv
|
||||
# LNbits runtime data — auth keys, DB files, etc. Never commit.
|
||||
data/
|
||||
*.sqlite3
|
||||
*.sqlite3-journal
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
669
crud.py
669
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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
119
models.py
119
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
|
||||
|
||||
|
|
|
|||
2
tasks.py
2
tasks.py
|
|
@ -1,2 +0,0 @@
|
|||
# No background tasks needed in client extension
|
||||
# Client extension is a read-only dashboard
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
# No transaction processing needed in client extension
|
||||
# All transaction processing is handled by the admin extension
|
||||
231
views_api.py
231
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue