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,337 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ # Engine-owned payment attempts. The table lives in the host application's
6
+ # database (the install generator emits the migration), but the schema, locking,
7
+ # settlement write-once, and reconciliation state machine are library-owned.
8
+ #
9
+ # An order may have many historical attempts. Each row is direct Lightning or
10
+ # exactly one provider swap attempt; never attach several provider orders to one
11
+ # invoice. commit_attempt! serializes on an OpenReceive-owned per-reference lock.
12
+ # Same-method reusable lives conflict; other rails may remain live
13
+ # so payers can switch methods. "Already paid" means any settled row; live means status "pending"
14
+ # and unexpired — hosts never see the live/supersede/conflict vocabulary.
15
+ class OpenReceivePayment < ActiveRecord::Base
16
+ self.table_name = "openreceive_payments"
17
+ self.filter_attributes += [:swap_data]
18
+
19
+ REUSE_BUFFER_SECONDS = 60
20
+ STATUSES = %w[pending settled expired failed attention].freeze
21
+
22
+ class AttemptConflict < StandardError; end
23
+
24
+ validates :payment_hash,
25
+ presence: true,
26
+ uniqueness: true,
27
+ format: { with: /\A[0-9a-f]{64}\z/ }
28
+ validates :expires_at, presence: true
29
+ validates :status, inclusion: { in: STATUSES }
30
+
31
+ # Ties break on payment_hash, matching the JS newest-first ordering so
32
+ # two same-second attempts resolve to the same row in both engines.
33
+ scope :newest_first, -> { order(created_at: :desc, payment_hash: :desc) }
34
+ scope :settled, -> { where(status: "settled") }
35
+ scope :pending, -> { where(status: "pending") }
36
+ # A superseded row stays pending so the wallet scan keeps covering it, but it
37
+ # is no longer offered to a payer — so it neither blocks a new attempt nor is
38
+ # superseded again.
39
+ scope :live_at, lambda { |time|
40
+ pending.where("expires_at > ?", time)
41
+ .where("status_reason IS NULL OR status_reason != ?", "superseded")
42
+ }
43
+
44
+ # Namespacing seed for the postgres per-reference advisory lock. Identical to the
45
+ # JS repository's ADVISORY_LOCK_SEED, and the lock key is computed with the
46
+ # same expression, so a database served by both engines at once still
47
+ # serializes one order's commits against each other.
48
+ ADVISORY_LOCK_SEED = 8_210_223
49
+ # MySQL's GET_LOCK names are capped at 64 bytes and an order id is arbitrary
50
+ # host text, so the name is a digest rather than the id itself.
51
+ MYSQL_LOCK_TIMEOUT_SECONDS = 10
52
+
53
+ class LockTimeout < StandardError; end
54
+
55
+ # The per-reference serialization boundary for commit and settlement, owned
56
+ # entirely by OpenReceive: every predicate commit_attempt! and
57
+ # mark_paid_once! evaluate reads only openreceive_payments rows, so the lock
58
+ # is keyed by the order id alone.
59
+ #
60
+ # Postgres takes a transaction-scoped advisory lock with the same algorithm
61
+ # and seed (8_210_223) as the JS repository's lockReference — the same LOCK,
62
+ # not the same schema: the JS DDL stores unix-seconds BIGINTs and TEXT
63
+ # checkout_data/swap_data where the Rails migration uses datetime columns and
64
+ # t.json, so one table cannot serve both engines. MySQL has no
65
+ # transaction-scoped equivalent, so it takes a session-scoped named lock
66
+ # around the transaction and releases it after commit. SQLite serializes
67
+ # writers itself, so the transaction is the boundary; the payment_hash UNIQUE
68
+ # constraint is the backstop on every adapter.
69
+ def self.with_reference_lock(reference, &block)
70
+ key = reference.to_s
71
+ raise ArgumentError, "reference is required" if key.empty?
72
+
73
+ return with_mysql_reference_lock(key, &block) if mysql_connection?
74
+
75
+ transaction do
76
+ if postgres_connection?
77
+ connection.select_value(
78
+ sanitize_sql_array(
79
+ ["SELECT pg_advisory_xact_lock(hashtextextended(?, ?))", key, ADVISORY_LOCK_SEED]
80
+ )
81
+ )
82
+ end
83
+ yield
84
+ end
85
+ end
86
+
87
+ # GET_LOCK is session-scoped, so it is taken before BEGIN and released after
88
+ # COMMIT — releasing inside the transaction would leave a window where a
89
+ # second worker could commit against state this one has already read.
90
+ def self.with_mysql_reference_lock(key)
91
+ name = "openreceive:#{Digest::SHA256.hexdigest(key)[0, 40]}"
92
+ acquired = connection.select_value(
93
+ sanitize_sql_array(["SELECT GET_LOCK(?, ?)", name, MYSQL_LOCK_TIMEOUT_SECONDS])
94
+ )
95
+ raise LockTimeout, "Timed out taking the OpenReceive lock for this reference." unless acquired.to_i == 1
96
+
97
+ begin
98
+ transaction { yield }
99
+ ensure
100
+ connection.select_value(sanitize_sql_array(["SELECT RELEASE_LOCK(?)", name]))
101
+ end
102
+ end
103
+
104
+ def self.postgres_connection?
105
+ connection.adapter_name.to_s.downcase.include?("postg")
106
+ end
107
+
108
+ def self.mysql_connection?
109
+ adapter = connection.adapter_name.to_s.downcase
110
+ adapter.include?("mysql") || adapter.include?("trilogy")
111
+ end
112
+
113
+ # Never expose provider recovery credentials through ordinary JSON rendering.
114
+ def serializable_hash(options = nil)
115
+ super.except("swap_data")
116
+ end
117
+
118
+ def self.selected_for(reference:, action:, payment_hash: nil, pay_in_asset: nil, now: Time.current)
119
+ OpenReceiveMeta.assert_supported_schema!
120
+ attempts = where(reference: reference)
121
+ unless payment_hash.to_s.strip.empty?
122
+ return attempts.find_by(payment_hash: payment_hash.to_s.downcase)
123
+ end
124
+
125
+ if %w[checkout.create swap.create].include?(action)
126
+ raise AttemptConflict, "This reference is already paid." if attempts.settled.exists?
127
+
128
+ matching = attempts.live_at(now).newest_first.select do |payment|
129
+ matches_create_action?(payment, action, pay_in_asset)
130
+ end
131
+ if matching.length > 1
132
+ raise AttemptConflict, "This reference has multiple unpaid checkouts in progress for this payment method; wait for them to expire before creating another."
133
+ end
134
+
135
+ selected = matching.first
136
+ return nil if selected.nil?
137
+ return nil unless reusable?(selected, now)
138
+
139
+ return selected
140
+ end
141
+
142
+ scope = %w[swap.read swap.refund].include?(action) ? attempts.where.not(swap_data: nil) : attempts
143
+ scope.newest_first.first
144
+ end
145
+
146
+ # Called before payer instructions are returned. The order-row lock is the
147
+ # cross-process serialization boundary; no OpenReceive-specific active flag
148
+ # or partial index is needed. Idempotent for a repeated payment_hash.
149
+ # Count attempt rows for one client IP at or after `since` — backs the
150
+ # optional built-in rate limiting (config.rate_limiting).
151
+ # Counts the immutable local-clock stamp, matching the JS repository: neither
152
+ # the wallet-reported created_at nor the moving updated_at may decide a payer's
153
+ # budget window.
154
+ def self.count_attempts_from_ip(client_ip, since)
155
+ where(client_ip: client_ip).where("inserted_at >= ?", since).count
156
+ end
157
+
158
+ def self.commit_attempt!(reference:, payment_hash:, checkout:, swap_data: nil, client_ip: nil)
159
+ OpenReceiveMeta.assert_supported_schema!
160
+ normalized_hash = payment_hash.to_s.downcase
161
+ raise ArgumentError, "invalid payment_hash" unless normalized_hash.match?(/\A[0-9a-f]{64}\z/)
162
+
163
+ key = reference.to_s
164
+ raise ArgumentError, "reference is required" if key.empty?
165
+
166
+ with_reference_lock(key) do
167
+ same = find_by(payment_hash: normalized_hash)
168
+ unless same.nil?
169
+ raise AttemptConflict, "payment hash belongs to another reference" if same.reference.to_s != key
170
+ return same
171
+ end
172
+ raise AttemptConflict, "This reference is already paid." if where(reference: key).settled.exists?
173
+
174
+ now = Time.current
175
+ where(reference: key).live_at(now).find_each do |live|
176
+ decision = live_attempt_commit_decision(live, swap_data, now)
177
+ case decision
178
+ when :conflict
179
+ raise AttemptConflict, "An unpaid checkout for this payment method is already in progress for this reference."
180
+ when :supersede
181
+ # Marked, not closed: the invoice stays payable until it expires
182
+ # wallet-side, and closing it here on the local clock would drop it
183
+ # out of the scan set, so funds paid to it could never be matched.
184
+ live.update!(status_reason: "superseded")
185
+ end
186
+ end
187
+
188
+ create!(
189
+ reference: key,
190
+ payment_hash: normalized_hash,
191
+ status: "pending",
192
+ expires_at: Time.at(attempt_expires_at(checkout, swap_data)).utc,
193
+ checkout_data: checkout,
194
+ created_at: Time.at(attempt_created_at(checkout)).utc,
195
+ inserted_at: Time.current,
196
+ swap_data: swap_data,
197
+ client_ip: client_ip.presence
198
+ )
199
+ end
200
+ end
201
+
202
+ # Write-once settlement. Records every settled attempt, including an
203
+ # accidental second payment: a later sibling settlement is stored with
204
+ # status_reason "duplicate_settlement". The fulfill block runs inside the
205
+ # settlement transaction only for the first settled attempt for a reference, and a
206
+ # settled row is never overwritten.
207
+ #
208
+ # Exactly-once holds across every settlement path OpenReceive owns, because
209
+ # first_for_order is decided from openreceive_payments rows under the same
210
+ # per-reference lock commit_attempt! takes. It says nothing about fulfillment the
211
+ # host triggers elsewhere (an admin action, another processor, a replayed
212
+ # job) — those race each other, and the host guards them. The install
213
+ # generator writes that guidance next to the generated on_paid.
214
+ def self.mark_paid_once!(payment_hash:, paid_at:)
215
+ OpenReceiveMeta.assert_supported_schema!
216
+ payment = find_by(payment_hash: payment_hash.to_s.downcase)
217
+ return nil if payment.nil?
218
+
219
+ with_reference_lock(payment.reference) do
220
+ payment.reload
221
+ return payment if payment.status == "settled"
222
+
223
+ first_for_order = !where(reference: payment.reference).settled.exists?
224
+ payment.update!(
225
+ status: "settled",
226
+ status_reason: first_for_order ? nil : "duplicate_settlement",
227
+ paid_at: Time.at(Integer(paid_at)).utc
228
+ )
229
+ yield(payment) if block_given? && first_for_order
230
+ payment
231
+ end
232
+ end
233
+
234
+ # Applies a terminal reconciliation transition only while the row is still
235
+ # pending — idempotent, and a settled attempt is never overwritten.
236
+ def self.record_reconciliation!(payment_hash:, status:, observed_at:, reason:)
237
+ OpenReceiveMeta.assert_supported_schema!
238
+ status_text = status.to_s
239
+ unless %w[expired failed attention].include?(status_text)
240
+ raise ArgumentError, "invalid reconciliation status: #{status_text}"
241
+ end
242
+
243
+ where(payment_hash: payment_hash.to_s.downcase, status: "pending").update_all(
244
+ status: status_text,
245
+ status_reason: reason,
246
+ updated_at: Time.at(Integer(observed_at)).utc
247
+ )
248
+ end
249
+
250
+ # Pending attempts for the reconciler's next wallet scan — oldest first, one
251
+ # batch per pass (OpenReceive::Server::RECONCILE_BATCH_SIZE): the attempts
252
+ # closest to their closure deadline are always covered, and a backlog drains
253
+ # over several passes instead of widening one wallet scan window without
254
+ # bound. Terminal rows never return.
255
+ def self.reconcilable_attempts
256
+ OpenReceiveMeta.assert_supported_schema!
257
+ pending.order(created_at: :asc)
258
+ .limit(OpenReceive::Server::RECONCILE_BATCH_SIZE)
259
+ .pluck(:payment_hash, :created_at, :expires_at).map do |hash, created_at, expires_at|
260
+ {
261
+ "payment_hash" => hash,
262
+ "created_at" => created_at.to_i,
263
+ "expires_at" => expires_at.to_i
264
+ }
265
+ end
266
+ end
267
+
268
+ def self.reusable?(payment, now = Time.current)
269
+ payment.expires_at.to_i - now.to_i > REUSE_BUFFER_SECONDS
270
+ end
271
+
272
+ def self.matches_create_action?(payment, action, pay_in_asset)
273
+ is_swap = payment.swap_data.present?
274
+ return !is_swap if action == "checkout.create"
275
+ return false unless action == "swap.create"
276
+ return false unless is_swap
277
+
278
+ return true if pay_in_asset.blank?
279
+
280
+ swap_pay_in_asset(payment.swap_data) == pay_in_asset
281
+ end
282
+
283
+ def self.live_attempt_commit_decision(live, incoming_swap_data, now)
284
+ return :ignore unless same_rail_and_asset?(live.swap_data, incoming_swap_data)
285
+
286
+ reusable?(live, now) ? :conflict : :supersede
287
+ end
288
+
289
+ def self.same_rail_and_asset?(left_swap, right_swap)
290
+ left_present = left_swap.present?
291
+ right_present = right_swap.present?
292
+ return false if left_present != right_present
293
+ return true unless left_present
294
+
295
+ left_asset = swap_pay_in_asset(left_swap)
296
+ right_asset = swap_pay_in_asset(right_swap)
297
+ left_asset == right_asset
298
+ end
299
+
300
+ # swap_data reaches the model with string or symbol keys depending on the
301
+ # host's JSON column coder, so both are probed. The camelCase `providerOrder`
302
+ # spelling the JS engine writes is NOT: swap and attempt recovery is
303
+ # per-engine (docs/guides/storage.md), because the two schemas cannot serve
304
+ # one table anyway — the JS DDL stores unix-seconds BIGINTs and TEXT
305
+ # checkout_data/swap_data where this engine's migration uses datetime columns
306
+ # and t.json. A half-alias implied a portability that does not work end to end.
307
+ def self.swap_provider_order_value(swap, key)
308
+ swap&.dig("provider_order", key.to_s) || swap&.dig(:provider_order, key.to_sym)
309
+ end
310
+
311
+ def self.swap_pay_in_asset(swap)
312
+ swap_provider_order_value(swap, :pay_in_asset)
313
+ end
314
+
315
+ def self.attempt_expires_at(checkout, swap_data)
316
+ provider_expiry = swap_provider_order_value(swap_data, :expires_at)
317
+ checkout_expiry =
318
+ checkout[:expires_at] ||
319
+ checkout["expires_at"] ||
320
+ checkout[:expiresAt] ||
321
+ checkout["expiresAt"]
322
+ Integer(provider_expiry || checkout_expiry)
323
+ end
324
+
325
+ def self.attempt_created_at(checkout)
326
+ Integer(
327
+ checkout[:created_at] ||
328
+ checkout["created_at"] ||
329
+ checkout[:createdAt] ||
330
+ checkout["createdAt"]
331
+ )
332
+ end
333
+
334
+ private_class_method :reusable?, :matches_create_action?, :live_attempt_commit_decision,
335
+ :same_rail_and_asset?, :swap_provider_order_value, :swap_pay_in_asset,
336
+ :attempt_expires_at, :attempt_created_at
337
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Engine routes — the shipped @openreceive/http contract (spec/openapi/openreceive-http.v1.yaml),
4
+ # mounted by the host with `mount OpenReceive::Engine => "/openreceive"`.
5
+ OpenReceive::Engine.routes.draw do
6
+ post "checkouts/prepare", to: "checkouts#prepare"
7
+ post "checkouts", to: "checkouts#create"
8
+ post "payments/check", to: "payments#check"
9
+ post "swaps/quote", to: "swaps#quote"
10
+ post "swaps", to: "swaps#create"
11
+ post "swaps/status", to: "swaps#status"
12
+ post "swaps/refunds", to: "swaps#refund"
13
+
14
+ get "rates", to: "rates#index"
15
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rails/generators/active_record"
5
+ require "rails/generators/active_record/migration"
6
+ require "openreceive/fulfillment_note"
7
+
8
+ module OpenReceive
9
+ module Generators
10
+ class InstallGenerator < ::Rails::Generators::Base
11
+ include ::ActiveRecord::Generators::Migration
12
+
13
+ namespace "openreceive:install"
14
+ source_root File.expand_path("templates", __dir__)
15
+ desc "Installs the OpenReceive routes, initializer, and one migration creating both " \
16
+ "engine tables (openreceive_payments and the openreceive_meta reconcile gate). " \
17
+ "The OpenReceivePayment model is engine-owned; only the tables live in the host database."
18
+
19
+ class_option :skip_initializer, type: :boolean, default: false
20
+ class_option :skip_route, type: :boolean, default: false
21
+ class_option :skip_migration, type: :boolean, default: false
22
+
23
+ def create_openreceive_migration
24
+ return if options[:skip_migration]
25
+
26
+ migration_template "migration.rb", "db/migrate/create_openreceive_tables.rb"
27
+ end
28
+
29
+ def create_initializer
30
+ template "initializer.rb", "config/initializers/openreceive.rb" unless options[:skip_initializer]
31
+ end
32
+
33
+ def mount_engine
34
+ route %(mount OpenReceive::Engine => "/openreceive") unless options[:skip_route]
35
+ end
36
+
37
+ private
38
+
39
+ # The exactly-once fulfillment note, shared verbatim with the JS scaffold.
40
+ #
41
+ # Rails and Thor binread their templates, so ERB builds the output in
42
+ # ASCII-8BIT. The note is UTF-8 prose, and concatenating the two raises
43
+ # Encoding::CompatibilityError — so hand ERB the same bytes tagged the
44
+ # way it expects. Thor writes the result in binary mode, so the file on
45
+ # disk is still correct UTF-8.
46
+ def fulfillment_note(prefix)
47
+ OpenReceive::FulfillmentNote.render(prefix: prefix).dup.force_encoding(Encoding::BINARY)
48
+ end
49
+
50
+ def migration_version
51
+ "#{::ActiveRecord::VERSION::MAJOR}.#{::ActiveRecord::VERSION::MINOR}"
52
+ end
53
+
54
+ # Mirrors the JS OPENRECEIVE_PAYMENTS_SCHEMA_VERSION.
55
+ def schema_version
56
+ OpenReceive::Server::PAYMENTS_SCHEMA_VERSION
57
+ end
58
+
59
+ # Postgres has regex matching; sqlite needs GLOB. Emitted as a Ruby
60
+ # string literal so the migration carries the right one for the adapter
61
+ # the app actually runs. MySQL's bare REGEXP follows the column's
62
+ # case-insensitive collation, so its rendering pins case sensitivity
63
+ # with REGEXP_LIKE's 'c' flag.
64
+ def payment_hash_check_sql
65
+ return %("REGEXP_LIKE(payment_hash, '^[0-9a-f]{64}$', 'c')") if mysql_adapter?
66
+
67
+ <<~RUBY.strip
68
+ if connection.adapter_name.downcase.include?("postgres")
69
+ "payment_hash ~ '^[0-9a-f]{64}$'"
70
+ else
71
+ "length(payment_hash) = 64 AND payment_hash NOT GLOB '*[^0-9a-f]*'"
72
+ end
73
+ RUBY
74
+ end
75
+
76
+ # MySQL has no ON CONFLICT and treats `key` as a reserved word, so its
77
+ # migration is rendered for it at generate time; the postgres/sqlite
78
+ # rendering keeps branching at migration runtime and stays unchanged.
79
+ def mysql_adapter?
80
+ %w[mysql2 trilogy].include?(database_adapter)
81
+ end
82
+
83
+ def database_adapter
84
+ ActiveRecord::Base.connection_db_config.adapter.to_s
85
+ rescue StandardError
86
+ ""
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Configure during initializer load (not after_initialize). parent_controller is
4
+ # read when OpenReceive::ApplicationController is eager-loaded in production.
5
+ OpenReceive.configure do |config|
6
+ config.parent_controller = "ApplicationController"
7
+ # Secrets load from NWC_URI, LSC_URI_PRIMARY, and LSC_URI_BACKUP.
8
+ # Keep ordinary settings here in the Rails initializer.
9
+ config.price_currencies = ["USD"]
10
+
11
+ # The host authorizes every request; OpenReceive mints no tokens.
12
+ #
13
+ # `context` is a Hash with three symbol keys:
14
+ # context[:action] — which route: "checkout.prepare", "checkout.create",
15
+ # "payment.check", "swap.quote", "swap.create",
16
+ # "swap.read", or "swap.refund"
17
+ # context[:request] — the ActionDispatch::Request; read your session,
18
+ # cookies, or headers from it, as in a controller
19
+ # context[:resource] — { reference:, payment_hash: } copied from the
20
+ # payer's JSON body. It names an order; it does not
21
+ # prove this caller owns it.
22
+ # Return true to allow the request, false for a 403.
23
+ #
24
+ # The default below allows every request, treating possession of the
25
+ # reference as the authorization (an unknown reference is still a 404,
26
+ # because amount_for returns nil for it). That is only safe while your
27
+ # references are unguessable (UUIDs, not sequential integers). If they are
28
+ # enumerable, or an order should only be visible to the customer who placed
29
+ # it, check ownership against your own session instead (`Order` stands in
30
+ # for your own model — any name works; OpenReceive never sees it):
31
+ # config.authorize = lambda do |context|
32
+ # order = Order.find_by(id: context[:resource][:reference])
33
+ # order && order.user_id == context[:request].session[:user_id]
34
+ # end
35
+ config.authorize = ->(_context) { true }
36
+
37
+ # The price for a reference — the string your checkout passes, typically
38
+ # your order id. Your application is the only price authority; payer input
39
+ # never carries an amount. Return { currency: "USD", value: "12.00" } or
40
+ # { sats: 1200 }, or nil when there is nothing to pay for (a 404). The
41
+ # engine refuses to serve checkouts until this is set.
42
+ #
43
+ # TODO(price): look the reference up in your own application, e.g.
44
+ # config.amount_for = lambda do |reference|
45
+ # order = Order.find_by(id: reference)
46
+ # order && { currency: "USD", value: order.total.to_s }
47
+ # end
48
+
49
+ # Runs inside the settlement transaction, only for the order's first settled
50
+ # attempt. `settlement` exposes reference, payment_hash, and paid_at.
51
+ #
52
+ <%= fulfillment_note(" # ") %>
53
+ #
54
+ # TODO(fulfillment): replace the logging placeholder with your real
55
+ # fulfillment. Written as the guarded transition described above, so a
56
+ # second fulfillment path can never ship the same order twice.
57
+ # (FulfillOrder stands in for your own application code — ship the goods,
58
+ # enqueue the confirmation email. OpenReceive does not provide it.)
59
+ #
60
+ # config.on_paid = lambda do |settlement|
61
+ # claimed = Order
62
+ # .where(id: settlement.reference, state: "awaiting_payment")
63
+ # .update_all(state: "paid", paid_at: Time.at(settlement.paid_at).utc)
64
+ # next if claimed.zero? # someone else already fulfilled it
65
+ #
66
+ # FulfillOrder.call(Order.find(settlement.reference),
67
+ # payment_hash: settlement.payment_hash)
68
+ # end
69
+ #
70
+ # The placeholder only logs the settlement; the engine warns at every boot
71
+ # while it is still configured, because orders would otherwise be recorded as
72
+ # settled without ever being fulfilled.
73
+ config.on_paid = OpenReceive::LOGGING_ON_PAID
74
+
75
+ # Recommended for public web shops: cap invoice creation per client IP,
76
+ # counted from the engine-owned openreceive_payments rows. Leave it off for
77
+ # point-of-sale deployments where many payers share one IP. Behind a proxy,
78
+ # configure Rails' trusted proxies so request.ip is the payer.
79
+ # config.rate_limiting = true
80
+
81
+ # Settlement discovery is opportunistic by default: every engine request
82
+ # first runs one reconcile pass through the durable openreceive_meta gate
83
+ # (shared by all Puma workers; min 2s between real wallet scans), so pending
84
+ # attempts settle or close on any later OpenReceive call — no scheduled job
85
+ # required. Set false only if a dedicated worker owns scanning.
86
+ # config.opportunistic_reconcile = false
87
+ end
88
+
89
+ # Optional: for push settlement the moment the wallet reports payment_received,
90
+ # run the long-lived worker `bin/rails openreceive:notifications` — it listens
91
+ # for NWC-02 notifications AND reconciles periodically in the same process (the
92
+ # safety net for notifications missed while it was down). One-shot primitives
93
+ # (`bin/rails openreceive:reconcile`, OpenReceive::ReconcileJob) remain
94
+ # available; there is no need to schedule them.
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Both engine-owned tables, in one migration: the payment attempts and the
4
+ # durable reconcile gate they share. Same host database, never a second one.
5
+ #
6
+ <%= fulfillment_note("# ") %>
7
+ class CreateOpenreceiveTables < ActiveRecord::Migration[<%= migration_version %>]
8
+ def change
9
+ create_table :openreceive_payments do |t|
10
+ # Your order id, as you passed it.
11
+ t.string :reference, null: false
12
+ t.string :payment_hash, null: false, limit: 64
13
+ # Attempt lifecycle: pending | settled | expired | failed | attention.
14
+ t.string :status, null: false, default: "pending"
15
+ # Operator-facing detail for the current status (e.g. "superseded").
16
+ t.string :status_reason
17
+ t.datetime :paid_at
18
+ t.datetime :expires_at, null: false
19
+ # Safe checkout response used for retry without another wallet call.
20
+ t.json :checkout_data, null: false
21
+ # Server-only provider recovery data. Never return or log this column.
22
+ t.json :swap_data
23
+ # Client IP captured at invoice creation; backs optional per-IP rate limiting.
24
+ t.string :client_ip
25
+ # Immutable local-clock stamp the rate limiter windows on. created_at is
26
+ # the wallet-reported invoice time (a skewed wallet clock would move the
27
+ # window) and updated_at moves on every status transition (which would
28
+ # re-enter an old attempt into the current window).
29
+ t.datetime :inserted_at, null: false
30
+ t.timestamps
31
+ end
32
+
33
+ add_index :openreceive_payments, :payment_hash, unique: true
34
+ add_index :openreceive_payments, [:reference, :created_at]
35
+ add_index :openreceive_payments, [:status, :created_at]
36
+ add_index :openreceive_payments, [:client_ip, :inserted_at]
37
+
38
+ # Engine-owned key/value/rev rows: the durable reconcile gate every worker
39
+ # on this database shares, so settlement scans piggybacking on requests
40
+ # collapse to one real wallet call per interval. Same host database as
41
+ # openreceive_payments, never a second one.
42
+ create_table :openreceive_meta, id: false do |t|
43
+ t.string :key, null: false, primary_key: true
44
+ t.text :value, null: false
45
+ t.bigint :rev, null: false, default: 0
46
+ end
47
+
48
+ # Database-level backstops for the two invariants the engines enforce in
49
+ # code, mirroring the JS paymentsSchemaSql. There is deliberately
50
+ # NO uniqueness constraint over live attempts: liveness is time-dependent
51
+ # (a superseded row stays pending with a future expires_at, and an expired
52
+ # row stays pending until a wallet scan closes it), so any such index would
53
+ # reject legitimate reminting.
54
+ add_check_constraint :openreceive_payments,
55
+ "status IN ('pending', 'settled', 'expired', 'failed', 'attention')",
56
+ name: "openreceive_payments_status_check"
57
+ add_check_constraint :openreceive_payments,
58
+ <%= payment_hash_check_sql %>,
59
+ name: "openreceive_payments_payment_hash_check"
60
+
61
+ # Which schema generation is installed. The engine refuses to run against a
62
+ # generation newer than the library it is linked with.
63
+ reversible do |direction|
64
+ direction.up do
65
+ <% if mysql_adapter? -%>
66
+ execute(<<~SQL.squish)
67
+ INSERT IGNORE INTO openreceive_meta (`key`, value, rev)
68
+ VALUES ('schema_version', '<%= schema_version %>', 0)
69
+ SQL
70
+ <% else -%>
71
+ execute(<<~SQL.squish)
72
+ INSERT INTO openreceive_meta (key, value, rev)
73
+ VALUES ('schema_version', '<%= schema_version %>', 0)
74
+ ON CONFLICT (key) DO NOTHING
75
+ SQL
76
+ <% end -%>
77
+ end
78
+ end
79
+ end
80
+ end