feat(v2): commission splits CRUD endpoints (P3c)

Adds 3 operator-scoped endpoints for managing the commission remainder
ruleset:

  GET    /api/v1/dca/commission-splits
                                       — operator's default ruleset
  GET    /api/v1/dca/commission-splits?machine_id=X
                                       — per-machine override (just the
                                          override, not the default)
  GET    /api/v1/dca/commission-splits?machine_id=X&effective=true
                                       — what the settlement processor
                                          actually applies (override if
                                          set, else operator default)
  PUT    /api/v1/dca/commission-splits — atomic replace; model validator
                                          enforces legs sum to 1.0
  DELETE /api/v1/dca/commission-splits — clear default (per-machine
                                          overrides still apply)
  DELETE /api/v1/dca/commission-splits?machine_id=X
                                       — clear per-machine override
                                          (falls back to default)

All routes verify operator owns the referenced machine (404 not 403 if
not). The DELETE path bypasses SetCommissionSplitsData's sum-to-1.0
validator by calling replace_commission_splits([]) directly, since an
empty ruleset is the correct "no rules" state — distribution.py logs a
warning and leaves operator_fee_sats in the machine wallet when this
happens.

28 routes registered total. 72/72 tests pass.

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:37:16 +02:00
commit e8dcbfe26e

View file

@ -19,12 +19,14 @@ from .crud import (
delete_deposit,
delete_machine,
get_client_balance_summary,
get_commission_splits,
get_dca_client,
get_dca_clients_for_machine,
get_dca_clients_for_operator,
get_deposit,
get_deposits_for_client,
get_deposits_for_operator,
get_effective_commission_splits,
get_machine,
get_machines_for_operator,
get_payments_for_operator,
@ -32,6 +34,7 @@ from .crud import (
get_settlements_for_machine,
get_settlements_for_operator,
get_super_config,
replace_commission_splits,
update_dca_client,
update_deposit,
update_deposit_status,
@ -40,6 +43,7 @@ from .crud import (
)
from .models import (
ClientBalanceSummary,
CommissionSplit,
CreateDcaClientData,
CreateDepositData,
CreateMachineData,
@ -48,6 +52,7 @@ from .models import (
DcaPayment,
DcaSettlement,
Machine,
SetCommissionSplitsData,
SuperConfig,
UpdateDcaClientData,
UpdateDepositData,
@ -384,6 +389,66 @@ async def api_list_payments(
return await get_payments_for_operator(user.id, leg_type=leg_type)
# =============================================================================
# Commission splits — operator's rules for distributing the commission
# remainder (post-super-fee). Sum-to-1.0 invariant enforced at the model
# boundary by SetCommissionSplitsData.
# =============================================================================
@satmachineadmin_api_router.get(
"/api/v1/dca/commission-splits", response_model=list[CommissionSplit]
)
async def api_get_commission_splits(
machine_id: str | None = None,
effective: bool = False,
user: User = Depends(check_user_exists),
) -> list[CommissionSplit]:
"""No machine_id: operator's default ruleset (rows where machine_id IS NULL).
With machine_id: per-machine override only (404 the machine if not yours).
With machine_id and ?effective=true: per-machine override if set, else
operator default what the settlement processor actually applies."""
if machine_id is not None:
await _machine_owned_by(machine_id, user.id)
if effective:
return await get_effective_commission_splits(user.id, machine_id)
return await get_commission_splits(user.id, machine_id)
return await get_commission_splits(user.id, None)
@satmachineadmin_api_router.put(
"/api/v1/dca/commission-splits", response_model=list[CommissionSplit]
)
async def api_replace_commission_splits(
data: SetCommissionSplitsData,
user: User = Depends(check_user_exists),
) -> list[CommissionSplit]:
"""Atomic replace for the (operator, machine) scope. If
data.machine_id is None, replaces the operator's default ruleset;
otherwise replaces the per-machine override (machine must be owned).
Sum-to-1.0 invariant enforced upstream by the Pydantic validator."""
if data.machine_id is not None:
await _machine_owned_by(data.machine_id, user.id)
return await replace_commission_splits(user.id, data.machine_id, data.legs)
@satmachineadmin_api_router.delete(
"/api/v1/dca/commission-splits",
status_code=HTTPStatus.NO_CONTENT,
)
async def api_delete_commission_splits(
machine_id: str | None = None,
user: User = Depends(check_user_exists),
) -> None:
"""Clear a ruleset. With machine_id: clears the per-machine override
(machine falls back to operator default). Without: clears the operator
default (any per-machine overrides keep applying)."""
if machine_id is not None:
await _machine_owned_by(machine_id, user.id)
# Atomic replace with an empty leg list — same effect as DELETE WHERE.
await replace_commission_splits(user.id, machine_id, [])
# =============================================================================
# Super config — operators read; super (LNbits instance admin) writes.
# =============================================================================