diff --git a/crud.py b/crud.py index be7a12e..b65826c 100644 --- a/crud.py +++ b/crud.py @@ -891,11 +891,15 @@ async def get_client_balance_summary( """, {"cid": client_id}, ) + # Both DCA legs (auto, from bitSpire settlements) and balance-settle legs + # (operator-initiated under #4) reduce the LP's remaining fiat balance. payments_row = await db.fetchone( """ SELECT COALESCE(SUM(amount_fiat), 0) AS total FROM satoshimachine.dca_payments - WHERE client_id = :cid AND leg_type = 'dca' AND status = 'completed' + WHERE client_id = :cid + AND leg_type IN ('dca', 'settlement') + AND status = 'completed' """, {"cid": client_id}, ) diff --git a/distribution.py b/distribution.py index 9e266b8..624f53c 100644 --- a/distribution.py +++ b/distribution.py @@ -50,10 +50,12 @@ from .crud import ( ) from .models import ( CreateDcaPaymentData, + DcaClient, DcaPayment, DcaSettlement, Machine, PartialDispenseData, + SettleBalanceData, SuperConfig, ) @@ -111,6 +113,108 @@ def _build_partial_dispense_memo( ) +async def settle_lp_balance( + client: DcaClient, machine: Machine, data: SettleBalanceData +) -> DcaPayment: + """Operator UX action — closes satmachineadmin#4. + + Settle an LP's remaining fiat balance from the operator's chosen funding + wallet at the rate the operator specified. Records a leg_type='settlement' + row that counts against the LP's balance summary (so a subsequent + get_client_balance_summary reflects the new zero/reduced balance). + + Caller is responsible for verifying the operator owns both the client's + machine and the funding wallet (API endpoint does this). The amount_fiat + is capped at the LP's remaining balance — operators cannot accidentally + over-pay via this path. + """ + summary = await get_client_balance_summary(client.id) + if summary is None: + raise ValueError(f"client {client.id} balance not available") + remaining = float(summary.remaining_balance) + if remaining <= 0: + raise ValueError( + f"client {client.id} has no remaining balance to settle" + ) + + # Resolve fiat amount: explicit if given (capped at remaining), else full. + requested = ( + float(data.amount_fiat) if data.amount_fiat is not None else remaining + ) + amount_fiat = round(min(requested, remaining), 2) + if amount_fiat <= 0: + raise ValueError("computed settlement amount is zero") + + exchange_rate = float(data.exchange_rate) + amount_sats = round(amount_fiat * exchange_rate) + if amount_sats <= 0: + raise ValueError( + f"computed sat amount is zero (amount_fiat={amount_fiat}, " + f"exchange_rate={exchange_rate})" + ) + + reason = (data.notes or "").strip() or "(no reason given)" + memo = ( + f"satmachine balance settle — {amount_fiat:.2f} " + f"{machine.fiat_code} @ {exchange_rate:g} sat/{machine.fiat_code} " + f"= {amount_sats} sats. Reason: {reason}" + ) + + leg_row = await create_dca_payment( + CreateDcaPaymentData( + settlement_id=None, + client_id=client.id, + machine_id=machine.id, + operator_user_id=machine.operator_user_id, + leg_type="settlement", + destination_wallet_id=client.wallet_id, + destination_ln_address=None, + amount_sats=amount_sats, + amount_fiat=amount_fiat, + exchange_rate=exchange_rate, + transaction_time=datetime.now(timezone.utc), + external_payment_hash=None, + ) + ) + extra = { + "satmachine_leg": "settlement", + "satmachine_client_id": client.id, + "satmachine_machine_npub": machine.machine_npub, + "satmachine_exchange_rate": exchange_rate, + } + try: + new_invoice = await create_invoice( + wallet_id=client.wallet_id, + amount=float(amount_sats), + internal=True, + memo=memo, + extra=extra, + ) + if not new_invoice or not new_invoice.bolt11: + await update_payment_status( + leg_row.id, "failed", None, "create_invoice returned empty" + ) + raise ValueError("create_invoice returned empty") + paid = await pay_invoice( + wallet_id=data.funding_wallet_id, + payment_request=new_invoice.bolt11, + description=memo, + tag=_payment_tag(machine), + extra=extra, + ) + completed = await update_payment_status( + leg_row.id, "completed", paid.payment_hash, None + ) + return completed if completed is not None else leg_row + except Exception as exc: + logger.error( + f"distribution: balance-settle failed for client {client.id} " + f"({amount_sats} sats from wallet {data.funding_wallet_id}): {exc}" + ) + await update_payment_status(leg_row.id, "failed", None, str(exc)[:512]) + raise + + async def apply_partial_dispense_and_redistribute( settlement_id: str, data: PartialDispenseData ) -> DcaSettlement: diff --git a/models.py b/models.py index e5bfd46..f3e94e2 100644 --- a/models.py +++ b/models.py @@ -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) diff --git a/views_api.py b/views_api.py index e0e5b7b..7fc176f 100644 --- a/views_api.py +++ b/views_api.py @@ -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. # =============================================================================