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__ # LNbits runtime data — auth keys, DB files, etc. Never commit.
node_modules data/
.mypy_cache *.sqlite3
.venv *.sqlite3-journal
__pycache__/
*.pyc

View file

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

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 typing import List, Optional
from datetime import datetime, timedelta
from lnbits.db import Database from lnbits.db import Database
from lnbits.utils.exchange_rates import satoshis_amount_as_fiat from lnbits.utils.exchange_rates import satoshis_amount_as_fiat
from lnbits.core.crud.wallets import get_wallet from loguru import logger
from .models import ( from .models import (
ClientDashboardSummary, ClientDashboardSummary,
ClientTransaction, ClientTransaction,
ClientAnalytics, LpPreferences,
UpdateClientSettings, PerMachinePosition,
ClientRegistrationData, UpdateLpPreferences,
) )
# Connect to admin extension's database # Same DB schema as the admin extension — we share satoshimachine.* tables.
db = Database("ext_satoshimachine") db = Database("ext_satoshimachine")
################################################### async def _fetch_user_clients(user_id: str) -> List[dict]:
############## CLIENT DASHBOARD CRUD ############## """All dca_clients rows for this LP, joined with their machines for
################################################### per-machine display metadata.
async def get_client_dashboard_summary(user_id: str) -> Optional[ClientDashboardSummary]: `dca_mode` lives on the LP's per-user `dca_lp` row now, not per
"""Get dashboard summary for a specific user""" enrolment INNER JOIN dca_lp so positions only return when the LP
has actually onboarded (otherwise distribution can't pay them either,
# Get client info so listing them in the dashboard would be misleading).
client = await db.fetchone( """
"SELECT * FROM satoshimachine.dca_clients WHERE user_id = :user_id", return await db.fetchall(
{"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]
# Get wallet to determine currency total_sats = sum(p.total_sats_accumulated for p in positions)
wallet = await get_wallet(client["wallet_id"]) total_invested = round(sum(p.total_fiat_invested for p in positions), 2)
# TODO: Get currency from wallet; bit more difficult to do in a different total_balance = round(sum(p.current_fiat_balance for p in positions), 2)
# currency than deposit cause of cross exchange rates total_tx = sum(p.total_transactions for p in positions)
# currency = wallet.currency or "GTQ" # Default to GTQ if no currency set last_tx = max(
currency = "GTQ" # Default to GTQ if no currency set (p.last_transaction_date for p in positions if p.last_transaction_date),
default=None,
# Get total sats accumulated from DCA transactions
sats_result = await db.fetchone(
"""
SELECT COALESCE(SUM(amount_sats), 0) as total_sats
FROM satoshimachine.dca_payments
WHERE client_id = :client_id AND status = 'confirmed'
""",
{"client_id": client["id"]}
) )
# Pending deposits (across all clients of this LP).
# Get total confirmed deposits (this is the "total invested") pending = await db.fetchone(
deposits_result = await db.fetchone(
""" """
SELECT COALESCE(SUM(amount), 0) as confirmed_deposits SELECT COALESCE(SUM(d.amount), 0) AS total
FROM satoshimachine.dca_deposits FROM satoshimachine.dca_deposits d
WHERE client_id = :client_id AND status = 'confirmed' JOIN satoshimachine.dca_clients c ON c.id = d.client_id
WHERE c.user_id = :uid AND d.status = 'pending'
""", """,
{"client_id": client["id"]} {"uid": user_id},
) )
pending_fiat = float(pending["total"]) if pending else 0.0
# Get total pending deposits (for additional info) # Cost basis = sats / fiat-spent. Use the distributed fiat (deposits
pending_deposits_result = await db.fetchone( # less remaining balance) so we get the cost basis on what's actually
""" # been DCA'd, not the gross deposits.
SELECT COALESCE(SUM(amount), 0) as pending_deposits fiat_spent = max(total_invested - total_balance, 0.0)
FROM satoshimachine.dca_deposits cost_basis = total_sats / fiat_spent if fiat_spent > 0 else 0.0
WHERE client_id = :client_id AND status = 'pending' # Display currency: if all positions share one, use it; else "MIX".
""", currencies = {p.currency for p in positions}
{"client_id": client["id"]} currency = currencies.pop() if len(currencies) == 1 else "MIX"
) # Best-effort current fiat value of the LP's sats stack.
current_value = 0.0
# Get total fiat spent on DCA transactions (to calculate remaining balance) if total_sats > 0 and currency != "MIX":
dca_spent_result = await db.fetchone(
"""
SELECT COALESCE(SUM(amount_fiat), 0) as dca_spent
FROM satoshimachine.dca_payments
WHERE client_id = :client_id AND status = 'confirmed'
""",
{"client_id": client["id"]}
)
# Get transaction count and last transaction date
tx_stats = await db.fetchone(
"""
SELECT
COUNT(*) as tx_count,
MAX(created_at) as last_tx_date
FROM satoshimachine.dca_payments
WHERE client_id = :client_id AND status = 'confirmed'
""",
{"client_id": client["id"]}
)
# Extract values from query results
total_sats = sats_result["total_sats"] if sats_result else 0
confirmed_deposits = deposits_result["confirmed_deposits"] if deposits_result else 0
pending_deposits = pending_deposits_result["pending_deposits"] if pending_deposits_result else 0
dca_spent = dca_spent_result["dca_spent"] if dca_spent_result else 0
# Calculate metrics
total_invested = confirmed_deposits # Total invested = all confirmed deposits
remaining_balance = confirmed_deposits - dca_spent # Remaining = deposits - DCA spending
avg_cost_basis = total_sats / dca_spent if dca_spent > 0 else 0 # Cost basis = sats / GTQ
# Calculate current fiat value of total sats
current_sats_fiat_value = 0.0
if total_sats > 0:
try: try:
current_sats_fiat_value = await satoshis_amount_as_fiat(total_sats, currency) current_value = await satoshis_amount_as_fiat(total_sats, currency)
except Exception as e: except Exception as exc:
print(f"Warning: Could not fetch exchange rate for {currency}: {e}") logger.warning(
current_sats_fiat_value = 0.0 f"satmachineclient: could not fetch exchange rate for "
f"{currency}: {exc}"
)
return ClientDashboardSummary( return ClientDashboardSummary(
user_id=user_id, user_id=user_id,
total_sats_accumulated=total_sats, total_sats_accumulated=total_sats,
total_fiat_invested=total_invested, # Sum of confirmed deposits total_fiat_invested=total_invested,
pending_fiat_deposits=pending_deposits, # Sum of pending deposits current_fiat_balance=total_balance,
current_sats_fiat_value=current_sats_fiat_value, # Current fiat value of sats pending_fiat_deposits=round(pending_fiat, 2),
average_cost_basis=avg_cost_basis, average_cost_basis=round(cost_basis, 4),
current_fiat_balance=remaining_balance, # Confirmed deposits - DCA spent current_sats_fiat_value=round(current_value, 2),
total_transactions=tx_stats["tx_count"] if tx_stats else 0, total_transactions=total_tx,
dca_mode=client["dca_mode"], total_machines=len(positions),
dca_status=client["status"], last_transaction_date=last_tx,
last_transaction_date=tx_stats["last_tx_date"] if tx_stats else None, currency=currency,
currency=currency # Wallet's currency positions=positions,
) )
@ -134,388 +163,115 @@ async def get_client_transactions(
user_id: str, user_id: str,
limit: int = 50, limit: int = 50,
offset: int = 0, offset: int = 0,
transaction_type: Optional[str] = None, machine_id: Optional[str] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> List[ClientTransaction]: ) -> List[ClientTransaction]:
"""Get client's transaction history with filtering""" """LP's transaction history. Filters to 'dca' / 'settlement' /
'autoforward' legs since those are the ones the LP cares about super_fee
and operator_split legs are operator-internal.
# Get client ID first Optional machine_id narrows to a single machine."""
client = await db.fetchone( params: dict = {
"SELECT id FROM satoshimachine.dca_clients WHERE user_id = :user_id", "uid": user_id,
{"user_id": user_id} "lim": limit,
"off": offset,
}
where = (
"WHERE c.user_id = :uid "
"AND p.leg_type IN ('dca', 'settlement', 'autoforward')"
) )
if machine_id is not None:
if not client: where += " AND p.machine_id = :mid"
return [] params["mid"] = machine_id
rows = await db.fetchall(
# Build query with filters
where_conditions = ["client_id = :client_id"]
params = {"client_id": client["id"], "limit": limit, "offset": offset}
if transaction_type:
where_conditions.append("transaction_type = :transaction_type")
params["transaction_type"] = transaction_type
if start_date:
where_conditions.append("created_at >= :start_date")
params["start_date"] = start_date
if end_date:
where_conditions.append("created_at <= :end_date")
params["end_date"] = end_date
where_clause = " AND ".join(where_conditions)
transactions = await db.fetchall(
f""" f"""
SELECT id, amount_sats, amount_fiat, exchange_rate, transaction_type, SELECT p.id, p.machine_id, p.settlement_id, p.leg_type,
status, created_at, transaction_time, lamassu_transaction_id p.amount_sats, p.amount_fiat, p.exchange_rate, p.status,
FROM satoshimachine.dca_payments p.created_at, p.transaction_time,
WHERE {where_clause} m.machine_npub
ORDER BY created_at DESC FROM satoshimachine.dca_payments p
LIMIT :limit OFFSET :offset JOIN satoshimachine.dca_clients c ON c.id = p.client_id
JOIN satoshimachine.dca_machines m ON m.id = p.machine_id
{where}
ORDER BY p.created_at DESC
LIMIT :lim OFFSET :off
""", """,
params params,
) )
return [ return [
ClientTransaction( ClientTransaction(
id=tx["id"], id=row["id"],
amount_sats=tx["amount_sats"], machine_id=row["machine_id"],
amount_fiat=tx["amount_fiat"], machine_npub=row["machine_npub"],
exchange_rate=tx["exchange_rate"], settlement_id=row["settlement_id"],
transaction_type=tx["transaction_type"], leg_type=row["leg_type"],
status=tx["status"], amount_sats=row["amount_sats"],
created_at=tx["created_at"], amount_fiat=row["amount_fiat"],
transaction_time=tx["transaction_time"], exchange_rate=row["exchange_rate"],
lamassu_transaction_id=tx["lamassu_transaction_id"] status=row["status"],
created_at=row["created_at"],
transaction_time=row["transaction_time"],
) )
for tx in transactions for row in rows
] ]
async def get_client_analytics(user_id: str, time_range: str = "30d") -> Optional[ClientAnalytics]: async def get_lp_preferences(user_id: str) -> Optional[LpPreferences]:
"""Get client performance analytics""" """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
try: use `ensure_lp_preferences` instead, which auto-creates on first
from datetime import datetime access."""
# 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_clients WHERE user_id = :user_id", "SELECT * FROM satoshimachine.dca_lp WHERE user_id = :uid",
{"user_id": user_id} {"uid": user_id},
LpPreferences,
) )
async def update_client_dca_settings(client_id: str, settings: UpdateClientSettings) -> bool: async def ensure_lp_preferences(user_id: str, default_wallet_id: str) -> LpPreferences:
"""Update client DCA settings (mode, limits, status)""" """Get-or-create the LP's preferences row.
try:
update_data = {k: v for k, v in settings.dict().items() if v is not None}
if not update_data:
return True # Nothing to update
update_data["updated_at"] = datetime.now() First call (no row): seed with `default_wallet_id` (passed by the
set_clause = ", ".join([f"{k} = :{k}" for k in update_data.keys()]) caller typically the wallet the LP authenticated through). LP can
update_data["id"] = client_id change it later via `update_lp_preferences`.
await db.execute( This is the structural enforcement of the "LP must onboard before
f"UPDATE satoshimachine.dca_clients SET {set_clause} WHERE id = :id", deposits work" gate: the act of opening satmachineclient and hitting
update_data any endpoint creates the dca_lp row, which unlocks deposit creation
) on the operator side.
return True """
except Exception: existing = await get_lp_preferences(user_id)
return False if existing is not None:
return existing
now = datetime.now()
###################################################
############## CLIENT REGISTRATION ###############
###################################################
async def register_dca_client(user_id: str, wallet_id: str, registration_data: ClientRegistrationData) -> Optional[dict]:
"""Register a new DCA client - special permission for self-registration"""
from lnbits.helpers import urlsafe_short_hash
from lnbits.core.crud import get_user
try:
# Verify user exists and get username
user = await get_user(user_id)
username = registration_data.username or (user.username if user else f"user_{user_id[:8]}")
# Check if client already exists
existing_client = await db.fetchone(
"SELECT id FROM satoshimachine.dca_clients WHERE user_id = :user_id",
{"user_id": user_id}
)
if existing_client:
return {"error": "Client already registered", "client_id": existing_client[0]}
# Create new client
client_id = urlsafe_short_hash()
await db.execute( await db.execute(
""" """
INSERT INTO satoshimachine.dca_clients INSERT INTO satoshimachine.dca_lp
(id, user_id, wallet_id, username, dca_mode, fixed_mode_daily_limit, status, created_at, updated_at) (user_id, dca_wallet_id, default_dca_mode,
VALUES (:id, :user_id, :wallet_id, :username, :dca_mode, :fixed_mode_daily_limit, :status, :created_at, :updated_at) autoforward_enabled, created_at, updated_at)
VALUES (:uid, :wallet, 'flow', false, :now, :now)
""", """,
{ {"uid": user_id, "wallet": default_wallet_id, "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()
}
) )
created = await get_lp_preferences(user_id)
return { assert created is not None
"success": True, return created
"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]: async def update_lp_preferences(
"""Get client by user_id - returns dict instead of model for easier access""" user_id: str, data: UpdateLpPreferences
try: ) -> Optional[LpPreferences]:
client = await db.fetchone( """LP-side update of their `dca_lp` row. Caller must ensure the row
"SELECT * FROM satoshimachine.dca_clients WHERE user_id = :user_id", exists first (typically via `ensure_lp_preferences` on dashboard
{"user_id": user_id} 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 return await get_lp_preferences(user_id)
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

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 datetime import datetime
from typing import List, Optional from typing import List, Optional
@ -6,95 +14,91 @@ from typing import List, Optional
from pydantic import BaseModel from pydantic import BaseModel
# API Models for Client Dashboard (Frontend communication in GTQ) class PerMachinePosition(BaseModel):
class ClientDashboardSummaryAPI(BaseModel): """LP's position at a single machine.
"""API model - client dashboard summary in GTQ"""
user_id: str
total_sats_accumulated: int
total_fiat_invested_gtq: float # Confirmed deposits in GTQ
pending_fiat_deposits_gtq: float # Pending deposits in GTQ
current_sats_fiat_value_gtq: float # Current fiat value of total sats in GTQ
average_cost_basis: float # Average sats per GTQ
current_fiat_balance_gtq: float # Available balance for DCA in GTQ
total_transactions: int
dca_mode: str # 'flow' or 'fixed'
dca_status: str # 'active' or 'inactive'
last_transaction_date: Optional[datetime]
currency: str = "GTQ"
`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): machine_id: str
"""API model - client transaction in GTQ""" machine_npub: str
id: str machine_name: Optional[str]
amount_sats: int machine_location: Optional[str]
amount_fiat_gtq: float # Amount in GTQ currency: str
exchange_rate: float dca_mode: str
transaction_type: str # 'flow', 'fixed', 'manual'
status: str status: str
created_at: datetime total_sats_accumulated: int
transaction_time: Optional[datetime] = None # Original ATM transaction time total_fiat_invested: float
lamassu_transaction_id: Optional[str] = None current_fiat_balance: float
total_transactions: int
last_transaction_date: Optional[datetime]
# Internal Models for Client Dashboard (Database storage in GTQ)
class ClientDashboardSummary(BaseModel): class ClientDashboardSummary(BaseModel):
"""Internal model - client dashboard summary stored in GTQ""" """LP's aggregated dashboard across all machines they're registered at."""
user_id: str user_id: str
total_sats_accumulated: int total_sats_accumulated: int
total_fiat_invested: float # Confirmed deposits in GTQ total_fiat_invested: float # confirmed deposits across all machines
pending_fiat_deposits: float # Pending deposits awaiting confirmation in GTQ current_fiat_balance: float # confirmed deposits - DCA - settlement legs
current_sats_fiat_value: float # Current fiat value of total sats in GTQ pending_fiat_deposits: float # deposits in 'pending' status
average_cost_basis: float # Average sats per GTQ average_cost_basis: float # total_sats / total_fiat_invested-spent
current_fiat_balance: float # Available balance for DCA in GTQ current_sats_fiat_value: float # current rate × total_sats (best-effort)
total_transactions: int total_transactions: int
dca_mode: str # 'flow' or 'fixed' total_machines: int # how many machines this LP is on
dca_status: str # 'active' or 'inactive'
last_transaction_date: Optional[datetime] last_transaction_date: Optional[datetime]
currency: str = "GTQ" currency: str # display currency; if multi-currency, "MIX"
positions: List[PerMachinePosition] = []
class 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): class ClientTransaction(BaseModel):
"""Internal model - client transaction stored in GTQ""" """A single distribution leg landing in the LP's wallet."""
id: str id: str
machine_id: str
machine_npub: str
settlement_id: Optional[str]
leg_type: str # 'dca' | 'settlement' | 'autoforward' (LP-visible)
amount_sats: int amount_sats: int
amount_fiat: float # Amount in GTQ (e.g., 150.75) amount_fiat: Optional[float]
exchange_rate: float exchange_rate: Optional[float]
transaction_type: str # 'flow', 'fixed', 'manual'
status: str status: str
created_at: datetime created_at: datetime
transaction_time: Optional[datetime] = None # Original ATM transaction time transaction_time: datetime
lamassu_transaction_id: Optional[str] = None
class ClientAnalytics(BaseModel):
"""Performance analytics for client dashboard"""
user_id: str
cost_basis_history: List[dict] # Historical cost basis data points
accumulation_timeline: List[dict] # Sats accumulated over time
transaction_frequency: dict # Transaction frequency metrics
performance_vs_market: Optional[dict] = None # Market comparison data
class ClientPreferences(BaseModel):
"""Client dashboard preferences and settings"""
user_id: str
preferred_currency: str = "GTQ"
dashboard_theme: str = "light"
chart_time_range: str = "30d" # Default chart time range
notification_preferences: dict = {}
class UpdateClientSettings(BaseModel):
"""Settings that client can modify"""
dca_mode: Optional[str] = None # 'flow' or 'fixed'
fixed_mode_daily_limit: Optional[int] = None
status: Optional[str] = None # 'active' or 'inactive'
class ClientRegistrationData(BaseModel):
"""Data for client self-registration"""
dca_mode: str = "flow" # Default to flow mode
fixed_mode_daily_limit: Optional[int] = None
username: Optional[str] = None
# `UpdateClientAutoforward` removed in the dca_lp refactor; preferences
# (autoforward, wallet, mode) now flow through `UpdateLpPreferences`
# against `PUT /api/v1/dca-client/preferences`.

View file

@ -1,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({ window.app = Vue.createApp({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [windowMixin],
delimiters: ['${', '}'], delimiters: ['${', '}'],
data: function () { data: function () {
return { return {
// Registration state // Registration / onboarding (legacy; always true after initial
isRegistered: false, // /preferences call because the endpoint auto-creates).
registrationChecked: false, isRegistered: true,
registrationChecked: true,
registrationForm: { registrationForm: {
selectedWallet: null, selectedWallet: null,
dca_mode: 'flow', dca_mode: 'flow',
@ -14,7 +30,11 @@ window.app = Vue.createApp({
username: '' 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: { adminConfig: {
max_daily_limit_gtq: 2000, max_daily_limit_gtq: 2000,
currency: 'GTQ' currency: 'GTQ'
@ -25,260 +45,165 @@ window.app = Vue.createApp({
transactions: [], transactions: [],
loading: true, loading: true,
error: null, 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: [ transactionColumns: [
{ {name: 'date', label: 'Date', align: 'left',
name: 'date', field: row => row.transaction_time || row.created_at, sortable: false},
label: 'Date', {name: 'amount_sats', label: 'Bitcoin', align: 'right',
align: 'left', field: 'amount_sats', sortable: false},
field: row => row.transaction_time || row.created_at, {name: 'amount_fiat', label: 'Fiat Amount', align: 'right',
sortable: false field: 'amount_fiat', sortable: false},
}, {name: 'type', label: 'Type', align: 'center',
{ field: 'leg_type', sortable: false},
name: 'amount_sats', {name: 'status', label: 'Status', align: 'center',
label: 'Bitcoin', field: 'status', sortable: false}
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 // -----------------------------------------------------------------
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 { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'GET', 'GET',
'/satmachineadmin/api/v1/dca/client-limits' '/satmachineclient/api/v1/dca-client/preferences',
// 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 = 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.isRegistered = true this.isRegistered = true
this.registrationChecked = 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) { } catch (error) {
console.error('Error registering client:', error) console.error('Error loading preferences:', error)
this.$q.notify({ this.error = 'Failed to load DCA preferences'
type: 'negative', this.registrationChecked = true
message: error.detail || 'Failed to register for DCA',
position: 'top'
})
} }
}, },
// Dashboard Methods // -----------------------------------------------------------------
// Formatting helpers
// -----------------------------------------------------------------
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', style: 'currency', currency: 'GTQ'
currency: 'GTQ', }).format(amount)
}).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', style: 'currency', currency: currencyCode
currency: currencyCode, }).format(amount)
}).format(currencyAmount);
} catch (error) { } catch (error) {
// Fallback if currency code is not supported return `${currencyCode} ${amount.toFixed(2)}`
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())) { if (isNaN(date.getTime())) return 'Invalid Date'
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())) { if (isNaN(date.getTime())) return 'Invalid Time'
console.warn('Invalid time string:', dateString) return date.toLocaleTimeString('en-US', {hour: '2-digit', minute: '2-digit'})
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)
// Add some excitement for larger amounts with consistent 5x→2x progression if (amount >= 100000000) return formatted + ' sats 🏆'
if (amount >= 100000000) return formatted + ' sats 🏆' // Full coiner (1 BTC) if (amount >= 50000000) return formatted + ' sats 🎆'
if (amount >= 50000000) return formatted + ' sats 🎆' // Bitcoin baron if (amount >= 10000000) return formatted + ' sats 👑'
if (amount >= 10000000) return formatted + ' sats 👑' // Bitcoin royalty if (amount >= 5000000) return formatted + ' sats 🏆'
if (amount >= 5000000) return formatted + ' sats 🏆' // Verified bag holder if (amount >= 1000000) return formatted + ' sats 🌟'
if (amount >= 1000000) return formatted + ' sats 🌟' // Millionaire if (amount >= 500000) return formatted + ' sats 🔥'
if (amount >= 500000) return formatted + ' sats 🔥' // Half million if (amount >= 100000) return formatted + ' sats 🚀'
if (amount >= 100000) return formatted + ' sats 🚀' // Getting serious if (amount >= 50000) return formatted + ' sats ⚡'
if (amount >= 50000) return formatted + ' sats ⚡' // Lightning quick if (amount >= 10000) return formatted + ' sats 🎯'
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/dashboard/summary', '/satmachineclient/api/v1/dca-client/positions',
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",
// 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) console.error('Error loading dashboard data:', error)
this.error = 'Failed to load dashboard data' 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/dashboard/transactions?limit=50', '/satmachineclient/api/v1/dca-client/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 // Most recent first return dateB - dateA
}) })
} catch (error) { } catch (error) {
console.error('Error loading transactions:', error) console.error('Error loading transactions:', error)
@ -294,6 +219,7 @@ 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()
]) ])
@ -315,11 +241,12 @@ 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
// Consistent 5x→2x progression pattern
if (sats < 10000) return {target: 10000, name: '10k sats'} if (sats < 10000) return {target: 10000, name: '10k sats'}
if (sats < 50000) return {target: 50000, name: '50k sats'} if (sats < 50000) return {target: 50000, name: '50k sats'}
if (sats < 100000) return {target: 100000, name: '100k sats'} if (sats < 100000) return {target: 100000, name: '100k sats'}
@ -333,484 +260,48 @@ window.app = Vue.createApp({
}, },
getMilestoneProgress() { getMilestoneProgress() {
if (!this.dashboardData) { if (!this.dashboardData) return 0
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
const result = Math.min(Math.max(progress, 0), 100) return 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() { // -----------------------------------------------------------------
console.log('initDCAChart called') // Stubs for the legacy registration wizard + chart panel.
console.log('analyticsData:', this.analyticsData) // Those template branches are dead (the wizard never shows because
console.log('dcaChart ref:', this.$refs.dcaChart) // isRegistered is true after auto-onboard; the chart panel has no
console.log('chartLoading state:', this.chartLoading) // backend analytics endpoint to feed it) but the .html still
// references these handlers. Keep stubs so a stray click doesn't
// Skip if we're not in a loading state (indicates this is a stale call) // throw an uncaught error.
if (!this.chartLoading && this.dcaChart) { // -----------------------------------------------------------------
console.log('Chart already exists and not loading, skipping initialization') async registerClient() {
return // 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
if (!this.analyticsData) { // proper rewrite).
console.log('No analytics data available') await this.loadPreferences()
return this.$q.notify({
} type: 'info',
message: 'Your DCA account is already set up — refreshed.',
if (!this.$refs.dcaChart) { position: 'top'
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: { loadChartData() {
responsive: true, // No backend analytics endpoint; chart panel is dead.
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
}
} }
}, },
async created() { async created() {
try { try {
this.loading = true this.loading = true
// Auto-onboard on load (creates dca_lp row if missing).
// Load client limits first await this.loadPreferences()
await this.loadClientLimits() // Then load dashboard + transactions.
// Check registration status
await this.checkRegistrationStatus()
// Only load dashboard data if registered
if (this.isRegistered) {
await Promise.all([ await Promise.all([
this.loadDashboardData(), this.loadDashboardData(),
this.loadTransactions(), 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'
@ -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: { computed: {
hasData() { hasData() {
return this.dashboardData && !this.loading && this.isRegistered return this.dashboardData && !this.loading && this.isRegistered
@ -848,23 +322,5 @@ 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
}
} }
}) })

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