mpp-rb 0.1.6 → 0.1.7

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.
@@ -7,8 +7,8 @@ module Mpp
7
7
  module Schemas
8
8
  HEX_PATTERN = /\A0x[a-fA-F0-9]+\z/
9
9
 
10
- MethodDetails = Data.define(:chain_id, :fee_payer, :fee_payer_url, :memo) do
11
- def initialize(chain_id: 4217, fee_payer: false, fee_payer_url: nil, memo: nil)
10
+ MethodDetails = Data.define(:chain_id, :fee_payer, :fee_payer_url) do
11
+ def initialize(chain_id: 4217, fee_payer: false, fee_payer_url: nil)
12
12
  super
13
13
  end
14
14
 
@@ -18,8 +18,7 @@ module Mpp
18
18
  new(
19
19
  chain_id: hash["chainId"] || 4217,
20
20
  fee_payer: hash["feePayer"] || false,
21
- fee_payer_url: hash["feePayerUrl"],
22
- memo: hash["memo"]
21
+ fee_payer_url: hash["feePayerUrl"]
23
22
  )
24
23
  end
25
24
  end
data/lib/mpp/parsing.rb CHANGED
@@ -58,10 +58,17 @@ module Mpp
58
58
  def parse_auth_params(params_str)
59
59
  params = {}
60
60
  params_str.scan(AUTH_PARAM_RE) do |key, quoted_val, token_val|
61
- Kernel.raise Mpp::ParseError, "Duplicate parameter: #{key}" if params.key?(key)
61
+ # Auth-parameter names are case-insensitive (RFC 9110 §11.2). Normalize
62
+ # to lowercase for both the duplicate check and storage, so "id" and
63
+ # "ID" are treated as the same parameter -- otherwise both get stored
64
+ # under their original casing and only the lowercase lookup downstream
65
+ # is ever read, silently ignoring the other value instead of rejecting
66
+ # the ambiguous header.
67
+ normalized_key = key.downcase
68
+ Kernel.raise Mpp::ParseError, "Duplicate parameter: #{key}" if params.key?(normalized_key)
62
69
 
63
70
  value = quoted_val.nil? ? token_val : unescape_quoted(quoted_val)
64
- params[key] = value
71
+ params[normalized_key] = value
65
72
  end
66
73
  params
67
74
  end
@@ -140,7 +140,7 @@ module Mpp
140
140
 
141
141
  sig { params(handler: T.untyped, entries: T::Array[T.untyped]).returns(ComposedHandler) }
142
142
  def self.from_entries(handler, entries)
143
- new(handler: handler, offers: entries.map { |entry| offer_from_entry(handler, entry) })
143
+ new(handler: handler, offers: entries.flat_map { |entry| offers_from_entry(handler, entry) })
144
144
  end
145
145
 
146
146
  # Flatten nested compositions into a single handler.
@@ -200,8 +200,9 @@ module Mpp
200
200
 
201
201
  private
202
202
 
203
- sig { params(handler: T.untyped, entry: T.untyped).returns(ComposeOffer) }
204
- def self.offer_from_entry(handler, entry)
203
+ # A method accepting several currencies yields one offer per currency.
204
+ sig { params(handler: T.untyped, entry: T.untyped).returns(T::Array[ComposeOffer]) }
205
+ def self.offers_from_entry(handler, entry)
205
206
  unless entry.is_a?(Array) && entry.length == 2
206
207
  Kernel.raise ArgumentError, "compose() entries must be [method, options] tuples"
207
208
  end
@@ -212,9 +213,13 @@ module Mpp
212
213
  Kernel.raise ArgumentError, "compose() options must be a Hash"
213
214
  end
214
215
 
215
- ComposeOffer.new(handler, handler.resolve_method(method_ref), options)
216
+ method = handler.resolve_method(method_ref)
217
+ currencies = Mpp::Server::MethodHelper.offer_currencies(method, options)
218
+ return [ComposeOffer.new(handler, method, options)] if currencies.nil?
219
+
220
+ currencies.map { |currency| ComposeOffer.new(handler, method, options.merge(currency: currency)) }
216
221
  end
217
- private_class_method :offer_from_entry
222
+ private_class_method :offers_from_entry
218
223
 
219
224
  sig do
220
225
  params(
@@ -6,7 +6,11 @@ module Mpp
6
6
  extend T::Sig
7
7
 
8
8
  # Intent interface (duck type):
9
- # name -> String
9
+ # name -> String
10
+ # validate(credential, request) -> Validation
11
+ # broadcast(credential, request) -> Receipt
12
+ #
13
+ # Intents may temporarily implement only the deprecated verify API:
10
14
  # verify(credential, request) -> Receipt
11
15
  #
12
16
  # Implement this interface for custom payment intents.
@@ -25,6 +29,7 @@ module Mpp
25
29
  end
26
30
 
27
31
  sig { params(credential: Mpp::Credential, request: T::Hash[String, T.untyped]).returns(Mpp::Receipt) }
32
+ # @deprecated Implement #validate and #broadcast for new intents.
28
33
  def verify(credential, request)
29
34
  @verify_fn.call(credential, request)
30
35
  end
@@ -0,0 +1,41 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Server
6
+ # Dispatches an intent through its optional two-phase lifecycle.
7
+ #
8
+ # Two-phase intents expose separate non-mutating validation and terminal
9
+ # payment operations:
10
+ # validate(credential, request) -> Validation
11
+ # broadcast(credential, request) -> Receipt
12
+ #
13
+ # Broadcast receives the original inputs rather than trusting a prior
14
+ # validation result, matching mppx's re-validation behavior.
15
+ #
16
+ # Intents that only implement the deprecated #verify API continue to work
17
+ # through the legacy path.
18
+ module IntentLifecycle
19
+ extend T::Sig
20
+
21
+ module_function
22
+
23
+ sig { params(intent: T.untyped, credential: Mpp::Credential, request: T::Hash[String, T.untyped]).returns(Mpp::Receipt) }
24
+ def call(intent, credential, request)
25
+ has_validate = intent.respond_to?(:validate)
26
+ has_broadcast = intent.respond_to?(:broadcast)
27
+
28
+ if has_validate != has_broadcast
29
+ Kernel.raise ArgumentError, "intent must implement both #validate and #broadcast"
30
+ end
31
+
32
+ if has_validate
33
+ intent.validate(credential, request)
34
+ intent.broadcast(credential, request)
35
+ else
36
+ intent.verify(credential, request)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -24,6 +24,20 @@ module Mpp
24
24
  end
25
25
  end
26
26
 
27
+ # Give a method a chance to create a request-scoped intent view and to
28
+ # remove private server input before the canonical request is built.
29
+ sig { params(method: T.untyped, intent: T.untyped, input: T::Hash[Symbol, T.untyped]).returns([T.untyped, T::Hash[Symbol, T.untyped]]) }
30
+ def prepare_intent(method, intent, input)
31
+ return [intent, input] unless method.respond_to?(:prepare_intent)
32
+
33
+ prepared = method.prepare_intent(intent, input)
34
+ unless prepared.is_a?(Array) && prepared.length == 2 && prepared[1].is_a?(Hash)
35
+ Kernel.raise ArgumentError, "prepare_intent must return [intent, request_input]"
36
+ end
37
+
38
+ [prepared[0], prepared[1]]
39
+ end
40
+
27
41
  # Check whether a method should be advertised for a canonical request.
28
42
  # This only governs composing new 402 offers, never credential redemption.
29
43
  sig { params(method: T.untyped, request: T::Hash[String, T.untyped]).returns(T::Boolean) }
@@ -37,6 +51,18 @@ module Mpp
37
51
 
38
52
  available
39
53
  end
54
+
55
+ # Currencies to fan out into one offer each, in order. Returns nil when
56
+ # the offer needs no fan-out: the method accepts at most one currency or
57
+ # the request already pins a currency.
58
+ sig { params(method: T.untyped, options: T::Hash[T.untyped, T.untyped]).returns(T.nilable(T::Array[String])) }
59
+ def offer_currencies(method, options)
60
+ return nil unless options[:currency].nil? && options["currency"].nil?
61
+ return nil unless method.respond_to?(:currencies)
62
+
63
+ currencies = method.currencies
64
+ (currencies.is_a?(Array) && currencies.length > 1) ? currencies : nil
65
+ end
40
66
  end
41
67
  end
42
68
  end
@@ -130,9 +130,10 @@ module Mpp
130
130
 
131
131
  # Handle a charge intent.
132
132
  #
133
- # With a single registered charge method this returns Challenge or
134
- # [Credential, Receipt]. With multiple charge methods it implicitly
135
- # composes them and returns a ComposedResult.
133
+ # With a single registered charge method offering a single currency this
134
+ # returns Challenge or [Credential, Receipt]. With multiple charge methods,
135
+ # or a method accepting several currencies, it implicitly composes one
136
+ # offer per method and currency and returns a ComposedResult.
136
137
  sig { params(authorization: T.nilable(String), amount: String, kwargs: T.untyped).returns(T.untyped) }
137
138
  def charge(authorization, amount, **kwargs)
138
139
  charge_methods = @methods.select { |candidate| candidate.intents.key?("charge") }
@@ -142,7 +143,7 @@ module Mpp
142
143
  offer_opts = kwargs.except(*REQUEST_OPTION_KEYS)
143
144
  payment = payment_credential_value(authorization, request_opts[:payment_authorization])
144
145
 
145
- if charge_methods.length == 1
146
+ if charge_methods.length == 1 && MethodHelper.offer_currencies(charge_methods.first, offer_opts).nil?
146
147
  return charge_one(
147
148
  charge_methods.first,
148
149
  payment,
@@ -176,6 +177,7 @@ module Mpp
176
177
  url = kwargs[:url]
177
178
  payment_signature = kwargs[:payment_signature]
178
179
  offer_opts = kwargs.except(*REQUEST_OPTION_KEYS)
180
+ intent, offer_opts = Mpp::Server::MethodHelper.prepare_intent(method, intent, offer_opts)
179
181
  request = build_charge_request(method, amount, **offer_opts)
180
182
  return nil if request_guard && !request_guard.call(request)
181
183
 
@@ -215,7 +217,6 @@ module Mpp
215
217
  currency = kwargs[:currency]
216
218
  recipient = kwargs[:recipient]
217
219
  external_id = kwargs[:external_id]
218
- memo = kwargs[:memo]
219
220
  fee_payer = if kwargs.key?(:fee_payer)
220
221
  kwargs[:fee_payer]
221
222
  else
@@ -225,7 +226,7 @@ module Mpp
225
226
  extra = kwargs[:extra]
226
227
  mppx_scope = kwargs[:mppx_scope]
227
228
 
228
- resolved_currency = currency || (method.respond_to?(:currency) ? method.currency : nil)
229
+ resolved_currency = currency || primary_currency(method)
229
230
  resolved_recipient = recipient || (method.respond_to?(:recipient) ? method.recipient : nil)
230
231
  raise ArgumentError, "currency must be set on the method or passed to charge()" unless resolved_currency
231
232
  raise ArgumentError, "recipient must be set on the method or passed to charge()" unless resolved_recipient
@@ -256,10 +257,9 @@ module Mpp
256
257
  resolved_chain_id = chain_id
257
258
  resolved_chain_id ||= method.chain_id if method.respond_to?(:chain_id)
258
259
 
259
- if memo || fee_payer || !resolved_chain_id.nil?
260
+ if fee_payer || !resolved_chain_id.nil?
260
261
  method_details = {}
261
262
  method_details["chainId"] = resolved_chain_id unless resolved_chain_id.nil?
262
- method_details["memo"] = memo if memo
263
263
  method_details["feePayer"] = true if fee_payer
264
264
  request["methodDetails"] = method_details
265
265
  end
@@ -296,6 +296,15 @@ module Mpp
296
296
  list
297
297
  end
298
298
 
299
+ # A method's first accepted currency, else its singular currency.
300
+ sig { params(method: T.untyped).returns(T.untyped) }
301
+ def primary_currency(method)
302
+ currencies = method.respond_to?(:currencies) ? method.currencies : nil
303
+ return currencies.first if currencies.is_a?(Array) && !currencies.empty?
304
+
305
+ method.respond_to?(:currency) ? method.currency : nil
306
+ end
307
+
299
308
  sig { params(key: String).returns(T.untyped) }
300
309
  def resolve_method_key(key)
301
310
  name, intent = key.split("/", 2)
@@ -346,7 +355,7 @@ module Mpp
346
355
  body: body,
347
356
  http_method: http_method
348
357
  )
349
- receipt = intent.verify(credential, challenge.request)
358
+ receipt = IntentLifecycle.call(intent, credential, challenge.request)
350
359
  rescue => error
351
360
  if @events.has_handlers?(Mpp::Events::PAYMENT_FAILED)
352
361
  Verify.emit_payment_failed(
@@ -359,6 +368,9 @@ module Mpp
359
368
  retry_challenge: challenge
360
369
  )
361
370
  end
371
+ if error.is_a?(Mpp::PaymentError) && error.status != 402
372
+ raise
373
+ end
362
374
  if error.is_a?(Mpp::ParseError) || error.is_a?(Mpp::VerificationError) || error.is_a?(Mpp::PaymentError)
363
375
  if @events.has_handlers?(Mpp::Events::CHALLENGE_CREATED)
364
376
  Verify.emit_challenge_created(
@@ -158,7 +158,7 @@ module Mpp
158
158
  end
159
159
 
160
160
  begin
161
- receipt = intent.verify(credential, request)
161
+ receipt = IntentLifecycle.call(intent, credential, request)
162
162
  rescue => e
163
163
  if dispatcher&.has_handlers?(Mpp::Events::PAYMENT_FAILED)
164
164
  emit_payment_failed(
data/lib/mpp/server.rb CHANGED
@@ -6,6 +6,7 @@ module Mpp
6
6
  autoload :Defaults, "mpp/server/defaults"
7
7
  autoload :Intent, "mpp/server/intent"
8
8
  autoload :FunctionalIntent, "mpp/server/intent"
9
+ autoload :IntentLifecycle, "mpp/server/intent_lifecycle"
9
10
  autoload :Method, "mpp/server/method"
10
11
  autoload :Verify, "mpp/server/verify"
11
12
  autoload :MppHandler, "mpp/server/mpp_handler"
@@ -0,0 +1,27 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ # Non-mutating validation result, matching mppx's Method.Validation fields.
6
+ # Details are method-specific, not a receipt or a guarantee of settlement.
7
+ # Challenge issuance and route binding are checked by the server, not by
8
+ # constructing this record or calling a rail's validate method directly.
9
+ Validation = Data.define(:challenge, :credential, :intent, :method, :request, :details, :source) do
10
+ extend T::Sig
11
+
12
+ sig do
13
+ params(
14
+ challenge: Mpp::ChallengeEcho,
15
+ credential: Mpp::Credential,
16
+ intent: String,
17
+ method: String,
18
+ request: T::Hash[String, T.untyped],
19
+ details: T.untyped,
20
+ source: T.nilable(String)
21
+ ).void
22
+ end
23
+ def initialize(challenge:, credential:, intent:, method:, request:, details:, source: nil)
24
+ super
25
+ end
26
+ end
27
+ end
data/lib/mpp/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Mpp
5
- VERSION = "0.1.6"
5
+ VERSION = "0.1.7"
6
6
  end
data/lib/mpp.rb CHANGED
@@ -18,6 +18,7 @@ module Mpp
18
18
  autoload :ChallengeEcho, "mpp/challenge_echo"
19
19
  autoload :Credential, "mpp/credential"
20
20
  autoload :Receipt, "mpp/receipt"
21
+ autoload :Validation, "mpp/validation"
21
22
  autoload :Parsing, "mpp/parsing"
22
23
  autoload :Json, "mpp/json"
23
24
  autoload :BodyDigest, "mpp/body_digest"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mpp-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stripe
@@ -87,11 +87,14 @@ files:
87
87
  - lib/mpp/methods/evm/charge_intent.rb
88
88
  - lib/mpp/methods/evm/evm_method.rb
89
89
  - lib/mpp/methods/stripe.rb
90
+ - lib/mpp/methods/stripe/analytics_metadata.rb
90
91
  - lib/mpp/methods/stripe/charge_intent.rb
91
92
  - lib/mpp/methods/stripe/client_method.rb
92
93
  - lib/mpp/methods/stripe/crypto_payment_recorder.rb
93
94
  - lib/mpp/methods/stripe/defaults.rb
94
95
  - lib/mpp/methods/stripe/machine_payments.rb
96
+ - lib/mpp/methods/stripe/payment_intent_method.rb
97
+ - lib/mpp/methods/stripe/payment_intent_options.rb
95
98
  - lib/mpp/methods/stripe/stripe_method.rb
96
99
  - lib/mpp/methods/tempo.rb
97
100
  - lib/mpp/methods/tempo/account.rb
@@ -117,6 +120,7 @@ files:
117
120
  - lib/mpp/server/decorator.rb
118
121
  - lib/mpp/server/defaults.rb
119
122
  - lib/mpp/server/intent.rb
123
+ - lib/mpp/server/intent_lifecycle.rb
120
124
  - lib/mpp/server/method.rb
121
125
  - lib/mpp/server/middleware.rb
122
126
  - lib/mpp/server/mpp_handler.rb
@@ -124,6 +128,7 @@ files:
124
128
  - lib/mpp/server/verify.rb
125
129
  - lib/mpp/store.rb
126
130
  - lib/mpp/units.rb
131
+ - lib/mpp/validation.rb
127
132
  - lib/mpp/version.rb
128
133
  - lib/mpp/x402.rb
129
134
  - lib/mpp/x402/facilitator.rb