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:
parent
3f2c46c550
commit
786857f568
4 changed files with 106 additions and 6 deletions
69
models.py
69
models.py
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue