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
15
crud.py
15
crud.py
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
69
models.py
69
models.py
|
|
@ -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:
|
||||||
raise ValueError(f"{values['op_type']} requires `{required}`")
|
val = values.get(required)
|
||||||
for field in ("bills", "count", "denomination"):
|
# 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:
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue