diff --git a/crud.py b/crud.py index 4798599..16ac206 100644 --- a/crud.py +++ b/crud.py @@ -1,8 +1,9 @@ # 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; this client surface is -# strictly read + LP-self autoforward toggle. +# 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 @@ -14,8 +15,9 @@ from loguru import logger from .models import ( ClientDashboardSummary, ClientTransaction, + LpPreferences, PerMachinePosition, - UpdateClientAutoforward, + UpdateLpPreferences, ) # Same DB schema as the admin extension — we share satoshimachine.* tables. @@ -24,20 +26,26 @@ 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 - per-machine display metadata.""" + per-machine display metadata. + + `dca_mode` lives on the LP's per-user `dca_lp` row now, not per + enrolment — INNER JOIN dca_lp so positions only return when the LP + has actually onboarded (otherwise distribution can't pay them either, + so listing them in the dashboard would be misleading). + """ return await db.fetchall( """ SELECT c.id AS client_id, c.machine_id, - c.wallet_id, - c.dca_mode, c.status, m.machine_npub, m.name AS machine_name, m.location AS machine_location, - m.fiat_code AS machine_fiat_code + 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 """, @@ -207,30 +215,63 @@ async def get_client_transactions( ] -async def update_lp_autoforward( - user_id: str, data: UpdateClientAutoforward -) -> int: - """LPs control their own auto-forward. Update applies to ALL of this - LP's dca_clients rows (every machine they're on) — operator can't - override LP-controlled settings.""" +async def get_lp_preferences(user_id: str) -> Optional[LpPreferences]: + """Read this LP's preferences row from `dca_lp`. Returns None if the + LP hasn't onboarded yet (no row). Callers in this extension generally + use `ensure_lp_preferences` instead, which auto-creates on first + access.""" + return await db.fetchone( + "SELECT * FROM satoshimachine.dca_lp WHERE user_id = :uid", + {"uid": user_id}, + LpPreferences, + ) + + +async def ensure_lp_preferences(user_id: str, default_wallet_id: str) -> LpPreferences: + """Get-or-create the LP's preferences row. + + First call (no row): seed with `default_wallet_id` (passed by the + caller — typically the wallet the LP authenticated through). LP can + change it later via `update_lp_preferences`. + + This is the structural enforcement of the "LP must onboard before + deposits work" gate: the act of opening satmachineclient and hitting + any endpoint creates the dca_lp row, which unlocks deposit creation + on the operator side. + """ + existing = await get_lp_preferences(user_id) + if existing is not None: + return existing + now = datetime.now() + await db.execute( + """ + INSERT INTO satoshimachine.dca_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 +) -> Optional[LpPreferences]: + """LP-side update of their `dca_lp` row. Caller must ensure the row + exists first (typically via `ensure_lp_preferences` on dashboard + load). Operator cannot reach this path — it requires the LP's wallet + admin key per the API auth dependency.""" update_data = {k: v for k, v in data.dict().items() if v is not None} if not update_data: - return 0 + 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",) - ) - result = await db.execute( - f"UPDATE satoshimachine.dca_clients SET {set_clause} WHERE user_id = :uid", + 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, ) - # db.execute return varies by backend; just return count of rows that - # exist (best-effort indicator of rows touched). - rows = await db.fetchone( - "SELECT COUNT(*) AS n FROM satoshimachine.dca_clients WHERE user_id = :uid", - {"uid": user_id}, - ) - _ = result - return int(rows["n"]) if rows else 0 - + return await get_lp_preferences(user_id) diff --git a/models.py b/models.py index d0906c9..fbc168e 100644 --- a/models.py +++ b/models.py @@ -15,7 +15,12 @@ from pydantic import BaseModel class PerMachinePosition(BaseModel): - """LP's position at a single machine.""" + """LP's position at a single machine. + + `dca_mode` was previously per-(machine, LP) and is now LP-wide (lives + on `dca_lp.default_dca_mode`). Echoed here for legacy UI display only + — every position for a given LP shares the same value. + """ machine_id: str machine_npub: str @@ -36,18 +41,48 @@ class ClientDashboardSummary(BaseModel): 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_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 + total_machines: int # how many machines this LP is on last_transaction_date: Optional[datetime] - currency: str # display currency; if multi-currency, "MIX" + 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): """A single distribution leg landing in the LP's wallet.""" @@ -55,7 +90,7 @@ class ClientTransaction(BaseModel): machine_id: str machine_npub: str settlement_id: Optional[str] - leg_type: str # 'dca' | 'settlement' | 'autoforward' (LP-visible) + leg_type: str # 'dca' | 'settlement' | 'autoforward' (LP-visible) amount_sats: int amount_fiat: Optional[float] exchange_rate: Optional[float] @@ -64,10 +99,6 @@ class ClientTransaction(BaseModel): transaction_time: datetime -class UpdateClientAutoforward(BaseModel): - """LPs can manage their own auto-forward setting per #8. Applies to all - of the LP's dca_clients rows (across every machine they're on).""" - - autoforward_enabled: Optional[bool] = None - autoforward_ln_address: 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`. diff --git a/views_api.py b/views_api.py index 494c83a..56af15b 100644 --- a/views_api.py +++ b/views_api.py @@ -4,8 +4,14 @@ # 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. +# This extension owns writes to `satoshimachine.dca_lp` — the LP's +# per-user preferences row (DCA wallet, mode, autoforward). Reads on +# any endpoint auto-init the row using the authenticated wallet as the +# default DCA destination, which is the act that satisfies the +# operator-side "must onboard before deposits accepted" gate. +# +# Maintenance-mode note: richer LP UI is moving to ~/dev/webapp. Keep +# this surface stable + minimal. from http import HTTPStatus from typing import List, Optional @@ -15,19 +21,54 @@ from lnbits.core.models import WalletTypeInfo from lnbits.decorators import require_admin_key from .crud import ( + ensure_lp_preferences, get_client_dashboard_summary, get_client_transactions, - update_lp_autoforward, + update_lp_preferences, ) from .models import ( ClientDashboardSummary, ClientTransaction, - UpdateClientAutoforward, + LpPreferences, + UpdateLpPreferences, ) satmachineclient_api_router = APIRouter() +@satmachineclient_api_router.get( + "/api/v1/dca-client/preferences", response_model=LpPreferences +) +async def api_get_preferences( + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> LpPreferences: + """Return the LP's DCA preferences. Auto-creates the `dca_lp` row on + first call, seeded with the authenticated wallet as the default DCA + destination. The act of hitting this endpoint is what marks the LP + as "onboarded" on the operator side.""" + return await ensure_lp_preferences( + wallet.wallet.user, default_wallet_id=wallet.wallet.id + ) + + +@satmachineclient_api_router.put( + "/api/v1/dca-client/preferences", response_model=LpPreferences +) +async def api_update_preferences( + data: UpdateLpPreferences, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> LpPreferences: + """LP-side update of DCA wallet / mode / autoforward. Operator can't + reach this path — it requires the LP's wallet admin key.""" + # Ensure the row exists before update so this endpoint is safe to + # call even if the LP somehow hits PUT before GET (they shouldn't, + # but the dashboard and any caller order shouldn't matter). + await ensure_lp_preferences(wallet.wallet.user, default_wallet_id=wallet.wallet.id) + updated = await update_lp_preferences(wallet.wallet.user, data) + assert updated is not None + return updated + + @satmachineclient_api_router.get( "/api/v1/dca-client/positions", response_model=ClientDashboardSummary ) @@ -35,8 +76,12 @@ async def api_get_positions( wallet: WalletTypeInfo = Depends(require_admin_key), ) -> ClientDashboardSummary: """LP's aggregated dashboard across every machine they're registered at, - plus a per-machine breakdown in the `positions` field.""" + plus a per-machine breakdown in the `positions` field. + + Also auto-creates the LP's `dca_lp` row on first access (so opening + the dashboard is itself the onboarding gesture).""" user_id = wallet.wallet.user + await ensure_lp_preferences(user_id, default_wallet_id=wallet.wallet.id) summary = await get_client_dashboard_summary(user_id) if summary is None: raise HTTPException( @@ -62,20 +107,3 @@ async def api_get_transactions( return await get_client_transactions( user_id, limit=limit, offset=offset, machine_id=machine_id ) - - -@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: - """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} -