Compare commits

..

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

9 changed files with 1433 additions and 540 deletions

10
.gitignore vendored
View file

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

View file

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

704
crud.py
View file

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

View file

@ -1,2 +1,2 @@
# No database migrations needed for client extension # No database migrations needed for client extension
# Client extension reads from admin extension's database (ext_satoshimachine schema) # Client extension reads from admin extension's database (ext_satoshimachine schema)

160
models.py
View file

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

View file

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

2
tasks.py Normal file
View file

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

2
transaction_processor.py Normal file
View file

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

View file

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