Module: OpenReceive::Server::Reconciliation
- Defined in:
- lib/openreceive/server/reconciliation.rb
Overview
Terminal-transition decisions for one non-settled reconciliation result. Mirrors spec/test-vectors/attempt-reconciliation.json exactly: closure of an unpaid attempt requires a successful wallet scan observed at or after expiry plus the grace window — a local clock alone never closes a row.
Constant Summary collapse
- EXPIRY_GRACE_SECONDS =
Seconds past an attempt's expiry during which reconciliation still scans for a settlement before closing the attempt. Covers clock skew and wallets that accept a payment moments after nominal invoice expiry. The value 900 is pinned by spec/test-vectors/attempt-reconciliation.json ("expiry_grace_seconds", asserted by tools/conformance/ruby-crosslang.rb) and mirrored by JS OPENRECEIVE_ATTEMPT_EXPIRY_GRACE_SECONDS.
900
Class Method Summary collapse
-
.transition(expires_at:, status:, observed_at:, transaction_state: nil) ⇒ Object
Returns { "status" =>, "reason" => } to persist, or nil to keep the attempt pending.
Class Method Details
.transition(expires_at:, status:, observed_at:, transaction_state: nil) ⇒ Object
Returns { "status" =>, "reason" => } to persist, or nil to keep the attempt pending. Settled results never reach this decision; they deliver settlement instead. transaction_state is the explicit state field on the wallet's transaction record, when the scan found one; it decides whether a pending result past expiry plus grace is an operator-attention case or just an abandoned invoice.
26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
# File 'lib/openreceive/server/reconciliation.rb', line 26 def transition(expires_at:, status:, observed_at:, transaction_state: nil) case status.to_s when "failed" { "status" => "failed", "reason" => "wallet_reported_failed" } when "expired" { "status" => "expired", "reason" => "wallet_reported_expired" } when "not_found", "pending" # The invoice may outlive the requested expiry, so closure waits for a # scan past expiry plus grace instead of trusting the local clock alone. return nil if Integer(observed_at) < Integer(expires_at) + EXPIRY_GRACE_SECONDS if status.to_s == "not_found" { "status" => "expired", "reason" => "not_found_after_expiry" } elsif %w[pending accepted].include?(transaction_state.to_s) # `attention` requires the wallet's EXPLICIT claim that the # transaction is still in flight long after expiry. { "status" => "attention", "reason" => "unsettled_after_expiry" } else # NIP-47 state fields are optional and the unpaid scan lists unpaid # invoices, so a state-less record is indistinguishable from an # ordinary abandoned invoice — close it as expired. { "status" => "expired", "reason" => "no_finality_after_expiry" } end else raise ArgumentError, "unexpected reconciliation status: #{status}" end end |