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

101
crud.py
View file

@ -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.
# =============================================================================