ADR-005 slice 2: operator alert, settle off-machine, glossary + guide #50

Merged
padreug merged 6 commits from feat/dispense-outcome-slice2 into main 2026-10-10 20:30:36 +00:00
2 changed files with 88 additions and 0 deletions
Showing only changes of commit 7375112638 - Show all commits

feat(api): settle a cash_owed / partial_pending settlement off-machine

POST /api/v1/dca/settlements/{id}/settle-cash-owed: the operator paid
the customer by hand. Appends the provenance note, distributes at the
full amount (the customer is whole), and publishes a settle_transaction
op so the machine flips its own dispense_error/partial row to
remediated. Until now the only way to close an owed row was to dispense
more cash from the machine that had just jammed.
Padreug 2026-10-10 22:26:45 +02:00

View file

@ -359,6 +359,31 @@ async def apply_partial_dispense_and_redistribute(
return after if after is not None else updated return after if after is not None else updated
async def settle_cash_owed_settlement(
settlement: DcaSettlement, note: str, author_user_id: str
) -> DcaSettlement:
"""ADR-005 §6 — operator paid the customer by hand. The sale is whole, so
the settlement distributes at the full amount it landed with. The note is
the provenance; `remediated` on the machine side comes from the
settle_transaction op the endpoint publishes."""
from .crud import append_settlement_note
if settlement.status not in ("cash_owed", "partial_pending"):
raise ValueError(f"settlement {settlement.id} is {settlement.status!r}")
await append_settlement_note(
settlement.id, f"Settled off-machine (full amount): {note}", author_user_id
)
await mark_settlement_status(settlement.id, "pending", None)
logger.info(
f"distribution: settlement {settlement.id} settled off-machine by "
f"{author_user_id[:8]}… — distributing in full"
)
await process_settlement(settlement.id)
after = await get_settlement(settlement.id)
assert after is not None
return after
async def process_settlement(settlement_id: str) -> None: async def process_settlement(settlement_id: str) -> None:
"""Process a pending settlement end-to-end. """Process a pending settlement end-to-end.

View file

@ -74,6 +74,7 @@ from .crud import (
) )
from .distribution import ( from .distribution import (
apply_partial_dispense_and_redistribute, apply_partial_dispense_and_redistribute,
settle_cash_owed_settlement,
process_settlement, process_settlement,
settle_lp_balance, settle_lp_balance,
) )
@ -95,6 +96,7 @@ from .models import (
Machine, Machine,
PairMachineData, PairMachineData,
PartialDispenseData, PartialDispenseData,
SettleCashOwedData,
SetCommissionSplitsData, SetCommissionSplitsData,
SettleBalanceData, SettleBalanceData,
StuckSettlementsResponse, StuckSettlementsResponse,
@ -815,6 +817,67 @@ async def api_get_settlement(
return settlement return settlement
@spirekeeper_api_router.post(
"/api/v1/dca/settlements/{settlement_id}/settle-cash-owed",
response_model=DcaSettlement,
)
async def api_settle_cash_owed(
settlement_id: str,
data: SettleCashOwedData,
user: User = Depends(check_user_exists),
) -> DcaSettlement:
"""ADR-005 §6 — the operator paid the customer outside the machine.
For a settlement in cash_owed or partial_pending: appends the provenance
note, moves it to pending and distributes at the FULL amount (the customer
is whole, so the sale is the full sale), and publishes a `settle_transaction`
op so the machine flips its own `dispense_error` / `partial` row to
`remediated`. Both ledgers close on one act; nothing is dispensed.
Until now the only way to clear an owed-cash row was to dispense more cash
from the machine that had just jammed.
Errors: 404 not yours; 409 wrong status. The machine op is best-effort —
recorded, and delivered with the next publish if the relay is down.
"""
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 settlement.status not in ("cash_owed", "partial_pending"):
raise HTTPException(
HTTPStatus.CONFLICT,
f"settlement is {settlement.status!r}; only cash_owed / partial_pending "
"can be settled off-machine",
)
updated = await settle_cash_owed_settlement(settlement, data.note, user.id)
if machine.machine_npub and settlement.bitspire_txid:
try:
await create_cassette_op(
machine.id,
CreateCassetteOpData(
position=0,
op_type="settle_transaction",
txid=settlement.bitspire_txid,
note=data.note,
),
created_by=user.id,
)
window = await get_cassette_ops_window(machine.id)
await publish_ops_to_atm(machine, window, user.id)
except (OperatorIdentityMissing, SignerUnavailable, RelayUnavailable) as exc:
logger.warning(
f"spirekeeper: settle_transaction op for {settlement.bitspire_txid} "
f"recorded but not published yet: {exc}"
)
except CassetteTransportError as exc:
logger.error(f"spirekeeper: settle_transaction publish failed: {exc}")
return updated
@spirekeeper_api_router.post( @spirekeeper_api_router.post(
"/api/v1/dca/settlements/{settlement_id}/partial-dispense", "/api/v1/dca/settlements/{settlement_id}/partial-dispense",
response_model=DcaSettlement, response_model=DcaSettlement,