openreceive-rails 0.4.10 → 0.4.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 624dd853dd3e316cd20bacb9a6c8f669ddb61558c6540d05da6000cc71d3efd2
4
- data.tar.gz: b2acac5e32343925c1d6974cb7ee1451f4ea34335aec82921feebe05c33b90e8
3
+ metadata.gz: 9b819ffb0e4f2872cd4de6da728c6404804496da560443a928d54d0aa25a5ac6
4
+ data.tar.gz: 674d40eb671af219dbaafd038eb4e8bee4849649e8bf32d82bf3fd1f45405414
5
5
  SHA512:
6
- metadata.gz: 86971442440a6ce6dc19c7f884ebac554e0a28999976ad6761b3843fbe1dee6daa9bb03087863e14f651d644140be222dace9e84df6b37de823af5b4151c71a0
7
- data.tar.gz: 1cf150cc363e76166a216f830a550a42d4615f0c99f287f9e9d69dc537fd60754e1fc26e3a1558a584b33056c88a49ccfe23d420ab565fdb4b4a14c65f3cfbeb
6
+ metadata.gz: a2ad30551213e01ce2fce1ccd0f083d0a26aacf8782a0e38d78c8e628d5a8bf68ebb765ba122fc8c9f8375c375bf7582e0fce8f92a5c8902d6e5a0f7effece7e
7
+ data.tar.gz: 16b67e1d5797202128acb827f388f371c6d391158c5227f6d8f5acf5fd530b2f3b32604ebbe81609b51cf03b4049f6fcf41a756cf5e81c8119c986e4eec466e9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.11 - 2026-09-21
4
+
5
+ Reconciliation progress is durable and bounded. A capped wallet walk now
6
+ stores its window — attempt identities, page offset, view and page
7
+ fingerprint — in `OpenReceive::ReconcileScan`, so the next request or worker
8
+ resumes where the last one stopped instead of restarting the history, and a
9
+ resumed scan is never treated as proof that a payment is absent. Scans carry
10
+ a deadline into the wallet request, and fulfillment commits in the same
11
+ transaction as settlement.
12
+
13
+ The default repository maps an unexpected persistence failure to
14
+ `HostPersistenceError` (retryable 503, payer instructions withheld) rather
15
+ than letting it surface as an unrelated error.
16
+
3
17
  ## 0.4.10 - 2026-09-16
4
18
 
5
19
  Release alongside the packaged checkout fix that shows one amount to send
data/README.md CHANGED
@@ -98,3 +98,36 @@ overrides it. Keep ordinary settings such as `config.price_currencies` in
98
98
  - Changelog: [CHANGELOG.md](CHANGELOG.md)
99
99
 
100
100
  MIT license.
101
+
102
+ ### Reconciliation upgrades and operator recovery
103
+
104
+ Explicit jobs, the notifications worker, notification fallback and mounted routes
105
+ share the durable lease/CAS gate and bounded scheduler. Disabling
106
+ `opportunistic_reconcile` disables request triggers only. Stop old application and
107
+ worker processes before deploying this scheduler contract, then start the updated
108
+ processes together. Resumed history slices discover positive wallet finality but
109
+ never prove absence; unpaid dense histories stay pending until a fresh complete
110
+ covering scan fits the budget. Deposit countdown/reuse expiry remains separate
111
+ from the saved Lightning invoice's settlement deadline.
112
+
113
+ `OpenReceivePayment.maintenance_candidates(after: cursor, limit: 100)` is a
114
+ read-only report with `candidates`, `next_cursor` and `scanned`. An operator can
115
+ review a selected candidate and call
116
+ `OpenReceivePayment.requeue_reviewed_attempt!(candidate, decision_id: "ticket-42")`.
117
+ The model checks the unchanged row under its reference lock, records the decision
118
+ in `openreceive_meta`, and requeues it without granting fulfillment. The ordinary
119
+ gated reconciler must still discover wallet finality. Settled rows remain
120
+ immutable, and genuine sibling payments never grant a second entitlement.
121
+ Candidates distinguish early swap-deadline closure from ordinary operator
122
+ attention; reports contain no swap credentials. Follow the
123
+ [coordinated upgrade guide](https://github.com/OpenReceive/openreceive/blob/master/docs/guides/payment-safety-upgrade.md).
124
+
125
+ MySQL repository operations require an outermost transaction; wrapping them in
126
+ an ambient ActiveRecord transaction is rejected before taking the named lock.
127
+
128
+ Fulfillment database writes and a host outbox belong in `on_paid`; a callback can
129
+ run again after rollback. External jobs need their own durable idempotency.
130
+ Storage-free `openreceive-server` handlers can call `on_paid` on every settled
131
+ poll, so advanced hosts own the conditional write/outbox. A raw create hook
132
+ refusal returns 409 with instructions withheld; repository infrastructure failures
133
+ remain retryable 503.
@@ -68,29 +68,58 @@ class OpenReceiveMeta < ActiveRecord::Base
68
68
  end
69
69
  end
70
70
 
71
- # Claim the durable global reconcile gate. Returns true when this caller may
72
- # run a wallet scan now; false (gate_busy) when another worker scanned within
73
- # interval_seconds. The winner is identified by reading back its own token —
74
- # the portable equivalent of an affected-row count, matching the JS
75
- # claimReconcileGate. A failed scan leaves claimed_at in place on purpose —
76
- # the next interval retries without a stampede.
77
- def self.claim_reconcile_gate(now:, interval_seconds:)
71
+ # The durable global scan gate returns a token/scheduler claim or nil.
72
+ # Checkpoints require that token and an unexpired lease; failed scans retain
73
+ # the interval and pre-scan queue so another worker can retry fairly.
74
+ def self.claim_reconcile_gate(now:, interval_seconds:, lease_seconds: 10)
78
75
  assert_supported_schema!
79
- claim = JSON.generate("claimed_at" => Integer(now), "token" => SecureRandom.uuid)
76
+ now = Integer(now)
80
77
  CAS_RETRIES.times do
81
78
  row = find_by(key: RECONCILE_GATE_KEY)
82
- if row.nil?
83
- cas(RECONCILE_GATE_KEY, claim, nil)
84
- else
85
- claimed_at = parse_claimed_at(row.value)
86
- if claimed_at && fresh_timestamp?(Integer(now), claimed_at, Integer(interval_seconds))
87
- return false
88
- end
89
- cas(RECONCILE_GATE_KEY, claim, row.rev)
90
- end
91
- return true if where(key: RECONCILE_GATE_KEY).pick(:value) == claim
79
+ current = parse_gate(row&.value)
80
+ claimed = current["claimed_at"]
81
+ return nil if claimed && fresh_timestamp?(now, claimed, interval_seconds)
82
+ return nil if current.fetch("lease_until", 0) > now && current.fetch("claimed_at", 0) <= now + 60
83
+
84
+ gate = {
85
+ "version" => 1, "claimed_at" => now, "token" => SecureRandom.uuid,
86
+ "lease_until" => now + lease_seconds, "interval_seconds" => interval_seconds,
87
+ "scheduler" => current.fetch("scheduler", { "cursor" => nil, "windows" => [] })
88
+ }
89
+ next unless cas(RECONCILE_GATE_KEY, JSON.generate(gate), row&.rev)
90
+
91
+ return { "token" => gate.fetch("token"), "scheduler" => gate.fetch("scheduler") }
92
92
  end
93
- false
93
+ nil
94
+ end
95
+
96
+ def self.checkpoint_reconcile_gate(claim, scheduler, now:, release: false)
97
+ assert_supported_schema!
98
+ row = find_by(key: RECONCILE_GATE_KEY)
99
+ return false if row.nil?
100
+
101
+ gate = parse_gate(row.value)
102
+ return false unless gate["token"] == claim.fetch("token") && gate.fetch("lease_until", 0) > now
103
+
104
+ windows = scheduler.fetch("windows")
105
+ raise ArgumentError, "Reconciliation checkpoint exceeded bounded cohorts" if windows.length > 2 || windows.any? { |w| w.fetch("attempts").length > 200 }
106
+
107
+ gate["scheduler"] = scheduler
108
+ gate["lease_until"] = 0 if release
109
+ encoded = JSON.generate(gate)
110
+ raise ArgumentError, "Reconciliation checkpoint exceeded 128 KiB" if encoded.bytesize > 128 * 1024
111
+
112
+ cas(RECONCILE_GATE_KEY, encoded, row.rev)
113
+ end
114
+
115
+ def self.parse_gate(value)
116
+ gate = value.nil? ? {} : JSON.parse(value.to_s)
117
+ gate = {} unless gate.is_a?(Hash)
118
+ raise OpenReceive::ConfigurationError, "Unsupported reconciliation checkpoint version; upgrade OpenReceive." if gate.fetch("version", 0) > 1
119
+
120
+ gate["version"] == 1 ? gate : { "scheduler" => { "cursor" => nil, "windows" => [] } }
121
+ rescue JSON::ParserError
122
+ { "scheduler" => { "cursor" => nil, "windows" => [] } }
94
123
  end
95
124
 
96
125
  def self.stored_schema_version
@@ -113,13 +142,5 @@ class OpenReceiveMeta < ActiveRecord::Base
113
142
  age < window_seconds
114
143
  end
115
144
 
116
- def self.parse_claimed_at(value)
117
- parsed = JSON.parse(value.to_s)
118
- claimed_at = parsed["claimed_at"]
119
- claimed_at.is_a?(Numeric) ? Integer(claimed_at) : nil
120
- rescue JSON::ParserError
121
- nil
122
- end
123
-
124
- private_class_method :stored_schema_version, :fresh_timestamp?, :parse_claimed_at
145
+ private_class_method :stored_schema_version, :fresh_timestamp?
125
146
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "digest"
4
+ require "time"
4
5
 
5
6
  # Engine-owned payment attempts. The table lives in the host application's
6
7
  # database (the install generator emits the migration), but the schema, locking,
@@ -88,6 +89,10 @@ class OpenReceivePayment < ActiveRecord::Base
88
89
  # COMMIT — releasing inside the transaction would leave a window where a
89
90
  # second worker could commit against state this one has already read.
90
91
  def self.with_mysql_reference_lock(key)
92
+ if connection.transaction_open?
93
+ raise RuntimeError, "OpenReceive MySQL reference operations require an outermost transaction; ambient transactions cannot retain the connection-scoped reference lock."
94
+ end
95
+
91
96
  name = "openreceive:#{Digest::SHA256.hexdigest(key)[0, 40]}"
92
97
  acquired = connection.select_value(
93
98
  sanitize_sql_array(["SELECT GET_LOCK(?, ?)", name, MYSQL_LOCK_TIMEOUT_SECONDS])
@@ -218,7 +223,7 @@ class OpenReceivePayment < ActiveRecord::Base
218
223
 
219
224
  with_reference_lock(payment.reference) do
220
225
  payment.reload
221
- return payment if payment.status == "settled"
226
+ return payment unless payment.status == "pending"
222
227
 
223
228
  first_for_order = !where(reference: payment.reference).settled.exists?
224
229
  payment.update!(
@@ -240,11 +245,16 @@ class OpenReceivePayment < ActiveRecord::Base
240
245
  raise ArgumentError, "invalid reconciliation status: #{status_text}"
241
246
  end
242
247
 
243
- where(payment_hash: payment_hash.to_s.downcase, status: "pending").update_all(
244
- status: status_text,
245
- status_reason: reason,
246
- updated_at: Time.at(Integer(observed_at)).utc
247
- )
248
+ payment = find_by(payment_hash: payment_hash.to_s.downcase)
249
+ return if payment.nil?
250
+
251
+ with_reference_lock(payment.reference) do
252
+ where(payment_hash: payment.payment_hash, status: "pending").update_all(
253
+ status: status_text,
254
+ status_reason: reason,
255
+ updated_at: Time.at(Integer(observed_at)).utc
256
+ )
257
+ end
248
258
  end
249
259
 
250
260
  # Pending attempts for the reconciler's next wallet scan — oldest first, one
@@ -252,19 +262,103 @@ class OpenReceivePayment < ActiveRecord::Base
252
262
  # closest to their closure deadline are always covered, and a backlog drains
253
263
  # over several passes instead of widening one wallet scan window without
254
264
  # bound. Terminal rows never return.
255
- def self.reconcilable_attempts
265
+ def self.reconcilable_attempts(after: nil, limit: OpenReceive::Server::RECONCILE_BATCH_SIZE)
256
266
  OpenReceiveMeta.assert_supported_schema!
257
- pending.order(created_at: :asc)
258
- .limit(OpenReceive::Server::RECONCILE_BATCH_SIZE)
259
- .pluck(:payment_hash, :created_at, :expires_at).map do |hash, created_at, expires_at|
267
+ query = pending.order(created_at: :asc, payment_hash: :asc)
268
+ if after
269
+ created = Time.at(after.fetch("created_at")).utc
270
+ query = query.where("created_at > ? OR (created_at = ? AND payment_hash > ?)", created, created, after.fetch("payment_hash"))
271
+ end
272
+ query.limit([limit, OpenReceive::Server::RECONCILE_BATCH_SIZE].min)
273
+ .pluck(:payment_hash, :created_at, :checkout_data).map do |hash, created_at, checkout|
260
274
  {
261
- "payment_hash" => hash,
262
- "created_at" => created_at.to_i,
263
- "expires_at" => expires_at.to_i
275
+ "payment_hash" => hash, "created_at" => created_at.to_i,
276
+ "expires_at" => settlement_expires_at(checkout, hash),
277
+ "created_at_source" => checkout["created_at_source"] || checkout[:created_at_source] || "host"
264
278
  }
265
279
  end
266
280
  end
267
281
 
282
+ # Same reconciliation DTO for authenticated by-hash notification lookup.
283
+ def self.find_pending_attempt(payment_hash)
284
+ OpenReceiveMeta.assert_supported_schema!
285
+ row = pending.where(payment_hash: payment_hash.to_s.downcase).pick(:payment_hash, :created_at, :checkout_data)
286
+ return nil if row.nil?
287
+
288
+ hash, created_at, checkout = row
289
+ {
290
+ "payment_hash" => hash, "created_at" => created_at.to_i,
291
+ "expires_at" => settlement_expires_at(checkout, hash),
292
+ "created_at_source" => checkout["created_at_source"] || checkout[:created_at_source] || "host"
293
+ }
294
+ end
295
+
296
+ # Saved Lightning deadline, independent of the payer's deposit countdown.
297
+ def self.settlement_expires_at(checkout, payment_hash)
298
+ value = checkout[:expires_at] || checkout["expires_at"] || checkout[:expiresAt] || checkout["expiresAt"]
299
+ raise ArgumentError unless value.is_a?(Integer) || (value.is_a?(String) && value.match?(/\A[0-9]+\z/))
300
+
301
+ expiry = Integer(value)
302
+ raise ArgumentError if expiry <= 0
303
+
304
+ expiry
305
+ rescue TypeError, ArgumentError, NoMethodError
306
+ raise RuntimeError, "Corrupt checkout_data wallet expiry on openreceive payment attempt #{payment_hash}."
307
+ end
308
+
309
+ # Host-invoked dry-run report. Normal routes never read terminal repair candidates.
310
+ def self.maintenance_candidates(after: nil, limit: 100)
311
+ OpenReceiveMeta.assert_supported_schema!
312
+ limit = [[limit, 1].max, 1000].min
313
+ query = where(status: %w[attention expired])
314
+ if after
315
+ stamp = Time.iso8601(after.fetch("updated_at"))
316
+ query = query.where("updated_at > ? OR (updated_at = ? AND payment_hash > ?)", stamp, stamp, after.fetch("payment_hash"))
317
+ end
318
+ rows = query.order(:updated_at, :payment_hash).limit(limit).to_a
319
+ cursor = rows.length == limit ? { "updated_at" => rows.last.updated_at.utc.iso8601(6), "payment_hash" => rows.last.payment_hash } : nil
320
+ { "candidates" => rows.filter_map { |row| repair_candidate(row) }, "next_cursor" => cursor, "scanned" => rows.length }
321
+ end
322
+
323
+ def self.requeue_reviewed_attempt!(candidate, decision_id:)
324
+ OpenReceiveMeta.assert_supported_schema!
325
+ unless /\A[A-Za-z0-9._:-]{1,120}\z/.match?(decision_id.to_s)
326
+ raise ArgumentError, "decision_id must be a nonsecret operator ticket identifier."
327
+ end
328
+ payment = find_by(payment_hash: candidate.fetch("payment_hash"))
329
+ return false if payment.nil?
330
+
331
+ with_reference_lock(payment.reference) do
332
+ payment.reload
333
+ return false unless repair_candidate(payment) == candidate
334
+
335
+ audit_key = "repair:#{payment.payment_hash}:#{decision_id}"
336
+ return false if OpenReceiveMeta.exists?(key: audit_key)
337
+
338
+ audit = candidate.merge("decision_id" => decision_id, "requeued_at" => Time.now.to_i)
339
+ OpenReceiveMeta.create!(key: audit_key, value: JSON.generate(audit), rev: 0)
340
+ payment.update!(status: "pending", status_reason: "operator_requeued")
341
+ true
342
+ end
343
+ end
344
+
345
+ def self.repair_candidate(row)
346
+ return nil unless %w[attention expired].include?(row.status)
347
+
348
+ wallet_expiry = settlement_expires_at(row.checkout_data, row.payment_hash)
349
+ reason = row.status == "attention" ? "operator_attention" : nil
350
+ if row.swap_data.present? && %w[not_found_after_expiry no_finality_after_expiry unsettled_after_expiry].include?(row.status_reason) &&
351
+ wallet_expiry > row.expires_at.to_i && row.updated_at.to_i >= row.expires_at.to_i + 900 && row.updated_at.to_i < wallet_expiry + 900
352
+ reason = "early_deposit_deadline_closure"
353
+ end
354
+ return nil if reason.nil?
355
+
356
+ { "reference" => row.reference, "payment_hash" => row.payment_hash,
357
+ "status" => row.status, "status_reason" => row.status_reason,
358
+ "updated_at" => row.updated_at.utc.iso8601(6), "instruction_expires_at" => row.expires_at.to_i,
359
+ "wallet_expires_at" => wallet_expiry, "reason" => reason }
360
+ end
361
+
268
362
  def self.reusable?(payment, now = Time.current)
269
363
  payment.expires_at.to_i - now.to_i > REUSE_BUFFER_SECONDS
270
364
  end
@@ -359,6 +359,8 @@ module OpenReceive
359
359
  # 409 CONFLICT. Leaving AttemptConflict unwrapped lets request_handler
360
360
  # #commit treat it as infrastructure failure (retryable 503 persist).
361
361
  raise OpenReceive::Server::ConflictError, e.message
362
+ rescue StandardError
363
+ raise OpenReceive::Server::HostPersistenceError
362
364
  end
363
365
  end
364
366
  end
@@ -13,13 +13,22 @@ module OpenReceive
13
13
  "",
14
14
  "WHAT OPENRECEIVE GUARANTEES",
15
15
  "",
16
- "Across every settlement path OpenReceive itself owns (wallet notifications,",
17
- "the opportunistic reconcile pass, an explicit reconcile job), the settlement",
18
- "hook runs AT MOST ONCE per reference. The library serializes on its own",
19
- "`{{table}}` rows, decides the winner there, and runs your hook",
20
- "inside that same transaction. A second payment to a second invoice for the",
21
- "same order is still recorded - with `status_reason = 'duplicate_settlement'`",
22
- "- but never fulfills a second time. You do not need to add a lock for this.",
16
+ "Repository-backed settlement paths (wallet notifications, gated reconciliation,",
17
+ "and explicit jobs) commit fulfillment for only the first settled attempt per",
18
+ "reference. The library locks its `{{table}}` rows, decides the winner,",
19
+ "and awaits your hook inside the same database transaction. A failure rolls",
20
+ "back payment and host writes together; the hook can run again on a retry.",
21
+ "A genuine second payment is still recorded with",
22
+ "`status_reason = 'duplicate_settlement'`, without another fulfillment.",
23
+ "",
24
+ "Write an entitlement or an outbox job through the supplied transaction. Email,",
25
+ "shipping APIs, and other external effects cannot commit atomically with it;",
26
+ "your outbox worker must use durable idempotency for external delivery.",
27
+ "A best-effort after-paid callback may be lost after commit and is not an outbox.",
28
+ "",
29
+ "Raw storage-free handlers do not provide this transaction: their on_paid hook",
30
+ "may run on every settled poll. Those hosts own the durable conditional write",
31
+ "or outbox themselves; process-local deduplication does not replace it.",
23
32
  "",
24
33
  "That makes the reference the unit of fulfillment: give every payable order",
25
34
  "its own reference, created before checkout, kept across retries, and never",
@@ -51,7 +60,7 @@ module OpenReceive
51
60
  "conditional UPDATE, take a row lock for the duration instead:",
52
61
  "",
53
62
  " SELECT * FROM orders WHERE id = :reference FOR UPDATE; -- postgres/mysql",
54
- " -- ...check state, ship, write the new state, all before COMMIT.",
63
+ " -- ...check state, grant entitlement/enqueue work, all before COMMIT.",
55
64
  "",
56
65
  "Run either one inside the transaction OpenReceive hands your settlement",
57
66
  "hook, so the order transition and the payment record commit together.",
@@ -5,6 +5,6 @@ module OpenReceive
5
5
  # top-level `::Rails` framework constant — engine code always references the framework as
6
6
  # `::Rails` to avoid shadowing.
7
7
  module Rails
8
- VERSION = "0.4.10"
8
+ VERSION = "0.4.11"
9
9
  end
10
10
  end
@@ -2,17 +2,15 @@
2
2
 
3
3
  require "json"
4
4
  require "openreceive/server"
5
+ require "openreceive/reconcile_scan"
5
6
 
6
7
  module OpenReceive
7
8
  # Floor for the durable reconcile-gate interval (seconds); stretched by
8
9
  # invoice age (2s while any pending invoice is under 2 minutes old, 6s under
9
10
  # 5 minutes, else 12s). Mirrors the JS OPENRECEIVE_MIN_RECONCILE_INTERVAL_SECONDS.
10
11
  MIN_RECONCILE_INTERVAL_SECONDS = 2
11
- # Wall-clock bound on an awaited request-path pass. Enforced as a deadline
12
- # the wallet scan checks between page fetches rather than a Timeout.timeout:
13
- # Thread#raise at an arbitrary point could tear down an ActiveRecord
14
- # connection or kill a host's on_paid fulfillment mid-flight, on every
15
- # winning request. A pass that runs out of budget simply stops walking.
12
+ # The deadline reaches the wallet adapter, which bounds only network I/O.
13
+ # Never interrupt the whole pass: it also runs host/database transactions.
16
14
  RECONCILE_SCAN_TIMEOUT_SECONDS = 9
17
15
  # Wallet-history pages a request-path pass may walk, mirroring the JS
18
16
  # OPENRECEIVE_RECONCILE_SCAN_MAX_PAGES.
@@ -29,8 +27,8 @@ module OpenReceive
29
27
  # from a successful wallet scan result observed at or after expiry plus
30
28
  # OpenReceive::Server::Reconciliation::EXPIRY_GRACE_SECONDS — a local clock
31
29
  # alone never closes a row, because a payment could have settled while the
32
- # application was offline. A wallet failure raises and leaves every row
33
- # pending for the next pass, and a hash absent from the pass results (a
30
+ # application was offline. A later wallet failure preserves already committed
31
+ # finality and leaves unresolved rows pending. A hash absent from pass results (a
34
32
  # truncated scan never proved it absent) is no information — the attempt
35
33
  # stays untouched.
36
34
  #
@@ -41,34 +39,6 @@ module OpenReceive
41
39
  # { "payment_hash", "status", "paid_at"?, "details"? } hashes) so callers —
42
40
  # notably payments/check — can serve a requested hash straight from the
43
41
  # pass instead of adding a second per-invoice wallet walk.
44
- def reconcile!(overlap_seconds: 60, now: nil, max_pages: nil, deadline: nil)
45
- attempts = OpenReceivePayment.reconcilable_attempts
46
- return [] if attempts.empty?
47
-
48
- observed_at = Integer(now || Time.now.to_i)
49
- request = {
50
- "attempts" => attempts,
51
- "overlap_seconds" => overlap_seconds,
52
- "until" => observed_at + overlap_seconds
53
- }
54
- request["max_pages"] = max_pages unless max_pages.nil?
55
- request["deadline"] = deadline unless deadline.nil?
56
- results = config.service.reconcile_payments(request)
57
- log_reconcile_pass(attempts, results, overlap_seconds, observed_at)
58
- by_hash = attempts.to_h { |attempt| [attempt.fetch("payment_hash"), attempt] }
59
- results.each do |checked|
60
- attempt = by_hash[checked.fetch("payment_hash")]
61
- next if attempt.nil?
62
-
63
- if checked["status"] == "settled" && checked["paid_at"]
64
- settle_attempt(checked)
65
- else
66
- record_attempt_transition(attempt, checked, observed_at)
67
- end
68
- end
69
- results
70
- end
71
-
72
42
  # Opportunistic settlement discovery, piggybacked on any OpenReceive call
73
43
  # (the engine's around_action runs it before every mounted route): skip
74
44
  # without a wallet call when nothing is pending, try the durable
@@ -87,30 +57,99 @@ module OpenReceive
87
57
  setting = config.opportunistic_reconcile
88
58
  return { "reason" => "disabled" } if setting == false
89
59
 
60
+ gated_reconcile!(now: now)
61
+ end
62
+
63
+ def reconcile!(overlap_seconds: 60, now: nil)
64
+ gated_reconcile!(overlap_seconds: overlap_seconds, now: now).fetch("checks", [])
65
+ end
66
+
67
+ def gated_reconcile!(overlap_seconds: 60, now: nil)
90
68
  attempts = OpenReceivePayment.reconcilable_attempts
91
69
  return { "reason" => "no_pending" } if attempts.empty?
92
70
 
93
71
  observed_at = Integer(now || Time.now.to_i)
94
- interval = reconcile_gate_interval_seconds(attempts, observed_at, setting)
95
- unless OpenReceiveMeta.claim_reconcile_gate(now: observed_at, interval_seconds: interval)
96
- # Another worker scanned within the interval; this request pays nothing.
97
- openreceive_logger&.debug(
98
- "[openreceive] opportunistic reconcile: gate_busy " \
99
- "(#{attempts.length} pending, interval #{interval}s)"
100
- )
101
- return { "reason" => "gate_busy" }
72
+ interval = reconcile_gate_interval_seconds(attempts, observed_at, config.opportunistic_reconcile)
73
+ claim = OpenReceiveMeta.claim_reconcile_gate(now: observed_at, interval_seconds: interval)
74
+ return { "reason" => "gate_busy" } if claim.nil?
75
+
76
+ scheduler = claim.fetch("scheduler")
77
+ windows = scheduler.fetch("windows")
78
+ if windows.length < 2
79
+ candidates = OpenReceivePayment.reconcilable_attempts(after: scheduler["cursor"])
80
+ if candidates.empty?
81
+ scheduler["cursor"] = nil
82
+ candidates = OpenReceivePayment.reconcilable_attempts
83
+ end
84
+ unless candidates.empty?
85
+ last = candidates.last
86
+ scheduler["cursor"] = candidates.length < Server::RECONCILE_BATCH_SIZE ? nil : last.slice("created_at", "payment_hash")
87
+ queued = windows.flat_map { |w| w.fetch("attempts").map { |a| a.fetch("payment_hash") } }
88
+ cohort = candidates.reject { |a| queued.include?(a.fetch("payment_hash")) }
89
+ windows << ReconcileScan.new_window(cohort, observed_at, overlap_seconds) unless cohort.empty?
90
+ end
91
+ end
92
+ window = windows.shift
93
+ # Checkpoint without the active window, so failures and process loss free
94
+ # its slot. Pending rows return on cursor wrap; successful slices resume.
95
+ checkpoint_now = now.nil? ? Time.now.to_i : observed_at
96
+ return { "reason" => "gate_busy" } unless OpenReceiveMeta.checkpoint_reconcile_gate(claim, scheduler, now: checkpoint_now)
97
+ if window.nil?
98
+ OpenReceiveMeta.checkpoint_reconcile_gate(claim, scheduler, now: checkpoint_now, release: true)
99
+ return { "reason" => "no_pending" }
102
100
  end
103
101
 
104
- checks = reconcile!(
105
- now: observed_at,
102
+ by_hash = window.fetch("attempts").to_h { |a| [a.fetch("payment_hash"), a] }
103
+ delivered = {}
104
+ before_scan = JSON.parse(JSON.generate(scheduler))
105
+ deliver_finality = lambda do |checked|
106
+ hash = checked.fetch("payment_hash")
107
+ current = now.nil? ? Time.now.to_i : observed_at
108
+ unless OpenReceiveMeta.checkpoint_reconcile_gate(claim, before_scan, now: current)
109
+ delivered[hash] = false
110
+ next
111
+ end
112
+ if checked["status"] == "settled" && checked["paid_at"]
113
+ delivered[hash] = settle_attempt(checked)
114
+ else
115
+ record_attempt_transition(by_hash.fetch(hash), checked, observed_at)
116
+ delivered[hash] = true
117
+ end
118
+ end
119
+ checks, complete, stalled = ReconcileScan.slice(config.service, window,
106
120
  max_pages: RECONCILE_SCAN_MAX_PAGES,
107
- deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + RECONCILE_SCAN_TIMEOUT_SECONDS
108
- )
109
- { "reason" => "ran", "checks" => checks }
121
+ deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + RECONCILE_SCAN_TIMEOUT_SECONDS, on_finality: deliver_finality)
122
+ lease_owned = OpenReceiveMeta.checkpoint_reconcile_gate(claim, before_scan, now: now.nil? ? Time.now.to_i : observed_at)
123
+ committed = checks.filter_map do |checked|
124
+ if delivered.key?(checked.fetch("payment_hash"))
125
+ next unless delivered.fetch(checked.fetch("payment_hash"))
126
+ elsif checked["status"] == "settled" && checked["paid_at"]
127
+ next
128
+ else
129
+ next unless lease_owned
130
+
131
+ record_attempt_transition(by_hash.fetch(checked.fetch("payment_hash")), checked,
132
+ checked.fetch("_coverage_started_at", observed_at))
133
+ end
134
+ checked.reject { |key, _| key.start_with?("_") }
135
+ end
136
+ unless complete || stalled
137
+ times = window.fetch("attempts").map { |a| a.fetch("created_at") }.uniq.sort
138
+ if windows.empty? && times.length > 1 && window.fetch("attempts").all? { |a| a["created_at_source"] == "wallet" }
139
+ middle = times[times.length / 2]
140
+ window.fetch("attempts").partition { |a| a.fetch("created_at") < middle }.each do |half|
141
+ windows << ReconcileScan.new_window(half, observed_at, overlap_seconds)
142
+ end
143
+ else
144
+ windows << window
145
+ end
146
+ end
147
+ checkpoint_now = now.nil? ? Time.now.to_i : observed_at
148
+ OpenReceiveMeta.checkpoint_reconcile_gate(claim, scheduler, now: checkpoint_now, release: true)
149
+ log_reconcile_pass(window.fetch("attempts"), committed, window)
150
+ { "reason" => "ran", "checks" => committed }
110
151
  rescue StandardError => e
111
- openreceive_logger&.warn(
112
- "[openreceive] opportunistic reconcile failed (will retry): #{sanitize_failure_message(e)}"
113
- )
152
+ openreceive_logger&.warn("[openreceive] reconciliation failed (will retry): #{sanitize_failure_message(e)}")
114
153
  { "reason" => "scan_failed" }
115
154
  end
116
155
 
@@ -172,13 +211,12 @@ module OpenReceive
172
211
  # `openreceive:notifications` worker — the process most likely to see a
173
212
  # connect error — logs failures of its own.
174
213
  def sanitize_failure_message(error)
175
- "#{error.class}: #{error.message}"
176
- .gsub(/nostr\+walletconnect:[^\s"'`<>]+/, "[REDACTED_NWC]")
177
- .gsub(/lightning\+swapconnect:[^\s"'`<>]+/, "[REDACTED_LSC]")
214
+ OpenReceive::Nwc.redact_error_text("#{error.class}: #{error.message}")
178
215
  end
179
216
 
180
217
  private
181
218
 
219
+
182
220
  # Rails.logger when the engine runs inside Rails; nil in bare-gem tests.
183
221
  # Settlement behavior never depends on logging.
184
222
  def openreceive_logger
@@ -196,11 +234,13 @@ module OpenReceive
196
234
  "paid_at" => checked.fetch("paid_at"),
197
235
  "details" => checked["details"]
198
236
  )
237
+ OpenReceivePayment.where(payment_hash: checked.fetch("payment_hash"), status: "settled").exists?
199
238
  rescue StandardError => e
200
239
  openreceive_logger&.warn(
201
240
  "[openreceive] settlement for #{checked.fetch('payment_hash')} failed " \
202
241
  "(will retry next pass): #{sanitize_failure_message(e)}"
203
242
  )
243
+ false
204
244
  end
205
245
 
206
246
  # Closure is decided by the shared reconciliation rules from a scan result
@@ -233,7 +273,7 @@ module OpenReceive
233
273
  # never one wallet call per invoice. One short line per poll: this fires
234
274
  # on every status poll while a payer waits. Mirrors the JS
235
275
  # payment.reconcile.completed line.
236
- def log_reconcile_pass(attempts, results, overlap_seconds, observed_at)
276
+ def log_reconcile_pass(attempts, results, window)
237
277
  logger = openreceive_logger
238
278
  return if logger.nil?
239
279
 
@@ -245,10 +285,9 @@ module OpenReceive
245
285
  decided = ["0 decided"] if decided.empty?
246
286
  # Attempts scanned vs hashes decided: a gap is how a truncated scan shows up.
247
287
  scanned = results.length == attempts.length ? "" : " of #{attempts.length} attempts"
248
- window_from = [attempts.map { |attempt| Integer(attempt.fetch("created_at")) }.min - overlap_seconds, 0].max
249
288
  logger.info(
250
289
  "[openreceive] payment.reconcile.completed: #{decided.join(', ')}#{scanned} " \
251
- "attempt_count=#{attempts.length} window=#{window_from}..#{observed_at + overlap_seconds}"
290
+ "attempt_count=#{attempts.length} window=#{window.fetch("from")}..#{window["until"] || "unbounded"}"
252
291
  )
253
292
  rescue StandardError
254
293
  # Diagnostics must never affect the pass.
@@ -303,7 +342,7 @@ module OpenReceive
303
342
  payment_hash = transaction["payment_hash"].to_s.downcase
304
343
  return false if payment_hash.empty?
305
344
 
306
- return false unless OpenReceivePayment.pending.where(payment_hash: payment_hash).exists?
345
+ return false if OpenReceivePayment.find_pending_attempt(payment_hash).nil?
307
346
 
308
347
  observed_at = Time.now.to_i
309
348
  config.settlement_hook.call(
@@ -315,7 +354,7 @@ module OpenReceive
315
354
  "paid_at_source" => transaction["settled_at"] ? "settled_at" : "observed_at"
316
355
  }
317
356
  )
318
- true
357
+ OpenReceivePayment.where(payment_hash: payment_hash, status: "settled").exists?
319
358
  rescue StandardError
320
359
  # A direct-settlement failure falls back to the scan-based safety net.
321
360
  false
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module OpenReceive
7
+ # Durable scan slices store only identities, offsets and minimal classifications.
8
+ module ReconcileScan
9
+ module_function
10
+
11
+ def new_window(attempts, now, overlap)
12
+ trusted = attempts.all? { |a| a["created_at_source"] == "wallet" }
13
+ {
14
+ "attempts" => attempts,
15
+ "from" => trusted ? [attempts.map { |a| a.fetch("created_at") }.min - overlap, 0].max : 0,
16
+ "until" => trusted ? attempts.map { |a| a.fetch("created_at") }.max + overlap : nil,
17
+ "view" => "default", "offset" => 0, "anchor_offset" => nil, "fingerprint" => nil,
18
+ "started_at" => now, "absence_safe" => true, "observations" => {}
19
+ }
20
+ end
21
+
22
+ def slice(service, window, max_pages:, deadline:, on_finality: nil)
23
+ results = {}
24
+ expected = window.fetch("attempts").map { |a| a.fetch("payment_hash") }
25
+ resumed = window.fetch("offset").positive? || window.fetch("view") != "default"
26
+ window["absence_safe"] = false if resumed
27
+ anchor = resumed ? window["anchor_offset"] : nil
28
+ replaying = !anchor.nil?
29
+ previous = window["fingerprint"]
30
+ max_pages.times do
31
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
32
+
33
+ offset = replaying ? anchor : window.fetch("offset")
34
+ request = { "type" => "incoming", "limit" => 20, "offset" => offset, "from" => window.fetch("from") }
35
+ request["until"] = window["until"] unless window["until"].nil?
36
+ request["unpaid"] = true if window.fetch("view") == "inclusive"
37
+ request["_deadline"] = deadline
38
+ page = OpenReceive.normalize_list_transactions_response(service.send(:call_nwc, :list_transactions, request))
39
+ return [results.values, false, false] if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
40
+
41
+ rows = page.fetch("transactions")
42
+ physical = rows.length + page.fetch("skipped_rows", 0)
43
+ fingerprint = Digest::SHA256.hexdigest(JSON.generate(rows.map { |row| row["payment_hash"] }))
44
+ rows.each do |row|
45
+ hash = row["payment_hash"]
46
+ next unless expected.include?(hash) && [nil, "incoming"].include?(row["type"])
47
+ next if window.fetch("observations").dig(hash, "status") == "settled"
48
+
49
+ status = OpenReceive::Settlement.status(row)
50
+ if %w[settled expired failed].include?(status)
51
+ results[hash] = service.send(:payment_result, hash, row)
52
+ on_finality&.call(results[hash]) if Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
53
+ end
54
+ window.fetch("observations")[hash] = { "status" => status, "transaction_state" => row["transaction_state"] }
55
+ end
56
+ if replaying
57
+ replaying = false
58
+ window["offset"] = offset + physical
59
+ previous = fingerprint
60
+ unless physical.zero?
61
+ window["anchor_offset"] = offset
62
+ window["fingerprint"] = fingerprint
63
+ next
64
+ end
65
+ end
66
+ if physical.zero?
67
+ if window.fetch("view") == "default"
68
+ window.merge!("view" => "inclusive", "offset" => 0, "anchor_offset" => nil, "fingerprint" => nil)
69
+ previous = nil
70
+ next
71
+ end
72
+ if window.fetch("absence_safe")
73
+ expected.each do |hash|
74
+ observation = window.fetch("observations")[hash]
75
+ next if results.key?(hash) || %w[settled expired failed].include?(observation&.fetch("status"))
76
+
77
+ result = { "payment_hash" => hash, "status" => observation.nil? ? "not_found" : observation.fetch("status"), "_coverage_started_at" => window.fetch("started_at") }
78
+ result["details"] = { "transaction" => { "transaction_state" => observation["transaction_state"] } } unless observation.nil?
79
+ results[hash] = result
80
+ end
81
+ end
82
+ return [results.values, true, false]
83
+ end
84
+ return [results.values, false, true] if fingerprint == previous
85
+
86
+ window.merge!("anchor_offset" => offset, "fingerprint" => fingerprint, "offset" => offset + physical)
87
+ previous = fingerprint
88
+ return [results.values, true, false] if expected.all? { |hash| %w[settled expired failed].include?(window.fetch("observations").dig(hash, "status")) }
89
+ end
90
+ [results.values, false, false]
91
+ end
92
+ end
93
+ end
@@ -1,6 +1,6 @@
1
1
  # OpenReceive agent directions (BTCPay Server)
2
2
 
3
- These directions describe OpenReceive 0.4.10.
3
+ These directions describe OpenReceive 0.4.11.
4
4
 
5
5
  Connect a BTCPay Server store to a receive-only NWC wallet with the OpenReceive
6
6
  plugin, and optionally let payers pay BTCPay invoices with USDT, USDC, ETH or
@@ -119,6 +119,8 @@ enough; drop the `.md` for the same page a person would read.
119
119
  Questions, or a problem with the plugin itself:
120
120
  https://openreceive.org/contact
121
121
 
122
+ - https://openreceive.org/guides/payment-safety-upgrade.md — coordinated upgrades and reviewed repair of existing attempts
123
+
122
124
  ---
123
125
 
124
126
  ## The quickstart, in full
@@ -157,9 +159,9 @@ invoices, checkout, webhooks and Greenfield API are the host.
157
159
 
158
160
  In BTCPay, open **Server Settings → Plugins**, search the plugin directory
159
161
  for **OpenReceive**, click **Install**, and restart BTCPay when prompted.
160
- BTCPay creates the plugin's one table (`openreceive_swaps`, schema
161
- `BTCPayServer.Plugins.OpenReceive`) in its own Postgres at startup; nothing
162
- else is created.
162
+ BTCPay creates the plugin's two tables (`openreceive_invoices` and
163
+ `openreceive_swaps`, schema `BTCPayServer.Plugins.OpenReceive`) in its own
164
+ Postgres at startup; nothing else is created.
163
165
 
164
166
  To build the plugin from source instead, follow
165
167
  [the .NET workspace README](https://github.com/OpenReceive/openreceive/blob/master/packages/dotnet/README.md).
@@ -1,6 +1,6 @@
1
1
  # OpenReceive agent directions (Django)
2
2
 
3
- These directions describe OpenReceive 0.4.10.
3
+ These directions describe OpenReceive 0.4.11.
4
4
 
5
5
  Add OpenReceive to a Django project — the app you are already working in. You
6
6
  do not need a copy of the OpenReceive source: the Python package is on PyPI
@@ -130,7 +130,7 @@ itself, and they hold for every integration.
130
130
  a placeholder that allows everything (`manage.py check` warns
131
131
  `openreceive.W002` while it is set) — replace it with this app's real
132
132
  ownership check, same as `on_paid`.
133
- - `on_paid` must be idempotent. It runs once per `reference` — your order
133
+ - `on_paid` must be idempotent. Its database fulfillment commits once per `reference` — your order
134
134
  id, one per thing you fulfill, created before checkout, kept across retries,
135
135
  never reused. A fresh id per page load lets one order be paid twice.
136
136
  - Receive-only NWC is required; a spend-capable code fails closed at boot unless
@@ -307,6 +307,8 @@ enough; drop the `.md` for the same page a person would read.
307
307
  Questions, or a problem with the library itself:
308
308
  https://openreceive.org/contact
309
309
 
310
+ - https://openreceive.org/guides/payment-safety-upgrade.md — coordinated upgrades and reviewed repair of existing attempts
311
+
310
312
  ---
311
313
 
312
314
  ## The quickstart, in full
@@ -480,7 +482,7 @@ The host class needs three things: authorization, the trusted price, and
480
482
  fulfillment. All three receive the `reference` — a string you choose, and the
481
483
  fulfillment identity: your order id, one per thing you fulfill, created before
482
484
  checkout, kept across retries, never reused. OpenReceive never looks inside
483
- it, but `on_paid` runs once per reference, a new checkout under a reference
485
+ it, but `on_paid` commits fulfillment once per reference, a new checkout under a reference
484
486
  that already settled is refused with 409, and a fresh id per page load lets
485
487
  one order be paid twice.
486
488
 
@@ -1,6 +1,6 @@
1
1
  # OpenReceive agent directions (FastAPI)
2
2
 
3
- These directions describe OpenReceive 0.4.10.
3
+ These directions describe OpenReceive 0.4.11.
4
4
 
5
5
  Add OpenReceive to a FastAPI application — the app you are already working in.
6
6
  You do not need a copy of the OpenReceive source: the engine is on PyPI
@@ -116,7 +116,7 @@ itself, and they hold for every integration.
116
116
  - `authorize` runs on every request, and the `resource` it receives is a CLAIM
117
117
  the payer made, not proof. Read the Starlette request's session, cookie or
118
118
  auth dependency; never trust a body field.
119
- - `on_paid` must be idempotent. It runs once per `reference` — your order id, one
119
+ - `on_paid` must be idempotent. Its database fulfillment commits once per `reference` — your order id, one
120
120
  per thing you fulfill, created before checkout, kept across retries, never
121
121
  reused. A fresh id per page load lets one order be paid twice.
122
122
  - Receive-only NWC is required; a spend-capable code fails closed at boot unless
@@ -301,6 +301,8 @@ enough; drop the `.md` for the same page a person would read.
301
301
  Questions, or a problem with the library itself:
302
302
  https://openreceive.org/contact
303
303
 
304
+ - https://openreceive.org/guides/payment-safety-upgrade.md — coordinated upgrades and reviewed repair of existing attempts
305
+
304
306
  ---
305
307
 
306
308
  ## The quickstart, in full
@@ -475,7 +477,7 @@ the `reference`. OpenReceive never prices from payer input.
475
477
  The `reference` is a string you choose, and it is the fulfillment identity:
476
478
  your order id — one per thing you fulfill, created before checkout, kept
477
479
  across retries, never reused. OpenReceive never looks inside it, but `on_paid`
478
- runs once per reference, a new checkout under a reference that already
480
+ commits fulfillment once per reference, a new checkout under a reference that already
479
481
  settled is refused with 409, and a fresh id per page load lets one order be
480
482
  paid twice.
481
483
 
@@ -524,7 +526,7 @@ Content-Security-Policy has a strict `img-src`, allow `data:`
524
526
  ([Provider registry](https://openreceive.org/guides/provider-registry.md#assets)).
525
527
 
526
528
  That is the whole loop: your server owns the price and the order, the payer gets
527
- an invoice, and `onPaid` runs once inside the settlement transaction.
529
+ an invoice, and `onPaid` runs inside the settlement transaction. Rolled-back transactions may retry the callback; use a host outbox for external delivery.
528
530
 
529
531
  A page without a bundler renders the same checkout as a custom element:
530
532
  `<openreceive-checkout reference="…" prefix="/openreceive">` from
@@ -1,6 +1,6 @@
1
1
  # OpenReceive agent directions (Fastify)
2
2
 
3
- These directions describe OpenReceive 0.4.10.
3
+ These directions describe OpenReceive 0.4.11.
4
4
 
5
5
  Add OpenReceive to a Fastify application — the app you are already working in.
6
6
  You do not need a copy of the OpenReceive source: the packages are on npm, and
@@ -107,7 +107,7 @@ itself, and they hold for every integration.
107
107
  payer-supplied amounts.
108
108
  - `authorize` runs on every request, and the `resource` it receives is a CLAIM
109
109
  the payer made, not proof. Read a framework session; never trust a body field.
110
- - `onPaid` must be idempotent. It runs once per `reference` — your order id, one
110
+ - `onPaid` must be idempotent. Its database fulfillment commits once per `reference` — your order id, one
111
111
  per thing you fulfill, created before checkout, kept across retries, never
112
112
  reused. A fresh id per page load lets one order be paid twice.
113
113
  - Receive-only NWC is required; a spend-capable code fails closed at boot unless
@@ -284,6 +284,8 @@ enough; drop the `.md` for the same page a person would read.
284
284
  Questions, or a problem with the library itself:
285
285
  https://openreceive.org/contact
286
286
 
287
+ - https://openreceive.org/guides/payment-safety-upgrade.md — coordinated upgrades and reviewed repair of existing attempts
288
+
287
289
  ---
288
290
 
289
291
  ## The quickstart, in full
@@ -477,7 +479,7 @@ the `reference`. OpenReceive never prices from payer input.
477
479
  The `reference` is a string you choose, and it is the fulfillment identity:
478
480
  your order id — one per thing you fulfill, created before checkout, kept
479
481
  across retries, never reused. OpenReceive never looks inside it, but `onPaid`
480
- runs once per reference, a new checkout under a reference that already
482
+ commits fulfillment once per reference, a new checkout under a reference that already
481
483
  settled is refused with 409, and a fresh id per page load lets one order be
482
484
  paid twice.
483
485
 
@@ -526,7 +528,7 @@ Content-Security-Policy has a strict `img-src`, allow `data:`
526
528
  ([Provider registry](https://openreceive.org/guides/provider-registry.md#assets)).
527
529
 
528
530
  That is the whole loop: your server owns the price and the order, the payer gets
529
- an invoice, and `onPaid` runs once inside the settlement transaction.
531
+ an invoice, and `onPaid` runs inside the settlement transaction. Rolled-back transactions may retry the callback; use a host outbox for external delivery.
530
532
 
531
533
  A runnable illustration of this boundary — not a template to copy models from —
532
534
  is Buy a Button
@@ -1,6 +1,6 @@
1
1
  # OpenReceive agent directions (Laravel)
2
2
 
3
- These directions describe OpenReceive 0.4.10.
3
+ These directions describe OpenReceive 0.4.11.
4
4
 
5
5
  Add OpenReceive to a Laravel application — the app you are already working in.
6
6
  You do not need a copy of the OpenReceive source: the package is on Packagist
@@ -121,7 +121,7 @@ itself, and they hold for every integration.
121
121
  scaffolds `use AllowAllAuthorize;`, a placeholder trait that allows
122
122
  everything (the engine warns at boot while it is there) — replace it with
123
123
  this app's real ownership check, same as `onPaid`.
124
- - `onPaid` must be idempotent. It runs once per `reference` — your order
124
+ - `onPaid` must be idempotent. Its database fulfillment commits once per `reference` — your order
125
125
  id, one per thing you fulfill, created before checkout, kept across retries,
126
126
  never reused. A fresh id per page load lets one order be paid twice.
127
127
  - Receive-only NWC is required; a spend-capable code fails closed at boot unless
@@ -291,6 +291,8 @@ enough; drop the `.md` for the same page a person would read.
291
291
  Questions, or a problem with the library itself:
292
292
  https://openreceive.org/contact
293
293
 
294
+ - https://openreceive.org/guides/payment-safety-upgrade.md — coordinated upgrades and reviewed repair of existing attempts
295
+
294
296
  ---
295
297
 
296
298
  ## The quickstart, in full
@@ -456,7 +458,7 @@ variable as set/unset only.
456
458
  price, and fulfillment. All three receive the `reference` — a string you
457
459
  choose, and the fulfillment identity: your order id, one per thing you
458
460
  fulfill, created before checkout, kept across retries, never reused.
459
- OpenReceive never looks inside it, but `onPaid` runs once per reference, a new
461
+ OpenReceive never looks inside it, but `onPaid` commits fulfillment once per reference, a new
460
462
  checkout under a reference that already settled is refused with 409, and a
461
463
  fresh id per page load lets one order be paid twice.
462
464
 
@@ -1,6 +1,6 @@
1
1
  # OpenReceive agent directions (Next.js)
2
2
 
3
- These directions describe OpenReceive 0.4.10.
3
+ These directions describe OpenReceive 0.4.11.
4
4
 
5
5
  Add OpenReceive to a Next.js App Router application — the app you are already
6
6
  working in. You do not need a copy of the OpenReceive source: the packages are
@@ -109,7 +109,7 @@ itself, and they hold for every integration.
109
109
  payer-supplied amounts.
110
110
  - `authorize` runs on every request, and the `resource` it receives is a CLAIM
111
111
  the payer made, not proof. Read a framework session; never trust a body field.
112
- - `onPaid` must be idempotent. It runs once per `reference` — your order id, one
112
+ - `onPaid` must be idempotent. Its database fulfillment commits once per `reference` — your order id, one
113
113
  per thing you fulfill, created before checkout, kept across retries, never
114
114
  reused. A fresh id per page load lets one order be paid twice.
115
115
  - Receive-only NWC is required; a spend-capable code fails closed at boot unless
@@ -290,6 +290,8 @@ enough; drop the `.md` for the same page a person would read.
290
290
  Questions, or a problem with the library itself:
291
291
  https://openreceive.org/contact
292
292
 
293
+ - https://openreceive.org/guides/payment-safety-upgrade.md — coordinated upgrades and reviewed repair of existing attempts
294
+
293
295
  ---
294
296
 
295
297
  ## The quickstart, in full
@@ -496,7 +498,7 @@ the `reference`. OpenReceive never prices from payer input.
496
498
  The `reference` is a string you choose, and it is the fulfillment identity:
497
499
  your order id — one per thing you fulfill, created before checkout, kept
498
500
  across retries, never reused. OpenReceive never looks inside it, but `onPaid`
499
- runs once per reference, a new checkout under a reference that already
501
+ commits fulfillment once per reference, a new checkout under a reference that already
500
502
  settled is refused with 409, and a fresh id per page load lets one order be
501
503
  paid twice.
502
504
 
@@ -576,7 +578,7 @@ Content-Security-Policy has a strict `img-src`, allow `data:`
576
578
  ([Provider registry](https://openreceive.org/guides/provider-registry.md#assets)).
577
579
 
578
580
  That is the whole loop: your server owns the price and the order, the payer gets
579
- an invoice, and `onPaid` runs once inside the settlement transaction.
581
+ an invoice, and `onPaid` runs inside the settlement transaction. Rolled-back transactions may retry the callback; use a host outbox for external delivery.
580
582
 
581
583
  A runnable illustration of this boundary — not a template to copy models from —
582
584
  is Buy a Button
@@ -1,6 +1,6 @@
1
1
  # OpenReceive agent directions (Node.js)
2
2
 
3
- These directions describe OpenReceive 0.4.10.
3
+ These directions describe OpenReceive 0.4.11.
4
4
 
5
5
  Add OpenReceive to a Node application — the app you are already working in. You
6
6
  do not need a copy of the OpenReceive source: the packages are on npm, and the
@@ -104,7 +104,7 @@ itself, and they hold for every integration.
104
104
  payer-supplied amounts.
105
105
  - `authorize` runs on every request, and the `resource` it receives is a CLAIM
106
106
  the payer made, not proof. Read a framework session; never trust a body field.
107
- - `onPaid` must be idempotent. It runs once per `reference` — your order id, one
107
+ - `onPaid` must be idempotent. Its database fulfillment commits once per `reference` — your order id, one
108
108
  per thing you fulfill, created before checkout, kept across retries, never
109
109
  reused. A fresh id per page load lets one order be paid twice.
110
110
  - Receive-only NWC is required; a spend-capable code fails closed at boot unless
@@ -276,6 +276,8 @@ enough; drop the `.md` for the same page a person would read.
276
276
  Questions, or a problem with the library itself:
277
277
  https://openreceive.org/contact
278
278
 
279
+ - https://openreceive.org/guides/payment-safety-upgrade.md — coordinated upgrades and reviewed repair of existing attempts
280
+
279
281
  ---
280
282
 
281
283
  ## The quickstart, in full
@@ -454,7 +456,7 @@ the `reference`. OpenReceive never prices from payer input.
454
456
  The `reference` is a string you choose, and it is the fulfillment identity:
455
457
  your order id — one per thing you fulfill, created before checkout, kept
456
458
  across retries, never reused. OpenReceive never looks inside it, but `onPaid`
457
- runs once per reference, a new checkout under a reference that already
459
+ commits fulfillment once per reference, a new checkout under a reference that already
458
460
  settled is refused with 409, and a fresh id per page load lets one order be
459
461
  paid twice.
460
462
 
@@ -503,7 +505,7 @@ Content-Security-Policy has a strict `img-src`, allow `data:`
503
505
  ([Provider registry](https://openreceive.org/guides/provider-registry.md#assets)).
504
506
 
505
507
  That is the whole loop: your server owns the price and the order, the payer gets
506
- an invoice, and `onPaid` runs once inside the settlement transaction.
508
+ an invoice, and `onPaid` runs inside the settlement transaction. Rolled-back transactions may retry the callback; use a host outbox for external delivery.
507
509
 
508
510
  A runnable illustration of this boundary — not a template to copy models from —
509
511
  is Buy a Button
@@ -1,6 +1,6 @@
1
1
  # OpenReceive agent directions (PHP)
2
2
 
3
- These directions describe OpenReceive 0.4.10.
3
+ These directions describe OpenReceive 0.4.11.
4
4
 
5
5
  Add OpenReceive to a PHP application — the app you are already working in. You
6
6
  do not need a copy of the OpenReceive source: the engine is on Packagist
@@ -125,7 +125,7 @@ itself, and they hold for every integration.
125
125
  is a placeholder that allows everything (the engine warns at boot while a
126
126
  host uses it) — replace it with this app's real ownership check, same as
127
127
  `onPaid`'s `Hosts\LoggingOnPaid`.
128
- - `onPaid` must be idempotent. It runs once per `reference` — your order id, one
128
+ - `onPaid` must be idempotent. Its database fulfillment commits once per `reference` — your order id, one
129
129
  per thing you fulfill, created before checkout, kept across retries, never
130
130
  reused. A fresh id per page load lets one order be paid twice.
131
131
  - Receive-only NWC is required; a spend-capable code fails closed at boot unless
@@ -301,6 +301,8 @@ enough; drop the `.md` for the same page a person would read.
301
301
  Questions, or a problem with the library itself:
302
302
  https://openreceive.org/contact
303
303
 
304
+ - https://openreceive.org/guides/payment-safety-upgrade.md — coordinated upgrades and reviewed repair of existing attempts
305
+
304
306
  ---
305
307
 
306
308
  ## The quickstart, in full
@@ -512,7 +514,7 @@ prices with exact decimal math, and returns the order id the page will pass as
512
514
  the `reference`. OpenReceive never prices from payer input. The `reference` is
513
515
  a string you choose, and it is the fulfillment identity: your order id — one
514
516
  per thing you fulfill, created before checkout, kept across retries, never
515
- reused. `onPaid` runs once per reference, a new checkout under a reference
517
+ reused. `onPaid` commits fulfillment once per reference, a new checkout under a reference
516
518
  that already settled is refused with 409, and a fresh id per page load lets
517
519
  one order be paid twice.
518
520
 
@@ -1,6 +1,6 @@
1
1
  # OpenReceive agent directions (Rails)
2
2
 
3
- These directions describe OpenReceive 0.4.10.
3
+ These directions describe OpenReceive 0.4.11.
4
4
 
5
5
  Add OpenReceive to a Rails application — the app you are already working in. You
6
6
  do not need a copy of the OpenReceive source: the gem is on RubyGems, the
@@ -116,7 +116,7 @@ itself, and they hold for every integration.
116
116
  body field. The generator installs `OpenReceive::ALLOW_ALL_AUTHORIZE`, a
117
117
  placeholder that allows everything (the engine warns at boot while it is
118
118
  set) — replace it with this app's real ownership check, same as `on_paid`.
119
- - `config.on_paid` must be idempotent. It runs once per `reference` — your order
119
+ - `config.on_paid` must be idempotent. Its database fulfillment commits once per `reference` — your order
120
120
  id, one per thing you fulfill, created before checkout, kept across retries,
121
121
  never reused. A fresh id per page load lets one order be paid twice.
122
122
  - Receive-only NWC is required; a spend-capable code fails closed at boot unless
@@ -285,6 +285,8 @@ enough; drop the `.md` for the same page a person would read.
285
285
  Questions, or a problem with the library itself:
286
286
  https://openreceive.org/contact
287
287
 
288
+ - https://openreceive.org/guides/payment-safety-upgrade.md — coordinated upgrades and reviewed repair of existing attempts
289
+
288
290
  ---
289
291
 
290
292
  ## The quickstart, in full
@@ -432,7 +434,7 @@ The initializer needs three things: authorization, the trusted price, and
432
434
  fulfillment. All three receive the `reference` — a string you choose, and the
433
435
  fulfillment identity: your order id, one per thing you fulfill, created before
434
436
  checkout, kept across retries, never reused. OpenReceive never looks inside
435
- it, but `on_paid` runs once per reference, a new checkout under a reference
437
+ it, but `on_paid` commits fulfillment once per reference, a new checkout under a reference
436
438
  that already settled is refused with 409, and a fresh id per page load lets
437
439
  one order be paid twice.
438
440
 
@@ -1,6 +1,6 @@
1
1
  # OpenReceive agent directions (WordPress + WooCommerce)
2
2
 
3
- These directions describe OpenReceive 0.4.10.
3
+ These directions describe OpenReceive 0.4.11.
4
4
 
5
5
  Install and configure the OpenReceive gateway in the existing WooCommerce
6
6
  store. Preserve its theme, checkout, customer accounts, order model and prices.
@@ -73,6 +73,8 @@ flows; a receive-only NWC wallet cannot send payments.
73
73
  - [Agent Directions: BTCPay Server](https://openreceive.org/guides/agent-directions-btcpay.md)
74
74
  - [WordPress + WooCommerce Quickstart](https://openreceive.org/guides/quickstart-woocommerce.md)
75
75
 
76
+ - https://openreceive.org/guides/payment-safety-upgrade.md — coordinated upgrades and reviewed repair of existing attempts
77
+
76
78
  ---
77
79
 
78
80
  ## The quickstart, in full
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openreceive-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.10
4
+ version: 0.4.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - OpenReceive
@@ -15,28 +15,28 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.4.10
18
+ version: 0.4.11
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.4.10
25
+ version: 0.4.11
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: openreceive-server
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - '='
31
31
  - !ruby/object:Gem::Version
32
- version: 0.4.10
32
+ version: 0.4.11
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - '='
38
38
  - !ruby/object:Gem::Version
39
- version: 0.4.10
39
+ version: 0.4.11
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: rails
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -129,6 +129,7 @@ files:
129
129
  - lib/openreceive/rails.rb
130
130
  - lib/openreceive/rails/version.rb
131
131
  - lib/openreceive/reconcile.rb
132
+ - lib/openreceive/reconcile_scan.rb
132
133
  - lib/tasks/openreceive.rake
133
134
  - skills/debug-openreceive-payment/SKILL.md
134
135
  - skills/integrate-openreceive/SKILL.md