feat(v2): commission split target accepts wallet id, invoice key, LN address, or LNURL

Closes the operator-facing limitation that commission split legs could
only target the operator's own LNbits wallets. Adopting the splitpayments
pattern — a single `target` string accepts:
  - LNbits wallet id (UUID-shaped) — direct internal pay
  - LNbits wallet invoice key — resolved via get_wallet_for_key, then
    internal pay (lets the operator split to any LNbits user who shares
    their invoice key)
  - Lightning address (user@domain) — resolved via LNURL-pay
  - LNURL string (LNURL1...) — resolved via LNURL-pay

Schema (m001 update — fresh-install only; no operator data in production):
  dca_commission_splits.wallet_id → target

Backend (distribution.py):
  - New _pay_split_leg helper: routes the leg by target type. External
    targets (@ or LNURL prefix) go through get_pr_from_lnurl + pay_invoice;
    internal targets go through create_invoice + pay_invoice (the original
    path), with get_wallet_for_key as the first resolution step so
    invoice keys work as well as wallet ids.
  - _pay_operator_splits delegates per-leg payment to the new helper.
  - dca_payments rows still record the leg as leg_type='operator_split';
    external targets land destination_ln_address (the human-readable
    target), internal targets land destination_wallet_id.
  - Errors are caught and surfaced via the existing failed-leg path
    so /retry can re-run them.

Frontend (commission tab):
  - Each leg gets a per-row q-btn-toggle: "My wallet" vs "Lightning
    address / LNURL / invoice key". Wallet mode shows the q-select of
    the operator's own wallets (previous behaviour); external mode
    shows a free-text q-input.
  - On load, targetKind is inferred from whether the stored target
    matches one of the operator's wallet ids (renders as 'wallet')
    or not (renders as 'external'). The kind is UI-only, not persisted.
  - Leg row laid out in a bordered card so the toggle + 3-column layout
    don't crowd at narrow widths.

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 19:37:33 +02:00
commit 5de9cd5205
6 changed files with 196 additions and 40 deletions

View file

@ -245,13 +245,27 @@ class DcaSettlement(BaseModel):
class CommissionSplitLeg(BaseModel):
"""Single leg of an operator's commission-split rule set."""
"""Single leg of an operator's commission-split rule set.
wallet_id: str
`target` accepts any of (splitpayments pattern):
- LNbits wallet id
- LNbits wallet invoice key (resolved server-side via get_wallet_for_key)
- Lightning address (user@domain)
- LNURL string (bech32 LNURL...)
"""
target: str
label: Optional[str] = None
pct: float
sort_order: int = 0
@validator("target")
def non_empty_target(cls, v):
v = (v or "").strip()
if not v:
raise ValueError("target cannot be empty")
return v
@validator("pct")
def pct_in_unit_range(cls, v):
if v < 0 or v > 1:
@ -263,7 +277,7 @@ class CommissionSplit(BaseModel):
id: str
machine_id: Optional[str] # None = operator's default ruleset
operator_user_id: str
wallet_id: str
target: str
label: Optional[str]
pct: float
sort_order: int