From 651c43d7b070cecf686a059a8679d8f9174b00d2 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 10 Oct 2026 22:27:39 +0200 Subject: [PATCH 1/2] feat(machine): apply the settle_transaction operator op MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-005 §6: the operator paid the customer by hand and recorded it in spirekeeper. The op carries the txid and the note; the machine flips its own dispense_error/partial row to remediated via remediateTransaction, which only touches rows still in an error state, so re-delivery is a no-op. Closes the machine side of the ledger for an owed-cash sale without dispensing anything. --- apps/machine/src/services/operator-config.ts | 33 ++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/machine/src/services/operator-config.ts b/apps/machine/src/services/operator-config.ts index e9f38b9..549719f 100644 --- a/apps/machine/src/services/operator-config.ts +++ b/apps/machine/src/services/operator-config.ts @@ -65,7 +65,21 @@ type CassetteOp = { * machine" gesture and already clears counts-uncertain. */ type ResumeCashOutOp = { id: string; at: number; type: 'resume_cash_out' } -type OperatorOp = CassetteOp | ResumeCashOutOp +/** + * ADR-005 §6: the operator paid the customer by hand, off-machine, for a + * cash-out this machine recorded as dispense_error / partial. Flips that row + * to `remediated` with the note as provenance so both ledgers close on one + * act. Idempotent by nature — remediateTransaction only touches rows still + * in an error state — so re-delivery is harmless. + */ +type SettleTransactionOp = { + id: string + at: number + type: 'settle_transaction' + txid: string + note?: string +} +type OperatorOp = CassetteOp | ResumeCashOutOp | SettleTransactionOp /** Accept operator events stamped up to this many seconds in the future. */ const MAX_FUTURE_SKEW_S = 60 @@ -249,7 +263,22 @@ async function handleOperatorConfigEvent( const resumeOps = allOps.filter( (o): o is ResumeCashOutOp => !!o && o.type === 'resume_cash_out' ) - const ops = allOps.filter((o): o is CassetteOp => !!o && o.type !== 'resume_cash_out') + const settleOps = allOps.filter( + (o): o is SettleTransactionOp => + !!o && o.type === 'settle_transaction' && typeof (o as SettleTransactionOp).txid === 'string' + ) + for (const op of settleOps) { + const provenance = `settled-off-machine:${op.id}${op.note ? `:${op.note}` : ''}` + const changed = await api.remediateTransaction(op.txid, provenance) + console.log( + `[OperatorConfig] settle_transaction ${op.id} for ${op.txid}: ` + + (changed ? 'row marked remediated' : 'no row in an error state (already closed, or unknown)') + ) + } + const ops = allOps.filter( + (o): o is CassetteOp => + !!o && o.type !== 'resume_cash_out' && o.type !== 'settle_transaction' + ) if (holdBefore && resumeOps.some((o) => typeof o.at === 'number' && o.at > holdBefore.since)) { await api.clearCashOutHold() console.log( From 5a4f70c90ea52ff06b720d81e772903f5566bda7 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 10 Oct 2026 22:27:39 +0200 Subject: [PATCH 2/2] docs(adr-005): record the NIP-17 alert and the settle-cash-owed path as built --- docs/adr/005-cash-out-dispense-outcome.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/adr/005-cash-out-dispense-outcome.md b/docs/adr/005-cash-out-dispense-outcome.md index 22bc03a..121616a 100644 --- a/docs/adr/005-cash-out-dispense-outcome.md +++ b/docs/adr/005-cash-out-dispense-outcome.md @@ -246,9 +246,13 @@ rather than pausing it. Server: `cash_owed` and `dispense_unreported` are two new buckets on `StuckSettlementsResponse`. They are the only buckets whose meaning is *a customer is owed -money*, and they render first. Arrival in either bucket triggers the operator notification -path (whatever `notifyOperator` equivalent spirekeeper grows; at minimum the dashboard banner -— but the push is the point, and it belongs in the same transaction that writes the row). +money*, and they render first. Arrival in `cash_owed` or `partial_pending` sends the operator +a **NIP-17 gift-wrapped DM** (kind 14 → 13 → 1059) to their own LNbits-account pubkey, or to +`super_config.alerts_pubkey` when set — a note to self any NIP-46 client renders. It is signed +through the operator's signer (no key at rest), is best-effort (a failed publish is logged and +the report is still acked — the worklist is the durable record), and sets +`operator_notified_at` so a report resend never re-alerts. Not email, not NIP-04. +*(Implemented: spirekeeper `notify.py`, slice 2.)* Resolution closes **both** ledgers: @@ -256,11 +260,13 @@ Resolution closes **both** ledgers: to `remediated` via `remediateTransaction`. The machine sends a `report_dispense` for the remediation with `remediates_txid`, and the server moves the settlement from `cash_owed` to `pending` and distributes. -- **Off-machine settlement.** The operator paid the customer by hand. A new `settle_cash_owed` - operator action records provenance (free text, author, time) on the settlement, moves it to - `pending`, and publishes a `settle_transaction { txid, note }` operator op; the machine - applies it by setting `remediated_by` to the note and `status = 'remediated'`. Today there is - no way to record this at all, and the machine's ledger asserts the debt forever. +- **Off-machine settlement.** The operator paid the customer by hand. + `POST /settlements/{id}/settle-cash-owed` records provenance (free text, author, time) on the + settlement, moves it to `pending` and distributes **at the full amount** (the customer is + whole), and publishes a machine-wide `settle_transaction { id, at, txid, note }` operator op; + the machine applies it through `remediateTransaction(txid, "settled-off-machine::")`, + which only touches rows still in an error state, so re-delivery is harmless. Before slice 2 + there was no way to record this at all, and the machine's ledger asserted the debt forever. `PartialDispenseData` is pre-filled from the report's `bills` so the operator confirms a number the hardware produced rather than typing one.