feat(machine): durable dispense-report outbox to spirekeeper (ADR-005 §2)
Every cash-out now produces one report_dispense — on success as well as failure — and the machine does not stop sending it until spirekeeper acknowledges it. state.db gains a dispense_reports table (migration v13 → v14): the report is written INSIDE recordTransaction's SQLite transaction, alongside the transactions row, so a crash between the two cannot lose it. Rows carry attempts / last_attempt_at / last_error / acked_at. Three IPC calls (pending / ack / note-attempt) expose it to the renderer. The store builds the report when a cash-out reaches complete, dispenseFault or outOfCash: txid, payment hash, dispense_confirmed, error / error_code / raw_code / error_class, per-denomination requested vs dispensed vs rejected, the per-bay cassette record verbatim, and counts_uncertain. The success report is what lets the server capture (distribute) the settlement; the failure report is what puts a customer on the owed-cash worklist instead of leaving the only record on the ATM. Delivery is at-least-once: a flusher drains pending rows after each persist, on relay (re)connect, and every 60 s, acking only on an OK reply and backing off 30 s · 2^attempts (capped 1 h) otherwise. While spirekeeper has not registered the RPC every send fails the same way; the backoff keeps that quiet and the rows wait — this half ships first. The lightning service exposes reportDispense; the function pointer is set at all three lightning-init sites so the flusher works on every path.
This commit is contained in:
parent
7120f306b6
commit
e8106b665a
7 changed files with 319 additions and 2 deletions
|
|
@ -15,6 +15,16 @@ interface CashOutHold {
|
|||
since: number
|
||||
}
|
||||
|
||||
/** Mirrors state-store.PendingDispenseReport (ADR-005 §2). */
|
||||
interface PendingDispenseReport {
|
||||
txid: string
|
||||
payload: unknown
|
||||
createdAt: number
|
||||
attempts: number
|
||||
lastAttemptAt: number | null
|
||||
lastError: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime configuration interface (public info only)
|
||||
* These values are read from environment variables at runtime (not build time)
|
||||
|
|
@ -127,6 +137,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
setCashOutHold: (hold: CashOutHold): Promise<CashOutHold> =>
|
||||
ipcRenderer.invoke('state:set-cash-out-hold', hold),
|
||||
clearCashOutHold: (): Promise<boolean> => ipcRenderer.invoke('state:clear-cash-out-hold'),
|
||||
// Dispense-report outbox (ADR-005 §2)
|
||||
pendingDispenseReports: (limit?: number): Promise<PendingDispenseReport[]> =>
|
||||
ipcRenderer.invoke('state:pending-dispense-reports', limit),
|
||||
ackDispenseReport: (txid: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke('state:ack-dispense-report', txid),
|
||||
noteDispenseReportAttempt: (txid: string, error: string | null): Promise<void> =>
|
||||
ipcRenderer.invoke('state:note-dispense-report-attempt', txid, error),
|
||||
markStatePublished: (unixTimestamp: number): Promise<void> =>
|
||||
ipcRenderer.invoke('state:mark-state-published', unixTimestamp),
|
||||
|
||||
|
|
@ -323,6 +340,9 @@ declare global {
|
|||
getCashOutHold: () => Promise<CashOutHold | null>
|
||||
setCashOutHold: (hold: CashOutHold) => Promise<CashOutHold>
|
||||
clearCashOutHold: () => Promise<boolean>
|
||||
pendingDispenseReports: (limit?: number) => Promise<PendingDispenseReport[]>
|
||||
ackDispenseReport: (txid: string) => Promise<boolean>
|
||||
noteDispenseReportAttempt: (txid: string, error: string | null) => Promise<void>
|
||||
markStatePublished: (unixTimestamp: number) => Promise<void>
|
||||
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
||||
clearBunkerBinding: () => Promise<void>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue