Compare commits
No commits in common. "v2-bitspire" and "main" have entirely different histories.
v2-bitspir
...
main
9 changed files with 1433 additions and 540 deletions
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -1,6 +1,4 @@
|
||||||
# LNbits runtime data — auth keys, DB files, etc. Never commit.
|
__pycache__
|
||||||
data/
|
node_modules
|
||||||
*.sqlite3
|
.mypy_cache
|
||||||
*.sqlite3-journal
|
.venv
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
# DCA Client Extension for LNBits
|
# DCA Client Extension for LNBits
|
||||||
|
|
||||||
> **Status: maintenance-mode (v2).** This extension provides a minimal LP-facing
|
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.
|
||||||
> read view over the v2 satmachineadmin schema. The richer LP UI is migrating to
|
|
||||||
> `~/dev/webapp`. Keep this surface stable; new LP features should land in webapp.
|
|
||||||
|
|
||||||
A Dollar Cost Averaging (DCA) administration extension for LNBits that integrates with bitSpire ATMs (formerly Lamassu) to automatically distribute Bitcoin to registered liquidity providers (LPs) based on their deposit balances.
|
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|
|
||||||
704
crud.py
704
crud.py
|
|
@ -1,277 +1,521 @@
|
||||||
# Satoshi Machine Client v2 — CRUD over admin schema.
|
# Description: Client extension CRUD operations - reads from admin extension database
|
||||||
#
|
|
||||||
# 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).
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from lnbits.db import Database
|
from lnbits.db import Database
|
||||||
from lnbits.utils.exchange_rates import satoshis_amount_as_fiat
|
from lnbits.utils.exchange_rates import satoshis_amount_as_fiat
|
||||||
from loguru import logger
|
from lnbits.core.crud.wallets import get_wallet
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
ClientDashboardSummary,
|
ClientDashboardSummary,
|
||||||
ClientTransaction,
|
ClientTransaction,
|
||||||
LpPreferences,
|
ClientAnalytics,
|
||||||
PerMachinePosition,
|
UpdateClientSettings,
|
||||||
UpdateLpPreferences,
|
ClientRegistrationData,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Same DB schema as the admin extension — we share satoshimachine.* tables.
|
# Connect to admin extension's database
|
||||||
db = Database("ext_satoshimachine")
|
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
|
############## CLIENT DASHBOARD CRUD ##############
|
||||||
per-machine display metadata.
|
###################################################
|
||||||
|
|
||||||
`dca_mode` lives on the LP's per-user `dca_lp` row now, not per
|
async def get_client_dashboard_summary(user_id: str) -> Optional[ClientDashboardSummary]:
|
||||||
enrolment — INNER JOIN dca_lp so positions only return when the LP
|
"""Get dashboard summary for a specific user"""
|
||||||
has actually onboarded (otherwise distribution can't pay them either,
|
|
||||||
so listing them in the dashboard would be misleading).
|
# Get client info
|
||||||
"""
|
client = await db.fetchone(
|
||||||
return await db.fetchall(
|
"SELECT * FROM satoshimachine.dca_clients WHERE user_id = :user_id",
|
||||||
"""
|
{"user_id": user_id}
|
||||||
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},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not client:
|
||||||
async def _position_for_client(client_row: dict) -> PerMachinePosition:
|
|
||||||
cid = client_row["client_id"]
|
|
||||||
confirmed_deposits = await db.fetchone(
|
|
||||||
"""
|
|
||||||
SELECT COALESCE(SUM(amount), 0) AS total
|
|
||||||
FROM satoshimachine.dca_deposits
|
|
||||||
WHERE client_id = :cid AND status = 'confirmed'
|
|
||||||
""",
|
|
||||||
{"cid": cid},
|
|
||||||
)
|
|
||||||
# DCA + settlement legs both count against the LP's balance.
|
|
||||||
distributed = await db.fetchone(
|
|
||||||
"""
|
|
||||||
SELECT COALESCE(SUM(amount_fiat), 0) AS total_fiat,
|
|
||||||
COALESCE(SUM(amount_sats), 0) AS total_sats,
|
|
||||||
COUNT(*) AS tx_count,
|
|
||||||
MAX(created_at) AS last_tx
|
|
||||||
FROM satoshimachine.dca_payments
|
|
||||||
WHERE client_id = :cid
|
|
||||||
AND leg_type IN ('dca', 'settlement')
|
|
||||||
AND status = 'completed'
|
|
||||||
""",
|
|
||||||
{"cid": cid},
|
|
||||||
)
|
|
||||||
total_deposits = float(confirmed_deposits["total"]) if confirmed_deposits else 0.0
|
|
||||||
total_fiat = float(distributed["total_fiat"]) if distributed else 0.0
|
|
||||||
total_sats = int(distributed["total_sats"]) if distributed else 0
|
|
||||||
tx_count = int(distributed["tx_count"]) if distributed else 0
|
|
||||||
last_tx = distributed["last_tx"] if distributed else None
|
|
||||||
return PerMachinePosition(
|
|
||||||
machine_id=client_row["machine_id"],
|
|
||||||
machine_npub=client_row["machine_npub"],
|
|
||||||
machine_name=client_row["machine_name"],
|
|
||||||
machine_location=client_row["machine_location"],
|
|
||||||
currency=client_row["machine_fiat_code"],
|
|
||||||
dca_mode=client_row["dca_mode"],
|
|
||||||
status=client_row["status"],
|
|
||||||
total_sats_accumulated=total_sats,
|
|
||||||
total_fiat_invested=round(total_deposits, 2),
|
|
||||||
current_fiat_balance=round(total_deposits - total_fiat, 2),
|
|
||||||
total_transactions=tx_count,
|
|
||||||
last_transaction_date=last_tx,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_client_dashboard_summary(
|
|
||||||
user_id: str,
|
|
||||||
) -> Optional[ClientDashboardSummary]:
|
|
||||||
"""Aggregated LP dashboard. Returns None if the LP has no positions."""
|
|
||||||
clients = await _fetch_user_clients(user_id)
|
|
||||||
if not clients:
|
|
||||||
return None
|
return None
|
||||||
positions = [await _position_for_client(c) for c in clients]
|
|
||||||
total_sats = sum(p.total_sats_accumulated for p in positions)
|
# Get wallet to determine currency
|
||||||
total_invested = round(sum(p.total_fiat_invested for p in positions), 2)
|
wallet = await get_wallet(client["wallet_id"])
|
||||||
total_balance = round(sum(p.current_fiat_balance for p in positions), 2)
|
# TODO: Get currency from wallet; bit more difficult to do in a different
|
||||||
total_tx = sum(p.total_transactions for p in positions)
|
# currency than deposit cause of cross exchange rates
|
||||||
last_tx = max(
|
# currency = wallet.currency or "GTQ" # Default to GTQ if no currency set
|
||||||
(p.last_transaction_date for p in positions if p.last_transaction_date),
|
currency = "GTQ" # Default to GTQ if no currency set
|
||||||
default=None,
|
|
||||||
)
|
# Get total sats accumulated from DCA transactions
|
||||||
# Pending deposits (across all clients of this LP).
|
sats_result = await db.fetchone(
|
||||||
pending = await db.fetchone(
|
|
||||||
"""
|
"""
|
||||||
SELECT COALESCE(SUM(d.amount), 0) AS total
|
SELECT COALESCE(SUM(amount_sats), 0) as total_sats
|
||||||
FROM satoshimachine.dca_deposits d
|
FROM satoshimachine.dca_payments
|
||||||
JOIN satoshimachine.dca_clients c ON c.id = d.client_id
|
WHERE client_id = :client_id AND status = 'confirmed'
|
||||||
WHERE c.user_id = :uid AND d.status = 'pending'
|
|
||||||
""",
|
""",
|
||||||
{"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
|
# Get total confirmed deposits (this is the "total invested")
|
||||||
# less remaining balance) so we get the cost basis on what's actually
|
deposits_result = await db.fetchone(
|
||||||
# been DCA'd, not the gross deposits.
|
"""
|
||||||
fiat_spent = max(total_invested - total_balance, 0.0)
|
SELECT COALESCE(SUM(amount), 0) as confirmed_deposits
|
||||||
cost_basis = total_sats / fiat_spent if fiat_spent > 0 else 0.0
|
FROM satoshimachine.dca_deposits
|
||||||
# Display currency: if all positions share one, use it; else "MIX".
|
WHERE client_id = :client_id AND status = 'confirmed'
|
||||||
currencies = {p.currency for p in positions}
|
""",
|
||||||
currency = currencies.pop() if len(currencies) == 1 else "MIX"
|
{"client_id": client["id"]}
|
||||||
# Best-effort current fiat value of the LP's sats stack.
|
)
|
||||||
current_value = 0.0
|
|
||||||
if total_sats > 0 and currency != "MIX":
|
# 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:
|
try:
|
||||||
current_value = await satoshis_amount_as_fiat(total_sats, currency)
|
current_sats_fiat_value = await satoshis_amount_as_fiat(total_sats, currency)
|
||||||
except Exception as exc:
|
except Exception as e:
|
||||||
logger.warning(
|
print(f"Warning: Could not fetch exchange rate for {currency}: {e}")
|
||||||
f"satmachineclient: could not fetch exchange rate for "
|
current_sats_fiat_value = 0.0
|
||||||
f"{currency}: {exc}"
|
|
||||||
)
|
|
||||||
return ClientDashboardSummary(
|
return ClientDashboardSummary(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
total_sats_accumulated=total_sats,
|
total_sats_accumulated=total_sats,
|
||||||
total_fiat_invested=total_invested,
|
total_fiat_invested=total_invested, # Sum of confirmed deposits
|
||||||
current_fiat_balance=total_balance,
|
pending_fiat_deposits=pending_deposits, # Sum of pending deposits
|
||||||
pending_fiat_deposits=round(pending_fiat, 2),
|
current_sats_fiat_value=current_sats_fiat_value, # Current fiat value of sats
|
||||||
average_cost_basis=round(cost_basis, 4),
|
average_cost_basis=avg_cost_basis,
|
||||||
current_sats_fiat_value=round(current_value, 2),
|
current_fiat_balance=remaining_balance, # Confirmed deposits - DCA spent
|
||||||
total_transactions=total_tx,
|
total_transactions=tx_stats["tx_count"] if tx_stats else 0,
|
||||||
total_machines=len(positions),
|
dca_mode=client["dca_mode"],
|
||||||
last_transaction_date=last_tx,
|
dca_status=client["status"],
|
||||||
currency=currency,
|
last_transaction_date=tx_stats["last_tx_date"] if tx_stats else None,
|
||||||
positions=positions,
|
currency=currency # Wallet's currency
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_client_transactions(
|
async def get_client_transactions(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
offset: int = 0,
|
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]:
|
) -> List[ClientTransaction]:
|
||||||
"""LP's transaction history. Filters to 'dca' / 'settlement' /
|
"""Get client's transaction history with filtering"""
|
||||||
'autoforward' legs since those are the ones the LP cares about — super_fee
|
|
||||||
and operator_split legs are operator-internal.
|
# Get client ID first
|
||||||
|
client = await db.fetchone(
|
||||||
Optional machine_id narrows to a single machine."""
|
"SELECT id FROM satoshimachine.dca_clients WHERE user_id = :user_id",
|
||||||
params: dict = {
|
{"user_id": user_id}
|
||||||
"uid": user_id,
|
|
||||||
"lim": limit,
|
|
||||||
"off": offset,
|
|
||||||
}
|
|
||||||
where = (
|
|
||||||
"WHERE c.user_id = :uid "
|
|
||||||
"AND p.leg_type IN ('dca', 'settlement', 'autoforward')"
|
|
||||||
)
|
)
|
||||||
if machine_id is not None:
|
|
||||||
where += " AND p.machine_id = :mid"
|
if not client:
|
||||||
params["mid"] = machine_id
|
return []
|
||||||
rows = await db.fetchall(
|
|
||||||
|
# Build query with filters
|
||||||
|
where_conditions = ["client_id = :client_id"]
|
||||||
|
params = {"client_id": client["id"], "limit": limit, "offset": offset}
|
||||||
|
|
||||||
|
if transaction_type:
|
||||||
|
where_conditions.append("transaction_type = :transaction_type")
|
||||||
|
params["transaction_type"] = transaction_type
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
where_conditions.append("created_at >= :start_date")
|
||||||
|
params["start_date"] = start_date
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
where_conditions.append("created_at <= :end_date")
|
||||||
|
params["end_date"] = end_date
|
||||||
|
|
||||||
|
where_clause = " AND ".join(where_conditions)
|
||||||
|
|
||||||
|
transactions = await db.fetchall(
|
||||||
f"""
|
f"""
|
||||||
SELECT p.id, p.machine_id, p.settlement_id, p.leg_type,
|
SELECT id, amount_sats, amount_fiat, exchange_rate, transaction_type,
|
||||||
p.amount_sats, p.amount_fiat, p.exchange_rate, p.status,
|
status, created_at, transaction_time, lamassu_transaction_id
|
||||||
p.created_at, p.transaction_time,
|
FROM satoshimachine.dca_payments
|
||||||
m.machine_npub
|
WHERE {where_clause}
|
||||||
FROM satoshimachine.dca_payments p
|
ORDER BY created_at DESC
|
||||||
JOIN satoshimachine.dca_clients c ON c.id = p.client_id
|
LIMIT :limit OFFSET :offset
|
||||||
JOIN satoshimachine.dca_machines m ON m.id = p.machine_id
|
|
||||||
{where}
|
|
||||||
ORDER BY p.created_at DESC
|
|
||||||
LIMIT :lim OFFSET :off
|
|
||||||
""",
|
""",
|
||||||
params,
|
params
|
||||||
)
|
)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
ClientTransaction(
|
ClientTransaction(
|
||||||
id=row["id"],
|
id=tx["id"],
|
||||||
machine_id=row["machine_id"],
|
amount_sats=tx["amount_sats"],
|
||||||
machine_npub=row["machine_npub"],
|
amount_fiat=tx["amount_fiat"],
|
||||||
settlement_id=row["settlement_id"],
|
exchange_rate=tx["exchange_rate"],
|
||||||
leg_type=row["leg_type"],
|
transaction_type=tx["transaction_type"],
|
||||||
amount_sats=row["amount_sats"],
|
status=tx["status"],
|
||||||
amount_fiat=row["amount_fiat"],
|
created_at=tx["created_at"],
|
||||||
exchange_rate=row["exchange_rate"],
|
transaction_time=tx["transaction_time"],
|
||||||
status=row["status"],
|
lamassu_transaction_id=tx["lamassu_transaction_id"]
|
||||||
created_at=row["created_at"],
|
|
||||||
transaction_time=row["transaction_time"],
|
|
||||||
)
|
)
|
||||||
for row in rows
|
for tx in transactions
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def get_lp_preferences(user_id: str) -> Optional[LpPreferences]:
|
async def get_client_analytics(user_id: str, time_range: str = "30d") -> Optional[ClientAnalytics]:
|
||||||
"""Read this LP's preferences row from `dca_lp`. Returns None if the
|
"""Get client performance analytics"""
|
||||||
LP hasn't onboarded yet (no row). Callers in this extension generally
|
|
||||||
use `ensure_lp_preferences` instead, which auto-creates on first
|
try:
|
||||||
access."""
|
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(
|
return await db.fetchone(
|
||||||
"SELECT * FROM satoshimachine.dca_lp WHERE user_id = :uid",
|
"SELECT * FROM satoshimachine.dca_clients WHERE user_id = :user_id",
|
||||||
{"uid": user_id},
|
{"user_id": user_id}
|
||||||
LpPreferences,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def ensure_lp_preferences(user_id: str, default_wallet_id: str) -> LpPreferences:
|
async def update_client_dca_settings(client_id: str, settings: UpdateClientSettings) -> bool:
|
||||||
"""Get-or-create the LP's preferences row.
|
"""Update client DCA settings (mode, limits, status)"""
|
||||||
|
try:
|
||||||
First call (no row): seed with `default_wallet_id` (passed by the
|
update_data = {k: v for k, v in settings.dict().items() if v is not None}
|
||||||
caller — typically the wallet the LP authenticated through). LP can
|
if not update_data:
|
||||||
change it later via `update_lp_preferences`.
|
return True # Nothing to update
|
||||||
|
|
||||||
This is the structural enforcement of the "LP must onboard before
|
update_data["updated_at"] = datetime.now()
|
||||||
deposits work" gate: the act of opening satmachineclient and hitting
|
set_clause = ", ".join([f"{k} = :{k}" for k in update_data.keys()])
|
||||||
any endpoint creates the dca_lp row, which unlocks deposit creation
|
update_data["id"] = client_id
|
||||||
on the operator side.
|
|
||||||
"""
|
await db.execute(
|
||||||
existing = await get_lp_preferences(user_id)
|
f"UPDATE satoshimachine.dca_clients SET {set_clause} WHERE id = :id",
|
||||||
if existing is not None:
|
update_data
|
||||||
return existing
|
)
|
||||||
now = datetime.now()
|
return True
|
||||||
await db.execute(
|
except Exception:
|
||||||
"""
|
return False
|
||||||
INSERT INTO satoshimachine.dca_lp
|
|
||||||
(user_id, dca_wallet_id, default_dca_mode,
|
|
||||||
autoforward_enabled, created_at, updated_at)
|
|
||||||
VALUES (:uid, :wallet, 'flow', false, :now, :now)
|
|
||||||
""",
|
|
||||||
{"uid": user_id, "wallet": default_wallet_id, "now": now},
|
|
||||||
)
|
|
||||||
created = await get_lp_preferences(user_id)
|
|
||||||
assert created is not None
|
|
||||||
return created
|
|
||||||
|
|
||||||
|
|
||||||
async def update_lp_preferences(
|
###################################################
|
||||||
user_id: str, data: UpdateLpPreferences
|
############## CLIENT REGISTRATION ###############
|
||||||
) -> Optional[LpPreferences]:
|
###################################################
|
||||||
"""LP-side update of their `dca_lp` row. Caller must ensure the row
|
|
||||||
exists first (typically via `ensure_lp_preferences` on dashboard
|
async def register_dca_client(user_id: str, wallet_id: str, registration_data: ClientRegistrationData) -> Optional[dict]:
|
||||||
load). Operator cannot reach this path — it requires the LP's wallet
|
"""Register a new DCA client - special permission for self-registration"""
|
||||||
admin key per the API auth dependency."""
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
update_data = {k: v for k, v in data.dict().items() if v is not None}
|
from lnbits.core.crud import get_user
|
||||||
if not update_data:
|
|
||||||
return await get_lp_preferences(user_id)
|
try:
|
||||||
update_data["updated_at"] = datetime.now()
|
# Verify user exists and get username
|
||||||
update_data["uid"] = user_id
|
user = await get_user(user_id)
|
||||||
set_clause = ", ".join(f"{k} = :{k}" for k in update_data if k not in ("uid",))
|
username = registration_data.username or (user.username if user else f"user_{user_id[:8]}")
|
||||||
await db.execute(
|
|
||||||
f"UPDATE satoshimachine.dca_lp SET {set_clause} WHERE user_id = :uid",
|
# Check if client already exists
|
||||||
update_data,
|
existing_client = await db.fetchone(
|
||||||
)
|
"SELECT id FROM satoshimachine.dca_clients WHERE user_id = :user_id",
|
||||||
return await get_lp_preferences(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
|
# 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)
|
||||||
160
models.py
160
models.py
|
|
@ -1,12 +1,4 @@
|
||||||
# Satoshi Machine Client v2 — Pydantic models.
|
# Description: Pydantic data models for client extension API responses
|
||||||
#
|
|
||||||
# LP-facing read view over the admin extension's v2 schema. An LP can hold
|
|
||||||
# DCA positions across multiple machines (and across multiple operators on
|
|
||||||
# the same LNbits instance); summary endpoints aggregate, with an optional
|
|
||||||
# per-machine breakdown for filtering.
|
|
||||||
#
|
|
||||||
# NOTE: this extension is in maintenance mode. The richer LP UI is moving
|
|
||||||
# to ~/dev/webapp; this surface stays minimal and read-mostly.
|
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
@ -14,91 +6,95 @@ from typing import List, Optional
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class PerMachinePosition(BaseModel):
|
# API Models for Client Dashboard (Frontend communication in GTQ)
|
||||||
"""LP's position at a single machine.
|
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
|
class ClientTransactionAPI(BaseModel):
|
||||||
machine_npub: str
|
"""API model - client transaction in GTQ"""
|
||||||
machine_name: Optional[str]
|
id: str
|
||||||
machine_location: Optional[str]
|
amount_sats: int
|
||||||
currency: str
|
amount_fiat_gtq: float # Amount in GTQ
|
||||||
dca_mode: str
|
exchange_rate: float
|
||||||
|
transaction_type: str # 'flow', 'fixed', 'manual'
|
||||||
status: str
|
status: str
|
||||||
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
|
created_at: datetime
|
||||||
updated_at: datetime
|
transaction_time: Optional[datetime] = None # Original ATM transaction time
|
||||||
|
lamassu_transaction_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class UpdateLpPreferences(BaseModel):
|
# Internal Models for Client Dashboard (Database storage in GTQ)
|
||||||
"""LP-side preference updates. All fields optional; only ones provided
|
class ClientDashboardSummary(BaseModel):
|
||||||
are touched. Use to switch DCA wallet, change mode, toggle autoforward."""
|
"""Internal model - client dashboard summary stored in GTQ"""
|
||||||
|
user_id: str
|
||||||
dca_wallet_id: Optional[str] = None
|
total_sats_accumulated: int
|
||||||
default_dca_mode: Optional[str] = None
|
total_fiat_invested: float # Confirmed deposits in GTQ
|
||||||
fixed_mode_daily_limit: Optional[float] = None
|
pending_fiat_deposits: float # Pending deposits awaiting confirmation in GTQ
|
||||||
autoforward_ln_address: Optional[str] = None
|
current_sats_fiat_value: float # Current fiat value of total sats in GTQ
|
||||||
autoforward_enabled: Optional[bool] = None
|
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):
|
class ClientTransaction(BaseModel):
|
||||||
"""A single distribution leg landing in the LP's wallet."""
|
"""Internal model - client transaction stored in GTQ"""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
machine_id: str
|
|
||||||
machine_npub: str
|
|
||||||
settlement_id: Optional[str]
|
|
||||||
leg_type: str # 'dca' | 'settlement' | 'autoforward' (LP-visible)
|
|
||||||
amount_sats: int
|
amount_sats: int
|
||||||
amount_fiat: Optional[float]
|
amount_fiat: float # Amount in GTQ (e.g., 150.75)
|
||||||
exchange_rate: Optional[float]
|
exchange_rate: float
|
||||||
|
transaction_type: str # 'flow', 'fixed', 'manual'
|
||||||
status: str
|
status: str
|
||||||
created_at: datetime
|
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`.
|
|
||||||
|
|
|
||||||
|
|
@ -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({
|
window.app = Vue.createApp({
|
||||||
el: '#vue',
|
el: '#vue',
|
||||||
mixins: [windowMixin],
|
mixins: [windowMixin],
|
||||||
delimiters: ['${', '}'],
|
delimiters: ['${', '}'],
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
// Registration / onboarding (legacy; always true after initial
|
// Registration state
|
||||||
// /preferences call because the endpoint auto-creates).
|
isRegistered: false,
|
||||||
isRegistered: true,
|
registrationChecked: false,
|
||||||
registrationChecked: true,
|
|
||||||
registrationForm: {
|
registrationForm: {
|
||||||
selectedWallet: null,
|
selectedWallet: null,
|
||||||
dca_mode: 'flow',
|
dca_mode: 'flow',
|
||||||
|
|
@ -30,11 +14,7 @@ window.app = Vue.createApp({
|
||||||
username: ''
|
username: ''
|
||||||
},
|
},
|
||||||
|
|
||||||
// LP preferences (loaded from dca_lp on first call).
|
// Admin configuration
|
||||||
preferences: null,
|
|
||||||
|
|
||||||
// Admin configuration (legacy: no longer fetched — kept so the
|
|
||||||
// legacy template doesn't undefined-error on `adminConfig.*`).
|
|
||||||
adminConfig: {
|
adminConfig: {
|
||||||
max_daily_limit_gtq: 2000,
|
max_daily_limit_gtq: 2000,
|
||||||
currency: 'GTQ'
|
currency: 'GTQ'
|
||||||
|
|
@ -45,165 +25,260 @@ window.app = Vue.createApp({
|
||||||
transactions: [],
|
transactions: [],
|
||||||
loading: true,
|
loading: true,
|
||||||
error: null,
|
error: null,
|
||||||
showFiatValues: false,
|
showFiatValues: false, // Hide fiat values by default
|
||||||
// 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,
|
|
||||||
transactionColumns: [
|
transactionColumns: [
|
||||||
{name: 'date', label: 'Date', align: 'left',
|
{
|
||||||
field: row => row.transaction_time || row.created_at, sortable: false},
|
name: 'date',
|
||||||
{name: 'amount_sats', label: 'Bitcoin', align: 'right',
|
label: 'Date',
|
||||||
field: 'amount_sats', sortable: false},
|
align: 'left',
|
||||||
{name: 'amount_fiat', label: 'Fiat Amount', align: 'right',
|
field: row => row.transaction_time || row.created_at,
|
||||||
field: 'amount_fiat', sortable: false},
|
sortable: false
|
||||||
{name: 'type', label: 'Type', align: 'center',
|
},
|
||||||
field: 'leg_type', sortable: false},
|
{
|
||||||
{name: 'status', label: 'Status', align: 'center',
|
name: 'amount_sats',
|
||||||
field: 'status', sortable: false}
|
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: {
|
transactionPagination: {
|
||||||
sortBy: 'date',
|
sortBy: 'date',
|
||||||
descending: true,
|
descending: true,
|
||||||
page: 1,
|
page: 1,
|
||||||
rowsPerPage: 10
|
rowsPerPage: 10
|
||||||
}
|
},
|
||||||
|
chartTimeRange: '30d',
|
||||||
|
dcaChart: null,
|
||||||
|
analyticsData: null,
|
||||||
|
chartLoading: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
// -----------------------------------------------------------------
|
// Configuration Methods
|
||||||
// Onboarding + preferences
|
async loadClientLimits() {
|
||||||
// -----------------------------------------------------------------
|
|
||||||
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.
|
|
||||||
try {
|
try {
|
||||||
const {data} = await LNbits.api.request(
|
const { data } = await LNbits.api.request(
|
||||||
'GET',
|
'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.g.user.wallets[0].adminkey
|
||||||
)
|
)
|
||||||
this.preferences = data
|
|
||||||
this.isRegistered = true
|
this.isRegistered = data.is_registered
|
||||||
this.registrationChecked = true
|
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) {
|
} catch (error) {
|
||||||
console.error('Error loading preferences:', error)
|
console.error('Error checking registration status:', error)
|
||||||
this.error = 'Failed to load DCA preferences'
|
this.error = 'Failed to check registration status'
|
||||||
this.registrationChecked = true
|
this.registrationChecked = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
async loadCurrentUser() {
|
||||||
// Formatting helpers
|
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) {
|
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', {
|
return new Intl.NumberFormat('es-GT', {
|
||||||
style: 'currency', currency: 'GTQ'
|
style: 'currency',
|
||||||
}).format(amount)
|
currency: 'GTQ',
|
||||||
|
}).format(gtqAmount);
|
||||||
},
|
},
|
||||||
|
|
||||||
formatCurrencyWithCode(amount, currencyCode) {
|
formatCurrencyWithCode(amount, currencyCode) {
|
||||||
if (!amount) return `${currencyCode} 0.00`
|
if (!amount) return `${currencyCode} 0.00`;
|
||||||
|
// Amount is already in GTQ
|
||||||
|
const currencyAmount = amount;
|
||||||
try {
|
try {
|
||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat('en-US', {
|
||||||
style: 'currency', currency: currencyCode
|
style: 'currency',
|
||||||
}).format(amount)
|
currency: currencyCode,
|
||||||
|
}).format(currencyAmount);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return `${currencyCode} ${amount.toFixed(2)}`
|
// Fallback if currency code is not supported
|
||||||
|
return `${currencyCode} ${currencyAmount.toFixed(2)}`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
formatDate(dateString) {
|
formatDate(dateString) {
|
||||||
if (!dateString) return ''
|
if (!dateString) return ''
|
||||||
const date = new Date(dateString)
|
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()
|
return date.toLocaleDateString()
|
||||||
},
|
},
|
||||||
|
|
||||||
formatTime(dateString) {
|
formatTime(dateString) {
|
||||||
if (!dateString) return ''
|
if (!dateString) return ''
|
||||||
const date = new Date(dateString)
|
const date = new Date(dateString)
|
||||||
if (isNaN(date.getTime())) return 'Invalid Time'
|
if (isNaN(date.getTime())) {
|
||||||
return date.toLocaleTimeString('en-US', {hour: '2-digit', minute: '2-digit'})
|
console.warn('Invalid time string:', dateString)
|
||||||
|
return 'Invalid Time'
|
||||||
|
}
|
||||||
|
return date.toLocaleTimeString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
formatSats(amount) {
|
formatSats(amount) {
|
||||||
if (!amount) return '0 sats'
|
if (!amount) return '0 sats'
|
||||||
const formatted = new Intl.NumberFormat('en-US').format(amount)
|
const formatted = new Intl.NumberFormat('en-US').format(amount)
|
||||||
if (amount >= 100000000) return formatted + ' sats 🏆'
|
// Add some excitement for larger amounts with consistent 5x→2x progression
|
||||||
if (amount >= 50000000) return formatted + ' sats 🎆'
|
if (amount >= 100000000) return formatted + ' sats 🏆' // Full coiner (1 BTC)
|
||||||
if (amount >= 10000000) return formatted + ' sats 👑'
|
if (amount >= 50000000) return formatted + ' sats 🎆' // Bitcoin baron
|
||||||
if (amount >= 5000000) return formatted + ' sats 🏆'
|
if (amount >= 10000000) return formatted + ' sats 👑' // Bitcoin royalty
|
||||||
if (amount >= 1000000) return formatted + ' sats 🌟'
|
if (amount >= 5000000) return formatted + ' sats 🏆' // Verified bag holder
|
||||||
if (amount >= 500000) return formatted + ' sats 🔥'
|
if (amount >= 1000000) return formatted + ' sats 🌟' // Millionaire
|
||||||
if (amount >= 100000) return formatted + ' sats 🚀'
|
if (amount >= 500000) return formatted + ' sats 🔥' // Half million
|
||||||
if (amount >= 50000) return formatted + ' sats ⚡'
|
if (amount >= 100000) return formatted + ' sats 🚀' // Getting serious
|
||||||
if (amount >= 10000) return formatted + ' sats 🎯'
|
if (amount >= 50000) return formatted + ' sats ⚡' // Lightning quick
|
||||||
|
if (amount >= 10000) return formatted + ' sats 🎯' // First milestone
|
||||||
return formatted + ' sats'
|
return formatted + ' sats'
|
||||||
},
|
},
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
// Dashboard data
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
async loadDashboardData() {
|
async loadDashboardData() {
|
||||||
try {
|
try {
|
||||||
const {data} = await LNbits.api.request(
|
const { data } = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/satmachineclient/api/v1/dca-client/positions',
|
'/satmachineclient/api/v1/dashboard/summary',
|
||||||
this.g.user.wallets[0].adminkey
|
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
|
this.dashboardData = data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 404 from /positions = "LP isn't enrolled at any machine yet",
|
console.error('Error loading dashboard data:', error)
|
||||||
// which is a valid state, not an error. Show an empty dashboard.
|
this.error = 'Failed to load dashboard data'
|
||||||
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'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadTransactions() {
|
async loadTransactions() {
|
||||||
try {
|
try {
|
||||||
const {data} = await LNbits.api.request(
|
const { data } = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
'/satmachineclient/api/v1/dca-client/transactions?limit=50',
|
'/satmachineclient/api/v1/dashboard/transactions?limit=50',
|
||||||
this.g.user.wallets[0].adminkey
|
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) => {
|
this.transactions = data.sort((a, b) => {
|
||||||
const dateA = new Date(a.transaction_time || a.created_at)
|
const dateA = new Date(a.transaction_time || a.created_at)
|
||||||
const dateB = new Date(b.transaction_time || b.created_at)
|
const dateB = new Date(b.transaction_time || b.created_at)
|
||||||
return dateB - dateA
|
return dateB - dateA // Most recent first
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading transactions:', error)
|
console.error('Error loading transactions:', error)
|
||||||
|
|
@ -219,7 +294,6 @@ window.app = Vue.createApp({
|
||||||
try {
|
try {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.loadPreferences(),
|
|
||||||
this.loadDashboardData(),
|
this.loadDashboardData(),
|
||||||
this.loadTransactions()
|
this.loadTransactions()
|
||||||
])
|
])
|
||||||
|
|
@ -241,67 +315,502 @@ window.app = Vue.createApp({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
// Milestone widget (purely cosmetic)
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
getNextMilestone() {
|
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
|
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'}
|
// Consistent 5x→2x progression pattern
|
||||||
if (sats < 100000) return {target: 100000, name: '100k sats'}
|
if (sats < 10000) return { target: 10000, name: '10k sats' }
|
||||||
if (sats < 500000) return {target: 500000, name: '500k sats'}
|
if (sats < 50000) return { target: 50000, name: '50k sats' }
|
||||||
if (sats < 1000000) return {target: 1000000, name: '1M sats'}
|
if (sats < 100000) return { target: 100000, name: '100k sats' }
|
||||||
if (sats < 5000000) return {target: 5000000, name: '5M sats'}
|
if (sats < 500000) return { target: 500000, name: '500k sats' }
|
||||||
if (sats < 10000000) return {target: 10000000, name: '10M sats'}
|
if (sats < 1000000) return { target: 1000000, name: '1M sats' }
|
||||||
if (sats < 50000000) return {target: 50000000, name: '50M sats'}
|
if (sats < 5000000) return { target: 5000000, name: '5M sats' }
|
||||||
if (sats < 100000000) return {target: 100000000, name: '100M sats (1 BTC!)'}
|
if (sats < 10000000) return { target: 10000000, name: '10M sats' }
|
||||||
return {target: 500000000, name: '500M sats (5 BTC)'}
|
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() {
|
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 sats = this.dashboardData.total_sats_accumulated
|
||||||
const milestone = this.getNextMilestone()
|
const milestone = this.getNextMilestone()
|
||||||
|
|
||||||
|
// Show total progress toward the next milestone (from 0)
|
||||||
const progress = (sats / milestone.target) * 100
|
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
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
initDCAChart() {
|
||||||
// Stubs for the legacy registration wizard + chart panel.
|
console.log('initDCAChart called')
|
||||||
// Those template branches are dead (the wizard never shows because
|
console.log('analyticsData:', this.analyticsData)
|
||||||
// isRegistered is true after auto-onboard; the chart panel has no
|
console.log('dcaChart ref:', this.$refs.dcaChart)
|
||||||
// backend analytics endpoint to feed it) but the .html still
|
console.log('chartLoading state:', this.chartLoading)
|
||||||
// references these handlers. Keep stubs so a stray click doesn't
|
|
||||||
// throw an uncaught error.
|
// Skip if we're not in a loading state (indicates this is a stale call)
|
||||||
// -----------------------------------------------------------------
|
if (!this.chartLoading && this.dcaChart) {
|
||||||
async registerClient() {
|
console.log('Chart already exists and not loading, skipping initialization')
|
||||||
// Old "register / pick wallet & mode" form is gone. Preferences
|
return
|
||||||
// are auto-created and editable via a separate path (TODO: add
|
}
|
||||||
// an editor card to this dashboard once the template gets a
|
|
||||||
// proper rewrite).
|
if (!this.analyticsData) {
|
||||||
await this.loadPreferences()
|
console.log('No analytics data available')
|
||||||
this.$q.notify({
|
return
|
||||||
type: 'info',
|
}
|
||||||
message: 'Your DCA account is already set up — refreshed.',
|
|
||||||
position: 'top'
|
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() {
|
async created() {
|
||||||
try {
|
try {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
// Auto-onboard on load (creates dca_lp row if missing).
|
|
||||||
await this.loadPreferences()
|
// Load client limits first
|
||||||
// Then load dashboard + transactions.
|
await this.loadClientLimits()
|
||||||
await Promise.all([
|
|
||||||
this.loadDashboardData(),
|
// Check registration status
|
||||||
this.loadTransactions()
|
await this.checkRegistrationStatus()
|
||||||
])
|
|
||||||
|
// Only load dashboard data if registered
|
||||||
|
if (this.isRegistered) {
|
||||||
|
await Promise.all([
|
||||||
|
this.loadDashboardData(),
|
||||||
|
this.loadTransactions(),
|
||||||
|
this.loadChartData()
|
||||||
|
])
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error initializing dashboard:', error)
|
console.error('Error initializing dashboard:', error)
|
||||||
this.error = 'Failed to initialize dashboard'
|
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: {
|
computed: {
|
||||||
hasData() {
|
hasData() {
|
||||||
return this.dashboardData && !this.loading && this.isRegistered
|
return this.dashboardData && !this.loading && this.isRegistered
|
||||||
|
|
@ -322,5 +848,23 @@ window.app = Vue.createApp({
|
||||||
value: wallet.id
|
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
2
tasks.py
Normal 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
2
transaction_processor.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# No transaction processing needed in client extension
|
||||||
|
# All transaction processing is handled by the admin extension
|
||||||
255
views_api.py
255
views_api.py
|
|
@ -1,109 +1,220 @@
|
||||||
# Satoshi Machine Client v2 — API.
|
# Description: Client-focused API endpoints for DCA dashboard
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, Query
|
||||||
from lnbits.core.models import WalletTypeInfo
|
from lnbits.core.models import WalletTypeInfo
|
||||||
from lnbits.decorators import require_admin_key
|
from lnbits.decorators import require_admin_key
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
from .crud import (
|
from .crud import (
|
||||||
ensure_lp_preferences,
|
|
||||||
get_client_dashboard_summary,
|
get_client_dashboard_summary,
|
||||||
get_client_transactions,
|
get_client_transactions,
|
||||||
update_lp_preferences,
|
get_client_analytics,
|
||||||
|
update_client_dca_settings,
|
||||||
|
get_client_by_user_id,
|
||||||
|
register_dca_client,
|
||||||
)
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
ClientDashboardSummary,
|
ClientDashboardSummary,
|
||||||
ClientTransaction,
|
ClientTransaction,
|
||||||
LpPreferences,
|
ClientAnalytics,
|
||||||
UpdateLpPreferences,
|
UpdateClientSettings,
|
||||||
|
ClientRegistrationData,
|
||||||
)
|
)
|
||||||
|
|
||||||
satmachineclient_api_router = APIRouter()
|
satmachineclient_api_router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@satmachineclient_api_router.get(
|
###################################################
|
||||||
"/api/v1/dca-client/preferences", response_model=LpPreferences
|
############## CLIENT REGISTRATION ###############
|
||||||
)
|
###################################################
|
||||||
async def api_get_preferences(
|
|
||||||
|
@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),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> LpPreferences:
|
) -> dict:
|
||||||
"""Return the LP's DCA preferences. Auto-creates the `dca_lp` row on
|
"""Register a new DCA client
|
||||||
first call, seeded with the authenticated wallet as the default DCA
|
|
||||||
destination. The act of hitting this endpoint is what marks the LP
|
Clients can self-register using their wallet admin key.
|
||||||
as "onboarded" on the operator side."""
|
Creates a new client entry in the satoshimachine database.
|
||||||
return await ensure_lp_preferences(
|
"""
|
||||||
wallet.wallet.user, default_wallet_id=wallet.wallet.id
|
result = await register_dca_client(
|
||||||
|
wallet.wallet.user,
|
||||||
|
wallet.wallet.id,
|
||||||
|
registration_data
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if "error" in result:
|
||||||
|
if "already registered" in result["error"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.CONFLICT,
|
||||||
|
detail=result["error"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail=result["error"]
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@satmachineclient_api_router.put(
|
@satmachineclient_api_router.get("/api/v1/registration-status")
|
||||||
"/api/v1/dca-client/preferences", response_model=LpPreferences
|
async def api_check_registration_status(
|
||||||
)
|
|
||||||
async def api_update_preferences(
|
|
||||||
data: UpdateLpPreferences,
|
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> LpPreferences:
|
) -> dict:
|
||||||
"""LP-side update of DCA wallet / mode / autoforward. Operator can't
|
"""Check if user is already registered as a DCA client"""
|
||||||
reach this path — it requires the LP's wallet admin key."""
|
client = await get_client_by_user_id(wallet.wallet.user)
|
||||||
# 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,
|
return {
|
||||||
# but the dashboard and any caller order shouldn't matter).
|
"is_registered": client is not None,
|
||||||
await ensure_lp_preferences(wallet.wallet.user, default_wallet_id=wallet.wallet.id)
|
"client_id": client["id"] if client else None,
|
||||||
updated = await update_lp_preferences(wallet.wallet.user, data)
|
"dca_mode": client["dca_mode"] if client else None,
|
||||||
assert updated is not None
|
"status": client["status"] if client else None,
|
||||||
return updated
|
}
|
||||||
|
|
||||||
|
|
||||||
@satmachineclient_api_router.get(
|
###################################################
|
||||||
"/api/v1/dca-client/positions", response_model=ClientDashboardSummary
|
############## CLIENT DASHBOARD API ###############
|
||||||
)
|
###################################################
|
||||||
async def api_get_positions(
|
|
||||||
|
@satmachineclient_api_router.get("/api/v1/dashboard/summary")
|
||||||
|
async def api_get_dashboard_summary(
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> ClientDashboardSummary:
|
) -> ClientDashboardSummary:
|
||||||
"""LP's aggregated dashboard across every machine they're registered at,
|
"""Get client dashboard summary metrics"""
|
||||||
plus a per-machine breakdown in the `positions` field.
|
summary = await get_client_dashboard_summary(wallet.wallet.user)
|
||||||
|
if not summary:
|
||||||
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:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.NOT_FOUND,
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
"No DCA positions found for this wallet's owner. "
|
detail="Client data not found"
|
||||||
"An operator must register you at a machine first.",
|
|
||||||
)
|
)
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|
||||||
@satmachineclient_api_router.get(
|
@satmachineclient_api_router.get("/api/v1/dashboard/transactions")
|
||||||
"/api/v1/dca-client/transactions", response_model=List[ClientTransaction]
|
async def api_get_client_transactions(
|
||||||
)
|
|
||||||
async def api_get_transactions(
|
|
||||||
limit: int = 50,
|
|
||||||
offset: int = 0,
|
|
||||||
machine_id: Optional[str] = None,
|
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
|
limit: int = Query(50, ge=1, le=1000),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
transaction_type: Optional[str] = Query(None),
|
||||||
|
start_date: Optional[datetime] = Query(None),
|
||||||
|
end_date: Optional[datetime] = Query(None),
|
||||||
) -> List[ClientTransaction]:
|
) -> List[ClientTransaction]:
|
||||||
"""LP's distribution history. Returns only legs the LP can see (DCA,
|
"""Get client's DCA transaction history with filtering"""
|
||||||
balance-settle, auto-forward). Optionally filter by ?machine_id=X."""
|
|
||||||
user_id = wallet.wallet.user
|
|
||||||
return await get_client_transactions(
|
return await get_client_transactions(
|
||||||
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue