From 2883eb7b79bcf1537eaf6cb0f7db675613f4729e Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 14 May 2026 15:46:33 +0200 Subject: [PATCH] feat(v2): partial-dispense + operator notes on settlements (P3d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crud.py | 101 ++++++++++++++++++++++++++++++++++ distribution.py | 142 +++++++++++++++++++++++++++++++++++++++++++++++- migrations.py | 18 ++++++ models.py | 27 +++++++++ views_api.py | 67 +++++++++++++++++++++++ 5 files changed, 352 insertions(+), 3 deletions(-) diff --git a/crud.py b/crud.py index 614a422..be7a12e 100644 --- a/crud.py +++ b/crud.py @@ -550,6 +550,107 @@ async def mark_settlement_status( return await get_settlement(settlement_id) +async def apply_partial_dispense( + settlement_id: str, + *, + new_gross_sats: int, + new_net_sats: int, + new_commission_sats: int, + new_platform_fee_sats: int, + new_operator_fee_sats: int, + new_fiat_amount: float, + appended_note: str, +) -> Optional[DcaSettlement]: + """Overwrite the monetary fields on a settlement (partial-dispense + recompute) and prepend `appended_note` to the notes column. + + Notes are append-only: new lines go at the top (newest first) so the + settlement detail view shows the most recent adjustment first without + needing to scroll. Resets status to 'pending' so process_settlement + can re-distribute via the existing idempotent path.""" + await db.execute( + """ + UPDATE satoshimachine.dca_settlements + SET gross_sats = :gross, + net_sats = :net, + commission_sats = :commission, + platform_fee_sats = :platform, + operator_fee_sats = :operator, + fiat_amount = :fiat, + status = 'pending', + error_message = NULL, + processed_at = NULL, + notes = CASE + WHEN notes IS NULL OR notes = '' THEN :note + ELSE :note || char(10) || char(10) || notes + END + WHERE id = :id + """, + { + "id": settlement_id, + "gross": new_gross_sats, + "net": new_net_sats, + "commission": new_commission_sats, + "platform": new_platform_fee_sats, + "operator": new_operator_fee_sats, + "fiat": new_fiat_amount, + "note": appended_note, + }, + ) + return await get_settlement(settlement_id) + + +async def count_completed_legs_for_settlement(settlement_id: str) -> int: + """Used by partial-dispense to refuse adjustments after any leg has + successfully moved sats (Lightning payments can't be clawed back).""" + row = await db.fetchone( + """ + SELECT COUNT(*) AS n FROM satoshimachine.dca_payments + WHERE settlement_id = :sid AND status = 'completed' + """, + {"sid": settlement_id}, + ) + return int(row["n"]) if row else 0 + + +async def append_settlement_note( + settlement_id: str, note: str, author_user_id: str +) -> Optional[DcaSettlement]: + """Prepend an operator-authored note to settlement.notes. Each entry is + timestamped (UTC) and tagged with the author's user id so the trail + is accountable. Append-only: existing entries are never edited.""" + from datetime import timezone + + ts = datetime.now(timezone.utc).isoformat(timespec="seconds") + formatted = f"[{ts} by {author_user_id}] {note}" + await db.execute( + """ + UPDATE satoshimachine.dca_settlements + SET notes = CASE + WHEN notes IS NULL OR notes = '' THEN :note + ELSE :note || char(10) || char(10) || notes + END + WHERE id = :id + """, + {"id": settlement_id, "note": formatted}, + ) + return await get_settlement(settlement_id) + + +async def void_open_legs_for_settlement(settlement_id: str) -> None: + """Marks pending/failed legs as 'voided' before re-running distribution + on a partial-dispense recompute. Preserves the rows for audit but stops + them from being interpreted as live.""" + await db.execute( + """ + UPDATE satoshimachine.dca_payments + SET status = 'voided' + WHERE settlement_id = :sid AND status IN ('pending', 'failed') + """, + {"sid": settlement_id}, + ) + + # ============================================================================= # Commission splits — operator's remainder-distribution rules. # ============================================================================= diff --git a/distribution.py b/distribution.py index 91dc401..9e266b8 100644 --- a/distribution.py +++ b/distribution.py @@ -23,14 +23,20 @@ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timezone from typing import List from lnbits.core.services import create_invoice, pay_invoice from loguru import logger -from .calculations import allocate_operator_split_legs, calculate_distribution +from .calculations import ( + allocate_operator_split_legs, + calculate_distribution, + split_two_stage_commission, +) from .crud import ( + apply_partial_dispense, + count_completed_legs_for_settlement, create_dca_payment, get_client_balance_summary, get_effective_commission_splits, @@ -40,12 +46,14 @@ from .crud import ( get_super_config, mark_settlement_status, update_payment_status, + void_open_legs_for_settlement, ) from .models import ( CreateDcaPaymentData, DcaPayment, DcaSettlement, Machine, + PartialDispenseData, SuperConfig, ) @@ -56,6 +64,134 @@ def _payment_tag(machine: Machine) -> str: return f"{PAYMENT_TAG_PREFIX}:{machine.machine_npub}" +def _resolve_partial_dispense_gross( + settlement: DcaSettlement, data: PartialDispenseData +) -> int: + if data.dispensed_sats is not None: + new_gross = int(data.dispensed_sats) + elif data.dispensed_fraction is not None: + new_gross = round(settlement.gross_sats * float(data.dispensed_fraction)) + else: + raise ValueError("provide one of dispensed_sats or dispensed_fraction") + if new_gross < 0: + raise ValueError("partial dispense cannot be negative") + if new_gross > settlement.gross_sats: + raise ValueError( + f"partial dispense ({new_gross} sats) cannot exceed the original " + f"gross ({settlement.gross_sats} sats)" + ) + return new_gross + + +def _build_partial_dispense_memo( + settlement: DcaSettlement, + data: PartialDispenseData, + *, + new_gross: int, + new_net: int, + new_commission: int, + new_platform: int, + new_operator: int, +) -> str: + reason = (data.notes or "").strip() or "(no reason given)" + if data.dispensed_sats is not None: + adjust = f"dispensed_sats={data.dispensed_sats}" + else: + adjust = f"dispensed_fraction={data.dispensed_fraction}" + ts = datetime.now(timezone.utc).isoformat(timespec="seconds") + return ( + f"[{ts}] partial dispense applied — {adjust}. " + f"Original gross={settlement.gross_sats} net={settlement.net_sats} " + f"commission={settlement.commission_sats} " + f"(super_fee={settlement.platform_fee_sats} " + f"operator_fee={settlement.operator_fee_sats}). " + f"New gross={new_gross} net={new_net} commission={new_commission} " + f"(super_fee={new_platform} operator_fee={new_operator}). " + f"Reason: {reason}" + ) + + +async def apply_partial_dispense_and_redistribute( + settlement_id: str, data: PartialDispenseData +) -> DcaSettlement: + """Operator UX action — closes satmachineadmin#3. + + When a bitSpire dispense fails mid-transaction (e.g., dispenser jam after + 6 of 10 bills), the operator confirms the actual amount dispensed and we + re-allocate the split against that partial gross. Sat amounts scale + linearly, preserving the original commission ratio exactly; the two-stage + super/operator split is recomputed using the CURRENT super_fee_pct + (super may have changed the rate since the original landed). + + Hard guard: refuses if any dca_payments leg has already completed. + Lightning payments can't be clawed back, so we won't try. + + Side effects: + - Voids pending/failed legs (status → 'voided'). + - Overwrites the settlement's monetary fields with the new totals. + - Appends a timestamped memo to settlement.notes capturing the + original values + operator's reason. + - Resets settlement.status to 'pending' and triggers process_settlement. + """ + settlement = await get_settlement(settlement_id) + if settlement is None: + raise ValueError(f"settlement {settlement_id} not found") + if settlement.gross_sats <= 0: + raise ValueError("cannot partial-dispense a zero-gross settlement") + completed = await count_completed_legs_for_settlement(settlement_id) + if completed > 0: + raise ValueError( + f"cannot partial-dispense: {completed} leg(s) already completed " + "(Lightning payments can't be clawed back)" + ) + + new_gross = _resolve_partial_dispense_gross(settlement, data) + # Linear scale preserves the original commission ratio exactly. + scale = new_gross / settlement.gross_sats + new_commission = round(settlement.commission_sats * scale) + new_net = new_gross - new_commission + new_fiat = round(float(settlement.fiat_amount) * scale, 2) + + # Re-stage-1 split using the CURRENT super_fee_pct. + super_config = await get_super_config() + super_fee_pct = float(super_config.super_fee_pct) if super_config else 0.0 + new_platform, new_operator = split_two_stage_commission( + new_commission, super_fee_pct + ) + + memo = _build_partial_dispense_memo( + settlement, + data, + new_gross=new_gross, + new_net=new_net, + new_commission=new_commission, + new_platform=new_platform, + new_operator=new_operator, + ) + + await void_open_legs_for_settlement(settlement_id) + updated = await apply_partial_dispense( + settlement_id, + new_gross_sats=new_gross, + new_net_sats=new_net, + new_commission_sats=new_commission, + new_platform_fee_sats=new_platform, + new_operator_fee_sats=new_operator, + new_fiat_amount=new_fiat, + appended_note=memo, + ) + if updated is None: + raise ValueError(f"settlement {settlement_id} disappeared mid-update") + + logger.info( + f"distribution: partial-dispense applied to settlement " + f"{settlement_id} — re-running distribution" + ) + await process_settlement(settlement_id) + after = await get_settlement(settlement_id) + return after if after is not None else updated + + async def process_settlement(settlement_id: str) -> None: """Process a pending settlement end-to-end. Safe to invoke multiple times — the status='processed' guard skips already-processed rows.""" @@ -155,7 +291,7 @@ async def _pay_operator_splits( settlement.operator_fee_sats, [float(leg.pct) for leg in splits], ) - for idx, (leg, amount) in enumerate(zip(splits, leg_amounts)): + for idx, (leg, amount) in enumerate(zip(splits, leg_amounts, strict=True)): if amount <= 0: continue label = leg.label or f"split-{idx + 1}" diff --git a/migrations.py b/migrations.py index c9588c0..8db8d0e 100644 --- a/migrations.py +++ b/migrations.py @@ -428,3 +428,21 @@ async def m005_satmachine_v2_overhaul(db): ); """ ) + + +async def m006_add_settlement_notes(db): + """Audit memo on dca_settlements. + + When an operator triggers an in-place adjustment (partial-dispense, + manual reconciliation override, etc.), the settlement row's monetary + fields are overwritten with the new numbers. To preserve the audit + trail without a separate history table, we append a timestamped memo + to this notes column capturing the previous values and the reason. + + Operators see this directly in the settlement detail view, so any + overwrite is visible and dated. Append-only convention: new memos + are prepended with a timestamp; never edited in place. + """ + await db.execute( + "ALTER TABLE satoshimachine.dca_settlements ADD COLUMN notes TEXT" + ) diff --git a/models.py b/models.py index a92cade..e5bfd46 100644 --- a/models.py +++ b/models.py @@ -224,6 +224,11 @@ class DcaSettlement(BaseModel): error_message: Optional[str] processed_at: Optional[datetime] created_at: datetime + # Append-only audit memo. Populated when an operator triggers an in-place + # adjustment (partial-dispense, manual reconciliation override). Each + # entry timestamped + records original values so the overwrite is + # auditable from the settlement detail view alone. Never edited in place. + notes: Optional[str] = None # ============================================================================= @@ -397,6 +402,28 @@ class PartialDispenseData(BaseModel): return v +class AppendSettlementNoteData(BaseModel): + """Operator-authored free-form note on a settlement. + + Notes are prepended (newest first) to the settlement's `notes` column, + with a UTC timestamp and the author's user id so each entry is + accountable. Useful for cash-drawer reconciliation context, off-the- + record refund records, or any narrative an operator wants to attach + for future reference. + """ + + note: str + + @validator("note") + def non_empty(cls, v): + v = v.strip() if isinstance(v, str) else v + if not v: + raise ValueError("note cannot be empty") + if len(v) > 2000: + raise ValueError("note too long (max 2000 chars)") + return v + + class SettleBalanceData(BaseModel): """Resolves satmachineadmin#4 — operator settles small remaining LP balance from their own wallet at the current exchange rate.""" diff --git a/views_api.py b/views_api.py index 3fa901f..e0e5b7b 100644 --- a/views_api.py +++ b/views_api.py @@ -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) # =============================================================================