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

124
crud.py
View file

@ -22,6 +22,7 @@ from .models import (
CreateMachineData,
DcaClient,
DcaDeposit,
DcaLpPreferences,
DcaPayment,
DcaSettlement,
Machine,
@ -32,6 +33,7 @@ from .models import (
UpdateDepositStatusData,
UpdateMachineData,
UpdateSuperConfigData,
UpsertDcaLpData,
)
db = Database("ext_satoshimachine")
@ -168,28 +170,27 @@ async def delete_machine(machine_id: str) -> None:
async def create_dca_client(data: CreateDcaClientData) -> DcaClient:
"""Operator enrols an LP at one of their machines.
Pure (machine, LP) record. Wallet / mode / autoforward live on
dca_lp (per-user) populated by the LP via satmachineclient.
Enrolment doesn't require the LP to be onboarded yet, but deposits
do (see `create_deposit`).
"""
client_id = urlsafe_short_hash()
now = datetime.now()
await db.execute(
"""
INSERT INTO satoshimachine.dca_clients
(id, machine_id, user_id, wallet_id, username, dca_mode,
fixed_mode_daily_limit, autoforward_ln_address, autoforward_enabled,
status, created_at, updated_at)
VALUES (:id, :machine_id, :user_id, :wallet_id, :username, :dca_mode,
:fixed_mode_daily_limit, :autoforward_ln_address,
:autoforward_enabled, :status, :created_at, :updated_at)
(id, machine_id, user_id, username, status, created_at, updated_at)
VALUES (:id, :machine_id, :user_id, :username, :status,
:created_at, :updated_at)
""",
{
"id": client_id,
"machine_id": data.machine_id,
"user_id": data.user_id,
"wallet_id": data.wallet_id,
"username": data.username,
"dca_mode": data.dca_mode,
"fixed_mode_daily_limit": data.fixed_mode_daily_limit,
"autoforward_ln_address": data.autoforward_ln_address,
"autoforward_enabled": data.autoforward_enabled,
"status": "active",
"created_at": now,
"updated_at": now,
@ -262,20 +263,109 @@ async def get_dca_clients_for_user(user_id: str) -> List[DcaClient]:
async def get_flow_mode_clients_for_machine(machine_id: str) -> List[DcaClient]:
"""Active flow-mode clients used by the distribution algorithm."""
"""Active LPs enrolled at this machine whose per-user `dca_lp` row
has `default_dca_mode = 'flow'`. Used by the distribution algorithm.
An LP enrolment without a matching `dca_lp` row (i.e., the LP hasn't
onboarded via satmachineclient yet) is filtered out by the INNER
JOIN there's no destination wallet to pay to.
"""
return await db.fetchall(
"""
SELECT * FROM satoshimachine.dca_clients
WHERE machine_id = :machine_id
AND dca_mode = 'flow'
AND status = 'active'
ORDER BY created_at ASC
SELECT c.*
FROM satoshimachine.dca_clients c
JOIN satoshimachine.dca_lp lp ON lp.user_id = c.user_id
WHERE c.machine_id = :machine_id
AND lp.default_dca_mode = 'flow'
AND c.status = 'active'
ORDER BY c.created_at ASC
""",
{"machine_id": machine_id},
DcaClient,
)
# =============================================================================
# DCA LP preferences (per-user) — wallet + mode + autoforward
# =============================================================================
async def get_dca_lp(user_id: str) -> Optional[DcaLpPreferences]:
"""Return the LP's preferences row, or None if they haven't onboarded
via satmachineclient yet."""
return await db.fetchone(
"SELECT * FROM satoshimachine.dca_lp WHERE user_id = :uid",
{"uid": user_id},
DcaLpPreferences,
)
async def lp_is_onboarded(user_id: str) -> bool:
"""Cheap existence check used by the deposit-creation gate."""
row = await db.fetchone(
"SELECT user_id FROM satoshimachine.dca_lp WHERE user_id = :uid",
{"uid": user_id},
)
return row is not None
async def upsert_dca_lp(
user_id: str,
data: UpsertDcaLpData,
*,
fallback_wallet_id: Optional[str] = None,
) -> DcaLpPreferences:
"""Create or update the LP's preferences row.
First call (no row yet): `data.dca_wallet_id` must be set OR
`fallback_wallet_id` must be provided (satmachineclient passes the
LP's default LNbits wallet here when auto-seeding on first dashboard
visit). Subsequent calls update only the fields in `data` that are
non-None.
"""
existing = await get_dca_lp(user_id)
now = datetime.now()
if existing is None:
wallet_id = data.dca_wallet_id or fallback_wallet_id
if not wallet_id:
raise ValueError(
"first upsert requires dca_wallet_id (or fallback_wallet_id)"
)
await db.execute(
"""
INSERT INTO satoshimachine.dca_lp
(user_id, dca_wallet_id, default_dca_mode, fixed_mode_daily_limit,
autoforward_ln_address, autoforward_enabled,
created_at, updated_at)
VALUES (:uid, :wallet, :mode, :limit, :ln_addr, :auto,
:now, :now)
""",
{
"uid": user_id,
"wallet": wallet_id,
"mode": data.default_dca_mode or "flow",
"limit": data.fixed_mode_daily_limit,
"ln_addr": data.autoforward_ln_address,
"auto": data.autoforward_enabled or False,
"now": now,
},
)
else:
update_data: dict = {k: v for k, v in data.dict().items() if v is not None}
if not update_data:
return existing
update_data["updated_at"] = now
set_clause = ", ".join(f"{k} = :{k}" for k in update_data)
update_data["uid"] = user_id
await db.execute(
f"UPDATE satoshimachine.dca_lp SET {set_clause} WHERE user_id = :uid",
update_data,
)
refreshed = await get_dca_lp(user_id)
assert refreshed is not None
return refreshed
async def update_dca_client(
client_id: str, data: UpdateDcaClientData
) -> Optional[DcaClient]: