ADR-005 slice 2: operator alert, settle off-machine, glossary + guide #50
2 changed files with 88 additions and 0 deletions
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.
commit
7375112638
|
|
@ -359,6 +359,31 @@ async def apply_partial_dispense_and_redistribute(
|
|||
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:
|
||||
"""Process a pending settlement end-to-end.
|
||||
|
||||
|
|
|
|||
63
views_api.py
63
views_api.py
|
|
@ -74,6 +74,7 @@ from .crud import (
|
|||
)
|
||||
from .distribution import (
|
||||
apply_partial_dispense_and_redistribute,
|
||||
settle_cash_owed_settlement,
|
||||
process_settlement,
|
||||
settle_lp_balance,
|
||||
)
|
||||
|
|
@ -95,6 +96,7 @@ from .models import (
|
|||
Machine,
|
||||
PairMachineData,
|
||||
PartialDispenseData,
|
||||
SettleCashOwedData,
|
||||
SetCommissionSplitsData,
|
||||
SettleBalanceData,
|
||||
StuckSettlementsResponse,
|
||||
|
|
@ -815,6 +817,67 @@ async def api_get_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(
|
||||
"/api/v1/dca/settlements/{settlement_id}/partial-dispense",
|
||||
response_model=DcaSettlement,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue