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
6
crud.py
6
crud.py
|
|
@ -874,16 +874,16 @@ async def replace_commission_splits(
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO satoshimachine.dca_commission_splits
|
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)
|
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)
|
:sort_order, :created_at)
|
||||||
""",
|
""",
|
||||||
{
|
{
|
||||||
"id": urlsafe_short_hash(),
|
"id": urlsafe_short_hash(),
|
||||||
"machine_id": machine_id,
|
"machine_id": machine_id,
|
||||||
"uid": operator_user_id,
|
"uid": operator_user_id,
|
||||||
"wallet_id": leg.wallet_id,
|
"target": leg.target,
|
||||||
"label": leg.label,
|
"label": leg.label,
|
||||||
"pct": leg.pct,
|
"pct": leg.pct,
|
||||||
"sort_order": leg.sort_order,
|
"sort_order": leg.sort_order,
|
||||||
|
|
|
||||||
108
distribution.py
108
distribution.py
|
|
@ -24,7 +24,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
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 import create_invoice, pay_invoice
|
||||||
from lnbits.core.services.lnurl import get_pr_from_lnurl
|
from lnbits.core.services.lnurl import get_pr_from_lnurl
|
||||||
|
|
@ -472,12 +472,10 @@ async def _pay_operator_splits(
|
||||||
f"satmachine operator split — "
|
f"satmachine operator split — "
|
||||||
f"{machine.name or machine.machine_npub[:12]} ({label})"
|
f"{machine.name or machine.machine_npub[:12]} ({label})"
|
||||||
)
|
)
|
||||||
await _pay_internal(
|
await _pay_split_leg(
|
||||||
settlement=settlement,
|
settlement=settlement,
|
||||||
machine=machine,
|
machine=machine,
|
||||||
leg_type="operator_split",
|
target=leg.target,
|
||||||
client_id=None,
|
|
||||||
destination_wallet_id=leg.wallet_id,
|
|
||||||
amount_sats=amount,
|
amount_sats=amount,
|
||||||
memo=memo,
|
memo=memo,
|
||||||
errors=errors,
|
errors=errors,
|
||||||
|
|
@ -671,6 +669,106 @@ async def _attempt_autoforward(
|
||||||
await update_payment_status(leg.id, "failed", None, str(exc)[:512])
|
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(
|
async def _pay_internal(
|
||||||
*,
|
*,
|
||||||
settlement: DcaSettlement,
|
settlement: DcaSettlement,
|
||||||
|
|
|
||||||
|
|
@ -209,13 +209,20 @@ async def m001_satmachine_v2_initial(db):
|
||||||
# leg. machine_id=NULL = operator default; non-null = per-machine
|
# leg. machine_id=NULL = operator default; non-null = per-machine
|
||||||
# override. Sum(pct) per (operator, machine) must equal 1.0 —
|
# override. Sum(pct) per (operator, machine) must equal 1.0 —
|
||||||
# enforced at write-time in crud.py.
|
# 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(
|
await db.execute(
|
||||||
f"""
|
f"""
|
||||||
CREATE TABLE IF NOT EXISTS satoshimachine.dca_commission_splits (
|
CREATE TABLE IF NOT EXISTS satoshimachine.dca_commission_splits (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
machine_id TEXT,
|
machine_id TEXT,
|
||||||
operator_user_id TEXT NOT NULL,
|
operator_user_id TEXT NOT NULL,
|
||||||
wallet_id TEXT NOT NULL,
|
target TEXT NOT NULL,
|
||||||
label TEXT,
|
label TEXT,
|
||||||
pct DECIMAL(10,4) NOT NULL,
|
pct DECIMAL(10,4) NOT NULL,
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
|
||||||
20
models.py
20
models.py
|
|
@ -245,13 +245,27 @@ class DcaSettlement(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class CommissionSplitLeg(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
|
label: Optional[str] = None
|
||||||
pct: float
|
pct: float
|
||||||
sort_order: int = 0
|
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")
|
@validator("pct")
|
||||||
def pct_in_unit_range(cls, v):
|
def pct_in_unit_range(cls, v):
|
||||||
if v < 0 or v > 1:
|
if v < 0 or v > 1:
|
||||||
|
|
@ -263,7 +277,7 @@ class CommissionSplit(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
machine_id: Optional[str] # None = operator's default ruleset
|
machine_id: Optional[str] # None = operator's default ruleset
|
||||||
operator_user_id: str
|
operator_user_id: str
|
||||||
wallet_id: str
|
target: str
|
||||||
label: Optional[str]
|
label: Optional[str]
|
||||||
pct: float
|
pct: float
|
||||||
sort_order: int
|
sort_order: int
|
||||||
|
|
|
||||||
|
|
@ -1080,8 +1080,12 @@ window.app = Vue.createApp({
|
||||||
const {data} = await LNbits.api.request(
|
const {data} = await LNbits.api.request(
|
||||||
'GET', `${COMMISSION_SPLITS_PATH}${params}`
|
'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 => ({
|
this.commissionLegs = (data || []).map(leg => ({
|
||||||
wallet_id: leg.wallet_id,
|
target: leg.target || '',
|
||||||
|
targetKind: this._inferTargetKind(leg.target),
|
||||||
label: leg.label || '',
|
label: leg.label || '',
|
||||||
pct: Number(leg.pct) || 0
|
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() {
|
addCommissionLeg() {
|
||||||
this.commissionLegs.push({
|
this.commissionLegs.push({
|
||||||
wallet_id: this.walletOptions[0]?.value || null,
|
target: this.walletOptions[0]?.value || '',
|
||||||
|
targetKind: 'wallet',
|
||||||
label: '',
|
label: '',
|
||||||
pct: 0
|
pct: 0
|
||||||
})
|
})
|
||||||
|
|
@ -1110,7 +1124,7 @@ window.app = Vue.createApp({
|
||||||
const body = {
|
const body = {
|
||||||
machine_id: this.commissionScope,
|
machine_id: this.commissionScope,
|
||||||
legs: this.commissionLegs.map((leg, idx) => ({
|
legs: this.commissionLegs.map((leg, idx) => ({
|
||||||
wallet_id: leg.wallet_id,
|
target: (leg.target || '').toString().trim(),
|
||||||
label: leg.label || null,
|
label: leg.label || null,
|
||||||
pct: Number(leg.pct),
|
pct: Number(leg.pct),
|
||||||
sort_order: idx
|
sort_order: idx
|
||||||
|
|
|
||||||
|
|
@ -494,19 +494,45 @@
|
||||||
</q-banner>
|
</q-banner>
|
||||||
|
|
||||||
<div v-for="(leg, idx) in commissionLegs" :key="idx"
|
<div v-for="(leg, idx) in commissionLegs" :key="idx"
|
||||||
class="row q-col-gutter-sm q-mb-sm items-center">
|
class="q-mb-md q-pa-sm"
|
||||||
<div class="col-12 col-md-4">
|
:style="{border: '1px solid rgba(255,255,255,0.08)', borderRadius: '4px'}">
|
||||||
<q-select v-model="leg.wallet_id"
|
<div class="row q-col-gutter-sm items-center q-mb-sm">
|
||||||
:options="walletOptions"
|
<div class="col">
|
||||||
label="Wallet"
|
<q-btn-toggle v-model="leg.targetKind"
|
||||||
emit-value map-options dense outlined></q-select>
|
:options="[
|
||||||
|
{label: 'My wallet', value: 'wallet'},
|
||||||
|
{label: 'Lightning address / LNURL / invoice key', value: 'external'}
|
||||||
|
]"
|
||||||
|
no-caps dense flat
|
||||||
|
toggle-color="primary"
|
||||||
|
color="grey-8"></q-btn-toggle>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-7 col-md-4">
|
<div class="col-auto">
|
||||||
<q-input v-model="leg.label"
|
<q-btn flat dense round icon="delete" color="red-7"
|
||||||
label="Label (e.g. employee, maintenance)"
|
@click="commissionLegs.splice(idx, 1)">
|
||||||
|
<q-tooltip>Remove leg</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row q-col-gutter-sm items-start">
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-select v-if="leg.targetKind === 'wallet'"
|
||||||
|
v-model="leg.target"
|
||||||
|
:options="walletOptions"
|
||||||
|
label="Wallet (one of yours)"
|
||||||
|
emit-value map-options dense outlined></q-select>
|
||||||
|
<q-input v-else
|
||||||
|
v-model.trim="leg.target"
|
||||||
|
label="LN address, LNURL, or invoice key"
|
||||||
|
hint="user@domain · LNURL1... · or an LP's invoice key"
|
||||||
dense outlined></q-input>
|
dense outlined></q-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4 col-md-3">
|
<div class="col-7 col-md-3">
|
||||||
|
<q-input v-model="leg.label"
|
||||||
|
label="Label (employee, maintenance, ...)"
|
||||||
|
dense outlined></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-5 col-md-3">
|
||||||
<q-input v-model.number="leg.pct"
|
<q-input v-model.number="leg.pct"
|
||||||
label="% (0..1)"
|
label="% (0..1)"
|
||||||
type="number" step="0.01" min="0" max="1"
|
type="number" step="0.01" min="0" max="1"
|
||||||
|
|
@ -517,9 +543,6 @@
|
||||||
</template>
|
</template>
|
||||||
</q-input>
|
</q-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-1 col-md-1">
|
|
||||||
<q-btn flat dense round icon="delete" color="red-7"
|
|
||||||
@click="commissionLegs.splice(idx, 1)"></q-btn>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue