mpp-rb 0.1.2 → 0.1.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7accbb98302b70cc76e32afc99bf2b5672c445194f79e9fd1cdfb0e5d7f058dd
4
- data.tar.gz: 3538fdc0cd0851816ac29f294de441e18fefb45f0bd00b44cad6295ca6a1e1f1
3
+ metadata.gz: cfa6aaca1aa33a23ec690b0c9cae8e04fe54ed71891fb82fd59703af067ec41e
4
+ data.tar.gz: 05fc8e20c0213027578d160f2b80fd953a4ea0e2d2ddd89bb6e1a78c971bfb11
5
5
  SHA512:
6
- metadata.gz: b6cd8af4a9376b0db632aab578f1d9fb0e9e6d3327c926e40f35badb16750d06a15774fe7444debde1a402a7d3b51133d25bfd43d577de726fe646c35bc4f522
7
- data.tar.gz: 2997bf04f49594dce0246c917075d5aba8a9f974e6ed4ff6cda586e8ae9361db110cf5197ee977fa8d795b5f0affc701f34170f4c75654f5485853608fb6776c
6
+ metadata.gz: d1649fad2a0269ba907e5dd59bbcbc3072b26d40f2951cbda262eede6156b97fd75547b8675cbb7d0c89a426a41983adb50276805b9acc048d542b7615f941d9
7
+ data.tar.gz: ddbe4e28550e8d428843907be443c0f6e67864e9d598f6cb217eb47b4af8501559284b3f2806a583cbd7363e7f858da79995aafa469c0067b5f183427ede7b1f
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Ruby SDK for the [**Machine Payments Protocol**](https://mpp.dev)
4
4
 
5
- [![Gem Version](https://img.shields.io/gem/v/mpp.svg)](https://rubygems.org/gems/mpp-rb)
5
+ [![Gem Version](https://img.shields.io/gem/v/mpp-rb.svg)](https://rubygems.org/gems/mpp-rb)
6
6
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
7
 
8
8
  ## Documentation
@@ -68,6 +68,35 @@ transport = Mpp::Client::Transport.new(
68
68
  response = transport.request(:get, "https://mpp.dev/api/ping/paid")
69
69
  ```
70
70
 
71
+ ### Event hooks
72
+
73
+ Register hooks to observe the automatic payment lifecycle. Each registration returns an unsubscribe proc.
74
+
75
+ ```ruby
76
+ server.on_challenge_created do |payload|
77
+ puts "challenge: #{payload[:challenge].id}"
78
+ end
79
+
80
+ server.on_payment_success do |payload|
81
+ puts "paid: #{payload[:receipt].reference}"
82
+ end
83
+
84
+ transport.on_challenge_received do |payload|
85
+ puts "received: #{payload[:challenge].id}"
86
+ nil
87
+ end
88
+
89
+ transport.on_payment_response do |payload|
90
+ puts "retry status: #{payload[:response].code}"
91
+ end
92
+
93
+ transport.on("*") do |event|
94
+ puts "payment event: #{event.name}"
95
+ end
96
+ ```
97
+
98
+ Client events are `challenge.received`, `credential.created`, `payment.response`, and `payment.failed`. Server events are `challenge.created`, `payment.success`, and `payment.failed`.
99
+
71
100
  ### Rack Middleware
72
101
 
73
102
  ```ruby
@@ -123,10 +152,13 @@ Built on the ["Payment" HTTP Authentication Scheme](https://datatracker.ietf.org
123
152
 
124
153
  ## Releasing
125
154
 
126
- 1. Update the version in `lib/mpp/version.rb`
127
- 2. Commit: `git commit -am "v0.x.x"`
128
- 3. Tag: `git tag v0.x.x`
129
- 4. Push: `git push origin main --tags`
155
+ 1. Create a release PR:
156
+ - Update the version in `lib/mpp/version.rb`
157
+ - Run `bundle lock --update mpp-rb` in the root and each `examples/` subdirectory
158
+ - Commit and open a PR to verify CI passes
159
+ 2. Merge the PR
160
+ 3. Tag the merge commit: `git tag v0.x.x`
161
+ 4. Push the tag: `git push origin --tags`
130
162
 
131
163
  ## License
132
164
 
@@ -21,7 +21,7 @@ module Mpp
21
21
  when String
22
22
  # use as-is
23
23
  end
24
- body = body.encode(Encoding::UTF_8) if body.is_a?(String)
24
+ body = body.b if body.is_a?(String)
25
25
  digest = OpenSSL::Digest::SHA256.digest(body)
26
26
  encoded = Base64.strict_encode64(digest)
27
27
  "sha-256=#{encoded}"
data/lib/mpp/challenge.rb CHANGED
@@ -61,17 +61,82 @@ module Mpp
61
61
  # Parse multiple Payment challenges from a merged WWW-Authenticate header.
62
62
  # Handles RFC 9110 §11.6.1 comma-separated authentication schemes.
63
63
  def self.from_www_authenticate_list(header)
64
- indices = []
65
- header.scan(/Payment\s+/i) { indices << T.must(Regexp.last_match).begin(0) }
66
- return [] if indices.empty?
67
-
68
- indices.each_with_index.map do |start_idx, i|
69
- end_idx = (i + 1 < indices.length) ? indices[i + 1] : header.length
64
+ payment_scheme_indices(header).map do |start_idx|
65
+ # End each chunk at the next scheme boundary of any kind, so an
66
+ # interleaved non-Payment scheme (e.g. "Payment ..., Bearer ...,
67
+ # Payment ...") is not folded into the preceding Payment challenge.
68
+ end_idx = next_auth_scheme_index(header, start_idx + "Payment".length) || header.length
70
69
  chunk = T.must(header[start_idx...end_idx]).sub(/,\s*$/, "")
71
70
  from_www_authenticate(chunk)
72
71
  end
73
72
  end
74
73
 
74
+ def self.payment_scheme_indices(header)
75
+ indices = []
76
+ each_auth_scheme_index(header) do |index, scheme|
77
+ indices << index if scheme.casecmp("Payment").zero?
78
+ end
79
+ indices
80
+ end
81
+
82
+ def self.next_auth_scheme_index(header, offset)
83
+ each_auth_scheme_index(header, offset) do |index, _scheme|
84
+ return index
85
+ end
86
+ nil
87
+ end
88
+
89
+ def self.each_auth_scheme_index(header, offset = 0)
90
+ in_quote = T.let(false, T::Boolean)
91
+ escaped = T.let(false, T::Boolean)
92
+ i = offset
93
+
94
+ while i < header.length
95
+ char = T.must(header[i])
96
+
97
+ if in_quote
98
+ if escaped
99
+ escaped = false
100
+ elsif char == "\\"
101
+ escaped = true
102
+ elsif char == "\""
103
+ in_quote = false
104
+ end
105
+ i += 1
106
+ next
107
+ end
108
+
109
+ if char == "\""
110
+ in_quote = true
111
+ i += 1
112
+ next
113
+ end
114
+
115
+ if scheme_boundary?(header, i)
116
+ match = T.must(header[i..]).match(/\A([A-Za-z][A-Za-z0-9._~+\/-]*)\s+/)
117
+ # An auth-param permits OWS around "=" (key\s*=\s*value), so a token
118
+ # followed by whitespace then "=" is a parameter, not a new scheme.
119
+ if match && header[i + T.must(match[0]).length] != "="
120
+ yield i, T.must(match[1])
121
+ i += T.must(match[0]).length
122
+ next
123
+ end
124
+ end
125
+
126
+ i += 1
127
+ end
128
+ end
129
+
130
+ def self.scheme_boundary?(header, index)
131
+ previous = T.must(header[0...index]).rstrip
132
+ # Start of the header, including the case where only optional whitespace
133
+ # (RFC 9110 OWS) precedes the first scheme; otherwise a scheme is a
134
+ # boundary only immediately after a comma separating two schemes.
135
+ return true if previous.empty?
136
+
137
+ previous.end_with?(",")
138
+ end
139
+
75
140
  # Serialize to a WWW-Authenticate header value.
76
141
  def to_www_authenticate(realm)
77
142
  Mpp::Parsing.format_www_authenticate(self, realm)
@@ -18,9 +18,35 @@ module Mpp
18
18
  class Transport
19
19
  extend T::Sig
20
20
 
21
- sig { params(methods: T::Array[T.untyped]).void }
22
- def initialize(methods:)
21
+ sig { params(methods: T::Array[T.untyped], events: T.nilable(Mpp::Events::Dispatcher)).void }
22
+ def initialize(methods:, events: nil)
23
23
  @methods = T.let(methods.to_h { |m| [m.name, m] }, T::Hash[String, T.untyped])
24
+ @events = T.let(events || Mpp::Events.client_dispatcher, Mpp::Events::Dispatcher)
25
+ end
26
+
27
+ sig { params(name: String, handler: T.untyped, block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) }
28
+ def on(name, handler = nil, &block)
29
+ @events.on(name, handler, &block)
30
+ end
31
+
32
+ sig { params(handler: T.untyped, block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) }
33
+ def on_challenge_received(handler = nil, &block)
34
+ on(Mpp::Events::CHALLENGE_RECEIVED, handler, &block)
35
+ end
36
+
37
+ sig { params(handler: T.untyped, block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) }
38
+ def on_credential_created(handler = nil, &block)
39
+ on(Mpp::Events::CREDENTIAL_CREATED, handler, &block)
40
+ end
41
+
42
+ sig { params(handler: T.untyped, block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) }
43
+ def on_payment_failed(handler = nil, &block)
44
+ on(Mpp::Events::PAYMENT_FAILED, handler, &block)
45
+ end
46
+
47
+ sig { params(handler: T.untyped, block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) }
48
+ def on_payment_response(handler = nil, &block)
49
+ on(Mpp::Events::PAYMENT_RESPONSE, handler, &block)
24
50
  end
25
51
 
26
52
  # Send an HTTP request with automatic 402 payment handling.
@@ -34,7 +60,7 @@ module Mpp
34
60
 
35
61
  # Parse WWW-Authenticate headers
36
62
  www_auth_headers = response.get_fields("www-authenticate") || []
37
- challenge, matched_method = find_matching_challenge(www_auth_headers)
63
+ challenge, matched_method = find_matching_challenge(www_auth_headers, input: url, response: response)
38
64
  return response unless challenge && matched_method
39
65
 
40
66
  # Check expiry before paying (client-side guardrail)
@@ -47,11 +73,90 @@ module Mpp
47
73
  end
48
74
  end
49
75
 
50
- credential = matched_method.create_credential(challenge)
51
- auth_header = credential.to_authorization
76
+ auth_header = T.let(nil, T.nilable(String))
77
+ create_credential = Kernel.lambda do
78
+ auth_header ||= credential_authorization(matched_method.create_credential(challenge))
79
+ end
80
+
81
+ begin
82
+ event_credential = nil
83
+ if @events.has_handlers?(Mpp::Events::CHALLENGE_RECEIVED)
84
+ # challenge.received can override credential creation; first non-empty credential wins.
85
+ event_credential = @events.emit_first(Mpp::Events::CHALLENGE_RECEIVED, {
86
+ challenge: challenge,
87
+ challenges: [challenge],
88
+ create_credential: create_credential,
89
+ input: url,
90
+ method: matched_method,
91
+ response: response
92
+ })
93
+ end
94
+ auth_header = credential_authorization(event_credential) unless event_credential.nil?
95
+ auth_header ||= create_credential.call
96
+
97
+ if @events.has_handlers?(Mpp::Events::CREDENTIAL_CREATED)
98
+ @events.emit(Mpp::Events::CREDENTIAL_CREATED, {
99
+ challenge: challenge,
100
+ credential: auth_header,
101
+ input: url,
102
+ method: matched_method,
103
+ response: response
104
+ })
105
+ end
106
+ rescue => e
107
+ if @events.has_handlers?(Mpp::Events::PAYMENT_FAILED)
108
+ @events.emit(Mpp::Events::PAYMENT_FAILED, {
109
+ challenge: challenge,
110
+ challenges: [challenge],
111
+ error: e,
112
+ input: url,
113
+ method: matched_method,
114
+ response: response
115
+ })
116
+ end
117
+ raise
118
+ end
52
119
 
53
120
  retry_headers = headers.merge("Authorization" => auth_header)
54
- send_request(uri, method, retry_headers, body)
121
+ payment_response = nil
122
+ begin
123
+ payment_response = send_request(uri, method, retry_headers, body)
124
+ rescue => e
125
+ if @events.has_handlers?(Mpp::Events::PAYMENT_FAILED)
126
+ @events.emit(Mpp::Events::PAYMENT_FAILED, {
127
+ challenge: challenge,
128
+ challenges: [challenge],
129
+ credential: auth_header,
130
+ error: e,
131
+ input: url,
132
+ method: matched_method,
133
+ response: response
134
+ })
135
+ end
136
+ raise
137
+ end
138
+
139
+ if payment_response.code.to_i.between?(200, 299) && @events.has_handlers?(Mpp::Events::PAYMENT_RESPONSE)
140
+ @events.emit(Mpp::Events::PAYMENT_RESPONSE, {
141
+ challenge: challenge,
142
+ credential: auth_header,
143
+ input: url,
144
+ method: matched_method,
145
+ response: payment_response
146
+ })
147
+ elsif @events.has_handlers?(Mpp::Events::PAYMENT_FAILED)
148
+ @events.emit(Mpp::Events::PAYMENT_FAILED, {
149
+ challenge: challenge,
150
+ challenges: [challenge],
151
+ credential: auth_header,
152
+ error: Mpp::VerificationFailedError.new(reason: "retry returned HTTP #{payment_response.code}"),
153
+ input: url,
154
+ method: matched_method,
155
+ response: payment_response
156
+ })
157
+ end
158
+
159
+ payment_response
55
160
  end
56
161
 
57
162
  sig { params(url: T.any(URI::Generic, String), kwargs: T.untyped).returns(T.untyped) }
@@ -97,20 +202,50 @@ module Mpp
97
202
  http.request(req)
98
203
  end
99
204
 
100
- sig { params(www_auth_headers: T.untyped).returns(T::Array[T.untyped]) }
101
- def find_matching_challenge(www_auth_headers)
205
+ sig { params(www_auth_headers: T.untyped, input: T.untyped, response: T.untyped).returns(T::Array[T.untyped]) }
206
+ def find_matching_challenge(www_auth_headers, input: nil, response: nil)
102
207
  www_auth_headers.each do |header|
103
208
  next unless header.downcase.start_with?("payment ")
104
209
 
105
210
  begin
106
211
  parsed = Mpp::Challenge.from_www_authenticate(header)
107
212
  return [parsed, @methods[parsed.method]] if @methods.key?(parsed.method)
108
- rescue Mpp::ParseError
213
+ rescue Mpp::ParseError => e
214
+ if @events.has_handlers?(Mpp::Events::PAYMENT_FAILED)
215
+ @events.emit(Mpp::Events::PAYMENT_FAILED, {
216
+ error: e,
217
+ input: input,
218
+ response: response
219
+ })
220
+ end
109
221
  next
110
222
  end
111
223
  end
112
224
  [nil, nil]
113
225
  end
226
+
227
+ sig { params(credential: T.untyped).returns(String) }
228
+ def credential_authorization(credential)
229
+ auth_header = if credential.respond_to?(:to_authorization)
230
+ credential.to_authorization
231
+ elsif credential.is_a?(String)
232
+ credential
233
+ else
234
+ raise ArgumentError, "Credential must be a String or respond to #to_authorization"
235
+ end
236
+
237
+ validate_authorization_header(auth_header)
238
+ auth_header
239
+ end
240
+
241
+ sig { params(auth_header: String).void }
242
+ def validate_authorization_header(auth_header)
243
+ unless auth_header.start_with?("Payment ") && auth_header.length > 8
244
+ raise ArgumentError, "Credential must be a non-empty Payment authorization header"
245
+ end
246
+
247
+ raise ArgumentError, "Credential contains invalid header characters" if auth_header.match?(/[[:cntrl:]]/)
248
+ end
114
249
  end
115
250
 
116
251
  # Module-level convenience methods
@@ -118,20 +253,20 @@ module Mpp
118
253
 
119
254
  module_function
120
255
 
121
- sig { params(method: T.untyped, url: T.untyped, methods: T::Array[T.untyped], kwargs: T.untyped).returns(T.untyped) }
122
- def request(method, url, methods:, **kwargs)
123
- transport = Transport.new(methods: methods)
256
+ sig { params(method: T.untyped, url: T.untyped, methods: T::Array[T.untyped], events: T.nilable(Mpp::Events::Dispatcher), kwargs: T.untyped).returns(T.untyped) }
257
+ def request(method, url, methods:, events: nil, **kwargs)
258
+ transport = Transport.new(methods: methods, events: events)
124
259
  transport.request(method, url, **kwargs)
125
260
  end
126
261
 
127
- sig { params(url: T.untyped, methods: T::Array[T.untyped], kwargs: T.untyped).returns(T.untyped) }
128
- def get(url, methods:, **kwargs)
129
- request("GET", url, **T.unsafe({methods: methods, **kwargs}))
262
+ sig { params(url: T.untyped, methods: T::Array[T.untyped], events: T.nilable(Mpp::Events::Dispatcher), kwargs: T.untyped).returns(T.untyped) }
263
+ def get(url, methods:, events: nil, **kwargs)
264
+ request("GET", url, **T.unsafe({methods: methods, events: events, **kwargs}))
130
265
  end
131
266
 
132
- sig { params(url: T.untyped, methods: T::Array[T.untyped], kwargs: T.untyped).returns(T.untyped) }
133
- def post(url, methods:, **kwargs)
134
- request("POST", url, **T.unsafe({methods: methods, **kwargs}))
267
+ sig { params(url: T.untyped, methods: T::Array[T.untyped], events: T.nilable(Mpp::Events::Dispatcher), kwargs: T.untyped).returns(T.untyped) }
268
+ def post(url, methods:, events: nil, **kwargs)
269
+ request("POST", url, **T.unsafe({methods: methods, events: events, **kwargs}))
135
270
  end
136
271
  end
137
272
  end
data/lib/mpp/errors.rb CHANGED
@@ -10,6 +10,9 @@ module Mpp
10
10
  # Base verification error.
11
11
  class VerificationError < StandardError; end
12
12
 
13
+ # Retryable verification error for transactions that were accepted but not mined yet.
14
+ class TransactionPendingError < VerificationError; end
15
+
13
16
  # Base class for all payment-related errors with RFC 9457 support.
14
17
  class PaymentError < StandardError
15
18
  extend T::Sig
data/lib/mpp/events.rb ADDED
@@ -0,0 +1,124 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Events
6
+ extend T::Sig
7
+
8
+ ANY = "*"
9
+ CHALLENGE_CREATED = "challenge.created"
10
+ CHALLENGE_RECEIVED = "challenge.received"
11
+ CREDENTIAL_CREATED = "credential.created"
12
+ PAYMENT_FAILED = "payment.failed"
13
+ PAYMENT_RESPONSE = "payment.response"
14
+ PAYMENT_SUCCESS = "payment.success"
15
+
16
+ class Event < T::Struct
17
+ const :name, String
18
+ const :payload, T::Hash[Symbol, T.untyped]
19
+ end
20
+
21
+ class Dispatcher
22
+ extend T::Sig
23
+
24
+ sig { params(event_names: T::Array[String]).void }
25
+ def initialize(event_names:)
26
+ @event_names = T.let(event_names.to_h { |name| [name, true] }, T::Hash[String, T::Boolean])
27
+ @event_names[ANY] = true
28
+ @handlers = T.let(@event_names.keys.to_h { |name| [name, []] }, T::Hash[String, T::Array[T.untyped]])
29
+ end
30
+
31
+ sig { params(name: String, handler: T.untyped, block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) }
32
+ def on(name, handler = nil, &block)
33
+ raise ArgumentError, "Unknown event: #{name}" unless @event_names.key?(name)
34
+
35
+ callback = handler || block
36
+ raise ArgumentError, "handler is required" unless callback
37
+
38
+ handlers = T.must(@handlers[name])
39
+ handlers << callback
40
+ Kernel.lambda { handlers.delete(callback) }
41
+ end
42
+
43
+ sig { params(name: String, payload: T::Hash[Symbol, T.untyped]).void }
44
+ def emit(name, payload)
45
+ return unless has_handlers?(name)
46
+
47
+ emit_observers(name, payload)
48
+ emit_any(name, payload)
49
+ end
50
+
51
+ sig { params(name: String, payload: T::Hash[Symbol, T.untyped]).returns(T.untyped) }
52
+ def emit_first(name, payload)
53
+ return nil unless has_handlers?(name)
54
+
55
+ result = T.let(nil, T.untyped)
56
+
57
+ T.must(@handlers[name]).each do |handler|
58
+ value = call(handler, payload)
59
+ next if empty_result?(value)
60
+
61
+ result = value
62
+ break
63
+ end
64
+
65
+ emit_any(name, payload)
66
+ result
67
+ end
68
+
69
+ sig { params(name: T.nilable(String)).returns(T::Boolean) }
70
+ def has_handlers?(name = nil)
71
+ return @handlers.any? { |_event_name, handlers| !handlers.empty? } unless name
72
+
73
+ !T.must(@handlers[name]).empty? || !T.must(@handlers[ANY]).empty?
74
+ end
75
+
76
+ private
77
+
78
+ sig { params(name: String, payload: T::Hash[Symbol, T.untyped]).void }
79
+ def emit_observers(name, payload)
80
+ T.must(@handlers[name]).each { |handler| call(handler, payload) }
81
+ end
82
+
83
+ sig { params(name: String, payload: T::Hash[Symbol, T.untyped]).void }
84
+ def emit_any(name, payload)
85
+ any_handlers = T.must(@handlers[ANY])
86
+ return if any_handlers.empty?
87
+
88
+ event = Event.new(name: name, payload: payload)
89
+ any_handlers.each { |handler| call(handler, event) }
90
+ end
91
+
92
+ sig { params(handler: T.untyped, payload: T.untyped).returns(T.untyped) }
93
+ def call(handler, payload)
94
+ handler.call(payload)
95
+ rescue
96
+ nil
97
+ end
98
+
99
+ sig { params(value: T.untyped).returns(T::Boolean) }
100
+ def empty_result?(value)
101
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
102
+ end
103
+ end
104
+
105
+ sig { returns(Dispatcher) }
106
+ def self.client_dispatcher
107
+ Dispatcher.new(event_names: [
108
+ CHALLENGE_RECEIVED,
109
+ CREDENTIAL_CREATED,
110
+ PAYMENT_FAILED,
111
+ PAYMENT_RESPONSE
112
+ ])
113
+ end
114
+
115
+ sig { returns(Dispatcher) }
116
+ def self.server_dispatcher
117
+ Dispatcher.new(event_names: [
118
+ CHALLENGE_CREATED,
119
+ PAYMENT_FAILED,
120
+ PAYMENT_SUCCESS
121
+ ])
122
+ end
123
+ end
124
+ end
@@ -182,7 +182,8 @@ module Mpp
182
182
  Mpp::Receipt.new(
183
183
  status: status,
184
184
  timestamp: Time.iso8601(timestamp.gsub("Z", "+00:00")),
185
- reference: reference || ""
185
+ reference: reference || "",
186
+ method: method
186
187
  )
187
188
  end
188
189
 
@@ -11,10 +11,11 @@ module Mpp
11
11
  class ChargeIntent
12
12
  attr_reader :name
13
13
 
14
- def initialize(secret_key:, api_base: Defaults::STRIPE_API_BASE)
14
+ def initialize(secret_key:, api_base: Defaults::STRIPE_API_BASE, client: nil)
15
15
  @name = "charge"
16
16
  @secret_key = secret_key
17
17
  @api_base = api_base
18
+ @client = client
18
19
  end
19
20
 
20
21
  def verify(credential, request)
@@ -31,7 +32,25 @@ module Mpp
31
32
  end
32
33
 
33
34
  spt = payload_data["spt"]
34
- external_id = payload_data["externalId"]
35
+ credential_external_id = payload_data["externalId"]
36
+ request_external_id = request["externalId"]
37
+ if !request_external_id.nil? && credential_external_id != request_external_id
38
+ raise Mpp::InvalidChallengeError.new(
39
+ challenge_id: credential.challenge.id,
40
+ reason: "credential externalId does not match request externalId"
41
+ )
42
+ end
43
+
44
+ method_details = request["methodDetails"]
45
+ method_details = {} unless method_details.is_a?(Hash)
46
+
47
+ # Enforce the payment method types allowlist from the challenge
48
+ payment_method_types = method_details["paymentMethodTypes"]
49
+ unless payment_method_types.is_a?(Array) &&
50
+ payment_method_types.any? &&
51
+ payment_method_types.all? { |type| type.is_a?(String) && !type.strip.empty? }
52
+ raise Mpp::VerificationError, "Invalid or missing methodDetails.paymentMethodTypes"
53
+ end
35
54
 
36
55
  # Build PaymentIntent params
37
56
  params = {
@@ -39,28 +58,28 @@ module Mpp
39
58
  currency: request["currency"],
40
59
  shared_payment_granted_token: spt,
41
60
  confirm: true,
42
- automatic_payment_methods: {
43
- enabled: true,
44
- allow_redirects: "never"
45
- }
61
+ payment_method_types: payment_method_types
46
62
  }
47
63
 
48
64
  # Include metadata from methodDetails if present
49
- method_details = request["methodDetails"]
50
- if method_details.is_a?(Hash) && method_details["metadata"].is_a?(Hash)
65
+ if method_details["metadata"].is_a?(Hash)
51
66
  params[:metadata] = method_details["metadata"].transform_values(&:to_s)
52
67
  end
53
68
 
54
- # Create PaymentIntent via Stripe SDK
55
- begin
56
- Kernel.require "stripe"
57
- rescue LoadError
58
- raise "stripe gem is required for Stripe charge verification. Install with: gem install stripe"
69
+ unless @client
70
+ begin
71
+ Kernel.require "stripe"
72
+ rescue LoadError
73
+ raise "stripe gem is required for Stripe charge verification. Install with: gem install stripe"
74
+ end
59
75
  end
60
76
 
61
77
  begin
62
- client = ::Stripe::StripeClient.new(@secret_key)
63
- result = client.v1.payment_intents.create(params)
78
+ client = @client || ::Stripe::StripeClient.new(@secret_key)
79
+ result = client.v1.payment_intents.create(
80
+ params,
81
+ {idempotency_key: stripe_idempotency_key(credential)}
82
+ )
64
83
  rescue => e
65
84
  raise Mpp::VerificationError, e.message
66
85
  end
@@ -82,7 +101,13 @@ module Mpp
82
101
  raise Mpp::VerificationError, "PaymentIntent #{pi_id} has status: #{status}"
83
102
  end
84
103
 
85
- Mpp::Receipt.success(pi_id, method: "stripe", external_id: external_id)
104
+ Mpp::Receipt.success(pi_id, method: "stripe", external_id: request_external_id)
105
+ end
106
+
107
+ private
108
+
109
+ def stripe_idempotency_key(credential)
110
+ "mpp-stripe-charge-#{credential.challenge.id}"
86
111
  end
87
112
  end
88
113
  end
@@ -29,7 +29,11 @@ module Mpp
29
29
  )
30
30
 
31
31
  payload = {"spt" => spt_id}
32
- payload["externalId"] = @external_id if @external_id
32
+ request_external_id = request["externalId"]
33
+ payload["externalId"] = request_external_id if request_external_id.is_a?(String)
34
+ if @external_id && !payload.key?("externalId")
35
+ raise ArgumentError, "external_id must be bound by the challenge request"
36
+ end
33
37
 
34
38
  Mpp::Credential.new(
35
39
  challenge: challenge.to_echo,