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 +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +33 -0
- data/app/models/open_receive_meta.rb +49 -28
- data/app/models/open_receive_payment.rb +107 -13
- data/lib/openreceive/configuration.rb +2 -0
- data/lib/openreceive/generated/fulfillment_note.rb +17 -8
- data/lib/openreceive/rails/version.rb +1 -1
- data/lib/openreceive/reconcile.rb +98 -59
- data/lib/openreceive/reconcile_scan.rb +93 -0
- data/skills/integrate-openreceive/references/btcpay.md +6 -4
- data/skills/integrate-openreceive/references/django.md +5 -3
- data/skills/integrate-openreceive/references/fastapi.md +6 -4
- data/skills/integrate-openreceive/references/fastify.md +6 -4
- data/skills/integrate-openreceive/references/laravel.md +5 -3
- data/skills/integrate-openreceive/references/next.md +6 -4
- data/skills/integrate-openreceive/references/node.md +6 -4
- data/skills/integrate-openreceive/references/php.md +5 -3
- data/skills/integrate-openreceive/references/rails.md +5 -3
- data/skills/integrate-openreceive/references/woocommerce.md +3 -1
- metadata +6 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9b819ffb0e4f2872cd4de6da728c6404804496da560443a928d54d0aa25a5ac6
|
|
4
|
+
data.tar.gz: 674d40eb671af219dbaafd038eb4e8bee4849649e8bf32d82bf3fd1f45405414
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
|
|
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
|
-
|
|
76
|
+
now = Integer(now)
|
|
80
77
|
CAS_RETRIES.times do
|
|
81
78
|
row = find_by(key: RECONCILE_GATE_KEY)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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
|
-
"
|
|
263
|
-
"
|
|
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
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
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,
|
|
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.",
|
|
@@ -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
|
-
#
|
|
12
|
-
#
|
|
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
|
|
33
|
-
#
|
|
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,
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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,
|
|
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=#{
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
161
|
-
`BTCPayServer.Plugins.OpenReceive`) in its own
|
|
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.
|
|
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.
|
|
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`
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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`
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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`
|
|
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.
|
|
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.
|
|
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`
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|