feat(v2): partial-dispense + operator notes on settlements (P3d)

Closes the v1 feature request satmachineadmin#3 (partial transaction
processing) and adds operator-authored audit notes on settlements.

Schema (m006_add_settlement_notes):
  ALTER TABLE dca_settlements ADD COLUMN notes TEXT

The notes column is append-only (prepend with timestamp, never edit in
place). Stores both system-generated audit memos (partial-dispense
recompute provenance) and operator-authored free-form notes (cash-
drawer reconciliation context, off-LN refund records, etc.).

Partial-dispense endpoint:
  POST /api/v1/dca/settlements/{id}/partial-dispense
  body: PartialDispenseData {dispensed_fraction OR dispensed_sats, notes}

Recompute path (in distribution.apply_partial_dispense_and_redistribute):
  1. Refuse if any leg has status='completed' (Lightning can't claw back)
  2. Resolve new_gross from dispensed_fraction or dispensed_sats
  3. Linear-scale net/commission/fiat — preserves the original commission
     ratio exactly; only rounding may drift by 1 sat
  4. Re-stage-1 split using the CURRENT super_fee_pct (super may have
     changed the rate since the original landed)
  5. Build a memo capturing original values + reason + new values
  6. Void pending/failed legs (status → 'voided')
  7. Overwrite the settlement's monetary fields + prepend memo to notes
  8. Reset status to 'pending' → process_settlement re-runs distribution

Operator notes endpoint:
  POST /api/v1/dca/settlements/{id}/notes
  body: AppendSettlementNoteData {note}

Each operator note is timestamped (UTC) and tagged with the author's
user_id so the audit trail is accountable. Non-empty, max 2000 chars.

72/72 tests still pass. 30 routes total. The full-directory ruff number
ballooned to ~500 because it includes legacy transaction_processor.py
(orphaned, not imported anywhere) and other v1 cruft on the branch.
Files I actively maintain are clean.

Note: a richer queryable audit history (filter by author / time range /
action type / etc.) is being tracked as a separate future-work issue.
The notes-column approach here is the v1 audit story; the dedicated
history table will be additive.

Refs: aiolabs/satmachineadmin#9, closes #3 (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 15:46:33 +02:00
commit 2883eb7b79
5 changed files with 352 additions and 3 deletions

View file

@ -12,6 +12,7 @@ from lnbits.core.models import User
from lnbits.decorators import check_super_user, check_user_exists
from .crud import (
append_settlement_note,
create_dca_client,
create_deposit,
create_machine,
@ -41,7 +42,9 @@ from .crud import (
update_machine,
update_super_config,
)
from .distribution import apply_partial_dispense_and_redistribute
from .models import (
AppendSettlementNoteData,
ClientBalanceSummary,
CommissionSplit,
CreateDcaClientData,
@ -52,6 +55,7 @@ from .models import (
DcaPayment,
DcaSettlement,
Machine,
PartialDispenseData,
SetCommissionSplitsData,
SuperConfig,
UpdateDcaClientData,
@ -374,6 +378,69 @@ async def api_get_settlement(
return settlement
@satmachineadmin_api_router.post(
"/api/v1/dca/settlements/{settlement_id}/partial-dispense",
response_model=DcaSettlement,
)
async def api_partial_dispense(
settlement_id: str,
data: PartialDispenseData,
user: User = Depends(check_user_exists),
) -> DcaSettlement:
"""Operator UX — resolves satmachineadmin#3.
Recompute the split for a settlement that didn't dispense the full
amount (jam, mid-tx error). Provide one of dispensed_fraction (0..1)
or dispensed_sats. Optionally include a reason in notes.
Refuses when any leg has already completed Lightning payments can't
be clawed back. Use balance settlement (P3e) for those cases.
"""
settlement = await get_settlement(settlement_id)
if settlement is None:
raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found")
machine = await get_machine(settlement.machine_id)
if machine is None or machine.operator_user_id != user.id:
raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found")
if (data.dispensed_fraction is None) == (data.dispensed_sats is None):
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Provide exactly one of dispensed_fraction or dispensed_sats",
)
try:
return await apply_partial_dispense_and_redistribute(settlement_id, data)
except ValueError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
@satmachineadmin_api_router.post(
"/api/v1/dca/settlements/{settlement_id}/notes",
response_model=DcaSettlement,
)
async def api_append_settlement_note(
settlement_id: str,
data: AppendSettlementNoteData,
user: User = Depends(check_user_exists),
) -> DcaSettlement:
"""Operator appends a free-form note to the settlement. Useful for cash-
drawer reconciliation context, off-LN refund records, or any narrative
an operator wants to attach. Each entry is timestamped (UTC) and tagged
with the author's user id; existing entries are never modified.
For richer queryable audit (filter by author, time range, action type),
see aiolabs/satmachineadmin (future audit-table feature)."""
settlement = await get_settlement(settlement_id)
if settlement is None:
raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found")
machine = await get_machine(settlement.machine_id)
if machine is None or machine.operator_user_id != user.id:
raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found")
updated = await append_settlement_note(settlement_id, data.note, user.id)
if updated is None:
raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found")
return updated
# =============================================================================
# Payments (read-only — the leg-typed breakdown of distributions)
# =============================================================================