refactor(v2): hoist LP state (wallet, mode, autoforward) into dca_lp table

LP-level preferences were denormalised across every `dca_clients` row
of a given user. Every LP enrolment carried its own wallet_id /
dca_mode / fixed_mode_daily_limit / autoforward_ln_address /
autoforward_enabled — and satmachineclient's `update_lp_autoforward`
did a multi-row UPDATE to keep them in sync. That sync dance was the
smell: user-level intent stored at machine-enrolment granularity.

New shape:

  dca_lp  (user_id PK, dca_wallet_id, default_dca_mode,
           fixed_mode_daily_limit, autoforward_ln_address,
           autoforward_enabled, ...)
  dca_clients  (id, machine_id, user_id, username, status, ...)
        // pure (machine, LP) enrolment — wallet/mode/autoforward gone

Authority split:
  - LP writes dca_lp via satmachineclient (Phase 2, separate commit).
  - Operator writes dca_clients via satmachineadmin. They cannot
    choose the LP's destination wallet — it's resolved from dca_lp
    at distribution time. Better trust hygiene.

Onboarding gate:
  - `api_create_deposit` refuses (HTTP 422) when the target LP has
    no dca_lp row. Forces every LP through a "yes, I am here and
    this is where I want my sats" gesture via satmachineclient
    before any fiat starts accumulating against them.

Schema:
  - m001 canonical schema updated: slim `dca_clients`, new `dca_lp`.
    Fresh installs land here directly.
  - m004 idempotent migration for installs that already have the
    legacy `dca_clients.wallet_id` column: creates dca_lp,
    backfills from the latest dca_clients row per user (window
    function), then DROP COLUMN on the moved fields. Greg's live
    test data survives the upgrade.

Distribution:
  - `get_flow_mode_clients_for_machine` INNER JOINs dca_lp so
    un-onboarded LPs are filtered out (no destination wallet).
  - `_pay_one_dca_leg`, `_attempt_autoforward`, `settle_lp_balance`
    all fetch `dca_lp` via the new `get_dca_lp(user_id)` helper.
    Wallet + autoforward read from prefs, not from client.

Models:
  - `DcaClient` loses 5 fields. `CreateDcaClientData` reduces to
    (machine_id, user_id, username). `UpdateDcaClientData` keeps
    only operator-controlled fields (username, status).
  - New `DcaLpPreferences` + `UpsertDcaLpData` models for the
    per-user surface (satmachineclient writes these in Phase 2).

CRUD:
  - New: `get_dca_lp`, `lp_is_onboarded`, `upsert_dca_lp` (the
    latter takes a `fallback_wallet_id` for first-onboarding when
    satmachineclient auto-seeds from the LP's default LNbits wallet).
  - `create_dca_client` insert reduces to the new column set.

Tests: 86 unit tests still green.

Next:
  - Phase 1c (this repo): UI simplification for operator's
    Add/Edit LP dialogs + deposit-gating UX.
  - Phase 2 (satmachineclient): own dca_lp writes + auto-init with
    the LP's default LNbits wallet on first dashboard visit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-16 10:05:54 +02:00
commit 80b5a6d785
5 changed files with 307 additions and 49 deletions

View file

@ -80,40 +80,70 @@ class UpdateMachineData(BaseModel):
class CreateDcaClientData(BaseModel):
"""Operator enrols an LP at one of their machines.
Pure (machine, LP) tuple no wallet, no mode, no autoforward. Those
live on the per-user `dca_lp` row, written by the LP themselves via
satmachineclient. An LP must have onboarded (have a `dca_lp` row)
before deposits can be recorded against this enrolment; enrolment
itself works either way.
"""
machine_id: str
user_id: str
wallet_id: str
username: Optional[str] = None
dca_mode: str = "flow" # 'flow' | 'fixed'
fixed_mode_daily_limit: Optional[float] = None
# Auto-forward DCA distributions to an external LN address (best-effort;
# sats stay in LNbits wallet on forward failure — see satmachineadmin#8).
autoforward_ln_address: Optional[str] = None
autoforward_enabled: bool = False
class DcaClient(BaseModel):
id: str
machine_id: str
user_id: str
wallet_id: str
username: Optional[str]
dca_mode: str
fixed_mode_daily_limit: Optional[float]
autoforward_ln_address: Optional[str]
autoforward_enabled: bool
status: str
created_at: datetime
updated_at: datetime
class UpdateDcaClientData(BaseModel):
"""Operator-side updates to an enrolment. The operator can only edit
fields that aren't LP-controlled (username display, status). Wallet
/ mode / autoforward changes go through satmachineclient against
`dca_lp` instead."""
username: Optional[str] = None
dca_mode: Optional[str] = None
status: Optional[str] = None
class DcaLpPreferences(BaseModel):
"""Per-user DCA preferences, owned by the LP.
Created on first satmachineclient dashboard access (the extension
auto-seeds `dca_wallet_id` with the LP's first/default LNbits wallet
they can change it from the dashboard). All distribution decisions
(where do the sats go, do we forward to an LN address, what's the
default mode) read from here, joined onto `dca_clients` by user_id.
"""
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 UpsertDcaLpData(BaseModel):
"""satmachineclient writes this on first onboarding / when the LP
edits their preferences. All fields optional on update pass only
the ones being changed."""
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
status: Optional[str] = None
class ClientBalanceSummary(BaseModel):