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,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
|