feat(schema): dispense outcome on settlements, dispense_reports, cash-out hold mirror (ADR-005)
m016: an append-only `dispense_reports` table (lamassu-server's cash_out_actions shape — one row per report the machine sent, so a retry, a late report and a remediation report stay distinct); dispense_confirmed / dispense_error / dispense_error_code / dispense_raw_code / dispense_error_class / dispense_reported_at / dispensed_fiat_cents on dca_settlements; cash_out_held_since / _reason / _code on dca_machines beside counts_uncertain_since. Settlement lifecycle gains awaiting_dispense (cash_out at insert — paid, waiting for the machine's report), partial_pending (some notes out, value short; held whole until the operator records the resolution) and cash_owed (nothing out; legs never run). dispense_unreported is derived by the worklist, not stored. resume_cash_out joins CASSETTE_OP_TYPES as a machine-wide op: position 0, no position on the wire, no bay fields. It rides the operator channel the machine already consumes; the machine honours it only if stamped after the hold began. A recount releases the hold too. crud: get_settlement_by_txid (the join the machine's extra.txid already provides), apply_dispense_outcome (copies the report onto the settlement and finally writes bills_json / cassettes_json with what actually came out), the dispense_reports accessors incl. adopting a report that arrived before its payment, set_machine_cash_out_hold, and the three new worklist buckets.
This commit is contained in:
parent
d09c51f277
commit
b8a5e6352a
3 changed files with 511 additions and 18 deletions
200
models.py
200
models.py
|
|
@ -67,6 +67,13 @@ class Machine(BaseModel):
|
|||
# count of what left the bay; cleared by the machine's own report. The
|
||||
# dashboard turns this into a prompt to open the bay and recount.
|
||||
counts_uncertain_since: datetime | None = None
|
||||
# ADR-005 §5: the machine has latched cash-out off after a terminal
|
||||
# dispenser fault. Mirrored from its state document. Cleared when the
|
||||
# machine reports the hold released (an operator recount or a
|
||||
# resume_cash_out op). The dashboard shows it and offers the button.
|
||||
cash_out_held_since: datetime | None = None
|
||||
cash_out_held_reason: str | None = None
|
||||
cash_out_held_code: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
|
@ -311,19 +318,39 @@ class DcaSettlement(BaseModel):
|
|||
fee_mismatch_sats: int | None = None
|
||||
bills_json: str | None
|
||||
cassettes_json: str | None
|
||||
# 'pending' (default at insert)
|
||||
# Lifecycle (bitspire ADR-005 §1 — payment is authorization, the
|
||||
# machine's dispense report is capture; distribution waits for capture):
|
||||
# 'awaiting_dispense' (cash_out at insert: paid, waiting for the report)
|
||||
# 'pending' (cash_in at insert; cash_out once dispense_confirmed)
|
||||
# 'processing' (claim taken by distribution processor)
|
||||
# 'processed' (all legs paid)
|
||||
# 'partial' (operator marked partial-dispense after the fact)
|
||||
# 'partial_pending' (report says some notes out, value short — holds
|
||||
# EVERYTHING until the operator records how the shortfall
|
||||
# was resolved; then one distribution at the final amount)
|
||||
# 'cash_owed' (report says nothing out — legs never run, funds stay in
|
||||
# the machine wallet, the customer is owed; worklist first)
|
||||
# 'partial' (operator confirmed a partial amount; distributed scaled)
|
||||
# 'refunded' (operator-initiated refund)
|
||||
# 'errored' (operational distribution failure — retry path applies)
|
||||
# 'rejected' (Nostr attribution cross-check failed at land time;
|
||||
# never went near distribution. error_message holds the
|
||||
# reason. Retry is wrong — investigate the machine.)
|
||||
# 'dispense_unreported' is NOT stored: the worklist derives it from
|
||||
# awaiting_dispense rows older than its threshold.
|
||||
status: str
|
||||
error_message: str | None
|
||||
processed_at: datetime | None
|
||||
created_at: datetime
|
||||
# ADR-005 §2 — copied from the machine's report. The three lamassu
|
||||
# fields plus raw_code / error_class; dispensed_fiat_cents is what the
|
||||
# hardware says physically left, which pre-fills partial-dispense.
|
||||
dispense_confirmed: bool | None = None
|
||||
dispense_error: str | None = None
|
||||
dispense_error_code: str | None = None
|
||||
dispense_raw_code: str | None = None
|
||||
dispense_error_class: str | None = None
|
||||
dispense_reported_at: datetime | None = None
|
||||
dispensed_fiat_cents: int | 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
|
||||
|
|
@ -336,6 +363,110 @@ class DcaSettlement(BaseModel):
|
|||
processing_claim: str | None = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Dispense outcome (bitspire ADR-005 §2) — machine → spirekeeper `report_dispense`
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class DispenseReportBill(BaseModel):
|
||||
denomination: int
|
||||
requested: int
|
||||
dispensed: int
|
||||
rejected: int
|
||||
|
||||
|
||||
class DispenseReportCassette(BaseModel):
|
||||
position: int
|
||||
denomination: int
|
||||
provisioned: int
|
||||
dispensed: int
|
||||
rejected: int
|
||||
|
||||
|
||||
class DispenseReportIn(BaseModel):
|
||||
"""The RPC body as the machine sends it. Mirrors @bitSpire/lnbits
|
||||
DispenseReportBody. Field names follow lamassu-server's cash_out_txs /
|
||||
cash_out_actions (dispense_confirmed, error, error_code). Idempotent on
|
||||
(txid, at): the machine resends until acked; a byte-identical resend is
|
||||
acknowledged without a new row."""
|
||||
|
||||
txid: str
|
||||
payment_hash: str | None = None
|
||||
tx_type: str = "cash_out"
|
||||
dispense_confirmed: bool
|
||||
error: str | None = None
|
||||
error_code: str | None = None
|
||||
raw_code: str | None = None
|
||||
error_class: str | None = None # 'terminal' | 'recoverable' | 'inventory'
|
||||
fiat_cents: int
|
||||
currency: str
|
||||
bills: list[DispenseReportBill] = []
|
||||
cassettes: list[DispenseReportCassette] = []
|
||||
counts_uncertain: bool = False
|
||||
remediates_txid: str | None = None
|
||||
at: int
|
||||
|
||||
@validator("txid")
|
||||
def _txid_present(cls, v):
|
||||
if not v or not v.strip():
|
||||
raise ValueError("txid is required")
|
||||
return v.strip()
|
||||
|
||||
@validator("tx_type")
|
||||
def _cash_out_only(cls, v):
|
||||
if v != "cash_out":
|
||||
raise ValueError("report_dispense is for cash_out transactions only")
|
||||
return v
|
||||
|
||||
@validator("error_class")
|
||||
def _known_class(cls, v):
|
||||
if v is not None and v not in ("terminal", "recoverable", "inventory"):
|
||||
raise ValueError(
|
||||
f"error_class must be terminal|recoverable|inventory, got {v!r}"
|
||||
)
|
||||
return v
|
||||
|
||||
@validator("fiat_cents")
|
||||
def _fiat_non_negative(cls, v):
|
||||
if v < 0:
|
||||
raise ValueError("fiat_cents must be >= 0")
|
||||
return v
|
||||
|
||||
@property
|
||||
def dispensed_fiat_cents(self) -> int:
|
||||
"""What the hardware says physically left, in cents."""
|
||||
return sum(b.denomination * b.dispensed for b in self.bills) * 100
|
||||
|
||||
@property
|
||||
def total_dispensed_notes(self) -> int:
|
||||
return sum(b.dispensed for b in self.bills)
|
||||
|
||||
|
||||
class DispenseReport(BaseModel):
|
||||
"""One stored report (append-only — a retry, a late report and a
|
||||
remediation report are distinct rows). `settlement_id` is NULL when the
|
||||
payment this report refers to was never seen by this server."""
|
||||
|
||||
id: str
|
||||
machine_id: str
|
||||
settlement_id: str | None
|
||||
txid: str
|
||||
payment_hash: str | None
|
||||
dispense_confirmed: bool
|
||||
error: str | None
|
||||
error_code: str | None
|
||||
raw_code: str | None
|
||||
error_class: str | None
|
||||
fiat_cents: int
|
||||
currency: str
|
||||
bills_json: str
|
||||
cassettes_json: str
|
||||
counts_uncertain: bool = False
|
||||
remediates_txid: str | None = None
|
||||
reported_at: datetime
|
||||
received_at: datetime
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Commission splits — operator-defined remainder allocation per machine.
|
||||
# =============================================================================
|
||||
|
|
@ -570,6 +701,13 @@ class StuckSettlementsResponse(BaseModel):
|
|||
"""
|
||||
|
||||
threshold_minutes: int
|
||||
# ADR-005 §6 — the only buckets whose meaning is "a customer is owed
|
||||
# money"; rendered first.
|
||||
cash_owed: list = [] # list[DcaSettlement]
|
||||
partial_pending: list = []
|
||||
# awaiting_dispense older than the threshold: the machine never reported.
|
||||
# A machine on an old build lands here too — that is the upgrade path.
|
||||
dispense_unreported: list = []
|
||||
rejected: list # list[DcaSettlement]
|
||||
errored: list
|
||||
stuck_pending: list
|
||||
|
|
@ -738,6 +876,11 @@ class PublishCassettesPayload(BaseModel):
|
|||
applied_ops: list[str] = []
|
||||
seq: int | None = None
|
||||
counts_uncertain_since: int | None = None
|
||||
# ADR-005 §5 — additive like counts_uncertain_since. Present while the
|
||||
# machine refuses cash-out after a terminal dispenser fault.
|
||||
cash_out_held_since: int | None = None
|
||||
cash_out_held_reason: str | None = None
|
||||
cash_out_held_code: str | None = None
|
||||
|
||||
@validator("positions", pre=True)
|
||||
def coerce_string_keys_to_int(cls, v):
|
||||
|
|
@ -803,7 +946,21 @@ class PublishCassettesPayload(BaseModel):
|
|||
# landed after the same problem.
|
||||
|
||||
|
||||
CASSETTE_OP_TYPES = ("refill", "empty", "recount", "set_denomination")
|
||||
CASSETTE_OP_TYPES = (
|
||||
"refill",
|
||||
"empty",
|
||||
"recount",
|
||||
"set_denomination",
|
||||
"resume_cash_out",
|
||||
)
|
||||
|
||||
# ADR-005 §5: `resume_cash_out` is not a cassette operation — it releases the
|
||||
# machine's cash-out hold without touching a bay — but it rides the same
|
||||
# operator event, with the same id/at dedup shape, because that channel is the
|
||||
# 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",)
|
||||
|
||||
|
||||
class CassetteOp(BaseModel):
|
||||
|
|
@ -837,11 +994,17 @@ class CassetteOp(BaseModel):
|
|||
raise ValueError(f"op_type must be one of {CASSETTE_OP_TYPES}, got {v!r}")
|
||||
return v
|
||||
|
||||
@validator("position")
|
||||
def _position_positive(cls, v):
|
||||
if v <= 0:
|
||||
raise ValueError(f"position must be > 0, got {v}")
|
||||
return v
|
||||
@root_validator(skip_on_failure=True)
|
||||
def _position_matches_scope(cls, values):
|
||||
pos, typ = values.get("position"), values.get("op_type")
|
||||
if typ in MACHINE_WIDE_OP_TYPES:
|
||||
if pos != 0:
|
||||
raise ValueError(
|
||||
f"{typ} is machine-wide; position must be 0, got {pos}"
|
||||
)
|
||||
elif pos is None or pos <= 0:
|
||||
raise ValueError(f"position must be > 0, got {pos}")
|
||||
return values
|
||||
|
||||
def to_wire_dict(self) -> dict:
|
||||
"""The published form. Drops the fields this op_type does not use, so
|
||||
|
|
@ -850,8 +1013,10 @@ class CassetteOp(BaseModel):
|
|||
"id": self.id,
|
||||
"at": int(self.created_at.timestamp()),
|
||||
"type": self.op_type,
|
||||
"position": self.position,
|
||||
}
|
||||
if self.op_type in MACHINE_WIDE_OP_TYPES:
|
||||
return out
|
||||
out["position"] = self.position
|
||||
if self.op_type == "refill":
|
||||
out["bills"] = self.bills
|
||||
elif self.op_type == "recount":
|
||||
|
|
@ -880,11 +1045,17 @@ class CreateCassetteOpData(BaseModel):
|
|||
raise ValueError(f"op_type must be one of {CASSETTE_OP_TYPES}, got {v!r}")
|
||||
return v
|
||||
|
||||
@validator("position")
|
||||
def _position_positive(cls, v):
|
||||
if v <= 0:
|
||||
raise ValueError(f"position must be > 0, got {v}")
|
||||
return v
|
||||
@root_validator(skip_on_failure=True)
|
||||
def _position_matches_scope(cls, values):
|
||||
pos, typ = values.get("position"), values.get("op_type")
|
||||
if typ in MACHINE_WIDE_OP_TYPES:
|
||||
if pos != 0:
|
||||
raise ValueError(
|
||||
f"{typ} is machine-wide; position must be 0, got {pos}"
|
||||
)
|
||||
elif pos is None or pos <= 0:
|
||||
raise ValueError(f"position must be > 0, got {pos}")
|
||||
return values
|
||||
|
||||
@validator("bills")
|
||||
def _bills_positive(cls, v):
|
||||
|
|
@ -911,6 +1082,7 @@ class CreateCassetteOpData(BaseModel):
|
|||
"recount": "count",
|
||||
"set_denomination": "denomination",
|
||||
"empty": None,
|
||||
"resume_cash_out": None,
|
||||
}[values.get("op_type")]
|
||||
if required is not None and values.get(required) is None:
|
||||
raise ValueError(f"{values['op_type']} requires `{required}`")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue