Class: OpenReceivePayment

Inherits:
ActiveRecord::Base
  • Object
show all
Defined in:
app/models/open_receive_payment.rb

Overview

Engine-owned payment attempts. The table lives in the host application's database (the install generator emits the migration), but the schema, locking, settlement write-once, and reconciliation state machine are library-owned.

An order may have many historical attempts. Each row is direct Lightning or exactly one provider swap attempt; never attach several provider orders to one invoice. commit_attempt! serializes on an OpenReceive-owned per-reference lock. Same-method reusable lives conflict; other rails may remain live so payers can switch methods. "Already paid" means any settled row; live means status "pending" and unexpired — hosts never see the live/supersede/conflict vocabulary.

Defined Under Namespace

Classes: AttemptConflict, LockTimeout

Constant Summary collapse

REUSE_BUFFER_SECONDS =
60
STATUSES =
%w[pending settled expired failed attention].freeze
ADVISORY_LOCK_SEED =

Namespacing seed for the postgres per-reference advisory lock. Identical to the JS repository's ADVISORY_LOCK_SEED, and the lock key is computed with the same expression, so a database served by both engines at once still serializes one order's commits against each other.

8_210_223
MYSQL_LOCK_TIMEOUT_SECONDS =

MySQL's GET_LOCK names are capped at 64 bytes and an order id is arbitrary host text, so the name is a digest rather than the id itself.

10

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.commit_attempt!(reference:, payment_hash:, checkout:, swap_data: nil, client_ip: nil) ⇒ Object

Raises:

  • (ArgumentError)


158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'app/models/open_receive_payment.rb', line 158

def self.commit_attempt!(reference:, payment_hash:, checkout:, swap_data: nil, client_ip: nil)
  OpenReceiveMeta.assert_supported_schema!
  normalized_hash = payment_hash.to_s.downcase
  raise ArgumentError, "invalid payment_hash" unless normalized_hash.match?(/\A[0-9a-f]{64}\z/)

  key = reference.to_s
  raise ArgumentError, "reference is required" if key.empty?

  with_reference_lock(key) do
    same = find_by(payment_hash: normalized_hash)
    unless same.nil?
      raise AttemptConflict, "payment hash belongs to another reference" if same.reference.to_s != key
      return same
    end
    raise AttemptConflict, "This reference is already paid." if where(reference: key).settled.exists?

    now = Time.current
    where(reference: key).live_at(now).find_each do |live|
      decision = live_attempt_commit_decision(live, swap_data, now)
      case decision
      when :conflict
        raise AttemptConflict, "An unpaid checkout for this payment method is already in progress for this reference."
      when :supersede
        # Marked, not closed: the invoice stays payable until it expires
        # wallet-side, and closing it here on the local clock would drop it
        # out of the scan set, so funds paid to it could never be matched.
        live.update!(status_reason: "superseded")
      end
    end

    create!(
      reference: key,
      payment_hash: normalized_hash,
      status: "pending",
      expires_at: Time.at(attempt_expires_at(checkout, swap_data)).utc,
      checkout_data: checkout,
      created_at: Time.at(attempt_created_at(checkout)).utc,
      inserted_at: Time.current,
      swap_data: swap_data,
      client_ip: client_ip.presence
    )
  end
end

.count_attempts_from_ip(client_ip, since) ⇒ Object

Called before payer instructions are returned. The order-row lock is the cross-process serialization boundary; no OpenReceive-specific active flag or partial index is needed. Idempotent for a repeated payment_hash. Count attempt rows for one client IP at or after since — backs the optional built-in rate limiting (config.rate_limiting). Counts the immutable local-clock stamp, matching the JS repository: neither the wallet-reported created_at nor the moving updated_at may decide a payer's budget window.



154
155
156
# File 'app/models/open_receive_payment.rb', line 154

def self.count_attempts_from_ip(client_ip, since)
  where(client_ip: client_ip).where("inserted_at >= ?", since).count
end

.mark_paid_once!(payment_hash:, paid_at:) ⇒ Object

Write-once settlement. Records every settled attempt, including an accidental second payment: a later sibling settlement is stored with status_reason "duplicate_settlement". The fulfill block runs inside the settlement transaction only for the first settled attempt for a reference, and a settled row is never overwritten.

Exactly-once holds across every settlement path OpenReceive owns, because first_for_order is decided from openreceive_payments rows under the same per-reference lock commit_attempt! takes. It says nothing about fulfillment the host triggers elsewhere (an admin action, another processor, a replayed job) — those race each other, and the host guards them. The install generator writes that guidance next to the generated on_paid.



214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'app/models/open_receive_payment.rb', line 214

def self.mark_paid_once!(payment_hash:, paid_at:)
  OpenReceiveMeta.assert_supported_schema!
  payment = find_by(payment_hash: payment_hash.to_s.downcase)
  return nil if payment.nil?

  with_reference_lock(payment.reference) do
    payment.reload
    return payment if payment.status == "settled"

    first_for_order = !where(reference: payment.reference).settled.exists?
    payment.update!(
      status: "settled",
      status_reason: first_for_order ? nil : "duplicate_settlement",
      paid_at: Time.at(Integer(paid_at)).utc
    )
    yield(payment) if block_given? && first_for_order
    payment
  end
end

.mysql_connection?Boolean

Returns:

  • (Boolean)


108
109
110
111
# File 'app/models/open_receive_payment.rb', line 108

def self.mysql_connection?
  adapter = connection.adapter_name.to_s.downcase
  adapter.include?("mysql") || adapter.include?("trilogy")
end

.postgres_connection?Boolean

Returns:

  • (Boolean)


