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:
parent
8968c0ae07
commit
5de9cd5205
6 changed files with 196 additions and 40 deletions
108
distribution.py
108
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue