ADR-005 slice 2: operator alert, settle off-machine, glossary + guide #50

Merged
padreug merged 6 commits from feat/dispense-outcome-slice2 into main 2026-10-10 20:30:36 +00:00
4 changed files with 106 additions and 6 deletions
Showing only changes of commit 786857f568 - Show all commits

feat(models): settle_transaction op, alerts_pubkey, operator_notified_at (m017)

ADR-005 §6 groundwork. settle_transaction is a machine-wide op like
resume_cash_out; it carries the machine txid it closes and the operator's
provenance note. super_config.alerts_pubkey overrides where owed-cash
alerts go; dca_settlements.operator_notified_at stops a report resend
from re-alerting.
Padreug 2026-10-10 22:26:45 +02:00

15
crud.py
View file

@ -739,6 +739,15 @@ async def create_settlement_idempotent(
return await get_settlement(settlement_id) 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( async def get_settlement_by_txid(
machine_id: str, bitspire_txid: str machine_id: str, bitspire_txid: str
) -> DcaSettlement | None: ) -> DcaSettlement | None:
@ -1927,9 +1936,9 @@ async def create_cassette_op(
""" """
INSERT INTO spirekeeper.cassette_ops INSERT INTO spirekeeper.cassette_ops
(id, machine_id, position, op_type, bills, count, denomination, (id, machine_id, position, op_type, bills, count, denomination,
created_at, created_by) txid, note, created_at, created_by)
VALUES (:id, :machine_id, :position, :op_type, :bills, :count, VALUES (:id, :machine_id, :position, :op_type, :bills, :count,
:denomination, :created_at, :created_by) :denomination, :txid, :note, :created_at, :created_by)
""", """,
{ {
"id": op_id, "id": op_id,
@ -1939,6 +1948,8 @@ async def create_cassette_op(
"bills": data.bills, "bills": data.bills,
"count": data.count, "count": data.count,
"denomination": data.denomination, "denomination": data.denomination,
"txid": data.txid,
"note": data.note,
"created_at": datetime.now(), "created_at": datetime.now(),
"created_by": created_by, "created_by": created_by,
}, },

View file

@ -1020,3 +1020,29 @@ async def m016_dispense_outcome(db):
await db.execute( await db.execute(
f"ALTER TABLE spirekeeper.dca_machines ADD COLUMN {col} {typ}" 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")

View file

@ -351,6 +351,9 @@ class DcaSettlement(BaseModel):
dispense_error_class: str | None = None dispense_error_class: str | None = None
dispense_reported_at: datetime | None = None dispense_reported_at: datetime | None = None
dispensed_fiat_cents: int | 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 # Append-only audit memo. Populated when an operator triggers an in-place
# adjustment (partial-dispense, manual reconciliation override). Each # adjustment (partial-dispense, manual reconciliation override). Each
# entry timestamped + records original values so the overwrite is # entry timestamped + records original values so the overwrite is
@ -623,6 +626,9 @@ class SuperConfig(BaseModel):
# rate, not sats, so this bounds a single ATM-attested principal. NULL = no # rate, not sats, so this bounds a single ATM-attested principal. NULL = no
# cap. # cap.
max_cash_in_sats: int | None = None 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 updated_at: datetime
@ -631,6 +637,23 @@ class UpdateSuperConfigData(BaseModel):
super_cash_out_fee_fraction: float | None = None super_cash_out_fee_fraction: float | None = None
super_fee_wallet_id: str | None = None super_fee_wallet_id: str | None = None
max_cash_in_sats: int | 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( @validator(
"super_cash_in_fee_fraction", "super_cash_in_fee_fraction",
@ -714,6 +737,27 @@ class StuckSettlementsResponse(BaseModel):
stuck_processing: list 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): class AppendSettlementNoteData(BaseModel):
"""Operator-authored free-form note on a settlement. """Operator-authored free-form note on a settlement.
@ -952,6 +996,7 @@ CASSETTE_OP_TYPES = (
"recount", "recount",
"set_denomination", "set_denomination",
"resume_cash_out", "resume_cash_out",
"settle_transaction",
) )
# ADR-005 §5: `resume_cash_out` is not a cassette operation — it releases the # ADR-005 §5: `resume_cash_out` is not a cassette operation — it releases the
@ -960,7 +1005,7 @@ CASSETTE_OP_TYPES = (
# one the machine already consumes. It is machine-wide, so its position is 0 # 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 # and its wire form carries no position at all. A recount also releases the
# hold (same "operator at the open machine" gesture). # hold (same "operator at the open machine" gesture).
MACHINE_WIDE_OP_TYPES = ("resume_cash_out",) MACHINE_WIDE_OP_TYPES = ("resume_cash_out", "settle_transaction")
class CassetteOp(BaseModel): class CassetteOp(BaseModel):
@ -984,6 +1029,10 @@ class CassetteOp(BaseModel):
bills: int | None = None bills: int | None = None
count: int | None = None count: int | None = None
denomination: 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_at: datetime
created_by: str | None = None created_by: str | None = None
acked_at: datetime | None = None acked_at: datetime | None = None
@ -1014,6 +1063,10 @@ class CassetteOp(BaseModel):
"at": int(self.created_at.timestamp()), "at": int(self.created_at.timestamp()),
"type": self.op_type, "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: if self.op_type in MACHINE_WIDE_OP_TYPES:
return out return out
out["position"] = self.position out["position"] = self.position
@ -1038,6 +1091,8 @@ class CreateCassetteOpData(BaseModel):
bills: int | None = None bills: int | None = None
count: int | None = None count: int | None = None
denomination: int | None = None denomination: int | None = None
txid: str | None = None
note: str | None = None
@validator("op_type") @validator("op_type")
def _known_op_type(cls, v): def _known_op_type(cls, v):
@ -1083,12 +1138,18 @@ class CreateCassetteOpData(BaseModel):
"set_denomination": "denomination", "set_denomination": "denomination",
"empty": None, "empty": None,
"resume_cash_out": None, "resume_cash_out": None,
"settle_transaction": "txid",
}[values.get("op_type")] }[values.get("op_type")]
if required is not None and values.get(required) is None: 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}`") raise ValueError(f"{values['op_type']} requires `{required}`")
for field in ("bills", "count", "denomination"): for field in ("bills", "count", "denomination", "txid"):
if field != required and values.get(field) is not None: if field != required and values.get(field) is not None:
raise ValueError(f"{values['op_type']} must not carry `{field}`") 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 return values

View file

@ -126,6 +126,8 @@ class TestWireShape:
"empty": {}, "empty": {},
# machine-wide (ADR-005 §5): position 0, no position on the wire # machine-wide (ADR-005 §5): position 0, no position on the wire
"resume_cash_out": {"position": 0}, "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] }[op_type]
wire = op(op_type=op_type, **kw).to_wire_dict() wire = op(op_type=op_type, **kw).to_wire_dict()
assert None not in wire.values() assert None not in wire.values()