104
105
106
# File 'app/models/open_receive_payment.rb', line 104

def self.postgres_connection?
  connection.adapter_name.to_s.downcase.include?("postg")
end

.reconcilable_attemptsObject

Pending attempts for the reconciler's next wallet scan — oldest first, one batch per pass (OpenReceive::Server::RECONCILE_BATCH_SIZE): the attempts closest to their closure deadline are always covered, and a backlog drains over several passes instead of widening one wallet scan window without bound. Terminal rows never return.



255
256
257
258
259
260
261
262
263
264
265
266
# File 'app/models/open_receive_payment.rb', line 255

def self.reconcilable_attempts
  OpenReceiveMeta.assert_supported_schema!
  pending.order(created_at: :asc)
         .limit(OpenReceive::Server::RECONCILE_BATCH_SIZE)
         .pluck(:payment_hash, :created_at, :expires_at).map do |hash, created_at, expires_at|
    {
      "payment_hash" => hash,
      "created_at" => created_at.to_i,
      "expires_at" => expires_at.to_i
    }
  end
end

.record_reconciliation!(payment_hash:, status:, observed_at:, reason:) ⇒ Object

Applies a terminal reconciliation transition only while the row is still pending — idempotent, and a settled attempt is never overwritten.



236
237
238
239
240
241
242
243
244
245
246
247
248
# File 'app/models/open_receive_payment.rb', line 236

def self.record_reconciliation!(payment_hash:, status:, observed_at:, reason:)
  OpenReceiveMeta.assert_supported_schema!
  status_text = status.to_s
  unless %w[expired failed attention].include?(status_text)
    raise ArgumentError, "invalid reconciliation status: #{status_text}"
  end

  where(payment_hash: payment_hash.to_s.downcase, status: "pending").update_all(
    status: status_text,
    status_reason: reason,
    updated_at: Time.at(Integer(observed_at)).utc
  )
end

.selected_for(reference:, action:, payment_hash: nil, pay_in_asset: nil, now: Time.current) ⇒ Object



118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'app/models/open_receive_payment.rb', line 118

def self.selected_for(reference:, action:, payment_hash: nil, pay_in_asset: nil, now: Time.current)
  OpenReceiveMeta.assert_supported_schema!
  attempts = where(reference: reference)
  unless payment_hash.to_s.strip.empty?
    return attempts.find_by(payment_hash: payment_hash.to_s.downcase)
  end

  if %w[checkout.create swap.create].include?(action)
    raise AttemptConflict, "This reference is already paid." if attempts.settled.exists?

    matching = attempts.live_at(now).newest_first.select do |payment|
      matches_create_action?(payment, action, pay_in_asset)
    end
    if matching.length > 1
      raise AttemptConflict, "This reference has multiple unpaid checkouts in progress for this payment method; wait for them to expire before creating another."
    end

    selected = matching.first
    return nil if selected.nil?
    return nil unless reusable?(selected, now)

    return selected
  end

  scope = %w[swap.read swap.refund].include?(action) ? attempts.where.not(swap_data: nil) : attempts
  scope.newest_first.first
end

.with_mysql_reference_lock(key) ⇒ Object

GET_LOCK is session-scoped, so it is taken before BEGIN and released after COMMIT — releasing inside the transaction would leave a window where a second worker could commit against state this one has already read.

Raises:



90
91
92
93
94
95
96
97
98
99
100
101
102
# File 'app/models/open_receive_payment.rb', line 90

def self.with_mysql_reference_lock(key)
  name = "openreceive:#{Digest::SHA256.hexdigest(key)[0, 40]}"
  acquired = connection.select_value(
    sanitize_sql_array(["SELECT GET_LOCK(?, ?)", name, MYSQL_LOCK_TIMEOUT_SECONDS])
  )
  raise LockTimeout, "Timed out taking the OpenReceive lock for this reference." unless acquired.to_i == 1

  begin
    transaction { yield }
  ensure
    connection.select_value(sanitize_sql_array(["SELECT RELEASE_LOCK(?)", name]))
  end
end

.with_reference_lock(reference, &block) ⇒ Object

The per-reference serialization boundary for commit and settlement, owned entirely by OpenReceive: every predicate commit_attempt! and mark_paid_once! evaluate reads only openreceive_payments rows, so the lock is keyed by the order id alone.

Postgres takes a transaction-scoped advisory lock with the same algorithm and seed (8_210_223) as the JS repository's lockReference — the same LOCK, not the same schema: the JS DDL stores unix-seconds BIGINTs and TEXT checkout_data/swap_data where the Rails migration uses datetime columns and t.json, so one table cannot serve both engines. MySQL has no transaction-scoped equivalent, so it takes a session-scoped named lock around the transaction and releases it after commit. SQLite serializes writers itself, so the transaction is the boundary; the payment_hash UNIQUE constraint is the backstop on every adapter.

Raises:

  • (ArgumentError)


69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# File 'app/models/open_receive_payment.rb', line 69

def self.with_reference_lock(reference, &block)
  key = reference.to_s
  raise ArgumentError, "reference is required" if key.empty?

  return with_mysql_reference_lock(key, &block) if mysql_connection?

  transaction do
    if postgres_connection?
      connection.select_value(
        sanitize_sql_array(
          ["SELECT pg_advisory_xact_lock(hashtextextended(?, ?))", key, ADVISORY_LOCK_SEED]
        )
      )
    end
    yield
  end
end

Instance Method Details

#serializable_hash(options = nil) ⇒ Object

Never expose provider recovery credentials through ordinary JSON rendering.



114
115
116
# File 'app/models/open_receive_payment.rb', line 114

def serializable_hash(options = nil)
  super.except("swap_data")
end