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

@ -8,6 +8,7 @@
from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException
from lnbits.core.crud import get_wallet
from lnbits.core.models import User
from lnbits.decorators import check_super_user, check_user_exists
@ -42,7 +43,10 @@ from .crud import (
update_machine,
update_super_config,
)
from .distribution import apply_partial_dispense_and_redistribute
from .distribution import (
apply_partial_dispense_and_redistribute,
settle_lp_balance,
)
from .models import (
AppendSettlementNoteData,
ClientBalanceSummary,
@ -57,6 +61,7 @@ from .models import (
Machine,
PartialDispenseData,
SetCommissionSplitsData,
SettleBalanceData,
SuperConfig,
UpdateDcaClientData,
UpdateDepositData,
@ -231,6 +236,39 @@ async def api_get_client_balance(
return summary
@satmachineadmin_api_router.post(
"/api/v1/dca/clients/{client_id}/settle", response_model=DcaPayment
)
async def api_settle_client_balance(
client_id: str,
data: SettleBalanceData,
user: User = Depends(check_user_exists),
) -> DcaPayment:
"""Operator UX — closes satmachineadmin#4.
Settle an LP's remaining fiat balance from the operator's chosen funding
wallet at the specified exchange rate. The amount_fiat is capped at the
LP's remaining balance; if omitted, settles the full remaining.
Use case: avoid the Zeno's-paradox of vanishing tiny shares for small
remaining balances. Operator hits 'Settle' on the LP, gets to specify
the rate, and the system pays out the rest in sats from their wallet.
"""
client = await _client_owned_by(client_id, user.id)
machine = await _machine_owned_by(client.machine_id, user.id)
# Verify the operator owns the funding wallet.
funding_wallet = await get_wallet(data.funding_wallet_id)
if funding_wallet is None or funding_wallet.user != user.id:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"funding_wallet_id is not owned by the authenticated operator",
)
try:
return await settle_lp_balance(client, machine, data)
except ValueError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
# =============================================================================
# Deposits — operator records fiat handed in by an LP at a machine.
# =============================================================================