ADR-005 slice 2: operator alert, settle off-machine, glossary + guide #50
4 changed files with 106 additions and 6 deletions
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.
commit
786857f568
15
crud.py
15
crud.py
|
|
@ -739,6 +739,15 @@ 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:
|
||||
|
|
@ -1927,9 +1936,9 @@ async def create_cassette_op(
|
|||
"""
|
||||
INSERT INTO spirekeeper.cassette_ops
|
||||
(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,
|
||||
:denomination, :created_at, :created_by)
|
||||
:denomination, :txid, :note, :created_at, :created_by)
|
||||
""",
|
||||
{
|
||||
"id": op_id,
|
||||
|
|
@ -1939,6 +1948,8 @@ 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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1020,3 +1020,29 @@ 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")
|
||||
|
|
|
|||
67
models.py
67
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:
|
||||
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"):
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -126,6 +126,8 @@ 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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue