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.
This commit is contained in:
Padreug 2026-10-10 22:26:45 +02:00
commit 7375112638
2 changed files with 88 additions and 0 deletions

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,