Compare commits

..

No commits in common. "v2-bitspire" and "main" have entirely different histories.

9 changed files with 1433 additions and 540 deletions

10
.gitignore vendored
View file

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

View file

@ -1,10 +1,6 @@
# DCA Client Extension for LNBits
> **Status: maintenance-mode (v2).** This extension provides a minimal LP-facing
> read view over the v2 satmachineadmin schema. The richer LP UI is migrating to
> `~/dev/webapp`. Keep this surface stable; new LP features should land in webapp.
A Dollar Cost Averaging (DCA) administration extension for LNBits that integrates with bitSpire ATMs (formerly Lamassu) to automatically distribute Bitcoin to registered liquidity providers (LPs) based on their deposit balances.
A Dollar Cost Averaging (DCA) administration extension for LNBits that integrates with Lamassu ATM machines to automatically distribute Bitcoin to registered clients based on their deposit balances.
## Overview

694
crud.py
View file

@ -1,161 +1,132 @@
# Satoshi Machine Client v2 — CRUD over admin schema.
#
# Cross-extension reads of satoshimachine.dca_* tables, filtered by the
# LP's user_id. The admin extension owns writes to dca_clients/deposits/
# settlements; this extension owns writes to satoshimachine.dca_lp
# (the LP's per-user preferences row — wallet, mode, autoforward).
# Description: Client extension CRUD operations - reads from admin extension database
from datetime import datetime
from typing import List, Optional
from datetime import datetime, timedelta
from lnbits.db import Database
from lnbits.utils.exchange_rates import satoshis_amount_as_fiat
from loguru import logger
from lnbits.core.crud.wallets import get_wallet
from .models import (
ClientDashboardSummary,
ClientTransaction,
LpPreferences,
PerMachinePosition,
UpdateLpPreferences,
ClientAnalytics,
UpdateClientSettings,
ClientRegistrationData,
)
# Same DB schema as the admin extension — we share satoshimachine.* tables.
# Connect to admin extension's database
db = Database("ext_satoshimachine")
async def _fetch_user_clients(user_id: str) -> List[dict]:
"""All dca_clients rows for this LP, joined with their machines for
per-machine display metadata.
###################################################
############## CLIENT DASHBOARD CRUD ##############
###################################################
`dca_mode` lives on the LP's per-user `dca_lp` row now, not per
enrolment INNER JOIN dca_lp so positions only return when the LP
has actually onboarded (otherwise distribution can't pay them either,
so listing them in the dashboard would be misleading).
"""
return await db.fetchall(
"""
SELECT c.id AS client_id,
c.machine_id,
c.status,
m.machine_npub,
m.name AS machine_name,
m.location AS machine_location,
m.fiat_code AS machine_fiat_code,
lp.default_dca_mode AS dca_mode
FROM satoshimachine.dca_clients c
JOIN satoshimachine.dca_machines m ON m.id = c.machine_id
JOIN satoshimachine.dca_lp lp ON lp.user_id = c.user_id
WHERE c.user_id = :user_id
ORDER BY c.created_at DESC
""",
{"user_id": user_id},
async def get_client_dashboard_summary(user_id: str) -> Optional[ClientDashboardSummary]:
"""Get dashboard summary for a specific user"""
# Get client info
client = await db.fetchone(
"SELECT * FROM satoshimachine.dca_clients WHERE user_id = :user_id",
{"user_id": user_id}
)
async def _position_for_client(client_row: dict) -> PerMachinePosition:
cid = client_row["client_id"]
confirmed_deposits = await db.fetchone(
"""
SELECT COALESCE(SUM(amount), 0) AS total
FROM satoshimachine.dca_deposits
WHERE client_id = :cid AND status = 'confirmed'
""",
{"cid": cid},
)
# DCA + settlement legs both count against the LP's balance.
distributed = await db.fetchone(
"""
SELECT COALESCE(SUM(amount_fiat), 0) AS total_fiat,
COALESCE(SUM(amount_sats), 0) AS total_sats,
COUNT(*) AS tx_count,
MAX(created_at) AS last_tx
FROM satoshimachine.dca_payments
WHERE client_id = :cid
AND leg_type IN ('dca', 'settlement')
AND status = 'completed'
""",
{"cid": cid},
)
total_deposits = float(confirmed_deposits["total"]) if confirmed_deposits else 0.0
total_fiat = float(distributed["total_fiat"]) if distributed else 0.0
total_sats = int(distributed["total_sats"]) if distributed else 0
tx_count = int(distributed["tx_count"]) if distributed else 0
last_tx = distributed["last_tx"] if distributed else None
return PerMachinePosition(
machine_id=client_row["machine_id"],
machine_npub=client_row["machine_npub"],
machine_name=client_row["machine_name"],
machine_location=client_row["machine_location"],
currency=client_row["machine_fiat_code"],
dca_mode=client_row["dca_mode"],
status=client_row["status"],
total_sats_accumulated=total_sats,
total_fiat_invested=round(total_deposits, 2),
current_fiat_balance=round(total_deposits - total_fiat, 2),
total_transactions=tx_count,
last_transaction_date=last_tx,
)
async def get_client_dashboard_summary(
user_id: str,
) -> Optional[ClientDashboardSummary]:
"""Aggregated LP dashboard. Returns None if the LP has no positions."""
clients = await _fetch_user_clients(user_id)
if not clients:
if not client:
return None
positions = [await _position_for_client(c) for c in clients]
total_sats = sum(p.total_sats_accumulated for p in positions)
total_invested = round(sum(p.total_fiat_invested for p in positions), 2)
total_balance = round(sum(p.current_fiat_balance for p in positions), 2)
total_tx = sum(p.total_transactions for p in positions)
last_tx = max(
(p.last_transaction_date for p in positions if p.last_transaction_date),
default=None,
)
# Pending deposits (across all clients of this LP).
pending = await db.fetchone(
# Get wallet to determine currency
wallet = await get_wallet(client["wallet_id"])
# TODO: Get currency from wallet; bit more difficult to do in a different
# currency than deposit cause of cross exchange rates
# currency = wallet.currency or "GTQ" # Default to GTQ if no currency set
currency = "GTQ" # Default to GTQ if no currency set
# Get total sats accumulated from DCA transactions
sats_result = await db.fetchone(
"""
SELECT COALESCE(SUM(d.amount), 0) AS total
FROM satoshimachine.dca_deposits d
JOIN satoshimachine.dca_clients c ON c.id = d.client_id
WHERE c.user_id = :uid AND d.status = 'pending'
SELECT COALESCE(SUM(amount_sats), 0) as total_sats
FROM satoshimachine.dca_payments
WHERE client_id = :client_id AND status = 'confirmed'
""",
{"uid": user_id},
{"client_id": client["id"]}
)
pending_fiat = float(pending["total"]) if pending else 0.0
# Cost basis = sats / fiat-spent. Use the distributed fiat (deposits
# less remaining balance) so we get the cost basis on what's actually
# been DCA'd, not the gross deposits.
fiat_spent = max(total_invested - total_balance, 0.0)
cost_basis = total_sats / fiat_spent if fiat_spent > 0 else 0.0
# Display currency: if all positions share one, use it; else "MIX".
currencies = {p.currency for p in positions}
currency = currencies.pop() if len(currencies) == 1 else "MIX"
# Best-effort current fiat value of the LP's sats stack.
current_value = 0.0
if total_sats > 0 and currency != "MIX":
# Get total confirmed deposits (this is the "total invested")
deposits_result = await db.fetchone(
"""
SELECT COALESCE(SUM(amount), 0) as confirmed_deposits
FROM satoshimachine.dca_deposits
WHERE client_id = :client_id AND status = 'confirmed'
""",
{"client_id": client["id"]}
)
# Get total pending deposits (for additional info)
pending_deposits_result = await db.fetchone(
"""
SELECT COALESCE(SUM(amount), 0) as pending_deposits
FROM satoshimachine.dca_deposits
WHERE client_id = :client_id AND status = 'pending'
""",
{"client_id": client["id"]}
)
# Get total fiat spent on DCA transactions (to calculate remaining balance)
dca_spent_result = await db.fetchone(
"""
SELECT COALESCE(SUM(amount_fiat), 0) as dca_spent
FROM satoshimachine.dca_payments
WHERE client_id = :client_id AND status = 'confirmed'
""",
{"client_id": client["id"]}
)
# Get transaction count and last transaction date
tx_stats = await db.fetchone(
"""
SELECT
COUNT(*) as tx_count,
MAX(created_at) as last_tx_date
FROM satoshimachine.dca_payments
WHERE client_id = :client_id AND status = 'confirmed'
""",
{"client_id": client["id"]}
)
# Extract values from query results
total_sats = sats_result["total_sats"] if sats_result else 0
confirmed_deposits = deposits_result["confirmed_deposits"] if deposits_result else 0
pending_deposits = pending_deposits_result["pending_deposits"] if pending_deposits_result else 0
dca_spent = dca_spent_result["dca_spent"] if dca_spent_result else 0
# Calculate metrics
total_invested = confirmed_deposits # Total invested = all confirmed deposits
remaining_balance = confirmed_deposits - dca_spent # Remaining = deposits - DCA spending
avg_cost_basis = total_sats / dca_spent if dca_spent > 0 else 0 # Cost basis = sats / GTQ
# Calculate current fiat value of total sats
current_sats_fiat_value = 0.0
if total_sats > 0:
try:
current_value = await satoshis_amount_as_fiat(total_sats, currency)
except Exception as exc:
logger.warning(
f"satmachineclient: could not fetch exchange rate for "
f"{currency}: {exc}"
)
current_sats_fiat_value = await satoshis_amount_as_fiat(total_sats, currency)
except Exception as e:
print(f"Warning: Could not fetch exchange rate for {currency}: {e}")
current_sats_fiat_value = 0.0
return ClientDashboardSummary(
user_id=user_id,
total_sats_accumulated=total_sats,
total_fiat_invested=total_invested,
current_fiat_balance=total_balance,
pending_fiat_deposits=round(pending_fiat, 2),
average_cost_basis=round(cost_basis, 4),
current_sats_fiat_value=round(current_value, 2),
total_transactions=total_tx,
total_machines=len(positions),
last_transaction_date=last_tx,
currency=currency,
positions=positions,
total_fiat_invested=total_invested, # Sum of confirmed deposits
pending_fiat_deposits=pending_deposits, # Sum of pending deposits
current_sats_fiat_value=current_sats_fiat_value, # Current fiat value of sats
average_cost_basis=avg_cost_basis,
current_fiat_balance=remaining_balance, # Confirmed deposits - DCA spent
total_transactions=tx_stats["tx_count"] if tx_stats else 0,
dca_mode=client["dca_mode"],
dca_status=client["status"],
last_transaction_date=tx_stats["last_tx_date"] if tx_stats else None,
currency=currency # Wallet's currency
)
@ -163,115 +134,388 @@ async def get_client_transactions(
user_id: str,
limit: int = 50,
offset: int = 0,
machine_id: Optional[str] = None,
transaction_type: Optional[str] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> List[ClientTransaction]:
"""LP's transaction history. Filters to 'dca' / 'settlement' /
'autoforward' legs since those are the ones the LP cares about super_fee
and operator_split legs are operator-internal.
"""Get client's transaction history with filtering"""
Optional machine_id narrows to a single machine."""
params: dict = {
"uid": user_id,
"lim": limit,
"off": offset,
}
where = (
"WHERE c.user_id = :uid "
"AND p.leg_type IN ('dca', 'settlement', 'autoforward')"
# Get client ID first
client = await db.fetchone(
"SELECT id FROM satoshimachine.dca_clients WHERE user_id = :user_id",
{"user_id": user_id}
)
if machine_id is not None:
where += " AND p.machine_id = :mid"
params["mid"] = machine_id
rows = await db.fetchall(
if not client:
return []
# Build query with filters
where_conditions = ["client_id = :client_id"]
params = {"client_id": client["id"], "limit": limit, "offset": offset}
if transaction_type:
where_conditions.append("transaction_type = :transaction_type")
params["transaction_type"] = transaction_type
if start_date:
where_conditions.append("created_at >= :start_date")
params["start_date"] = start_date
if end_date:
where_conditions.append("created_at <= :end_date")
params["end_date"] = end_date
where_clause = " AND ".join(where_conditions)
transactions = await db.fetchall(
f"""
SELECT p.id, p.machine_id, p.settlement_id, p.leg_type,
p.amount_sats, p.amount_fiat, p.exchange_rate, p.status,
p.created_at, p.transaction_time,
m.machine_npub
FROM satoshimachine.dca_payments p
JOIN satoshimachine.dca_clients c ON c.id = p.client_id
JOIN satoshimachine.dca_machines m ON m.id = p.machine_id
{where}
ORDER BY p.created_at DESC
LIMIT :lim OFFSET :off
SELECT id, amount_sats, amount_fiat, exchange_rate, transaction_type,
status, created_at, transaction_time, lamassu_transaction_id
FROM satoshimachine.dca_payments
WHERE {where_clause}
ORDER BY created_at DESC
LIMIT :limit OFFSET :offset
""",
params,
params
)
return [
ClientTransaction(
id=row["id"],
machine_id=row["machine_id"],
machine_npub=row["machine_npub"],
settlement_id=row["settlement_id"],
leg_type=row["leg_type"],
amount_sats=row["amount_sats"],
amount_fiat=row["amount_fiat"],
exchange_rate=row["exchange_rate"],
status=row["status"],
created_at=row["created_at"],
transaction_time=row["transaction_time"],
id=tx["id"],
amount_sats=tx["amount_sats"],
amount_fiat=tx["amount_fiat"],
exchange_rate=tx["exchange_rate"],
transaction_type=tx["transaction_type"],
status=tx["status"],
created_at=tx["created_at"],
transaction_time=tx["transaction_time"],
lamassu_transaction_id=tx["lamassu_transaction_id"]
)
for row in rows
for tx in transactions
]
async def get_lp_preferences(user_id: str) -> Optional[LpPreferences]:
"""Read this LP's preferences row from `dca_lp`. Returns None if the
LP hasn't onboarded yet (no row). Callers in this extension generally
use `ensure_lp_preferences` instead, which auto-creates on first
access."""
async def get_client_analytics(user_id: str, time_range: str = "30d") -> Optional[ClientAnalytics]:
"""Get client performance analytics"""
try:
from datetime import datetime
# Get client ID
client = await db.fetchone(
"SELECT id FROM satoshimachine.dca_clients WHERE user_id = :user_id",
{"user_id": user_id}
)
if not client:
print(f"No client found for user_id: {user_id}")
return None
print(f"Found client {client['id']} for user {user_id}, loading analytics for time_range: {time_range}")
# Calculate date range
if time_range == "7d":
start_date = datetime.now() - timedelta(days=7)
elif time_range == "30d":
start_date = datetime.now() - timedelta(days=30)
elif time_range == "90d":
start_date = datetime.now() - timedelta(days=90)
elif time_range == "1y":
start_date = datetime.now() - timedelta(days=365)
else: # "all"
start_date = datetime(2020, 1, 1) # Arbitrary early date
# Get cost basis history (running average)
cost_basis_data = await db.fetchall(
"""
SELECT
COALESCE(transaction_time, created_at) as transaction_date,
amount_sats,
amount_fiat,
exchange_rate,
SUM(amount_sats) OVER (ORDER BY COALESCE(transaction_time, created_at)) as cumulative_sats,
SUM(amount_fiat) OVER (ORDER BY COALESCE(transaction_time, created_at)) as cumulative_fiat
FROM satoshimachine.dca_payments
WHERE client_id = :client_id
AND status = 'confirmed'
AND COALESCE(transaction_time, created_at) IS NOT NULL
AND COALESCE(transaction_time, created_at) >= :start_date
ORDER BY COALESCE(transaction_time, created_at)
""",
{"client_id": client["id"], "start_date": start_date}
)
# Build cost basis history
cost_basis_history = []
for record in cost_basis_data:
avg_cost_basis = record["cumulative_sats"] / record["cumulative_fiat"] if record["cumulative_fiat"] > 0 else 0 # Cost basis = sats / GTQ
# Use transaction_date (which is COALESCE(transaction_time, created_at))
date_to_use = record["transaction_date"]
if date_to_use is None:
print(f"Warning: Null date in cost basis data, skipping record")
continue
elif hasattr(date_to_use, 'isoformat'):
# This is a datetime object
date_str = date_to_use.isoformat()
elif hasattr(date_to_use, 'strftime'):
# This is a date object
date_str = date_to_use.strftime('%Y-%m-%d')
elif isinstance(date_to_use, (int, float)):
# This might be a Unix timestamp - check if it's in a reasonable range
timestamp = float(date_to_use)
# Check if this looks like a timestamp (between 1970 and 2100)
if 0 < timestamp < 4102444800: # Jan 1, 2100
# Could be seconds or milliseconds
if timestamp > 1000000000000: # Likely milliseconds
timestamp = timestamp / 1000
date_str = datetime.fromtimestamp(timestamp).isoformat()
else:
# Not a timestamp, treat as string
date_str = str(date_to_use)
print(f"Warning: Numeric date value out of timestamp range: {date_to_use}")
elif isinstance(date_to_use, str) and date_to_use.isdigit():
# This is a numeric string - might be a timestamp
timestamp = float(date_to_use)
# Check if this looks like a timestamp
if 0 < timestamp < 4102444800: # Jan 1, 2100
# Could be seconds or milliseconds
if timestamp > 1000000000000: # Likely milliseconds
timestamp = timestamp / 1000
date_str = datetime.fromtimestamp(timestamp).isoformat()
else:
# Not a timestamp, treat as string
date_str = str(date_to_use)
print(f"Warning: Numeric date string out of timestamp range: {date_to_use}")
else:
# Convert string representation to proper format
date_str = str(date_to_use)
print(f"Warning: Unexpected date format: {date_to_use} (type: {type(date_to_use)})")
cost_basis_history.append({
"date": date_str,
"average_cost_basis": avg_cost_basis,
"cumulative_sats": record["cumulative_sats"],
"cumulative_fiat": record["cumulative_fiat"]
})
# Get accumulation timeline (daily/weekly aggregation)
accumulation_data = await db.fetchall(
"""
SELECT
DATE(COALESCE(transaction_time, created_at)) as date,
SUM(amount_sats) as daily_sats,
SUM(amount_fiat) as daily_fiat,
COUNT(*) as daily_transactions
FROM satoshimachine.dca_payments
WHERE client_id = :client_id
AND status = 'confirmed'
AND COALESCE(transaction_time, created_at) IS NOT NULL
AND COALESCE(transaction_time, created_at) >= :start_date
GROUP BY DATE(COALESCE(transaction_time, created_at))
ORDER BY date
""",
{"client_id": client["id"], "start_date": start_date}
)
accumulation_timeline = []
for record in accumulation_data:
# Handle date conversion safely
date_value = record["date"]
if date_value is None:
print(f"Warning: Null date in accumulation data, skipping record")
continue
elif hasattr(date_value, 'isoformat'):
# This is a datetime object
date_str = date_value.isoformat()
elif hasattr(date_value, 'strftime'):
# This is a date object (from DATE() function)
date_str = date_value.strftime('%Y-%m-%d')
elif isinstance(date_value, (int, float)):
# This might be a Unix timestamp - check if it's in a reasonable range
timestamp = float(date_value)
# Check if this looks like a timestamp (between 1970 and 2100)
if 0 < timestamp < 4102444800: # Jan 1, 2100
# Could be seconds or milliseconds
if timestamp > 1000000000000: # Likely milliseconds
timestamp = timestamp / 1000
date_str = datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d')
else:
# Not a timestamp, treat as string
date_str = str(date_value)
print(f"Warning: Numeric accumulation date out of timestamp range: {date_value}")
elif isinstance(date_value, str) and date_value.isdigit():
# This is a numeric string - might be a timestamp
timestamp = float(date_value)
# Check if this looks like a timestamp
if 0 < timestamp < 4102444800: # Jan 1, 2100
# Could be seconds or milliseconds
if timestamp > 1000000000000: # Likely milliseconds
timestamp = timestamp / 1000
date_str = datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d')
else:
# Not a timestamp, treat as string
date_str = str(date_value)
print(f"Warning: Numeric accumulation date string out of timestamp range: {date_value}")
else:
# Convert string representation to proper format
date_str = str(date_value)
print(f"Warning: Unexpected accumulation date format: {date_value} (type: {type(date_value)})")
accumulation_timeline.append({
"date": date_str,
"sats": record["daily_sats"],
"fiat": record["daily_fiat"],
"transactions": record["daily_transactions"]
})
# Get transaction frequency metrics
frequency_stats = await db.fetchone(
"""
SELECT
COUNT(*) as total_transactions,
AVG(amount_sats) as avg_sats_per_tx,
AVG(amount_fiat) as avg_fiat_per_tx,
MIN(COALESCE(transaction_time, created_at)) as first_tx,
MAX(COALESCE(transaction_time, created_at)) as last_tx
FROM satoshimachine.dca_payments
WHERE client_id = :client_id AND status = 'confirmed'
""",
{"client_id": client["id"]}
)
# Build transaction frequency with safe date handling
transaction_frequency = {
"total_transactions": frequency_stats["total_transactions"] if frequency_stats else 0,
"avg_sats_per_transaction": frequency_stats["avg_sats_per_tx"] if frequency_stats else 0,
"avg_fiat_per_transaction": frequency_stats["avg_fiat_per_tx"] if frequency_stats else 0,
"first_transaction": None,
"last_transaction": None
}
# Handle first_tx date safely
if frequency_stats and frequency_stats["first_tx"]:
first_tx = frequency_stats["first_tx"]
if hasattr(first_tx, 'isoformat'):
transaction_frequency["first_transaction"] = first_tx.isoformat()
else:
transaction_frequency["first_transaction"] = str(first_tx)
# Handle last_tx date safely
if frequency_stats and frequency_stats["last_tx"]:
last_tx = frequency_stats["last_tx"]
if hasattr(last_tx, 'isoformat'):
transaction_frequency["last_transaction"] = last_tx.isoformat()
else:
transaction_frequency["last_transaction"] = str(last_tx)
return ClientAnalytics(
user_id=user_id,
cost_basis_history=cost_basis_history,
accumulation_timeline=accumulation_timeline,
transaction_frequency=transaction_frequency
)
except Exception as e:
print(f"Error in get_client_analytics for user {user_id}: {str(e)}")
import traceback
traceback.print_exc()
return None
async def get_client_by_user_id(user_id: str):
"""Get client record by user_id"""
return await db.fetchone(
"SELECT * FROM satoshimachine.dca_lp WHERE user_id = :uid",
{"uid": user_id},
LpPreferences,
"SELECT * FROM satoshimachine.dca_clients WHERE user_id = :user_id",
{"user_id": user_id}
)
async def ensure_lp_preferences(user_id: str, default_wallet_id: str) -> LpPreferences:
"""Get-or-create the LP's preferences row.
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
First call (no row): seed with `default_wallet_id` (passed by the
caller typically the wallet the LP authenticated through). LP can
change it later via `update_lp_preferences`.
update_data["updated_at"] = datetime.now()
set_clause = ", ".join([f"{k} = :{k}" for k in update_data.keys()])
update_data["id"] = client_id
This is the structural enforcement of the "LP must onboard before
deposits work" gate: the act of opening satmachineclient and hitting
any endpoint creates the dca_lp row, which unlocks deposit creation
on the operator side.
"""
existing = await get_lp_preferences(user_id)
if existing is not None:
return existing
now = datetime.now()
await db.execute(
"""
INSERT INTO satoshimachine.dca_lp
(user_id, dca_wallet_id, default_dca_mode,
autoforward_enabled, created_at, updated_at)
VALUES (:uid, :wallet, 'flow', false, :now, :now)
""",
{"uid": user_id, "wallet": default_wallet_id, "now": now},
)
created = await get_lp_preferences(user_id)
assert created is not None
return created
await db.execute(
f"UPDATE satoshimachine.dca_clients SET {set_clause} WHERE id = :id",
update_data
)
return True
except Exception:
return False
async def update_lp_preferences(
user_id: str, data: UpdateLpPreferences
) -> Optional[LpPreferences]:
"""LP-side update of their `dca_lp` row. Caller must ensure the row
exists first (typically via `ensure_lp_preferences` on dashboard
load). Operator cannot reach this path it requires the LP's wallet
admin key per the API auth dependency."""
update_data = {k: v for k, v in data.dict().items() if v is not None}
if not update_data:
return await get_lp_preferences(user_id)
update_data["updated_at"] = datetime.now()
update_data["uid"] = user_id
set_clause = ", ".join(f"{k} = :{k}" for k in update_data if k not in ("uid",))
await db.execute(
f"UPDATE satoshimachine.dca_lp SET {set_clause} WHERE user_id = :uid",
update_data,
)
return await get_lp_preferences(user_id)
###################################################
############## CLIENT REGISTRATION ###############
###################################################
async def register_dca_client(user_id: str, wallet_id: str, registration_data: ClientRegistrationData) -> Optional[dict]:
"""Register a new DCA client - special permission for self-registration"""
from lnbits.helpers import urlsafe_short_hash
from lnbits.core.crud import get_user
try:
# Verify user exists and get username
user = await get_user(user_id)
username = registration_data.username or (user.username if user else f"user_{user_id[:8]}")
# Check if client already exists
existing_client = await db.fetchone(
"SELECT id FROM satoshimachine.dca_clients WHERE user_id = :user_id",
{"user_id": user_id}
)
if existing_client:
return {"error": "Client already registered", "client_id": existing_client[0]}
# Create new client
client_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO satoshimachine.dca_clients
(id, user_id, wallet_id, username, dca_mode, fixed_mode_daily_limit, status, created_at, updated_at)
VALUES (:id, :user_id, :wallet_id, :username, :dca_mode, :fixed_mode_daily_limit, :status, :created_at, :updated_at)
""",
{
"id": client_id,
"user_id": user_id,
"wallet_id": wallet_id,
"username": username,
"dca_mode": registration_data.dca_mode,
"fixed_mode_daily_limit": registration_data.fixed_mode_daily_limit,
"status": "active",
"created_at": datetime.now(),
"updated_at": datetime.now()
}
)
return {
"success": True,
"client_id": client_id,
"message": f"DCA client registered successfully with {registration_data.dca_mode} mode"
}
except Exception as e:
print(f"Error registering DCA client: {e}")
return {"error": f"Registration failed: {str(e)}"}
async def get_client_by_user_id(user_id: str) -> Optional[dict]:
"""Get client by user_id - returns dict instead of model for easier access"""
try:
client = await db.fetchone(
"SELECT * FROM satoshimachine.dca_clients WHERE user_id = :user_id",
{"user_id": user_id}
)
return dict(client) if client else None
except Exception:
return None
# Removed get_active_lamassu_config - client should not access sensitive admin config
# Client limits are now fetched via secure public API endpoint

160
models.py
View file

@ -1,12 +1,4 @@
# Satoshi Machine Client v2 — Pydantic models.
#
# LP-facing read view over the admin extension's v2 schema. An LP can hold
# DCA positions across multiple machines (and across multiple operators on
# the same LNbits instance); summary endpoints aggregate, with an optional
# per-machine breakdown for filtering.
#
# NOTE: this extension is in maintenance mode. The richer LP UI is moving
# to ~/dev/webapp; this surface stays minimal and read-mostly.
# Description: Pydantic data models for client extension API responses
from datetime import datetime
from typing import List, Optional
@ -14,91 +6,95 @@ from typing import List, Optional
from pydantic import BaseModel
class PerMachinePosition(BaseModel):
"""LP's position at a single machine.
# API Models for Client Dashboard (Frontend communication in GTQ)
class ClientDashboardSummaryAPI(BaseModel):
"""API model - client dashboard summary in GTQ"""
user_id: str
total_sats_accumulated: int
total_fiat_invested_gtq: float # Confirmed deposits in GTQ
pending_fiat_deposits_gtq: float # Pending deposits in GTQ
current_sats_fiat_value_gtq: float # Current fiat value of total sats in GTQ
average_cost_basis: float # Average sats per GTQ
current_fiat_balance_gtq: float # Available balance for DCA in GTQ
total_transactions: int
dca_mode: str # 'flow' or 'fixed'
dca_status: str # 'active' or 'inactive'
last_transaction_date: Optional[datetime]
currency: str = "GTQ"
`dca_mode` was previously per-(machine, LP) and is now LP-wide (lives
on `dca_lp.default_dca_mode`). Echoed here for legacy UI display only
every position for a given LP shares the same value.
"""
machine_id: str
machine_npub: str
machine_name: Optional[str]
machine_location: Optional[str]
currency: str
dca_mode: str
class ClientTransactionAPI(BaseModel):
"""API model - client transaction in GTQ"""
id: str
amount_sats: int
amount_fiat_gtq: float # Amount in GTQ
exchange_rate: float
transaction_type: str # 'flow', 'fixed', 'manual'
status: str
total_sats_accumulated: int
total_fiat_invested: float
current_fiat_balance: float
total_transactions: int
last_transaction_date: Optional[datetime]
class ClientDashboardSummary(BaseModel):
"""LP's aggregated dashboard across all machines they're registered at."""
user_id: str
total_sats_accumulated: int
total_fiat_invested: float # confirmed deposits across all machines
current_fiat_balance: float # confirmed deposits - DCA - settlement legs
pending_fiat_deposits: float # deposits in 'pending' status
average_cost_basis: float # total_sats / total_fiat_invested-spent
current_sats_fiat_value: float # current rate × total_sats (best-effort)
total_transactions: int
total_machines: int # how many machines this LP is on
last_transaction_date: Optional[datetime]
currency: str # display currency; if multi-currency, "MIX"
positions: List[PerMachinePosition] = []
class LpPreferences(BaseModel):
"""LP-controlled DCA preferences (one row per user in `dca_lp`).
Auto-created on first satmachineclient dashboard access with the LP's
authenticated wallet as the default `dca_wallet_id`; they can change
any field via `PUT /api/v1/dca-client/preferences`. Distribution
reads from here at payout time operator cannot override.
"""
user_id: str
dca_wallet_id: str
default_dca_mode: str # 'flow' | 'fixed'
fixed_mode_daily_limit: Optional[float]
autoforward_ln_address: Optional[str]
autoforward_enabled: bool
created_at: datetime
updated_at: datetime
transaction_time: Optional[datetime] = None # Original ATM transaction time
lamassu_transaction_id: Optional[str] = None
class UpdateLpPreferences(BaseModel):
"""LP-side preference updates. All fields optional; only ones provided
are touched. Use to switch DCA wallet, change mode, toggle autoforward."""
dca_wallet_id: Optional[str] = None
default_dca_mode: Optional[str] = None
fixed_mode_daily_limit: Optional[float] = None
autoforward_ln_address: Optional[str] = None
autoforward_enabled: Optional[bool] = None
# Internal Models for Client Dashboard (Database storage in GTQ)
class ClientDashboardSummary(BaseModel):
"""Internal model - client dashboard summary stored in GTQ"""
user_id: str
total_sats_accumulated: int
total_fiat_invested: float # Confirmed deposits in GTQ
pending_fiat_deposits: float # Pending deposits awaiting confirmation in GTQ
current_sats_fiat_value: float # Current fiat value of total sats in GTQ
average_cost_basis: float # Average sats per GTQ
current_fiat_balance: float # Available balance for DCA in GTQ
total_transactions: int
dca_mode: str # 'flow' or 'fixed'
dca_status: str # 'active' or 'inactive'
last_transaction_date: Optional[datetime]
currency: str = "GTQ"
class ClientTransaction(BaseModel):
"""A single distribution leg landing in the LP's wallet."""
"""Internal model - client transaction stored in GTQ"""
id: str
machine_id: str
machine_npub: str
settlement_id: Optional[str]
leg_type: str # 'dca' | 'settlement' | 'autoforward' (LP-visible)
amount_sats: int
amount_fiat: Optional[float]
exchange_rate: Optional[float]
amount_fiat: float # Amount in GTQ (e.g., 150.75)
exchange_rate: float
transaction_type: str # 'flow', 'fixed', 'manual'
status: str
created_at: datetime
transaction_time: datetime
transaction_time: Optional[datetime] = None # Original ATM transaction time
lamassu_transaction_id: Optional[str] = None
class ClientAnalytics(BaseModel):
"""Performance analytics for client dashboard"""
user_id: str
cost_basis_history: List[dict] # Historical cost basis data points
accumulation_timeline: List[dict] # Sats accumulated over time
transaction_frequency: dict # Transaction frequency metrics
performance_vs_market: Optional[dict] = None # Market comparison data
class ClientPreferences(BaseModel):
"""Client dashboard preferences and settings"""
user_id: str
preferred_currency: str = "GTQ"
dashboard_theme: str = "light"
chart_time_range: str = "30d" # Default chart time range
notification_preferences: dict = {}
class UpdateClientSettings(BaseModel):
"""Settings that client can modify"""
dca_mode: Optional[str] = None # 'flow' or 'fixed'
fixed_mode_daily_limit: Optional[int] = None
status: Optional[str] = None # 'active' or 'inactive'
class ClientRegistrationData(BaseModel):
"""Data for client self-registration"""
dca_mode: str = "flow" # Default to flow mode
fixed_mode_daily_limit: Optional[int] = None
username: Optional[str] = None
# `UpdateClientAutoforward` removed in the dca_lp refactor; preferences
# (autoforward, wallet, mode) now flow through `UpdateLpPreferences`
# against `PUT /api/v1/dca-client/preferences`.

View file

@ -1,28 +1,12 @@
// Satoshi Machine Client v2 — LP dashboard JS.
//
// Maintenance-mode reminder: the rich LP UI is moving to ~/dev/webapp.
// This file is the lightweight in-LNbits dashboard kept functional for
// dev / E2E testing on the v2-bitspire branch. Endpoints:
// GET /satmachineclient/api/v1/dca-client/preferences (auto-onboards)
// PUT /satmachineclient/api/v1/dca-client/preferences
// GET /satmachineclient/api/v1/dca-client/positions
// GET /satmachineclient/api/v1/dca-client/transactions
//
// The "registration / welcome" wizard in the template is dead — every
// call to GET /preferences auto-creates the LP's dca_lp row on first
// hit, so `isRegistered` is always true after the initial load and the
// wizard never shows. Cleanup of the template is deferred.
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
delimiters: ['${', '}'],
data: function () {
return {
// Registration / onboarding (legacy; always true after initial
// /preferences call because the endpoint auto-creates).
isRegistered: true,
registrationChecked: true,
// Registration state
isRegistered: false,
registrationChecked: false,
registrationForm: {
selectedWallet: null,
dca_mode: 'flow',
@ -30,11 +14,7 @@ window.app = Vue.createApp({
username: ''
},
// LP preferences (loaded from dca_lp on first call).
preferences: null,
// Admin configuration (legacy: no longer fetched — kept so the
// legacy template doesn't undefined-error on `adminConfig.*`).
// Admin configuration
adminConfig: {
max_daily_limit_gtq: 2000,
currency: 'GTQ'
@ -45,165 +25,260 @@ window.app = Vue.createApp({
transactions: [],
loading: true,
error: null,
showFiatValues: false,
// Stubs for legacy template bindings (chart + registration form
// are dead branches but still in the .html — keep these defined
// so Vue doesn't emit warnings on initial render).
chartLoading: false,
chartTimeRange: '30d',
analyticsData: null,
showFiatValues: false, // Hide fiat values by default
transactionColumns: [
{name: 'date', label: 'Date', align: 'left',
field: row => row.transaction_time || row.created_at, sortable: false},
{name: 'amount_sats', label: 'Bitcoin', align: 'right',
field: 'amount_sats', sortable: false},
{name: 'amount_fiat', label: 'Fiat Amount', align: 'right',
field: 'amount_fiat', sortable: false},
{name: 'type', label: 'Type', align: 'center',
field: 'leg_type', sortable: false},
{name: 'status', label: 'Status', align: 'center',
field: 'status', sortable: false}
{
name: 'date',
label: 'Date',
align: 'left',
field: row => row.transaction_time || row.created_at,
sortable: false
},
{
name: 'amount_sats',
label: 'Bitcoin',
align: 'right',
field: 'amount_sats',
sortable: false
},
{
name: 'amount_fiat',
label: 'Fiat Amount',
align: 'right',
field: 'amount_fiat',
sortable: false
},
{
name: 'type',
label: 'Type',
align: 'center',
field: 'transaction_type',
sortable: false
},
{
name: 'status',
label: 'Status',
align: 'center',
field: 'status',
sortable: false
}
],
transactionPagination: {
sortBy: 'date',
descending: true,
page: 1,
rowsPerPage: 10
}
},
chartTimeRange: '30d',
dcaChart: null,
analyticsData: null,
chartLoading: false
}
},
methods: {
// -----------------------------------------------------------------
// Onboarding + preferences
// -----------------------------------------------------------------
async loadPreferences() {
// GET /preferences auto-creates the LP's dca_lp row with the
// authenticated wallet as the default DCA destination. This is
// the structural enforcement of the "LP must onboard before
// deposits work" gate on the operator side.
// Configuration Methods
async loadClientLimits() {
try {
const {data} = await LNbits.api.request(
const { data } = await LNbits.api.request(
'GET',
'/satmachineclient/api/v1/dca-client/preferences',
'/satmachineadmin/api/v1/dca/client-limits'
// No authentication required - public endpoint with safe data only
)
this.adminConfig = data
console.log('Client limits loaded:', this.adminConfig)
} catch (error) {
console.error('Error loading client limits:', error)
// Keep default values if client limits fail to load
}
},
// Registration Methods
async checkRegistrationStatus() {
try {
const { data } = await LNbits.api.request(
'GET',
'/satmachineclient/api/v1/registration-status',
this.g.user.wallets[0].adminkey
)
this.preferences = data
this.isRegistered = true
this.isRegistered = data.is_registered
this.registrationChecked = true
if (!this.isRegistered) {
// Fetch current user info to get the username
await this.loadCurrentUser()
this.registrationForm.selectedWallet = this.g.user.wallets[0]?.id || null
}
return data
} catch (error) {
console.error('Error loading preferences:', error)
this.error = 'Failed to load DCA preferences'
console.error('Error checking registration status:', error)
this.error = 'Failed to check registration status'
this.registrationChecked = true
}
},
// -----------------------------------------------------------------
// Formatting helpers
// -----------------------------------------------------------------
async loadCurrentUser() {
try {
const { data } = await LNbits.api.getAuthenticatedUser()
// Set username from API response with priority: display_name > username > email > fallback
const username = data.extra?.display_name || data.username || data.email
this.registrationForm.username = (username !== null && username !== undefined && username !== '')
? username
: `user_${this.g.user.id.substring(0, 8)}`
} catch (error) {
console.error('Error loading current user:', error)
// Fallback to generated username
this.registrationForm.username = `user_${this.g.user.id.substring(0, 8)}`
}
},
async registerClient() {
try {
// Prepare registration data using the form's username (already loaded from API)
const registrationData = {
dca_mode: this.registrationForm.dca_mode,
fixed_mode_daily_limit: this.registrationForm.fixed_mode_daily_limit,
username: this.registrationForm.username || `user_${this.g.user.id.substring(0, 8)}`
}
// Find the selected wallet object to get the adminkey
const selectedWallet = this.g.user.wallets.find(w => w.id === this.registrationForm.selectedWallet)
if (!selectedWallet) {
throw new Error('Selected wallet not found')
}
const { data } = await LNbits.api.request(
'POST',
'/satmachineclient/api/v1/register',
selectedWallet.adminkey,
registrationData
)
this.isRegistered = true
this.$q.notify({
type: 'positive',
message: data.message || 'Successfully registered for DCA!',
icon: 'check_circle',
position: 'top'
})
// Load dashboard data after successful registration
await this.loadDashboardData()
} catch (error) {
console.error('Error registering client:', error)
this.$q.notify({
type: 'negative',
message: error.detail || 'Failed to register for DCA',
position: 'top'
})
}
},
// Dashboard Methods
formatCurrency(amount) {
if (!amount) return 'Q 0.00'
if (!amount) return 'Q 0.00';
// Amount is already in GTQ
const gtqAmount = amount;
return new Intl.NumberFormat('es-GT', {
style: 'currency', currency: 'GTQ'
}).format(amount)
style: 'currency',
currency: 'GTQ',
}).format(gtqAmount);
},
formatCurrencyWithCode(amount, currencyCode) {
if (!amount) return `${currencyCode} 0.00`
if (!amount) return `${currencyCode} 0.00`;
// Amount is already in GTQ
const currencyAmount = amount;
try {
return new Intl.NumberFormat('en-US', {
style: 'currency', currency: currencyCode
}).format(amount)
style: 'currency',
currency: currencyCode,
}).format(currencyAmount);
} catch (error) {
return `${currencyCode} ${amount.toFixed(2)}`
// Fallback if currency code is not supported
return `${currencyCode} ${currencyAmount.toFixed(2)}`;
}
},
formatDate(dateString) {
if (!dateString) return ''
const date = new Date(dateString)
if (isNaN(date.getTime())) return 'Invalid Date'
if (isNaN(date.getTime())) {
console.warn('Invalid date string:', dateString)
return 'Invalid Date'
}
return date.toLocaleDateString()
},
formatTime(dateString) {
if (!dateString) return ''
const date = new Date(dateString)
if (isNaN(date.getTime())) return 'Invalid Time'
return date.toLocaleTimeString('en-US', {hour: '2-digit', minute: '2-digit'})
if (isNaN(date.getTime())) {
console.warn('Invalid time string:', dateString)
return 'Invalid Time'
}
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
})
},
formatSats(amount) {
if (!amount) return '0 sats'
const formatted = new Intl.NumberFormat('en-US').format(amount)
if (amount >= 100000000) return formatted + ' sats 🏆'
if (amount >= 50000000) return formatted + ' sats 🎆'
if (amount >= 10000000) return formatted + ' sats 👑'
if (amount >= 5000000) return formatted + ' sats 🏆'
if (amount >= 1000000) return formatted + ' sats 🌟'
if (amount >= 500000) return formatted + ' sats 🔥'
if (amount >= 100000) return formatted + ' sats 🚀'
if (amount >= 50000) return formatted + ' sats ⚡'
if (amount >= 10000) return formatted + ' sats 🎯'
// Add some excitement for larger amounts with consistent 5x→2x progression
if (amount >= 100000000) return formatted + ' sats 🏆' // Full coiner (1 BTC)
if (amount >= 50000000) return formatted + ' sats 🎆' // Bitcoin baron
if (amount >= 10000000) return formatted + ' sats 👑' // Bitcoin royalty
if (amount >= 5000000) return formatted + ' sats 🏆' // Verified bag holder
if (amount >= 1000000) return formatted + ' sats 🌟' // Millionaire
if (amount >= 500000) return formatted + ' sats 🔥' // Half million
if (amount >= 100000) return formatted + ' sats 🚀' // Getting serious
if (amount >= 50000) return formatted + ' sats ⚡' // Lightning quick
if (amount >= 10000) return formatted + ' sats 🎯' // First milestone
return formatted + ' sats'
},
// -----------------------------------------------------------------
// Dashboard data
// -----------------------------------------------------------------
async loadDashboardData() {
try {
const {data} = await LNbits.api.request(
const { data } = await LNbits.api.request(
'GET',
'/satmachineclient/api/v1/dca-client/positions',
'/satmachineclient/api/v1/dashboard/summary',
this.g.user.wallets[0].adminkey
)
// Backend returns ClientDashboardSummary with no dca_mode field
// at the top level any more (it's LP-wide and lives on
// preferences); echo `default_dca_mode` from prefs so the
// legacy template renderers (`dashboardData.dca_mode`) keep
// working until the template is rewritten.
data.dca_mode = this.preferences?.default_dca_mode || 'flow'
data.dca_status = 'active'
this.dashboardData = data
} catch (error) {
// 404 from /positions = "LP isn't enrolled at any machine yet",
// which is a valid state, not an error. Show an empty dashboard.
if (error?.response?.status === 404) {
this.dashboardData = {
user_id: this.g.user.id,
total_sats_accumulated: 0,
total_fiat_invested: 0,
current_fiat_balance: 0,
pending_fiat_deposits: 0,
average_cost_basis: 0,
current_sats_fiat_value: 0,
total_transactions: 0,
total_machines: 0,
last_transaction_date: null,
currency: this.adminConfig.currency,
positions: [],
dca_mode: this.preferences?.default_dca_mode || 'flow',
dca_status: 'awaiting_enrolment'
}
} else {
console.error('Error loading dashboard data:', error)
this.error = 'Failed to load dashboard data'
}
console.error('Error loading dashboard data:', error)
this.error = 'Failed to load dashboard data'
}
},
async loadTransactions() {
try {
const {data} = await LNbits.api.request(
const { data } = await LNbits.api.request(
'GET',
'/satmachineclient/api/v1/dca-client/transactions?limit=50',
'/satmachineclient/api/v1/dashboard/transactions?limit=50',
this.g.user.wallets[0].adminkey
)
// Debug: Log the first transaction to see date format
if (data.length > 0) {
console.log('Sample transaction data:', data[0])
console.log('transaction_time:', data[0].transaction_time)
console.log('created_at:', data[0].created_at)
}
// Sort by most recent first and store
this.transactions = data.sort((a, b) => {
const dateA = new Date(a.transaction_time || a.created_at)
const dateB = new Date(b.transaction_time || b.created_at)
return dateB - dateA
return dateB - dateA // Most recent first
})
} catch (error) {
console.error('Error loading transactions:', error)
@ -219,7 +294,6 @@ window.app = Vue.createApp({
try {
this.loading = true
await Promise.all([
this.loadPreferences(),
this.loadDashboardData(),
this.loadTransactions()
])
@ -241,67 +315,502 @@ window.app = Vue.createApp({
}
},
// -----------------------------------------------------------------
// Milestone widget (purely cosmetic)
// -----------------------------------------------------------------
getNextMilestone() {
if (!this.dashboardData) return {target: 10000, name: '10k sats'}
if (!this.dashboardData) return { target: 10000, name: '10k sats' }
const sats = this.dashboardData.total_sats_accumulated
if (sats < 10000) return {target: 10000, name: '10k sats'}
if (sats < 50000) return {target: 50000, name: '50k sats'}
if (sats < 100000) return {target: 100000, name: '100k sats'}
if (sats < 500000) return {target: 500000, name: '500k sats'}
if (sats < 1000000) return {target: 1000000, name: '1M sats'}
if (sats < 5000000) return {target: 5000000, name: '5M sats'}
if (sats < 10000000) return {target: 10000000, name: '10M sats'}
if (sats < 50000000) return {target: 50000000, name: '50M sats'}
if (sats < 100000000) return {target: 100000000, name: '100M sats (1 BTC!)'}
return {target: 500000000, name: '500M sats (5 BTC)'}
// Consistent 5x→2x progression pattern
if (sats < 10000) return { target: 10000, name: '10k sats' }
if (sats < 50000) return { target: 50000, name: '50k sats' }
if (sats < 100000) return { target: 100000, name: '100k sats' }
if (sats < 500000) return { target: 500000, name: '500k sats' }
if (sats < 1000000) return { target: 1000000, name: '1M sats' }
if (sats < 5000000) return { target: 5000000, name: '5M sats' }
if (sats < 10000000) return { target: 10000000, name: '10M sats' }
if (sats < 50000000) return { target: 50000000, name: '50M sats' }
if (sats < 100000000) return { target: 100000000, name: '100M sats (1 BTC!)' }
return { target: 500000000, name: '500M sats (5 BTC)' }
},
getMilestoneProgress() {
if (!this.dashboardData) return 0
if (!this.dashboardData) {
console.log('getMilestoneProgress: no dashboard data')
return 0
}
const sats = this.dashboardData.total_sats_accumulated
const milestone = this.getNextMilestone()
// Show total progress toward the next milestone (from 0)
const progress = (sats / milestone.target) * 100
return Math.min(Math.max(progress, 0), 100)
const result = Math.min(Math.max(progress, 0), 100)
console.log('getMilestoneProgress:', { sats, milestone, progress, result })
return result
},
async loadChartData() {
// Prevent multiple simultaneous requests
if (this.chartLoading) {
console.log('Chart already loading, ignoring request')
return
}
try {
this.chartLoading = true
// Destroy existing chart immediately to prevent conflicts
if (this.dcaChart) {
console.log('Destroying existing chart before loading new data')
this.dcaChart.destroy()
this.dcaChart = null
}
const { data } = await LNbits.api.request(
'GET',
`/satmachineclient/api/v1/dashboard/analytics?time_range=${this.chartTimeRange}`,
this.g.user.wallets[0].adminkey
)
// Debug: Log analytics data
console.log('Analytics data received:', data)
if (data && data.cost_basis_history && data.cost_basis_history.length > 0) {
console.log('Sample cost basis point:', data.cost_basis_history[0])
}
this.analyticsData = data
// Wait for DOM update and ensure we're still in loading state
await this.$nextTick()
// Double-check we're still the active loading request
if (this.chartLoading) {
this.initDCAChart()
} else {
console.log('Chart loading was cancelled, skipping initialization')
this.chartLoading = false
}
} catch (error) {
console.error('Error loading chart data:', error)
this.chartLoading = false
}
},
// -----------------------------------------------------------------
// Stubs for the legacy registration wizard + chart panel.
// Those template branches are dead (the wizard never shows because
// isRegistered is true after auto-onboard; the chart panel has no
// backend analytics endpoint to feed it) but the .html still
// references these handlers. Keep stubs so a stray click doesn't
// throw an uncaught error.
// -----------------------------------------------------------------
async registerClient() {
// Old "register / pick wallet & mode" form is gone. Preferences
// are auto-created and editable via a separate path (TODO: add
// an editor card to this dashboard once the template gets a
// proper rewrite).
await this.loadPreferences()
this.$q.notify({
type: 'info',
message: 'Your DCA account is already set up — refreshed.',
position: 'top'
initDCAChart() {
console.log('initDCAChart called')
console.log('analyticsData:', this.analyticsData)
console.log('dcaChart ref:', this.$refs.dcaChart)
console.log('chartLoading state:', this.chartLoading)
// Skip if we're not in a loading state (indicates this is a stale call)
if (!this.chartLoading && this.dcaChart) {
console.log('Chart already exists and not loading, skipping initialization')
return
}
if (!this.analyticsData) {
console.log('No analytics data available')
return
}
if (!this.$refs.dcaChart) {
console.log('No chart ref available, waiting for DOM...')
// Try again after DOM update, but only if still loading
this.$nextTick(() => {
if (this.$refs.dcaChart && this.chartLoading) {
this.initDCAChart()
}
})
return
}
// Check if Chart.js is loaded
if (typeof Chart === 'undefined') {
console.error('Chart.js is not loaded')
return
}
console.log('Chart.js version:', Chart.version || 'unknown')
console.log('Chart.js available:', typeof Chart)
// Destroy existing chart (redundant safety check)
if (this.dcaChart) {
console.log('Destroying existing chart in initDCAChart')
this.dcaChart.destroy()
this.dcaChart = null
}
const ctx = this.$refs.dcaChart.getContext('2d')
// Use accumulation_timeline data which is already grouped by day
const timelineData = this.analyticsData.accumulation_timeline || []
console.log('Timeline data sample:', timelineData.slice(0, 2)) // Debug first 2 records
// If we have timeline data, use it (already grouped by day)
if (timelineData.length > 0) {
// Calculate running totals from daily data
let runningSats = 0
const labels = []
const cumulativeSats = []
timelineData.forEach(point => {
// Ensure sats is a valid number
const sats = point.sats || 0
const validSats = typeof sats === 'number' ? sats : parseFloat(sats) || 0
runningSats += validSats
const date = new Date(point.date)
if (!isNaN(date.getTime())) {
labels.push(date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
}))
cumulativeSats.push(runningSats)
}
})
console.log('Timeline chart data:', { labels, cumulativeSats })
this.createChart(labels, cumulativeSats)
return
}
// Fallback to cost_basis_history but group by date to avoid duplicates
console.log('No timeline data, using cost_basis_history as fallback')
const chartData = this.analyticsData.cost_basis_history || []
console.log('Chart data sample:', chartData.slice(0, 2)) // Debug first 2 records
// Handle empty data case
if (chartData.length === 0) {
console.log('No chart data available')
// Create gradient for placeholder chart
const placeholderGradient = ctx.createLinearGradient(0, 0, 0, 300)
placeholderGradient.addColorStop(0, 'rgba(255, 149, 0, 0.3)')
placeholderGradient.addColorStop(1, 'rgba(255, 149, 0, 0.05)')
// Show placeholder chart with enhanced styling
this.dcaChart = new Chart(ctx, {
type: 'line',
data: {
labels: ['Start Your DCA Journey'],
datasets: [{
label: 'Total Sats Accumulated',
data: [0],
borderColor: '#FF9500',
backgroundColor: placeholderGradient,
borderWidth: 3,
fill: true,
tension: 0.4,
pointRadius: 8,
pointBackgroundColor: '#FFFFFF',
pointBorderColor: '#FF9500',
pointBorderWidth: 3,
pointHoverRadius: 10
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#FFFFFF',
bodyColor: '#FFFFFF',
borderColor: '#FF9500',
borderWidth: 2,
cornerRadius: 8,
callbacks: {
label: function (context) {
return `${context.parsed.y.toLocaleString()} sats`
}
}
}
},
scales: {
x: {
grid: { display: false },
ticks: {
color: '#666666',
font: { size: 12, weight: '500' }
}
},
y: {
beginAtZero: true,
grid: {
color: 'rgba(255, 149, 0, 0.1)',
drawBorder: false
},
ticks: {
color: '#666666',
font: { size: 12, weight: '500' },
callback: function (value) {
return value.toLocaleString() + ' sats'
}
}
}
}
}
})
// Clear loading state after creating placeholder chart
this.chartLoading = false
return
}
// Group cost_basis_history by date to eliminate duplicates
const groupedData = new Map()
chartData.forEach(point => {
const dateStr = new Date(point.date).toDateString()
if (!groupedData.has(dateStr)) {
groupedData.set(dateStr, point)
} else {
// Use the latest cumulative values for the same date
const existing = groupedData.get(dateStr)
if (point.cumulative_sats > existing.cumulative_sats) {
groupedData.set(dateStr, point)
}
}
})
const uniqueChartData = Array.from(groupedData.values()).sort((a, b) =>
new Date(a.date).getTime() - new Date(b.date).getTime()
)
const labels = uniqueChartData.map(point => {
// Handle different date formats with enhanced timezone handling
let date;
if (point.date) {
console.log('Raw date from API:', point.date); // Debug the actual date string
// If it's an ISO string with timezone info, parse it correctly
if (typeof point.date === 'string' && point.date.includes('T')) {
// ISO string - parse and convert to local date
date = new Date(point.date);
// For display purposes, use the date part only to avoid timezone shifts
const localDateStr = date.getFullYear() + '-' +
String(date.getMonth() + 1).padStart(2, '0') + '-' +
String(date.getDate()).padStart(2, '0');
date = new Date(localDateStr + 'T00:00:00'); // Force local midnight
} else {
date = new Date(point.date);
}
// Check if date is valid
if (isNaN(date.getTime())) {
date = new Date();
}
} else {
date = new Date();
}
console.log('Formatted date:', date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }));
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
})
})
const cumulativeSats = uniqueChartData.map(point => {
// Ensure cumulative_sats is a valid number
const sats = point.cumulative_sats || 0
return typeof sats === 'number' ? sats : parseFloat(sats) || 0
})
console.log('Final chart data:', { labels, cumulativeSats })
console.log('Labels array:', labels)
console.log('CumulativeSats array:', cumulativeSats)
// Validate data before creating chart
if (labels.length === 0 || cumulativeSats.length === 0) {
console.warn('No valid data for chart, skipping creation')
return
}
if (labels.length !== cumulativeSats.length) {
console.warn('Mismatched data arrays:', { labelsLength: labels.length, dataLength: cumulativeSats.length })
return
}
// Check for any invalid values in cumulativeSats
const hasInvalidValues = cumulativeSats.some(val => val === null || val === undefined || isNaN(val))
if (hasInvalidValues) {
console.warn('Invalid values found in cumulative sats:', cumulativeSats)
return
}
this.createChart(labels, cumulativeSats)
},
loadChartData() {
// No backend analytics endpoint; chart panel is dead.
createChart(labels, cumulativeSats) {
console.log('createChart called with loading state:', this.chartLoading)
if (!this.$refs.dcaChart) {
console.log('Chart ref not available for createChart')
return
}
// Skip if we're not in a loading state (indicates this is a stale call)
if (!this.chartLoading) {
console.log('Not in loading state, skipping createChart')
return
}
// Destroy existing chart
if (this.dcaChart) {
console.log('Destroying existing chart in createChart')
this.dcaChart.destroy()
this.dcaChart = null
}
const ctx = this.$refs.dcaChart.getContext('2d')
try {
// Create gradient for the area fill
const gradient = ctx.createLinearGradient(0, 0, 0, 300)
gradient.addColorStop(0, 'rgba(255, 149, 0, 0.4)')
gradient.addColorStop(0.5, 'rgba(255, 149, 0, 0.2)')
gradient.addColorStop(1, 'rgba(255, 149, 0, 0.05)')
// Small delay to ensure Chart.js is fully initialized
setTimeout(() => {
try {
// Final check to ensure we're still in the correct loading state
if (!this.chartLoading) {
console.log('Loading state changed during timeout, aborting chart creation')
return
}
this.dcaChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Total Sats Accumulated',
data: cumulativeSats,
borderColor: '#FF9500',
backgroundColor: gradient,
borderWidth: 3,
fill: true,
tension: 0.4,
pointBackgroundColor: '#FFFFFF',
pointBorderColor: '#FF9500',
pointBorderWidth: 3,
pointRadius: 6,
pointHoverRadius: 8,
pointHoverBackgroundColor: '#FFFFFF',
pointHoverBorderColor: '#FF7700',
pointHoverBorderWidth: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
mode: 'index',
intersect: false,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#FFFFFF',
bodyColor: '#FFFFFF',
borderColor: '#FF9500',
borderWidth: 2,
cornerRadius: 8,
displayColors: false,
callbacks: {
title: function (context) {
return `📅 ${context[0].label}`
},
label: function (context) {
return `${context.parsed.y.toLocaleString()} sats accumulated`
}
}
}
},
scales: {
x: {
display: true,
grid: {
display: false
},
ticks: {
color: '#666666',
font: {
size: 12,
weight: '500'
}
}
},
y: {
display: true,
beginAtZero: true,
grid: {
color: 'rgba(255, 149, 0, 0.1)',
drawBorder: false
},
ticks: {
color: '#666666',
font: {
size: 12,
weight: '500'
},
callback: function (value) {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + 'M sats'
} else if (value >= 1000) {
return (value / 1000).toFixed(0) + 'k sats'
}
return value.toLocaleString() + ' sats'
}
}
}
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
},
elements: {
point: {
hoverRadius: 8
}
}
}
})
console.log('Chart created successfully in createChart!')
// Chart is now created, clear loading state
this.chartLoading = false
} catch (error) {
console.error('Error in createChart setTimeout:', error)
this.chartLoading = false
}
}, 50)
} catch (error) {
console.error('Error creating Chart.js chart in createChart:', error)
console.log('Chart data that failed:', { labels, cumulativeSats })
// Clear loading state on error
this.chartLoading = false
}
}
},
async created() {
try {
this.loading = true
// Auto-onboard on load (creates dca_lp row if missing).
await this.loadPreferences()
// Then load dashboard + transactions.
await Promise.all([
this.loadDashboardData(),
this.loadTransactions()
])
// Load client limits first
await this.loadClientLimits()
// Check registration status
await this.checkRegistrationStatus()
// Only load dashboard data if registered
if (this.isRegistered) {
await Promise.all([
this.loadDashboardData(),
this.loadTransactions(),
this.loadChartData()
])
}
} catch (error) {
console.error('Error initializing dashboard:', error)
this.error = 'Failed to initialize dashboard'
@ -310,6 +819,23 @@ window.app = Vue.createApp({
}
},
mounted() {
// Initialize chart after DOM is ready and data is loaded
this.$nextTick(() => {
console.log('Component mounted, checking for chart initialization')
console.log('Loading state:', this.loading)
console.log('Chart ref available:', !!this.$refs.dcaChart)
console.log('Analytics data available:', !!this.analyticsData)
if (this.analyticsData && this.$refs.dcaChart) {
console.log('Initializing chart from mounted hook')
this.initDCAChart()
} else {
console.log('Chart will initialize after data loads')
}
})
},
computed: {
hasData() {
return this.dashboardData && !this.loading && this.isRegistered
@ -322,5 +848,23 @@ window.app = Vue.createApp({
value: wallet.id
}))
}
},
watch: {
analyticsData: {
handler(newData) {
if (newData && !this.chartLoading && !this.dcaChart) {
console.log('Analytics data changed and no chart exists, initializing chart...')
this.$nextTick(() => {
// Only initialize if we don't have a chart and aren't currently loading
if (!this.dcaChart && !this.chartLoading) {
this.chartLoading = true
this.initDCAChart()
}
})
}
},
immediate: false
}
}
})

2
tasks.py Normal file
View file

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

2
transaction_processor.py Normal file
View file

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

View file

@ -1,109 +1,220 @@
# Satoshi Machine Client v2 — API.
#
# Read-mostly LP surface over the admin extension's v2 schema. Auth via
# wallet admin key (LP must be an LNbits user; the admin key identifies
# them as the wallet's owner).
#
# This extension owns writes to `satoshimachine.dca_lp` — the LP's
# per-user preferences row (DCA wallet, mode, autoforward). Reads on
# any endpoint auto-init the row using the authenticated wallet as the
# default DCA destination, which is the act that satisfies the
# operator-side "must onboard before deposits accepted" gate.
#
# Maintenance-mode note: richer LP UI is moving to ~/dev/webapp. Keep
# this surface stable + minimal.
# Description: Client-focused API endpoints for DCA dashboard
from http import HTTPStatus
from typing import List, Optional
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, Query
from lnbits.core.models import WalletTypeInfo
from lnbits.decorators import require_admin_key
from starlette.exceptions import HTTPException
from .crud import (
ensure_lp_preferences,
get_client_dashboard_summary,
get_client_transactions,
update_lp_preferences,
get_client_analytics,
update_client_dca_settings,
get_client_by_user_id,
register_dca_client,
)
from .models import (
ClientDashboardSummary,
ClientTransaction,
LpPreferences,
UpdateLpPreferences,
ClientAnalytics,
UpdateClientSettings,
ClientRegistrationData,
)
satmachineclient_api_router = APIRouter()
@satmachineclient_api_router.get(
"/api/v1/dca-client/preferences", response_model=LpPreferences
)
async def api_get_preferences(
###################################################
############## CLIENT REGISTRATION ###############
###################################################
@satmachineclient_api_router.post("/api/v1/register", status_code=HTTPStatus.CREATED)
async def api_register_client(
registration_data: ClientRegistrationData,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> LpPreferences:
"""Return the LP's DCA preferences. Auto-creates the `dca_lp` row on
first call, seeded with the authenticated wallet as the default DCA
destination. The act of hitting this endpoint is what marks the LP
as "onboarded" on the operator side."""
return await ensure_lp_preferences(
wallet.wallet.user, default_wallet_id=wallet.wallet.id
) -> dict:
"""Register a new DCA client
Clients can self-register using their wallet admin key.
Creates a new client entry in the satoshimachine database.
"""
result = await register_dca_client(
wallet.wallet.user,
wallet.wallet.id,
registration_data
)
if "error" in result:
if "already registered" in result["error"]:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail=result["error"]
)
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=result["error"]
)
@satmachineclient_api_router.put(
"/api/v1/dca-client/preferences", response_model=LpPreferences
)
async def api_update_preferences(
data: UpdateLpPreferences,
return result
@satmachineclient_api_router.get("/api/v1/registration-status")
async def api_check_registration_status(
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> LpPreferences:
"""LP-side update of DCA wallet / mode / autoforward. Operator can't
reach this path it requires the LP's wallet admin key."""
# Ensure the row exists before update so this endpoint is safe to
# call even if the LP somehow hits PUT before GET (they shouldn't,
# but the dashboard and any caller order shouldn't matter).
await ensure_lp_preferences(wallet.wallet.user, default_wallet_id=wallet.wallet.id)
updated = await update_lp_preferences(wallet.wallet.user, data)
assert updated is not None
return updated
) -> dict:
"""Check if user is already registered as a DCA client"""
client = await get_client_by_user_id(wallet.wallet.user)
return {
"is_registered": client is not None,
"client_id": client["id"] if client else None,
"dca_mode": client["dca_mode"] if client else None,
"status": client["status"] if client else None,
}
@satmachineclient_api_router.get(
"/api/v1/dca-client/positions", response_model=ClientDashboardSummary
)
async def api_get_positions(
###################################################
############## CLIENT DASHBOARD API ###############
###################################################
@satmachineclient_api_router.get("/api/v1/dashboard/summary")
async def api_get_dashboard_summary(
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> ClientDashboardSummary:
"""LP's aggregated dashboard across every machine they're registered at,
plus a per-machine breakdown in the `positions` field.
Also auto-creates the LP's `dca_lp` row on first access (so opening
the dashboard is itself the onboarding gesture)."""
user_id = wallet.wallet.user
await ensure_lp_preferences(user_id, default_wallet_id=wallet.wallet.id)
summary = await get_client_dashboard_summary(user_id)
if summary is None:
"""Get client dashboard summary metrics"""
summary = await get_client_dashboard_summary(wallet.wallet.user)
if not summary:
raise HTTPException(
HTTPStatus.NOT_FOUND,
"No DCA positions found for this wallet's owner. "
"An operator must register you at a machine first.",
status_code=HTTPStatus.NOT_FOUND,
detail="Client data not found"
)
return summary
@satmachineclient_api_router.get(
"/api/v1/dca-client/transactions", response_model=List[ClientTransaction]
)
async def api_get_transactions(
limit: int = 50,
offset: int = 0,
machine_id: Optional[str] = None,
@satmachineclient_api_router.get("/api/v1/dashboard/transactions")
async def api_get_client_transactions(
wallet: WalletTypeInfo = Depends(require_admin_key),
limit: int = Query(50, ge=1, le=1000),
offset: int = Query(0, ge=0),
transaction_type: Optional[str] = Query(None),
start_date: Optional[datetime] = Query(None),
end_date: Optional[datetime] = Query(None),
) -> List[ClientTransaction]:
"""LP's distribution history. Returns only legs the LP can see (DCA,
balance-settle, auto-forward). Optionally filter by ?machine_id=X."""
user_id = wallet.wallet.user
"""Get client's DCA transaction history with filtering"""
return await get_client_transactions(
user_id, limit=limit, offset=offset, machine_id=machine_id
wallet.wallet.user,
limit=limit,
offset=offset,
transaction_type=transaction_type,
start_date=start_date,
end_date=end_date
)
@satmachineclient_api_router.get("/api/v1/dashboard/analytics")
async def api_get_client_analytics(
wallet: WalletTypeInfo = Depends(require_admin_key),
time_range: str = Query("30d", regex="^(7d|30d|90d|1y|all)$"),
) -> ClientAnalytics:
"""Get client performance analytics and cost basis data"""
try:
analytics = await get_client_analytics(wallet.wallet.user, time_range)
if not analytics:
# Return empty analytics data instead of error
return ClientAnalytics(
user_id=wallet.wallet.user,
cost_basis_history=[],
accumulation_timeline=[],
transaction_frequency={}
)
return analytics
except Exception as e:
print(f"Analytics error: {e}")
# Return empty analytics data as fallback
return ClientAnalytics(
user_id=wallet.wallet.user,
cost_basis_history=[],
accumulation_timeline=[],
transaction_frequency={}
)
@satmachineclient_api_router.put("/api/v1/dashboard/settings")
async def api_update_client_settings(
settings: UpdateClientSettings,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> dict:
"""Update client DCA settings (mode, limits, status)
Security: Users can only modify their own DCA settings.
Validated by user_id lookup from wallet.wallet.user.
"""
client = await get_client_by_user_id(wallet.wallet.user)
if not client:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Client profile not found"
)
success = await update_client_dca_settings(client.id, settings)
if not success:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Failed to update settings"
)
return {"message": "Settings updated successfully"}
@satmachineclient_api_router.get("/api/v1/dashboard/export/transactions")
async def api_export_transactions(
wallet: WalletTypeInfo = Depends(require_admin_key),
format: str = Query("csv", regex="^(csv|json)$"),
start_date: Optional[datetime] = Query(None),
end_date: Optional[datetime] = Query(None),
):
"""Export client transaction history"""
transactions = await get_client_transactions(
wallet.wallet.user,
limit=10000, # Large limit for export
start_date=start_date,
end_date=end_date
)
if format == "csv":
# Return CSV response
from io import StringIO
import csv
output = StringIO()
writer = csv.writer(output)
writer.writerow(['Date', 'Amount (Sats)', 'Amount (Fiat)', 'Exchange Rate', 'Type', 'Status'])
for tx in transactions:
writer.writerow([
tx.created_at.isoformat(),
tx.amount_sats,
tx.amount_fiat, # Amount already in GTQ
tx.exchange_rate,
tx.transaction_type,
tx.status
])
from fastapi.responses import StreamingResponse
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=dca_transactions.csv"}
)
else:
return {"transactions": transactions}
# Removed local client-limits endpoint
# Client should call admin extension's public endpoint directly