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.
This commit is contained in:
Padreug 2026-10-10 22:26:45 +02:00
commit 786857f568
4 changed files with 106 additions and 6 deletions

View file

@ -351,6 +351,9 @@ 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
@ -623,6 +626,9 @@ 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
@ -631,6 +637,23 @@ 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",
@ -714,6 +737,27 @@ 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.
@ -952,6 +996,7 @@ 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
@ -960,7 +1005,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",)
MACHINE_WIDE_OP_TYPES = ("resume_cash_out", "settle_transaction")
class CassetteOp(BaseModel):
@ -984,6 +1029,10 @@ 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
@ -1014,6 +1063,10 @@ 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
@ -1038,6 +1091,8 @@ 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):
@ -1083,12 +1138,18 @@ class CreateCassetteOpData(BaseModel):
"set_denomination": "denomination",
"empty": None,
"resume_cash_out": None,
"settle_transaction": "txid",
}[values.get("op_type")]
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 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 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