Compare commits

...
Sign in to create a new pull request.

3 commits

Author SHA1 Message Date
18010de363 fix(v2)(ui): rewire LP dashboard JS to call the new dca_lp endpoints
The static JS was orphaned at a prior backend refactor: it called
/registration-status, /register, /dashboard/summary, /dashboard/
transactions, /dashboard/analytics — none of which exist in views_api.
First render hit `Failed to check registration status` and the
"Welcome to DCA" form's submit failed with `Failed to register for DCA`.

This commit rewires the data layer to the v2 endpoints:
  - GET  /api/v1/dca-client/preferences  (auto-onboards the LP on load
    — the act of opening this dashboard is what creates the LP's
    dca_lp row, which unlocks deposit creation on the operator side)
  - GET  /api/v1/dca-client/positions    (404 → empty-state, not error)
  - GET  /api/v1/dca-client/transactions

The legacy "Welcome / register" wizard in index.html is now dead
(`isRegistered` defaults to true and `loadPreferences` always succeeds
because the backend auto-creates). The chart panel is also dead (no
backend /analytics endpoint). Stubs added for `registerClient`,
`loadChartData`, plus `chartLoading` / `chartTimeRange` /
`analyticsData` data fields so the template renders without
undefined-binding warnings even though those branches never execute.

Future cleanup: the registration card + chart panel HTML can be
deleted from the template, and a preferences-editor card (PUT
/preferences) added. Out of scope here — the priority was unblocking
E2E testing on v2-bitspire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:29:18 +02:00
7dac898a10 feat(v2): own dca_lp writes + auto-init on first dashboard access
Pairs with the satmachineadmin Phase 1 refactor that hoisted LP state
into `satoshimachine.dca_lp` (one row per user). This extension is now
the WRITER for that table; satmachineadmin only reads it during
distribution.

