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
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
63
views_api.py
63
views_api.py
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue