openreceive-rails 0.2.1

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.
@@ -0,0 +1,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openreceive/server"
5
+
6
+ module OpenReceive
7
+ # Floor for the durable reconcile-gate interval (seconds); stretched by
8
+ # invoice age (2s while any pending invoice is under 2 minutes old, 6s under
9
+ # 5 minutes, else 12s). Mirrors the JS OPENRECEIVE_MIN_RECONCILE_INTERVAL_SECONDS.
10
+ 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.
16
+ RECONCILE_SCAN_TIMEOUT_SECONDS = 9
17
+ # Wallet-history pages a request-path pass may walk, mirroring the JS
18
+ # OPENRECEIVE_RECONCILE_SCAN_MAX_PAGES.
19
+ RECONCILE_SCAN_MAX_PAGES = 50
20
+ # Cap on the `openreceive:notifications` worker's resubscribe backoff, and
21
+ # the subscription lifetime past which the ramp resets to 1s.
22
+ NOTIFICATIONS_MAX_BACKOFF_SECONDS = 60
23
+
24
+ class << self
25
+ # One bounded reconciliation pass over the engine-owned payment ledger:
26
+ # scan the wallet for every pending attempt, deliver settlements through the
27
+ # settlement hook (write-once + on_paid), and persist terminal transitions
28
+ # so closed attempts leave the scan set. Attempt closure only ever happens
29
+ # from a successful wallet scan result observed at or after expiry plus
30
+ # OpenReceive::Server::Reconciliation::EXPIRY_GRACE_SECONDS — a local clock
31
+ # 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
34
+ # truncated scan never proved it absent) is no information — the attempt
35
+ # stays untouched.
36
+ #
37
+ # Runs on any OpenReceive call via maybe_reconcile! (default), from the
38
+ # optional `bin/rails openreceive:notifications` worker, or one-shot from
39
+ # OpenReceive::ReconcileJob / `bin/rails openreceive:reconcile`.
40
+ # Returns the per-hash check results of the pass (an array of
41
+ # { "payment_hash", "status", "paid_at"?, "details"? } hashes) so callers —
42
+ # notably payments/check — can serve a requested hash straight from the
43
+ # 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
+ # Opportunistic settlement discovery, piggybacked on any OpenReceive call
73
+ # (the engine's around_action runs it before every mounted route): skip
74
+ # without a wallet call when nothing is pending, try the durable
75
+ # openreceive_meta gate shared by every Puma worker ("gate_busy" means
76
+ # another worker just scanned — skip the wallet), otherwise AWAIT one
77
+ # bounded reconcile! pass and return its per-hash results. Never raises: a
78
+ # failed or timed-out scan warns and returns "scan_failed" — the caller's
79
+ # own request must not fail because a settlement sweep did, and claimed_at
80
+ # stays in place so a broken wallet cannot stampede.
81
+ #
82
+ # Returns { "reason" => "ran", "checks" => [...] } or
83
+ # { "reason" => "disabled" | "no_pending" | "gate_busy" | "scan_failed" }.
84
+ # Exported for host code too: host-only routes (e.g. an app's own POST
85
+ # /orders) never auto-run it, but may call OpenReceive.maybe_reconcile!.
86
+ def maybe_reconcile!(now: nil)
87
+ setting = config.opportunistic_reconcile
88
+ return { "reason" => "disabled" } if setting == false
89
+
90
+ attempts = OpenReceivePayment.reconcilable_attempts
91
+ return { "reason" => "no_pending" } if attempts.empty?
92
+
93
+ 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" }
102
+ end
103
+
104
+ checks = reconcile!(
105
+ now: observed_at,
106
+ max_pages: RECONCILE_SCAN_MAX_PAGES,
107
+ deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + RECONCILE_SCAN_TIMEOUT_SECONDS
108
+ )
109
+ { "reason" => "ran", "checks" => checks }
110
+ rescue StandardError => e
111
+ openreceive_logger&.warn(
112
+ "[openreceive] opportunistic reconcile failed (will retry): #{sanitize_failure_message(e)}"
113
+ )
114
+ { "reason" => "scan_failed" }
115
+ end
116
+
117
+ # Opt-in NWC-02 notifications: subscribe to the configured NWC client's
118
+ # `payment_received` notifications. Notifications are authenticated wallet
119
+ # data — a payload that satisfies the shared settlement rule (`settled_at`
120
+ # or a settled transaction state; never a preimage alone) and matches a
121
+ # pending attempt settles that attempt directly through the engine's
122
+ # write-once settlement path (mark_paid_once! + on_paid), with no redundant
123
+ # wallet scan for that invoice. Anything less — no finality signal, an
124
+ # unknown hash, or a direct-settlement failure — falls back to one bounded
125
+ # OpenReceive.reconcile! pass. Polling (OpenReceive::ReconcileJob /
126
+ # `bin/rails openreceive:reconcile`) remains the safety net for
127
+ # notifications missed while offline. Direct settlement assumes the NWC
128
+ # client binds notification decryption to the connection's wallet pubkey;
129
+ # a client that skips author verification must not be granted it.
130
+ #
131
+ # The client contract is one method, `subscribe_notifications(&handler)`,
132
+ # yielding NWC-02 wire payloads (`notification_type` plus the
133
+ # transaction-shaped `notification`) — the shape NwcRubyReceiveClient
134
+ # adapts nwc-ruby's notification object to. The handler filters
135
+ # `payment_received` itself, like the Node listener: an NWC-02
136
+ # subscription is not type-filtered, the wallet decides what it publishes.
137
+ # Returns whatever the client's subscribe call returns; blocking clients
138
+ # simply do not return until the subscription ends. Raises
139
+ # OpenReceive::ConfigurationError when the client does not support
140
+ # notifications.
141
+ def listen_for_notifications!(overlap_seconds: 60)
142
+ client = config.send(:resolved_nwc_client)
143
+ unless client.respond_to?(:subscribe_notifications)
144
+ raise ConfigurationError,
145
+ "The configured NWC client does not support NWC-02 notifications " \
146
+ "(no subscribe_notifications method). Notifications are optional; " \
147
+ "keep polling with OpenReceive::ReconcileJob or " \
148
+ "`bin/rails openreceive:reconcile`."
149
+ end
150
+
151
+ client.subscribe_notifications do |notification|
152
+ next unless payment_received_notification?(notification)
153
+
154
+ reconcile!(overlap_seconds: overlap_seconds) unless settle_from_notification!(notification)
155
+ end
156
+ end
157
+
158
+ # Retry delay for the `openreceive:notifications` worker's subscribe loop:
159
+ # doubles per consecutive failure up to NOTIFICATIONS_MAX_BACKOFF_SECONDS,
160
+ # and a subscription that stayed up at least that long was healthy, so the
161
+ # next drop starts the ramp from scratch (mirrors the JS notifications
162
+ # worker, which reconnects fresh per subscription).
163
+ def notifications_retry_delay(previous_delay, subscribed_seconds)
164
+ return 1 if previous_delay.nil? || subscribed_seconds >= NOTIFICATIONS_MAX_BACKOFF_SECONDS
165
+
166
+ [previous_delay * 2, NOTIFICATIONS_MAX_BACKOFF_SECONDS].min
167
+ end
168
+
169
+ # Failure text can embed wallet credentials (an NWC URI inside a connect
170
+ # error); redact them before the message reaches the host log, mirroring
171
+ # the JS redactSecrets URI patterns. Public because the long-lived
172
+ # `openreceive:notifications` worker — the process most likely to see a
173
+ # connect error — logs failures of its own.
174
+ def sanitize_failure_message(error)
175
+ "#{error.class}: #{error.message}"
176
+ .gsub(/nostr\+walletconnect:[^\s"'`<>]+/, "[REDACTED_NWC]")
177
+ .gsub(/lightning\+swapconnect:[^\s"'`<>]+/, "[REDACTED_LSC]")
178
+ end
179
+
180
+ private
181
+
182
+ # Rails.logger when the engine runs inside Rails; nil in bare-gem tests.
183
+ # Settlement behavior never depends on logging.
184
+ def openreceive_logger
185
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:logger)
186
+
187
+ ::Rails.logger
188
+ end
189
+
190
+ # One failing settlement (a raising on_paid, a host data problem)
191
+ # must not abort the rest of the pass: every later attempt would
192
+ # otherwise never settle and never close, on every pass.
193
+ def settle_attempt(checked)
194
+ config.settlement_hook.call(
195
+ "payment_hash" => checked.fetch("payment_hash"),
196
+ "paid_at" => checked.fetch("paid_at"),
197
+ "details" => checked["details"]
198
+ )
199
+ rescue StandardError => e
200
+ openreceive_logger&.warn(
201
+ "[openreceive] settlement for #{checked.fetch('payment_hash')} failed " \
202
+ "(will retry next pass): #{sanitize_failure_message(e)}"
203
+ )
204
+ end
205
+
206
+ # Closure is decided by the shared reconciliation rules from a scan result
207
+ # the wallet actually returned; a nil transition means keep waiting.
208
+ def record_attempt_transition(attempt, checked, observed_at)
209
+ wallet_transaction = checked.dig("details", "transaction") || {}
210
+ transition = OpenReceive::Server::Reconciliation.transition(
211
+ expires_at: attempt.fetch("expires_at"),
212
+ status: checked.fetch("status"),
213
+ observed_at: observed_at,
214
+ # The row here is the service's NORMALIZED output, which carries
215
+ # "transaction_state" only — the raw wallet's "state" spelling was
216
+ # already resolved at the client boundary.
217
+ transaction_state: wallet_transaction["transaction_state"]
218
+ )
219
+ return if transition.nil?
220
+
221
+ OpenReceivePayment.record_reconciliation!(
222
+ payment_hash: checked.fetch("payment_hash"),
223
+ status: transition.fetch("status"),
224
+ observed_at: observed_at,
225
+ reason: transition.fetch("reason")
226
+ )
227
+ end
228
+
229
+ # Info, not debug: passes are durably gated (min 2s apart, and only while
230
+ # attempts are pending), so operators can watch settlement discovery and
231
+ # the batched list_transactions window without raising the log level. All
232
+ # pending attempts share one creation-time window walked at most twice —
233
+ # never one wallet call per invoice.
234
+ def log_reconcile_pass(attempts, results, overlap_seconds, observed_at)
235
+ logger = openreceive_logger
236
+ return if logger.nil?
237
+
238
+ counts = results.group_by { |checked| checked["status"] }.transform_values(&:length)
239
+ window_from = [attempts.map { |attempt| Integer(attempt.fetch("created_at")) }.min - overlap_seconds, 0].max
240
+ logger.info(
241
+ "[openreceive] reconcile pass: #{attempts.length} pending attempt(s) in one " \
242
+ "batched list_transactions window (from #{window_from} until #{observed_at + overlap_seconds}, <=2 walks): " \
243
+ "#{counts.map { |status, count| "#{count} #{status}" }.join(', ')}"
244
+ )
245
+ rescue StandardError
246
+ # Diagnostics must never affect the pass.
247
+ nil
248
+ end
249
+
250
+ # The gate interval for the current pending set: the configured floor
251
+ # (config.opportunistic_reconcile min_interval_seconds), stretched by
252
+ # invoice age — 2s while any pending invoice is under 2 minutes old, 6s
253
+ # under 5 minutes, else 12s. Mirrors the JS reconcile gate.
254
+ def reconcile_gate_interval_seconds(attempts, now, setting)
255
+ floor = MIN_RECONCILE_INTERVAL_SECONDS
256
+ if setting.is_a?(Hash)
257
+ configured = setting[:min_interval_seconds] || setting["min_interval_seconds"]
258
+ floor = [Integer(configured), floor].max unless configured.nil?
259
+ end
260
+ age_stretch = attempts.map do |attempt|
261
+ elapsed = [now - Integer(attempt.fetch("created_at")), 0].max
262
+ if elapsed < 120
263
+ 2
264
+ elsif elapsed < 300
265
+ 6
266
+ else
267
+ 12
268
+ end
269
+ end.min
270
+ [floor, age_stretch].max
271
+ end
272
+
273
+ def payment_received_notification?(notification)
274
+ return false unless notification.respond_to?(:[])
275
+
276
+ type = notification["notification_type"] || notification[:notification_type] ||
277
+ notification["type"] || notification[:type]
278
+ type.to_s == "payment_received"
279
+ end
280
+
281
+ # Direct settlement from one authenticated payment_received payload.
282
+ # Returns true only when the payload, normalized like a list_transactions
283
+ # row, satisfies the shared settlement rule AND matches a pending attempt —
284
+ # in that case it settles through the engine's write-once settlement hook
285
+ # and no wallet scan runs for that invoice. Any other outcome (no payload,
286
+ # no finality signal, unknown/not-pending hash, or a failure) returns
287
+ # false so the caller falls back to a bounded reconciliation scan.
288
+ def settle_from_notification!(notification)
289
+ payload = notification["notification"] || notification[:notification]
290
+ return false unless payload.respond_to?(:each_pair)
291
+
292
+ transaction = OpenReceive::Nwc.normalize_transaction(payload)
293
+ return false unless OpenReceive::Settlement.status(transaction) == "settled"
294
+
295
+ payment_hash = transaction["payment_hash"].to_s.downcase
296
+ return false if payment_hash.empty?
297
+
298
+ return false unless OpenReceivePayment.pending.where(payment_hash: payment_hash).exists?
299
+
300
+ observed_at = Time.now.to_i
301
+ config.settlement_hook.call(
302
+ "payment_hash" => payment_hash,
303
+ "paid_at" => transaction["settled_at"] || observed_at,
304
+ "details" => {
305
+ "transaction" => transaction,
306
+ "observed_at" => observed_at,
307
+ "paid_at_source" => transaction["settled_at"] ? "settled_at" : "observed_at"
308
+ }
309
+ )
310
+ true
311
+ rescue StandardError
312
+ # A direct-settlement failure falls back to the scan-based safety net.
313
+ false
314
+ end
315
+ end
316
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Conventional dashed entry point (`require "openreceive-rails"`). Delegates to the namespaced
4
+ # require tree so both `require "openreceive-rails"` and `require "openreceive/rails"` work.
5
+ require "openreceive/rails"
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :openreceive do
4
+ desc "Run one OpenReceive reconciliation pass over pending payment attempts"
5
+ task reconcile: :environment do
6
+ checks = OpenReceive.reconcile!
7
+ puts "openreceive:reconcile checked #{checks.length} pending attempt(s)"
8
+ end
9
+
10
+ # The one optional worker (case 2). By default no worker is needed at all:
11
+ # every engine request runs the durably gated opportunistic reconcile, so
12
+ # settlement of abandoned checkouts piggybacks on ordinary traffic. Run this
13
+ # long-lived process only when you want settlements pushed the moment the
14
+ # wallet reports payment_received — it both listens for NWC-02 notifications
15
+ # AND runs a periodic reconcile pass in the same process, the safety net for
16
+ # notifications missed while this worker was down. Hosts do not additionally
17
+ # schedule OpenReceive::ReconcileJob; it remains available as a one-shot
18
+ # primitive.
19
+ desc "Optional worker: listen for NWC-02 notifications and reconcile periodically (long-running)"
20
+ task notifications: :environment do
21
+ interval = Integer(ENV.fetch("OPENRECEIVE_NOTIFICATIONS_RECONCILE_INTERVAL_SECONDS", "15"))
22
+ puts "openreceive:notifications listening for NWC-02 payment_received and " \
23
+ "reconciling every #{interval}s. Notifications are authenticated wallet data; " \
24
+ "the periodic pass covers notifications missed while this worker was down."
25
+
26
+ reconciler = Thread.new do
27
+ loop do
28
+ begin
29
+ OpenReceive.reconcile!
30
+ rescue StandardError => error
31
+ # Redacted: a connect failure can quote the NWC URI, secret and all.
32
+ warn "openreceive:notifications periodic reconcile failed (will retry): " \
33
+ "#{OpenReceive.sanitize_failure_message(error)}"
34
+ end
35
+ sleep interval
36
+ end
37
+ end
38
+ reconciler.abort_on_exception = false
39
+
40
+ backoff = nil
41
+ loop do
42
+ subscribed_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
43
+ failure = nil
44
+ begin
45
+ OpenReceive.listen_for_notifications!
46
+ rescue OpenReceive::ConfigurationError
47
+ # The configured NWC client cannot notify; surface the limitation
48
+ # clearly. The periodic pass alone would be a silent downgrade the
49
+ # operator asked this worker to exceed.
50
+ reconciler.kill
51
+ raise
52
+ rescue StandardError => error
53
+ failure = error
54
+ end
55
+ backoff = OpenReceive.notifications_retry_delay(
56
+ backoff, Process.clock_gettime(Process::CLOCK_MONOTONIC) - subscribed_at
57
+ )
58
+ if failure.nil?
59
+ warn "openreceive:notifications subscription ended; retrying in #{backoff}s"
60
+ else
61
+ warn "openreceive:notifications error: " \
62
+ "#{OpenReceive.sanitize_failure_message(failure)}; retrying in #{backoff}s " \
63
+ "(the periodic reconcile pass still covers settlements)"
64
+ end
65
+ sleep backoff
66
+ end
67
+ end
68
+ end
metadata ADDED
@@ -0,0 +1,136 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: openreceive-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.1
5
+ platform: ruby
6
+ authors:
7
+ - OpenReceive
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: openreceive
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - '='
17
+ - !ruby/object:Gem::Version
18
+ version: 0.2.1
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - '='
24
+ - !ruby/object:Gem::Version
25
+ version: 0.2.1
26
+ - !ruby/object:Gem::Dependency
27
+ name: openreceive-server
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - '='
31
+ - !ruby/object:Gem::Version
32
+ version: 0.2.1
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - '='
38
+ - !ruby/object:Gem::Version
39
+ version: 0.2.1
40
+ - !ruby/object:Gem::Dependency
41
+ name: rails
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '8.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '8.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: sqlite3
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '2.1'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '2.1'
68
+ description: 'A mountable Rails engine that ships OpenReceive''s receive-only checkout
69
+ routes into a Rails app. Engine controllers inherit from the host''s ApplicationController
70
+ (keeping its authentication, current_user, and forgery protection; the shared handler
71
+ adds JSON-only and same-site gates), delegate to the openreceive-server Service,
72
+ and obey host-supplied authorization, amount-resolution, and settlement hooks. The
73
+ engine owns the OpenReceivePayment attempt model, its status state machine, settlement
74
+ write-once, and reconciliation (OpenReceive.reconcile!, OpenReceive::ReconcileJob,
75
+ rake openreceive:reconcile); its install generator emits only the migration, initializer,
76
+ and route mount. Receive-only: it never exposes a spend path, and boot fails closed
77
+ on spend-capable NWC codes.'
78
+ email:
79
+ - info@openreceive.org
80
+ executables: []
81
+ extensions: []
82
+ extra_rdoc_files: []
83
+ files:
84
+ - CHANGELOG.md
85
+ - LICENSE
86
+ - README.md
87
+ - app/controllers/openreceive/application_controller.rb
88
+ - app/controllers/openreceive/checkouts_controller.rb
89
+ - app/controllers/openreceive/payments_controller.rb
90
+ - app/controllers/openreceive/rates_controller.rb
91
+ - app/controllers/openreceive/swaps_controller.rb
92
+ - app/jobs/openreceive/reconcile_job.rb
93
+ - app/models/open_receive_meta.rb
94
+ - app/models/open_receive_payment.rb
95
+ - config/routes.rb
96
+ - lib/generators/openreceive/install/install_generator.rb
97
+ - lib/generators/openreceive/install/templates/initializer.rb
98
+ - lib/generators/openreceive/install/templates/migration.rb
99
+ - lib/openreceive-rails.rb
100
+ - lib/openreceive/configuration.rb
101
+ - lib/openreceive/engine.rb
102
+ - lib/openreceive/fulfillment_note.rb
103
+ - lib/openreceive/generated/fulfillment_note.rb
104
+ - lib/openreceive/rails.rb
105
+ - lib/openreceive/rails/version.rb
106
+ - lib/openreceive/reconcile.rb
107
+ - lib/tasks/openreceive.rake
108
+ homepage: https://openreceive.org
109
+ licenses:
110
+ - MIT
111
+ metadata:
112
+ homepage_uri: https://openreceive.org
113
+ source_code_uri: https://github.com/openreceive/openreceive
114
+ changelog_uri: https://github.com/openreceive/openreceive/blob/master/packages/ruby/openreceive-rails/CHANGELOG.md
115
+ bug_tracker_uri: https://github.com/openreceive/openreceive/issues
116
+ documentation_uri: https://rubydoc.info/gems/openreceive-rails
117
+ rubygems_mfa_required: 'true'
118
+ rdoc_options: []
119
+ require_paths:
120
+ - lib
121
+ required_ruby_version: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ version: '3.2'
126
+ required_rubygems_version: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '0'
131
+ requirements: []
132
+ rubygems_version: 3.6.8
133
+ specification_version: 4
134
+ summary: OpenReceive mountable Rails engine with engine-owned payment attempts in
135
+ the host database.
136
+ test_files: []