API surface:
  - `GET  /api/v1/dca-client/preferences` — returns the LP's
    `dca_lp` row, AUTO-CREATING it with the authenticated wallet
    as the default DCA destination on first call. Hitting this
    endpoint is the act that marks the LP as onboarded on the
    operator side (gating their deposit creation).
  - `PUT  /api/v1/dca-client/preferences` — LP-side update of
    wallet / mode / fixed-mode limit / autoforward fields. Ensures
    the row exists before applying. Replaces the old
    `PUT /autoforward` endpoint (which is gone).
  - `GET  /api/v1/dca-client/positions` — same shape as before
    but also auto-inits dca_lp on entry (so opening the dashboard
    onboards the LP). Now INNER JOINs dca_lp so only onboarded
    LPs see positions (matches the operator-side "must onboard
    before deposits" gate).
  - `GET  /api/v1/dca-client/transactions` — unchanged.

Models:
  - New `LpPreferences` / `UpdateLpPreferences` exposing the
    dca_lp fields.
  - `UpdateClientAutoforward` removed (replaced by the broader
    `UpdateLpPreferences`).
  - `PerMachinePosition.dca_mode` now sourced from `dca_lp` (it's
    LP-wide, echoed on each position row for legacy display
    compatibility).

CRUD:
  - `_fetch_user_clients` rewritten: INNER JOIN dca_lp, drop
    references to removed `dca_clients.wallet_id` / `.dca_mode`
    columns (they don't exist anymore post-Phase-1).
  - New: `get_lp_preferences`, `ensure_lp_preferences`,
    `update_lp_preferences`. The first writes nothing; the second
    is the get-or-create that defends the auto-onboard invariant.
  - `update_lp_autoforward` removed — write path is now
    `update_lp_preferences` against `dca_lp`, not the multi-row
    UPDATE on `dca_clients` that used to be needed because the
    state was denormalised across enrolments.

Note: the legacy static/js/index.js in this extension references
endpoints that no longer exist (`/registration-status`, `/register`,
`/dashboard/summary`, ...) — that's pre-existing tech debt from when
the LP UX was moved to ~/dev/webapp. Not regressed by this commit;
the deprecated frontend is out of scope. For now LP onboarding works
via direct API call (curl `GET /preferences` once with the LP's wallet
admin key); the webapp will own the proper UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:11:27 +02:00
ade4e67541 feat(v2): satmachineclient maintenance pass — v2 admin schema (P7)
Updates the LP-facing client extension to work against the v2 admin schema
(per-machine dca_clients, leg_type-discriminated dca_payments, settlement
audit trail) and strips the dead v1 code paths.

Maintenance-mode framing: the richer LP UI is migrating to ~/dev/webapp.
This extension now provides a minimal stable read-mostly surface so
existing LP installs don't break against the v2 admin schema.

models.py — rewrite:
  + PerMachinePosition: LP's position at a single machine
  ~ ClientDashboardSummary: now aggregates across all the LP's machines
    and exposes per-machine breakdown in `positions` field
  ~ ClientTransaction: gains machine_id + machine_npub + settlement_id;
    drops transaction_type / lamassu_transaction_id (v1 fields)
  ~ leg_type discriminator on ClientTransaction so the LP can tell DCA,
    operator-initiated balance-settle, and auto-forward legs apart
  + UpdateClientAutoforward: LPs control their own autoforward setting
  - ClientDashboardSummaryAPI, ClientTransactionAPI: deleted (GTQ-only
    parallel "API" models that duplicated the internal ones)
  - ClientPreferences, ClientRegistrationData, UpdateClientSettings:
    deleted (registration + settings are operator-controlled in v2)

crud.py — rewrite:
  + _fetch_user_clients: all dca_clients rows for the LP, joined with
    dca_machines for display metadata
  + _position_for_client: per-machine aggregation helper
  + get_client_dashboard_summary: aggregates positions + sums sats/fiat
    + computes cost basis on fiat-spent (not gross deposits)
  + get_client_transactions: filters to LP-visible leg_types
    (dca / settlement / autoforward); optional machine_id filter
  + update_lp_autoforward: LP self-manages autoforward across all their
    machine positions
  - get_client_analytics (300+ lines of date-parsing pretzels): deleted;
    webapp will own analytics
  - register_dca_client: deleted (admin owns registration in v2)
  - update_client_dca_settings: deleted (admin owns DCA mode/limits)
  - Lamassu-era status='confirmed' (now 'completed'), transaction_type
    column refs, lamassu_transaction_id column refs: all gone

views_api.py — rewrite to 3 endpoints:
  GET /api/v1/dca-client/positions       (aggregated dashboard)
  GET /api/v1/dca-client/transactions    (filterable history)
  PUT /api/v1/dca-client/autoforward     (LP self-manages forwarding)

All require_admin_key on the LP's wallet. Old registration / settings
/ analytics endpoints removed.

Files deleted:
  tasks.py — empty stub ("No background tasks needed")
  transaction_processor.py — empty stub ("No transaction processing")

README.md gains a status banner pointing LP audience at webapp.

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

4 routes registered. Zero ruff errors across the extension.

Refs: aiolabs/satmachineadmin#9 — completes P7

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:53:13 +02:00
9 changed files with 540 additions and 1433 deletions

10
.gitignore vendored
View file

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

View file

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

682
crud.py
View file

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

152
models.py
View file

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

View file

@ -1,12 +1,28 @@
// Satoshi Machine Client v2 — LP dashboard JS.
//
// Maintenance-mode reminder: the rich LP UI is moving to ~/dev/webapp.
// This file is the lightweight in-LNbits dashboard kept functional for
// dev / E2E testing on the v2-bitspire branch. Endpoints:
// GET /satmachineclient/api/v1/dca-client/preferences (auto-onboards)
// PUT /satmachineclient/api/v1/dca-client/preferences
// GET /satmachineclient/api/v1/dca-client/positions
// GET /satmachineclient/api/v1/dca-client/transactions
//
// The "registration / welcome" wizard in the template is dead — every
// call to GET /preferences auto-creates the LP's dca_lp row on first
// hit, so `isRegistered` is always true after the initial load and the
// wizard never shows. Cleanup of the template is deferred.
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
delimiters: ['${', '}'],
data: function () {
return {
// Registration state
isRegistered: false,
registrationChecked: false,
// Registration / onboarding (legacy; always true after initial
// /preferences call because the endpoint auto-creates).
isRegistered: true,
registrationChecked: true,
registrationForm: {
selectedWallet: null,
dca_mode: 'flow',
@ -14,7 +30,11 @@ window.app = Vue.createApp({
username: ''
},
// Admin configuration
// LP preferences (loaded from dca_lp on first call).
preferences: null,
// Admin configuration (legacy: no longer fetched — kept so the
// legacy template doesn't undefined-error on `adminConfig.*`).
adminConfig: {
max_daily_limit_gtq: 2000,
currency: 'GTQ'
@ -25,260 +45,165 @@ window.app = Vue.createApp({
transactions: [],
loading: true,
error: null,
showFiatValues: false, // Hide fiat values by default
showFiatValues: false,
// Stubs for legacy template bindings (chart + registration form
// are dead branches but still in the .html — keep these defined
// so Vue doesn't emit warnings on initial render).
chartLoading: false,
chartTimeRange: '30d',
analyticsData: null,
transactionColumns: [
{
name: 'date',
label: 'Date',
align: 'left',
field: row => row.transaction_time || row.created_at,
sortable: false
},
{
name: 'amount_sats',
label: 'Bitcoin',
align: 'right',
field: 'amount_sats',
sortable: false
},
{
name: 'amount_fiat',
label: 'Fiat Amount',
align: 'right',
field: 'amount_fiat',
sortable: false
},
{
name: 'type',
label: 'Type',
align: 'center',
field: 'transaction_type',
sortable: false
},
{
name: 'status',
label: 'Status',
align: 'center',
field: 'status',
sortable: false
}
{name: 'date', label: 'Date', align: 'left',
field: row => row.transaction_time || row.created_at, sortable: false},
{name: 'amount_sats', label: 'Bitcoin', align: 'right',
field: 'amount_sats', sortable: false},
{name: 'amount_fiat', label: 'Fiat Amount', align: 'right',
field: 'amount_fiat', sortable: false},
{name: 'type', label: 'Type', align: 'center',
field: 'leg_type', sortable: false},
{name: 'status', label: 'Status', align: 'center',
field: 'status', sortable: false}
],
transactionPagination: {
sortBy: 'date',
descending: true,
page: 1,
rowsPerPage: 10
},
chartTimeRange: '30d',
dcaChart: null,
analyticsData: null,
chartLoading: false
}
}
},
methods: {
// Configuration Methods
async loadClientLimits() {
// -----------------------------------------------------------------
// Onboarding + preferences
// -----------------------------------------------------------------
async loadPreferences() {
// GET /preferences auto-creates the LP's dca_lp row with the
// authenticated wallet as the default DCA destination. This is
// the structural enforcement of the "LP must onboard before
// deposits work" gate on the operator side.
try {
const { data } = await LNbits.api.request(
const {data} = await LNbits.api.request(
'GET',
'/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',
'/satmachineclient/api/v1/dca-client/preferences',
this.g.user.wallets[0].adminkey
)
this.isRegistered = data.is_registered
this.registrationChecked = true
if (!this.isRegistered) {
// Fetch current user info to get the username
await this.loadCurrentUser()
this.registrationForm.selectedWallet = this.g.user.wallets[0]?.id || null
}
return data
} catch (error) {
console.error('Error checking registration status:', error)
this.error = 'Failed to check registration status'
this.registrationChecked = true
}
},
async loadCurrentUser() {
try {
const { data } = await LNbits.api.getAuthenticatedUser()
// Set username from API response with priority: display_name > username > email > fallback
const username = data.extra?.display_name || data.username || data.email
this.registrationForm.username = (username !== null && username !== undefined && username !== '')
? username
: `user_${this.g.user.id.substring(0, 8)}`
} catch (error) {
console.error('Error loading current user:', error)
// Fallback to generated username
this.registrationForm.username = `user_${this.g.user.id.substring(0, 8)}`
}
},
async registerClient() {
try {
// Prepare registration data using the form's username (already loaded from API)
const registrationData = {
dca_mode: this.registrationForm.dca_mode,
fixed_mode_daily_limit: this.registrationForm.fixed_mode_daily_limit,
username: this.registrationForm.username || `user_${this.g.user.id.substring(0, 8)}`
}
// Find the selected wallet object to get the adminkey
const selectedWallet = this.g.user.wallets.find(w => w.id === this.registrationForm.selectedWallet)
if (!selectedWallet) {
throw new Error('Selected wallet not found')
}
const { data } = await LNbits.api.request(
'POST',
'/satmachineclient/api/v1/register',
selectedWallet.adminkey,
registrationData
)
this.preferences = data
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()
this.registrationChecked = true
} catch (error) {
console.error('Error registering client:', error)
this.$q.notify({
type: 'negative',
message: error.detail || 'Failed to register for DCA',
position: 'top'
})
console.error('Error loading preferences:', error)
this.error = 'Failed to load DCA preferences'
this.registrationChecked = true
}
},
// Dashboard Methods
// -----------------------------------------------------------------
// Formatting helpers
// -----------------------------------------------------------------
formatCurrency(amount) {
if (!amount) return 'Q 0.00';
// Amount is already in GTQ
const gtqAmount = amount;
if (!amount) return 'Q 0.00'
return new Intl.NumberFormat('es-GT', {
style: 'currency',
currency: 'GTQ',
}).format(gtqAmount);
style: 'currency', currency: 'GTQ'
}).format(amount)
},
formatCurrencyWithCode(amount, currencyCode) {
if (!amount) return `${currencyCode} 0.00`;
// Amount is already in GTQ
const currencyAmount = amount;
if (!amount) return `${currencyCode} 0.00`
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
}).format(currencyAmount);
style: 'currency', currency: currencyCode
}).format(amount)
} catch (error) {
// Fallback if currency code is not supported
return `${currencyCode} ${currencyAmount.toFixed(2)}`;
return `${currencyCode} ${amount.toFixed(2)}`
}
},
formatDate(dateString) {
if (!dateString) return ''
const date = new Date(dateString)
if (isNaN(date.getTime())) {
console.warn('Invalid date string:', dateString)
return 'Invalid Date'
}
if (isNaN(date.getTime())) return 'Invalid Date'
return date.toLocaleDateString()
},
formatTime(dateString) {
if (!dateString) return ''
const date = new Date(dateString)
if (isNaN(date.getTime())) {
console.warn('Invalid time string:', dateString)
return 'Invalid Time'
}
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
})
if (isNaN(date.getTime())) return 'Invalid Time'
return date.toLocaleTimeString('en-US', {hour: '2-digit', minute: '2-digit'})
},
formatSats(amount) {
if (!amount) return '0 sats'
const formatted = new Intl.NumberFormat('en-US').format(amount)
// Add some excitement for larger amounts with consistent 5x→2x progression
if (amount >= 100000000) return formatted + ' sats 🏆' // Full coiner (1 BTC)
if (amount >= 50000000) return formatted + ' sats 🎆' // Bitcoin baron
if (amount >= 10000000) return formatted + ' sats 👑' // Bitcoin royalty
if (amount >= 5000000) return formatted + ' sats 🏆' // Verified bag holder
if (amount >= 1000000) return formatted + ' sats 🌟' // Millionaire
if (amount >= 500000) return formatted + ' sats 🔥' // Half million
if (amount >= 100000) return formatted + ' sats 🚀' // Getting serious
if (amount >= 50000) return formatted + ' sats ⚡' // Lightning quick
if (amount >= 10000) return formatted + ' sats 🎯' // First milestone
if (amount >= 100000000) return formatted + ' sats 🏆'
if (amount >= 50000000) return formatted + ' sats 🎆'
if (amount >= 10000000) return formatted + ' sats 👑'
if (amount >= 5000000) return formatted + ' sats 🏆'
if (amount >= 1000000) return formatted + ' sats 🌟'
if (amount >= 500000) return formatted + ' sats 🔥'
if (amount >= 100000) return formatted + ' sats 🚀'
if (amount >= 50000) return formatted + ' sats ⚡'
if (amount >= 10000) return formatted + ' sats 🎯'
return formatted + ' sats'
},
// -----------------------------------------------------------------
// Dashboard data
// -----------------------------------------------------------------
async loadDashboardData() {
try {
const { data } = await LNbits.api.request(
const {data} = await LNbits.api.request(
'GET',
'/satmachineclient/api/v1/dashboard/summary',
'/satmachineclient/api/v1/dca-client/positions',
this.g.user.wallets[0].adminkey
)
// Backend returns ClientDashboardSummary with no dca_mode field
// at the top level any more (it's LP-wide and lives on
// preferences); echo `default_dca_mode` from prefs so the
// legacy template renderers (`dashboardData.dca_mode`) keep
// working until the template is rewritten.
data.dca_mode = this.preferences?.default_dca_mode || 'flow'
data.dca_status = 'active'
this.dashboardData = data
} catch (error) {
// 404 from /positions = "LP isn't enrolled at any machine yet",
// which is a valid state, not an error. Show an empty dashboard.
if (error?.response?.status === 404) {
this.dashboardData = {
user_id: this.g.user.id,
total_sats_accumulated: 0,
total_fiat_invested: 0,
current_fiat_balance: 0,
pending_fiat_deposits: 0,
average_cost_basis: 0,
current_sats_fiat_value: 0,
total_transactions: 0,
total_machines: 0,
last_transaction_date: null,
currency: this.adminConfig.currency,
positions: [],
dca_mode: this.preferences?.default_dca_mode || 'flow',
dca_status: 'awaiting_enrolment'
}
} else {
console.error('Error loading dashboard data:', error)
this.error = 'Failed to load dashboard data'
}
}
},
async loadTransactions() {
try {
const { data } = await LNbits.api.request(
const {data} = await LNbits.api.request(
'GET',
'/satmachineclient/api/v1/dashboard/transactions?limit=50',
'/satmachineclient/api/v1/dca-client/transactions?limit=50',
this.g.user.wallets[0].adminkey
)
// Debug: Log the first transaction to see date format
if (data.length > 0) {
console.log('Sample transaction data:', data[0])
console.log('transaction_time:', data[0].transaction_time)
console.log('created_at:', data[0].created_at)
}
// Sort by most recent first and store
this.transactions = data.sort((a, b) => {
const dateA = new Date(a.transaction_time || a.created_at)
const dateB = new Date(b.transaction_time || b.created_at)
return dateB - dateA // Most recent first
return dateB - dateA
})
} catch (error) {
console.error('Error loading transactions:', error)
@ -294,6 +219,7 @@ window.app = Vue.createApp({
try {
this.loading = true
await Promise.all([
this.loadPreferences(),
this.loadDashboardData(),
this.loadTransactions()
])
@ -315,502 +241,67 @@ window.app = Vue.createApp({
}
},
// -----------------------------------------------------------------
// Milestone widget (purely cosmetic)
// -----------------------------------------------------------------
getNextMilestone() {
if (!this.dashboardData) return { target: 10000, name: '10k sats' }
if (!this.dashboardData) return {target: 10000, name: '10k sats'}
const sats = this.dashboardData.total_sats_accumulated
// Consistent 5x→2x progression pattern
if (sats < 10000) return { target: 10000, name: '10k sats' }
if (sats < 50000) return { target: 50000, name: '50k sats' }
if (sats < 100000) return { target: 100000, name: '100k sats' }
if (sats < 500000) return { target: 500000, name: '500k sats' }
if (sats < 1000000) return { target: 1000000, name: '1M sats' }
if (sats < 5000000) return { target: 5000000, name: '5M sats' }
if (sats < 10000000) return { target: 10000000, name: '10M sats' }
if (sats < 50000000) return { target: 50000000, name: '50M sats' }
if (sats < 100000000) return { target: 100000000, name: '100M sats (1 BTC!)' }
return { target: 500000000, name: '500M sats (5 BTC)' }
if (sats < 10000) return {target: 10000, name: '10k sats'}
if (sats < 50000) return {target: 50000, name: '50k sats'}
if (sats < 100000) return {target: 100000, name: '100k sats'}
if (sats < 500000) return {target: 500000, name: '500k sats'}
if (sats < 1000000) return {target: 1000000, name: '1M sats'}
if (sats < 5000000) return {target: 5000000, name: '5M sats'}
if (sats < 10000000) return {target: 10000000, name: '10M sats'}
if (sats < 50000000) return {target: 50000000, name: '50M sats'}
if (sats < 100000000) return {target: 100000000, name: '100M sats (1 BTC!)'}
return {target: 500000000, name: '500M sats (5 BTC)'}
},
getMilestoneProgress() {
if (!this.dashboardData) {
console.log('getMilestoneProgress: no dashboard data')
return 0
}
if (!this.dashboardData) return 0
const sats = this.dashboardData.total_sats_accumulated
const milestone = this.getNextMilestone()
// Show total progress toward the next milestone (from 0)
const progress = (sats / milestone.target) * 100
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
}
return Math.min(Math.max(progress, 0), 100)
},
initDCAChart() {
console.log('initDCAChart called')
console.log('analyticsData:', this.analyticsData)
console.log('dcaChart ref:', this.$refs.dcaChart)
console.log('chartLoading state:', this.chartLoading)
// Skip if we're not in a loading state (indicates this is a stale call)
if (!this.chartLoading && this.dcaChart) {
console.log('Chart already exists and not loading, skipping initialization')
return
}
if (!this.analyticsData) {
console.log('No analytics data available')
return
}
if (!this.$refs.dcaChart) {
console.log('No chart ref available, waiting for DOM...')
// Try again after DOM update, but only if still loading
this.$nextTick(() => {
if (this.$refs.dcaChart && this.chartLoading) {
this.initDCAChart()
}
// -----------------------------------------------------------------
// Stubs for the legacy registration wizard + chart panel.
// Those template branches are dead (the wizard never shows because
// isRegistered is true after auto-onboard; the chart panel has no
// backend analytics endpoint to feed it) but the .html still
// references these handlers. Keep stubs so a stray click doesn't
// throw an uncaught error.
// -----------------------------------------------------------------
async registerClient() {
// Old "register / pick wallet & mode" form is gone. Preferences
// are auto-created and editable via a separate path (TODO: add
// an editor card to this dashboard once the template gets a
// proper rewrite).
await this.loadPreferences()
this.$q.notify({
type: 'info',
message: 'Your DCA account is already set up — refreshed.',
position: 'top'
})
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)
},
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
}
loadChartData() {
// No backend analytics endpoint; chart panel is dead.
}
},
async created() {
try {
this.loading = true
// Load client limits first
await this.loadClientLimits()
// Check registration status
await this.checkRegistrationStatus()
// Only load dashboard data if registered
if (this.isRegistered) {
// Auto-onboard on load (creates dca_lp row if missing).
await this.loadPreferences()
// Then load dashboard + transactions.
await Promise.all([
this.loadDashboardData(),
this.loadTransactions(),
this.loadChartData()
this.loadTransactions()
])
}
} catch (error) {
console.error('Error initializing dashboard:', error)
this.error = 'Failed to initialize dashboard'
@ -819,23 +310,6 @@ window.app = Vue.createApp({
}
},
mounted() {
// Initialize chart after DOM is ready and data is loaded
this.$nextTick(() => {
console.log('Component mounted, checking for chart initialization')
console.log('Loading state:', this.loading)
console.log('Chart ref available:', !!this.$refs.dcaChart)
console.log('Analytics data available:', !!this.analyticsData)
if (this.analyticsData && this.$refs.dcaChart) {
console.log('Initializing chart from mounted hook')
this.initDCAChart()
} else {
console.log('Chart will initialize after data loads')
}
})
},
computed: {
hasData() {
return this.dashboardData && !this.loading && this.isRegistered
@ -848,23 +322,5 @@ window.app = Vue.createApp({
value: wallet.id
}))
}
},
watch: {
analyticsData: {
handler(newData) {
if (newData && !this.chartLoading && !this.dcaChart) {
console.log('Analytics data changed and no chart exists, initializing chart...')
this.$nextTick(() => {
// Only initialize if we don't have a chart and aren't currently loading
if (!this.dcaChart && !this.chartLoading) {
this.chartLoading = true
this.initDCAChart()
}
})
}
},
immediate: false
}
}
})

View file

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

View file

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

View file

@ -1,220 +1,109 @@
# Description: Client-focused API endpoints for DCA dashboard
# Satoshi Machine Client v2 — API.
#
# Read-mostly LP surface over the admin extension's v2 schema. Auth via
# wallet admin key (LP must be an LNbits user; the admin key identifies
# them as the wallet's owner).
#
# 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 typing import List, Optional
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException
from lnbits.core.models import WalletTypeInfo
from lnbits.decorators import require_admin_key
from starlette.exceptions import HTTPException
from .crud import (
ensure_lp_preferences,
get_client_dashboard_summary,
get_client_transactions,
get_client_analytics,
update_client_dca_settings,
get_client_by_user_id,
register_dca_client,
update_lp_preferences,
)
from .models import (
ClientDashboardSummary,
ClientTransaction,
ClientAnalytics,
UpdateClientSettings,
ClientRegistrationData,
LpPreferences,
UpdateLpPreferences,
)
satmachineclient_api_router = APIRouter()
###################################################
############## CLIENT REGISTRATION ###############
###################################################
@satmachineclient_api_router.post("/api/v1/register", status_code=HTTPStatus.CREATED)
async def api_register_client(
registration_data: ClientRegistrationData,
@satmachineclient_api_router.get(
"/api/v1/dca-client/preferences", response_model=LpPreferences
)
async def api_get_preferences(
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> dict:
"""Register a new DCA client
Clients can self-register using their wallet admin key.
Creates a new client entry in the satoshimachine database.
"""
result = await register_dca_client(
wallet.wallet.user,
wallet.wallet.id,
registration_data
) -> LpPreferences:
"""Return the LP's DCA preferences. Auto-creates the `dca_lp` row on
first call, seeded with the authenticated wallet as the default DCA
destination. The act of hitting this endpoint is what marks the LP
as "onboarded" on the operator side."""
return await ensure_lp_preferences(
wallet.wallet.user, default_wallet_id=wallet.wallet.id
)
if "error" in result:
if "already registered" in result["error"]:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail=result["error"]
)
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=result["error"]
)
return result
@satmachineclient_api_router.get("/api/v1/registration-status")
async def api_check_registration_status(
@satmachineclient_api_router.put(
"/api/v1/dca-client/preferences", response_model=LpPreferences
)
async def api_update_preferences(
data: UpdateLpPreferences,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> dict:
"""Check if user is already registered as a DCA client"""
client = await get_client_by_user_id(wallet.wallet.user)
return {
"is_registered": client is not None,
"client_id": client["id"] if client else None,
"dca_mode": client["dca_mode"] if client else None,
"status": client["status"] if client else None,
}
) -> LpPreferences:
"""LP-side update of DCA wallet / mode / autoforward. Operator can't
reach this path it requires the LP's wallet admin key."""
# Ensure the row exists before update so this endpoint is safe to
# call even if the LP somehow hits PUT before GET (they shouldn't,
# but the dashboard and any caller order shouldn't matter).
await ensure_lp_preferences(wallet.wallet.user, default_wallet_id=wallet.wallet.id)
updated = await update_lp_preferences(wallet.wallet.user, data)
assert updated is not None
return updated
###################################################
############## CLIENT DASHBOARD API ###############
###################################################
@satmachineclient_api_router.get("/api/v1/dashboard/summary")
async def api_get_dashboard_summary(
@satmachineclient_api_router.get(
"/api/v1/dca-client/positions", response_model=ClientDashboardSummary
)
async def api_get_positions(
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> ClientDashboardSummary:
"""Get client dashboard summary metrics"""
summary = await get_client_dashboard_summary(wallet.wallet.user)
if not summary:
"""LP's aggregated dashboard across every machine they're registered at,
plus a per-machine breakdown in the `positions` field.
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(
status_code=HTTPStatus.NOT_FOUND,
detail="Client data not found"
HTTPStatus.NOT_FOUND,
"No DCA positions found for this wallet's owner. "
"An operator must register you at a machine first.",
)
return summary
@satmachineclient_api_router.get("/api/v1/dashboard/transactions")
async def api_get_client_transactions(
@satmachineclient_api_router.get(
"/api/v1/dca-client/transactions", response_model=List[ClientTransaction]
)
async def api_get_transactions(
limit: int = 50,
offset: int = 0,
machine_id: Optional[str] = None,
wallet: WalletTypeInfo = Depends(require_admin_key),
limit: int = Query(50, ge=1, le=1000),
offset: int = Query(0, ge=0),
transaction_type: Optional[str] = Query(None),
start_date: Optional[datetime] = Query(None),
end_date: Optional[datetime] = Query(None),
) -> List[ClientTransaction]:
"""Get client's DCA transaction history with filtering"""
"""LP's distribution history. Returns only legs the LP can see (DCA,
balance-settle, auto-forward). Optionally filter by ?machine_id=X."""
user_id = wallet.wallet.user
return await get_client_transactions(
wallet.wallet.user,
limit=limit,
offset=offset,
transaction_type=transaction_type,
start_date=start_date,
end_date=end_date
user_id, limit=limit, offset=offset, machine_id=machine_id
)
@satmachineclient_api_router.get("/api/v1/dashboard/analytics")
async def api_get_client_analytics(
wallet: WalletTypeInfo = Depends(require_admin_key),
time_range: str = Query("30d", regex="^(7d|30d|90d|1y|all)$"),
) -> ClientAnalytics:
"""Get client performance analytics and cost basis data"""
try:
analytics = await get_client_analytics(wallet.wallet.user, time_range)
if not analytics:
# Return empty analytics data instead of error
return ClientAnalytics(
user_id=wallet.wallet.user,
cost_basis_history=[],
accumulation_timeline=[],
transaction_frequency={}
)
return analytics
except Exception as e:
print(f"Analytics error: {e}")
# Return empty analytics data as fallback
return ClientAnalytics(
user_id=wallet.wallet.user,
cost_basis_history=[],
accumulation_timeline=[],
transaction_frequency={}
)
@satmachineclient_api_router.put("/api/v1/dashboard/settings")
async def api_update_client_settings(
settings: UpdateClientSettings,
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