From e8dcbfe26edc4fc43d1a9617f3cae33c87e5a13f Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 14 May 2026 15:37:16 +0200 Subject: [PATCH] feat(v2): commission splits CRUD endpoints (P3c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- views_api.py | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/views_api.py b/views_api.py index ccab37a..3fa901f 100644 --- a/views_api.py +++ b/views_api.py @@ -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. # =============================================================================