feat(v2): balance settlement at current rate (P3e)

Closes the v1 feature request satmachineadmin#4 (balance settlement for
small remaining LP balances). Operator hits 'Settle' on an LP, specifies
the exchange rate they're willing to honor, and the system pays out the
remaining fiat balance in sats from the operator's chosen funding wallet.

Avoids the Zeno's-paradox of vanishing tiny proportional shares — small
balances no longer drag on forever; they get cleanly zeroed.

New endpoint:
  POST /api/v1/dca/clients/{client_id}/settle
  body: SettleBalanceData {funding_wallet_id, exchange_rate,
                            amount_fiat?, notes?}

Flow (distribution.settle_lp_balance):
  1. Get LP's remaining balance summary
  2. amount_fiat capped at remaining (defaults to full remaining)
  3. amount_sats = round(amount_fiat * exchange_rate)
  4. Internal transfer funding_wallet → client.wallet via
     create_invoice(internal=True) + pay_invoice
  5. Records leg_type='settlement' in dca_payments

Two ownership checks at the API boundary: client (via machine→operator)
and funding_wallet_id (via lnbits.core.crud.get_wallet → wallet.user
== current operator). 400 (not 404) if funding wallet isn't owned —
operators can identify their own wallets so leaking existence is fine.

Updated get_client_balance_summary to count both leg_type='dca' AND
leg_type='settlement' completed legs against the LP's remaining
balance. Without this update, settled amounts would leave the LP's
balance unchanged in the summary and re-fire on the next bitSpire tx.

Exchange rate is operator-supplied and required — explicit so there's
no ambiguity about what rate was used. Operator can use exchange spot,
market midpoint, or a favorable rate as a gesture; the rate is recorded
on the dca_payments row alongside amount_fiat for audit.

72/72 tests still pass. 31 routes total.

Refs: aiolabs/satmachineadmin#9, closes #4 (in spirit, marked once
verified end-to-end)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-14 17:17:41 +02:00
commit d0a947b7e6
4 changed files with 174 additions and 8 deletions

View file

@ -426,16 +426,36 @@ class AppendSettlementNoteData(BaseModel):
class SettleBalanceData(BaseModel):
"""Resolves satmachineadmin#4 — operator settles small remaining LP balance
from their own wallet at the current exchange rate."""
from their own wallet at a specified exchange rate.
Use case: an LP has a small remaining fiat balance (e.g. 47 GTQ) that
keeps shrinking proportionally on each new transaction (Zeno's paradox).
Operator hits 'Settle', specifies the exchange rate they're willing to
honor, and the system pays out the remaining balance in sats from the
operator's wallet. The LP's balance goes to zero; settlement legs count
against the LP's balance summary alongside DCA legs.
"""
client_id: str
funding_wallet_id: str
# If None, settle the full remaining balance.
# The exchange rate the operator is settling at (sats per 1 fiat unit).
# Operator picks the rate so they can use exchange spot, a market
# midpoint, or a favorable rate as a gesture. Required and explicit so
# there's no ambiguity about what rate was used.
exchange_rate: float
# If None, settle the LP's full remaining balance. Else partial.
amount_fiat: Optional[float] = None
notes: Optional[str] = None
@validator("exchange_rate")
def positive_rate(cls, v):
if v is None or v <= 0:
raise ValueError("exchange_rate must be > 0 (sats per fiat unit)")
return float(v)
@validator("amount_fiat")
def round_amount(cls, v):
if v is not None:
return round(float(v), 2)
return v
if v is None:
return v
if v <= 0:
raise ValueError("amount_fiat must be > 0 if specified")
return round(float(v), 2)