Compare commits
No commits in common. "main" and "v0.1.5" have entirely different histories.
19 changed files with 397 additions and 4018 deletions
|
|
@ -6,7 +6,6 @@ from loguru import logger
|
|||
|
||||
from .cashin_transport import register_create_withdraw_rpc
|
||||
from .crud import db
|
||||
from .dispense_transport import register_dispense_report_rpc
|
||||
from .machine_config_transport import register_machine_config_rpc
|
||||
from .nostr_transport_roster import register_with_lnbits as register_roster_with_lnbits
|
||||
from .tasks import wait_for_cassette_state_events, wait_for_paid_invoices
|
||||
|
|
@ -69,11 +68,6 @@ def spirekeeper_start():
|
|||
# config over the transport, leaving "awaiting configuration" with no
|
||||
# per-machine env provisioning. Soft-fails if register_rpc isn't exposed.
|
||||
register_machine_config_rpc()
|
||||
# Dispense outcome capture (bitspire ADR-005 §2 / #122): register the
|
||||
# report_dispense RPC. A cash-out settlement now waits in awaiting_dispense
|
||||
# until the machine reports; the success report is what distributes it,
|
||||
# a failure report puts the customer on the owed-cash worklist.
|
||||
register_dispense_report_rpc()
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
|
|
|||
|
|
@ -17,14 +17,8 @@ publishes position-keyed cassette config to a target ATM via:
|
|||
The ATM-side consumer (lamassu-next#56) subscribes by the d-tag + its own
|
||||
npub, decrypts, validates, applies, hot-reloads HAL.
|
||||
|
||||
The operator → ATM direction carries OPERATIONS as of v2 (bitspire ADR-004):
|
||||
refill, empty, recount, set_denomination, each with an id the machine dedups
|
||||
on. It used to carry absolute counts, which meant the operator and the machine
|
||||
both wrote the same value over a transport that never tells a writer it lost —
|
||||
so a form loaded before a dispense discarded that dispense when published.
|
||||
|
||||
Reverse direction (ATM → operator, continuous: the machine publishes on
|
||||
startup, after every change to its bays, and on a heartbeat):
|
||||
Reverse direction (ATM → operator, v1 = one-shot bootstrap on first boot,
|
||||
v2 = continuous reverse channel for reconciliation):
|
||||
|
||||
kind = 30078
|
||||
tags = [
|
||||
|
|
@ -36,7 +30,7 @@ startup, after every change to its bays, and on a heartbeat):
|
|||
|
||||
This module owns the wire-format side of both directions. The consumer
|
||||
task (tasks.py) calls `decrypt_and_parse_state_event` per incoming event;
|
||||
the API endpoint (views_api.py) calls `publish_ops_to_atm` per operation.
|
||||
the API endpoint (views_api.py) calls `publish_to_atm` per operator submit.
|
||||
|
||||
The `<m>` placeholder semantics (load-bearing per the 2026-05-30T11:50Z
|
||||
coord-log entry): always the ATM's hex pubkey, NEVER spirekeeper's
|
||||
|
|
@ -58,12 +52,7 @@ from lnbits.core.signers.base import (
|
|||
)
|
||||
from lnbits.utils.nostr import normalize_public_key
|
||||
|
||||
from .models import (
|
||||
CassetteOp,
|
||||
Machine,
|
||||
PublishCassetteOpsPayload,
|
||||
PublishCassettesPayload,
|
||||
)
|
||||
from .models import Machine, PublishCassettesPayload
|
||||
from .nip44 import Nip44Error
|
||||
from .nostr_publish import (
|
||||
NostrPublishError,
|
||||
|
|
@ -86,7 +75,7 @@ __all__ = [
|
|||
"RelayUnavailable",
|
||||
"build_state_d_tags_for_machines",
|
||||
"decrypt_and_parse_state_event",
|
||||
"publish_ops_to_atm",
|
||||
"publish_to_atm",
|
||||
]
|
||||
|
||||
_D_TAG_CONFIG_PREFIX = "bitspire-cassettes:" # operator → ATM
|
||||
|
|
@ -163,41 +152,35 @@ def build_state_d_tags_for_machines(machines: list[Machine]) -> list[str]:
|
|||
# =============================================================================
|
||||
|
||||
|
||||
async def publish_ops_to_atm(
|
||||
async def publish_to_atm(
|
||||
machine: Machine,
|
||||
ops: list[CassetteOp],
|
||||
payload: PublishCassettesPayload,
|
||||
operator_user_id: str,
|
||||
) -> dict:
|
||||
"""Publish the operator's recent cassette OPERATIONS to the target ATM.
|
||||
"""Build, encrypt, sign, and publish a kind-30078 cassette config event
|
||||
from the operator to the target ATM.
|
||||
|
||||
The v2 wire (bitspire ADR-004). Replaces sending absolute counts, which
|
||||
let a dashboard form loaded before a dispense silently discard that
|
||||
dispense — the operator and the machine were both writing the same value
|
||||
over a transport that never tells a writer it lost.
|
||||
|
||||
`ops` is a WINDOW, oldest-first, not just the newest change. The event is
|
||||
addressable, so each publish replaces the last, and a machine that was
|
||||
offline for one of them would otherwise never see that operation again.
|
||||
Carrying the recent history means the channel heals itself without anyone
|
||||
noticing it broke. Re-delivery is harmless because each op carries an id
|
||||
the machine dedups on.
|
||||
Returns the signed event dict on success (caller may log event.id for
|
||||
audit). Raises NostrPublishError subclasses (re-exported here as
|
||||
CassetteTransportError, OperatorIdentityMissing, SignerUnavailable,
|
||||
RelayUnavailable) on hard failures.
|
||||
"""
|
||||
atm_pubkey_hex = _atm_hex_pubkey(machine)
|
||||
payload = PublishCassetteOpsPayload(ops=ops)
|
||||
signed = await publish_encrypted_kind_30078(
|
||||
operator_user_id=operator_user_id,
|
||||
recipient_pubkey_hex=atm_pubkey_hex,
|
||||
d_tag=_config_d_tag(atm_pubkey_hex),
|
||||
payload=payload.to_wire_dict(),
|
||||
log_context=(
|
||||
f"cassette ops (machine={machine.id}, ops={[o.op_type for o in ops]})"
|
||||
f"cassette config (machine={machine.id}, "
|
||||
f"positions={sorted(payload.positions.keys())})"
|
||||
),
|
||||
)
|
||||
return signed
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Consume — ATM → operator (the machine's state reports)
|
||||
# Consume — ATM → operator (the bootstrap consumer task)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"name": "spirekeeper",
|
||||
"version": "0.1.8",
|
||||
"short_description": "Operator admin for bitSpire ATMs — DCA distribution over Nostr",
|
||||
"tile": "/spirekeeper/static/image/aio.png",
|
||||
"min_lnbits_version": "1.0.0",
|
||||
|
|
|
|||
507
crud.py
507
crud.py
|
|
@ -12,11 +12,9 @@ from lnbits.helpers import urlsafe_short_hash
|
|||
|
||||
from .models import (
|
||||
CassetteConfig,
|
||||
CassetteOp,
|
||||
ClientBalanceSummary,
|
||||
CommissionSplit,
|
||||
CommissionSplitLeg,
|
||||
CreateCassetteOpData,
|
||||
CreateDcaClientData,
|
||||
CreateDcaPaymentData,
|
||||
CreateDcaSettlementData,
|
||||
|
|
@ -27,8 +25,6 @@ from .models import (
|
|||
DcaLpPreferences,
|
||||
DcaPayment,
|
||||
DcaSettlement,
|
||||
DispenseReport,
|
||||
DispenseReportIn,
|
||||
Machine,
|
||||
PublishCassettesPayload,
|
||||
SuperConfig,
|
||||
|
|
@ -38,6 +34,7 @@ from .models import (
|
|||
UpdateDepositStatusData,
|
||||
UpdateMachineData,
|
||||
UpdateSuperConfigData,
|
||||
UpsertCassetteConfigData,
|
||||
UpsertDcaLpData,
|
||||
)
|
||||
|
||||
|
|
@ -259,51 +256,6 @@ async def set_machine_unpaired(machine_id: str) -> Machine | None:
|
|||
return await get_machine(machine_id)
|
||||
|
||||
|
||||
async def set_machine_cash_out_hold(
|
||||
machine_id: str,
|
||||
since: datetime | None,
|
||||
reason: str | None,
|
||||
code: str | None,
|
||||
) -> None:
|
||||
"""Mirror the machine's cash-out hold (ADR-005 §5) onto its registry row.
|
||||
|
||||
Written on every state event, including when it is None: the machine
|
||||
clearing the hold — after an operator recount or resume_cash_out — is as
|
||||
important as it setting one. `updated_at` is left alone for the same
|
||||
reason as counts_uncertain_since: this is the machine reporting about
|
||||
itself on a heartbeat, not an operator editing the machine.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE spirekeeper.dca_machines
|
||||
SET cash_out_held_since = :since,
|
||||
cash_out_held_reason = :reason,
|
||||
cash_out_held_code = :code
|
||||
WHERE id = :id
|
||||
""",
|
||||
{"id": machine_id, "since": since, "reason": reason, "code": code},
|
||||
)
|
||||
|
||||
|
||||
async def set_machine_counts_uncertain(machine_id: str, since: datetime | None) -> None:
|
||||
"""Record (or clear) the machine's own "I can't vouch for these counts"
|
||||
marker, straight from its state document.
|
||||
|
||||
`updated_at` is deliberately left alone. This is the machine reporting
|
||||
about itself on a five-minute heartbeat, not an operator editing the
|
||||
machine, and touching the timestamp on every heartbeat would make the
|
||||
registry look perpetually just-modified.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE spirekeeper.dca_machines
|
||||
SET counts_uncertain_since = :since
|
||||
WHERE id = :id
|
||||
""",
|
||||
{"since": since, "id": machine_id},
|
||||
)
|
||||
|
||||
|
||||
async def delete_machine(machine_id: str) -> None:
|
||||
await db.execute(
|
||||
"DELETE FROM spirekeeper.dca_machines WHERE id = :id",
|
||||
|
|
@ -739,180 +691,6 @@ 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:
|
||||
"""The settlement a machine report refers to. `bitspire_txid` comes from
|
||||
the invoice's extra.txid, stamped by the machine at create_invoice time,
|
||||
so it is the natural join for a report that names the same txid."""
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM spirekeeper.dca_settlements "
|
||||
"WHERE machine_id = :mid AND bitspire_txid = :txid",
|
||||
{"mid": machine_id, "txid": bitspire_txid},
|
||||
DcaSettlement,
|
||||
)
|
||||
|
||||
|
||||
async def apply_dispense_outcome(
|
||||
settlement_id: str,
|
||||
report: DispenseReportIn,
|
||||
new_status: str,
|
||||
reported_at: datetime,
|
||||
) -> DcaSettlement | None:
|
||||
"""Copy the machine's report onto the settlement and move it (ADR-005 §1).
|
||||
|
||||
Fills the never-before-written bills_json / cassettes_json with what
|
||||
actually came out, not what was provisioned. `error_message` is left to
|
||||
the distribution path; the dispense error lives in its own columns.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE spirekeeper.dca_settlements
|
||||
SET status = :status,
|
||||
dispense_confirmed = :confirmed,
|
||||
dispense_error = :error,
|
||||
dispense_error_code = :error_code,
|
||||
dispense_raw_code = :raw_code,
|
||||
dispense_error_class = :error_class,
|
||||
dispense_reported_at = :reported_at,
|
||||
dispensed_fiat_cents = :dispensed_fiat_cents,
|
||||
bills_json = :bills_json,
|
||||
cassettes_json = :cassettes_json,
|
||||
processing_claim = NULL
|
||||
WHERE id = :id
|
||||
""",
|
||||
{
|
||||
"id": settlement_id,
|
||||
"status": new_status,
|
||||
"confirmed": report.dispense_confirmed,
|
||||
"error": report.error,
|
||||
"error_code": report.error_code,
|
||||
"raw_code": report.raw_code,
|
||||
"error_class": report.error_class,
|
||||
"reported_at": reported_at,
|
||||
"dispensed_fiat_cents": report.dispensed_fiat_cents,
|
||||
"bills_json": _json.dumps([b.dict() for b in report.bills]),
|
||||
"cassettes_json": _json.dumps([c.dict() for c in report.cassettes]),
|
||||
},
|
||||
)
|
||||
return await get_settlement(settlement_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispense reports (ADR-005 §2) — append-only
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_dispense_report(
|
||||
machine_id: str, txid: str, reported_at: int
|
||||
) -> DispenseReport | None:
|
||||
"""A report is identified by (machine, txid, at): the machine resends the
|
||||
same report until acked, and a byte-identical resend must not grow the
|
||||
log. A remediation report for the same txid carries a later `at`."""
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM spirekeeper.dispense_reports "
|
||||
"WHERE machine_id = :mid AND txid = :txid AND reported_at = :at",
|
||||
{"mid": machine_id, "txid": txid, "at": datetime.fromtimestamp(reported_at)},
|
||||
DispenseReport,
|
||||
)
|
||||
|
||||
|
||||
async def insert_dispense_report(
|
||||
machine_id: str, settlement_id: str | None, report: DispenseReportIn
|
||||
) -> DispenseReport:
|
||||
import json as _json
|
||||
|
||||
report_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO spirekeeper.dispense_reports
|
||||
(id, machine_id, settlement_id, txid, payment_hash, dispense_confirmed,
|
||||
error, error_code, raw_code, error_class, fiat_cents, currency,
|
||||
bills_json, cassettes_json, counts_uncertain, remediates_txid,
|
||||
reported_at, received_at)
|
||||
VALUES (:id, :machine_id, :settlement_id, :txid, :payment_hash,
|
||||
:confirmed, :error, :error_code, :raw_code, :error_class,
|
||||
:fiat_cents, :currency, :bills_json, :cassettes_json,
|
||||
:counts_uncertain, :remediates_txid, :reported_at, :received_at)
|
||||
""",
|
||||
{
|
||||
"id": report_id,
|
||||
"machine_id": machine_id,
|
||||
"settlement_id": settlement_id,
|
||||
"txid": report.txid,
|
||||
"payment_hash": report.payment_hash,
|
||||
"confirmed": report.dispense_confirmed,
|
||||
"error": report.error,
|
||||
"error_code": report.error_code,
|
||||
"raw_code": report.raw_code,
|
||||
"error_class": report.error_class,
|
||||
"fiat_cents": report.fiat_cents,
|
||||
"currency": report.currency,
|
||||
"bills_json": _json.dumps([b.dict() for b in report.bills]),
|
||||
"cassettes_json": _json.dumps([c.dict() for c in report.cassettes]),
|
||||
"counts_uncertain": report.counts_uncertain,
|
||||
"remediates_txid": report.remediates_txid,
|
||||
"reported_at": datetime.fromtimestamp(report.at),
|
||||
"received_at": datetime.now(),
|
||||
},
|
||||
)
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM spirekeeper.dispense_reports WHERE id = :id",
|
||||
{"id": report_id},
|
||||
DispenseReport,
|
||||
)
|
||||
assert row is not None, "Newly inserted dispense report couldn't be retrieved"
|
||||
return row
|
||||
|
||||
|
||||
async def link_dispense_reports_to_settlement(
|
||||
machine_id: str, txid: str, settlement_id: str
|
||||
) -> int:
|
||||
"""A report can arrive before its payment lands (hold invoices settle
|
||||
after the dispense; the invoice listener can lag). When the settlement is
|
||||
finally inserted, adopt the orphan rows."""
|
||||
result = await db.execute(
|
||||
"UPDATE spirekeeper.dispense_reports SET settlement_id = :sid "
|
||||
"WHERE machine_id = :mid AND txid = :txid AND settlement_id IS NULL",
|
||||
{"sid": settlement_id, "mid": machine_id, "txid": txid},
|
||||
)
|
||||
return getattr(result, "rowcount", 0) or 0
|
||||
|
||||
|
||||
async def get_latest_unlinked_dispense_report(
|
||||
machine_id: str, txid: str
|
||||
) -> DispenseReport | None:
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM spirekeeper.dispense_reports "
|
||||
"WHERE machine_id = :mid AND txid = :txid AND settlement_id IS NULL "
|
||||
"ORDER BY reported_at DESC LIMIT 1",
|
||||
{"mid": machine_id, "txid": txid},
|
||||
DispenseReport,
|
||||
)
|
||||
|
||||
|
||||
async def get_dispense_reports_for_settlement(
|
||||
settlement_id: str,
|
||||
) -> list[DispenseReport]:
|
||||
return await db.fetchall(
|
||||
"SELECT * FROM spirekeeper.dispense_reports WHERE settlement_id = :sid "
|
||||
"ORDER BY reported_at ASC",
|
||||
{"sid": settlement_id},
|
||||
DispenseReport,
|
||||
)
|
||||
|
||||
|
||||
async def get_settlement(settlement_id: str) -> DcaSettlement | None:
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM spirekeeper.dca_settlements WHERE id = :id",
|
||||
|
|
@ -954,14 +732,7 @@ async def get_stuck_settlements_for_operator(
|
|||
) -> dict:
|
||||
"""Operator worklist of settlements that didn't process cleanly.
|
||||
|
||||
Returns a dict with seven keyed lists. The first three are ADR-005 §6 —
|
||||
the only ones whose meaning is "a customer is owed money":
|
||||
- 'cash_owed': the machine reported nothing dispensed; legs never ran.
|
||||
- 'partial_pending': some notes out, value short; held whole until the
|
||||
operator records the resolution.
|
||||
- 'dispense_unreported': awaiting_dispense older than the threshold —
|
||||
the machine never reported (crashed, offline, or an old build).
|
||||
Then the original four:
|
||||
Returns a dict with four keyed lists:
|
||||
- 'rejected': any status='rejected' (Nostr attribution cross-check
|
||||
failed — signer didn't match the machine identity). Distinct
|
||||
from 'errored' because retry is wrong: the row was misrouted,
|
||||
|
|
@ -1026,48 +797,7 @@ async def get_stuck_settlements_for_operator(
|
|||
{"uid": operator_user_id, "threshold": threshold_at},
|
||||
DcaSettlement,
|
||||
)
|
||||
# ADR-005 §6 — the owed-cash buckets. cash_owed / partial_pending are
|
||||
# stored statuses; dispense_unreported is derived: a cash-out that landed
|
||||
# and never heard from its machine within the threshold.
|
||||
cash_owed = await db.fetchall(
|
||||
"""
|
||||
SELECT s.*
|
||||
FROM spirekeeper.dca_settlements s
|
||||
JOIN spirekeeper.dca_machines m ON m.id = s.machine_id
|
||||
WHERE m.operator_user_id = :uid AND s.status = 'cash_owed'
|
||||
ORDER BY s.created_at DESC
|
||||
""",
|
||||
{"uid": operator_user_id},
|
||||
DcaSettlement,
|
||||
)
|
||||
partial_pending = await db.fetchall(
|
||||
"""
|
||||
SELECT s.*
|
||||
FROM spirekeeper.dca_settlements s
|
||||
JOIN spirekeeper.dca_machines m ON m.id = s.machine_id
|
||||
WHERE m.operator_user_id = :uid AND s.status = 'partial_pending'
|
||||
ORDER BY s.created_at DESC
|
||||
""",
|
||||
{"uid": operator_user_id},
|
||||
DcaSettlement,
|
||||
)
|
||||
dispense_unreported = await db.fetchall(
|
||||
"""
|
||||
SELECT s.*
|
||||
FROM spirekeeper.dca_settlements s
|
||||
JOIN spirekeeper.dca_machines m ON m.id = s.machine_id
|
||||
WHERE m.operator_user_id = :uid
|
||||
AND s.status = 'awaiting_dispense'
|
||||
AND s.created_at < :threshold
|
||||
ORDER BY s.created_at ASC
|
||||
""",
|
||||
{"uid": operator_user_id, "threshold": threshold_at},
|
||||
DcaSettlement,
|
||||
)
|
||||
return {
|
||||
"cash_owed": cash_owed,
|
||||
"partial_pending": partial_pending,
|
||||
"dispense_unreported": dispense_unreported,
|
||||
"rejected": rejected,
|
||||
"errored": errored,
|
||||
"stuck_pending": stuck_pending,
|
||||
|
|
@ -1120,10 +850,9 @@ async def mark_settlement_status(
|
|||
status: str,
|
||||
error_message: str | None = None,
|
||||
) -> DcaSettlement | None:
|
||||
"""Status: 'awaiting_dispense' | 'pending' | 'processing' | 'processed' |
|
||||
'partial_pending' | 'cash_owed' | 'partial' | 'refunded' | 'errored'.
|
||||
Clears processing_claim on terminal states so a fresh claim attempt won't
|
||||
see a stale token."""
|
||||
"""Status: 'pending' | 'processing' | 'processed' | 'partial' |
|
||||
'refunded' | 'errored'. Clears processing_claim on terminal states so a
|
||||
fresh claim attempt won't see a stale token."""
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE spirekeeper.dca_settlements
|
||||
|
|
@ -1715,11 +1444,10 @@ async def upsert_fleet_snapshot(
|
|||
# Row lifecycle per #29:
|
||||
# - First population for a (machine_id, position) pair → apply_reported_state
|
||||
# (consumer reading the ATM's one-shot bitspire-cassettes-state event)
|
||||
# - The operator does NOT write these rows. It records operations
|
||||
# (cassette_ops) and the machine keeps the running count; these columns
|
||||
# hold what the machine last reported.
|
||||
# - Rows appear and disappear only as the machine reports its bay set —
|
||||
# the slot count is hardware-determined.
|
||||
# - Operator edit of denomination or count → update_cassette_config
|
||||
# (refuses to create new rows; the slot count is hardware-determined)
|
||||
# - Row creation/deletion for a new position → admin only, via ATM
|
||||
# re-provisioning + new bootstrap event (not exposed in v1 here)
|
||||
|
||||
|
||||
def _as_unix(value) -> float | None:
|
||||
|
|
@ -1742,23 +1470,13 @@ def _as_unix(value) -> float | None:
|
|||
return None
|
||||
|
||||
|
||||
def _row_field(row, name):
|
||||
"""Read one column from a row the data layer may hand back as either a
|
||||
mapping or an object, depending on the driver."""
|
||||
if isinstance(row, dict):
|
||||
return row.get(name)
|
||||
return getattr(row, name, None)
|
||||
|
||||
|
||||
def _should_apply_state_event(
|
||||
oldest_state_at, incoming_created_at, oldest_seq=None, incoming_seq=None
|
||||
) -> bool:
|
||||
def _should_apply_state_event(oldest_state_at, incoming_created_at) -> bool:
|
||||
"""Ordering gate for apply_reported_state.
|
||||
|
||||
Applies only when the incoming event is strictly newer than the OLDEST
|
||||
state stamp on file for the machine.
|
||||
|
||||
Three deliberate choices:
|
||||
Two deliberate choices:
|
||||
|
||||
- Compare created_at, not event ids. The old gate asked only whether the
|
||||
incoming id differed from one stored row, which is a one-event memory:
|
||||
|
|
@ -1771,14 +1489,6 @@ def _should_apply_state_event(
|
|||
some rows advanced and some not. Gating on the oldest means a partial
|
||||
apply is re-applied on the next event rather than being mistaken for a
|
||||
complete one, and the ATM republishes on a heartbeat, so it converges.
|
||||
- `seq` breaks a same-second tie, and only that. NIP-01 stamps at
|
||||
one-second granularity, so a dispense and the publish that follows it
|
||||
can share a stamp and the later report would be dropped. The machine
|
||||
bumps seq on every local count change, so a higher seq at an equal stamp
|
||||
is strictly newer. It is consulted ONLY on equality: a machine whose
|
||||
state.db was replaced restarts its counter at zero, and gating on seq
|
||||
across different stamps would lock that machine out permanently while
|
||||
its wall clock kept moving forward.
|
||||
"""
|
||||
oldest = _as_unix(oldest_state_at)
|
||||
if oldest is None:
|
||||
|
|
@ -1786,11 +1496,7 @@ def _should_apply_state_event(
|
|||
incoming = _as_unix(incoming_created_at)
|
||||
if incoming is None:
|
||||
return False
|
||||
if incoming != oldest:
|
||||
return incoming > oldest
|
||||
if incoming_seq is None or oldest_seq is None:
|
||||
return False
|
||||
return incoming_seq > oldest_seq
|
||||
|
||||
|
||||
async def get_cassette_config(machine_id: str, position: int) -> CassetteConfig | None:
|
||||
|
|
@ -1813,6 +1519,38 @@ async def list_cassette_configs_for_machine(
|
|||
)
|
||||
|
||||
|
||||
async def update_cassette_config(
|
||||
machine_id: str,
|
||||
position: int,
|
||||
data: UpsertCassetteConfigData,
|
||||
*,
|
||||
updated_by: str | None = None,
|
||||
) -> CassetteConfig | None:
|
||||
"""Operator-driven row update: change denomination and/or count for a
|
||||
single cassette slot. Refuses to create new rows — those only land via
|
||||
apply_reported_state() consuming an ATM bootstrap event (per #29 row
|
||||
lifecycle: hardware-determined slot count, not operator-creatable).
|
||||
Returns None if the (machine_id, position) row doesn't exist.
|
||||
"""
|
||||
existing = await get_cassette_config(machine_id, position)
|
||||
if existing is None:
|
||||
return None
|
||||
update_data: dict = {k: v for k, v in data.dict().items() if v is not None}
|
||||
if not update_data:
|
||||
return existing
|
||||
update_data["updated_at"] = datetime.now()
|
||||
update_data["updated_by"] = updated_by
|
||||
set_clause = ", ".join(f"{k} = :{k}" for k in update_data)
|
||||
update_data["mid"] = machine_id
|
||||
update_data["pos"] = position
|
||||
await db.execute(
|
||||
f"UPDATE spirekeeper.cassette_configs SET {set_clause} "
|
||||
"WHERE machine_id = :mid AND position = :pos",
|
||||
update_data,
|
||||
)
|
||||
return await get_cassette_config(machine_id, position)
|
||||
|
||||
|
||||
async def apply_reported_state(
|
||||
machine_id: str,
|
||||
event_id: str,
|
||||
|
|
@ -1838,19 +1576,19 @@ async def apply_reported_state(
|
|||
against believed.
|
||||
"""
|
||||
oldest: dict | None = await db.fetchone(
|
||||
"SELECT state_at, state_seq FROM spirekeeper.cassette_configs "
|
||||
"SELECT state_at FROM spirekeeper.cassette_configs "
|
||||
"WHERE machine_id = :mid AND state_at IS NOT NULL "
|
||||
"ORDER BY state_at ASC, state_seq ASC LIMIT 1",
|
||||
"ORDER BY state_at ASC LIMIT 1",
|
||||
{"mid": machine_id},
|
||||
)
|
||||
oldest_state_at = None
|
||||
oldest_seq = None
|
||||
if oldest is not None:
|
||||
oldest_state_at = _row_field(oldest, "state_at")
|
||||
oldest_seq = _row_field(oldest, "state_seq")
|
||||
if not _should_apply_state_event(
|
||||
oldest_state_at, event_created_at, oldest_seq, payload.seq
|
||||
):
|
||||
oldest_state_at = (
|
||||
oldest.get("state_at")
|
||||
if isinstance(oldest, dict)
|
||||
else getattr(oldest, "state_at", None)
|
||||
)
|
||||
if not _should_apply_state_event(oldest_state_at, event_created_at):
|
||||
return False
|
||||
|
||||
# Drop bays the machine no longer reports, before writing the rest. A crash
|
||||
|
|
@ -1874,10 +1612,9 @@ async def apply_reported_state(
|
|||
INSERT INTO spirekeeper.cassette_configs
|
||||
(machine_id, position, denomination, count, updated_at,
|
||||
updated_by, state_denomination, state_count, state_at,
|
||||
state_event_id, state_seq)
|
||||
state_event_id)
|
||||
VALUES (:mid, :pos, :denom, :count, :now, :by,
|
||||
:state_denom, :state_count, :state_at, :event_id,
|
||||
:state_seq)
|
||||
:state_denom, :state_count, :state_at, :event_id)
|
||||
ON CONFLICT (machine_id, position) DO UPDATE SET
|
||||
denomination = excluded.denomination,
|
||||
count = excluded.count,
|
||||
|
|
@ -1886,8 +1623,7 @@ async def apply_reported_state(
|
|||
state_denomination = excluded.state_denomination,
|
||||
state_count = excluded.state_count,
|
||||
state_at = excluded.state_at,
|
||||
state_event_id = excluded.state_event_id,
|
||||
state_seq = excluded.state_seq
|
||||
state_event_id = excluded.state_event_id
|
||||
""",
|
||||
{
|
||||
"mid": machine_id,
|
||||
|
|
@ -1900,141 +1636,6 @@ async def apply_reported_state(
|
|||
"state_count": row.count,
|
||||
"state_at": event_created_at,
|
||||
"event_id": event_id,
|
||||
"state_seq": payload.seq,
|
||||
},
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cassette operations — the v2 operator → ATM wire (bitspire ADR-004)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Append-only. The operator records what it DID to a bay; the machine keeps the
|
||||
# running count. Nothing here ever updates cassette_configs: that table now
|
||||
# holds only what the machine has reported, and letting an operation write it
|
||||
# would reintroduce the second writer this design exists to remove.
|
||||
|
||||
# How many operations ride along in each published event. The window is what
|
||||
# makes the channel self-healing — a machine that missed one event still sees
|
||||
# the operation in the next — so it needs to cover a plausible outage, not just
|
||||
# the newest change. Twenty is roughly a fortnight of refills on a busy fleet
|
||||
# and still a small payload.
|
||||
CASSETTE_OPS_WINDOW = 20
|
||||
|
||||
|
||||
async def create_cassette_op(
|
||||
machine_id: str, data: CreateCassetteOpData, created_by: str | None
|
||||
) -> CassetteOp:
|
||||
"""Record one operator intent against one bay.
|
||||
|
||||
The id minted here is the idempotency key: it travels on the wire, the
|
||||
machine records the ones it has applied, and a re-delivered event is
|
||||
therefore free rather than double-counted.
|
||||
"""
|
||||
op_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO spirekeeper.cassette_ops
|
||||
(id, machine_id, position, op_type, bills, count, denomination,
|
||||
txid, note, created_at, created_by)
|
||||
VALUES (:id, :machine_id, :position, :op_type, :bills, :count,
|
||||
:denomination, :txid, :note, :created_at, :created_by)
|
||||
""",
|
||||
{
|
||||
"id": op_id,
|
||||
"machine_id": machine_id,
|
||||
"position": data.position,
|
||||
"op_type": data.op_type,
|
||||
"bills": data.bills,
|
||||
"count": data.count,
|
||||
"denomination": data.denomination,
|
||||
"txid": data.txid,
|
||||
"note": data.note,
|
||||
"created_at": datetime.now(),
|
||||
"created_by": created_by,
|
||||
},
|
||||
)
|
||||
op = await get_cassette_op(op_id)
|
||||
assert op is not None, "Newly recorded cassette op couldn't be retrieved"
|
||||
return op
|
||||
|
||||
|
||||
async def get_cassette_op(op_id: str) -> CassetteOp | None:
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM spirekeeper.cassette_ops WHERE id = :id",
|
||||
{"id": op_id},
|
||||
CassetteOp,
|
||||
)
|
||||
|
||||
|
||||
async def get_cassette_ops_window(
|
||||
machine_id: str, limit: int = CASSETTE_OPS_WINDOW
|
||||
) -> list[CassetteOp]:
|
||||
"""The operations a publish carries, oldest first.
|
||||
|
||||
Selected newest-first to take the most recent `limit`, then reversed so the
|
||||
machine applies them in the order they happened. That ordering matters:
|
||||
a recount followed by a refill is not the same as the reverse.
|
||||
"""
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM spirekeeper.cassette_ops WHERE machine_id = :mid "
|
||||
"ORDER BY created_at DESC, id DESC LIMIT :limit",
|
||||
{"mid": machine_id, "limit": limit},
|
||||
CassetteOp,
|
||||
)
|
||||
return list(reversed(rows))
|
||||
|
||||
|
||||
async def list_cassette_ops(
|
||||
machine_id: str, limit: int = CASSETTE_OPS_WINDOW
|
||||
) -> list[CassetteOp]:
|
||||
"""Newest-first, for the dashboard's history panel."""
|
||||
return await db.fetchall(
|
||||
"SELECT * FROM spirekeeper.cassette_ops WHERE machine_id = :mid "
|
||||
"ORDER BY created_at DESC, id DESC LIMIT :limit",
|
||||
{"mid": machine_id, "limit": limit},
|
||||
CassetteOp,
|
||||
)
|
||||
|
||||
|
||||
def _should_ack_op(existing: CassetteOp | None, machine_id: str) -> bool:
|
||||
"""Pure decision behind mark_cassette_ops_acked, extracted so it is
|
||||
testable without a database — same approach as the state-event gate.
|
||||
|
||||
Three reasons not to ack, and each matters:
|
||||
- the id is unknown, so there is nothing to close out;
|
||||
- it belongs to another machine, and one machine reporting an id must
|
||||
never be able to close out another machine's operation;
|
||||
- it is already acked, and the first acknowledgement is the interesting
|
||||
one. The machine echoes a WINDOW, so every id arrives many times over;
|
||||
overwriting would keep sliding the timestamp forward and lose when the
|
||||
operation actually landed.
|
||||
"""
|
||||
if existing is None:
|
||||
return False
|
||||
if existing.machine_id != machine_id:
|
||||
return False
|
||||
return existing.acked_at is None
|
||||
|
||||
|
||||
async def mark_cassette_ops_acked(machine_id: str, op_ids: list[str]) -> int:
|
||||
"""Record that the machine reported these operation ids as applied.
|
||||
|
||||
Returns how many rows moved from unacked to acked. See _should_ack_op for
|
||||
which reports are ignored and why.
|
||||
"""
|
||||
if not op_ids:
|
||||
return 0
|
||||
acked = 0
|
||||
now = datetime.now()
|
||||
for op_id in op_ids:
|
||||
existing = await get_cassette_op(op_id)
|
||||
if not _should_ack_op(existing, machine_id):
|
||||
continue
|
||||
await db.execute(
|
||||
"UPDATE spirekeeper.cassette_ops SET acked_at = :now "
|
||||
"WHERE id = :id AND machine_id = :mid",
|
||||
{"now": now, "id": op_id, "mid": machine_id},
|
||||
)
|
||||
acked += 1
|
||||
return acked
|
||||
|
|
|
|||
|
|
@ -1,309 +0,0 @@
|
|||
"""
|
||||
Dispense outcome capture: the `report_dispense` nostr-transport RPC
|
||||
(bitspire ADR-005 §1-§2, aiolabs/bitspire#122).
|
||||
|
||||
A cash-out used to be captured the instant its payment landed: `_handle_payment`
|
||||
spawned distribution in the same breath, so when a dispenser jammed two seconds
|
||||
later the legs were already paid and `processed` was the honest answer. Payment
|
||||
is now the authorization and the machine's dispense report is the capture. A
|
||||
`cash_out` settlement lands as `awaiting_dispense` and this handler moves it:
|
||||
|
||||
dispense_confirmed → pending → distribution runs
|
||||
some notes out, value short → partial_pending (held whole — ADR-005
|
||||
Decision 1: one distribution when the
|
||||
shortfall is resolved)
|
||||
nothing out → cash_owed (legs never run; the customer
|
||||
is owed; first on the worklist)
|
||||
report carries remediates_txid → the owed/partial settlement it names
|
||||
goes to pending and distributes in full
|
||||
|
||||
Every report is stored append-only in `dispense_reports` (lamassu-server's
|
||||
`cash_out_actions` shape), including the success ones — the success report is
|
||||
what captures. Identity is the VERIFIED transport sender, never the body, same
|
||||
as `create_withdraw` / `get_machine_config`. Idempotent on (txid, at): the
|
||||
machine resends until it gets an OK, and a byte-identical resend is acked
|
||||
without a new row or a second transition.
|
||||
|
||||
A report can also arrive BEFORE its payment lands (hold invoices settle after
|
||||
the dispense; the invoice listener can lag). It is stored with no settlement;
|
||||
`_handle_payment` adopts it when the settlement is inserted and applies the
|
||||
same transition. See `apply_report_to_settlement`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from .crud import (
|
||||
apply_dispense_outcome,
|
||||
get_dispense_report,
|
||||
get_machine_by_atm_pubkey_hex,
|
||||
get_settlement_by_txid,
|
||||
insert_dispense_report,
|
||||
link_dispense_reports_to_settlement,
|
||||
set_machine_counts_uncertain,
|
||||
)
|
||||
from .models import DcaSettlement, DispenseReport, DispenseReportIn, Machine
|
||||
|
||||
_RPC_NAME = "report_dispense"
|
||||
|
||||
# Statuses a first report may move. Anything else (processed, pending,
|
||||
# processing, errored, rejected, partial, refunded) is recorded but not moved —
|
||||
# a report cannot un-pay legs, and a late report for an already-captured sale
|
||||
# is information, not an instruction.
|
||||
_CAPTURABLE = ("awaiting_dispense",)
|
||||
# Statuses a remediation report may close out.
|
||||
_OWED = ("cash_owed", "partial_pending")
|
||||
|
||||
# Strong references to in-flight distribution tasks, same reason as tasks.py.
|
||||
_inflight: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def _outcome_status(report: DispenseReportIn) -> str:
|
||||
if report.dispense_confirmed:
|
||||
return "pending"
|
||||
if report.total_dispensed_notes > 0:
|
||||
return "partial_pending"
|
||||
return "cash_owed"
|
||||
|
||||
|
||||
def _spawn_distribution(settlement_id: str) -> None:
|
||||
# Lazy import: distribution imports crud, and crud is what this module
|
||||
# already depends on; importing at module load would make a cycle.
|
||||
from .distribution import process_settlement
|
||||
|
||||
task = asyncio.create_task(process_settlement(settlement_id))
|
||||
_inflight.add(task)
|
||||
task.add_done_callback(_inflight.discard)
|
||||
|
||||
|
||||
async def _alert_operator(settlement, machine: Machine, report: DispenseReportIn, status: str) -> None:
|
||||
"""ADR-005 §6: a human is now owed money — tell the operator over Nostr.
|
||||
Best-effort; the report is acked regardless (see notify.py)."""
|
||||
from .notify import notify_cash_outcome
|
||||
|
||||
await notify_cash_outcome(settlement, machine, report, status)
|
||||
|
||||
|
||||
async def apply_report_to_settlement(
|
||||
settlement: DcaSettlement,
|
||||
report: DispenseReportIn,
|
||||
machine: Machine,
|
||||
) -> str:
|
||||
"""Move `settlement` according to `report`. Returns the resulting status.
|
||||
|
||||
Shared by the RPC handler (report after payment) and `_handle_payment`
|
||||
(payment after report), so both orders of arrival take the same path.
|
||||
"""
|
||||
reported_at = datetime.fromtimestamp(report.at)
|
||||
|
||||
if report.remediates_txid:
|
||||
# Handled by the caller against the settlement the remediation names;
|
||||
# for the remediation's OWN txid there is nothing to capture.
|
||||
return settlement.status
|
||||
|
||||
if settlement.status not in _CAPTURABLE:
|
||||
logger.info(
|
||||
f"spirekeeper: report_dispense for settlement {settlement.id} in status "
|
||||
f"{settlement.status!r} — recorded, not moved (txid={report.txid})"
|
||||
)
|
||||
return settlement.status
|
||||
|
||||
new_status = _outcome_status(report)
|
||||
updated = await apply_dispense_outcome(
|
||||
settlement.id, report, new_status, reported_at
|
||||
)
|
||||
status = updated.status if updated else new_status
|
||||
|
||||
if new_status == "pending":
|
||||
_spawn_distribution(settlement.id)
|
||||
logger.info(
|
||||
f"spirekeeper: dispense CONFIRMED for settlement {settlement.id} "
|
||||
f"(machine={machine.id}, txid={report.txid}, "
|
||||
f"{report.fiat_cents / 100:.2f} {report.currency}) — distributing"
|
||||
)
|
||||
elif new_status == "partial_pending":
|
||||
await _alert_operator(updated or settlement, machine, report, new_status)
|
||||
logger.warning(
|
||||
f"spirekeeper: PARTIAL dispense for settlement {settlement.id} "
|
||||
f"(machine={machine.id}, txid={report.txid}): "
|
||||
f"{report.dispensed_fiat_cents / 100:.2f} of "
|
||||
f"{report.fiat_cents / 100:.2f} "
|
||||
f"{report.currency} left the machine; {report.error_code or 'no error'} "
|
||||
f"{report.raw_code or ''}. Held until the operator resolves the shortfall."
|
||||
)
|
||||
else:
|
||||
await _alert_operator(updated or settlement, machine, report, new_status)
|
||||
logger.error(
|
||||
f"spirekeeper: CASH OWED — settlement {settlement.id} "
|
||||
f"(machine={machine.id}, txid={report.txid}): customer paid "
|
||||
f"{report.fiat_cents / 100:.2f} {report.currency}, nothing dispensed; "
|
||||
f"{report.error_code or 'no error'} {report.raw_code or ''}: "
|
||||
f"{report.error or ''}"
|
||||
)
|
||||
return status
|
||||
|
||||
|
||||
async def _apply_remediation(
|
||||
machine: Machine, report: DispenseReportIn
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""A manual dispense closed out an earlier failed txid. Returns
|
||||
(settlement_id, status) of the remediated settlement, or (None, None)."""
|
||||
assert report.remediates_txid
|
||||
target = await get_settlement_by_txid(machine.id, report.remediates_txid)
|
||||
if target is None:
|
||||
logger.warning(
|
||||
f"spirekeeper: remediation report {report.txid} names txid "
|
||||
f"{report.remediates_txid} with no settlement on this server"
|
||||
)
|
||||
return None, None
|
||||
if target.status not in _OWED:
|
||||
logger.info(
|
||||
f"spirekeeper: remediation report {report.txid} for settlement "
|
||||
f"{target.id} in status {target.status!r} — recorded, not moved"
|
||||
)
|
||||
return target.id, target.status
|
||||
if not report.dispense_confirmed:
|
||||
logger.warning(
|
||||
f"spirekeeper: remediation report {report.txid} for settlement "
|
||||
f"{target.id} did not itself confirm — settlement stays {target.status}"
|
||||
)
|
||||
return target.id, target.status
|
||||
# The customer is whole: the sale is the full amount. Keep the ORIGINAL
|
||||
# report's columns on the settlement (that is what happened at the sale);
|
||||
# the remediation is its own dispense_reports row.
|
||||
from .crud import mark_settlement_status
|
||||
|
||||
await mark_settlement_status(target.id, "pending", None)
|
||||
_spawn_distribution(target.id)
|
||||
logger.info(
|
||||
f"spirekeeper: settlement {target.id} remediated by manual dispense "
|
||||
f"{report.txid} — distributing in full"
|
||||
)
|
||||
return target.id, "pending"
|
||||
|
||||
|
||||
async def handle_report_dispense(auth, request) -> dict:
|
||||
"""nostr-transport RPC handler. `auth` is the roster-resolved auth context
|
||||
(unused — the machine is identified from the signature); `request` is a
|
||||
NostrRpcRequest with `body` and `sender_pubkey` (verified).
|
||||
|
||||
Returns `{txid, received, settlement_status}`; raises ValueError (→ transport
|
||||
ERROR reply) for an unpaired sender or a malformed body. The machine treats
|
||||
anything but OK as "resend later", so a malformed report is retried — which
|
||||
is right: the bug is on one side or the other and the row must not be lost.
|
||||
"""
|
||||
sender = (request.sender_pubkey or "").lower()
|
||||
if not sender:
|
||||
raise ValueError("missing verified sender_pubkey")
|
||||
machine = await get_machine_by_atm_pubkey_hex(sender)
|
||||
if machine is None:
|
||||
raise ValueError("sender pubkey is not a paired machine")
|
||||
|
||||
try:
|
||||
report = DispenseReportIn(**(request.body or {}))
|
||||
except Exception as exc: # pydantic ValidationError, TypeError
|
||||
raise ValueError(f"invalid report_dispense body: {exc}") from exc
|
||||
|
||||
# Idempotency: the machine resends until acked.
|
||||
existing = await get_dispense_report(machine.id, report.txid, report.at)
|
||||
if existing is not None:
|
||||
settlement = (
|
||||
await get_settlement_by_txid(machine.id, report.txid)
|
||||
if existing.settlement_id
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"txid": report.txid,
|
||||
"received": True,
|
||||
"settlement_status": settlement.status if settlement else None,
|
||||
"duplicate": True,
|
||||
}
|
||||
|
||||
if report.counts_uncertain:
|
||||
# The state document carries this too; mirroring it here means the
|
||||
# dashboard learns at report time rather than at the next heartbeat.
|
||||
await set_machine_counts_uncertain(machine.id, datetime.now())
|
||||
|
||||
settlement = await get_settlement_by_txid(machine.id, report.txid)
|
||||
stored: DispenseReport = await insert_dispense_report(
|
||||
machine.id, settlement.id if settlement else None, report
|
||||
)
|
||||
|
||||
status: str | None
|
||||
if report.remediates_txid:
|
||||
_, status = await _apply_remediation(machine, report)
|
||||
elif settlement is None:
|
||||
# Payment not landed yet (or never will). Kept unlinked;
|
||||
# _handle_payment adopts it when the settlement is inserted.
|
||||
logger.warning(
|
||||
f"spirekeeper: report_dispense {report.txid} from machine {machine.id} "
|
||||
f"has no settlement yet (confirmed={report.dispense_confirmed}) — "
|
||||
f"stored unlinked, will attach when the payment lands"
|
||||
)
|
||||
status = None
|
||||
else:
|
||||
status = await apply_report_to_settlement(settlement, report, machine)
|
||||
|
||||
logger.info(
|
||||
f"spirekeeper: report_dispense stored id={stored.id} machine={machine.id} "
|
||||
f"txid={report.txid} confirmed={report.dispense_confirmed} → {status}"
|
||||
)
|
||||
return {"txid": report.txid, "received": True, "settlement_status": status}
|
||||
|
||||
|
||||
async def adopt_unlinked_report(
|
||||
settlement: DcaSettlement, machine: Machine, report_row: DispenseReport
|
||||
) -> str:
|
||||
"""`_handle_payment` found a report that arrived before the payment: link
|
||||
it and apply the same transition the live handler would have."""
|
||||
import json as _json
|
||||
|
||||
await link_dispense_reports_to_settlement(
|
||||
machine.id, report_row.txid, settlement.id
|
||||
)
|
||||
report = DispenseReportIn(
|
||||
txid=report_row.txid,
|
||||
payment_hash=report_row.payment_hash,
|
||||
dispense_confirmed=report_row.dispense_confirmed,
|
||||
error=report_row.error,
|
||||
error_code=report_row.error_code,
|
||||
raw_code=report_row.raw_code,
|
||||
error_class=report_row.error_class,
|
||||
fiat_cents=report_row.fiat_cents,
|
||||
currency=report_row.currency,
|
||||
bills=_json.loads(report_row.bills_json or "[]"),
|
||||
cassettes=_json.loads(report_row.cassettes_json or "[]"),
|
||||
counts_uncertain=report_row.counts_uncertain,
|
||||
remediates_txid=report_row.remediates_txid,
|
||||
at=int(report_row.reported_at.timestamp()),
|
||||
)
|
||||
logger.info(
|
||||
f"spirekeeper: adopting early dispense report {report_row.id} for "
|
||||
f"settlement {settlement.id} (report preceded the payment)"
|
||||
)
|
||||
return await apply_report_to_settlement(settlement, report, machine)
|
||||
|
||||
|
||||
def register_dispense_report_rpc() -> None:
|
||||
"""Register `report_dispense` with the lnbits nostr transport. Soft-fails
|
||||
if the transport doesn't expose `register_rpc` (older lnbits) — then
|
||||
cash-out settlements wait in awaiting_dispense and surface on the
|
||||
worklist as dispense_unreported, which is the honest state."""
|
||||
try:
|
||||
from lnbits.core.services.nostr_transport.dispatcher import ( # type: ignore
|
||||
AUTH_ACCOUNT,
|
||||
register_rpc,
|
||||
)
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"spirekeeper: nostr-transport register_rpc unavailable; "
|
||||
"'report_dispense' not registered (ADR-005 capture disabled — "
|
||||
"cash-out settlements will sit in awaiting_dispense)"
|
||||
)
|
||||
return
|
||||
register_rpc(_RPC_NAME, handle_report_dispense, AUTH_ACCOUNT)
|
||||
logger.info("spirekeeper: registered nostr-transport RPC 'report_dispense'")
|
||||
|
|
@ -359,31 +359,6 @@ async def apply_partial_dispense_and_redistribute(
|
|||
return after if after is not None else updated
|
||||
|
||||
|
||||
async def settle_cash_owed_settlement(
|
||||
settlement: DcaSettlement, note: str, author_user_id: str
|
||||
) -> DcaSettlement:
|
||||
"""ADR-005 §6 — operator paid the customer by hand. The sale is whole, so
|
||||
the settlement distributes at the full amount it landed with. The note is
|
||||
the provenance; `remediated` on the machine side comes from the
|
||||
settle_transaction op the endpoint publishes."""
|
||||
from .crud import append_settlement_note
|
||||
|
||||
if settlement.status not in ("cash_owed", "partial_pending"):
|
||||
raise ValueError(f"settlement {settlement.id} is {settlement.status!r}")
|
||||
await append_settlement_note(
|
||||
settlement.id, f"Settled off-machine (full amount): {note}", author_user_id
|
||||
)
|
||||
await mark_settlement_status(settlement.id, "pending", None)
|
||||
logger.info(
|
||||
f"distribution: settlement {settlement.id} settled off-machine by "
|
||||
f"{author_user_id[:8]}… — distributing in full"
|
||||
)
|
||||
await process_settlement(settlement.id)
|
||||
after = await get_settlement(settlement.id)
|
||||
assert after is not None
|
||||
return after
|
||||
|
||||
|
||||
async def process_settlement(settlement_id: str) -> None:
|
||||
"""Process a pending settlement end-to-end.
|
||||
|
||||
|
|
|
|||
186
migrations.py
186
migrations.py
|
|
@ -860,189 +860,3 @@ async def m012_add_max_cash_in_sats(db):
|
|||
await db.execute(
|
||||
"ALTER TABLE spirekeeper.super_config ADD COLUMN max_cash_in_sats INTEGER"
|
||||
)
|
||||
|
||||
|
||||
async def m013_add_cassette_ops(db):
|
||||
"""Cassette operations — the operator→ATM v2 wire (aiolabs/bitspire ADR-004).
|
||||
|
||||
Until now the operator published absolute counts and the ATM applied them
|
||||
outright. Both sides wrote the same value over a transport that never tells
|
||||
a writer it lost, so a dashboard form loaded before a dispense would
|
||||
silently discard that dispense when published. The fix is to stop the
|
||||
operator writing counts at all: it publishes OPERATIONS and the machine,
|
||||
which holds the physical notes, owns the running count.
|
||||
|
||||
Each row here is one operator intent — a refill, an empty, a recount, a
|
||||
denomination change. `id` is minted here and is the idempotency key the ATM
|
||||
dedups on, because a delta applied twice is wrong and addressable events
|
||||
are re-delivered on reconnect. `acked_at` is set when the machine reports
|
||||
the id back in its state document, which is the only acknowledgement this
|
||||
transport can carry.
|
||||
|
||||
Kept append-only on purpose: the published window is a slice of this table,
|
||||
and an operation the machine has not yet acknowledged must stay publishable.
|
||||
"""
|
||||
await db.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS spirekeeper.cassette_ops (
|
||||
id TEXT PRIMARY KEY,
|
||||
machine_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
op_type TEXT NOT NULL,
|
||||
bills INTEGER,
|
||||
count INTEGER,
|
||||
denomination INTEGER,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||
created_by TEXT,
|
||||
acked_at TIMESTAMP
|
||||
);
|
||||
""")
|
||||
# The publisher reads the most recent N for a machine on every publish.
|
||||
await db.execute(
|
||||
"CREATE INDEX IF NOT EXISTS cassette_ops_machine_idx "
|
||||
"ON cassette_ops (machine_id, created_at DESC)"
|
||||
)
|
||||
|
||||
|
||||
async def m014_add_counts_uncertain_since(db):
|
||||
"""Surface the machine's "I don't know what left the bay" marker.
|
||||
|
||||
A dispenser can throw, or time out, after notes have physically moved. The
|
||||
machine cannot know how many left, so rather than decrement a number it
|
||||
would be guessing at, it stamps the moment and reports it (bitspire
|
||||
ADR-004, decision 3). It has been publishing this field since v1 of the
|
||||
state document and the operator has been discarding it, which defeats the
|
||||
point: the marker exists to tell a human to open the bay and recount.
|
||||
|
||||
Stored on the machine, not the bay, because the uncertainty is about the
|
||||
dispense as a whole — a multi-bay dispense that fails midway leaves no
|
||||
reliable way to attribute it to one position. Cleared to NULL by the
|
||||
machine's own report once it is confident again.
|
||||
"""
|
||||
await db.execute(
|
||||
"ALTER TABLE spirekeeper.dca_machines "
|
||||
"ADD COLUMN counts_uncertain_since TIMESTAMP"
|
||||
)
|
||||
|
||||
|
||||
async def m015_add_cassette_state_seq(db):
|
||||
"""Break the same-second tie in the state-event ordering gate.
|
||||
|
||||
The gate compares `created_at`, which NIP-01 defines at one-second
|
||||
granularity — so two reports from the same second are indistinguishable to
|
||||
it, and the later one is dropped. A dispense and the publish that follows
|
||||
it land inside one second routinely.
|
||||
|
||||
The machine bumps `seq` on every local change to a bay count, whatever
|
||||
caused it, and carries it in the state document. Stored per row alongside
|
||||
`state_at` and consulted ONLY when the stamps are equal, so a machine whose
|
||||
state.db was replaced — seq back to zero, wall clock still moving forward —
|
||||
is not locked out by its own counter.
|
||||
"""
|
||||
await db.execute(
|
||||
"ALTER TABLE spirekeeper.cassette_configs ADD COLUMN state_seq INTEGER"
|
||||
)
|
||||
|
||||
|
||||
async def m016_dispense_outcome(db):
|
||||
"""The dispense outcome becomes a first-class fact (bitspire ADR-005 §2).
|
||||
|
||||
Until now a cash-out settlement was captured the instant the payment
|
||||
landed: `_handle_payment` spawned distribution in the same breath, so by
|
||||
the time a dispenser jammed two seconds later the legs were already paid
|
||||
and the dashboard honestly reported `processed`. The machine now reports
|
||||
every cash-out's outcome over a `report_dispense` RPC and the settlement
|
||||
waits for it (`awaiting_dispense`) before anything moves.
|
||||
|
||||
`dispense_reports` is append-only, one row per report the machine sent —
|
||||
lamassu-server's `cash_out_actions` shape — so a retry, a late report and
|
||||
a remediation report are all visible as distinct rows. `settlement_id` is
|
||||
NULL for a report whose payment this server never saw.
|
||||
|
||||
The settlement carries the three lamassu fields (dispense_confirmed,
|
||||
error, error_code) plus raw_code / error_class and the fiat value that
|
||||
actually left the machine, so the partial-dispense dialog can be
|
||||
pre-filled with the hardware's own number instead of a typed one.
|
||||
|
||||
The machine's cash-out hold is mirrored onto its registry row beside
|
||||
counts_uncertain_since: a latched machine refuses cash-out until an
|
||||
operator recounts or publishes `resume_cash_out`, and the dashboard needs
|
||||
to show that and offer the button.
|
||||
"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS spirekeeper.dispense_reports (
|
||||
id TEXT PRIMARY KEY,
|
||||
machine_id TEXT NOT NULL,
|
||||
settlement_id TEXT,
|
||||
txid TEXT NOT NULL,
|
||||
payment_hash TEXT,
|
||||
dispense_confirmed BOOLEAN NOT NULL,
|
||||
error TEXT,
|
||||
error_code TEXT,
|
||||
raw_code TEXT,
|
||||
error_class TEXT,
|
||||
fiat_cents INTEGER NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
bills_json TEXT NOT NULL,
|
||||
cassettes_json TEXT NOT NULL,
|
||||
counts_uncertain BOOLEAN NOT NULL DEFAULT false,
|
||||
remediates_txid TEXT,
|
||||
reported_at TIMESTAMP NOT NULL,
|
||||
received_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
"CREATE INDEX IF NOT EXISTS dispense_reports_txid_idx "
|
||||
"ON dispense_reports (machine_id, txid)"
|
||||
)
|
||||
await db.execute(
|
||||
"CREATE INDEX IF NOT EXISTS dispense_reports_settlement_idx "
|
||||
"ON dispense_reports (settlement_id)"
|
||||
)
|
||||
for col, typ in (
|
||||
("dispense_confirmed", "BOOLEAN"),
|
||||
("dispense_error", "TEXT"),
|
||||
("dispense_error_code", "TEXT"),
|
||||
("dispense_raw_code", "TEXT"),
|
||||
("dispense_error_class", "TEXT"),
|
||||
("dispense_reported_at", "TIMESTAMP"),
|
||||
("dispensed_fiat_cents", "INTEGER"),
|
||||
):
|
||||
await db.execute(
|
||||
f"ALTER TABLE spirekeeper.dca_settlements ADD COLUMN {col} {typ}"
|
||||
)
|
||||
for col, typ in (
|
||||
("cash_out_held_since", "TIMESTAMP"),
|
||||
("cash_out_held_reason", "TEXT"),
|
||||
("cash_out_held_code", "TEXT"),
|
||||
):
|
||||
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")
|
||||
|
|
|
|||
472
models.py
472
models.py
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, root_validator, validator
|
||||
from pydantic import BaseModel, validator
|
||||
|
||||
# =============================================================================
|
||||
# Machines — one row per bitSpire ATM, owned by exactly one operator.
|
||||
|
|
@ -63,17 +63,6 @@ class Machine(BaseModel):
|
|||
# NIP-46 bunker pairing (S0 / #9). NULL until the spire is first paired.
|
||||
bunker_spire_key_name: str | None = None
|
||||
paired_at: datetime | None = None
|
||||
# Set when the machine reports that a dispense ended without a reliable
|
||||
# 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
|
||||
|
||||
|
|
@ -318,42 +307,19 @@ class DcaSettlement(BaseModel):
|
|||
fee_mismatch_sats: int | None = None
|
||||
bills_json: str | None
|
||||
cassettes_json: str | None
|
||||
# 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)
|
||||
# 'pending' (default at insert)
|
||||
# 'processing' (claim taken by distribution processor)
|
||||
# 'processed' (all legs paid)
|
||||
# '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)
|
||||
# 'partial' (operator marked partial-dispense after the fact)
|
||||
# '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
|
||||
# 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
|
||||
|
|
@ -366,110 +332,6 @@ 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.
|
||||
# =============================================================================
|
||||
|
|
@ -626,9 +488,6 @@ 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
|
||||
|
||||
|
||||
|
|
@ -637,23 +496,6 @@ 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",
|
||||
|
|
@ -724,40 +566,12 @@ 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
|
||||
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.
|
||||
|
||||
|
|
@ -859,9 +673,31 @@ class CassetteConfig(BaseModel):
|
|||
state_count: int | None
|
||||
state_at: datetime | None
|
||||
state_event_id: str | None
|
||||
# The machine's own counter, bumped on every local count change. Breaks a
|
||||
# same-second tie in the ordering gate, where created_at cannot.
|
||||
state_seq: int | None = None
|
||||
|
||||
|
||||
class UpsertCassetteConfigData(BaseModel):
|
||||
"""Operator edits a single cassette row's denomination or count from
|
||||
the dashboard. Both fields optional; pass only those changed.
|
||||
Position is not edited — it's the row's identity (hardware bay)."""
|
||||
|
||||
denomination: int | None = None
|
||||
count: int | None = None
|
||||
|
||||
@validator("denomination")
|
||||
def denomination_positive(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if v <= 0:
|
||||
raise ValueError("denomination must be > 0")
|
||||
return v
|
||||
|
||||
@validator("count")
|
||||
def count_non_negative(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if v < 0:
|
||||
raise ValueError("count must be >= 0")
|
||||
return v
|
||||
|
||||
|
||||
class CassettePayloadRow(BaseModel):
|
||||
|
|
@ -885,46 +721,22 @@ class CassettePayloadRow(BaseModel):
|
|||
|
||||
|
||||
class PublishCassettesPayload(BaseModel):
|
||||
"""The decrypted content of the ATM → operator state document
|
||||
(d-tag `bitspire-cassettes-state:<atm_pubkey_hex>`).
|
||||
"""The decrypted JSON content of a kind-30078 cassette event, both
|
||||
directions:
|
||||
- operator → ATM (d-tag `bitspire-cassettes:<atm_pubkey_hex>`)
|
||||
- ATM → operator (d-tag `bitspire-cassettes-state:<atm_pubkey_hex>`)
|
||||
|
||||
It carried the operator → ATM direction too until v2 moved that to
|
||||
PublishCassetteOpsPayload. This is now the machine reporting what it
|
||||
holds, and the machine is the only writer of those counts.
|
||||
|
||||
Wire shape: `{"positions": {"<pos_str>": {"denomination", "count"}}}`
|
||||
plus the optional fields below. JSON object keys are always strings; the
|
||||
validator coerces back to int on parse.
|
||||
Wire shape: `{"positions": {"<pos_str>": {"denomination", "count"}}}`.
|
||||
JSON object keys are always strings; the validator coerces back to
|
||||
int on parse. The position key set MUST match what the receiver
|
||||
already has (slot count is hardware-fixed; no add/remove from this
|
||||
payload).
|
||||
|
||||
No denomination-unique constraint: multiple same-denom cassettes are
|
||||
operationally valid (cash-out throughput on a popular denom).
|
||||
|
||||
The optional fields are all absent on older machines, so every one of them
|
||||
defaults to a value meaning "this machine does not report that yet" rather
|
||||
than to a value that would be wrong:
|
||||
|
||||
- `applied_ops`: operation ids the machine has applied. This is the
|
||||
acknowledgement, and the only one an addressable event can carry — a
|
||||
relay returns OK for an event it then discards, so the publisher is
|
||||
never told anything. An empty list reads as "nothing acknowledged",
|
||||
which is correct for a machine that has not yet applied any.
|
||||
- `seq`: the machine's own monotonic counter, bumped on every local count
|
||||
change. Regression detection independent of created_at, which is only
|
||||
second-granular and can be forced by a bad clock.
|
||||
- `counts_uncertain_since`: set when a dispense ended without the
|
||||
dispenser reporting what it moved, so the counts above are the
|
||||
machine's best guess rather than a measurement.
|
||||
"""
|
||||
|
||||
positions: dict[int, CassettePayloadRow]
|
||||
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):
|
||||
|
|
@ -956,216 +768,6 @@ class PublishCassettesPayload(BaseModel):
|
|||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Cassette operations — operator → ATM v2 (bitspire ADR-004)
|
||||
# =============================================================================
|
||||
# The operator no longer publishes counts. It publishes what it DID, and the
|
||||
# machine — which holds the notes — keeps the running total. A value with one
|
||||
# writer cannot be clobbered, which is the point: the old absolute-count wire
|
||||
# let a form loaded before a dispense discard that dispense when published, and
|
||||
# nothing in an addressable event can tell the loser it lost.
|
||||
#
|
||||
# Wire shape (kind-30078 content, NIP-44 v2 encrypted, schema_version 2):
|
||||
# {
|
||||
# "schema_version": 2,
|
||||
# "ops": [
|
||||
# {"id": "<uuid>", "at": 1790106060, "type": "refill",
|
||||
# "position": 2, "bills": 100},
|
||||
# {"id": "<uuid>", "at": 1790106061, "type": "empty", "position": 3},
|
||||
# {"id": "<uuid>", "at": 1790106062, "type": "recount",
|
||||
# "position": 1, "count": 37},
|
||||
# {"id": "<uuid>", "at": 1790106063, "type": "set_denomination",
|
||||
# "position": 1, "denomination": 50}
|
||||
# ]
|
||||
# }
|
||||
#
|
||||
# `ops` is a WINDOW of recent operations, not just the newest. An event the
|
||||
# machine missed is carried again by the next one, so the channel heals itself
|
||||
# without the operator noticing. `id` is the idempotency key: deltas are not
|
||||
# idempotent and addressable events are re-delivered on reconnect, so the
|
||||
# machine records what it applied and ignores repeats.
|
||||
#
|
||||
# The vocabulary mirrors lamassu-server's cash_unit_operation_type
|
||||
# (refill / empty / count-change), which is where the ancestor of this fleet
|
||||
# landed after the same problem.
|
||||
|
||||
|
||||
CASSETTE_OP_TYPES = (
|
||||
"refill",
|
||||
"empty",
|
||||
"recount",
|
||||
"set_denomination",
|
||||
"resume_cash_out",
|
||||
"settle_transaction",
|
||||
)
|
||||
|
||||
# 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", "settle_transaction")
|
||||
|
||||
|
||||
class CassetteOp(BaseModel):
|
||||
"""One operator intent against one bay, as stored and as published.
|
||||
|
||||
Exactly one of bills/count/denomination is meaningful, decided by op_type:
|
||||
- refill → bills, the number of notes ADDED (a delta)
|
||||
- empty → none; the bay was emptied
|
||||
- recount → count, an absolute the operator physically counted
|
||||
- set_denomination → denomination, what is now loaded in that bay
|
||||
|
||||
`recount` is the only absolute, and deliberately so: it is what an operator
|
||||
opening a bay and counting actually does, and it is auditable as a distinct
|
||||
act rather than being indistinguishable from a stale form.
|
||||
"""
|
||||
|
||||
id: str
|
||||
machine_id: str
|
||||
position: int
|
||||
op_type: str
|
||||
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
|
||||
|
||||
@validator("op_type")
|
||||
def _known_op_type(cls, v):
|
||||
if v not in CASSETTE_OP_TYPES:
|
||||
raise ValueError(f"op_type must be one of {CASSETTE_OP_TYPES}, got {v!r}")
|
||||
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
|
||||
the machine never has to guess which of three nullable columns applies."""
|
||||
out: dict = {
|
||||
"id": self.id,
|
||||
"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
|
||||
if self.op_type == "refill":
|
||||
out["bills"] = self.bills
|
||||
elif self.op_type == "recount":
|
||||
out["count"] = self.count
|
||||
elif self.op_type == "set_denomination":
|
||||
out["denomination"] = self.denomination
|
||||
return out
|
||||
|
||||
|
||||
class CreateCassetteOpData(BaseModel):
|
||||
"""Operator submits one operation from the dashboard.
|
||||
|
||||
Validated per type here rather than at the endpoint so an instance is
|
||||
always publishable, matching FeeConfigPayload's contract.
|
||||
"""
|
||||
|
||||
position: int
|
||||
op_type: str
|
||||
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):
|
||||
if v not in CASSETTE_OP_TYPES:
|
||||
raise ValueError(f"op_type must be one of {CASSETTE_OP_TYPES}, got {v!r}")
|
||||
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):
|
||||
if v is not None and v <= 0:
|
||||
raise ValueError("bills must be > 0 (a refill adds notes)")
|
||||
return v
|
||||
|
||||
@validator("count")
|
||||
def _count_non_negative(cls, v):
|
||||
if v is not None and v < 0:
|
||||
raise ValueError("count must be >= 0")
|
||||
return v
|
||||
|
||||
@validator("denomination")
|
||||
def _denomination_positive(cls, v):
|
||||
if v is not None and v <= 0:
|
||||
raise ValueError("denomination must be > 0")
|
||||
return v
|
||||
|
||||
@root_validator(skip_on_failure=True)
|
||||
def _field_matches_type(cls, values):
|
||||
required = {
|
||||
"refill": "bills",
|
||||
"recount": "count",
|
||||
"set_denomination": "denomination",
|
||||
"empty": None,
|
||||
"resume_cash_out": None,
|
||||
"settle_transaction": "txid",
|
||||
}[values.get("op_type")]
|
||||
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
|
||||
|
||||
|
||||
class PublishCassetteOpsPayload(BaseModel):
|
||||
"""The decrypted content of a v2 operator → ATM cassette event."""
|
||||
|
||||
schema_version: int = 2
|
||||
ops: list[CassetteOp]
|
||||
|
||||
def to_wire_dict(self) -> dict:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"ops": [op.to_wire_dict() for op in self.ops],
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fee-config Nostr payload — operator → ATM (aiolabs/satmachineadmin#39)
|
||||
# =============================================================================
|
||||
|
|
|
|||
202
notify.py
202
notify.py
|
|
@ -1,202 +0,0 @@
|
|||
"""
|
||||
Operator alerts over Nostr (bitspire ADR-005 §6 / Future directions).
|
||||
|
||||
When a cash-out settlement lands in `cash_owed` or `partial_pending` — a
|
||||
customer has paid and is owed cash — the operator is told, immediately, as a
|
||||
Nostr event addressed to their pubkey. Not email, not SMS (lamassu's
|
||||
`notifyOperator` channel). The pieces this reuses:
|
||||
|
||||
- the recipient is the operator's own LNbits-account pubkey (`account.pubkey`,
|
||||
which `get_machine_config` already refuses to run without), or
|
||||
`super_config.alerts_pubkey` when set — a phone identity, say;
|
||||
- the sender signs and NIP-44-encrypts through the operator's signer
|
||||
(bunker-backed where the operator has moved to one), so no key is at rest
|
||||
here. For the default recipient this is a note to self, which every NIP-46
|
||||
client renders as a DM thread with yourself.
|
||||
|
||||
Wire: NIP-17. A kind-14 rumor (unsigned) is sealed (kind 13, NIP-44 to the
|
||||
recipient, signed by the sender) and gift-wrapped (kind 1059, NIP-44 to the
|
||||
recipient, signed by a throwaway key, `p`-tagged to the recipient, stamped at
|
||||
a random moment in the last two days). The relay sees a throwaway pubkey and
|
||||
ciphertext; only the recipient can open it. NIP-04 is not used anywhere in
|
||||
this stack.
|
||||
|
||||
Best-effort by contract: a failed alert is logged and never fails the RPC
|
||||
that triggered it — the dispense report must still be acknowledged, and the
|
||||
worklist is the durable record. `operator_notified_at` is set only on a
|
||||
successful publish, so a report resend cannot re-alert.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from .crud import get_super_config, mark_settlement_notified
|
||||
from .models import DcaSettlement, DispenseReportIn, Machine
|
||||
from .nostr_publish import (
|
||||
NostrPublishError,
|
||||
nip44_encrypt_via_signer,
|
||||
publish_signed_event,
|
||||
resolve_operator_signer,
|
||||
sign_as_operator,
|
||||
)
|
||||
|
||||
KIND_DM = 14
|
||||
KIND_SEAL = 13
|
||||
KIND_GIFT_WRAP = 1059
|
||||
# NIP-17: randomise outer timestamps up to two days in the past so the wrap
|
||||
# does not reveal when the conversation happened.
|
||||
_MAX_BACKDATE_S = 2 * 24 * 60 * 60
|
||||
|
||||
|
||||
def _canonical_id(
|
||||
pubkey: str, created_at: int, kind: int, tags: list, content: str
|
||||
) -> str:
|
||||
from lnbits.utils.nostr import json_dumps
|
||||
|
||||
return hashlib.sha256(
|
||||
json_dumps([0, pubkey, created_at, kind, tags, content]).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _backdated_now() -> int:
|
||||
return int(time.time()) - random.randint(0, _MAX_BACKDATE_S) # noqa: S311
|
||||
|
||||
|
||||
def _wrap_with_ephemeral(plaintext: str, recipient: str, created_at: int) -> dict:
|
||||
"""Kind-1059 gift wrap: NIP-44-encrypt `plaintext` to `recipient` from a
|
||||
freshly generated key, sign with that key, and forget it."""
|
||||
import coincurve
|
||||
from lnbits.utils.nostr import generate_keypair, sign_event
|
||||
|
||||
from .nip44 import encrypt_for
|
||||
|
||||
priv_hex, pub_hex = generate_keypair()
|
||||
wrap = {
|
||||
"kind": KIND_GIFT_WRAP,
|
||||
"created_at": created_at,
|
||||
"tags": [["p", recipient]],
|
||||
"content": encrypt_for(plaintext, priv_hex, recipient),
|
||||
}
|
||||
return sign_event(wrap, pub_hex, coincurve.PrivateKey(bytes.fromhex(priv_hex)))
|
||||
|
||||
|
||||
async def build_gift_wrapped_dm(
|
||||
operator_user_id: str, recipient_pubkey_hex: str, text: str
|
||||
) -> dict:
|
||||
"""Build a NIP-17 gift-wrapped DM from the operator's identity to
|
||||
`recipient_pubkey_hex`. Returns the signed kind-1059 event, ready to publish.
|
||||
|
||||
Raises the typed NostrPublishError subclasses from nostr_publish on a hard
|
||||
failure (no pubkey on file, signer unavailable, …)."""
|
||||
account, signer = await resolve_operator_signer(operator_user_id)
|
||||
sender = account.pubkey
|
||||
recipient = recipient_pubkey_hex.lower()
|
||||
|
||||
# 1. rumor — unsigned kind 14, id computed so clients can thread it
|
||||
rumor_created = int(time.time())
|
||||
rumor_tags = [["p", recipient]]
|
||||
rumor = {
|
||||
"id": _canonical_id(sender, rumor_created, KIND_DM, rumor_tags, text),
|
||||
"pubkey": sender,
|
||||
"created_at": rumor_created,
|
||||
"kind": KIND_DM,
|
||||
"tags": rumor_tags,
|
||||
"content": text,
|
||||
}
|
||||
|
||||
# 2. seal — NIP-44 to the recipient, signed by the sender (the bunker
|
||||
# path never sees the operator's key here)
|
||||
sealed_content = await nip44_encrypt_via_signer(
|
||||
account, signer, json.dumps(rumor, separators=(",", ":")), recipient
|
||||
)
|
||||
seal = {"kind": KIND_SEAL, "tags": [], "content": sealed_content}
|
||||
seal = await sign_as_operator(operator_user_id, seal)
|
||||
|
||||
# 3. gift wrap — NIP-44 from a throwaway key to the recipient, p-tagged,
|
||||
# backdated, signed by that same throwaway key, which is then forgotten
|
||||
return _wrap_with_ephemeral(
|
||||
json.dumps(seal, separators=(",", ":")), recipient, _backdated_now()
|
||||
)
|
||||
|
||||
|
||||
async def resolve_alert_recipient(operator_user_id: str) -> str:
|
||||
"""`super_config.alerts_pubkey` when set, else the operator's own pubkey."""
|
||||
cfg = await get_super_config()
|
||||
override = (getattr(cfg, "alerts_pubkey", None) or "").strip() if cfg else ""
|
||||
if override:
|
||||
return override.lower()
|
||||
account, _signer = await resolve_operator_signer(operator_user_id)
|
||||
return account.pubkey
|
||||
|
||||
|
||||
def format_cash_outcome_alert(
|
||||
settlement: DcaSettlement, machine: Machine, report: DispenseReportIn, status: str
|
||||
) -> str:
|
||||
"""The message the operator reads on their phone. Plain text, the facts
|
||||
they need to act, no raw dispenser code (that is in the dashboard)."""
|
||||
name = machine.name or machine.id
|
||||
paid = f"{report.fiat_cents / 100:.2f} {report.currency}"
|
||||
out = f"{report.dispensed_fiat_cents / 100:.2f} {report.currency}"
|
||||
if status == "cash_owed":
|
||||
head = f"⚠️ CASH OWED — {name}"
|
||||
body = f"A customer paid {paid} and received nothing."
|
||||
else:
|
||||
head = f"⚠️ PARTIAL DISPENSE — {name}"
|
||||
body = f"A customer paid {paid} and received {out}."
|
||||
lines = [
|
||||
head,
|
||||
body,
|
||||
f"Cause: {report.error or 'no dispenser error reported'}"
|
||||
+ (f" ({report.error_code})" if report.error_code else ""),
|
||||
f"Transaction: {report.txid}",
|
||||
f"Settlement: {settlement.id}",
|
||||
"Nothing has been distributed. Open spirekeeper → Worklist to resolve "
|
||||
"(remediate at the machine, or settle off-machine).",
|
||||
]
|
||||
if report.error_class == "terminal":
|
||||
lines.append(
|
||||
"The machine has taken cash-out out of service until you recount "
|
||||
"or resume it."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def notify_cash_outcome(
|
||||
settlement: DcaSettlement,
|
||||
machine: Machine,
|
||||
report: DispenseReportIn,
|
||||
status: str,
|
||||
) -> bool:
|
||||
"""Alert the operator that `settlement` needs a human. Best-effort:
|
||||
returns True if the DM was published, False (after logging) otherwise.
|
||||
Never raises — the caller is the dispense-report RPC and must ack."""
|
||||
if getattr(settlement, "operator_notified_at", None):
|
||||
return False
|
||||
try:
|
||||
recipient = await resolve_alert_recipient(machine.operator_user_id)
|
||||
text = format_cash_outcome_alert(settlement, machine, report, status)
|
||||
wrapped = await build_gift_wrapped_dm(machine.operator_user_id, recipient, text)
|
||||
await publish_signed_event(wrapped)
|
||||
await mark_settlement_notified(settlement.id)
|
||||
logger.info(
|
||||
f"spirekeeper: operator alerted ({status}) for settlement {settlement.id} "
|
||||
f"→ {recipient[:12]}… (gift wrap {wrapped['id'][:12]}…)"
|
||||
)
|
||||
return True
|
||||
except NostrPublishError as exc:
|
||||
logger.warning(
|
||||
f"spirekeeper: could not alert operator for settlement {settlement.id}: {exc} "
|
||||
"— the worklist still has it"
|
||||
)
|
||||
except Exception as exc: # never let an alert break the RPC
|
||||
logger.error(
|
||||
f"spirekeeper: unexpected error alerting operator for settlement "
|
||||
f"{settlement.id}: {exc!r}"
|
||||
)
|
||||
return False
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>bitSpire — Dispenser error glossary</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body { font: 15px/1.5 system-ui, sans-serif; max-width: 52rem; margin: 2rem auto; padding: 0 1rem; }
|
||||
h1 { font-size: 1.6rem; } h2 { font-size: 1.2rem; margin-top: 2.2rem; border-bottom: 1px solid #8884; padding-bottom: .3rem; }
|
||||
code { font-family: ui-monospace, monospace; background: #8882; padding: .05em .35em; border-radius: 3px; }
|
||||
.cls { font-weight: 600; } .terminal { color: #c62828; } .recoverable { color: #ef6c00; } .inventory { color: #2e7d32; }
|
||||
dl { margin: .5rem 0 0; } dt { font-weight: 600; margin-top: .6rem; } dd { margin: .2rem 0 0 1.2rem; }
|
||||
.entry { margin-top: 1.4rem; padding: .6rem .9rem; border-left: 3px solid #8884; }
|
||||
.entry:target { border-left-color: #1976d2; background: #1976d20d; }
|
||||
small { color: #777; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Dispenser error glossary</h1>
|
||||
<p>Every dispense failure a machine reports carries an <strong>error code</strong> (the family — which driver), a <strong>raw code</strong> (what the hardware said), and a <strong>class</strong> that decides what the machine did next:</p>
|
||||
<dl>
|
||||
<dt><span class="cls terminal">terminal</span></dt>
|
||||
<dd>The transport path is compromised. The machine has <strong>taken cash-out out of service</strong> and will stay that way until you open it. Clear the path, then record a <em>Recount</em> (which also fixes the bay count) or press <em>Resume cash-out</em>. Re-initialising the dispenser does not move a stuck note, so a restart will not clear this.</dd>
|
||||
<dt><span class="cls recoverable">recoverable</span></dt>
|
||||
<dd>This bay or this note. The customer still sees a fault screen if they were short-changed, but the machine stays in service. Check the bay named in the report and the reject tray.</dd>
|
||||
<dt><span class="cls inventory">inventory</span></dt>
|
||||
<dd>Nothing was asked of the hardware — the request could not be met from the bays. Not a hardware fault. If this happens after payment, the customer is still owed (see the worklist).</dd>
|
||||
</dl>
|
||||
<p><strong>Whatever the class, a report that is not <code>dispense_confirmed</code> after a payment means a customer is owed money.</strong> The worklist shows it as <em>Cash owed</em> (nothing dispensed) or <em>Partial dispense</em> (some notes out). Nothing has been distributed; the funds are in the machine wallet. Resolve by dispensing the shortfall at the machine (the machine reports the remediation and the settlement completes) or by paying the customer by hand and recording it with <em>Settle off-machine</em>.</p>
|
||||
|
||||
<h2>Fujitsu F53 / F56 (error code <code>F56DispenseError</code>)</h2>
|
||||
<p><small>Codes are the two bytes the BDU returns after a failed bill count. Source: Fujitsu Frontech F56-BDU Error Code List (K3KD03234–K3KD03236-0001, ed. E02) and field observations. An unknown code is treated as terminal until it has been decoded.</small></p>
|
||||
|
||||
<div class="entry" id="78-42">
|
||||
<h3><code>78 42</code> — note stopped at the cassette exit <span class="cls terminal">terminal</span></h3>
|
||||
<p>A note left the bay and stopped in the transport just past the cassette. The counters report <em>0 dispensed, 0 rejected</em> because the note completed neither path — so the bay count is also one high until you recount.</p>
|
||||
<p><strong>What to do:</strong> open the unit, remove the note from the transport path, check the cassette is seated, then <em>Recount</em> the bay (this releases the cash-out hold and corrects the count). First seen: sintra, 2026-10-09 — a 20 EUR note, customer paid 40 EUR and received nothing.</p>
|
||||
</div>
|
||||
|
||||
<div class="entry" id="82-00">
|
||||
<h3><code>82 00</code> — bill length check failed (long) <span class="cls recoverable">recoverable</span></h3>
|
||||
<p>The BDU measured a note longer than the accept window configured for that denomination and rejected it. Repeated on every pick from one bay, it is almost always the <em>configured window</em>, not the notes: a GTQ Tejo rejected 5 of 5 on every pick in 2026-09 because the window was ±5 mm where the identical-size USD note has ±10.</p>
|
||||
<p><strong>What to do:</strong> empty the reject tray and look at the notes. Single notes → window too narrow; report it. Pairs → the separator is worn (offset double-pick) and the narrow window is doing its job.</p>
|
||||
</div>
|
||||
|
||||
<div class="entry" id="83-00">
|
||||
<h3><code>83 00</code> — bill length check failed (short) <span class="cls recoverable">recoverable</span></h3>
|
||||
<p>As above, measured short. Torn or folded notes, or the wrong denomination loaded in the bay.</p>
|
||||
</div>
|
||||
|
||||
<div class="entry" id="84-00">
|
||||
<h3><code>84 00</code> — bill thickness check failed <span class="cls recoverable">recoverable</span></h3>
|
||||
<p>Two notes stuck together, or a taped/damaged note. Check the reject tray.</p>
|
||||
</div>
|
||||
|
||||
<div class="entry" id="85">
|
||||
<h3><code>85 0n</code> — pick from another safe <span class="cls recoverable">recoverable</span></h3>
|
||||
<p>A note arrived from a bay other than the one commanded (<code>n</code> = which). Usually a cassette not fully latched.</p>
|
||||
</div>
|
||||
|
||||
<div class="entry" id="86-00">
|
||||
<h3><code>86 00</code> — bill spacing error <span class="cls recoverable">recoverable</span></h3>
|
||||
<p>Notes too close together on the transport. Often follows a worn feed roller.</p>
|
||||
</div>
|
||||
|
||||
<div class="entry" id="b5">
|
||||
<h3><code>B5 ..</code> — reject box overflow <span class="cls terminal">terminal</span></h3>
|
||||
<p>The reject tray is full; nothing more can be rejected, so nothing more can be dispensed safely.</p>
|
||||
<p><strong>What to do:</strong> empty the reject tray, count what is in it (those notes left a bay), <em>Recount</em>.</p>
|
||||
</div>
|
||||
|
||||
<div class="entry" id="f56dispenseerror">
|
||||
<h3>Unrecognised code <span class="cls terminal">terminal</span></h3>
|
||||
<p>A code not yet in this table. The machine treats it as a jam — cash-out held — until someone decodes it. Please report the raw code with what you found inside the unit so it can be added.</p>
|
||||
</div>
|
||||
|
||||
<div class="entry" id="dispensetimeout">
|
||||
<h3><code>DispenseTimeout</code> — dispenser did not answer <span class="cls terminal">terminal</span></h3>
|
||||
<p>No frame came back within the dispense timeout (serial timeout, port closed, framing error). The transport state is unknown; the bay counts are flagged unverified.</p>
|
||||
</div>
|
||||
|
||||
<h2>Puloon LCDM (error code <code>PuloonDispenseError</code>)</h2>
|
||||
<p>No decode table yet; every Puloon fault is treated as <span class="cls terminal">terminal</span>. The raw code is whatever the driver returned — report it.</p>
|
||||
|
||||
<h2>Software-side codes</h2>
|
||||
<div class="entry" id="insufficientinventory">
|
||||
<h3><code>InsufficientInventory</code> / <code>NoCassetteForDenomination</code> <span class="cls inventory">inventory</span></h3>
|
||||
<p>The request could not be met from the bays as the machine believes them to be. After a payment this still leaves the customer owed; before one, the sale is simply refused. If the bays physically hold more than the machine thinks, <em>Recount</em>.</p>
|
||||
</div>
|
||||
<div class="entry" id="dispenseshort">
|
||||
<h3><code>DispenseShort</code> <span class="cls inventory">inventory</span></h3>
|
||||
<p>The dispenser returned fewer notes than requested and reported no error. The customer is owed the difference.</p>
|
||||
</div>
|
||||
|
||||
<p><small>Reference: bitspire <code>docs/adr/005-cash-out-dispense-outcome.md</code>, Decisions 3–7.</small></p>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>spirekeeper — Operator guide</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body { font: 15px/1.5 system-ui, sans-serif; max-width: 52rem; margin: 2rem auto; padding: 0 1rem; }
|
||||
h1 { font-size: 1.6rem; } h2 { font-size: 1.2rem; margin-top: 2.2rem; border-bottom: 1px solid #8884; padding-bottom: .3rem; }
|
||||
code { font-family: ui-monospace, monospace; background: #8882; padding: .05em .35em; border-radius: 3px; }
|
||||
table { border-collapse: collapse; margin: .6rem 0; } td, th { border: 1px solid #8884; padding: .3rem .6rem; text-align: left; vertical-align: top; }
|
||||
.warn { border-left: 3px solid #ef6c00; padding: .4rem .8rem; background: #ef6c000d; }
|
||||
small { color: #777; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>spirekeeper — operator guide</h1>
|
||||
<p>spirekeeper is the operator side of a bitSpire ATM: it pairs machines, publishes their fee and cassette configuration, receives every cash-out's outcome, and distributes each settlement. Everything between you and a machine travels over Nostr relays — there is no HTTP endpoint on the machine and none it needs on you.</p>
|
||||
|
||||
<h2>1. Setting up a machine</h2>
|
||||
<ol>
|
||||
<li><strong>Register</strong> it under <em>Machines</em> with a name, location and fiat currency, and pick the LNbits wallet that receives its payments.</li>
|
||||
<li><strong>Pair</strong> it: <em>Pair</em> mints a one-shot <code>spire-seed</code> QR. Show it to the machine's camera (an unpaired machine boots into the pairing wizard). Pairing gives the machine its signing identity through the bunker; no key is ever typed into the machine.</li>
|
||||
<li><strong>Fees</strong>: your per-machine cash-in / cash-out commission plus the platform fee are published to the machine automatically whenever either changes, and delivered again on every boot.</li>
|
||||
</ol>
|
||||
|
||||
<h2>2. Bays and cassettes — who decides what</h2>
|
||||
<p><strong>The machine owns its bay layout and its running counts.</strong> How many bays it has is hardware; which denomination sits in each and how many notes are there is something only the person at the machine knows. So:</p>
|
||||
<table>
|
||||
<tr><th>Fact</th><th>Where it comes from</th><th>How you change it</th></tr>
|
||||
<tr><td>Number of bays</td><td>The machine's installation (<code>VITE_BITSPIRE_CASSETTES</code> in its <code>.env</code> on first boot, then its own database). spirekeeper adopts whatever the machine reports and <em>deletes</em> bays it stops reporting.</td><td>Re-provision the machine. There is no dashboard control for bay count.</td></tr>
|
||||
<tr><td>Denomination per bay</td><td>You.</td><td><em>Set denomination</em> op.</td></tr>
|
||||
<tr><td>Count per bay</td><td>The machine's running total: refills add, dispenses subtract.</td><td>You publish <em>operations</em>, never counts: <em>Refill +N</em>, <em>Empty</em>, <em>Recount</em>. A recount is the only absolute — it is what you do when you open the bay and count it.</td></tr>
|
||||
</table>
|
||||
<p class="warn">Never set a count from a form you loaded before a dispense happened — that is exactly why counts are not editable. If the number on screen is wrong, open the bay, count, and record a <em>Recount</em>.</p>
|
||||
<p>Each operation carries an id the machine deduplicates on, and the machine echoes the ids it has applied back in its state report; an op shows as <em>acknowledged</em> only when the machine says so. The machine republishes its state on every change and on a heartbeat, so a dashboard that disagrees with a machine heals itself within minutes.</p>
|
||||
|
||||
<h2>3. What a cash-out looks like now</h2>
|
||||
<p>Payment is the <em>authorization</em>; the machine's dispense report is the <em>capture</em>. A customer's payment lands as <code>awaiting_dispense</code> and <strong>nothing is distributed</strong> until the machine reports what physically came out:</p>
|
||||
<table>
|
||||
<tr><th>Machine reports</th><th>Settlement</th><th>What happens</th></tr>
|
||||
<tr><td>Everything dispensed</td><td><code>processed</code></td><td>Distribution runs — platform fee, your splits, DCA.</td></tr>
|
||||
<tr><td>Some notes out, value short</td><td><code>partial_pending</code></td><td>Held whole until you record how the shortfall was resolved.</td></tr>
|
||||
<tr><td>Nothing out</td><td><code>cash_owed</code></td><td>Nothing moves. The customer is owed. You are alerted.</td></tr>
|
||||
<tr><td>No report within 30 min</td><td><code>dispense_unreported</code></td><td>The machine never said. Check it.</td></tr>
|
||||
</table>
|
||||
<p>Those last three are the first thing on the <em>Worklist</em>. Every one means a human is owed money or a machine needs eyes.</p>
|
||||
|
||||
<h2>4. When the machine takes itself out of service</h2>
|
||||
<p>A <em>terminal</em> dispenser fault — a jam, a motor or sensor error, a dispense that never answered — makes the machine refuse further cash-out and show the customer that it is temporarily unavailable. The machine's card in spirekeeper shows a red <strong>Cash-out is held</strong> banner with the code and the reason. It stays held until:</p>
|
||||
<ul>
|
||||
<li>you record a <em>Recount</em> on any bay (you opened the machine — that also fixes the count), or</li>
|
||||
<li>you press <em>Resume cash-out</em> on the banner (you cleared the jam without touching a count).</li>
|
||||
</ul>
|
||||
<p>Restarting the machine does not clear it: re-initialising a dispenser does not move a stuck note. See the <a href="errors.html">error glossary</a> for what each code means and what you will find inside.</p>
|
||||
|
||||
<h2>5. Resolving owed cash</h2>
|
||||
<p>Two ways, both audited, both close the machine's ledger as well as this one:</p>
|
||||
<ul>
|
||||
<li><strong>At the machine</strong> — dispense the shortfall through the machine's manual-dispense command against the original transaction. The machine reports the remediation and the settlement completes on its own.</li>
|
||||
<li><strong>Off-machine</strong> — you paid the customer by hand. Open the settlement (worklist or table) → <em>Settle off-machine</em> → write how (<em>"handed 40 EUR to customer, 2026-10-09"</em>). The settlement distributes at the full amount and the machine marks its own row remediated.</li>
|
||||
</ul>
|
||||
<p>For a <em>partial</em>, <em>Record the resolution</em> opens the partial-dispense dialog pre-filled from the machine's own count of what came out: confirm it if you are writing the shortfall off (the settlement distributes the scaled amount), or use one of the two routes above if you made the customer whole.</p>
|
||||
|
||||
<h2>6. Alerts</h2>
|
||||
<!-- # pragma: allowlist secret -->
|
||||
<p>When a settlement lands in <code>cash_owed</code> or <code>partial_pending</code> you receive a Nostr direct message (NIP-17) addressed to the pubkey on your LNbits account — a note to self, readable in any client that speaks NIP-46 (the aiolabs webapp, Amber, nsec.app). You do not need your private key for this. To receive alerts on a different identity, set <em>Alerts pubkey</em> in the platform settings.</p>
|
||||
|
||||
<h2>7. Reconciling a machine</h2>
|
||||
<p>On the machine, <code>atm-reconcile</code> re-derives each bay from its last recount plus refills minus dispenses and reports any gap. A bay that has never been recounted cannot be reconciled — its starting number is unrecorded. Recount every bay once after installation; after that, any gap is real.</p>
|
||||
|
||||
<p><small>Design record: bitspire <code>docs/adr/004-cassette-state-synchronization.md</code> and <code>docs/adr/005-cash-out-dispense-outcome.md</code>.</small></p>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -70,10 +70,6 @@ window.app = Vue.createApp({
|
|||
|
||||
// Worklist (P9g)
|
||||
worklist: {
|
||||
// ADR-005 §6 — owed-cash buckets first
|
||||
cash_owed: [],
|
||||
partial_pending: [],
|
||||
dispense_unreported: [],
|
||||
rejected: [],
|
||||
errored: [],
|
||||
stuck_pending: [],
|
||||
|
|
@ -108,8 +104,7 @@ window.app = Vue.createApp({
|
|||
super_cash_in_fee_fraction: 0,
|
||||
super_cash_out_fee_fraction: 0,
|
||||
super_fee_wallet_id: '',
|
||||
max_cash_in_sats: null,
|
||||
alerts_pubkey: ''
|
||||
max_cash_in_sats: null
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -217,14 +212,15 @@ window.app = Vue.createApp({
|
|||
loading: false,
|
||||
machine: null,
|
||||
settlements: [],
|
||||
// Cassettes sub-tab state (v2, bitspire ADR-004) — see
|
||||
// openCassetteOpDialog / submitCassetteOp + the cassettes panel in
|
||||
// templates/spirekeeper/index.html. Read-only by design: the
|
||||
// operator records operations, the machine owns the counts.
|
||||
// Cassettes sub-tab state (#29 v1) — see openCassettePublishConfirm /
|
||||
// submitCassettePublish methods + the cassettes panel in
|
||||
// templates/spirekeeper/index.html.
|
||||
activeTab: 'settlements',
|
||||
cassettes: [], // machine-reported rows, not editable
|
||||
cassetteOps: [], // recent operations, newest first
|
||||
cassetteEdits: [], // editable working copy of cassette_configs rows
|
||||
cassettesPristine: [], // last-known-clean snapshot for revert
|
||||
cassettesLoading: false,
|
||||
cassettesPublishing: false,
|
||||
cassettesDirty: false,
|
||||
cassettesError: null
|
||||
},
|
||||
cassettesTable: {
|
||||
|
|
@ -232,27 +228,14 @@ window.app = Vue.createApp({
|
|||
{name: 'position', label: 'Bay', field: 'position', align: 'right'},
|
||||
{name: 'denomination', label: 'Denomination', field: 'denomination', align: 'right'},
|
||||
{name: 'count', label: 'Count', field: 'count', align: 'right'},
|
||||
{name: 'state_at', label: 'Machine reported', field: 'state_at', align: 'left'},
|
||||
{name: 'actions', label: '', field: 'position', align: 'right'}
|
||||
{name: 'state', label: 'ATM-reported', field: 'state_denomination', align: 'right'},
|
||||
{name: 'updated_at', label: 'Updated', field: 'updated_at', align: 'left'}
|
||||
],
|
||||
pagination: {rowsPerPage: 0} // hide pagination — cassette count is small
|
||||
},
|
||||
cassetteOpDialog: {
|
||||
show: false,
|
||||
saving: false,
|
||||
error: null,
|
||||
position: null,
|
||||
op_type: 'refill',
|
||||
bills: null,
|
||||
count: null,
|
||||
denomination: null
|
||||
cassettePublishConfirm: {
|
||||
show: false
|
||||
},
|
||||
cassetteOpTypeOptions: [
|
||||
{label: 'Refill — notes added', value: 'refill'},
|
||||
{label: 'Empty — bay emptied', value: 'empty'},
|
||||
{label: 'Recount — notes counted', value: 'recount'},
|
||||
{label: 'Set denomination', value: 'set_denomination'}
|
||||
],
|
||||
partialDispenseDialog: {
|
||||
show: false,
|
||||
saving: false,
|
||||
|
|
@ -262,13 +245,6 @@ window.app = Vue.createApp({
|
|||
dispensed_sats: null,
|
||||
notes: ''
|
||||
},
|
||||
// ADR-005 §6 — settle an owed-cash settlement off-machine
|
||||
settleDialog: {
|
||||
show: false,
|
||||
settlement: null,
|
||||
note: '',
|
||||
saving: false
|
||||
},
|
||||
noteDialog: {
|
||||
show: false,
|
||||
saving: false,
|
||||
|
|
@ -310,28 +286,6 @@ window.app = Vue.createApp({
|
|||
},
|
||||
|
||||
computed: {
|
||||
cassetteBayOptions() {
|
||||
return this.machineDetail.cassettes.map(row => ({
|
||||
label: `Bay ${row.position} — ${row.denomination} ${
|
||||
(this.machineDetail.machine || {}).fiat_code || ''
|
||||
} ×${row.count}`,
|
||||
value: row.position
|
||||
}))
|
||||
},
|
||||
|
||||
cassetteOpIsComplete() {
|
||||
// Mirrors CreateCassetteOpData's root validator: exactly one value
|
||||
// field, decided by the type. Enforced here only to keep the button
|
||||
// honest — the server rejects a malformed op regardless.
|
||||
const d = this.cassetteOpDialog
|
||||
if (!d.position) return false
|
||||
if (d.op_type === 'empty') return true
|
||||
if (d.op_type === 'refill') return Number(d.bills) > 0
|
||||
if (d.op_type === 'recount') return d.count !== null && Number(d.count) >= 0
|
||||
if (d.op_type === 'set_denomination') return Number(d.denomination) > 0
|
||||
return false
|
||||
},
|
||||
|
||||
superAnyFee() {
|
||||
// Banner styling key — true when either directional super fee is
|
||||
// non-zero, so the banner reads as "active platform fee" instead
|
||||
|
|
@ -384,33 +338,6 @@ window.app = Vue.createApp({
|
|||
},
|
||||
worklistBuckets() {
|
||||
return [
|
||||
{
|
||||
key: 'cash_owed',
|
||||
label:
|
||||
'Cash owed — customer paid, machine dispensed nothing. ' +
|
||||
'Legs never ran; funds are in the machine wallet.',
|
||||
icon: 'money_off',
|
||||
color: 'negative',
|
||||
rows: this.worklist.cash_owed
|
||||
},
|
||||
{
|
||||
key: 'partial_pending',
|
||||
label:
|
||||
'Partial dispense — some notes out, value short. Held whole ' +
|
||||
'until you record how the shortfall was resolved.',
|
||||
icon: 'call_split',
|
||||
color: 'deep-orange',
|
||||
rows: this.worklist.partial_pending
|
||||
},
|
||||
{
|
||||
key: 'dispense_unreported',
|
||||
label:
|
||||
'Unreported — cash-out paid, machine never reported the dispense. ' +
|
||||
'Check the machine; an old build lands here too.',
|
||||
icon: 'help_outline',
|
||||
color: 'amber',
|
||||
rows: this.worklist.dispense_unreported
|
||||
},
|
||||
{
|
||||
key: 'rejected',
|
||||
label: 'Rejected — Nostr attribution failed; investigate machine',
|
||||
|
|
@ -599,9 +526,6 @@ window.app = Vue.createApp({
|
|||
try {
|
||||
const {data} = await LNbits.api.request('GET', STUCK_PATH)
|
||||
this.worklistCount =
|
||||
(data?.cash_owed?.length || 0) +
|
||||
(data?.partial_pending?.length || 0) +
|
||||
(data?.dispense_unreported?.length || 0) +
|
||||
(data?.rejected?.length || 0) +
|
||||
(data?.errored?.length || 0) +
|
||||
(data?.stuck_pending?.length || 0) +
|
||||
|
|
@ -617,17 +541,11 @@ window.app = Vue.createApp({
|
|||
const {data} = await LNbits.api.request(
|
||||
'GET', `${STUCK_PATH}?threshold_minutes=${this.worklistThreshold}`
|
||||
)
|
||||
this.worklist.cash_owed = data?.cash_owed || []
|
||||
this.worklist.partial_pending = data?.partial_pending || []
|
||||
this.worklist.dispense_unreported = data?.dispense_unreported || []
|
||||
this.worklist.rejected = data?.rejected || []
|
||||
this.worklist.errored = data?.errored || []
|
||||
this.worklist.stuck_pending = data?.stuck_pending || []
|
||||
this.worklist.stuck_processing = data?.stuck_processing || []
|
||||
this.worklist.totalCount =
|
||||
this.worklist.cash_owed.length +
|
||||
this.worklist.partial_pending.length +
|
||||
this.worklist.dispense_unreported.length +
|
||||
this.worklist.rejected.length +
|
||||
this.worklist.errored.length +
|
||||
this.worklist.stuck_pending.length +
|
||||
|
|
@ -667,8 +585,7 @@ window.app = Vue.createApp({
|
|||
super_cash_out_fee_fraction:
|
||||
this.superConfig?.super_cash_out_fee_fraction ?? 0,
|
||||
super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || '',
|
||||
max_cash_in_sats: this.superConfig?.max_cash_in_sats ?? null,
|
||||
alerts_pubkey: this.superConfig?.alerts_pubkey || ''
|
||||
max_cash_in_sats: this.superConfig?.max_cash_in_sats ?? null
|
||||
}
|
||||
this.superFeeDialog.show = true
|
||||
},
|
||||
|
|
@ -1026,8 +943,9 @@ window.app = Vue.createApp({
|
|||
async viewMachine(machine) {
|
||||
this.machineDetail.machine = machine
|
||||
this.machineDetail.settlements = []
|
||||
this.machineDetail.cassettes = []
|
||||
this.machineDetail.cassetteOps = []
|
||||
this.machineDetail.cassetteEdits = []
|
||||
this.machineDetail.cassettesPristine = []
|
||||
this.machineDetail.cassettesDirty = false
|
||||
this.machineDetail.cassettesError = null
|
||||
this.machineDetail.activeTab = 'settlements'
|
||||
this.machineDetail.show = true
|
||||
|
|
@ -1054,25 +972,21 @@ window.app = Vue.createApp({
|
|||
},
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Cassette inventory + operations (v2, bitspire ADR-004)
|
||||
// Cassette inventory (#29 v1)
|
||||
// -----------------------------------------------------------------
|
||||
async loadMachineCassettes() {
|
||||
if (!this.machineDetail.machine) return
|
||||
this.machineDetail.cassettesLoading = true
|
||||
this.machineDetail.cassettesError = null
|
||||
const base = `${MACHINES_PATH}/${this.machineDetail.machine.id}`
|
||||
try {
|
||||
// The machine row is re-read too: counts_uncertain_since lives on it
|
||||
// and is set by the consumer as state events land, so the row the
|
||||
// machines table handed us goes stale while this dialog is open.
|
||||
const [machine, bays, ops] = await Promise.all([
|
||||
LNbits.api.request('GET', base),
|
||||
LNbits.api.request('GET', `${base}/cassettes`),
|
||||
LNbits.api.request('GET', `${base}/cassettes/ops`)
|
||||
])
|
||||
if (machine.data) this.machineDetail.machine = machine.data
|
||||
this.machineDetail.cassettes = bays.data || []
|
||||
this.machineDetail.cassetteOps = ops.data || []
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`${MACHINES_PATH}/${this.machineDetail.machine.id}/cassettes`
|
||||
)
|
||||
const rows = (data || []).map(row => ({...row, _dirty: false}))
|
||||
this.machineDetail.cassetteEdits = rows
|
||||
this.machineDetail.cassettesPristine = JSON.parse(JSON.stringify(rows))
|
||||
this.machineDetail.cassettesDirty = false
|
||||
} catch (e) {
|
||||
this._notifyError(e, 'Failed to load cassettes')
|
||||
} finally {
|
||||
|
|
@ -1080,91 +994,73 @@ window.app = Vue.createApp({
|
|||
}
|
||||
},
|
||||
|
||||
cassetteOpIcon(opType) {
|
||||
return (
|
||||
{
|
||||
refill: 'add_circle_outline',
|
||||
empty: 'remove_circle_outline',
|
||||
recount: 'fact_check',
|
||||
set_denomination: 'sell'
|
||||
}[opType] || 'help_outline'
|
||||
markCassetteDirty(row) {
|
||||
// Find pristine match by position (the row identity) and compare;
|
||||
// flip _dirty + overall dirty flag accordingly. Editable fields
|
||||
// are denomination + count; position is the immutable row key.
|
||||
const pristine = this.machineDetail.cassettesPristine.find(
|
||||
p => p.position === row.position
|
||||
)
|
||||
row._dirty =
|
||||
!pristine ||
|
||||
Number(row.denomination) !== Number(pristine.denomination) ||
|
||||
Number(row.count) !== Number(pristine.count)
|
||||
this.machineDetail.cassettesDirty =
|
||||
this.machineDetail.cassetteEdits.some(r => r._dirty)
|
||||
},
|
||||
|
||||
cassetteOpSummary(op) {
|
||||
const fiat = (this.machineDetail.machine || {}).fiat_code || ''
|
||||
const bay = `Bay ${op.position}`
|
||||
if (op.op_type === 'refill') return `${bay} — added ${op.bills} notes`
|
||||
if (op.op_type === 'empty') return `${bay} — emptied`
|
||||
if (op.op_type === 'recount') return `${bay} — recounted to ${op.count}`
|
||||
if (op.op_type === 'set_denomination') {
|
||||
return `${bay} — denomination set to ${op.denomination} ${fiat}`.trim()
|
||||
revertCassetteEdits() {
|
||||
this.machineDetail.cassetteEdits = JSON.parse(
|
||||
JSON.stringify(this.machineDetail.cassettesPristine)
|
||||
)
|
||||
this.machineDetail.cassettesDirty = false
|
||||
this.machineDetail.cassettesError = null
|
||||
},
|
||||
|
||||
openCassettePublishConfirm() {
|
||||
if (!this.machineDetail.cassettesDirty) return
|
||||
this.machineDetail.cassettesError = null
|
||||
this.cassettePublishConfirm.show = true
|
||||
},
|
||||
|
||||
async submitCassettePublish() {
|
||||
// Build the PublishCassettesPayload shape (v1.1, position-keyed):
|
||||
// { positions: { "<pos>": { denomination, count }, ... } }
|
||||
// The API enforces the position set matches what's stored —
|
||||
// since we only edit existing rows, this should always pass.
|
||||
const positions = {}
|
||||
for (const row of this.machineDetail.cassetteEdits) {
|
||||
positions[String(row.position)] = {
|
||||
denomination: Number(row.denomination),
|
||||
count: Number(row.count)
|
||||
}
|
||||
return `${bay} — ${op.op_type}`
|
||||
},
|
||||
|
||||
openCassetteOpDialog(position) {
|
||||
const bays = this.machineDetail.cassettes
|
||||
if (!bays.length) return
|
||||
Object.assign(this.cassetteOpDialog, {
|
||||
show: true,
|
||||
saving: false,
|
||||
error: null,
|
||||
position: position || bays[0].position,
|
||||
op_type: 'refill',
|
||||
bills: null,
|
||||
count: null,
|
||||
denomination: null
|
||||
})
|
||||
},
|
||||
|
||||
resetCassetteOpValue() {
|
||||
// Each type carries exactly one value field and the server rejects an
|
||||
// op that carries a foreign one. Clearing on every type switch means a
|
||||
// number typed under the previous type can't ride along invisibly.
|
||||
Object.assign(this.cassetteOpDialog, {
|
||||
bills: null,
|
||||
count: null,
|
||||
denomination: null,
|
||||
error: null
|
||||
})
|
||||
},
|
||||
|
||||
async submitCassetteOp() {
|
||||
const d = this.cassetteOpDialog
|
||||
if (!this.cassetteOpIsComplete) return
|
||||
const payload = {position: Number(d.position), op_type: d.op_type}
|
||||
if (d.op_type === 'refill') payload.bills = Number(d.bills)
|
||||
if (d.op_type === 'recount') payload.count = Number(d.count)
|
||||
if (d.op_type === 'set_denomination') {
|
||||
payload.denomination = Number(d.denomination)
|
||||
}
|
||||
d.saving = true
|
||||
d.error = null
|
||||
const payload = {positions}
|
||||
this.machineDetail.cassettesPublishing = true
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
const {data} = await LNbits.api.request(
|
||||
'POST',
|
||||
`${MACHINES_PATH}/${this.machineDetail.machine.id}/cassettes/ops`,
|
||||
`${MACHINES_PATH}/${this.machineDetail.machine.id}/cassettes/publish`,
|
||||
null,
|
||||
payload
|
||||
)
|
||||
d.show = false
|
||||
const fresh = (data || []).map(r => ({...r, _dirty: false}))
|
||||
this.machineDetail.cassetteEdits = fresh
|
||||
this.machineDetail.cassettesPristine = JSON.parse(JSON.stringify(fresh))
|
||||
this.machineDetail.cassettesDirty = false
|
||||
this.cassettePublishConfirm.show = false
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Operation recorded and published to the ATM'
|
||||
message: 'Cassette config published to ATM'
|
||||
})
|
||||
await this.loadMachineCassettes()
|
||||
} catch (e) {
|
||||
const detail =
|
||||
(e && e.response && e.response.data && e.response.data.detail) ||
|
||||
'Could not record the operation'
|
||||
// A 503 means the op IS recorded and will ride out with the next
|
||||
// publish, so reload either way — the list should show it pending.
|
||||
d.error = detail
|
||||
this._notifyError(e, 'Operation failed')
|
||||
await this.loadMachineCassettes()
|
||||
'Publish failed'
|
||||
this.machineDetail.cassettesError = detail
|
||||
this._notifyError(e, 'Publish failed')
|
||||
} finally {
|
||||
d.saving = false
|
||||
this.machineDetail.cassettesPublishing = false
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -1257,89 +1153,12 @@ window.app = Vue.createApp({
|
|||
openPartialDispense(settlement) {
|
||||
this.partialDispenseDialog.settlement = settlement
|
||||
this.partialDispenseDialog.mode = 'fraction'
|
||||
// ADR-005: pre-fill from the machine's report — the hardware's own count
|
||||
// of what left — so the operator confirms a number rather than typing one.
|
||||
const dispensedCents = settlement.dispensed_fiat_cents
|
||||
const fiat = Number(settlement.fiat_amount)
|
||||
this.partialDispenseDialog.dispensed_fraction =
|
||||
dispensedCents != null && fiat > 0
|
||||
? Math.round((dispensedCents / 100 / fiat) * 10000) / 10000
|
||||
: null
|
||||
this.partialDispenseDialog.dispensed_fraction = null
|
||||
this.partialDispenseDialog.dispensed_sats = null
|
||||
this.partialDispenseDialog.notes = settlement.dispense_error
|
||||
? `Machine reported: ${settlement.dispense_error_code || ''} ${settlement.dispense_raw_code || ''} — ${settlement.dispense_error}`.trim()
|
||||
: ''
|
||||
this.partialDispenseDialog.notes = ''
|
||||
this.partialDispenseDialog.show = true
|
||||
},
|
||||
|
||||
// ADR-005 §6 — the operator paid the customer by hand; close both ledgers.
|
||||
openSettleCashOwed(settlement) {
|
||||
this.settleDialog.settlement = settlement
|
||||
this.settleDialog.note = ''
|
||||
this.settleDialog.show = true
|
||||
},
|
||||
|
||||
async submitSettleCashOwed() {
|
||||
const d = this.settleDialog
|
||||
d.saving = true
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'POST',
|
||||
`${SETTLEMENTS_PATH}/${d.settlement.id}/settle-cash-owed`,
|
||||
null,
|
||||
{note: d.note}
|
||||
)
|
||||
this._replaceSettlement(data)
|
||||
d.show = false
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message:
|
||||
'Settled — distributing the full amount; the machine will mark its row remediated'
|
||||
})
|
||||
if (this.worklist.totalCount) await this.loadWorklist()
|
||||
} catch (e) {
|
||||
this._notifyError(e, 'Settle failed')
|
||||
} finally {
|
||||
d.saving = false
|
||||
}
|
||||
},
|
||||
|
||||
// Deep link into the error glossary for a settlement's dispenser error.
|
||||
errorGlossaryHref(settlement) {
|
||||
const code = settlement.dispense_raw_code || settlement.dispense_error_code || ''
|
||||
const anchor = code.toLowerCase().replace(/[^a-z0-9]+/g, '-')
|
||||
return `/spirekeeper/static/docs/errors.html${anchor ? '#' + anchor : ''}`
|
||||
},
|
||||
|
||||
// ADR-005 §5 — release a machine's cash-out hold after a terminal
|
||||
// dispenser fault, when the jam was cleared without a recount.
|
||||
confirmResumeCashOut(machine) {
|
||||
Quasar.Dialog.create({
|
||||
title: 'Resume cash-out?',
|
||||
message:
|
||||
'The machine latched cash-out off after a dispenser fault' +
|
||||
(machine.cash_out_held_code ? ` (${machine.cash_out_held_code})` : '') +
|
||||
'. Only do this after the transport path has been physically cleared. ' +
|
||||
'A recount releases the hold too, and also fixes the bay count.',
|
||||
cancel: true,
|
||||
persistent: true
|
||||
}).onOk(async () => {
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
`/spirekeeper/api/v1/dca/machines/${machine.id}/resume-cash-out`
|
||||
)
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Resume published — the machine clears the hold on receipt'
|
||||
})
|
||||
if (this.machineDetail && this.machineDetail.machine) await this.reloadMachineDetail()
|
||||
} catch (e) {
|
||||
this._notifyError(e, 'Resume cash-out failed')
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async submitPartialDispense() {
|
||||
const d = this.partialDispenseDialog
|
||||
const body = {notes: d.notes || null}
|
||||
|
|
|
|||
133
tasks.py
133
tasks.py
|
|
@ -169,17 +169,8 @@ async def _handle_payment(payment: Payment) -> None:
|
|||
if isinstance(nostr_event_id, str) and nostr_event_id:
|
||||
data.bitspire_event_id = nostr_event_id
|
||||
|
||||
# 3) Insert. ADR-005 §1: payment is authorization, the machine's dispense
|
||||
# report is capture. A cash_out waits in `awaiting_dispense` for that
|
||||
# report (handled in dispense_transport); distribution runs only once it
|
||||
# says dispense_confirmed. A cash_in has no dispense and proceeds as
|
||||
# before. Before this gate the legs were paid sub-second, before the
|
||||
# machine had even begun to dispense — which is how a jam read
|
||||
# `processed` on 2026-10-09 (bitspire#122).
|
||||
is_cash_out = data.tx_type == "cash_out"
|
||||
settlement = await create_settlement_idempotent(
|
||||
data, initial_status="awaiting_dispense" if is_cash_out else "pending"
|
||||
)
|
||||
# 3) Insert + distribute.
|
||||
settlement = await create_settlement_idempotent(data, initial_status="pending")
|
||||
if settlement is None:
|
||||
logger.error(
|
||||
f"spirekeeper: failed to insert settlement for "
|
||||
|
|
@ -194,10 +185,6 @@ async def _handle_payment(payment: Payment) -> None:
|
|||
f"(super_fee={data.platform_fee_sats} "
|
||||
f"operator_fee={data.operator_fee_sats})"
|
||||
)
|
||||
if is_cash_out:
|
||||
await _await_dispense_or_adopt(settlement, machine, data)
|
||||
return
|
||||
|
||||
# Spawn distribution on a background task so the LNbits invoice queue
|
||||
# (shared across all extensions) keeps draining while we move sats.
|
||||
# Concurrency-safe: process_settlement uses claim_settlement_for_processing
|
||||
|
|
@ -209,23 +196,6 @@ async def _handle_payment(payment: Payment) -> None:
|
|||
task.add_done_callback(_inflight_distributions.discard)
|
||||
|
||||
|
||||
async def _await_dispense_or_adopt(
|
||||
settlement, machine: Machine, data: CreateDcaSettlementData
|
||||
) -> None:
|
||||
"""A cash_out waits for the machine's dispense report (ADR-005 §1) — unless
|
||||
the report is already here. Under hold invoices the payment settles AFTER
|
||||
the dispense, and the invoice listener can lag the transport, so an orphan
|
||||
report for this txid is adopted and applied now."""
|
||||
if settlement.status != "awaiting_dispense" or not data.bitspire_txid:
|
||||
return
|
||||
from .crud import get_latest_unlinked_dispense_report
|
||||
from .dispense_transport import adopt_unlinked_report
|
||||
|
||||
early = await get_latest_unlinked_dispense_report(machine.id, data.bitspire_txid)
|
||||
if early is not None:
|
||||
await adopt_unlinked_report(settlement, machine, early)
|
||||
|
||||
|
||||
async def _record_rejected(payment: Payment, machine: Machine, exc: Exception) -> None:
|
||||
"""Insert a minimal `dca_settlements` row with `status='rejected'` and
|
||||
the exception message for operator forensics.
|
||||
|
|
@ -368,8 +338,6 @@ async def _cassette_consumer_tick(current_filter_key: str | None) -> str:
|
|||
apply_reported_state,
|
||||
get_machine_by_atm_pubkey_hex,
|
||||
list_all_active_machines,
|
||||
mark_cassette_ops_acked,
|
||||
set_machine_counts_uncertain,
|
||||
)
|
||||
|
||||
machines = await list_all_active_machines()
|
||||
|
|
@ -405,8 +373,6 @@ async def _cassette_consumer_tick(current_filter_key: str | None) -> str:
|
|||
event_message,
|
||||
get_machine_by_atm_pubkey_hex,
|
||||
apply_reported_state,
|
||||
mark_cassette_ops_acked,
|
||||
set_machine_counts_uncertain,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
|
|
@ -417,89 +383,10 @@ async def _cassette_consumer_tick(current_filter_key: str | None) -> str:
|
|||
return filter_key
|
||||
|
||||
|
||||
async def _record_op_acknowledgements(
|
||||
machine_id: str, payload, mark_cassette_ops_acked
|
||||
) -> None:
|
||||
"""Mark the operations a machine reports as applied.
|
||||
|
||||
Deliberately not gated on whether the state event advanced the counts. The
|
||||
machine echoes its applied-op ids on EVERY state publish, so an event
|
||||
carrying nothing new about the counts can still be the first one to tell us
|
||||
an operation landed; gating on that would lose the acknowledgement.
|
||||
|
||||
This echo is the only acknowledgement the transport can carry. An
|
||||
addressable event gives its publisher no failure signal at all — the relay
|
||||
returns OK for an event it then discards — so without it the dashboard
|
||||
could only ever show an operation as sent, never as delivered.
|
||||
"""
|
||||
if not payload.applied_ops:
|
||||
return
|
||||
newly_acked = await mark_cassette_ops_acked(machine_id, payload.applied_ops)
|
||||
if newly_acked:
|
||||
logger.info(
|
||||
f"spirekeeper: machine {machine_id} acknowledged "
|
||||
f"{newly_acked} cassette operation(s)"
|
||||
)
|
||||
|
||||
|
||||
async def _record_counts_uncertainty(
|
||||
machine_id: str, payload, set_machine_counts_uncertain
|
||||
) -> None:
|
||||
"""Mirror the machine's counts-uncertain marker onto its registry row.
|
||||
|
||||
The machine sets this when a dispense ended without a reliable count of
|
||||
what physically left the bay — a dispenser throw, or a timeout. It cannot
|
||||
know how many notes moved, so it says so instead of decrementing a number
|
||||
it would be guessing at.
|
||||
|
||||
Written on every state event, including when it is None, because the
|
||||
machine clearing the marker is exactly as important as setting it: the
|
||||
operator has recounted, the bay is trustworthy again, and a banner that
|
||||
never goes away is a banner nobody reads.
|
||||
"""
|
||||
from datetime import datetime as _datetime
|
||||
from datetime import timezone as _timezone
|
||||
|
||||
since = None
|
||||
if payload.counts_uncertain_since is not None:
|
||||
since = _datetime.fromtimestamp(
|
||||
int(payload.counts_uncertain_since), tz=_timezone.utc
|
||||
)
|
||||
await set_machine_counts_uncertain(machine_id, since)
|
||||
|
||||
|
||||
async def _record_cash_out_hold(
|
||||
machine_id: str, payload, set_machine_cash_out_hold
|
||||
) -> None:
|
||||
"""Mirror the machine's cash-out hold (ADR-005 §5) onto its registry row.
|
||||
|
||||
Written on every state event, including when absent, because the machine
|
||||
clearing the hold — after an operator recount or resume_cash_out — matters
|
||||
exactly as much as it setting one.
|
||||
"""
|
||||
from datetime import datetime as _datetime
|
||||
from datetime import timezone as _timezone
|
||||
|
||||
since = None
|
||||
if payload.cash_out_held_since is not None:
|
||||
since = _datetime.fromtimestamp(
|
||||
int(payload.cash_out_held_since), tz=_timezone.utc
|
||||
)
|
||||
await set_machine_cash_out_hold(
|
||||
machine_id,
|
||||
since,
|
||||
payload.cash_out_held_reason if since else None,
|
||||
payload.cash_out_held_code if since else None,
|
||||
)
|
||||
|
||||
|
||||
async def _handle_cassette_state_event(
|
||||
event_message,
|
||||
get_machine_by_atm_pubkey_hex,
|
||||
apply_reported_state,
|
||||
mark_cassette_ops_acked,
|
||||
set_machine_counts_uncertain,
|
||||
set_machine_cash_out_hold=None,
|
||||
) -> None:
|
||||
"""Verify signature, resolve the operator's signer, decrypt via the
|
||||
signer abstraction (bunker round-trip for RemoteBunkerSigner; direct
|
||||
|
|
@ -600,22 +487,12 @@ async def _handle_cassette_state_event(
|
|||
)
|
||||
if applied:
|
||||
logger.info(
|
||||
f"spirekeeper: applied reported state event {event_id[:12]}... "
|
||||
f"spirekeeper: applied bootstrap state event {event_id[:12]}... "
|
||||
f"to machine {machine.id} ({len(payload.positions)} cassettes)"
|
||||
)
|
||||
else:
|
||||
# Replay or an older event. Normal on relay reconnect.
|
||||
# Replay: event_id already on file. Normal on relay reconnect.
|
||||
logger.debug(
|
||||
f"spirekeeper: cassette state event {event_id[:12]}... "
|
||||
f"not newer than stored state for machine {machine.id} (no-op)"
|
||||
f"already applied to machine {machine.id} (replay no-op)"
|
||||
)
|
||||
|
||||
# Acknowledgement runs regardless of whether the counts were newer — see
|
||||
# _record_op_acknowledgements for why. Same for the uncertainty marker.
|
||||
await _record_op_acknowledgements(machine.id, payload, mark_cassette_ops_acked)
|
||||
await _record_counts_uncertainty(machine.id, payload, set_machine_counts_uncertain)
|
||||
if set_machine_cash_out_hold is None:
|
||||
from .crud import set_machine_cash_out_hold as _default_set_hold
|
||||
|
||||
set_machine_cash_out_hold = _default_set_hold
|
||||
await _record_cash_out_hold(machine.id, payload, set_machine_cash_out_hold)
|
||||
|
|
|
|||
|
|
@ -16,9 +16,6 @@
|
|||
<h4 class="q-my-none">Satoshi Machine — Operator</h4>
|
||||
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
|
||||
Manage your bitSpire fleet, liquidity providers, and commission distribution.
|
||||
<a href="/spirekeeper/static/docs/operator-guide.html" target="_blank" rel="noopener"
|
||||
:style="{marginLeft: '8px'}">Operator guide</a> ·
|
||||
<a href="/spirekeeper/static/docs/errors.html" target="_blank" rel="noopener">Error glossary</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
|
|
@ -657,13 +654,6 @@
|
|||
<q-td key="error_message">
|
||||
<span :style="{fontSize: '0.85em', opacity: 0.8}"
|
||||
v-text="props.row.error_message || '—'"></span>
|
||||
<a v-if="props.row.dispense_error_code"
|
||||
:href="errorGlossaryHref(props.row)" target="_blank" rel="noopener"
|
||||
:style="{fontSize: '0.8em', marginLeft: '6px', whiteSpace: 'nowrap'}">
|
||||
<code v-text="props.row.dispense_raw_code || props.row.dispense_error_code"></code>
|
||||
<q-icon name="help_outline" size="xs"></q-icon>
|
||||
<q-tooltip>What this code means and what to do</q-tooltip>
|
||||
</a>
|
||||
</q-td>
|
||||
<q-td key="actions" auto-width>
|
||||
<q-btn flat dense size="sm" icon="open_in_new"
|
||||
|
|
@ -671,18 +661,6 @@
|
|||
@click="viewMachineFromWorklist(props.row)">
|
||||
<q-tooltip>Open machine detail</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn v-if="bucket.key === 'cash_owed' || bucket.key === 'partial_pending'"
|
||||
flat dense size="sm" icon="handshake"
|
||||
color="positive"
|
||||
@click="openSettleCashOwed(props.row)">
|
||||
<q-tooltip>Settle off-machine — you paid the customer by hand</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn v-if="bucket.key === 'partial_pending'"
|
||||
flat dense size="sm" icon="call_split"
|
||||
color="deep-orange"
|
||||
@click="openPartialDispense(props.row)">
|
||||
<q-tooltip>Record the resolution (pre-filled from the machine's report)</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn v-if="bucket.key === 'errored'"
|
||||
flat dense size="sm" icon="restart_alt"
|
||||
color="primary"
|
||||
|
|
@ -1135,15 +1113,6 @@
|
|||
</q-item-section>
|
||||
<q-item-section>Retry distribution</q-item-section>
|
||||
</q-item>
|
||||
<q-item
|
||||
v-if="['cash_owed','partial_pending'].includes(props.row.status)"
|
||||
clickable v-close-popup
|
||||
@click="openSettleCashOwed(props.row)">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="handshake" color="positive"></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>Settle off-machine…</q-item-section>
|
||||
</q-item>
|
||||
<q-item
|
||||
v-if="['pending','errored'].includes(props.row.status)"
|
||||
clickable v-close-popup
|
||||
|
|
@ -1182,22 +1151,23 @@
|
|||
<div class="col">
|
||||
<h6 class="q-my-none">Cassettes</h6>
|
||||
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
|
||||
The machine owns these counts — it holds the notes. You
|
||||
record what you <i>did</i> to a bay and it keeps the
|
||||
running total. Bay count and denomination set are
|
||||
hardware-determined (re-provision via atm-tui to change
|
||||
the bays themselves).
|
||||
Per-cassette count and physical bay position. Denomination
|
||||
set is hardware-determined (re-provision via atm-tui to
|
||||
change). "Publish to ATM" encrypts + signs + sends the new
|
||||
config to the machine via Nostr.
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat dense icon="refresh" label="Refresh"
|
||||
:loading="machineDetail.cassettesLoading"
|
||||
@click="loadMachineCassettes">
|
||||
<q-tooltip>Re-read the machine's latest report</q-tooltip>
|
||||
<q-btn flat dense icon="undo" label="Revert"
|
||||
:disable="!machineDetail.cassettesDirty"
|
||||
@click="revertCassetteEdits">
|
||||
<q-tooltip>Discard unsaved edits</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn color="primary" icon="add" label="Record operation"
|
||||
:disable="!machineDetail.cassettes.length"
|
||||
@click="openCassetteOpDialog()"></q-btn>
|
||||
<q-btn color="primary" icon="cloud_upload"
|
||||
label="Publish to ATM"
|
||||
:disable="!machineDetail.cassettesDirty"
|
||||
:loading="machineDetail.cassettesPublishing"
|
||||
@click="openCassettePublishConfirm"></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1209,127 +1179,64 @@
|
|||
<span v-text="machineDetail.cassettesError"></span>
|
||||
</q-banner>
|
||||
|
||||
<q-banner v-if="machineDetail.machine
|
||||
&& machineDetail.machine.cash_out_held_since"
|
||||
class="bg-red-1 text-grey-9 q-mb-md">
|
||||
<template v-slot:avatar>
|
||||
<q-icon name="block" color="negative"></q-icon>
|
||||
</template>
|
||||
<b>Cash-out is held.</b>
|
||||
The machine latched cash-out off after a terminal dispenser fault
|
||||
(<span v-text="machineDetail.machine.cash_out_held_code || 'fault'"></span>
|
||||
at <span v-text="formatTime(machineDetail.machine.cash_out_held_since)"></span>):
|
||||
<span v-text="machineDetail.machine.cash_out_held_reason"></span>.
|
||||
Clear the transport path, then either record a <b>Recount</b>
|
||||
(which also fixes the count) or release it here.
|
||||
<template v-slot:action>
|
||||
<q-btn flat color="negative" label="Resume cash-out"
|
||||
@click="confirmResumeCashOut(machineDetail.machine)"></q-btn>
|
||||
</template>
|
||||
</q-banner>
|
||||
|
||||
<q-banner v-if="machineDetail.machine
|
||||
&& machineDetail.machine.counts_uncertain_since"
|
||||
class="bg-orange-1 text-grey-9 q-mb-md">
|
||||
<template v-slot:avatar>
|
||||
<q-icon name="help_outline" color="warning"></q-icon>
|
||||
</template>
|
||||
<b>The machine can't vouch for these counts.</b>
|
||||
A dispense ended without a reliable count of what left the bay
|
||||
(<span v-text="formatTime(machineDetail.machine.counts_uncertain_since)"></span>).
|
||||
Open the bay, count it, and record a <b>Recount</b> — the
|
||||
machine clears this on its own once you do.
|
||||
</q-banner>
|
||||
|
||||
<q-banner v-if="!machineDetail.cassettes.length
|
||||
<q-banner v-if="!machineDetail.cassetteEdits.length
|
||||
&& !machineDetail.cassettesLoading"
|
||||
class="bg-blue-1 text-grey-9">
|
||||
<template v-slot:avatar>
|
||||
<q-icon name="hourglass_empty" color="blue"></q-icon>
|
||||
</template>
|
||||
Waiting for the ATM's state event. Power on the ATM
|
||||
Waiting for the ATM's bootstrap state event. Power on the ATM
|
||||
and confirm it has reached the configured relay; cassette
|
||||
rows will auto-populate on receipt.
|
||||
</q-banner>
|
||||
|
||||
<q-table v-if="machineDetail.cassettes.length"
|
||||
<q-table v-if="machineDetail.cassetteEdits.length"
|
||||
dense flat
|
||||
:rows="machineDetail.cassettes"
|
||||
:rows="machineDetail.cassetteEdits"
|
||||
row-key="position"
|
||||
:columns="cassettesTable.columns"
|
||||
:pagination="cassettesTable.pagination"
|
||||
hide-pagination>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-tr :props="props"
|
||||
:style="props.row._dirty
|
||||
? {boxShadow: 'inset 4px 0 0 0 #fdd835'}
|
||||
: {}">
|
||||
<q-td key="position" class="text-right">
|
||||
<b v-text="'Bay ' + props.row.position"></b>
|
||||
</q-td>
|
||||
<q-td key="denomination" class="text-right">
|
||||
<span v-text="props.row.denomination"></span>
|
||||
<span :style="{opacity: 0.6}"
|
||||
v-text="' ' + (machineDetail.machine.fiat_code || '')"></span>
|
||||
<q-input v-model.number="props.row.denomination"
|
||||
type="number" min="1" step="1" dense outlined
|
||||
:suffix="machineDetail.machine.fiat_code || ''"
|
||||
:style="{width: '140px', display: 'inline-block'}"
|
||||
@update:model-value="markCassetteDirty(props.row)"></q-input>
|
||||
</q-td>
|
||||
<q-td key="count" class="text-right">
|
||||
<b v-text="props.row.count"></b>
|
||||
<span :style="{opacity: 0.6}"> notes</span>
|
||||
<q-input v-model.number="props.row.count" type="number"
|
||||
min="0" step="1" dense outlined
|
||||
:style="{width: '120px', display: 'inline-block'}"
|
||||
@update:model-value="markCassetteDirty(props.row)"></q-input>
|
||||
</q-td>
|
||||
<q-td key="state_at">
|
||||
<q-td key="state" class="text-right">
|
||||
<span v-if="props.row.state_denomination !== null"
|
||||
:style="{fontSize: '0.85em', opacity: 0.7}">
|
||||
<span v-text="props.row.state_denomination"></span>
|
||||
<span :style="{opacity: 0.6}"
|
||||
v-text="' ' + (machineDetail.machine.fiat_code || '')"></span>
|
||||
<span :style="{opacity: 0.6}"> · </span>
|
||||
<span v-text="'×' + props.row.state_count"></span>
|
||||
</span>
|
||||
<span v-else :style="{opacity: 0.4}">—</span>
|
||||
</q-td>
|
||||
<q-td key="updated_at">
|
||||
<span :style="{fontSize: '0.85em', opacity: 0.7}"
|
||||
v-text="props.row.state_at
|
||||
? formatTime(props.row.state_at)
|
||||
: 'never'"></span>
|
||||
</q-td>
|
||||
<q-td key="actions" class="text-right">
|
||||
<q-btn flat dense size="sm" icon="edit_note"
|
||||
@click="openCassetteOpDialog(props.row.position)">
|
||||
<q-tooltip>Record an operation on this bay</q-tooltip>
|
||||
</q-btn>
|
||||
v-text="formatTime(props.row.updated_at)"></span>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
<div v-if="machineDetail.cassettes.length" class="q-mt-lg">
|
||||
<div class="text-subtitle2 q-mb-xs">Recent operations</div>
|
||||
<p class="text-caption q-mt-none q-mb-sm" :style="{opacity: 0.7}">
|
||||
"Pending" means sent but not yet echoed back by the machine.
|
||||
Every publish carries the recent window, so a pending
|
||||
operation keeps being re-offered until it lands — there is
|
||||
nothing to retry by hand.
|
||||
</p>
|
||||
<q-banner v-if="!machineDetail.cassetteOps.length"
|
||||
class="bg-grey-3 text-grey-9">
|
||||
No operations recorded yet.
|
||||
</q-banner>
|
||||
<q-list v-else dense bordered separator>
|
||||
<q-item v-for="op in machineDetail.cassetteOps" :key="op.id">
|
||||
<q-item-section avatar>
|
||||
<q-icon :name="cassetteOpIcon(op.op_type)"
|
||||
:color="op.acked_at ? 'positive' : 'grey'"></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="cassetteOpSummary(op)"></q-item-label>
|
||||
<q-item-label caption
|
||||
v-text="formatTime(op.created_at)"></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-chip dense size="sm"
|
||||
:color="op.acked_at ? 'green-1' : 'orange-1'"
|
||||
text-color="grey-9"
|
||||
:label="op.acked_at ? 'Applied' : 'Pending'">
|
||||
<q-tooltip v-if="op.acked_at">
|
||||
Machine confirmed at
|
||||
<span v-text="formatTime(op.acked_at)"></span>
|
||||
</q-tooltip>
|
||||
<q-tooltip v-else>
|
||||
Sent; waiting for the machine to echo this id back
|
||||
</q-tooltip>
|
||||
</q-chip>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
</q-card-section>
|
||||
|
|
@ -1337,82 +1244,53 @@
|
|||
</q-dialog>
|
||||
|
||||
<!-- =============================================================== -->
|
||||
<!-- RECORD CASSETTE OPERATION DIALOG -->
|
||||
<!-- CASSETTE PUBLISH CONFIRM DIALOG -->
|
||||
<!-- =============================================================== -->
|
||||
<q-dialog v-model="cassetteOpDialog.show" persistent>
|
||||
<q-dialog v-model="cassettePublishConfirm.show" persistent>
|
||||
<q-card :style="{width: '480px', maxWidth: '95vw'}">
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<div class="text-h6">Record cassette operation</div>
|
||||
<div class="text-h6">Publish cassette config to ATM</div>
|
||||
<q-space ></q-space>
|
||||
<q-btn icon="close" flat round dense v-close-popup></q-btn>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-banner class="bg-blue-1 text-grey-9 q-mb-md">
|
||||
<q-banner class="bg-orange-1 text-grey-9 q-mb-md">
|
||||
<template v-slot:avatar>
|
||||
<q-icon name="info" color="blue"></q-icon>
|
||||
<q-icon name="warning" color="warning"></q-icon>
|
||||
</template>
|
||||
Record what you did to the bay. The machine applies it to the
|
||||
count it already holds, so a dispense that happened while this
|
||||
dialog was open is kept, not overwritten.
|
||||
</q-banner>
|
||||
|
||||
<q-select v-model.number="cassetteOpDialog.position"
|
||||
:options="cassetteBayOptions"
|
||||
emit-value map-options
|
||||
label="Bay" dense outlined
|
||||
class="q-mb-md"></q-select>
|
||||
|
||||
<q-select v-model="cassetteOpDialog.op_type"
|
||||
:options="cassetteOpTypeOptions"
|
||||
emit-value map-options
|
||||
label="Operation" dense outlined
|
||||
class="q-mb-md"
|
||||
@update:model-value="resetCassetteOpValue"></q-select>
|
||||
|
||||
<q-input v-if="cassetteOpDialog.op_type === 'refill'"
|
||||
v-model.number="cassetteOpDialog.bills"
|
||||
type="number" min="1" step="1" dense outlined
|
||||
label="Notes added"
|
||||
hint="How many notes you put IN — a delta, not a total."
|
||||
></q-input>
|
||||
|
||||
<q-input v-if="cassetteOpDialog.op_type === 'recount'"
|
||||
v-model.number="cassetteOpDialog.count"
|
||||
type="number" min="0" step="1" dense outlined
|
||||
label="Notes counted"
|
||||
hint="What you physically counted in the bay, right now."
|
||||
></q-input>
|
||||
|
||||
<q-input v-if="cassetteOpDialog.op_type === 'set_denomination'"
|
||||
v-model.number="cassetteOpDialog.denomination"
|
||||
type="number" min="1" step="1" dense outlined
|
||||
label="Denomination"
|
||||
:suffix="machineDetail.machine
|
||||
? (machineDetail.machine.fiat_code || '') : ''"
|
||||
hint="What is now loaded in that bay. The machine can't
|
||||
know this — only you can."
|
||||
></q-input>
|
||||
|
||||
<q-banner v-if="cassetteOpDialog.op_type === 'empty'"
|
||||
class="bg-grey-3 text-grey-9">
|
||||
The bay is now empty. Nothing else to enter.
|
||||
</q-banner>
|
||||
|
||||
<q-banner v-if="cassetteOpDialog.error"
|
||||
class="bg-red-1 text-grey-9 q-mt-md">
|
||||
<template v-slot:avatar>
|
||||
<q-icon name="warning" color="negative"></q-icon>
|
||||
</template>
|
||||
<span v-text="cassetteOpDialog.error"></span>
|
||||
<b>This publish will overwrite the ATM's currently-tracked
|
||||
counts.</b> If the ATM has dispensed cash since your last
|
||||
refill or count baseline, those decrements will be lost.
|
||||
Publish only after a physical refill (a known total), not to
|
||||
"tweak" counts mid-day. v2 reconciliation will replace this
|
||||
modal with reconciled state display.
|
||||
</q-banner>
|
||||
<p class="q-mb-sm">Sending to ATM:</p>
|
||||
<q-list dense bordered>
|
||||
<q-item v-for="row in machineDetail.cassetteEdits"
|
||||
:key="row.position">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<b v-text="'Bay ' + row.position"></b>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-item-label caption>
|
||||
<b v-text="row.denomination + ' ' +
|
||||
(machineDetail.machine.fiat_code || '')"></b>
|
||||
· count
|
||||
<b v-text="row.count"></b>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Cancel" v-close-popup></q-btn>
|
||||
<q-btn color="primary"
|
||||
label="Record + publish"
|
||||
:disable="!cassetteOpIsComplete"
|
||||
:loading="cassetteOpDialog.saving"
|
||||
@click="submitCassetteOp"></q-btn>
|
||||
label="Publish to ATM"
|
||||
:loading="machineDetail.cassettesPublishing"
|
||||
@click="submitCassettePublish"></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
|
@ -1479,41 +1357,6 @@
|
|||
<!-- =============================================================== -->
|
||||
<!-- ADD-NOTE DIALOG -->
|
||||
<!-- =============================================================== -->
|
||||
<!-- ADR-005 §6: settle an owed-cash settlement off-machine -->
|
||||
<q-dialog v-model="settleDialog.show" persistent>
|
||||
<q-card :style="{width: '460px', maxWidth: '95vw'}">
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<div class="text-h6">Settle off-machine</div>
|
||||
<q-space></q-space>
|
||||
<q-btn icon="close" flat round dense v-close-popup></q-btn>
|
||||
</q-card-section>
|
||||
<q-card-section v-if="settleDialog.settlement">
|
||||
<p class="q-mb-sm">
|
||||
The customer paid
|
||||
<strong v-text="formatFiat(settleDialog.settlement.fiat_amount, settleDialog.settlement.fiat_code)"></strong>
|
||||
and the machine reports
|
||||
<strong v-text="settleDialog.settlement.status === 'cash_owed' ? 'nothing dispensed' : 'a partial dispense'"></strong>.
|
||||
</p>
|
||||
<p class="text-caption q-mb-md" :style="{opacity: 0.8}">
|
||||
Use this when you have made the customer whole <em>outside</em> the machine.
|
||||
The settlement distributes at the full amount and the machine marks its own
|
||||
record remediated. Nothing is dispensed. If you dispensed the shortfall at the
|
||||
machine instead, do nothing — it reports that itself.
|
||||
</p>
|
||||
<q-input v-model="settleDialog.note" type="textarea" autogrow outlined dense
|
||||
label="How was the customer paid? (required)"
|
||||
hint="e.g. handed 40 EUR to the customer in person, 2026-10-09"
|
||||
maxlength="500" counter></q-input>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Cancel" v-close-popup></q-btn>
|
||||
<q-btn color="positive" label="Settle at full amount"
|
||||
:disable="!settleDialog.note.trim()" :loading="settleDialog.saving"
|
||||
@click="submitSettleCashOwed"></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="noteDialog.show" persistent>
|
||||
<q-card :style="{width: '420px', maxWidth: '95vw'}">
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
|
|
@ -1578,10 +1421,6 @@
|
|||
hint="Server-side ceiling on a single cash-in's principal. The ATM attests the amount; this bounds a compromised/buggy machine to one capped tx."
|
||||
type="number" step="1" min="0"
|
||||
class="q-mb-md" dense outlined></q-input>
|
||||
<q-input v-model="superFeeDialog.data.alerts_pubkey"
|
||||
label="Alerts pubkey (hex or npub — blank = each operator's own account key)"
|
||||
hint="Where owed-cash alerts are delivered as Nostr DMs (NIP-17). Blank sends each operator a note to self on their LNbits account identity."
|
||||
class="q-mb-md" dense outlined></q-input>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Cancel" v-close-popup></q-btn>
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
Tests for the v1.1 cassette-config layer (aiolabs/satmachineadmin#29).
|
||||
|
||||
Covers the pure pieces that don't need a live DB:
|
||||
- Pydantic validator behaviour on PublishCassettesPayload + the row model
|
||||
(position key coercion, integer ranges, multiple-same-denomination
|
||||
payloads, wire-format round-trip)
|
||||
- Pydantic validator behaviour on PublishCassettesPayload + the row /
|
||||
upsert models (position key coercion, integer ranges, multiple-same-
|
||||
denomination payloads, wire-format round-trip)
|
||||
- _should_apply_state_event ordering gate (extracted from
|
||||
apply_reported_state so the decision is testable without a database
|
||||
round-trip)
|
||||
|
|
@ -29,6 +29,7 @@ from ..crud import _as_unix, _should_apply_state_event
|
|||
from ..models import (
|
||||
CassettePayloadRow,
|
||||
PublishCassettesPayload,
|
||||
UpsertCassetteConfigData,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -148,6 +149,44 @@ class TestCassettePayloadRow:
|
|||
CassettePayloadRow(denomination=20, count=-1)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# UpsertCassetteConfigData — operator-edit form
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestUpsertCassetteConfigData:
|
||||
"""Operator-driven row edit. Both fields optional; same int constraints
|
||||
as the wire-format row but applied independently per-edit. Position is
|
||||
NOT editable — it's the row's identity (the hardware bay number)."""
|
||||
|
||||
def test_partial_update_count_only(self):
|
||||
d = UpsertCassetteConfigData(count=80)
|
||||
assert d.count == 80
|
||||
assert d.denomination is None
|
||||
|
||||
def test_partial_update_denomination_only(self):
|
||||
"""v1.1 operational case: operator records a cartridge swap at
|
||||
refill — slot 1 was $20, dispatcher replaced with $50."""
|
||||
d = UpsertCassetteConfigData(denomination=50)
|
||||
assert d.denomination == 50
|
||||
assert d.count is None
|
||||
|
||||
def test_empty_update_is_legal(self):
|
||||
"""An empty UpsertCassetteConfigData parses fine; the CRUD short-
|
||||
circuits a no-op on empty payload (no SQL emitted)."""
|
||||
d = UpsertCassetteConfigData()
|
||||
assert d.count is None
|
||||
assert d.denomination is None
|
||||
|
||||
def test_rejects_negative_count(self):
|
||||
with pytest.raises(ValueError):
|
||||
UpsertCassetteConfigData(count=-1)
|
||||
|
||||
def test_rejects_non_positive_denomination(self):
|
||||
with pytest.raises(ValueError):
|
||||
UpsertCassetteConfigData(denomination=0)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _should_apply_state_event — ordering gate
|
||||
# =============================================================================
|
||||
|
|
@ -208,31 +247,3 @@ class TestShouldApplyStateEvent:
|
|||
"""Fail closed: an unparseable incoming stamp must not overwrite
|
||||
state that is known-good."""
|
||||
assert _should_apply_state_event(NOW, "nonsense") is False
|
||||
|
||||
def test_seq_breaks_a_same_second_tie(self):
|
||||
"""NIP-01 stamps at one-second granularity, so a dispense and the
|
||||
publish that follows it share a stamp routinely. Without the counter
|
||||
the later report is dropped and the operator keeps the pre-dispense
|
||||
count until the next heartbeat."""
|
||||
assert _should_apply_state_event(NOW, NOW, 4, 5) is True
|
||||
assert _should_apply_state_event(NOW, NOW, 5, 5) is False
|
||||
assert _should_apply_state_event(NOW, NOW, 5, 4) is False
|
||||
|
||||
def test_seq_is_ignored_when_the_stamps_differ(self):
|
||||
"""A machine whose state.db was replaced restarts its counter at zero
|
||||
while its wall clock keeps moving forward. Gating on the counter
|
||||
across different stamps would lock that machine out for good."""
|
||||
assert (
|
||||
_should_apply_state_event(NOW, NOW + timedelta(seconds=1), 900, 0) is True
|
||||
)
|
||||
assert (
|
||||
_should_apply_state_event(NOW, NOW - timedelta(seconds=1), 0, 900) is False
|
||||
)
|
||||
|
||||
def test_same_second_without_a_counter_stays_closed(self):
|
||||
"""An older machine sends no seq at all. Equal stamps then carry no
|
||||
evidence the report is newer, and fail-closed is the safe read: the
|
||||
machine republishes on its heartbeat a second later."""
|
||||
assert _should_apply_state_event(NOW, NOW, None, None) is False
|
||||
assert _should_apply_state_event(NOW, NOW, None, 5) is False
|
||||
assert _should_apply_state_event(NOW, NOW, 5, None) is False
|
||||
|
|
|
|||
|
|
@ -1,360 +0,0 @@
|
|||
"""
|
||||
Tests for the v2 cassette-operations models (bitspire ADR-004).
|
||||
|
||||
The operator no longer publishes counts; it publishes operations and the
|
||||
machine keeps the running total. These cover the pure pieces: per-type field
|
||||
validation, and the wire shape the publisher ships.
|
||||
|
||||
A CreateCassetteOpData instance is meant to be publishable by construction —
|
||||
same contract as FeeConfigPayload — so the type/field agreement is enforced in
|
||||
the model rather than at the endpoint.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from .. import cassette_transport, tasks
|
||||
from ..crud import _should_ack_op
|
||||
from ..models import (
|
||||
CASSETTE_OP_TYPES,
|
||||
CassetteOp,
|
||||
CreateCassetteOpData,
|
||||
Machine,
|
||||
PublishCassetteOpsPayload,
|
||||
PublishCassettesPayload,
|
||||
)
|
||||
|
||||
AT = datetime.fromtimestamp(1790106060, timezone.utc)
|
||||
|
||||
|
||||
def op(**kw) -> CassetteOp:
|
||||
base = {"id": "op-1", "machine_id": "m1", "position": 2, "created_at": AT}
|
||||
return CassetteOp(**{**base, **kw})
|
||||
|
||||
|
||||
class TestCreateCassetteOpData:
|
||||
def test_accepts_one_of_each_type(self):
|
||||
CreateCassetteOpData(position=2, op_type="refill", bills=100)
|
||||
CreateCassetteOpData(position=3, op_type="empty")
|
||||
CreateCassetteOpData(position=1, op_type="recount", count=37)
|
||||
CreateCassetteOpData(position=1, op_type="set_denomination", denomination=50)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[
|
||||
{"position": 2, "op_type": "refill"},
|
||||
{"position": 1, "op_type": "recount"},
|
||||
{"position": 1, "op_type": "set_denomination"},
|
||||
],
|
||||
)
|
||||
def test_rejects_a_type_missing_its_field(self, kwargs):
|
||||
with pytest.raises(ValidationError):
|
||||
CreateCassetteOpData(**kwargs)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[
|
||||
{"position": 2, "op_type": "refill", "bills": 1, "count": 5},
|
||||
{"position": 3, "op_type": "empty", "bills": 1},
|
||||
{"position": 1, "op_type": "recount", "count": 1, "denomination": 50},
|
||||
],
|
||||
)
|
||||
def test_rejects_a_type_carrying_a_foreign_field(self, kwargs):
|
||||
"""An op that carries two meanings is ambiguous on the wire, and the
|
||||
machine would have to guess which one to apply."""
|
||||
with pytest.raises(ValidationError):
|
||||
CreateCassetteOpData(**kwargs)
|
||||
|
||||
def test_rejects_a_refill_of_zero_or_fewer_notes(self):
|
||||
"""A refill is a delta that adds notes. Zero is a no-op an operator
|
||||
did not mean, and negative is a withdrawal wearing a refill's name."""
|
||||
for bills in (0, -5):
|
||||
with pytest.raises(ValidationError):
|
||||
CreateCassetteOpData(position=2, op_type="refill", bills=bills)
|
||||
|
||||
def test_allows_a_recount_to_zero(self):
|
||||
"""Distinct from refill: counting a bay and finding it empty is a real
|
||||
and important observation."""
|
||||
assert CreateCassetteOpData(position=2, op_type="recount", count=0).count == 0
|
||||
|
||||
def test_rejects_a_negative_recount_and_a_non_positive_denomination(self):
|
||||
with pytest.raises(ValidationError):
|
||||
CreateCassetteOpData(position=2, op_type="recount", count=-1)
|
||||
with pytest.raises(ValidationError):
|
||||
CreateCassetteOpData(position=2, op_type="set_denomination", denomination=0)
|
||||
|
||||
def test_rejects_an_unknown_type_and_a_non_positive_position(self):
|
||||
with pytest.raises(ValidationError):
|
||||
CreateCassetteOpData(position=1, op_type="drain")
|
||||
with pytest.raises(ValidationError):
|
||||
CreateCassetteOpData(position=0, op_type="empty")
|
||||
|
||||
|
||||
class TestWireShape:
|
||||
def test_each_type_ships_only_its_own_field(self):
|
||||
assert op(op_type="refill", bills=100).to_wire_dict() == {
|
||||
"id": "op-1",
|
||||
"at": 1790106060,
|
||||
"type": "refill",
|
||||
"position": 2,
|
||||
"bills": 100,
|
||||
}
|
||||
assert op(op_type="empty").to_wire_dict() == {
|
||||
"id": "op-1",
|
||||
"at": 1790106060,
|
||||
"type": "empty",
|
||||
"position": 2,
|
||||
}
|
||||
assert op(op_type="recount", count=37).to_wire_dict()["count"] == 37
|
||||
assert (
|
||||
op(op_type="set_denomination", denomination=50).to_wire_dict()[
|
||||
"denomination"
|
||||
]
|
||||
== 50
|
||||
)
|
||||
|
||||
def test_nulls_never_reach_the_wire(self):
|
||||
"""The row has three nullable columns and one op only ever means one
|
||||
of them. Shipping the other two as null would make the machine guess."""
|
||||
for op_type in CASSETTE_OP_TYPES:
|
||||
kw = {
|
||||
"refill": {"bills": 1},
|
||||
"recount": {"count": 1},
|
||||
"set_denomination": {"denomination": 1},
|
||||
"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()
|
||||
|
||||
def test_payload_declares_v2_and_preserves_order(self):
|
||||
ops = [
|
||||
op(id="a", op_type="refill", bills=1),
|
||||
op(id="b", op_type="empty"),
|
||||
]
|
||||
wire = PublishCassetteOpsPayload(ops=ops).to_wire_dict()
|
||||
assert wire["schema_version"] == 2
|
||||
assert [o["id"] for o in wire["ops"]] == ["a", "b"]
|
||||
|
||||
def test_an_empty_window_is_representable(self):
|
||||
"""A machine with no operator history still gets a well-formed
|
||||
payload rather than the publisher having to special-case it."""
|
||||
assert PublishCassetteOpsPayload(ops=[]).to_wire_dict() == {
|
||||
"schema_version": 2,
|
||||
"ops": [],
|
||||
}
|
||||
|
||||
|
||||
class TestShouldAckOp:
|
||||
"""The pure decision behind mark_cassette_ops_acked.
|
||||
|
||||
The machine echoes a WINDOW of applied ids on every state publish, so the
|
||||
same id arrives repeatedly and from a machine that may not own it.
|
||||
"""
|
||||
|
||||
def test_acks_an_unacked_op_for_the_reporting_machine(self):
|
||||
assert _should_ack_op(op(op_type="empty"), "m1") is True
|
||||
|
||||
def test_ignores_an_unknown_id(self):
|
||||
assert _should_ack_op(None, "m1") is False
|
||||
|
||||
def test_ignores_an_op_belonging_to_another_machine(self):
|
||||
"""Ids are unique, but a report from one machine must never close out
|
||||
another machine's operation."""
|
||||
assert _should_ack_op(op(op_type="empty", machine_id="m2"), "m1") is False
|
||||
|
||||
def test_keeps_the_first_acknowledgement(self):
|
||||
"""Every subsequent window carries the id again. Re-acking would slide
|
||||
the timestamp forward and lose when the operation actually landed."""
|
||||
already = op(op_type="empty", acked_at=AT)
|
||||
assert _should_ack_op(already, "m1") is False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# publish_ops_to_atm — the v2 wire contract
|
||||
# =============================================================================
|
||||
|
||||
ATM_HEX = "df2003343784b69cb813b2a4fd231f83ae81133279251c735414f9909baa7ac6"
|
||||
|
||||
|
||||
def machine() -> Machine:
|
||||
return Machine(
|
||||
id="m1",
|
||||
operator_user_id="op1",
|
||||
machine_npub=ATM_HEX,
|
||||
wallet_id="w1",
|
||||
name="Cinderella",
|
||||
location=None,
|
||||
fiat_code="EUR",
|
||||
is_active=True,
|
||||
created_at=AT,
|
||||
updated_at=AT,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def captured(monkeypatch):
|
||||
"""Capture what the transport would publish, without a relay or signer."""
|
||||
seen: dict = {}
|
||||
|
||||
async def fake_publish(**kwargs):
|
||||
seen.update(kwargs)
|
||||
return {"id": "event-id"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
cassette_transport, "publish_encrypted_kind_30078", fake_publish
|
||||
)
|
||||
return seen
|
||||
|
||||
|
||||
class TestPublishOpsToAtm:
|
||||
@pytest.mark.asyncio
|
||||
async def test_publishes_v2_ops_to_the_config_d_tag(self, captured):
|
||||
ops = [
|
||||
op(id="a", op_type="refill", bills=100),
|
||||
op(id="b", op_type="empty", position=3),
|
||||
]
|
||||
await cassette_transport.publish_ops_to_atm(machine(), ops, "op1")
|
||||
|
||||
# Same d-tag as the counts wire it replaces: the machine subscribes by
|
||||
# this tag, and the document is addressable, so v2 replaces v1 in place.
|
||||
assert captured["d_tag"] == f"bitspire-cassettes:{ATM_HEX}"
|
||||
assert captured["recipient_pubkey_hex"] == ATM_HEX
|
||||
assert captured["operator_user_id"] == "op1"
|
||||
|
||||
payload = captured["payload"]
|
||||
assert payload["schema_version"] == 2
|
||||
assert [o["id"] for o in payload["ops"]] == ["a", "b"]
|
||||
assert payload["ops"][0]["bills"] == 100
|
||||
assert "positions" not in payload
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preserves_window_order(self, captured):
|
||||
"""Order is meaning: a recount then a refill is not the same as the
|
||||
reverse, so the publisher must not re-sort what crud handed it."""
|
||||
ops = [
|
||||
op(id="first", op_type="recount", count=10),
|
||||
op(id="second", op_type="refill", bills=5),
|
||||
]
|
||||
await cassette_transport.publish_ops_to_atm(machine(), ops, "op1")
|
||||
assert [o["id"] for o in captured["payload"]["ops"]] == ["first", "second"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publishes_an_empty_window_rather_than_skipping(self, captured):
|
||||
"""A machine with no operator history still gets a well-formed v2
|
||||
document, so it can tell 'no operations' from 'operator still on v1'."""
|
||||
await cassette_transport.publish_ops_to_atm(machine(), [], "op1")
|
||||
assert captured["payload"] == {"schema_version": 2, "ops": []}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accepts_an_npub_and_publishes_hex(self, captured):
|
||||
"""Operators enter either form in the UI; the d-tag is always hex, or
|
||||
the machine's subscription filter silently never matches."""
|
||||
import bech32
|
||||
|
||||
data = bech32.convertbits(bytes.fromhex(ATM_HEX), 8, 5)
|
||||
npub = bech32.bech32_encode("npub", data)
|
||||
m = machine().copy(update={"machine_npub": npub})
|
||||
await cassette_transport.publish_ops_to_atm(m, [], "op1")
|
||||
assert captured["d_tag"] == f"bitspire-cassettes:{ATM_HEX}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _record_op_acknowledgements — the only ack this transport can carry
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def state_payload(**kw) -> PublishCassettesPayload:
|
||||
base = {"positions": {"1": {"denomination": 50, "count": 24}}}
|
||||
return PublishCassettesPayload(**{**base, **kw})
|
||||
|
||||
|
||||
class TestRecordOpAcknowledgements:
|
||||
@pytest.mark.asyncio
|
||||
async def test_marks_the_reported_ids(self):
|
||||
calls = []
|
||||
|
||||
async def mark(machine_id, op_ids):
|
||||
calls.append((machine_id, op_ids))
|
||||
return len(op_ids)
|
||||
|
||||
await tasks._record_op_acknowledgements(
|
||||
"m1", state_payload(applied_ops=["a", "b"]), mark
|
||||
)
|
||||
assert calls == [("m1", ["a", "b"])]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_nothing_when_the_machine_reports_none(self):
|
||||
"""An older machine sends no applied_ops at all, and a new one with
|
||||
nothing applied sends an empty list. Neither should write."""
|
||||
calls = []
|
||||
|
||||
async def mark(machine_id, op_ids):
|
||||
calls.append((machine_id, op_ids))
|
||||
return 0
|
||||
|
||||
await tasks._record_op_acknowledgements("m1", state_payload(), mark)
|
||||
await tasks._record_op_acknowledgements(
|
||||
"m1", state_payload(applied_ops=[]), mark
|
||||
)
|
||||
assert calls == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acks_even_when_the_counts_were_not_newer(self):
|
||||
"""The machine echoes its applied ids on every publish, including
|
||||
heartbeats that carry nothing new about the counts. One of those can
|
||||
still be the first event to tell us an operation landed, so the ack
|
||||
must not depend on the state having advanced."""
|
||||
seen = []
|
||||
|
||||
async def mark(machine_id, op_ids):
|
||||
seen.extend(op_ids)
|
||||
return len(op_ids)
|
||||
|
||||
# Same positions as already on file — a pure heartbeat.
|
||||
await tasks._record_op_acknowledgements(
|
||||
"m1", state_payload(applied_ops=["late-ack"]), mark
|
||||
)
|
||||
assert seen == ["late-ack"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _record_counts_uncertainty — the machine saying "don't trust these counts"
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestRecordCountsUncertainty:
|
||||
@pytest.mark.asyncio
|
||||
async def test_stores_the_reported_moment_as_utc(self):
|
||||
calls = []
|
||||
|
||||
async def setter(machine_id, since):
|
||||
calls.append((machine_id, since))
|
||||
|
||||
await tasks._record_counts_uncertainty(
|
||||
"m1", state_payload(counts_uncertain_since=1790110546), setter
|
||||
)
|
||||
assert len(calls) == 1
|
||||
machine_id, since = calls[0]
|
||||
assert machine_id == "m1"
|
||||
assert since is not None
|
||||
assert since.tzinfo is not None
|
||||
assert int(since.timestamp()) == 1790110546
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clears_the_marker_when_the_machine_is_confident_again(self):
|
||||
"""A banner that never goes away is a banner nobody reads. The
|
||||
machine dropping the field is how the operator learns the recount
|
||||
took, so None must be written through rather than skipped."""
|
||||
calls = []
|
||||
|
||||
async def setter(machine_id, since):
|
||||
calls.append((machine_id, since))
|
||||
|
||||
await tasks._record_counts_uncertainty("m1", state_payload(), setter)
|
||||
assert calls == [("m1", None)]
|
||||
|
|
@ -1,542 +0,0 @@
|
|||
"""
|
||||
Tests for dispense-outcome capture (bitspire ADR-005 §1-§2, #122).
|
||||
|
||||
Covers the pure pieces and the handler's transitions with the crud layer
|
||||
monkeypatched (no DB), in the project's established style: asyncio.run inside
|
||||
the test body, SimpleNamespace for request/payment shapes.
|
||||
|
||||
- models: DispenseReportIn validation + derived numbers; resume_cash_out as a
|
||||
machine-wide op (position 0, no position on the wire); the three new
|
||||
worklist buckets default empty.
|
||||
- handler: confirmed → pending + distribution spawned; nothing out →
|
||||
cash_owed; some out → partial_pending; already-captured settlements are
|
||||
recorded but not moved; a byte-identical resend is acked without a new
|
||||
row; an orphan report (payment not landed) is stored unlinked; a
|
||||
remediation report moves the owed settlement to pending; unpaired sender
|
||||
and bad bodies are refused.
|
||||
- gate: _handle_payment inserts cash_out as awaiting_dispense and does NOT
|
||||
spawn distribution; cash_in is unchanged; an early report is adopted.
|
||||
- consumer: the state document's cash_out_held_* mirrors onto the machine,
|
||||
including clearing it.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from .. import crud as crud_mod
|
||||
from .. import dispense_transport, tasks
|
||||
from ..dispense_transport import (
|
||||
_outcome_status,
|
||||
handle_report_dispense,
|
||||
)
|
||||
from ..models import (
|
||||
CASSETTE_OP_TYPES,
|
||||
CassetteOp,
|
||||
CreateCassetteOpData,
|
||||
CreateDcaSettlementData,
|
||||
DcaSettlement,
|
||||
DispenseReportIn,
|
||||
Machine,
|
||||
PublishCassettesPayload,
|
||||
StuckSettlementsResponse,
|
||||
)
|
||||
|
||||
_NOW = datetime(2026, 10, 9, 7, 2, 33)
|
||||
_ATM_HEX = "df2003343784b69cb813b2a4fd231f83ae81133279251c735414f9909baa7ac6"
|
||||
_TXID = "tx_mv0madw6_wdhtea1v"
|
||||
_HASH = "6f216df32c36" + "0" * 52
|
||||
|
||||
|
||||
def _machine(**over) -> Machine:
|
||||
base: dict[str, Any] = {
|
||||
"id": "m1",
|
||||
"operator_user_id": "op1",
|
||||
"machine_npub": _ATM_HEX,
|
||||
"wallet_id": "w1",
|
||||
"name": "sintra",
|
||||
"location": None,
|
||||
"fiat_code": "EUR",
|
||||
"is_active": True,
|
||||
"created_at": _NOW,
|
||||
"updated_at": _NOW,
|
||||
}
|
||||
base.update(over)
|
||||
return Machine(**base)
|
||||
|
||||
|
||||
def _settlement(status="awaiting_dispense", **over) -> DcaSettlement:
|
||||
base: dict[str, Any] = {
|
||||
"id": "s1",
|
||||
"machine_id": "m1",
|
||||
"payment_hash": _HASH,
|
||||
"bitspire_event_id": None,
|
||||
"bitspire_txid": _TXID,
|
||||
"wire_sats": 54440,
|
||||
"fiat_amount": 40.0,
|
||||
"fiat_code": "EUR",
|
||||
"exchange_rate": 1361.0,
|
||||
"principal_sats": 54440,
|
||||
"fee_sats": 0,
|
||||
"platform_fee_sats": 0,
|
||||
"operator_fee_sats": 0,
|
||||
"tx_type": "cash_out",
|
||||
"bills_json": None,
|
||||
"cassettes_json": None,
|
||||
"status": status,
|
||||
"error_message": None,
|
||||
"processed_at": None,
|
||||
"created_at": _NOW,
|
||||
}
|
||||
base.update(over)
|
||||
return DcaSettlement(**base)
|
||||
|
||||
|
||||
def _report(**over) -> dict:
|
||||
"""The wire body for sintra's 2026-10-09 jam, as a dict."""
|
||||
body: dict[str, Any] = {
|
||||
"txid": _TXID,
|
||||
"payment_hash": _HASH,
|
||||
"tx_type": "cash_out",
|
||||
"dispense_confirmed": False,
|
||||
"error": "Note stopped at the cassette exit",
|
||||
"error_code": "F56DispenseError",
|
||||
"raw_code": "78 42",
|
||||
"error_class": "terminal",
|
||||
"fiat_cents": 4000,
|
||||
"currency": "EUR",
|
||||
"bills": [{"denomination": 20, "requested": 2, "dispensed": 0, "rejected": 0}],
|
||||
"cassettes": [
|
||||
{
|
||||
"position": 1,
|
||||
"denomination": 50,
|
||||
"provisioned": 0,
|
||||
"dispensed": 0,
|
||||
"rejected": 0,
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"denomination": 20,
|
||||
"provisioned": 2,
|
||||
"dispensed": 0,
|
||||
"rejected": 0,
|
||||
},
|
||||
],
|
||||
"counts_uncertain": True,
|
||||
"at": 1791529353,
|
||||
}
|
||||
body.update(over)
|
||||
return body
|
||||
|
||||
|
||||
def _confirmed() -> dict:
|
||||
return _report(
|
||||
dispense_confirmed=True,
|
||||
error=None,
|
||||
error_code=None,
|
||||
raw_code=None,
|
||||
error_class=None,
|
||||
bills=[{"denomination": 20, "requested": 2, "dispensed": 2, "rejected": 0}],
|
||||
counts_uncertain=False,
|
||||
)
|
||||
|
||||
|
||||
def _partial() -> dict:
|
||||
return _report(
|
||||
bills=[{"denomination": 20, "requested": 2, "dispensed": 1, "rejected": 1}],
|
||||
counts_uncertain=False,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDispenseReportIn:
|
||||
def test_derived_numbers(self):
|
||||
r = DispenseReportIn(**_partial())
|
||||
assert r.dispensed_fiat_cents == 2000
|
||||
assert r.total_dispensed_notes == 1
|
||||
assert DispenseReportIn(**_confirmed()).dispensed_fiat_cents == 4000
|
||||
assert DispenseReportIn(**_report()).total_dispensed_notes == 0
|
||||
|
||||
def test_outcome_routing(self):
|
||||
assert _outcome_status(DispenseReportIn(**_confirmed())) == "pending"
|
||||
assert _outcome_status(DispenseReportIn(**_partial())) == "partial_pending"
|
||||
assert _outcome_status(DispenseReportIn(**_report())) == "cash_owed"
|
||||
|
||||
def test_rejects_cash_in_and_unknown_class(self):
|
||||
with pytest.raises(ValidationError):
|
||||
DispenseReportIn(**_report(tx_type="cash_in"))
|
||||
with pytest.raises(ValidationError):
|
||||
DispenseReportIn(**_report(error_class="weird"))
|
||||
with pytest.raises(ValidationError):
|
||||
DispenseReportIn(**_report(txid=" "))
|
||||
with pytest.raises(ValidationError):
|
||||
DispenseReportIn(**_report(fiat_cents=-1))
|
||||
|
||||
|
||||
class TestResumeCashOutOp:
|
||||
def test_is_a_known_type_and_machine_wide(self):
|
||||
assert "resume_cash_out" in CASSETTE_OP_TYPES
|
||||
op = CassetteOp(
|
||||
id="r1",
|
||||
machine_id="m1",
|
||||
position=0,
|
||||
op_type="resume_cash_out",
|
||||
created_at=_NOW,
|
||||
)
|
||||
wire = op.to_wire_dict()
|
||||
assert wire == {
|
||||
"id": "r1",
|
||||
"at": int(_NOW.timestamp()),
|
||||
"type": "resume_cash_out",
|
||||
}
|
||||
assert "position" not in wire
|
||||
|
||||
def test_position_must_be_zero_for_resume_and_positive_otherwise(self):
|
||||
with pytest.raises(ValidationError):
|
||||
CassetteOp(
|
||||
id="r1",
|
||||
machine_id="m1",
|
||||
position=2,
|
||||
op_type="resume_cash_out",
|
||||
created_at=_NOW,
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
CreateCassetteOpData(position=1, op_type="resume_cash_out")
|
||||
with pytest.raises(ValidationError):
|
||||
CreateCassetteOpData(position=0, op_type="refill", bills=5)
|
||||
CreateCassetteOpData(position=0, op_type="resume_cash_out") # ok
|
||||
|
||||
def test_resume_carries_no_bay_fields(self):
|
||||
with pytest.raises(ValidationError):
|
||||
CreateCassetteOpData(position=0, op_type="resume_cash_out", count=3)
|
||||
|
||||
|
||||
class TestWorklistModel:
|
||||
def test_new_buckets_default_empty(self):
|
||||
r = StuckSettlementsResponse(
|
||||
threshold_minutes=30,
|
||||
rejected=[],
|
||||
errored=[],
|
||||
stuck_pending=[],
|
||||
stuck_processing=[],
|
||||
)
|
||||
assert (
|
||||
r.cash_owed == []
|
||||
and r.partial_pending == []
|
||||
and r.dispense_unreported == []
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _Wired:
|
||||
"""Monkeypatched crud layer for handle_report_dispense."""
|
||||
|
||||
def __init__(self, monkeypatch, *, machine, settlement, existing_report=None):
|
||||
self.inserted = []
|
||||
self.applied = []
|
||||
self.spawned = []
|
||||
self.uncertain = []
|
||||
self.statuses = []
|
||||
self.settlement = settlement
|
||||
|
||||
async def get_machine(_hex):
|
||||
return machine
|
||||
|
||||
async def get_report(_mid, _txid, _at):
|
||||
return existing_report
|
||||
|
||||
async def get_settlement(_mid, txid):
|
||||
if self.settlement is not None and self.settlement.bitspire_txid == txid:
|
||||
return self.settlement
|
||||
return None
|
||||
|
||||
async def insert(mid, sid, report):
|
||||
self.inserted.append((mid, sid, report))
|
||||
return SimpleNamespace(id="rep1", settlement_id=sid, txid=report.txid)
|
||||
|
||||
async def apply(sid, report, new_status, reported_at):
|
||||
self.applied.append((sid, new_status, reported_at))
|
||||
return (
|
||||
self.settlement.copy(update={"status": new_status})
|
||||
if self.settlement
|
||||
else None
|
||||
)
|
||||
|
||||
async def set_uncertain(mid, since):
|
||||
self.uncertain.append((mid, since))
|
||||
|
||||
async def mark_status(sid, status, _msg):
|
||||
self.statuses.append((sid, status))
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
dispense_transport, "get_machine_by_atm_pubkey_hex", get_machine
|
||||
)
|
||||
monkeypatch.setattr(dispense_transport, "get_dispense_report", get_report)
|
||||
monkeypatch.setattr(
|
||||
dispense_transport, "get_settlement_by_txid", get_settlement
|
||||
)
|
||||
monkeypatch.setattr(dispense_transport, "insert_dispense_report", insert)
|
||||
monkeypatch.setattr(dispense_transport, "apply_dispense_outcome", apply)
|
||||
monkeypatch.setattr(
|
||||
dispense_transport, "set_machine_counts_uncertain", set_uncertain
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
dispense_transport, "_spawn_distribution", self.spawned.append
|
||||
)
|
||||
monkeypatch.setattr(crud_mod, "mark_settlement_status", mark_status)
|
||||
|
||||
|
||||
def _req(body, sender=_ATM_HEX):
|
||||
return SimpleNamespace(body=body, sender_pubkey=sender, event_id="ev1")
|
||||
|
||||
|
||||
class TestHandleReportDispense:
|
||||
def test_confirmed_captures_and_distributes(self, monkeypatch):
|
||||
w = _Wired(monkeypatch, machine=_machine(), settlement=_settlement())
|
||||
out = asyncio.run(handle_report_dispense(None, _req(_confirmed())))
|
||||
assert out["received"] is True
|
||||
assert out["settlement_status"] == "pending"
|
||||
assert w.applied == [("s1", "pending", datetime.fromtimestamp(1791529353))]
|
||||
assert w.spawned == ["s1"]
|
||||
assert w.inserted[0][1] == "s1" # linked to the settlement
|
||||
assert w.uncertain == []
|
||||
|
||||
def test_nothing_out_is_cash_owed_and_nothing_moves(self, monkeypatch):
|
||||
w = _Wired(monkeypatch, machine=_machine(), settlement=_settlement())
|
||||
out = asyncio.run(handle_report_dispense(None, _req(_report())))
|
||||
assert out["settlement_status"] == "cash_owed"
|
||||
assert w.applied[0][1] == "cash_owed"
|
||||
assert w.spawned == []
|
||||
# counts_uncertain on the report mirrors onto the machine immediately
|
||||
assert len(w.uncertain) == 1 and w.uncertain[0][0] == "m1"
|
||||
|
||||
def test_some_out_is_partial_pending_held_whole(self, monkeypatch):
|
||||
w = _Wired(monkeypatch, machine=_machine(), settlement=_settlement())
|
||||
out = asyncio.run(handle_report_dispense(None, _req(_partial())))
|
||||
assert out["settlement_status"] == "partial_pending"
|
||||
assert w.spawned == [] # ADR-005 Decision 1: one distribution, when final
|
||||
|
||||
def test_already_captured_settlement_is_recorded_not_moved(self, monkeypatch):
|
||||
w = _Wired(
|
||||
monkeypatch, machine=_machine(), settlement=_settlement(status="processed")
|
||||
)
|
||||
out = asyncio.run(handle_report_dispense(None, _req(_report())))
|
||||
assert out["settlement_status"] == "processed"
|
||||
assert w.applied == [] and w.spawned == []
|
||||
assert len(w.inserted) == 1 # the row still lands — it is information
|
||||
|
||||
def test_identical_resend_is_acked_without_a_new_row(self, monkeypatch):
|
||||
existing = SimpleNamespace(id="rep0", settlement_id="s1", txid=_TXID)
|
||||
w = _Wired(
|
||||
monkeypatch,
|
||||
machine=_machine(),
|
||||
settlement=_settlement(status="cash_owed"),
|
||||
existing_report=existing,
|
||||
)
|
||||
out = asyncio.run(handle_report_dispense(None, _req(_report())))
|
||||
assert out["received"] is True and out.get("duplicate") is True
|
||||
assert out["settlement_status"] == "cash_owed"
|
||||
assert w.inserted == [] and w.applied == []
|
||||
|
||||
def test_report_before_payment_is_stored_unlinked(self, monkeypatch):
|
||||
w = _Wired(monkeypatch, machine=_machine(), settlement=None)
|
||||
out = asyncio.run(handle_report_dispense(None, _req(_confirmed())))
|
||||
assert out["settlement_status"] is None
|
||||
assert w.inserted[0][1] is None
|
||||
assert w.spawned == []
|
||||
|
||||
def test_remediation_moves_the_owed_settlement_to_pending(self, monkeypatch):
|
||||
owed = _settlement(status="cash_owed")
|
||||
w = _Wired(monkeypatch, machine=_machine(), settlement=owed)
|
||||
body = _confirmed()
|
||||
body.update(txid="manual-1", remediates_txid=_TXID, at=1791530000)
|
||||
out = asyncio.run(handle_report_dispense(None, _req(body)))
|
||||
assert out["settlement_status"] == "pending"
|
||||
assert w.statuses == [("s1", "pending")]
|
||||
assert w.spawned == ["s1"]
|
||||
assert w.applied == [] # the ORIGINAL report's columns stay on the settlement
|
||||
|
||||
def test_remediation_that_did_not_confirm_leaves_it_owed(self, monkeypatch):
|
||||
w = _Wired(
|
||||
monkeypatch, machine=_machine(), settlement=_settlement(status="cash_owed")
|
||||
)
|
||||
body = _report()
|
||||
body.update(txid="manual-2", remediates_txid=_TXID, at=1791530001)
|
||||
out = asyncio.run(handle_report_dispense(None, _req(body)))
|
||||
assert out["settlement_status"] == "cash_owed"
|
||||
assert w.statuses == [] and w.spawned == []
|
||||
|
||||
def test_unpaired_sender_and_bad_body_are_refused(self, monkeypatch):
|
||||
_Wired(monkeypatch, machine=None, settlement=None)
|
||||
with pytest.raises(ValueError, match="not a paired machine"):
|
||||
asyncio.run(handle_report_dispense(None, _req(_report())))
|
||||
_Wired(monkeypatch, machine=_machine(), settlement=_settlement())
|
||||
with pytest.raises(ValueError, match="invalid report_dispense body"):
|
||||
asyncio.run(handle_report_dispense(None, _req({"txid": _TXID})))
|
||||
with pytest.raises(ValueError, match="sender_pubkey"):
|
||||
asyncio.run(handle_report_dispense(None, _req(_report(), sender="")))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gate in _handle_payment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _payment(is_in=True):
|
||||
return SimpleNamespace(
|
||||
success=True,
|
||||
wallet_id="w1",
|
||||
extra={
|
||||
"source": "bitspire",
|
||||
"type": "cash_out" if is_in else "cash_in",
|
||||
"txid": _TXID,
|
||||
},
|
||||
is_in=is_in,
|
||||
sat=54440 if is_in else -54440,
|
||||
payment_hash=_HASH,
|
||||
)
|
||||
|
||||
|
||||
def _data(tx_type) -> CreateDcaSettlementData:
|
||||
return CreateDcaSettlementData(
|
||||
machine_id="m1",
|
||||
payment_hash=_HASH,
|
||||
bitspire_txid=_TXID,
|
||||
wire_sats=54440,
|
||||
fiat_amount=40.0,
|
||||
fiat_code="EUR",
|
||||
exchange_rate=1361.0,
|
||||
principal_sats=54440,
|
||||
fee_sats=0,
|
||||
platform_fee_sats=0,
|
||||
operator_fee_sats=0,
|
||||
tx_type=tx_type,
|
||||
)
|
||||
|
||||
|
||||
class _GateWired:
|
||||
def __init__(self, monkeypatch, *, tx_type, early_report=None):
|
||||
self.created = []
|
||||
self.spawned = []
|
||||
self.adopted = []
|
||||
|
||||
async def get_machine(_wid):
|
||||
return _machine()
|
||||
|
||||
def attribution(_machine, _extra):
|
||||
return None
|
||||
|
||||
async def get_super():
|
||||
return SimpleNamespace(id="default")
|
||||
|
||||
def parse(**_kw):
|
||||
return _data(tx_type)
|
||||
|
||||
async def create(data, initial_status, error_message=None):
|
||||
self.created.append((data.tx_type, initial_status))
|
||||
return _settlement(status=initial_status, tx_type=data.tx_type)
|
||||
|
||||
async def process(sid):
|
||||
self.spawned.append(sid)
|
||||
|
||||
async def early(_mid, _txid):
|
||||
return early_report
|
||||
|
||||
async def adopt(settlement, machine, row):
|
||||
self.adopted.append((settlement.id, row.id))
|
||||
return "pending"
|
||||
|
||||
monkeypatch.setattr(tasks, "get_active_machine_by_wallet_id", get_machine)
|
||||
monkeypatch.setattr(tasks, "assert_nostr_attribution", attribution)
|
||||
monkeypatch.setattr(tasks, "get_super_config", get_super)
|
||||
monkeypatch.setattr(tasks, "parse_settlement", parse)
|
||||
monkeypatch.setattr(tasks, "create_settlement_idempotent", create)
|
||||
monkeypatch.setattr(tasks, "process_settlement", process)
|
||||
monkeypatch.setattr(crud_mod, "get_latest_unlinked_dispense_report", early)
|
||||
monkeypatch.setattr(dispense_transport, "adopt_unlinked_report", adopt)
|
||||
|
||||
|
||||
async def _drain():
|
||||
# let any create_task'd distribution run
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
class TestPaymentGate:
|
||||
def test_cash_out_lands_awaiting_dispense_and_does_not_distribute(
|
||||
self, monkeypatch
|
||||
):
|
||||
w = _GateWired(monkeypatch, tx_type="cash_out")
|
||||
|
||||
async def run():
|
||||
await tasks._handle_payment(_payment(is_in=True))
|
||||
await _drain()
|
||||
|
||||
asyncio.run(run())
|
||||
assert w.created == [("cash_out", "awaiting_dispense")]
|
||||
assert w.spawned == []
|
||||
assert w.adopted == []
|
||||
|
||||
def test_cash_in_is_unchanged(self, monkeypatch):
|
||||
w = _GateWired(monkeypatch, tx_type="cash_in")
|
||||
|
||||
async def run():
|
||||
await tasks._handle_payment(_payment(is_in=False))
|
||||
await _drain()
|
||||
|
||||
asyncio.run(run())
|
||||
assert w.created == [("cash_in", "pending")]
|
||||
assert w.spawned == ["s1"]
|
||||
|
||||
def test_early_report_is_adopted_when_the_payment_lands(self, monkeypatch):
|
||||
early = SimpleNamespace(id="rep-early", txid=_TXID)
|
||||
w = _GateWired(monkeypatch, tx_type="cash_out", early_report=early)
|
||||
|
||||
async def run():
|
||||
await tasks._handle_payment(_payment(is_in=True))
|
||||
await _drain()
|
||||
|
||||
asyncio.run(run())
|
||||
assert w.adopted == [("s1", "rep-early")]
|
||||
assert w.spawned == [] # adoption decides; the fake adopt did not spawn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Consumer mirror
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCashOutHoldMirror:
|
||||
def test_sets_and_clears_from_the_state_document(self):
|
||||
calls = []
|
||||
|
||||
async def setter(mid, since, reason, code):
|
||||
calls.append((mid, since, reason, code))
|
||||
|
||||
held = PublishCassettesPayload(
|
||||
positions={"2": {"denomination": 20, "count": 54}},
|
||||
cash_out_held_since=1791529353,
|
||||
cash_out_held_reason="Note stopped at the cassette exit",
|
||||
cash_out_held_code="78 42",
|
||||
)
|
||||
clear = PublishCassettesPayload(
|
||||
positions={"2": {"denomination": 20, "count": 54}}
|
||||
)
|
||||
asyncio.run(tasks._record_cash_out_hold("m1", held, setter))
|
||||
asyncio.run(tasks._record_cash_out_hold("m1", clear, setter))
|
||||
assert calls[0][0] == "m1"
|
||||
assert calls[0][1] == datetime.fromtimestamp(1791529353, tz=timezone.utc)
|
||||
assert calls[0][2:] == ("Note stopped at the cassette exit", "78 42")
|
||||
assert calls[1] == ("m1", None, None, None)
|
||||
|
|
@ -1,424 +0,0 @@
|
|||
"""
|
||||
ADR-005 slice 2: operator alerts, off-machine settlement, the
|
||||
settle_transaction op, alerts_pubkey.
|
||||
|
||||
- NIP-17 builder: a gift wrap (kind 1059) signed by a throwaway key, p-tagged
|
||||
to the recipient, backdated; its content opens with the recipient's key
|
||||
to a seal (kind 13) signed by the sender, which opens to a kind-14 rumor
|
||||
carrying the text. Built with a fake LocalSigner-style signer so no bunker
|
||||
is needed.
|
||||
- notify_cash_outcome is best-effort: publishes once, marks notified, never
|
||||
raises, and does not re-alert a settlement already notified.
|
||||
- apply_report_to_settlement alerts on cash_owed / partial_pending and not on
|
||||
a confirmed dispense.
|
||||
- settle_cash_owed_settlement: note appended, status → pending, distribution
|
||||
run; refuses other statuses.
|
||||
- models: settle_transaction op (machine-wide, carries txid + note, wire
|
||||
shape), alerts_pubkey validator (hex / npub / clear).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import coincurve
|
||||
import pytest
|
||||
from lnbits.utils.nostr import verify_event
|
||||
from pydantic import ValidationError
|
||||
|
||||
from .. import dispense_transport, distribution, notify
|
||||
from ..models import (
|
||||
CassetteOp,
|
||||
CreateCassetteOpData,
|
||||
DcaSettlement,
|
||||
DispenseReportIn,
|
||||
Machine,
|
||||
UpdateSuperConfigData,
|
||||
)
|
||||
from ..nip44 import decrypt_from, encrypt_for
|
||||
|
||||
_NOW = datetime(2026, 10, 9, 7, 2, 33)
|
||||
_OP_SEC = "00" * 31 + "01"
|
||||
_ALERT_SEC = "00" * 31 + "03"
|
||||
|
||||
|
||||
def _pub(sec_hex: str) -> str:
|
||||
return (
|
||||
coincurve.PrivateKey(bytes.fromhex(sec_hex)).public_key.format(True)[1:].hex()
|
||||
)
|
||||
|
||||
|
||||
_OP_PUB = _pub(_OP_SEC)
|
||||
_ALERT_PUB = _pub(_ALERT_SEC)
|
||||
|
||||
|
||||
def _machine() -> Machine:
|
||||
return Machine(
|
||||
id="m1",
|
||||
operator_user_id="op1",
|
||||
machine_npub="df20" * 16,
|
||||
wallet_id="w1",
|
||||
name="sintra",
|
||||
location=None,
|
||||
fiat_code="EUR",
|
||||
is_active=True,
|
||||
created_at=_NOW,
|
||||
updated_at=_NOW,
|
||||
)
|
||||
|
||||
|
||||
def _settlement(status="cash_owed", notified=None) -> DcaSettlement:
|
||||
return DcaSettlement(
|
||||
id="s1",
|
||||
machine_id="m1",
|
||||
payment_hash="ab" * 32,
|
||||
bitspire_event_id=None,
|
||||
bitspire_txid="tx_mv0madw6_wdhtea1v",
|
||||
wire_sats=54440,
|
||||
fiat_amount=40.0,
|
||||
fiat_code="EUR",
|
||||
exchange_rate=1361.0,
|
||||
principal_sats=54440,
|
||||
fee_sats=0,
|
||||
platform_fee_sats=0,
|
||||
operator_fee_sats=0,
|
||||
tx_type="cash_out",
|
||||
bills_json=None,
|
||||
cassettes_json=None,
|
||||
status=status,
|
||||
error_message=None,
|
||||
processed_at=None,
|
||||
created_at=_NOW,
|
||||
operator_notified_at=notified,
|
||||
)
|
||||
|
||||
|
||||
def _report(**over) -> DispenseReportIn:
|
||||
body: dict = {
|
||||
"txid": "tx_mv0madw6_wdhtea1v",
|
||||
"payment_hash": "ab" * 32,
|
||||
"dispense_confirmed": False,
|
||||
"error": "Note stopped at the cassette exit",
|
||||
"error_code": "F56DispenseError",
|
||||
"raw_code": "78 42",
|
||||
"error_class": "terminal",
|
||||
"fiat_cents": 4000,
|
||||
"currency": "EUR",
|
||||
"bills": [{"denomination": 20, "requested": 2, "dispensed": 0, "rejected": 0}],
|
||||
"at": 1791529353,
|
||||
}
|
||||
body.update(over)
|
||||
return DispenseReportIn(**body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A signer that behaves like a LocalSigner holding _OP_SEC, without lnbits' DB
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeSigner:
|
||||
def can_sign(self):
|
||||
return True
|
||||
|
||||
async def nip44_encrypt(self, plaintext, peer):
|
||||
return encrypt_for(plaintext, _OP_SEC, peer)
|
||||
|
||||
async def sign_event(self, event):
|
||||
from lnbits.utils.nostr import sign_event
|
||||
|
||||
return sign_event(event, _OP_PUB, coincurve.PrivateKey(bytes.fromhex(_OP_SEC)))
|
||||
|
||||
|
||||
def _wire_signer(monkeypatch):
|
||||
account = SimpleNamespace(pubkey=_OP_PUB, signer_type="LocalSigner", prvkey=_OP_SEC)
|
||||
|
||||
async def resolve(_uid):
|
||||
return account, _FakeSigner()
|
||||
|
||||
async def sign_as_operator(_uid, event):
|
||||
import time
|
||||
|
||||
event["created_at"] = int(time.time())
|
||||
return await _FakeSigner().sign_event(event)
|
||||
|
||||
monkeypatch.setattr(notify, "resolve_operator_signer", resolve)
|
||||
monkeypatch.setattr(notify, "sign_as_operator", sign_as_operator)
|
||||
return account
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NIP-17 builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGiftWrap:
|
||||
def test_wrap_seal_rumor_round_trip_to_the_recipient(self, monkeypatch):
|
||||
_wire_signer(monkeypatch)
|
||||
wrap = asyncio.run(
|
||||
notify.build_gift_wrapped_dm("op1", _ALERT_PUB, "hello operator")
|
||||
)
|
||||
|
||||
assert wrap["kind"] == 1059
|
||||
assert wrap["tags"] == [["p", _ALERT_PUB]]
|
||||
assert wrap["pubkey"] != _OP_PUB # throwaway key, not the sender
|
||||
assert verify_event(wrap)
|
||||
import time
|
||||
|
||||
assert wrap["created_at"] <= int(time.time())
|
||||
assert wrap["created_at"] >= int(time.time()) - 2 * 86400 - 5
|
||||
|
||||
seal = json.loads(decrypt_from(wrap["content"], _ALERT_SEC, wrap["pubkey"]))
|
||||
assert seal["kind"] == 13 and seal["pubkey"] == _OP_PUB and seal["tags"] == []
|
||||
assert verify_event(seal)
|
||||
|
||||
rumor = json.loads(decrypt_from(seal["content"], _ALERT_SEC, _OP_PUB))
|
||||
assert rumor["kind"] == 14
|
||||
assert rumor["pubkey"] == _OP_PUB
|
||||
assert rumor["content"] == "hello operator"
|
||||
assert rumor["tags"] == [["p", _ALERT_PUB]]
|
||||
assert "sig" not in rumor and len(rumor["id"]) == 64
|
||||
|
||||
def test_note_to_self_when_recipient_is_the_sender(self, monkeypatch):
|
||||
_wire_signer(monkeypatch)
|
||||
wrap = asyncio.run(notify.build_gift_wrapped_dm("op1", _OP_PUB, "to me"))
|
||||
seal = json.loads(decrypt_from(wrap["content"], _OP_SEC, wrap["pubkey"]))
|
||||
rumor = json.loads(decrypt_from(seal["content"], _OP_SEC, _OP_PUB))
|
||||
assert rumor["content"] == "to me"
|
||||
|
||||
|
||||
class TestNotifyCashOutcome:
|
||||
def _wire(self, monkeypatch, *, alerts_pubkey=None, publish_fails=False):
|
||||
_wire_signer(monkeypatch)
|
||||
published, marked = [], []
|
||||
|
||||
async def get_super():
|
||||
return SimpleNamespace(alerts_pubkey=alerts_pubkey)
|
||||
|
||||
async def publish(ev):
|
||||
if publish_fails:
|
||||
from ..nostr_publish import RelayUnavailable
|
||||
|
||||
raise RelayUnavailable("no relay")
|
||||
published.append(ev)
|
||||
|
||||
async def mark(sid):
|
||||
marked.append(sid)
|
||||
|
||||
monkeypatch.setattr(notify, "get_super_config", get_super)
|
||||
monkeypatch.setattr(notify, "publish_signed_event", publish)
|
||||
monkeypatch.setattr(notify, "mark_settlement_notified", mark)
|
||||
return published, marked
|
||||
|
||||
def test_alerts_once_to_the_operator_by_default(self, monkeypatch):
|
||||
published, marked = self._wire(monkeypatch)
|
||||
ok = asyncio.run(
|
||||
notify.notify_cash_outcome(
|
||||
_settlement(), _machine(), _report(), "cash_owed"
|
||||
)
|
||||
)
|
||||
assert ok is True
|
||||
assert marked == ["s1"]
|
||||
assert published[0]["tags"] == [["p", _OP_PUB]]
|
||||
seal = json.loads(
|
||||
decrypt_from(published[0]["content"], _OP_SEC, published[0]["pubkey"])
|
||||
)
|
||||
rumor = json.loads(decrypt_from(seal["content"], _OP_SEC, _OP_PUB))
|
||||
assert "CASH OWED" in rumor["content"]
|
||||
assert "sintra" in rumor["content"]
|
||||
assert "40.00 EUR" in rumor["content"]
|
||||
assert "tx_mv0madw6_wdhtea1v" in rumor["content"]
|
||||
assert "78 42" not in rumor["content"] # raw code stays in the dashboard
|
||||
assert "out of service" in rumor["content"] # terminal → latch mentioned
|
||||
|
||||
def test_alerts_pubkey_override_and_partial_wording(self, monkeypatch):
|
||||
published, _ = self._wire(monkeypatch, alerts_pubkey=_ALERT_PUB)
|
||||
rep = _report(
|
||||
bills=[{"denomination": 20, "requested": 2, "dispensed": 1, "rejected": 1}]
|
||||
)
|
||||
asyncio.run(
|
||||
notify.notify_cash_outcome(
|
||||
_settlement("partial_pending"), _machine(), rep, "partial_pending"
|
||||
)
|
||||
)
|
||||
assert published[0]["tags"] == [["p", _ALERT_PUB]]
|
||||
seal = json.loads(
|
||||
decrypt_from(published[0]["content"], _ALERT_SEC, published[0]["pubkey"])
|
||||
)
|
||||
rumor = json.loads(decrypt_from(seal["content"], _ALERT_SEC, _OP_PUB))
|
||||
assert (
|
||||
"PARTIAL" in rumor["content"] and "received 20.00 EUR" in rumor["content"]
|
||||
)
|
||||
|
||||
def test_does_not_re_alert_and_never_raises(self, monkeypatch):
|
||||
published, marked = self._wire(monkeypatch)
|
||||
ok = asyncio.run(
|
||||
notify.notify_cash_outcome(
|
||||
_settlement(notified=_NOW), _machine(), _report(), "cash_owed"
|
||||
)
|
||||
)
|
||||
assert ok is False and published == [] and marked == []
|
||||
|
||||
published, marked = self._wire(monkeypatch, publish_fails=True)
|
||||
ok = asyncio.run(
|
||||
notify.notify_cash_outcome(
|
||||
_settlement(), _machine(), _report(), "cash_owed"
|
||||
)
|
||||
)
|
||||
assert ok is False and marked == []
|
||||
|
||||
|
||||
class TestCaptureAlerts:
|
||||
def _wire(self, monkeypatch, status_after):
|
||||
alerts = []
|
||||
|
||||
async def apply(sid, report, new_status, reported_at):
|
||||
return _settlement(new_status)
|
||||
|
||||
async def alert(settlement, machine, report, status):
|
||||
alerts.append(status)
|
||||
|
||||
monkeypatch.setattr(dispense_transport, "apply_dispense_outcome", apply)
|
||||
monkeypatch.setattr(dispense_transport, "_spawn_distribution", lambda sid: None)
|
||||
monkeypatch.setattr(notify, "notify_cash_outcome", alert)
|
||||
return alerts
|
||||
|
||||
def test_alerts_on_owed_and_partial_not_on_confirmed(self, monkeypatch):
|
||||
alerts = self._wire(monkeypatch, "cash_owed")
|
||||
asyncio.run(
|
||||
dispense_transport.apply_report_to_settlement(
|
||||
_settlement("awaiting_dispense"), _report(), _machine()
|
||||
)
|
||||
)
|
||||
rep_partial = _report(
|
||||
bills=[{"denomination": 20, "requested": 2, "dispensed": 1, "rejected": 1}]
|
||||
)
|
||||
asyncio.run(
|
||||
dispense_transport.apply_report_to_settlement(
|
||||
_settlement("awaiting_dispense"), rep_partial, _machine()
|
||||
)
|
||||
)
|
||||
rep_ok = _report(
|
||||
dispense_confirmed=True,
|
||||
error=None,
|
||||
error_code=None,
|
||||
raw_code=None,
|
||||
error_class=None,
|
||||
bills=[{"denomination": 20, "requested": 2, "dispensed": 2, "rejected": 0}],
|
||||
)
|
||||
asyncio.run(
|
||||
dispense_transport.apply_report_to_settlement(
|
||||
_settlement("awaiting_dispense"), rep_ok, _machine()
|
||||
)
|
||||
)
|
||||
assert alerts == ["cash_owed", "partial_pending"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Off-machine settlement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSettleCashOwed:
|
||||
def _wire(self, monkeypatch):
|
||||
notes, statuses, processed = [], [], []
|
||||
|
||||
async def note(sid, text, author):
|
||||
notes.append((sid, text, author))
|
||||
return _settlement()
|
||||
|
||||
async def status(sid, st, msg):
|
||||
statuses.append((sid, st))
|
||||
return None
|
||||
|
||||
async def process(sid):
|
||||
processed.append(sid)
|
||||
|
||||
async def get(sid):
|
||||
return _settlement("processed")
|
||||
|
||||
import importlib
|
||||
|
||||
crud = importlib.import_module("..crud", __package__)
|
||||
monkeypatch.setattr(crud, "append_settlement_note", note)
|
||||
monkeypatch.setattr(distribution, "mark_settlement_status", status)
|
||||
monkeypatch.setattr(distribution, "process_settlement", process)
|
||||
monkeypatch.setattr(distribution, "get_settlement", get)
|
||||
return notes, statuses, processed
|
||||
|
||||
def test_closes_at_full_amount(self, monkeypatch):
|
||||
notes, statuses, processed = self._wire(monkeypatch)
|
||||
after = asyncio.run(
|
||||
distribution.settle_cash_owed_settlement(
|
||||
_settlement(), "handed 40 EUR by hand", "op1"
|
||||
)
|
||||
)
|
||||
assert after.status == "processed"
|
||||
assert notes[0][1].startswith(
|
||||
"Settled off-machine (full amount): handed 40 EUR"
|
||||
)
|
||||
assert statuses == [("s1", "pending")]
|
||||
assert processed == ["s1"]
|
||||
|
||||
def test_refuses_other_statuses(self, monkeypatch):
|
||||
self._wire(monkeypatch)
|
||||
with pytest.raises(ValueError):
|
||||
asyncio.run(
|
||||
distribution.settle_cash_owed_settlement(
|
||||
_settlement("processed"), "x", "op1"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSettleOpAndAlertsPubkey:
|
||||
def test_settle_transaction_is_machine_wide_and_carries_txid_note(self):
|
||||
op = CassetteOp(
|
||||
id="st1",
|
||||
machine_id="m1",
|
||||
position=0,
|
||||
op_type="settle_transaction",
|
||||
txid="tx_mv0madw6_wdhtea1v",
|
||||
note="paid by hand",
|
||||
created_at=_NOW,
|
||||
)
|
||||
assert op.to_wire_dict() == {
|
||||
"id": "st1",
|
||||
"at": int(_NOW.timestamp()),
|
||||
"type": "settle_transaction",
|
||||
"txid": "tx_mv0madw6_wdhtea1v",
|
||||
"note": "paid by hand",
|
||||
}
|
||||
CreateCassetteOpData(
|
||||
position=0, op_type="settle_transaction", txid="tx_1", note="n"
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
CreateCassetteOpData(
|
||||
position=0, op_type="settle_transaction"
|
||||
) # txid required
|
||||
with pytest.raises(ValidationError):
|
||||
CreateCassetteOpData(position=2, op_type="settle_transaction", txid="tx_1")
|
||||
with pytest.raises(ValidationError):
|
||||
CreateCassetteOpData(position=1, op_type="refill", bills=3, txid="tx_1")
|
||||
with pytest.raises(ValidationError):
|
||||
CreateCassetteOpData(position=1, op_type="refill", bills=3, note="no")
|
||||
|
||||
def test_alerts_pubkey_accepts_hex_npub_and_clear(self):
|
||||
assert (
|
||||
UpdateSuperConfigData(alerts_pubkey=_ALERT_PUB.upper()).alerts_pubkey
|
||||
== _ALERT_PUB
|
||||
)
|
||||
from lnbits.utils.nostr import hex_to_npub
|
||||
|
||||
assert (
|
||||
UpdateSuperConfigData(alerts_pubkey=hex_to_npub(_ALERT_PUB)).alerts_pubkey
|
||||
== _ALERT_PUB
|
||||
)
|
||||
assert UpdateSuperConfigData(alerts_pubkey="").alerts_pubkey == ""
|
||||
assert UpdateSuperConfigData().alerts_pubkey is None
|
||||
with pytest.raises(ValidationError):
|
||||
UpdateSuperConfigData(alerts_pubkey="not-a-key")
|
||||
301
views_api.py
301
views_api.py
|
|
@ -19,7 +19,6 @@ from lnbits.core.services.nsec_bunker import (
|
|||
)
|
||||
from lnbits.decorators import check_super_user, check_user_exists
|
||||
from lnbits.utils.nostr import normalize_public_key
|
||||
from loguru import logger
|
||||
|
||||
from .calculations import MAX_FEE_FRACTION_PER_DIRECTION
|
||||
from .cassette_transport import (
|
||||
|
|
@ -27,12 +26,20 @@ from .cassette_transport import (
|
|||
OperatorIdentityMissing,
|
||||
RelayUnavailable,
|
||||
SignerUnavailable,
|
||||
publish_ops_to_atm,
|
||||
publish_to_atm,
|
||||
)
|
||||
from .fee_transport import publish_fee_config
|
||||
from .pairing import (
|
||||
PairResult,
|
||||
PairingError,
|
||||
RevokeResult,
|
||||
default_relay_endpoint,
|
||||
pair_spire,
|
||||
revoke_spire,
|
||||
)
|
||||
from .crud import (
|
||||
append_settlement_note,
|
||||
count_completed_legs_for_settlement,
|
||||
create_cassette_op,
|
||||
create_dca_client,
|
||||
create_deposit,
|
||||
create_machine,
|
||||
|
|
@ -40,7 +47,6 @@ from .crud import (
|
|||
delete_deposit,
|
||||
delete_machine,
|
||||
force_reset_stuck_settlement,
|
||||
get_cassette_ops_window,
|
||||
get_client_balance_summary,
|
||||
get_commission_splits,
|
||||
get_dca_client,
|
||||
|
|
@ -60,12 +66,12 @@ from .crud import (
|
|||
get_super_config,
|
||||
list_all_active_machines,
|
||||
list_cassette_configs_for_machine,
|
||||
list_cassette_ops,
|
||||
lp_is_onboarded,
|
||||
replace_commission_splits,
|
||||
reset_settlement_for_retry,
|
||||
set_machine_pairing,
|
||||
set_machine_unpaired,
|
||||
update_cassette_config,
|
||||
update_dca_client,
|
||||
update_deposit,
|
||||
update_deposit_status,
|
||||
|
|
@ -74,18 +80,14 @@ from .crud import (
|
|||
)
|
||||
from .distribution import (
|
||||
apply_partial_dispense_and_redistribute,
|
||||
settle_cash_owed_settlement,
|
||||
process_settlement,
|
||||
settle_lp_balance,
|
||||
)
|
||||
from .fee_transport import publish_fee_config
|
||||
from .models import (
|
||||
AppendSettlementNoteData,
|
||||
CassetteConfig,
|
||||
CassetteOp,
|
||||
ClientBalanceSummary,
|
||||
CommissionSplit,
|
||||
CreateCassetteOpData,
|
||||
CreateDcaClientData,
|
||||
CreateDepositData,
|
||||
CreateMachineData,
|
||||
|
|
@ -96,7 +98,7 @@ from .models import (
|
|||
Machine,
|
||||
PairMachineData,
|
||||
PartialDispenseData,
|
||||
SettleCashOwedData,
|
||||
PublishCassettesPayload,
|
||||
SetCommissionSplitsData,
|
||||
SettleBalanceData,
|
||||
StuckSettlementsResponse,
|
||||
|
|
@ -106,14 +108,7 @@ from .models import (
|
|||
UpdateDepositStatusData,
|
||||
UpdateMachineData,
|
||||
UpdateSuperConfigData,
|
||||
)
|
||||
from .pairing import (
|
||||
PairingError,
|
||||
PairResult,
|
||||
RevokeResult,
|
||||
default_relay_endpoint,
|
||||
pair_spire,
|
||||
revoke_spire,
|
||||
UpsertCassetteConfigData,
|
||||
)
|
||||
|
||||
spirekeeper_api_router = APIRouter()
|
||||
|
|
@ -771,13 +766,7 @@ async def api_list_stuck_settlements(
|
|||
) -> StuckSettlementsResponse:
|
||||
"""Operator worklist of settlements that didn't process cleanly.
|
||||
|
||||
Returns seven lists. The first three (ADR-005 §6) mean a customer is owed
|
||||
money and render first:
|
||||
- cash_owed: the machine reported nothing dispensed; nothing moved
|
||||
- partial_pending: some notes out, value short; held until resolved
|
||||
- dispense_unreported: cash-out landed, machine never reported within
|
||||
the threshold
|
||||
Then:
|
||||
Returns four lists:
|
||||
- rejected: Nostr attribution cross-check failed — signer didn't
|
||||
match the machine identity. Investigate; do not retry.
|
||||
- errored: distribution ran and failed; retry endpoint handles these
|
||||
|
|
@ -792,9 +781,6 @@ async def api_list_stuck_settlements(
|
|||
buckets = await get_stuck_settlements_for_operator(user.id, threshold_minutes)
|
||||
return StuckSettlementsResponse(
|
||||
threshold_minutes=threshold_minutes,
|
||||
cash_owed=buckets["cash_owed"],
|
||||
partial_pending=buckets["partial_pending"],
|
||||
dispense_unreported=buckets["dispense_unreported"],
|
||||
rejected=buckets["rejected"],
|
||||
errored=buckets["errored"],
|
||||
stuck_pending=buckets["stuck_pending"],
|
||||
|
|
@ -817,67 +803,6 @@ async def api_get_settlement(
|
|||
return settlement
|
||||
|
||||
|
||||
|
||||
@spirekeeper_api_router.post(
|
||||
"/api/v1/dca/settlements/{settlement_id}/settle-cash-owed",
|
||||
response_model=DcaSettlement,
|
||||
)
|
||||
async def api_settle_cash_owed(
|
||||
settlement_id: str,
|
||||
data: SettleCashOwedData,
|
||||
user: User = Depends(check_user_exists),
|
||||
) -> DcaSettlement:
|
||||
"""ADR-005 §6 — the operator paid the customer outside the machine.
|
||||
|
||||
For a settlement in cash_owed or partial_pending: appends the provenance
|
||||
note, moves it to pending and distributes at the FULL amount (the customer
|
||||
is whole, so the sale is the full sale), and publishes a `settle_transaction`
|
||||
op so the machine flips its own `dispense_error` / `partial` row to
|
||||
`remediated`. Both ledgers close on one act; nothing is dispensed.
|
||||
|
||||
Until now the only way to clear an owed-cash row was to dispense more cash
|
||||
from the machine that had just jammed.
|
||||
|
||||
Errors: 404 not yours; 409 wrong status. The machine op is best-effort —
|
||||
recorded, and delivered with the next publish if the relay is down.
|
||||
"""
|
||||
settlement = await get_settlement(settlement_id)
|
||||
if settlement is None:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found")
|
||||
machine = await get_machine(settlement.machine_id)
|
||||
if machine is None or machine.operator_user_id != user.id:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found")
|
||||
if settlement.status not in ("cash_owed", "partial_pending"):
|
||||
raise HTTPException(
|
||||
HTTPStatus.CONFLICT,
|
||||
f"settlement is {settlement.status!r}; only cash_owed / partial_pending "
|
||||
"can be settled off-machine",
|
||||
)
|
||||
updated = await settle_cash_owed_settlement(settlement, data.note, user.id)
|
||||
|
||||
if machine.machine_npub and settlement.bitspire_txid:
|
||||
try:
|
||||
await create_cassette_op(
|
||||
machine.id,
|
||||
CreateCassetteOpData(
|
||||
position=0,
|
||||
op_type="settle_transaction",
|
||||
txid=settlement.bitspire_txid,
|
||||
note=data.note,
|
||||
),
|
||||
created_by=user.id,
|
||||
)
|
||||
window = await get_cassette_ops_window(machine.id)
|
||||
await publish_ops_to_atm(machine, window, user.id)
|
||||
except (OperatorIdentityMissing, SignerUnavailable, RelayUnavailable) as exc:
|
||||
logger.warning(
|
||||
f"spirekeeper: settle_transaction op for {settlement.bitspire_txid} "
|
||||
f"recorded but not published yet: {exc}"
|
||||
)
|
||||
except CassetteTransportError as exc:
|
||||
logger.error(f"spirekeeper: settle_transaction publish failed: {exc}")
|
||||
return updated
|
||||
|
||||
@spirekeeper_api_router.post(
|
||||
"/api/v1/dca/settlements/{settlement_id}/partial-dispense",
|
||||
response_model=DcaSettlement,
|
||||
|
|
@ -1177,21 +1102,16 @@ async def api_update_super_config(
|
|||
|
||||
|
||||
# =============================================================================
|
||||
# Cassettes — per-machine ATM inventory (bitspire ADR-004)
|
||||
# Cassette configs (#29 v1.1) — per-machine ATM cassette inventory
|
||||
# =============================================================================
|
||||
# GET /machines/{id}/cassettes — bays as the machine last reported them
|
||||
# GET /machines/{id}/cassettes/ops — recent operations, with their acks
|
||||
# POST /machines/{id}/cassettes/ops — record one operation and publish
|
||||
# v1.1 surface, paired with aiolabs/lamassu-next#56 ATM-side. Two endpoints:
|
||||
# GET /machines/{id}/cassettes — list rows for the operator UI
|
||||
# POST /machines/{id}/cassettes/publish — apply edits + publish kind-30078
|
||||
#
|
||||
# The operator does not write counts. It records what it DID to a bay and the
|
||||
# machine, which holds the notes, keeps the running total. The rows behind the
|
||||
# first endpoint are the machine's report, not an operator draft.
|
||||
#
|
||||
# This replaced an edit-and-publish form over absolute counts. Both sides wrote
|
||||
# the same value across a transport that never tells a writer it lost, so a
|
||||
# form loaded before a dispense discarded that dispense when published — and
|
||||
# nothing could detect it afterwards. Bay count stays hardware-determined:
|
||||
# rows appear and disappear only as the machine reports them.
|
||||
# Row creation (new (machine_id, position) pairs) is admin-only via the
|
||||
# bootstrap consumer task — slot count is hardware-determined. Operator-
|
||||
# side flow is edit-and-publish over the existing rows only; the editable
|
||||
# fields per row are denomination and count.
|
||||
|
||||
|
||||
@spirekeeper_api_router.get(
|
||||
|
|
@ -1209,150 +1129,105 @@ async def api_list_machine_cassettes(
|
|||
return await list_cassette_configs_for_machine(machine_id)
|
||||
|
||||
|
||||
@spirekeeper_api_router.get(
|
||||
"/api/v1/dca/machines/{machine_id}/cassettes/ops",
|
||||
response_model=list[CassetteOp],
|
||||
)
|
||||
async def api_list_machine_cassette_ops(
|
||||
machine_id: str,
|
||||
user: User = Depends(check_user_exists),
|
||||
) -> list[CassetteOp]:
|
||||
"""Recent cassette operations for a machine, newest first.
|
||||
|
||||
Each carries acked_at: null until the machine has reported that id back in
|
||||
its state document, which is how the dashboard distinguishes an operation
|
||||
that has been delivered from one that has merely been sent.
|
||||
"""
|
||||
await _machine_owned_by(machine_id, user.id)
|
||||
return await list_cassette_ops(machine_id)
|
||||
|
||||
|
||||
@spirekeeper_api_router.post(
|
||||
"/api/v1/dca/machines/{machine_id}/cassettes/ops",
|
||||
response_model=CassetteOp,
|
||||
"/api/v1/dca/machines/{machine_id}/cassettes/publish",
|
||||
response_model=list[CassetteConfig],
|
||||
)
|
||||
async def api_create_machine_cassette_op(
|
||||
async def api_publish_machine_cassettes(
|
||||
machine_id: str,
|
||||
data: CreateCassetteOpData,
|
||||
payload: PublishCassettesPayload,
|
||||
user: User = Depends(check_user_exists),
|
||||
) -> CassetteOp:
|
||||
"""Record one cassette operation and publish the machine's recent window.
|
||||
) -> list[CassetteConfig]:
|
||||
"""Operator submits the full per-machine cassette state for publish to
|
||||
the ATM. Validates the position set matches what's currently in
|
||||
cassette_configs for the machine (slot count is hardware-fixed),
|
||||
upserts each row, then encrypts + signs + publishes a kind-30078
|
||||
event tagged with d=bitspire-cassettes:<atm_pubkey_hex> and
|
||||
p=<atm_pubkey_hex>.
|
||||
|
||||
This replaces publishing absolute counts. The operator now records what it
|
||||
DID — a refill, an empty, a recount, a denomination change — and the
|
||||
machine keeps the running total. Both sides used to write the same value
|
||||
over a transport that never tells a writer it lost, so a dashboard form
|
||||
loaded before a dispense silently discarded that dispense when published.
|
||||
The `<m>` placeholder in the published d-tag is the ATM's hex pubkey
|
||||
from machine.machine_npub (canonicalised via normalize_public_key),
|
||||
NOT the internal dca_machines.id UUID — see #29 'machine_id semantics'
|
||||
section and coord-log 2026-05-30T11:50Z load-bearing nudge.
|
||||
|
||||
The op is recorded BEFORE the publish and is deliberately not rolled back
|
||||
if the publish fails. It represents something that physically happened —
|
||||
notes went into a bay — and that stays true whether or not a relay was
|
||||
reachable. Because each publish carries a window of recent operations
|
||||
rather than just the newest, an op that missed its own publish rides out
|
||||
with the next one.
|
||||
Returns the fresh cassette_configs rows after the upserts so the UI
|
||||
can refresh its table from one round-trip.
|
||||
|
||||
Errors:
|
||||
400 — machine not paired, so there is no ATM identity to publish to
|
||||
400 — position is not a bay this machine has reported
|
||||
503 — signer offline, or relay/nostrclient unreachable. The operation is
|
||||
still recorded and will be delivered with the next publish.
|
||||
400 — payload position set doesn't match the machine's stored set
|
||||
(operator publishing for a slot that doesn't exist on the
|
||||
ATM; or the bootstrap hasn't landed yet so no rows exist)
|
||||
400 — operator hasn't onboarded a Nostr identity
|
||||
503 — signer offline / client-side-only, or nostrclient extension
|
||||
not installed on this LNbits instance
|
||||
500 — anything else from the publish path
|
||||
"""
|
||||
machine = await _machine_owned_by(machine_id, user.id)
|
||||
if not machine.machine_npub:
|
||||
# Unpaired machine (machine_npub None — nullable since #29/m011) has no
|
||||
# ATM identity to publish a cassette config to. Fail fast with a clean
|
||||
# 400 instead of crashing publish_to_atm's normalize_public_key(None).
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"machine is not paired — pair it before recording cassette operations",
|
||||
"machine is not paired — pair it before publishing cassette config",
|
||||
)
|
||||
|
||||
existing = await list_cassette_configs_for_machine(machine_id)
|
||||
existing_positions = {row.position for row in existing}
|
||||
incoming_positions = set(payload.positions.keys())
|
||||
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
(
|
||||
"No cassette rows for this machine yet — waiting for its state "
|
||||
"event. Power on the ATM and confirm it has reached the "
|
||||
"configured relay; spirekeeper populates the bays on receipt."
|
||||
"No cassette_configs rows exist for this machine yet — "
|
||||
"waiting for the ATM's bootstrap state event. Power on the "
|
||||
"ATM and confirm it has reached the configured relay; "
|
||||
"spirekeeper will auto-populate cassette_configs on "
|
||||
"receipt."
|
||||
),
|
||||
)
|
||||
known_positions = {row.position for row in existing}
|
||||
if data.position not in known_positions:
|
||||
if existing_positions != incoming_positions:
|
||||
missing = existing_positions - incoming_positions
|
||||
extra = incoming_positions - existing_positions
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
(
|
||||
f"position {data.position} is not a bay this machine has "
|
||||
f"reported (has: {sorted(known_positions)}). Bay count is "
|
||||
"hardware-determined; re-provision via atm-tui to change it."
|
||||
"Payload position set doesn't match the machine's stored "
|
||||
f"set. Missing from payload: {sorted(missing)}; extra in "
|
||||
f"payload: {sorted(extra)}. Slot count is hardware-fixed "
|
||||
"— re-provision the ATM via atm-tui to add/remove physical "
|
||||
"bays, then re-publish."
|
||||
),
|
||||
)
|
||||
|
||||
op = await create_cassette_op(machine_id, data, created_by=user.id)
|
||||
|
||||
window = await get_cassette_ops_window(machine_id)
|
||||
try:
|
||||
await publish_ops_to_atm(machine, window, user.id)
|
||||
except OperatorIdentityMissing as exc:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
||||
except (SignerUnavailable, RelayUnavailable) as exc:
|
||||
raise HTTPException(
|
||||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
f"{exc} — the operation was recorded and will be delivered with "
|
||||
"the next publish",
|
||||
) from exc
|
||||
except CassetteTransportError as exc:
|
||||
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc)) from exc
|
||||
|
||||
return op
|
||||
@spirekeeper_api_router.post(
|
||||
"/api/v1/dca/machines/{machine_id}/resume-cash-out",
|
||||
response_model=CassetteOp,
|
||||
)
|
||||
async def api_resume_cash_out(
|
||||
machine_id: str,
|
||||
user: User = Depends(check_user_exists),
|
||||
) -> CassetteOp:
|
||||
"""Release a machine's cash-out hold (bitspire ADR-005 §5).
|
||||
|
||||
After a terminal dispenser fault the machine refuses cash-out until an
|
||||
operator has been to it. A `recount` releases the hold as a side effect;
|
||||
this is for the case where the jam was cleared without touching a bay
|
||||
count. Recorded as a machine-wide op (position 0) and published on the
|
||||
same operator channel as the cassette ops — the machine honours it only if
|
||||
it is stamped after the hold began, so a re-delivered old resume cannot
|
||||
clear a newer fault.
|
||||
|
||||
Errors mirror the cassette-op endpoint: 400 unpaired, 503 signer/relay
|
||||
unavailable (the op is recorded and rides out with the next publish).
|
||||
"""
|
||||
machine = await _machine_owned_by(machine_id, user.id)
|
||||
if not machine.machine_npub:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"machine is not paired — there is no ATM identity to publish to",
|
||||
)
|
||||
if machine.cash_out_held_since is None:
|
||||
logger.info(
|
||||
f"spirekeeper: resume_cash_out for machine {machine_id} with no hold "
|
||||
"on file — publishing anyway (the machine is the authority)"
|
||||
)
|
||||
|
||||
op = await create_cassette_op(
|
||||
# Apply each per-row edit so the operator-believed state on
|
||||
# spirekeeper reflects the published payload, even if the ATM
|
||||
# ack lands later (v2). updated_by audit-stamps the operator user id.
|
||||
for pos, row in payload.positions.items():
|
||||
updated = await update_cassette_config(
|
||||
machine_id,
|
||||
CreateCassetteOpData(position=0, op_type="resume_cash_out"),
|
||||
created_by=user.id,
|
||||
pos,
|
||||
UpsertCassetteConfigData(denomination=row.denomination, count=row.count),
|
||||
updated_by=user.id,
|
||||
)
|
||||
window = await get_cassette_ops_window(machine_id)
|
||||
if updated is None:
|
||||
# Defensive — we just validated the row exists, but a
|
||||
# concurrent delete could land between. Surface as 500.
|
||||
raise HTTPException(
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
f"cassette row for position {pos} disappeared mid-publish",
|
||||
)
|
||||
|
||||
try:
|
||||
await publish_ops_to_atm(machine, window, user.id)
|
||||
await publish_to_atm(machine, payload, user.id)
|
||||
except OperatorIdentityMissing as exc:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
||||
except (SignerUnavailable, RelayUnavailable) as exc:
|
||||
raise HTTPException(
|
||||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
f"{exc} — the resume was recorded and will be delivered with the "
|
||||
"next publish",
|
||||
) from exc
|
||||
except SignerUnavailable as exc:
|
||||
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, str(exc)) from exc
|
||||
except RelayUnavailable as exc:
|
||||
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, str(exc)) from exc
|
||||
except CassetteTransportError as exc:
|
||||
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc)) from exc
|
||||
return op
|
||||
|
||||
|
||||
return await list_cassette_configs_for_machine(machine_id)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue