feat(v2): client CRUD + balance summary endpoints (P3a)

Adds 6 operator-scoped LP management endpoints:

  POST   /api/v1/dca/clients                  — register LP at a machine
  GET    /api/v1/dca/clients                  — operator's LPs (all)
  GET    /api/v1/dca/clients?machine_id=X     — scoped to one machine
  GET    /api/v1/dca/clients/{id}             — single LP
  PUT    /api/v1/dca/clients/{id}             — update mode/autoforward/etc
  DELETE /api/v1/dca/clients/{id}             — delete
  GET    /api/v1/dca/clients/{id}/balance     — fiat balance summary

Ownership transitively checked via the LP's machine — operators can
only see/modify LPs at machines they own. New _machine_owned_by and
_client_owned_by helpers consolidate the 404-not-403 ownership pattern.

Refs: aiolabs/satmachineadmin#9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-14 15:35:15 +02:00
commit 7226b8289d

View file

@ -12,8 +12,14 @@ from lnbits.core.models import User
from lnbits.decorators import check_super_user, check_user_exists
from .crud import (
create_dca_client,
create_machine,
delete_dca_client,
delete_machine,
get_client_balance_summary,
get_dca_client,
get_dca_clients_for_machine,
get_dca_clients_for_operator,
get_machine,
get_machines_for_operator,
get_payments_for_operator,
@ -21,15 +27,20 @@ from .crud import (
get_settlements_for_machine,
get_settlements_for_operator,
get_super_config,
update_dca_client,
update_machine,
update_super_config,
)
from .models import (
ClientBalanceSummary,
CreateDcaClientData,
CreateMachineData,
DcaClient,
DcaPayment,
DcaSettlement,
Machine,
SuperConfig,
UpdateDcaClientData,
UpdateMachineData,
UpdateSuperConfigData,
)
@ -99,6 +110,107 @@ async def api_delete_machine(
await delete_machine(machine_id)
# =============================================================================
# DCA Clients (LPs) — scoped per (machine, user).
# =============================================================================
async def _machine_owned_by(machine_id: str, user_id: str) -> Machine:
"""Lookup-with-ownership guard. 404 (not 403) so operators can't probe
for other operators' machines."""
machine = await get_machine(machine_id)
if machine is None or machine.operator_user_id != user_id:
raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found")
return machine
async def _client_owned_by(client_id: str, user_id: str) -> DcaClient:
"""Lookup-with-ownership guard for an LP record; ownership is checked
transitively via the client's machine. 404 if either doesn't match."""
client = await get_dca_client(client_id)
if client is None:
raise HTTPException(HTTPStatus.NOT_FOUND, "Client not found")
machine = await get_machine(client.machine_id)
if machine is None or machine.operator_user_id != user_id:
raise HTTPException(HTTPStatus.NOT_FOUND, "Client not found")
return client
@satmachineadmin_api_router.post(
"/api/v1/dca/clients", response_model=DcaClient
)
async def api_create_client(
data: CreateDcaClientData, user: User = Depends(check_user_exists)
) -> DcaClient:
# Operator can only register LPs on machines they own.
await _machine_owned_by(data.machine_id, user.id)
return await create_dca_client(data)
@satmachineadmin_api_router.get(
"/api/v1/dca/clients", response_model=list[DcaClient]
)
async def api_list_clients(
machine_id: str | None = None,
user: User = Depends(check_user_exists),
) -> list[DcaClient]:
"""List the operator's LPs. Without ?machine_id, returns all LPs across
the operator's fleet. With ?machine_id, scoped to that machine (with
ownership check)."""
if machine_id is None:
return await get_dca_clients_for_operator(user.id)
await _machine_owned_by(machine_id, user.id)
return await get_dca_clients_for_machine(machine_id)
@satmachineadmin_api_router.get(
"/api/v1/dca/clients/{client_id}", response_model=DcaClient
)
async def api_get_client(
client_id: str, user: User = Depends(check_user_exists)
) -> DcaClient:
return await _client_owned_by(client_id, user.id)
@satmachineadmin_api_router.put(
"/api/v1/dca/clients/{client_id}", response_model=DcaClient
)
async def api_update_client(
client_id: str,
data: UpdateDcaClientData,
user: User = Depends(check_user_exists),
) -> DcaClient:
await _client_owned_by(client_id, user.id)
updated = await update_dca_client(client_id, data)
if updated is None:
raise HTTPException(HTTPStatus.NOT_FOUND, "Client not found")
return updated
@satmachineadmin_api_router.delete(
"/api/v1/dca/clients/{client_id}", status_code=HTTPStatus.NO_CONTENT
)
async def api_delete_client(
client_id: str, user: User = Depends(check_user_exists)
) -> None:
await _client_owned_by(client_id, user.id)
await delete_dca_client(client_id)
@satmachineadmin_api_router.get(
"/api/v1/dca/clients/{client_id}/balance",
response_model=ClientBalanceSummary,
)
async def api_get_client_balance(
client_id: str, user: User = Depends(check_user_exists)
) -> ClientBalanceSummary:
await _client_owned_by(client_id, user.id)
summary = await get_client_balance_summary(client_id)
if summary is None:
raise HTTPException(HTTPStatus.NOT_FOUND, "Client not found")
return summary
# =============================================================================
# Settlements (read-only at this phase; landing happens in tasks.py)
# =============================================================================