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)
|
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.
|
# Commission splits — operator's remainder-distribution rules.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
142
distribution.py
142
distribution.py
|
|
@ -23,14 +23,20 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from lnbits.core.services import create_invoice, pay_invoice
|
from lnbits.core.services import create_invoice, pay_invoice
|
||||||
from loguru import logger
|
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 (
|
from .crud import (
|
||||||
|
apply_partial_dispense,
|
||||||
|
count_completed_legs_for_settlement,
|
||||||
create_dca_payment,
|
create_dca_payment,
|
||||||
get_client_balance_summary,
|
get_client_balance_summary,
|
||||||
get_effective_commission_splits,
|
get_effective_commission_splits,
|
||||||
|
|
@ -40,12 +46,14 @@ from .crud import (
|
||||||
get_super_config,
|
get_super_config,
|
||||||
mark_settlement_status,
|
mark_settlement_status,
|
||||||
update_payment_status,
|
update_payment_status,
|
||||||
|
void_open_legs_for_settlement,
|
||||||
)
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
CreateDcaPaymentData,
|
CreateDcaPaymentData,
|
||||||
DcaPayment,
|
DcaPayment,
|
||||||
DcaSettlement,
|
DcaSettlement,
|
||||||
Machine,
|
Machine,
|
||||||
|
PartialDispenseData,
|
||||||
SuperConfig,
|
SuperConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -56,6 +64,134 @@ def _payment_tag(machine: Machine) -> str:
|
||||||
return f"{PAYMENT_TAG_PREFIX}:{machine.machine_npub}"
|
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:
|
async def process_settlement(settlement_id: str) -> None:
|
||||||
"""Process a pending settlement end-to-end. Safe to invoke multiple
|
"""Process a pending settlement end-to-end. Safe to invoke multiple
|
||||||
times — the status='processed' guard skips already-processed rows."""
|
times — the status='processed' guard skips already-processed rows."""
|
||||||
|
|
@ -155,7 +291,7 @@ async def _pay_operator_splits(
|
||||||
settlement.operator_fee_sats,
|
settlement.operator_fee_sats,
|
||||||
[float(leg.pct) for leg in splits],
|
[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:
|
if amount <= 0:
|
||||||
continue
|
continue
|
||||||
label = leg.label or f"split-{idx + 1}"
|
label = leg.label or f"split-{idx + 1}"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
|
|
|
||||||
27
models.py
27
models.py
|
|
@ -224,6 +224,11 @@ class DcaSettlement(BaseModel):
|
||||||
error_message: Optional[str]
|
error_message: Optional[str]
|
||||||
processed_at: Optional[datetime]
|
processed_at: Optional[datetime]
|
||||||
created_at: 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
|
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):
|
class SettleBalanceData(BaseModel):
|
||||||
"""Resolves satmachineadmin#4 — operator settles small remaining LP balance
|
"""Resolves satmachineadmin#4 — operator settles small remaining LP balance
|
||||||
from their own wallet at the current exchange rate."""
|
from their own wallet at the current exchange rate."""
|
||||||
|
|
|
||||||
67
views_api.py
67
views_api.py
|
|
@ -12,6 +12,7 @@ from lnbits.core.models import User
|
||||||
from lnbits.decorators import check_super_user, check_user_exists
|
from lnbits.decorators import check_super_user, check_user_exists
|
||||||
|
|
||||||
from .crud import (
|
from .crud import (
|
||||||
|
append_settlement_note,
|
||||||
create_dca_client,
|
create_dca_client,
|
||||||
create_deposit,
|
create_deposit,
|
||||||
create_machine,
|
create_machine,
|
||||||
|
|
@ -41,7 +42,9 @@ from .crud import (
|
||||||
update_machine,
|
update_machine,
|
||||||
update_super_config,
|
update_super_config,
|
||||||
)
|
)
|
||||||
|
from .distribution import apply_partial_dispense_and_redistribute
|
||||||
from .models import (
|
from .models import (
|
||||||
|
AppendSettlementNoteData,
|
||||||
ClientBalanceSummary,
|
ClientBalanceSummary,
|
||||||
CommissionSplit,
|
CommissionSplit,
|
||||||
CreateDcaClientData,
|
CreateDcaClientData,
|
||||||
|
|
@ -52,6 +55,7 @@ from .models import (
|
||||||
DcaPayment,
|
DcaPayment,
|
||||||
DcaSettlement,
|
DcaSettlement,
|
||||||
Machine,
|
Machine,
|
||||||
|
PartialDispenseData,
|
||||||
SetCommissionSplitsData,
|
SetCommissionSplitsData,
|
||||||
SuperConfig,
|
SuperConfig,
|
||||||
UpdateDcaClientData,
|
UpdateDcaClientData,
|
||||||
|
|
@ -374,6 +378,69 @@ async def api_get_settlement(
|
||||||
return 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)
|
# Payments (read-only — the leg-typed breakdown of distributions)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue