feat(lnbits): report_dispense RPC and the dispense-report wire types (ADR-005 §2)

One cash-out's dispense outcome, sent on success as well as failure —
the success report is what captures the settlement server-side. Field
names follow lamassu-server's cash_out_txs / cash_out_actions
(dispense_confirmed, error, error_code) with raw_code and error_class
alongside, per-denomination bills with `requested`, per-bay cassettes
verbatim, the payment hash as the join key, and counts_uncertain.

Idempotent on txid (the server upserts), so the call is wrapped in
idempotent() and safe for the machine's outbox to retry. Until
spirekeeper registers the RPC it rejects with LnbitsRpcError, which the
outbox treats like any other transient failure.
This commit is contained in:
Padreug 2026-10-10 21:31:26 +02:00
commit 7120f306b6
3 changed files with 81 additions and 0 deletions

View file

@ -48,6 +48,8 @@ import type {
CreateWithdrawResult, CreateWithdrawResult,
LnbitsWithdrawLink, LnbitsWithdrawLink,
UniqueHashesResponse, UniqueHashesResponse,
DispenseReportBody,
DispenseReportAck,
} from './types.js' } from './types.js'
const LNBITS_KIND_RPC = 21000 const LNBITS_KIND_RPC = 21000
@ -234,6 +236,18 @@ export class LnbitsClient {
) )
} }
/**
* Report a cash-out's dispense outcome (ADR-005 §2). Sent on success and on
* failure; the success report is what captures the settlement server-side.
* Idempotent on `txid` (the server upserts), so it is safe to retry — and the
* caller keeps it in a durable outbox and resends until this resolves.
* Rejects with LnbitsRpcError while spirekeeper has not registered the RPC;
* the outbox treats that like any other transient failure.
*/
async reportDispense(body: DispenseReportBody): Promise<DispenseReportAck> {
return this.idempotent(() => this.sendRpc<DispenseReportAck>('report_dispense', { body }))
}
// ============================================================================ // ============================================================================
// Invoices // Invoices
// ============================================================================ // ============================================================================

View file

@ -80,4 +80,8 @@ export type {
UniqueHashEntry, UniqueHashEntry,
UniqueHashesResponse, UniqueHashesResponse,
LnbitsPayLink, LnbitsPayLink,
DispenseReportBody,
DispenseReportAck,
DispenseReportBill,
DispenseReportCassette,
} from './types.js' } from './types.js'

View file

@ -310,3 +310,66 @@ export interface MachineConfigResponse {
/** Freshness watermark (unix s) for the consumer's fee-config replay guard. */ /** Freshness watermark (unix s) for the consumer's fee-config replay guard. */
created_at: number created_at: number
} }
// ============================================================================
// Dispense outcome report (ADR-005 §2) — machine → spirekeeper `report_dispense`
// ============================================================================
/** Per-denomination outcome. `requested` is what the sale asked for. */
export interface DispenseReportBill {
denomination: number
requested: number
dispensed: number
rejected: number
}
/** Per-bay outcome, verbatim from the machine's cassette_bills row. */
export interface DispenseReportCassette {
position: number
denomination: number
provisioned: number
dispensed: number
rejected: number
}
/**
* One cash-out's dispense outcome, sent on SUCCESS as well as failure — the
* success report is what captures the settlement server-side. Field names
* follow lamassu-server's cash_out_txs / cash_out_actions (dispense_confirmed,
* error, error_code) so the server's model lines up with ten years of prior
* art. Idempotent on `txid`: the machine resends until acked, the server
* upserts.
*/
export interface DispenseReportBody {
txid: string
/** Hash of the invoice the customer paid — the join key to the LNbits payment. */
payment_hash: string | null
tx_type: 'cash_out'
/** Σ(denomination × dispensed) === requested fiat value (computed on value). */
dispense_confirmed: boolean
/** Human message; null on success. */
error: string | null
/** The error's NAME, e.g. 'F56DispenseError'; null on success. */
error_code: string | null
/** Driver-native code, e.g. '78 42'; null when none. */
raw_code: string | null
error_class: 'terminal' | 'recoverable' | 'inventory' | null
fiat_cents: number
currency: string
bills: DispenseReportBill[]
cassettes: DispenseReportCassette[]
/** The machine could not vouch for its bay counts after this dispense. */
counts_uncertain: boolean
/** Set when this report closes an earlier failed txid via manual dispense. */
remediates_txid?: string
/** unix seconds the outcome was recorded on the machine */
at: number
}
/** Server acknowledgement. `settlement_status` is what the server moved the settlement to. */
export interface DispenseReportAck {
txid: string
received: boolean
settlement_status?: string
}