diff --git a/crud.py b/crud.py index 1858cb6..6dc7ea9 100644 --- a/crud.py +++ b/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, }, diff --git a/migrations.py b/migrations.py index ed3d6f1..0c1f0c0 100644 --- a/migrations.py +++ b/migrations.py @@ -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") diff --git a/models.py b/models.py index f1422fc..f51adf8 100644 --- a/models.py +++ b/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 diff --git a/tests/test_cassette_ops.py b/tests/test_cassette_ops.py index 35ca48c..a6380c5 100644 --- a/tests/test_cassette_ops.py +++ b/tests/test_cassette_ops.py @@ -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()