From 5de9cd5205c6d68ac87943925be79b728d1fcebf Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 14 May 2026 19:37:33 +0200 Subject: [PATCH] feat(v2): commission split target accepts wallet id, invoice key, LN address, or LNURL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crud.py | 6 +- distribution.py | 108 +++++++++++++++++++++++++-- migrations.py | 9 ++- models.py | 20 ++++- static/js/index.js | 20 ++++- templates/satmachineadmin/index.html | 73 +++++++++++------- 6 files changed, 196 insertions(+), 40 deletions(-) diff --git a/crud.py b/crud.py index 1fa5360..948852c 100644 --- a/crud.py +++ b/crud.py @@ -874,16 +874,16 @@ async def replace_commission_splits( await db.execute( """ INSERT INTO satoshimachine.dca_commission_splits - (id, machine_id, operator_user_id, wallet_id, label, pct, + (id, machine_id, operator_user_id, target, label, pct, sort_order, created_at) - VALUES (:id, :machine_id, :uid, :wallet_id, :label, :pct, + VALUES (:id, :machine_id, :uid, :target, :label, :pct, :sort_order, :created_at) """, { "id": urlsafe_short_hash(), "machine_id": machine_id, "uid": operator_user_id, - "wallet_id": leg.wallet_id, + "target": leg.target, "label": leg.label, "pct": leg.pct, "sort_order": leg.sort_order, diff --git a/distribution.py b/distribution.py index 7f47c7e..4d8c2b2 100644 --- a/distribution.py +++ b/distribution.py @@ -24,7 +24,7 @@ from __future__ import annotations from datetime import datetime, timezone -from typing import List +from typing import List, Optional from lnbits.core.services import create_invoice, pay_invoice from lnbits.core.services.lnurl import get_pr_from_lnurl @@ -472,12 +472,10 @@ async def _pay_operator_splits( f"satmachine operator split — " f"{machine.name or machine.machine_npub[:12]} ({label})" ) - await _pay_internal( + await _pay_split_leg( settlement=settlement, machine=machine, - leg_type="operator_split", - client_id=None, - destination_wallet_id=leg.wallet_id, + target=leg.target, amount_sats=amount, memo=memo, errors=errors, @@ -671,6 +669,106 @@ async def _attempt_autoforward( await update_payment_status(leg.id, "failed", None, str(exc)[:512]) +async def _pay_split_leg( + *, + settlement: DcaSettlement, + machine: Machine, + target: str, + amount_sats: int, + memo: str, + errors: List[str], +) -> Optional[DcaPayment]: + """Pay a commission-split leg to an arbitrary target. + + `target` accepts (splitpayments pattern): + - Lightning address (user@domain) — resolved via LNURL-pay + - LNURL string (LNURL...) — resolved via LNURL-pay + - LNbits wallet invoice key — resolved via get_wallet_for_key, + then internal create_invoice + pay + - LNbits wallet id — direct internal create_invoice + pay + + Records a dca_payments row regardless of outcome (success → 'completed', + failure → 'failed'); operator sees the row in audit either way. + """ + target = (target or "").strip() + # External target: Lightning address or LNURL. + if "@" in target or target.upper().startswith("LNURL"): + leg_row = await create_dca_payment( + CreateDcaPaymentData( + settlement_id=settlement.id, + client_id=None, + machine_id=machine.id, + operator_user_id=machine.operator_user_id, + leg_type="operator_split", + destination_wallet_id=None, + destination_ln_address=target, + amount_sats=amount_sats, + amount_fiat=None, + exchange_rate=None, + transaction_time=datetime.now(timezone.utc), + external_payment_hash=None, + ) + ) + extra = { + "satmachine_leg": "operator_split", + "satmachine_settlement_id": settlement.id, + "satmachine_machine_npub": machine.machine_npub, + "satmachine_destination": target, + } + try: + ln_target = ( + LnAddress(target) if "@" in target else target + ) + bolt11 = await get_pr_from_lnurl( + lnurl=ln_target, + amount_msat=amount_sats * 1000, + comment=memo, + ) + paid = await pay_invoice( + wallet_id=machine.wallet_id, + payment_request=bolt11, + description=memo, + tag=_payment_tag(machine), + extra=extra, + ) + await update_payment_status( + leg_row.id, "completed", paid.payment_hash, None + ) + return leg_row + except Exception as exc: + logger.error( + f"distribution: operator_split (LNURL/LN-addr) FAILED " + f"target={target} settlement={settlement.id}: {exc}" + ) + await update_payment_status( + leg_row.id, "failed", None, str(exc)[:512] + ) + errors.append(f"operator_split→{target}: {exc}") + return leg_row + + # Internal LNbits target: try as invoice key first, fall back to wallet id. + resolved_wallet_id = target + try: + from lnbits.core.crud.wallets import get_wallet_for_key + wallet = await get_wallet_for_key(target) + if wallet is not None: + resolved_wallet_id = wallet.id + except Exception: + # If get_wallet_for_key isn't importable in this LNbits version, just + # treat target as a wallet id directly. + pass + return await _pay_internal( + settlement=settlement, + machine=machine, + leg_type="operator_split", + client_id=None, + destination_wallet_id=resolved_wallet_id, + amount_sats=amount_sats, + memo=memo, + errors=errors, + ) + + async def _pay_internal( *, settlement: DcaSettlement, diff --git a/migrations.py b/migrations.py index 171ff67..41b82eb 100644 --- a/migrations.py +++ b/migrations.py @@ -209,13 +209,20 @@ async def m001_satmachine_v2_initial(db): # leg. machine_id=NULL = operator default; non-null = per-machine # override. Sum(pct) per (operator, machine) must equal 1.0 — # enforced at write-time in crud.py. + # + # `target` accepts any of (splitpayments-style): + # - LNbits wallet id (UUID-shaped) + # - LNbits wallet invoice key (resolved via get_wallet_for_key) + # - Lightning address (user@domain) + # - LNURL string (bech32 LNURL...) + # Resolution lives in distribution._pay_one_split_leg. await db.execute( f""" CREATE TABLE IF NOT EXISTS satoshimachine.dca_commission_splits ( id TEXT PRIMARY KEY, machine_id TEXT, operator_user_id TEXT NOT NULL, - wallet_id TEXT NOT NULL, + target TEXT NOT NULL, label TEXT, pct DECIMAL(10,4) NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, diff --git a/models.py b/models.py index cfae3a2..3b0025b 100644 --- a/models.py +++ b/models.py @@ -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 diff --git a/static/js/index.js b/static/js/index.js index d11a526..5d8091e 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1080,8 +1080,12 @@ window.app = Vue.createApp({ const {data} = await LNbits.api.request( 'GET', `${COMMISSION_SPLITS_PATH}${params}` ) + // targetKind is a UI-only hint derived from the stored target string. + // It's not persisted server-side; the server resolves the target + // at payment time regardless. this.commissionLegs = (data || []).map(leg => ({ - wallet_id: leg.wallet_id, + target: leg.target || '', + targetKind: this._inferTargetKind(leg.target), label: leg.label || '', pct: Number(leg.pct) || 0 })) @@ -1091,9 +1095,19 @@ window.app = Vue.createApp({ } }, + _inferTargetKind(target) { + // If the value matches one of the operator's own wallet ids, render + // the row in 'wallet' mode (q-select). Otherwise treat as external + // (free-text q-input). + if (!target) return 'wallet' + const ownIds = new Set(this.walletOptions.map(w => w.value)) + return ownIds.has(target) ? 'wallet' : 'external' + }, + addCommissionLeg() { this.commissionLegs.push({ - wallet_id: this.walletOptions[0]?.value || null, + target: this.walletOptions[0]?.value || '', + targetKind: 'wallet', label: '', pct: 0 }) @@ -1110,7 +1124,7 @@ window.app = Vue.createApp({ const body = { machine_id: this.commissionScope, legs: this.commissionLegs.map((leg, idx) => ({ - wallet_id: leg.wallet_id, + target: (leg.target || '').toString().trim(), label: leg.label || null, pct: Number(leg.pct), sort_order: idx diff --git a/templates/satmachineadmin/index.html b/templates/satmachineadmin/index.html index 584a27b..6e43866 100644 --- a/templates/satmachineadmin/index.html +++ b/templates/satmachineadmin/index.html @@ -494,32 +494,55 @@
-
- + class="q-mb-md q-pa-sm" + :style="{border: '1px solid rgba(255,255,255,0.08)', borderRadius: '4px'}"> +
+
+ +
+
+ + Remove leg + +
-
- -
-
- - - -
-
- +
+
+ + +
+
+ +
+
+ + + +