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:
parent
e8dcbfe26e
commit
2883eb7b79
5 changed files with 352 additions and 3 deletions
101
crud.py
101
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.
|
||||
# =============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue