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,380 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openreceive/server"
4
+
5
+ module OpenReceive
6
+ class ConfigurationError < StandardError; end
7
+
8
+ # Passed to the quickstart `config.on_paid` inside the settlement transaction,
9
+ # only for the first settled attempt for a reference. (Named to avoid the core gem's
10
+ # OpenReceive::Settlement rules module; mirrors JS PaymentSettlement.)
11
+ # `details` carries the wallet-observed settlement details JS delivers to
12
+ # onPaid (transaction snapshot, observed_at, paid_at_source); may be nil for
13
+ # settlements recorded without wallet details.
14
+ PaymentSettlement = Struct.new(:reference, :payment_hash, :paid_at, :details, keyword_init: true)
15
+
16
+ # The generated initializer's placeholder `config.on_paid`: it logs the
17
+ # settlement and fulfills nothing. Kept as a named constant so the engine can
18
+ # detect it at boot and warn while a host still ships it — orders recorded as
19
+ # settled without ever being fulfilled must not pass silently.
20
+ LOGGING_ON_PAID = lambda do |settlement|
21
+ ::Rails.logger.info(
22
+ "[openreceive] order #{settlement.reference} paid (payment_hash #{settlement.payment_hash})"
23
+ )
24
+ end
25
+
26
+ class Configuration
27
+ # Quickstart contract: authorize + amount_for + on_paid. The engine derives
28
+ # checkout resolution, attempt commit, and settlement write-once from the
29
+ # engine-owned OpenReceivePayment model.
30
+ #
31
+ # Advanced escape hatch: hosts with a custom payment repository configure
32
+ # resolve_checkout and on_checkout_created together; on_paid then receives
33
+ # the raw settlement event and owns replay safety itself.
34
+ attr_accessor :parent_controller, :nwc, :nwc_client, :authorize,
35
+ :amount_for, :on_paid,
36
+ :resolve_checkout, :on_checkout_created,
37
+ :rate_limit, :rate_limiting, :client_ip, :price_provider,
38
+ :swap_providers, :price_currencies, :allow_spend_capable_wallet,
39
+ :opportunistic_reconcile
40
+
41
+ def initialize
42
+ @parent_controller = "ActionController::Base"
43
+ @nwc = nil
44
+ @nwc_client = nil
45
+ @authorize = nil
46
+ @amount_for = nil
47
+ @on_paid = nil
48
+ @resolve_checkout = nil
49
+ @on_checkout_created = nil
50
+ @rate_limit = nil
51
+ # Built-in per-IP invoice rate limiting (mirrors the JS `rateLimiting`
52
+ # option): OFF by default — shared-IP deployments (POS terminals,
53
+ # kiosks) must never be throttled by accident. `true` caps invoice
54
+ # creation at 60 per client IP per rolling hour, counted from the
55
+ # engine-owned openreceive_payments rows; or a Hash with
56
+ # limit_per_hour / limit_per_day. Mutually exclusive with rate_limit.
57
+ @rate_limiting = false
58
+ # Client IP attribution: a proc receiving the framework request.
59
+ # Defaults to ActionDispatch::Request#ip (which honors the app's
60
+ # trusted-proxy configuration).
61
+ @client_ip = nil
62
+ # nil (the default) mirrors the JS createOpenReceive defaults: the
63
+ # price provider becomes the built-in cached live price feed (with
64
+ # OPENRECEIVE_PRICE_FEED_*_URL overrides), and swap providers are
65
+ # auto-built from LSC_URI_PRIMARY / LSC_URI_BACKUP. Set an explicit
66
+ # value (e.g. swap_providers = []) to override.
67
+ @price_provider = nil
68
+ @swap_providers = nil
69
+ @price_currencies = ["USD"]
70
+ @allow_spend_capable_wallet = false
71
+ # Opportunistic settlement discovery, ON by default (mirrors the JS
72
+ # handler's opportunisticReconcile): every engine request first runs one
73
+ # durably gated reconcile pass when attempts are pending, so abandoned
74
+ # checkouts settle on any later OpenReceive call with no scheduled job.
75
+ # The openreceive_meta gate row is shared by every Puma worker/process on
76
+ # the host database (min 2s between real wallet scans, stretched by
77
+ # invoice age). Set false to disable (e.g. when the optional
78
+ # `bin/rails openreceive:notifications` worker owns scanning), or a Hash
79
+ # with min_interval_seconds to tune.
80
+ @opportunistic_reconcile = true
81
+ end
82
+
83
+ def service
84
+ validate!
85
+ # The Rails logger, so the service's operator diagnostics actually land:
86
+ # the detailed invoice-expiry rejection ("requested Xs, got Ys…") and the
87
+ # spend-capable override warning are logged, never sent, so without this
88
+ # a Rails operator saw only the short 502 wire message.
89
+ @service ||= OpenReceive::Server::Service.new(
90
+ nwc_client: resolved_nwc_client,
91
+ price_provider: @price_provider,
92
+ swap_providers: @swap_providers,
93
+ price_currencies: @price_currencies,
94
+ allow_spend_capable_wallet: @allow_spend_capable_wallet,
95
+ logger: rails_logger
96
+ )
97
+ end
98
+
99
+ def request_handler
100
+ validate!
101
+ @request_handler ||= OpenReceive::Server::RequestHandler.new(
102
+ service: service,
103
+ authorize: @authorize,
104
+ resolve_checkout: @resolve_checkout || engine_resolve_checkout,
105
+ on_checkout_created: @on_checkout_created || engine_on_checkout_created,
106
+ on_paid: settlement_hook,
107
+ rate_limit: resolved_rate_limit,
108
+ client_ip: resolved_client_ip
109
+ )
110
+ end
111
+
112
+ # The settlement-event hook shared by payments/check and OpenReceive.reconcile!.
113
+ # In quickstart mode it delivers through OpenReceivePayment.mark_paid_once!,
114
+ # so config.on_paid fires only for the order's FIRST settled attempt, inside
115
+ # the settlement transaction; a later duplicate settlement is recorded
116
+ # (status_reason "duplicate_settlement") but never fulfilled again.
117
+ def settlement_hook
118
+ return @on_paid if advanced_hooks?
119
+
120
+ @settlement_hook ||= engine_settlement_hook
121
+ end
122
+
123
+ def advanced_hooks?
124
+ !@resolve_checkout.nil? && !@on_checkout_created.nil?
125
+ end
126
+
127
+ def reset_runtime!
128
+ %i[@service @request_handler @settlement_hook @resolved_nwc_client].each do |name|
129
+ remove_instance_variable(name) if instance_variable_defined?(name)
130
+ end
131
+ self
132
+ end
133
+
134
+ def validate!
135
+ raise ConfigurationError, "OpenReceive.config.authorize is required." if @authorize.nil?
136
+ if @rate_limiting && @rate_limit
137
+ raise ConfigurationError,
138
+ "Set either OpenReceive.config.rate_limiting or a custom rate_limit hook, not both."
139
+ end
140
+ if @rate_limiting && advanced_hooks?
141
+ raise ConfigurationError,
142
+ "config.rate_limiting counts engine-owned OpenReceivePayment rows; with a custom " \
143
+ "repository (resolve_checkout/on_checkout_created), pass a custom rate_limit hook " \
144
+ "backed by your own store instead."
145
+ end
146
+ if @opportunistic_reconcile && advanced_hooks?
147
+ # Same fail-at-construction idiom as the JS handler: the default
148
+ # settlement path needs the engine-owned durable gate and payment rows;
149
+ # a custom repository must opt out explicitly, never degrade silently.
150
+ raise ConfigurationError,
151
+ "config.opportunistic_reconcile (on by default) scans engine-owned " \
152
+ "OpenReceivePayment rows through the shared openreceive_meta gate; with a custom " \
153
+ "repository (resolve_checkout/on_checkout_created), set " \
154
+ "config.opportunistic_reconcile = false and run your own settlement worker."
155
+ end
156
+ if @on_paid.nil?
157
+ raise ConfigurationError, "OpenReceive.config.on_paid is required to durably record settlement."
158
+ end
159
+ if @resolve_checkout.nil? != @on_checkout_created.nil?
160
+ raise ConfigurationError,
161
+ "OpenReceive.config.resolve_checkout and on_checkout_created must be configured together (advanced mode)."
162
+ end
163
+ if !advanced_hooks? && @amount_for.nil?
164
+ raise ConfigurationError,
165
+ "Set OpenReceive.config.amount_for (quickstart), " \
166
+ "or resolve_checkout and on_checkout_created (advanced)."
167
+ end
168
+ resolved_nwc_client
169
+ true
170
+ end
171
+
172
+ private
173
+
174
+ DEFAULT_RATE_LIMIT_PER_HOUR = 60
175
+ HOUR_SECONDS = 3_600
176
+ DAY_SECONDS = 86_400
177
+
178
+ def resolved_client_ip
179
+ extractor = @client_ip || lambda do |request|
180
+ if request.respond_to?(:ip)
181
+ request.ip
182
+ elsif request.is_a?(Hash)
183
+ request["REMOTE_ADDR"]
184
+ end
185
+ end
186
+ # Mirrors the JS handler: the extracted IP (custom hook or framework
187
+ # default) is normalized into the bucket the limiter counts with (IPv6
188
+ # /64, v4-mapped collapsed) and that same bucket is what gets stamped on
189
+ # committed attempt rows. No attributable IP stays nil (fail open).
190
+ ->(request) { OpenReceive::Server::ClientIp.attributed(extractor.call(request)) }
191
+ end
192
+
193
+ # The built-in limiter (config.rate_limiting): a COUNT over the
194
+ # engine-owned rows' client_ip within the rolling window, throttling only
195
+ # the invoice-minting actions — identical semantics to the JS handler.
196
+ # Mirrors the JS built-in limiter, including its message and its refusal to
197
+ # accept a non-positive limit: `limit_per_hour: 0` would otherwise block
198
+ # every attributable payer while looking like a configured budget.
199
+ BUILT_IN_RATE_LIMIT_MESSAGE = "Too many payment attempts. Please try again later."
200
+
201
+ def resolved_rate_limit
202
+ return @rate_limit unless @rate_limiting
203
+ settings = @rate_limiting.is_a?(Hash) ? @rate_limiting : {}
204
+ limit_per_hour = positive_rate_limit(
205
+ settings[:limit_per_hour] || settings["limit_per_hour"] || DEFAULT_RATE_LIMIT_PER_HOUR,
206
+ "limit_per_hour"
207
+ )
208
+ limit_per_day = settings[:limit_per_day] || settings["limit_per_day"]
209
+ limit_per_day = positive_rate_limit(limit_per_day, "limit_per_day") unless limit_per_day.nil?
210
+ extract_ip = resolved_client_ip
211
+ warned_unattributable = false
212
+ lambda do |context|
213
+ next true unless %w[checkout.create swap.create].include?(context[:action].to_s)
214
+ ip = extract_ip.call(context[:request]).to_s
215
+ if ip.empty?
216
+ # Warned once per process: a deployment where no request ever yields
217
+ # an IP has rate limiting silently switched off.
218
+ unless warned_unattributable
219
+ warned_unattributable = true
220
+ rails_logger&.warn(
221
+ "[openreceive] rate limiting is enabled but no client IP could be resolved; " \
222
+ "attempts from this request are not counted. Configure config.client_ip."
223
+ )
224
+ end
225
+ next true
226
+ end
227
+ now = Time.now
228
+ over_hour = OpenReceivePayment.count_attempts_from_ip(ip, now - HOUR_SECONDS) >= limit_per_hour
229
+ raise OpenReceive::Server::RateLimitedError, BUILT_IN_RATE_LIMIT_MESSAGE if over_hour
230
+
231
+ if limit_per_day &&
232
+ OpenReceivePayment.count_attempts_from_ip(ip, now - DAY_SECONDS) >= limit_per_day
233
+ raise OpenReceive::Server::RateLimitedError, BUILT_IN_RATE_LIMIT_MESSAGE
234
+ end
235
+ true
236
+ end
237
+ end
238
+
239
+ # Rails.logger when the engine runs inside Rails; nil in bare-gem tests.
240
+ def rails_logger
241
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:logger)
242
+
243
+ ::Rails.logger
244
+ end
245
+
246
+ def positive_rate_limit(value, name)
247
+ limit = Integer(value)
248
+ return limit if limit.positive?
249
+
250
+ raise ConfigurationError,
251
+ "OpenReceive.config.rate_limiting #{name} must be a positive integer (got #{limit})."
252
+ end
253
+
254
+ # The host is asked only where a price is minted or quoted. Status polls
255
+ # and refund recovery for committed attempts are answered from the engine's
256
+ # own rows and never wait for the host's price hook.
257
+ def engine_resolve_checkout
258
+ lambda do |action:, request:, reference:, input:, pay_in_asset: nil|
259
+ pricing = %w[checkout.prepare swap.quote checkout.create swap.create].include?(action)
260
+ amount = pricing ? @amount_for.call(reference) : nil
261
+ raise OpenReceive::Server::NotFoundError, "Unknown reference." if pricing && amount.nil?
262
+ next({ amount: amount }) if %w[checkout.prepare swap.quote].include?(action)
263
+
264
+ requested_hash = input["payment_hash"] || input[:payment_hash]
265
+ begin
266
+ payment = OpenReceivePayment.selected_for(
267
+ reference: reference,
268
+ action: action,
269
+ payment_hash: requested_hash,
270
+ pay_in_asset: pay_in_asset
271
+ )
272
+ rescue OpenReceivePayment::AttemptConflict => e
273
+ raise OpenReceive::Server::ConflictError, e.message
274
+ end
275
+ if !requested_hash.to_s.strip.empty? && payment.nil?
276
+ raise OpenReceive::Server::NotFoundError, "Payment attempt not found for this reference."
277
+ end
278
+
279
+ {
280
+ amount: amount,
281
+ payment_hash: payment&.payment_hash,
282
+ checkout: payment&.checkout_data,
283
+ swap_data: payment&.swap_data
284
+ }.compact
285
+ end
286
+ end
287
+
288
+ def engine_on_checkout_created
289
+ lambda do |reference:, payment_hash:, checkout:, swap_data: nil, client_ip: nil, **|
290
+ begin
291
+ OpenReceivePayment.commit_attempt!(
292
+ reference: reference,
293
+ payment_hash: payment_hash,
294
+ checkout: checkout,
295
+ swap_data: swap_data,
296
+ client_ip: client_ip
297
+ )
298
+ rescue OpenReceivePayment::AttemptConflict => e
299
+ # Same wrap as engine_resolve_checkout: a live same-method row is a
300
+ # 409 CONFLICT. Leaving AttemptConflict unwrapped lets request_handler
301
+ # #commit treat it as infrastructure failure (retryable 503 persist).
302
+ raise OpenReceive::Server::ConflictError, e.message
303
+ end
304
+ end
305
+ end
306
+
307
+ def engine_settlement_hook
308
+ lambda do |event|
309
+ data = OpenReceive.as_string_keys(event)
310
+ OpenReceivePayment.mark_paid_once!(
311
+ payment_hash: data.fetch("payment_hash"),
312
+ paid_at: data.fetch("paid_at")
313
+ ) do |payment|
314
+ @on_paid.call(
315
+ PaymentSettlement.new(
316
+ reference: payment.reference,
317
+ payment_hash: payment.payment_hash,
318
+ paid_at: payment.paid_at,
319
+ details: data["details"]
320
+ )
321
+ )
322
+ end
323
+ end
324
+ end
325
+
326
+ # Memoized for real: `||= begin ... return ... end` returned out of the
327
+ # method BEFORE the assignment, so the injected-client and
328
+ # client-shaped-string paths never cached — and reset_runtime! cleared an
329
+ # ivar they never set.
330
+ def resolved_nwc_client
331
+ @resolved_nwc_client ||= build_resolved_nwc_client
332
+ end
333
+
334
+ def build_resolved_nwc_client
335
+ return @nwc_client unless @nwc_client.nil?
336
+
337
+ connection = @nwc || ENV["NWC_URI"]&.strip
338
+ if connection.nil? || connection.empty?
339
+ raise ConfigurationError, "Set NWC_URI, or configure OpenReceive.config.nwc/nwc_client explicitly."
340
+ end
341
+ return connection if connection.respond_to?(:make_invoice) || connection.respond_to?(:makeInvoice)
342
+
343
+ OpenReceive::NwcRubyReceiveClient.new(
344
+ client: build_nwc_ruby_client(connection), connection_uri: connection
345
+ )
346
+ end
347
+
348
+ def build_nwc_ruby_client(connection)
349
+ require "nwc_ruby"
350
+ ::NwcRuby::Client.from_uri(connection)
351
+ rescue LoadError
352
+ raise ConfigurationError, "Install nwc-ruby or configure nwc_client."
353
+ end
354
+ end
355
+
356
+
357
+ class << self
358
+ def configure
359
+ @configured = true
360
+ yield(config) if block_given?
361
+ config.reset_runtime!
362
+ end
363
+
364
+ # True once the host ran OpenReceive.configure — the engine's boot-time
365
+ # preflight only makes sense for a configured install (the gem may sit in a
366
+ # Gemfile before the installer has been run).
367
+ def configured?
368
+ @configured == true
369
+ end
370
+
371
+ def config
372
+ @config ||= Configuration.new
373
+ end
374
+
375
+ def reset_config!
376
+ @config = nil
377
+ @configured = false
378
+ end
379
+ end
380
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenReceive
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace OpenReceive
6
+
7
+ # Zeitwerk would otherwise camelize the "openreceive/" directory to Openreceive.
8
+ initializer "openreceive.inflections", before: :set_autoload_paths do
9
+ ActiveSupport::Inflector.inflections(:en) do |inflect|
10
+ inflect.acronym "OpenReceive"
11
+ end
12
+ end
13
+
14
+ # Boot fails closed for real: in production the service — and its wallet
15
+ # preflight — is built eagerly, so a missing NWC_URI, a dead relay, or a
16
+ # spend-capable wallet stops the deploy instead of surfacing as
17
+ # customer-facing 500s on the first checkout. Skipped when the host never
18
+ # ran OpenReceive.configure (gem installed, installer not run yet), and
19
+ # outside production so tests and consoles boot without a live wallet.
20
+ config.after_initialize do
21
+ if OpenReceive.configured?
22
+ if OpenReceive.config.on_paid.equal?(OpenReceive::LOGGING_ON_PAID)
23
+ ::Rails.logger&.warn(
24
+ "[openreceive] config.on_paid is still the generated logging placeholder — " \
25
+ "orders will be recorded as settled without any fulfillment. Replace it in " \
26
+ "config/initializers/openreceive.rb."
27
+ )
28
+ end
29
+ OpenReceive.config.service if ::Rails.env.production?
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "generated/fulfillment_note"
4
+
5
+ module OpenReceive
6
+ # The ONE canonical statement of the host's exactly-once fulfillment duty —
7
+ # what the engine guarantees about on_paid and where that guarantee stops —
8
+ # rendered wherever the install generator writes host-facing code.
9
+ #
10
+ # The text lives in spec/data/fulfillment-note.txt and is generated into both
11
+ # this gem and @openreceive/core, so the Rails install generator and the
12
+ # scaffold CLI cannot give different advice.
13
+ module FulfillmentNote
14
+ DEFAULT_TABLE = "openreceive_payments"
15
+
16
+ module_function
17
+
18
+ # The note with every line given +prefix+ (already including any trailing
19
+ # space). Pass "# " for Ruby comments, "-- " for SQL, or "" for prose.
20
+ # Blank lines drop the prefix's trailing space, so no comment block ends up
21
+ # with trailing whitespace an editor or linter would strip.
22
+ def render(prefix: "", table: DEFAULT_TABLE)
23
+ lines(table: table).map { |line| line.empty? ? prefix.rstrip : "#{prefix}#{line}" }.join("\n")
24
+ end
25
+
26
+ def lines(table: DEFAULT_TABLE)
27
+ Generated::FULFILLMENT_NOTE_TEMPLATE.map { |line| line.gsub("{{table}}", table) }
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ # GENERATED FILE — DO NOT EDIT.
4
+ # Source: spec/data/fulfillment-note.txt (npm run generate:models).
5
+ # The JS twin is packages/js/core/src/generated/fulfillment-note-text.ts; both render the same text, so the
6
+ # Rails install generator and the scaffold CLI cannot give different advice.
7
+
8
+ module OpenReceive
9
+ module Generated
10
+ # The note's lines, with "{{table}}" awaiting the caller's table name.
11
+ FULFILLMENT_NOTE_TEMPLATE = [
12
+ "Fulfilling exactly once",
13
+ "",
14
+ "WHAT OPENRECEIVE GUARANTEES",
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.",
23
+ "",
24
+ "That makes the reference the unit of fulfillment: give every payable order",
25
+ "its own reference, created before checkout, kept across retries, and never",
26
+ "reused. A new checkout under a reference that has already settled is refused",
27
+ "with a 409 rather than fulfilled again; a fresh reference per page load",
28
+ "leaves one order payable twice.",
29
+ "",
30
+ "WHAT YOU MUST GUARANTEE",
31
+ "",
32
+ "OpenReceive cannot see fulfillment that happens outside it. If ANY other",
33
+ "path can also mark this order fulfilled - an admin action, a second payment",
34
+ "processor, a support tool, a replayed webhook, a retried background job -",
35
+ "then those paths race each other, not OpenReceive, and you must make",
36
+ "fulfillment idempotent yourself.",
37
+ "",
38
+ "The usual way is to make the transition itself the lock: guard it with a",
39
+ "conditional write that only one transaction can win.",
40
+ "",
41
+ " -- Idempotent by construction: the WHERE clause is the guard. Whoever",
42
+ " -- flips 'awaiting_payment' -> 'paid' first is the only one who fulfills;",
43
+ " -- every later attempt updates 0 rows and must do nothing.",
44
+ " UPDATE orders",
45
+ " SET state = 'paid', paid_at = :paid_at",
46
+ " WHERE id = :reference",
47
+ " AND state = 'awaiting_payment';",
48
+ " -- then: if 0 rows were affected, return without shipping anything.",
49
+ "",
50
+ "If your fulfillment is a read-modify-write that cannot be expressed as one",
51
+ "conditional UPDATE, take a row lock for the duration instead:",
52
+ "",
53
+ " SELECT * FROM orders WHERE id = :reference FOR UPDATE; -- postgres/mysql",
54
+ " -- ...check state, ship, write the new state, all before COMMIT.",
55
+ "",
56
+ "Run either one inside the transaction OpenReceive hands your settlement",
57
+ "hook, so the order transition and the payment record commit together.",
58
+ ].freeze
59
+ end
60
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenReceive
4
+ # Namespace for the Rails engine gem (openreceive-rails). This is distinct from the
5
+ # top-level `::Rails` framework constant — engine code always references the framework as
6
+ # `::Rails` to avoid shadowing.
7
+ module Rails
8
+ VERSION = "0.2.1"
9
+ end
10
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # OpenReceive Rails engine — mounts the receive-only checkout routes into a Rails app.
4
+ #
5
+ # This file is the require tree. It is deliberately loadable WITHOUT Rails installed so the gem
6
+ # can be syntax/structure-checked in isolation (CI without a Rails app, `ruby -c`, unit tests of
7
+ # the pure-Ruby Configuration + the shared Server::RequestHandler). The Rails-dependent pieces (the
8
+ # Engine, the controllers under app/, the generators) are only wired up when `::Rails::Engine` is
9
+ # present.
10
+ #
11
+ # Layering:
12
+ # openreceive — dependency-free core (money, settlement, NWC normalization)
13
+ # openreceive-server — storage-agnostic Service, the shared
14
+ # framework-neutral RequestHandler, and the RackApp adapter over it
15
+ # openreceive-rails — this gem: Configuration, the Engine, controllers (thin adapters that
16
+ # delegate to Server::RequestHandler), the engine-owned OpenReceivePayment
17
+ # model, and reconciliation (OpenReceive.reconcile!, ReconcileJob, rake task).
18
+
19
+ require "openreceive"
20
+ require "openreceive/server"
21
+
22
+ require "openreceive/rails/version"
23
+ require "openreceive/configuration"
24
+ require "openreceive/reconcile"
25
+
26
+ # The Engine and everything that subclasses a Rails class load only when a usable
27
+ # Rails install is available. Guard so this file still loads for syntax/structure
28
+ # checks without Bundler (CI unit tests, `ruby -c`). A half-installed global Rails
29
+ # gem that raises during boot is treated the same as "Rails not present".
30
+ begin
31
+ require "rails"
32
+ require "rails/engine"
33
+ require "openreceive/engine" if defined?(::Rails::Engine)
34
+ rescue LoadError, NameError, NoMethodError
35
+ # Rails is missing or incomplete. Configuration + Server::RequestHandler remain
36
+ # usable; the Engine is simply not defined.
37
+ end