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>
This commit is contained in:
Padreug 2026-05-14 17:53:13 +02:00
commit ade4e67541
8 changed files with 289 additions and 738 deletions

View file

@ -1,220 +1,81 @@
# 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).
#
# Maintenance-mode note: richer LP UI is moving to ~/dev/webapp. Keep this
# surface stable + minimal.
from http import HTTPStatus
from typing import List, Optional
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException
from lnbits.core.models import WalletTypeInfo
from lnbits.decorators import require_admin_key
from starlette.exceptions import HTTPException
from .crud import (
get_client_dashboard_summary,
get_client_transactions,
get_client_analytics,
update_client_dca_settings,
get_client_by_user_id,
register_dca_client,
update_lp_autoforward,
)
from .models import (
ClientDashboardSummary,
ClientTransaction,
ClientAnalytics,
UpdateClientSettings,
ClientRegistrationData,
UpdateClientAutoforward,
)
satmachineclient_api_router = APIRouter()
###################################################
############## CLIENT REGISTRATION ###############
###################################################
@satmachineclient_api_router.post("/api/v1/register", status_code=HTTPStatus.CREATED)
async def api_register_client(
registration_data: ClientRegistrationData,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> dict:
"""Register a new DCA client
Clients can self-register using their wallet admin key.
Creates a new client entry in the satoshimachine database.
"""
result = await register_dca_client(
wallet.wallet.user,
wallet.wallet.id,
registration_data
)
if "error" in result:
if "already registered" in result["error"]:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail=result["error"]
)
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=result["error"]
)
return result
@satmachineclient_api_router.get("/api/v1/registration-status")
async def api_check_registration_status(
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> dict:
"""Check if user is already registered as a DCA client"""
client = await get_client_by_user_id(wallet.wallet.user)
return {
"is_registered": client is not None,
"client_id": client["id"] if client else None,
"dca_mode": client["dca_mode"] if client else None,
"status": client["status"] if client else None,
}
###################################################
############## CLIENT DASHBOARD API ###############
###################################################
@satmachineclient_api_router.get("/api/v1/dashboard/summary")
async def api_get_dashboard_summary(
@satmachineclient_api_router.get(
"/api/v1/dca-client/positions", response_model=ClientDashboardSummary
)
async def api_get_positions(
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> ClientDashboardSummary:
"""Get client dashboard summary metrics"""
summary = await get_client_dashboard_summary(wallet.wallet.user)
if not summary:
"""LP's aggregated dashboard across every machine they're registered at,
plus a per-machine breakdown in the `positions` field."""
user_id = wallet.wallet.user
summary = await get_client_dashboard_summary(user_id)
if summary is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Client data not found"
HTTPStatus.NOT_FOUND,
"No DCA positions found for this wallet's owner. "
"An operator must register you at a machine first.",
)
return summary
@satmachineclient_api_router.get("/api/v1/dashboard/transactions")
async def api_get_client_transactions(
@satmachineclient_api_router.get(
"/api/v1/dca-client/transactions", response_model=List[ClientTransaction]
)
async def api_get_transactions(
limit: int = 50,
offset: int = 0,
machine_id: Optional[str] = None,
wallet: WalletTypeInfo = Depends(require_admin_key),
limit: int = Query(50, ge=1, le=1000),
offset: int = Query(0, ge=0),
transaction_type: Optional[str] = Query(None),
start_date: Optional[datetime] = Query(None),
end_date: Optional[datetime] = Query(None),
) -> List[ClientTransaction]:
"""Get client's DCA transaction history with filtering"""
"""LP's distribution history. Returns only legs the LP can see (DCA,
balance-settle, auto-forward). Optionally filter by ?machine_id=X."""
user_id = wallet.wallet.user
return await get_client_transactions(
wallet.wallet.user,
limit=limit,
offset=offset,
transaction_type=transaction_type,
start_date=start_date,
end_date=end_date
user_id, limit=limit, offset=offset, machine_id=machine_id
)
@satmachineclient_api_router.get("/api/v1/dashboard/analytics")
async def api_get_client_analytics(
wallet: WalletTypeInfo = Depends(require_admin_key),
time_range: str = Query("30d", regex="^(7d|30d|90d|1y|all)$"),
) -> ClientAnalytics:
"""Get client performance analytics and cost basis data"""
try:
analytics = await get_client_analytics(wallet.wallet.user, time_range)
if not analytics:
# Return empty analytics data instead of error
return ClientAnalytics(
user_id=wallet.wallet.user,
cost_basis_history=[],
accumulation_timeline=[],
transaction_frequency={}
)
return analytics
except Exception as e:
print(f"Analytics error: {e}")
# Return empty analytics data as fallback
return ClientAnalytics(
user_id=wallet.wallet.user,
cost_basis_history=[],
accumulation_timeline=[],
transaction_frequency={}
)
@satmachineclient_api_router.put("/api/v1/dashboard/settings")
async def api_update_client_settings(
settings: UpdateClientSettings,
@satmachineclient_api_router.put(
"/api/v1/dca-client/autoforward", response_model=dict
)
async def api_update_autoforward(
data: UpdateClientAutoforward,
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"}
"""LP controls their own auto-forward setting (where DCA distributions
get forwarded to externally, per satmachineadmin#8). Applies to all of
the LP's dca_clients rows; operator can't override LP-controlled
settings."""
user_id = wallet.wallet.user
n = await update_lp_autoforward(user_id, data)
return {"updated_clients": n}
@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