diff --git a/distribution.py b/distribution.py index 271494b..4ce28f1 100644 --- a/distribution.py +++ b/distribution.py @@ -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. diff --git a/views_api.py b/views_api.py index 7d6bf9f..fd6d69e 100644 --- a/views_api.py +++ b/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,