feat(v2): settlement distribution — three leg groups, super-fee write (P2)

After a settlement lands (P1a), this commit pays out the three leg
groups via LNbits internal transfers (create_invoice + pay_invoice with
internal=True). Wired synchronously from the invoice listener — latency
is one bitSpire-tx wide. process_settlement is idempotent (status guard)
so retries are safe.

distribution.py — three leg groups, in order:

  1. super_fee leg:
       platform_fee_sats → super_fee_wallet_id (if set)
       skip + warn if super fee % > 0 but wallet not configured
  2. operator_split legs:
       operator_fee_sats sliced per the operator's commission_splits
       ruleset (per-machine override or operator default)
       skip + warn if operator has no ruleset configured
  3. dca legs:
       net_sats distributed proportionally to active flow-mode LPs at
       this machine, each capped at the LP's remaining-fiat-balance-
       in-sats (preserves the v1 sync-mismatch fix from PR #2)
       skip if exchange_rate=0 (fallback path with missing rate)

Every leg lands a dca_payments row with the leg_type discriminator and
inherits Payment.tag "satmachine:{machine_npub}" so LNbits payment-
history filters work natively across machines + operators.

Atomicity model: LN payments cannot be rolled back. Each leg is
attempted independently; success/fail recorded on the dca_payments row.
The settlement is marked 'processed' only when every leg completed; any
failure marks 'errored' with a concatenated message but leaves successful
legs in place. Sats that don't pay out (failed legs, missing super
wallet, no commission ruleset, no LP coverage) remain in the machine's
wallet — visible to the operator on the dashboard.

calculations.py — extracted two pure helpers:

  split_two_stage_commission(commission_sats, super_fee_pct)
    Stage-1: super takes super_fee_pct (rounded); operator absorbs the
    rounding remainder so platform + operator == commission_sats exactly.

  allocate_operator_split_legs(operator_fee_sats, leg_pcts)
    Stage-2: distributes the remainder across N legs per pct rules. Last
    leg absorbs the rounding remainder so sum(legs) == operator_fee_sats.

50 new tests cover the plan's verification scenario:
  100 sats commission, super=30%, operator splits 50/30/20
  → super 30, operator 35/21/14. Sum 100 ✓
plus all the edge cases the plan called out (super=0, super=100,
single-leg, zero-fee, parametrised invariant on sums).

views_api.py adds the super-only platform-fee write endpoint:
  PUT /api/v1/dca/super-config  (check_super_user)

This is the only super-only endpoint in v2 — sets super_fee_pct and the
destination wallet for collecting the fee.

72/72 tests pass (22 calculation + 50 two-stage-split). 13 routes
registered against LNbits 1.4 (nostr-transport).

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:34:07 +02:00
commit 56be3e5c52
5 changed files with 559 additions and 4 deletions

View file

@ -9,7 +9,7 @@ from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.decorators import check_super_user, check_user_exists
from .crud import (
create_machine,
@ -22,6 +22,7 @@ from .crud import (
get_settlements_for_operator,
get_super_config,
update_machine,
update_super_config,
)
from .models import (
CreateMachineData,
@ -30,6 +31,7 @@ from .models import (
Machine,
SuperConfig,
UpdateMachineData,
UpdateSuperConfigData,
)
satmachineadmin_api_router = APIRouter()
@ -155,7 +157,7 @@ async def api_list_payments(
# =============================================================================
# Super config — read-only at this phase. Super-only write endpoint lands in P2.
# Super config — operators read; super (LNbits instance admin) writes.
# =============================================================================
@ -167,8 +169,7 @@ async def api_get_super_config(
) -> SuperConfig:
"""Returns the platform-fee config so operators can display it as a
read-only line item in their UI. The fee is set by the LNbits super
instance-wide; operators see it but can't change it (write endpoint
protected by check_super_user, landing in P2)."""
instance-wide; operators see it but can't change it."""
config = await get_super_config()
if config is None:
raise HTTPException(
@ -177,6 +178,25 @@ async def api_get_super_config(
return config
@satmachineadmin_api_router.put(
"/api/v1/dca/super-config", response_model=SuperConfig
)
async def api_update_super_config(
data: UpdateSuperConfigData,
_user: User = Depends(check_super_user),
) -> SuperConfig:
"""Super-only: set the platform fee % charged on every operator's
commission, plus the destination wallet for collecting it. The fee is
enforced before the operator's own commission_splits ruleset fires
(see distribution.process_settlement)."""
config = await update_super_config(data)
if config is None:
raise HTTPException(
HTTPStatus.INTERNAL_SERVER_ERROR, "Failed to update super config"
)
return config
# =============================================================================
# Catch-all stub for endpoints not yet implemented (clients, deposits,
# commission splits, partial-tx, balance-settle, super-config write). Each