diff --git a/crud.py b/crud.py
index 6dc7ea9..1858cb6 100644
--- a/crud.py
+++ b/crud.py
@@ -739,15 +739,6 @@ async def create_settlement_idempotent(
return await get_settlement(settlement_id)
-async def mark_settlement_notified(settlement_id: str) -> None:
- """The operator alert for this settlement went out (ADR-005 §6)."""
- await db.execute(
- "UPDATE spirekeeper.dca_settlements SET operator_notified_at = :now "
- "WHERE id = :id AND operator_notified_at IS NULL",
- {"id": settlement_id, "now": datetime.now()},
- )
-
-
async def get_settlement_by_txid(
machine_id: str, bitspire_txid: str
) -> DcaSettlement | None:
@@ -1936,9 +1927,9 @@ async def create_cassette_op(
"""
INSERT INTO spirekeeper.cassette_ops
(id, machine_id, position, op_type, bills, count, denomination,
- txid, note, created_at, created_by)
+ created_at, created_by)
VALUES (:id, :machine_id, :position, :op_type, :bills, :count,
- :denomination, :txid, :note, :created_at, :created_by)
+ :denomination, :created_at, :created_by)
""",
{
"id": op_id,
@@ -1948,8 +1939,6 @@ async def create_cassette_op(
"bills": data.bills,
"count": data.count,
"denomination": data.denomination,
- "txid": data.txid,
- "note": data.note,
"created_at": datetime.now(),
"created_by": created_by,
},
diff --git a/dispense_transport.py b/dispense_transport.py
index 80921eb..2b13efa 100644
--- a/dispense_transport.py
+++ b/dispense_transport.py
@@ -80,14 +80,6 @@ def _spawn_distribution(settlement_id: str) -> None:
task.add_done_callback(_inflight.discard)
-async def _alert_operator(settlement, machine: Machine, report: DispenseReportIn, status: str) -> None:
- """ADR-005 §6: a human is now owed money — tell the operator over Nostr.
- Best-effort; the report is acked regardless (see notify.py)."""
- from .notify import notify_cash_outcome
-
- await notify_cash_outcome(settlement, machine, report, status)
-
-
async def apply_report_to_settlement(
settlement: DcaSettlement,
report: DispenseReportIn,
@@ -126,7 +118,6 @@ async def apply_report_to_settlement(
f"{report.fiat_cents / 100:.2f} {report.currency}) — distributing"
)
elif new_status == "partial_pending":
- await _alert_operator(updated or settlement, machine, report, new_status)
logger.warning(
f"spirekeeper: PARTIAL dispense for settlement {settlement.id} "
f"(machine={machine.id}, txid={report.txid}): "
@@ -136,7 +127,6 @@ async def apply_report_to_settlement(
f"{report.raw_code or ''}. Held until the operator resolves the shortfall."
)
else:
- await _alert_operator(updated or settlement, machine, report, new_status)
logger.error(
f"spirekeeper: CASH OWED — settlement {settlement.id} "
f"(machine={machine.id}, txid={report.txid}): customer paid "
diff --git a/distribution.py b/distribution.py
index 4ce28f1..271494b 100644
--- a/distribution.py
+++ b/distribution.py
@@ -359,31 +359,6 @@ 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/migrations.py b/migrations.py
index 0c1f0c0..ed3d6f1 100644
--- a/migrations.py
+++ b/migrations.py
@@ -1020,29 +1020,3 @@ async def m016_dispense_outcome(db):
await db.execute(
f"ALTER TABLE spirekeeper.dca_machines ADD COLUMN {col} {typ}"
)
-
-
-async def m017_owed_cash_closure(db):
- """ADR-005 §6 — closing an owed-cash settlement, and telling the operator.
-
- `operator_notified_at`: when the Nostr alert for a cash_owed /
- partial_pending settlement went out (NIP-17 DM to the operator). Kept so
- a report resend never re-alerts and the dashboard can show that the
- operator was told.
-
- `super_config.alerts_pubkey`: optional recipient override. By default the
- alert goes to the operator's own LNbits-account pubkey (a note to self,
- readable in any NIP-46 client); operators who want it on a phone identity
- set this.
-
- `cassette_ops.txid` / `.note`: the machine-wide `settle_transaction` op —
- the operator paid the customer by hand, outside the machine — carries the
- machine txid it closes and the provenance note, so the machine can flip its
- own row to `remediated` and both ledgers close on one act.
- """
- await db.execute(
- "ALTER TABLE spirekeeper.dca_settlements ADD COLUMN operator_notified_at TIMESTAMP"
- )
- await db.execute("ALTER TABLE spirekeeper.super_config ADD COLUMN alerts_pubkey TEXT")
- await db.execute("ALTER TABLE spirekeeper.cassette_ops ADD COLUMN txid TEXT")
- await db.execute("ALTER TABLE spirekeeper.cassette_ops ADD COLUMN note TEXT")
diff --git a/models.py b/models.py
index f51adf8..f1422fc 100644
--- a/models.py
+++ b/models.py
@@ -351,9 +351,6 @@ class DcaSettlement(BaseModel):
dispense_error_class: str | None = None
dispense_reported_at: datetime | None = None
dispensed_fiat_cents: int | None = None
- # ADR-005 §6: when the operator was alerted (NIP-17 DM) that this
- # settlement needs a human. NULL = no alert sent (or none was due).
- operator_notified_at: datetime | None = None
# Append-only audit memo. Populated when an operator triggers an in-place
# adjustment (partial-dispense, manual reconciliation override). Each
# entry timestamped + records original values so the overwrite is
@@ -626,9 +623,6 @@ class SuperConfig(BaseModel):
# rate, not sats, so this bounds a single ATM-attested principal. NULL = no
# cap.
max_cash_in_sats: int | None = None
- # ADR-005 §6: where owed-cash alerts go. NULL/empty = the operator's own
- # LNbits-account pubkey (a note to self). 64-char hex.
- alerts_pubkey: str | None = None
updated_at: datetime
@@ -637,23 +631,6 @@ class UpdateSuperConfigData(BaseModel):
super_cash_out_fee_fraction: float | None = None
super_fee_wallet_id: str | None = None
max_cash_in_sats: int | None = None
- # Empty string clears it (None means "not touching this field").
- alerts_pubkey: str | None = None
-
- @validator("alerts_pubkey")
- def _alerts_pubkey_hex(cls, v):
- if v is None:
- return v
- v = v.strip()
- if v == "":
- return ""
- if v.startswith("npub1"):
- from lnbits.utils.nostr import normalize_public_key
-
- v = normalize_public_key(v)
- if len(v) != 64 or any(c not in "0123456789abcdefABCDEF" for c in v):
- raise ValueError("alerts_pubkey must be a 64-char hex pubkey or an npub")
- return v.lower()
@validator(
"super_cash_in_fee_fraction",
@@ -737,27 +714,6 @@ class StuckSettlementsResponse(BaseModel):
stuck_processing: list
-class SettleCashOwedData(BaseModel):
- """ADR-005 §6 — the operator paid the customer outside the machine.
-
- Closes a cash_owed / partial_pending settlement: the provenance note is
- appended, the settlement distributes at the FULL amount (the customer is
- whole), and a `settle_transaction` op is published so the machine flips
- its own row to `remediated`. Nothing is dispensed.
- """
-
- note: str
-
- @validator("note")
- def non_empty(cls, v):
- v = v.strip() if isinstance(v, str) else v
- if not v:
- raise ValueError("note cannot be empty — say how the customer was paid")
- if len(v) > 500:
- raise ValueError("note too long (max 500 chars)")
- return v
-
-
class AppendSettlementNoteData(BaseModel):
"""Operator-authored free-form note on a settlement.
@@ -996,7 +952,6 @@ CASSETTE_OP_TYPES = (
"recount",
"set_denomination",
"resume_cash_out",
- "settle_transaction",
)
# ADR-005 §5: `resume_cash_out` is not a cassette operation — it releases the
@@ -1005,7 +960,7 @@ CASSETTE_OP_TYPES = (
# one the machine already consumes. It is machine-wide, so its position is 0
# and its wire form carries no position at all. A recount also releases the
# hold (same "operator at the open machine" gesture).
-MACHINE_WIDE_OP_TYPES = ("resume_cash_out", "settle_transaction")
+MACHINE_WIDE_OP_TYPES = ("resume_cash_out",)
class CassetteOp(BaseModel):
@@ -1029,10 +984,6 @@ class CassetteOp(BaseModel):
bills: int | None = None
count: int | None = None
denomination: int | None = None
- # settle_transaction only (ADR-005 §6): the machine txid this closes and
- # the operator's provenance note ("paid 20 EUR by hand, 2026-10-09").
- txid: str | None = None
- note: str | None = None
created_at: datetime
created_by: str | None = None
acked_at: datetime | None = None
@@ -1063,10 +1014,6 @@ class CassetteOp(BaseModel):
"at": int(self.created_at.timestamp()),
"type": self.op_type,
}
- if self.op_type == "settle_transaction":
- out["txid"] = self.txid
- out["note"] = self.note or ""
- return out
if self.op_type in MACHINE_WIDE_OP_TYPES:
return out
out["position"] = self.position
@@ -1091,8 +1038,6 @@ class CreateCassetteOpData(BaseModel):
bills: int | None = None
count: int | None = None
denomination: int | None = None
- txid: str | None = None
- note: str | None = None
@validator("op_type")
def _known_op_type(cls, v):
@@ -1138,18 +1083,12 @@ class CreateCassetteOpData(BaseModel):
"set_denomination": "denomination",
"empty": None,
"resume_cash_out": None,
- "settle_transaction": "txid",
}[values.get("op_type")]
- if required is not None:
- val = values.get(required)
- # count=0 is a real observation (an empty bay); only txid must be non-empty
- if val is None or (required == "txid" and not str(val).strip()):
- raise ValueError(f"{values['op_type']} requires `{required}`")
- for field in ("bills", "count", "denomination", "txid"):
+ if required is not None and values.get(required) is None:
+ raise ValueError(f"{values['op_type']} requires `{required}`")
+ for field in ("bills", "count", "denomination"):
if field != required and values.get(field) is not None:
raise ValueError(f"{values['op_type']} must not carry `{field}`")
- if values.get("note") is not None and values.get("op_type") != "settle_transaction":
- raise ValueError(f"{values['op_type']} must not carry `note`")
return values
diff --git a/notify.py b/notify.py
deleted file mode 100644
index f860a73..0000000
--- a/notify.py
+++ /dev/null
@@ -1,202 +0,0 @@
-"""
-Operator alerts over Nostr (bitspire ADR-005 §6 / Future directions).
-
-When a cash-out settlement lands in `cash_owed` or `partial_pending` — a
-customer has paid and is owed cash — the operator is told, immediately, as a
-Nostr event addressed to their pubkey. Not email, not SMS (lamassu's
-`notifyOperator` channel). The pieces this reuses:
-
- - the recipient is the operator's own LNbits-account pubkey (`account.pubkey`,
- which `get_machine_config` already refuses to run without), or
- `super_config.alerts_pubkey` when set — a phone identity, say;
- - the sender signs and NIP-44-encrypts through the operator's signer
- (bunker-backed where the operator has moved to one), so no key is at rest
- here. For the default recipient this is a note to self, which every NIP-46
- client renders as a DM thread with yourself.
-
-Wire: NIP-17. A kind-14 rumor (unsigned) is sealed (kind 13, NIP-44 to the
-recipient, signed by the sender) and gift-wrapped (kind 1059, NIP-44 to the
-recipient, signed by a throwaway key, `p`-tagged to the recipient, stamped at
-a random moment in the last two days). The relay sees a throwaway pubkey and
-ciphertext; only the recipient can open it. NIP-04 is not used anywhere in
-this stack.
-
-Best-effort by contract: a failed alert is logged and never fails the RPC
-that triggered it — the dispense report must still be acknowledged, and the
-worklist is the durable record. `operator_notified_at` is set only on a
-successful publish, so a report resend cannot re-alert.
-"""
-
-from __future__ import annotations
-
-import hashlib
-import json
-import random
-import time
-
-from loguru import logger
-
-from .crud import get_super_config, mark_settlement_notified
-from .models import DcaSettlement, DispenseReportIn, Machine
-from .nostr_publish import (
- NostrPublishError,
- nip44_encrypt_via_signer,
- publish_signed_event,
- resolve_operator_signer,
- sign_as_operator,
-)
-
-KIND_DM = 14
-KIND_SEAL = 13
-KIND_GIFT_WRAP = 1059
-# NIP-17: randomise outer timestamps up to two days in the past so the wrap
-# does not reveal when the conversation happened.
-_MAX_BACKDATE_S = 2 * 24 * 60 * 60
-
-
-def _canonical_id(
- pubkey: str, created_at: int, kind: int, tags: list, content: str
-) -> str:
- from lnbits.utils.nostr import json_dumps
-
- return hashlib.sha256(
- json_dumps([0, pubkey, created_at, kind, tags, content]).encode()
- ).hexdigest()
-
-
-def _backdated_now() -> int:
- return int(time.time()) - random.randint(0, _MAX_BACKDATE_S) # noqa: S311
-
-
-def _wrap_with_ephemeral(plaintext: str, recipient: str, created_at: int) -> dict:
- """Kind-1059 gift wrap: NIP-44-encrypt `plaintext` to `recipient` from a
- freshly generated key, sign with that key, and forget it."""
- import coincurve
- from lnbits.utils.nostr import generate_keypair, sign_event
-
- from .nip44 import encrypt_for
-
- priv_hex, pub_hex = generate_keypair()
- wrap = {
- "kind": KIND_GIFT_WRAP,
- "created_at": created_at,
- "tags": [["p", recipient]],
- "content": encrypt_for(plaintext, priv_hex, recipient),
- }
- return sign_event(wrap, pub_hex, coincurve.PrivateKey(bytes.fromhex(priv_hex)))
-
-
-async def build_gift_wrapped_dm(
- operator_user_id: str, recipient_pubkey_hex: str, text: str
-) -> dict:
- """Build a NIP-17 gift-wrapped DM from the operator's identity to
- `recipient_pubkey_hex`. Returns the signed kind-1059 event, ready to publish.
-
- Raises the typed NostrPublishError subclasses from nostr_publish on a hard
- failure (no pubkey on file, signer unavailable, …)."""
- account, signer = await resolve_operator_signer(operator_user_id)
- sender = account.pubkey
- recipient = recipient_pubkey_hex.lower()
-
- # 1. rumor — unsigned kind 14, id computed so clients can thread it
- rumor_created = int(time.time())
- rumor_tags = [["p", recipient]]
- rumor = {
- "id": _canonical_id(sender, rumor_created, KIND_DM, rumor_tags, text),
- "pubkey": sender,
- "created_at": rumor_created,
- "kind": KIND_DM,
- "tags": rumor_tags,
- "content": text,
- }
-
- # 2. seal — NIP-44 to the recipient, signed by the sender (the bunker
- # path never sees the operator's key here)
- sealed_content = await nip44_encrypt_via_signer(
- account, signer, json.dumps(rumor, separators=(",", ":")), recipient
- )
- seal = {"kind": KIND_SEAL, "tags": [], "content": sealed_content}
- seal = await sign_as_operator(operator_user_id, seal)
-
- # 3. gift wrap — NIP-44 from a throwaway key to the recipient, p-tagged,
- # backdated, signed by that same throwaway key, which is then forgotten
- return _wrap_with_ephemeral(
- json.dumps(seal, separators=(",", ":")), recipient, _backdated_now()
- )
-
-
-async def resolve_alert_recipient(operator_user_id: str) -> str:
- """`super_config.alerts_pubkey` when set, else the operator's own pubkey."""
- cfg = await get_super_config()
- override = (getattr(cfg, "alerts_pubkey", None) or "").strip() if cfg else ""
- if override:
- return override.lower()
- account, _signer = await resolve_operator_signer(operator_user_id)
- return account.pubkey
-
-
-def format_cash_outcome_alert(
- settlement: DcaSettlement, machine: Machine, report: DispenseReportIn, status: str
-) -> str:
- """The message the operator reads on their phone. Plain text, the facts
- they need to act, no raw dispenser code (that is in the dashboard)."""
- name = machine.name or machine.id
- paid = f"{report.fiat_cents / 100:.2f} {report.currency}"
- out = f"{report.dispensed_fiat_cents / 100:.2f} {report.currency}"
- if status == "cash_owed":
- head = f"⚠️ CASH OWED — {name}"
- body = f"A customer paid {paid} and received nothing."
- else:
- head = f"⚠️ PARTIAL DISPENSE — {name}"
- body = f"A customer paid {paid} and received {out}."
- lines = [
- head,
- body,
- f"Cause: {report.error or 'no dispenser error reported'}"
- + (f" ({report.error_code})" if report.error_code else ""),
- f"Transaction: {report.txid}",
- f"Settlement: {settlement.id}",
- "Nothing has been distributed. Open spirekeeper → Worklist to resolve "
- "(remediate at the machine, or settle off-machine).",
- ]
- if report.error_class == "terminal":
- lines.append(
- "The machine has taken cash-out out of service until you recount "
- "or resume it."
- )
- return "\n".join(lines)
-
-
-async def notify_cash_outcome(
- settlement: DcaSettlement,
- machine: Machine,
- report: DispenseReportIn,
- status: str,
-) -> bool:
- """Alert the operator that `settlement` needs a human. Best-effort:
- returns True if the DM was published, False (after logging) otherwise.
- Never raises — the caller is the dispense-report RPC and must ack."""
- if getattr(settlement, "operator_notified_at", None):
- return False
- try:
- recipient = await resolve_alert_recipient(machine.operator_user_id)
- text = format_cash_outcome_alert(settlement, machine, report, status)
- wrapped = await build_gift_wrapped_dm(machine.operator_user_id, recipient, text)
- await publish_signed_event(wrapped)
- await mark_settlement_notified(settlement.id)
- logger.info(
- f"spirekeeper: operator alerted ({status}) for settlement {settlement.id} "
- f"→ {recipient[:12]}… (gift wrap {wrapped['id'][:12]}…)"
- )
- return True
- except NostrPublishError as exc:
- logger.warning(
- f"spirekeeper: could not alert operator for settlement {settlement.id}: {exc} "
- "— the worklist still has it"
- )
- except Exception as exc: # never let an alert break the RPC
- logger.error(
- f"spirekeeper: unexpected error alerting operator for settlement "
- f"{settlement.id}: {exc!r}"
- )
- return False
diff --git a/static/docs/errors.html b/static/docs/errors.html
deleted file mode 100644
index af8f9ff..0000000
--- a/static/docs/errors.html
+++ /dev/null
@@ -1,98 +0,0 @@
-
-
-
-
-
-bitSpire — Dispenser error glossary
-
-
-
-Dispenser error glossary
-Every dispense failure a machine reports carries an error code (the family — which driver), a raw code (what the hardware said), and a class that decides what the machine did next:
-
- - terminal
- - The transport path is compromised. The machine has taken cash-out out of service and will stay that way until you open it. Clear the path, then record a Recount (which also fixes the bay count) or press Resume cash-out. Re-initialising the dispenser does not move a stuck note, so a restart will not clear this.
- - recoverable
- - This bay or this note. The customer still sees a fault screen if they were short-changed, but the machine stays in service. Check the bay named in the report and the reject tray.
- - inventory
- - Nothing was asked of the hardware — the request could not be met from the bays. Not a hardware fault. If this happens after payment, the customer is still owed (see the worklist).
-
-Whatever the class, a report that is not dispense_confirmed after a payment means a customer is owed money. The worklist shows it as Cash owed (nothing dispensed) or Partial dispense (some notes out). Nothing has been distributed; the funds are in the machine wallet. Resolve by dispensing the shortfall at the machine (the machine reports the remediation and the settlement completes) or by paying the customer by hand and recording it with Settle off-machine.
-
-Fujitsu F53 / F56 (error code F56DispenseError)
-Codes are the two bytes the BDU returns after a failed bill count. Source: Fujitsu Frontech F56-BDU Error Code List (K3KD03234–K3KD03236-0001, ed. E02) and field observations. An unknown code is treated as terminal until it has been decoded.
-
-
-
78 42 — note stopped at the cassette exit terminal
-
A note left the bay and stopped in the transport just past the cassette. The counters report 0 dispensed, 0 rejected because the note completed neither path — so the bay count is also one high until you recount.
-
What to do: open the unit, remove the note from the transport path, check the cassette is seated, then Recount the bay (this releases the cash-out hold and corrects the count). First seen: sintra, 2026-10-09 — a 20 EUR note, customer paid 40 EUR and received nothing.
-
-
-
-
82 00 — bill length check failed (long) recoverable
-
The BDU measured a note longer than the accept window configured for that denomination and rejected it. Repeated on every pick from one bay, it is almost always the configured window, not the notes: a GTQ Tejo rejected 5 of 5 on every pick in 2026-09 because the window was ±5 mm where the identical-size USD note has ±10.
-
What to do: empty the reject tray and look at the notes. Single notes → window too narrow; report it. Pairs → the separator is worn (offset double-pick) and the narrow window is doing its job.
-
-
-
-
83 00 — bill length check failed (short) recoverable
-
As above, measured short. Torn or folded notes, or the wrong denomination loaded in the bay.
-
-
-
-
84 00 — bill thickness check failed recoverable
-
Two notes stuck together, or a taped/damaged note. Check the reject tray.
-
-
-
-
85 0n — pick from another safe recoverable
-
A note arrived from a bay other than the one commanded (n = which). Usually a cassette not fully latched.
-
-
-
-
86 00 — bill spacing error recoverable
-
Notes too close together on the transport. Often follows a worn feed roller.
-
-
-
-
B5 .. — reject box overflow terminal
-
The reject tray is full; nothing more can be rejected, so nothing more can be dispensed safely.
-
What to do: empty the reject tray, count what is in it (those notes left a bay), Recount.
-
-
-
-
Unrecognised code terminal
-
A code not yet in this table. The machine treats it as a jam — cash-out held — until someone decodes it. Please report the raw code with what you found inside the unit so it can be added.
-
-
-
-
DispenseTimeout — dispenser did not answer terminal
-
No frame came back within the dispense timeout (serial timeout, port closed, framing error). The transport state is unknown; the bay counts are flagged unverified.
-
-
-Puloon LCDM (error code PuloonDispenseError)
-No decode table yet; every Puloon fault is treated as terminal. The raw code is whatever the driver returned — report it.
-
-Software-side codes
-
-
InsufficientInventory / NoCassetteForDenomination inventory
-
The request could not be met from the bays as the machine believes them to be. After a payment this still leaves the customer owed; before one, the sale is simply refused. If the bays physically hold more than the machine thinks, Recount.
-
-
-
DispenseShort inventory
-
The dispenser returned fewer notes than requested and reported no error. The customer is owed the difference.
-
-
-Reference: bitspire docs/adr/005-cash-out-dispense-outcome.md, Decisions 3–7.
-
-
diff --git a/static/docs/operator-guide.html b/static/docs/operator-guide.html
deleted file mode 100644
index 363af30..0000000
--- a/static/docs/operator-guide.html
+++ /dev/null
@@ -1,75 +0,0 @@
-
-
-
-
-
-spirekeeper — Operator guide
-
-
-
-spirekeeper — operator guide
-spirekeeper is the operator side of a bitSpire ATM: it pairs machines, publishes their fee and cassette configuration, receives every cash-out's outcome, and distributes each settlement. Everything between you and a machine travels over Nostr relays — there is no HTTP endpoint on the machine and none it needs on you.
-
-1. Setting up a machine
-
- - Register it under Machines with a name, location and fiat currency, and pick the LNbits wallet that receives its payments.
- - Pair it: Pair mints a one-shot
spire-seed QR. Show it to the machine's camera (an unpaired machine boots into the pairing wizard). Pairing gives the machine its signing identity through the bunker; no key is ever typed into the machine.
- - Fees: your per-machine cash-in / cash-out commission plus the platform fee are published to the machine automatically whenever either changes, and delivered again on every boot.
-
-
-2. Bays and cassettes — who decides what
-The machine owns its bay layout and its running counts. How many bays it has is hardware; which denomination sits in each and how many notes are there is something only the person at the machine knows. So:
-
- | Fact | Where it comes from | How you change it |
- | Number of bays | The machine's installation (VITE_BITSPIRE_CASSETTES in its .env on first boot, then its own database). spirekeeper adopts whatever the machine reports and deletes bays it stops reporting. | Re-provision the machine. There is no dashboard control for bay count. |
- | Denomination per bay | You. | Set denomination op. |
- | Count per bay | The machine's running total: refills add, dispenses subtract. | You publish operations, never counts: Refill +N, Empty, Recount. A recount is the only absolute — it is what you do when you open the bay and count it. |
-
-Never set a count from a form you loaded before a dispense happened — that is exactly why counts are not editable. If the number on screen is wrong, open the bay, count, and record a Recount.
-Each operation carries an id the machine deduplicates on, and the machine echoes the ids it has applied back in its state report; an op shows as acknowledged only when the machine says so. The machine republishes its state on every change and on a heartbeat, so a dashboard that disagrees with a machine heals itself within minutes.
-
-3. What a cash-out looks like now
-Payment is the authorization; the machine's dispense report is the capture. A customer's payment lands as awaiting_dispense and nothing is distributed until the machine reports what physically came out:
-
- | Machine reports | Settlement | What happens |
- | Everything dispensed | processed | Distribution runs — platform fee, your splits, DCA. |
- | Some notes out, value short | partial_pending | Held whole until you record how the shortfall was resolved. |
- | Nothing out | cash_owed | Nothing moves. The customer is owed. You are alerted. |
- | No report within 30 min | dispense_unreported | The machine never said. Check it. |
-
-Those last three are the first thing on the Worklist. Every one means a human is owed money or a machine needs eyes.
-
-4. When the machine takes itself out of service
-A terminal dispenser fault — a jam, a motor or sensor error, a dispense that never answered — makes the machine refuse further cash-out and show the customer that it is temporarily unavailable. The machine's card in spirekeeper shows a red Cash-out is held banner with the code and the reason. It stays held until:
-
- - you record a Recount on any bay (you opened the machine — that also fixes the count), or
- - you press Resume cash-out on the banner (you cleared the jam without touching a count).
-
-Restarting the machine does not clear it: re-initialising a dispenser does not move a stuck note. See the error glossary for what each code means and what you will find inside.
-
-5. Resolving owed cash
-Two ways, both audited, both close the machine's ledger as well as this one:
-
- - At the machine — dispense the shortfall through the machine's manual-dispense command against the original transaction. The machine reports the remediation and the settlement completes on its own.
- - Off-machine — you paid the customer by hand. Open the settlement (worklist or table) → Settle off-machine → write how ("handed 40 EUR to customer, 2026-10-09"). The settlement distributes at the full amount and the machine marks its own row remediated.
-
-For a partial, Record the resolution opens the partial-dispense dialog pre-filled from the machine's own count of what came out: confirm it if you are writing the shortfall off (the settlement distributes the scaled amount), or use one of the two routes above if you made the customer whole.
-
-6. Alerts
-
-When a settlement lands in cash_owed or partial_pending you receive a Nostr direct message (NIP-17) addressed to the pubkey on your LNbits account — a note to self, readable in any client that speaks NIP-46 (the aiolabs webapp, Amber, nsec.app). You do not need your private key for this. To receive alerts on a different identity, set Alerts pubkey in the platform settings.
-
-7. Reconciling a machine
-On the machine, atm-reconcile re-derives each bay from its last recount plus refills minus dispenses and reports any gap. A bay that has never been recounted cannot be reconciled — its starting number is unrecorded. Recount every bay once after installation; after that, any gap is real.
-
-Design record: bitspire docs/adr/004-cassette-state-synchronization.md and docs/adr/005-cash-out-dispense-outcome.md.
-
-
diff --git a/static/js/index.js b/static/js/index.js
index a52aa55..54b6014 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -108,8 +108,7 @@ window.app = Vue.createApp({
super_cash_in_fee_fraction: 0,
super_cash_out_fee_fraction: 0,
super_fee_wallet_id: '',
- max_cash_in_sats: null,
- alerts_pubkey: ''
+ max_cash_in_sats: null
}
},
@@ -262,13 +261,6 @@ window.app = Vue.createApp({
dispensed_sats: null,
notes: ''
},
- // ADR-005 §6 — settle an owed-cash settlement off-machine
- settleDialog: {
- show: false,
- settlement: null,
- note: '',
- saving: false
- },
noteDialog: {
show: false,
saving: false,
@@ -667,8 +659,7 @@ window.app = Vue.createApp({
super_cash_out_fee_fraction:
this.superConfig?.super_cash_out_fee_fraction ?? 0,
super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || '',
- max_cash_in_sats: this.superConfig?.max_cash_in_sats ?? null,
- alerts_pubkey: this.superConfig?.alerts_pubkey || ''
+ max_cash_in_sats: this.superConfig?.max_cash_in_sats ?? null
}
this.superFeeDialog.show = true
},
@@ -1272,45 +1263,6 @@ window.app = Vue.createApp({
this.partialDispenseDialog.show = true
},
- // ADR-005 §6 — the operator paid the customer by hand; close both ledgers.
- openSettleCashOwed(settlement) {
- this.settleDialog.settlement = settlement
- this.settleDialog.note = ''
- this.settleDialog.show = true
- },
-
- async submitSettleCashOwed() {
- const d = this.settleDialog
- d.saving = true
- try {
- const {data} = await LNbits.api.request(
- 'POST',
- `${SETTLEMENTS_PATH}/${d.settlement.id}/settle-cash-owed`,
- null,
- {note: d.note}
- )
- this._replaceSettlement(data)
- d.show = false
- Quasar.Notify.create({
- type: 'positive',
- message:
- 'Settled — distributing the full amount; the machine will mark its row remediated'
- })
- if (this.worklist.totalCount) await this.loadWorklist()
- } catch (e) {
- this._notifyError(e, 'Settle failed')
- } finally {
- d.saving = false
- }
- },
-
- // Deep link into the error glossary for a settlement's dispenser error.
- errorGlossaryHref(settlement) {
- const code = settlement.dispense_raw_code || settlement.dispense_error_code || ''
- const anchor = code.toLowerCase().replace(/[^a-z0-9]+/g, '-')
- return `/spirekeeper/static/docs/errors.html${anchor ? '#' + anchor : ''}`
- },
-
// ADR-005 §5 — release a machine's cash-out hold after a terminal
// dispenser fault, when the jam was cleared without a recount.
confirmResumeCashOut(machine) {
diff --git a/templates/spirekeeper/index.html b/templates/spirekeeper/index.html
index eb84664..77d9612 100644
--- a/templates/spirekeeper/index.html
+++ b/templates/spirekeeper/index.html
@@ -16,9 +16,6 @@
Satoshi Machine — Operator
Manage your bitSpire fleet, liquidity providers, and commission distribution.
- Operator guide ·
- Error glossary
@@ -657,13 +654,6 @@
-
-
-
- What this code means and what to do
-
Open machine detail
-
- Settle off-machine — you paid the customer by hand
-
Retry distribution
-
-
-
-
- Settle off-machine…
-
-
-
-
-
- Settle off-machine
-
-
-
-
-
- The customer paid
-
- and the machine reports
- .
-
-
- Use this when you have made the customer whole outside the machine.
- The settlement distributes at the full amount and the machine marks its own
- record remediated. Nothing is dispensed. If you dispensed the shortfall at the
- machine instead, do nothing — it reports that itself.
-
-
-
-
-
-
-
-
-
-
@@ -1578,10 +1518,6 @@
hint="Server-side ceiling on a single cash-in's principal. The ATM attests the amount; this bounds a compromised/buggy machine to one capped tx."
type="number" step="1" min="0"
class="q-mb-md" dense outlined>
-
diff --git a/tests/test_cassette_ops.py b/tests/test_cassette_ops.py
index a6380c5..35ca48c 100644
--- a/tests/test_cassette_ops.py
+++ b/tests/test_cassette_ops.py
@@ -126,8 +126,6 @@ class TestWireShape:
"empty": {},
# machine-wide (ADR-005 §5): position 0, no position on the wire
"resume_cash_out": {"position": 0},
- # machine-wide too (ADR-005 §6): carries the txid it closes
- "settle_transaction": {"position": 0, "txid": "tx_1"},
}[op_type]
wire = op(op_type=op_type, **kw).to_wire_dict()
assert None not in wire.values()
diff --git a/tests/test_slice2.py b/tests/test_slice2.py
deleted file mode 100644
index 8e5dcd6..0000000
--- a/tests/test_slice2.py
+++ /dev/null
@@ -1,424 +0,0 @@
-"""
-ADR-005 slice 2: operator alerts, off-machine settlement, the
-settle_transaction op, alerts_pubkey.
-
- - NIP-17 builder: a gift wrap (kind 1059) signed by a throwaway key, p-tagged
- to the recipient, backdated; its content opens with the recipient's key
- to a seal (kind 13) signed by the sender, which opens to a kind-14 rumor
- carrying the text. Built with a fake LocalSigner-style signer so no bunker
- is needed.
- - notify_cash_outcome is best-effort: publishes once, marks notified, never
- raises, and does not re-alert a settlement already notified.
- - apply_report_to_settlement alerts on cash_owed / partial_pending and not on
- a confirmed dispense.
- - settle_cash_owed_settlement: note appended, status → pending, distribution
- run; refuses other statuses.
- - models: settle_transaction op (machine-wide, carries txid + note, wire
- shape), alerts_pubkey validator (hex / npub / clear).
-"""
-
-import asyncio
-import json
-from datetime import datetime
-from types import SimpleNamespace
-
-import coincurve
-import pytest
-from lnbits.utils.nostr import verify_event
-from pydantic import ValidationError
-
-from .. import dispense_transport, distribution, notify
-from ..models import (
- CassetteOp,
- CreateCassetteOpData,
- DcaSettlement,
- DispenseReportIn,
- Machine,
- UpdateSuperConfigData,
-)
-from ..nip44 import decrypt_from, encrypt_for
-
-_NOW = datetime(2026, 10, 9, 7, 2, 33)
-_OP_SEC = "00" * 31 + "01"
-_ALERT_SEC = "00" * 31 + "03"
-
-
-def _pub(sec_hex: str) -> str:
- return (
- coincurve.PrivateKey(bytes.fromhex(sec_hex)).public_key.format(True)[1:].hex()
- )
-
-
-_OP_PUB = _pub(_OP_SEC)
-_ALERT_PUB = _pub(_ALERT_SEC)
-
-
-def _machine() -> Machine:
- return Machine(
- id="m1",
- operator_user_id="op1",
- machine_npub="df20" * 16,
- wallet_id="w1",
- name="sintra",
- location=None,
- fiat_code="EUR",
- is_active=True,
- created_at=_NOW,
- updated_at=_NOW,
- )
-
-
-def _settlement(status="cash_owed", notified=None) -> DcaSettlement:
- return DcaSettlement(
- id="s1",
- machine_id="m1",
- payment_hash="ab" * 32,
- bitspire_event_id=None,
- bitspire_txid="tx_mv0madw6_wdhtea1v",
- wire_sats=54440,
- fiat_amount=40.0,
- fiat_code="EUR",
- exchange_rate=1361.0,
- principal_sats=54440,
- fee_sats=0,
- platform_fee_sats=0,
- operator_fee_sats=0,
- tx_type="cash_out",
- bills_json=None,
- cassettes_json=None,
- status=status,
- error_message=None,
- processed_at=None,
- created_at=_NOW,
- operator_notified_at=notified,
- )
-
-
-def _report(**over) -> DispenseReportIn:
- body: dict = {
- "txid": "tx_mv0madw6_wdhtea1v",
- "payment_hash": "ab" * 32,
- "dispense_confirmed": False,
- "error": "Note stopped at the cassette exit",
- "error_code": "F56DispenseError",
- "raw_code": "78 42",
- "error_class": "terminal",
- "fiat_cents": 4000,
- "currency": "EUR",
- "bills": [{"denomination": 20, "requested": 2, "dispensed": 0, "rejected": 0}],
- "at": 1791529353,
- }
- body.update(over)
- return DispenseReportIn(**body)
-
-
-# ---------------------------------------------------------------------------
-# A signer that behaves like a LocalSigner holding _OP_SEC, without lnbits' DB
-# ---------------------------------------------------------------------------
-
-
-class _FakeSigner:
- def can_sign(self):
- return True
-
- async def nip44_encrypt(self, plaintext, peer):
- return encrypt_for(plaintext, _OP_SEC, peer)
-
- async def sign_event(self, event):
- from lnbits.utils.nostr import sign_event
-
- return sign_event(event, _OP_PUB, coincurve.PrivateKey(bytes.fromhex(_OP_SEC)))
-
-
-def _wire_signer(monkeypatch):
- account = SimpleNamespace(pubkey=_OP_PUB, signer_type="LocalSigner", prvkey=_OP_SEC)
-
- async def resolve(_uid):
- return account, _FakeSigner()
-
- async def sign_as_operator(_uid, event):
- import time
-
- event["created_at"] = int(time.time())
- return await _FakeSigner().sign_event(event)
-
- monkeypatch.setattr(notify, "resolve_operator_signer", resolve)
- monkeypatch.setattr(notify, "sign_as_operator", sign_as_operator)
- return account
-
-
-# ---------------------------------------------------------------------------
-# NIP-17 builder
-# ---------------------------------------------------------------------------
-
-
-class TestGiftWrap:
- def test_wrap_seal_rumor_round_trip_to_the_recipient(self, monkeypatch):
- _wire_signer(monkeypatch)
- wrap = asyncio.run(
- notify.build_gift_wrapped_dm("op1", _ALERT_PUB, "hello operator")
- )
-
- assert wrap["kind"] == 1059
- assert wrap["tags"] == [["p", _ALERT_PUB]]
- assert wrap["pubkey"] != _OP_PUB # throwaway key, not the sender
- assert verify_event(wrap)
- import time
-
- assert wrap["created_at"] <= int(time.time())
- assert wrap["created_at"] >= int(time.time()) - 2 * 86400 - 5
-
- seal = json.loads(decrypt_from(wrap["content"], _ALERT_SEC, wrap["pubkey"]))
- assert seal["kind"] == 13 and seal["pubkey"] == _OP_PUB and seal["tags"] == []
- assert verify_event(seal)
-
- rumor = json.loads(decrypt_from(seal["content"], _ALERT_SEC, _OP_PUB))
- assert rumor["kind"] == 14
- assert rumor["pubkey"] == _OP_PUB
- assert rumor["content"] == "hello operator"
- assert rumor["tags"] == [["p", _ALERT_PUB]]
- assert "sig" not in rumor and len(rumor["id"]) == 64
-
- def test_note_to_self_when_recipient_is_the_sender(self, monkeypatch):
- _wire_signer(monkeypatch)
- wrap = asyncio.run(notify.build_gift_wrapped_dm("op1", _OP_PUB, "to me"))
- seal = json.loads(decrypt_from(wrap["content"], _OP_SEC, wrap["pubkey"]))
- rumor = json.loads(decrypt_from(seal["content"], _OP_SEC, _OP_PUB))
- assert rumor["content"] == "to me"
-
-
-class TestNotifyCashOutcome:
- def _wire(self, monkeypatch, *, alerts_pubkey=None, publish_fails=False):
- _wire_signer(monkeypatch)
- published, marked = [], []
-
- async def get_super():
- return SimpleNamespace(alerts_pubkey=alerts_pubkey)
-
- async def publish(ev):
- if publish_fails:
- from ..nostr_publish import RelayUnavailable
-
- raise RelayUnavailable("no relay")
- published.append(ev)
-
- async def mark(sid):
- marked.append(sid)
-
- monkeypatch.setattr(notify, "get_super_config", get_super)
- monkeypatch.setattr(notify, "publish_signed_event", publish)
- monkeypatch.setattr(notify, "mark_settlement_notified", mark)
- return published, marked
-
- def test_alerts_once_to_the_operator_by_default(self, monkeypatch):
- published, marked = self._wire(monkeypatch)
- ok = asyncio.run(
- notify.notify_cash_outcome(
- _settlement(), _machine(), _report(), "cash_owed"
- )
- )
- assert ok is True
- assert marked == ["s1"]
- assert published[0]["tags"] == [["p", _OP_PUB]]
- seal = json.loads(
- decrypt_from(published[0]["content"], _OP_SEC, published[0]["pubkey"])
- )
- rumor = json.loads(decrypt_from(seal["content"], _OP_SEC, _OP_PUB))
- assert "CASH OWED" in rumor["content"]
- assert "sintra" in rumor["content"]
- assert "40.00 EUR" in rumor["content"]
- assert "tx_mv0madw6_wdhtea1v" in rumor["content"]
- assert "78 42" not in rumor["content"] # raw code stays in the dashboard
- assert "out of service" in rumor["content"] # terminal → latch mentioned
-
- def test_alerts_pubkey_override_and_partial_wording(self, monkeypatch):
- published, _ = self._wire(monkeypatch, alerts_pubkey=_ALERT_PUB)
- rep = _report(
- bills=[{"denomination": 20, "requested": 2, "dispensed": 1, "rejected": 1}]
- )
- asyncio.run(
- notify.notify_cash_outcome(
- _settlement("partial_pending"), _machine(), rep, "partial_pending"
- )
- )
- assert published[0]["tags"] == [["p", _ALERT_PUB]]
- seal = json.loads(
- decrypt_from(published[0]["content"], _ALERT_SEC, published[0]["pubkey"])
- )
- rumor = json.loads(decrypt_from(seal["content"], _ALERT_SEC, _OP_PUB))
- assert (
- "PARTIAL" in rumor["content"] and "received 20.00 EUR" in rumor["content"]
- )
-
- def test_does_not_re_alert_and_never_raises(self, monkeypatch):
- published, marked = self._wire(monkeypatch)
- ok = asyncio.run(
- notify.notify_cash_outcome(
- _settlement(notified=_NOW), _machine(), _report(), "cash_owed"
- )
- )
- assert ok is False and published == [] and marked == []
-
- published, marked = self._wire(monkeypatch, publish_fails=True)
- ok = asyncio.run(
- notify.notify_cash_outcome(
- _settlement(), _machine(), _report(), "cash_owed"
- )
- )
- assert ok is False and marked == []
-
-
-class TestCaptureAlerts:
- def _wire(self, monkeypatch, status_after):
- alerts = []
-
- async def apply(sid, report, new_status, reported_at):
- return _settlement(new_status)
-
- async def alert(settlement, machine, report, status):
- alerts.append(status)
-
- monkeypatch.setattr(dispense_transport, "apply_dispense_outcome", apply)
- monkeypatch.setattr(dispense_transport, "_spawn_distribution", lambda sid: None)
- monkeypatch.setattr(notify, "notify_cash_outcome", alert)
- return alerts
-
- def test_alerts_on_owed_and_partial_not_on_confirmed(self, monkeypatch):
- alerts = self._wire(monkeypatch, "cash_owed")
- asyncio.run(
- dispense_transport.apply_report_to_settlement(
- _settlement("awaiting_dispense"), _report(), _machine()
- )
- )
- rep_partial = _report(
- bills=[{"denomination": 20, "requested": 2, "dispensed": 1, "rejected": 1}]
- )
- asyncio.run(
- dispense_transport.apply_report_to_settlement(
- _settlement("awaiting_dispense"), rep_partial, _machine()
- )
- )
- rep_ok = _report(
- dispense_confirmed=True,
- error=None,
- error_code=None,
- raw_code=None,
- error_class=None,
- bills=[{"denomination": 20, "requested": 2, "dispensed": 2, "rejected": 0}],
- )
- asyncio.run(
- dispense_transport.apply_report_to_settlement(
- _settlement("awaiting_dispense"), rep_ok, _machine()
- )
- )
- assert alerts == ["cash_owed", "partial_pending"]
-
-
-# ---------------------------------------------------------------------------
-# Off-machine settlement
-# ---------------------------------------------------------------------------
-
-
-class TestSettleCashOwed:
- def _wire(self, monkeypatch):
- notes, statuses, processed = [], [], []
-
- async def note(sid, text, author):
- notes.append((sid, text, author))
- return _settlement()
-
- async def status(sid, st, msg):
- statuses.append((sid, st))
- return None
-
- async def process(sid):
- processed.append(sid)
-
- async def get(sid):
- return _settlement("processed")
-
- import importlib
-
- crud = importlib.import_module("..crud", __package__)
- monkeypatch.setattr(crud, "append_settlement_note", note)
- monkeypatch.setattr(distribution, "mark_settlement_status", status)
- monkeypatch.setattr(distribution, "process_settlement", process)
- monkeypatch.setattr(distribution, "get_settlement", get)
- return notes, statuses, processed
-
- def test_closes_at_full_amount(self, monkeypatch):
- notes, statuses, processed = self._wire(monkeypatch)
- after = asyncio.run(
- distribution.settle_cash_owed_settlement(
- _settlement(), "handed 40 EUR by hand", "op1"
- )
- )
- assert after.status == "processed"
- assert notes[0][1].startswith(
- "Settled off-machine (full amount): handed 40 EUR"
- )
- assert statuses == [("s1", "pending")]
- assert processed == ["s1"]
-
- def test_refuses_other_statuses(self, monkeypatch):
- self._wire(monkeypatch)
- with pytest.raises(ValueError):
- asyncio.run(
- distribution.settle_cash_owed_settlement(
- _settlement("processed"), "x", "op1"
- )
- )
-
-
-# ---------------------------------------------------------------------------
-# Models
-# ---------------------------------------------------------------------------
-
-
-class TestSettleOpAndAlertsPubkey:
- def test_settle_transaction_is_machine_wide_and_carries_txid_note(self):
- op = CassetteOp(
- id="st1",
- machine_id="m1",
- position=0,
- op_type="settle_transaction",
- txid="tx_mv0madw6_wdhtea1v",
- note="paid by hand",
- created_at=_NOW,
- )
- assert op.to_wire_dict() == {
- "id": "st1",
- "at": int(_NOW.timestamp()),
- "type": "settle_transaction",
- "txid": "tx_mv0madw6_wdhtea1v",
- "note": "paid by hand",
- }
- CreateCassetteOpData(
- position=0, op_type="settle_transaction", txid="tx_1", note="n"
- )
- with pytest.raises(ValidationError):
- CreateCassetteOpData(
- position=0, op_type="settle_transaction"
- ) # txid required
- with pytest.raises(ValidationError):
- CreateCassetteOpData(position=2, op_type="settle_transaction", txid="tx_1")
- with pytest.raises(ValidationError):
- CreateCassetteOpData(position=1, op_type="refill", bills=3, txid="tx_1")
- with pytest.raises(ValidationError):
- CreateCassetteOpData(position=1, op_type="refill", bills=3, note="no")
-
- def test_alerts_pubkey_accepts_hex_npub_and_clear(self):
- assert (
- UpdateSuperConfigData(alerts_pubkey=_ALERT_PUB.upper()).alerts_pubkey
- == _ALERT_PUB
- )
- from lnbits.utils.nostr import hex_to_npub
-
- assert (
- UpdateSuperConfigData(alerts_pubkey=hex_to_npub(_ALERT_PUB)).alerts_pubkey
- == _ALERT_PUB
- )
- assert UpdateSuperConfigData(alerts_pubkey="").alerts_pubkey == ""
- assert UpdateSuperConfigData().alerts_pubkey is None
- with pytest.raises(ValidationError):
- UpdateSuperConfigData(alerts_pubkey="not-a-key")
diff --git a/views_api.py b/views_api.py
index fd6d69e..7d6bf9f 100644
--- a/views_api.py
+++ b/views_api.py
@@ -74,7 +74,6 @@ from .crud import (
)
from .distribution import (
apply_partial_dispense_and_redistribute,
- settle_cash_owed_settlement,
process_settlement,
settle_lp_balance,
)
@@ -96,7 +95,6 @@ from .models import (
Machine,
PairMachineData,
PartialDispenseData,
- SettleCashOwedData,
SetCommissionSplitsData,
SettleBalanceData,
StuckSettlementsResponse,
@@ -817,67 +815,6 @@ 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,