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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE +21 -0
- data/README.md +52 -0
- data/app/controllers/openreceive/application_controller.rb +117 -0
- data/app/controllers/openreceive/checkouts_controller.rb +21 -0
- data/app/controllers/openreceive/payments_controller.rb +37 -0
- data/app/controllers/openreceive/rates_controller.rb +18 -0
- data/app/controllers/openreceive/swaps_controller.rb +32 -0
- data/app/jobs/openreceive/reconcile_job.rb +15 -0
- data/app/models/open_receive_meta.rb +110 -0
- data/app/models/open_receive_payment.rb +337 -0
- data/config/routes.rb +15 -0
- data/lib/generators/openreceive/install/install_generator.rb +90 -0
- data/lib/generators/openreceive/install/templates/initializer.rb +94 -0
- data/lib/generators/openreceive/install/templates/migration.rb +80 -0
- data/lib/openreceive/configuration.rb +380 -0
- data/lib/openreceive/engine.rb +33 -0
- data/lib/openreceive/fulfillment_note.rb +30 -0
- data/lib/openreceive/generated/fulfillment_note.rb +60 -0
- data/lib/openreceive/rails/version.rb +10 -0
- data/lib/openreceive/rails.rb +37 -0
- data/lib/openreceive/reconcile.rb +316 -0
- data/lib/openreceive-rails.rb +5 -0
- data/lib/tasks/openreceive.rake +68 -0
- metadata +136 -0
|
@@ -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
|