mpp-rb 0.1.6 → 0.1.8

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: d028d7ba9d07e95173c4d3618a688dd87d4674284195a58ecfa4d6d43a311bf7
4
- data.tar.gz: 3a342589c1478477c42077297f11244550ce035e4d66f9e4afde2e9a125d091c
3
+ metadata.gz: e5d5c20207eb5f09b4f077cea8bdd1fb1f2acc15d12c5d00928cc7e8eddcf627
4
+ data.tar.gz: 1f080945c2a322985b47d184ccaf00b543931bcb6f4623c070175d03efab5cd8
5
5
  SHA512:
6
- metadata.gz: bf7ad2c6e7db109dc6244609e24eaf42f444396f0df9a69dd428017635dca2abb001271d89cd6c1e715fa3c58bf352ee267455ea0e38623cfd6dc608cd2da609
7
- data.tar.gz: 6f090b293910e66a99054316767d36913a1a258c9ae5e1989fd74b16f67c8bad236b2e5351f6c4582140f5f48a0f707f2395245196470ec4c2206b0fb41418ad
6
+ metadata.gz: 7044c78ebef4814fce755614c194a6723c39df930071762aab94e19e944bb241f33bfd554fdc18ed0ef5f7b3f9cdc9d219923ab4a9aac6278e94896069b2634c
7
+ data.tar.gz: d71d6869c20d1a569a0425cde39dd39938688735d0360db47fe82f80ac454f3d4ec015624f942c1bf1760c271e9297ca848300ac63d5d0366496b57a2475efc6
data/README.md CHANGED
@@ -42,6 +42,10 @@ if result.is_a?(Mpp::Challenge)
42
42
  # Return 402 with WWW-Authenticate header
43
43
  resp = Mpp::Server::Decorator.make_challenge_response(result, server.realm)
44
44
  # resp["status"], resp["headers"], resp["body"]
45
+ elsif result.is_a?(Mpp::Server::ComposedResult)
46
+ # Several offers (methods or currencies); see "Accepted currencies"
47
+ resp = result.to_response if result.payment_required?
48
+ credential, receipt = result.payment unless result.payment_required?
45
49
  else
46
50
  credential, receipt = result
47
51
  # credential.source — payer address
@@ -49,6 +53,34 @@ else
49
53
  end
50
54
  ```
51
55
 
56
+ ### Accepted currencies
57
+
58
+ A Tempo server method offers one charge per accepted currency, in order, and
59
+ accepts a credential for any of them. With the default `chain_id: 4217` (mainnet) the
60
+ defaults are OUSD then USDC.e; with `chain_id: 42431` (Moderato) they are OUSD
61
+ then pathUSD. Other chains, or an explicit `chain_id: nil`, keep the single pathUSD default.
62
+ Several accepted currencies make `charge` return a `Mpp::Server::ComposedResult`.
63
+
64
+ ```ruby
65
+ Mpp::Methods::Tempo.tempo(
66
+ intents: {"charge" => Mpp::Methods::Tempo::ChargeIntent.new},
67
+ chain_id: 4217,
68
+ recipient: "0x0000000000000000000000000000000000000001",
69
+ currencies: [Mpp::Methods::Tempo::Defaults::USDC] # replaces the defaults
70
+ )
71
+ ```
72
+
73
+ An explicit `currencies:` list replaces the defaults. The deprecated
74
+ `currency:` option accepts exactly one token; pass one or the other. A
75
+ `currency:` passed to `charge` or a compose entry still issues a single offer.
76
+ Sponsored charges choose the fee token independently of the charge currency.
77
+ A local fee payer accepts `fee_payer_allowed_fee_tokens:` when set, otherwise
78
+ pathUSD and the chain's default currency (pathUSD and USDC.e on mainnet,
79
+ pathUSD on Moderato). It pays gas in `fee_token:` when set, otherwise in the
80
+ first allowed token it holds a balance of, otherwise in the first allowed
81
+ token. `fee_token:` is only valid with a local fee payer; hosted sponsors
82
+ choose their own fee token.
83
+
52
84
  If the endpoint already uses `Authorization` (API keys, Bearer tokens), create the server with `requires_auth: true`. Challenges then advertise `header="Payment-Authorization"`, and clients send the Payment credential in that header instead of `Authorization`.
53
85
 
54
86
  ```ruby
@@ -128,6 +160,59 @@ not advertised. Successful Tempo and Base payments are best-effort recorded as
128
160
  Stripe crypto transaction-verification PaymentIntents. Metadata is included on
129
161
  every Stripe PaymentIntent.
130
162
 
163
+ Stripe machine-payment methods also accept private, request-scoped
164
+ `payment_intent_options` in `charge` or composed-offer input. Supported fields
165
+ match mppx: `customer`, `receipt_email`, `metadata`, and Stripe Tax calculation
166
+ hooks. The value can be a static hash or a deferred callable:
167
+
168
+ ```ruby
169
+ options = lambda do |challenge:, credential:, request:|
170
+ calculation = stripe_client.v1.tax.calculations.create(
171
+ tax_params,
172
+ {idempotency_key: "mpp_tax_#{challenge.id}"}
173
+ )
174
+ {hooks: {inputs: {tax: {calculation: calculation.id}}}}
175
+ end
176
+
177
+ payment = server.compose(
178
+ [payments.tempo.charge, {amount: "0.50", payment_intent_options: options}],
179
+ [payments.spt.charge, {amount: "0.50", payment_intent_options: options}]
180
+ )
181
+ ```
182
+
183
+ For SPT, deferred callables run after credential validation and immediately
184
+ before PaymentIntent creation. For crypto, they run after the rail's `validate`
185
+ and immediately before `broadcast` when supported, or before legacy `verify`
186
+ otherwise. Tempo and Base both support the split path, including relay and x402.
187
+ The options are not serialized in the MPP challenge. Recorded crypto payments
188
+ retry once without optional fields only when Stripe definitively rejects them.
189
+
190
+ Two-phase intents implement `validate(credential, request)` and
191
+ `broadcast(credential, request)`. Validation returns an `Mpp::Validation` record
192
+ with `challenge`, `credential`, `request`, `method`, `intent`, `source`, and
193
+ method-specific `details`, matching mppx's validation-result fields. Failed
194
+ checks raise; validation must not settle a payment or consume replay state.
195
+ Use an empty hash for `details` when none are available; `source` defaults to
196
+ `nil`. The record is not a settlement receipt or independent proof of challenge
197
+ issuance. Broadcast receives
198
+ the original inputs and returns a receipt; the combined lifecycle does not pass
199
+ the validation record into broadcast.
200
+
201
+ Tempo and EVM charge intents expose `validate(credential, request)` and
202
+ `broadcast(credential, request)` using this contract. Validation does not submit
203
+ payments, co-sign transactions, or consume replay state. `verify` is deprecated
204
+ and calls both phases in order.
205
+ Like [mppx](https://github.com/wevm/mppx/tree/43ec92c36f575339cf7c45ae466507048783a0d3/src),
206
+ local Tempo broadcast repeats credential checks, while EVM and relay broadcast
207
+ delegate to settlement without repeating the remote validation call. Validation
208
+ is a preflight check, not a guarantee that settlement will succeed.
209
+
210
+ Tempo retains its existing validation: transaction preflight is best-effort,
211
+ local sponsorship checks run before co-signing, and hosted payers and relays
212
+ retain their own validation policies. This split does not add mppx's stronger
213
+ transaction preflight checks; final transaction acceptance still depends on the
214
+ node or payment service.
215
+
131
216
  `evm.charge` additionally emits `PAYMENT-REQUIRED` and accepts `PAYMENT-SIGNATURE` (x402 v2 exact) when a facilitator is configured:
132
217
 
133
218
  ```ruby
@@ -94,7 +94,7 @@ module Mpp
94
94
  core_credential = mcp_credential.to_core
95
95
 
96
96
  begin
97
- core_receipt = intent.verify(core_credential, request)
97
+ core_receipt = Mpp::Server::IntentLifecycle.call(intent, core_credential, request)
98
98
  rescue Mpp::VerificationError => e
99
99
  Kernel.raise PaymentVerificationError.new(
100
100
  challenges: [new_challenge.call],
@@ -18,7 +18,13 @@ module Mpp
18
18
  @route_binding = route_binding
19
19
  end
20
20
 
21
+ # @deprecated Use #validate followed by #broadcast.
21
22
  def verify(credential, request)
23
+ validate(credential, request)
24
+ broadcast(credential, request)
25
+ end
26
+
27
+ def validate(credential, request)
22
28
  payload = credential.payload
23
29
  unless payload.is_a?(Hash) && payload["type"] == "authorization"
24
30
  raise Mpp::VerificationError, "EVM authorization credentials are not supported for this challenge"
@@ -65,7 +71,36 @@ module Mpp
65
71
  raise Mpp::VerificationFailedError.new(reason: "EVM authorization source mismatch")
66
72
  end
67
73
 
68
- settled = settle(payload, request)
74
+ payment_payload, requirements = facilitator_input(payload, request)
75
+ verified = @facilitator.verify(payment_payload, requirements)
76
+ unless verified["isValid"]
77
+ raise Mpp::VerificationFailedError.new(
78
+ reason: verified["invalidMessage"] || verified["invalidReason"] || "EVM facilitator verify failed"
79
+ )
80
+ end
81
+
82
+ Mpp::Validation.new(
83
+ challenge: credential.challenge,
84
+ credential: credential,
85
+ details: {payer: checksummed_from},
86
+ intent: name,
87
+ method: "evm",
88
+ request: request,
89
+ source: credential.source
90
+ )
91
+ end
92
+
93
+ # Like mppx, settlement relies on a preceding #validate and the
94
+ # facilitator's terminal checks; it does not repeat /verify.
95
+ def broadcast(credential, request)
96
+ payment_payload, requirements = facilitator_input(credential.payload, request)
97
+ settled = @facilitator.settle(payment_payload, requirements)
98
+ unless settled["success"]
99
+ raise Mpp::VerificationFailedError.new(
100
+ reason: settled["errorMessage"] || settled["errorReason"] || "EVM facilitator settlement failed"
101
+ )
102
+ end
103
+
69
104
  Mpp::Receipt.success(settled.fetch("transaction"), method: "evm")
70
105
  end
71
106
 
@@ -79,7 +114,7 @@ module Mpp
79
114
 
80
115
  private
81
116
 
82
- def settle(payload, request)
117
+ def facilitator_input(payload, request)
83
118
  requirements = payment_requirements(request)
84
119
  payment_payload = {
85
120
  "accepted" => requirements,
@@ -97,21 +132,7 @@ module Mpp
97
132
  "x402Version" => Mpp::X402::VERSION
98
133
  }
99
134
 
100
- verified = @facilitator.verify(payment_payload, requirements)
101
- unless verified["isValid"]
102
- raise Mpp::VerificationFailedError.new(
103
- reason: verified["invalidMessage"] || verified["invalidReason"] || "EVM facilitator verify failed"
104
- )
105
- end
106
-
107
- settled = @facilitator.settle(payment_payload, requirements)
108
- unless settled["success"]
109
- raise Mpp::VerificationFailedError.new(
110
- reason: settled["errorMessage"] || settled["errorReason"] || "EVM facilitator settlement failed"
111
- )
112
- end
113
-
114
- settled
135
+ [payment_payload, requirements]
115
136
  end
116
137
 
117
138
  def x402_credential?(credential)
@@ -0,0 +1,24 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Stripe
7
+ # Builds Stripe-owned metadata used to identify MPP payments.
8
+ module AnalyticsMetadata
9
+ LIMIT = 500
10
+
11
+ module_function
12
+
13
+ def build(challenge)
14
+ {
15
+ "machine_payment" => "true",
16
+ "mpp_sdk" => "mpp-rb/#{Mpp::VERSION}",
17
+ "mpp_challenge_id" => challenge.id.to_s,
18
+ "mpp_intent" => challenge.intent.to_s
19
+ }.transform_values { |value| value.each_char.first(LIMIT).join }
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -11,11 +11,22 @@ module Mpp
11
11
  class ChargeIntent
12
12
  attr_reader :name
13
13
 
14
- def initialize(secret_key:, api_base: Defaults::STRIPE_API_BASE, client: nil)
14
+ def initialize(secret_key:, api_base: Defaults::STRIPE_API_BASE, client: nil,
15
+ payment_intent_options: nil)
15
16
  @name = "charge"
16
17
  @secret_key = secret_key
17
18
  @api_base = api_base
18
19
  @client = client
20
+ @payment_intent_options = payment_intent_options
21
+ end
22
+
23
+ def with_payment_intent_options(payment_intent_options)
24
+ self.class.new(
25
+ secret_key: @secret_key,
26
+ api_base: @api_base,
27
+ client: @client,
28
+ payment_intent_options: payment_intent_options
29
+ )
19
30
  end
20
31
 
21
32
  def verify(credential, request)
@@ -27,11 +38,11 @@ module Mpp
27
38
  end
28
39
 
29
40
  payload_data = credential.payload
30
- unless payload_data.is_a?(Hash) && payload_data.key?("spt")
31
- raise Mpp::VerificationError, "Invalid credential payload: missing spt"
41
+ spt = payload_data["spt"] if payload_data.is_a?(Hash)
42
+ unless spt.is_a?(String) && !spt.empty?
43
+ raise Mpp::VerificationError, "Invalid credential payload: missing or invalid spt"
32
44
  end
33
45
 
34
- spt = payload_data["spt"]
35
46
  credential_external_id = payload_data["externalId"]
36
47
  request_external_id = request["externalId"]
37
48
  if !request_external_id.nil? && credential_external_id != request_external_id
@@ -52,6 +63,13 @@ module Mpp
52
63
  raise Mpp::VerificationError, "Invalid or missing methodDetails.paymentMethodTypes"
53
64
  end
54
65
 
66
+ payment_intent_options = PaymentIntentOptions.resolve(
67
+ @payment_intent_options,
68
+ challenge: credential.challenge,
69
+ credential: credential,
70
+ request: request
71
+ ) || {}
72
+
55
73
  # Build PaymentIntent params
56
74
  params = {
57
75
  amount: Integer(request["amount"]),
@@ -61,8 +79,14 @@ module Mpp
61
79
  payment_method_types: payment_method_types
62
80
  }
63
81
 
82
+ params[:customer] = payment_intent_options[:customer] if payment_intent_options.key?(:customer)
83
+ params[:hooks] = payment_intent_options[:hooks] if payment_intent_options.key?(:hooks)
84
+ params[:receipt_email] = payment_intent_options[:receipt_email] if payment_intent_options.key?(:receipt_email)
85
+
64
86
  metadata = method_details["metadata"].is_a?(Hash) ? method_details["metadata"].transform_values(&:to_s) : {}
65
- params[:metadata] = metadata.merge("machine_payment" => "true")
87
+ params[:metadata] = AnalyticsMetadata.build(credential.challenge)
88
+ .merge(metadata)
89
+ .merge(payment_intent_options.fetch(:metadata, {}))
66
90
 
67
91
  unless @client
68
92
  begin
@@ -4,7 +4,7 @@
4
4
  module Mpp
5
5
  module Methods
6
6
  module Stripe
7
- # Records an already-verified crypto transfer as a Stripe PaymentIntent.
7
+ # Records an already-settled crypto transfer as a Stripe PaymentIntent.
8
8
  class CryptoPaymentRecorder
9
9
  RAW_UNITS_PER_CENT = 10_000
10
10
 
@@ -22,7 +22,11 @@ module Mpp
22
22
  cents = (amount + (RAW_UNITS_PER_CENT / 2)) / RAW_UNITS_PER_CENT
23
23
  return if cents < 1
24
24
 
25
- params = {
25
+ analytics = AnalyticsMetadata.build(payload.fetch(:challenge))
26
+ options = payload[:payment_intent_options] || {}
27
+ configured_metadata = @metadata.is_a?(Hash) ? @metadata.transform_keys(&:to_s).transform_values(&:to_s) : {}
28
+ optional_metadata = configured_metadata.merge(options.fetch(:metadata, {}))
29
+ required_params = {
26
30
  amount: cents,
27
31
  currency: "usd",
28
32
  confirm: true,
@@ -33,22 +37,55 @@ module Mpp
33
37
  mode: "transaction_verification",
34
38
  transaction_verification_options: {network: @network, transaction_hash: reference}
35
39
  }
36
- }
40
+ },
41
+ metadata: analytics
37
42
  }
38
- metadata = @metadata.is_a?(Hash) ? @metadata.transform_values(&:to_s) : {}
39
- params[:metadata] = metadata.merge("machine_payment" => "true")
43
+ params = required_params.dup
44
+ params[:customer] = options[:customer] if options.key?(:customer)
45
+ params[:hooks] = options[:hooks] if options.key?(:hooks)
46
+ params[:receipt_email] = options[:receipt_email] if options.key?(:receipt_email)
47
+ params[:metadata] = analytics.merge(optional_metadata)
48
+ has_optional_params = !optional_metadata.empty? || [:customer, :hooks, :receipt_email].any? do |key|
49
+ options.key?(key)
50
+ end
40
51
 
41
- @client.v1.payment_intents.create(
42
- params,
43
- {stripe_version: Defaults::MACHINE_PAYMENTS_API_VERSION, idempotency_key: reference}
44
- )
52
+ create(params, reference)
45
53
  rescue => error
54
+ if defined?(required_params) && has_optional_params && definitive_invalid_request?(error)
55
+ Kernel.warn(
56
+ "[stripe] optional PaymentIntent recording fields were rejected; retrying without them: " \
57
+ "#{error.class}: #{error.message}"
58
+ )
59
+ begin
60
+ return create(required_params, "#{reference}_fallback")
61
+ rescue => fallback_error
62
+ error = fallback_error
63
+ end
64
+ end
65
+
46
66
  Kernel.warn(
47
67
  "[stripe] failed to record crypto payment " \
48
68
  "network=#{@network.inspect} transaction_hash=#{reference.inspect}: #{error.class}: #{error.message}"
49
69
  )
50
70
  nil
51
71
  end
72
+
73
+ private
74
+
75
+ def create(params, idempotency_key)
76
+ @client.v1.payment_intents.create(
77
+ params,
78
+ {stripe_version: Defaults::MACHINE_PAYMENTS_API_VERSION, idempotency_key: idempotency_key}
79
+ )
80
+ end
81
+
82
+ def definitive_invalid_request?(error)
83
+ return true if defined?(::Stripe::InvalidRequestError) && error.is_a?(::Stripe::InvalidRequestError)
84
+ return true if error.respond_to?(:type) && error.type == "StripeInvalidRequestError"
85
+ return true if error.respond_to?(:error) && error.error.respond_to?(:type) && error.error.type == "invalid_request_error"
86
+
87
+ false
88
+ end
52
89
  end
53
90
  end
54
91
  end
@@ -90,16 +90,15 @@ module Mpp
90
90
 
91
91
  chain_id = @livemode ? Mpp::Methods::Tempo::Defaults::CHAIN_ID : Mpp::Methods::Tempo::Defaults::TESTNET_CHAIN_ID
92
92
  currency = @livemode ? Mpp::Methods::Tempo::Defaults::USDC : Mpp::Methods::Tempo::Defaults::PATH_USD
93
- recorder = CryptoPaymentRecorder.new(client: @client, network: "tempo", metadata: @metadata)
94
- Mpp::Methods::Tempo.tempo(
93
+ method = Mpp::Methods::Tempo.tempo(
95
94
  intents: {"charge" => Mpp::Methods::Tempo::ChargeIntent.new(chain_id: chain_id)},
96
95
  chain_id: chain_id,
97
96
  currency: currency,
98
97
  recipient: @recipient,
99
98
  decimals: Mpp::Methods::Tempo::Defaults::PATH_USD_DECIMALS,
100
- can_offer: MachinePayments.minimum_amount(CryptoPaymentRecorder::RAW_UNITS_PER_CENT),
101
- on_payment_success: ->(payload) { recorder.call(payload) }
99
+ can_offer: MachinePayments.minimum_amount(CryptoPaymentRecorder::RAW_UNITS_PER_CENT)
102
100
  )
101
+ PaymentIntentMethod.new(method: method, client: @client, network: "tempo", metadata: @metadata)
103
102
  end
104
103
  end
105
104
 
@@ -115,14 +114,13 @@ module Mpp
115
114
  raise ArgumentError, "deposit_addresses[:base] is required for Base payments" if @recipient.nil?
116
115
 
117
116
  currency = @livemode ? Mpp::Methods::Evm::Assets::BASE_USDC : Mpp::Methods::Evm::Assets::BASE_SEPOLIA_USDC
118
- recorder = CryptoPaymentRecorder.new(client: @client, network: "base", metadata: @metadata)
119
- Mpp::Methods::Evm.charge(
117
+ method = Mpp::Methods::Evm.charge(
120
118
  currency: currency,
121
119
  recipient: @recipient,
122
120
  x402: x402,
123
- can_offer: MachinePayments.minimum_amount(CryptoPaymentRecorder::RAW_UNITS_PER_CENT),
124
- on_payment_success: ->(payload) { recorder.call(payload) }
121
+ can_offer: MachinePayments.minimum_amount(CryptoPaymentRecorder::RAW_UNITS_PER_CENT)
125
122
  )
123
+ PaymentIntentMethod.new(method: method, client: @client, network: "base", metadata: @metadata)
126
124
  end
127
125
  end
128
126
  end
@@ -0,0 +1,103 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Stripe
7
+ # Decorates a crypto method with private, request-scoped Stripe input.
8
+ class PaymentIntentMethod
9
+ def initialize(method:, client:, network:, metadata: nil)
10
+ @method = method
11
+ @client = client
12
+ @network = network
13
+ @metadata = metadata
14
+ end
15
+
16
+ def prepare_intent(intent, input)
17
+ options_input = input[:payment_intent_options]
18
+ PaymentIntentOptions.validate_input(options_input)
19
+ sanitized = input.except(:payment_intent_options)
20
+ decorated = WrappedIntent.new(
21
+ intent: intent,
22
+ options_input: options_input,
23
+ client: @client,
24
+ network: @network,
25
+ metadata: @metadata
26
+ )
27
+ [decorated, sanitized]
28
+ end
29
+
30
+ def method_missing(name, *args, **kwargs, &block)
31
+ return super unless @method.respond_to?(name)
32
+
33
+ @method.public_send(name, *args, **kwargs, &block)
34
+ end
35
+
36
+ def respond_to_missing?(name, include_private = false)
37
+ @method.respond_to?(name, include_private) || super
38
+ end
39
+
40
+ # Per-attempt intent view retaining resolved input outside protocol state.
41
+ class WrappedIntent
42
+ attr_reader :name
43
+
44
+ def initialize(intent:, options_input:, client:, network:, metadata:)
45
+ @intent = intent
46
+ @name = intent.name
47
+ @options_input = options_input
48
+ @client = client
49
+ @network = network
50
+ @metadata = metadata
51
+
52
+ # Mirror the rail's capabilities so server dispatch can validate
53
+ # before entering the terminal operation that resolves options.
54
+ if intent.respond_to?(:validate)
55
+ define_singleton_method(:validate) do |credential, request|
56
+ @intent.validate(credential, request)
57
+ end
58
+ end
59
+ if intent.respond_to?(:broadcast)
60
+ define_singleton_method(:broadcast) do |credential, request|
61
+ options = resolve_options(credential, request)
62
+ receipt = @intent.broadcast(credential, request)
63
+ record_payment(credential, request, receipt, options)
64
+ end
65
+ end
66
+ end
67
+
68
+ # @deprecated Use #validate and #broadcast when supported by the rail.
69
+ def verify(credential, request)
70
+ if respond_to?(:validate) || respond_to?(:broadcast)
71
+ return Mpp::Server::IntentLifecycle.call(self, credential, request)
72
+ end
73
+
74
+ options = resolve_options(credential, request)
75
+ receipt = @intent.verify(credential, request)
76
+ record_payment(credential, request, receipt, options)
77
+ end
78
+
79
+ private
80
+
81
+ def resolve_options(credential, request)
82
+ PaymentIntentOptions.resolve(
83
+ @options_input,
84
+ challenge: credential.challenge,
85
+ credential: credential,
86
+ request: request
87
+ )
88
+ end
89
+
90
+ def record_payment(credential, request, receipt, options)
91
+ CryptoPaymentRecorder.new(client: @client, network: @network, metadata: @metadata).call(
92
+ challenge: credential.challenge,
93
+ receipt: receipt,
94
+ request: request,
95
+ payment_intent_options: options
96
+ )
97
+ receipt
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,81 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Stripe
7
+ # Validation and deferred resolution for request-scoped PaymentIntent input.
8
+ module PaymentIntentOptions
9
+ module_function
10
+
11
+ def validate_input(input)
12
+ return input if input.nil? || input.respond_to?(:call)
13
+
14
+ validate(input)
15
+ end
16
+
17
+ def resolve(input, challenge:, credential:, request:)
18
+ value = if input.respond_to?(:call)
19
+ input.call(challenge: challenge, credential: credential, request: request)
20
+ else
21
+ input
22
+ end
23
+ return if value.nil?
24
+
25
+ validate(value)
26
+ end
27
+
28
+ def validate(value)
29
+ raise ArgumentError, "payment_intent_options must be a Hash or callable" unless value.is_a?(Hash)
30
+
31
+ input = symbolize(value)
32
+ options = {}
33
+ options[:customer] = non_empty_string(input[:customer], "customer") if input.key?(:customer)
34
+ options[:receipt_email] = non_empty_string(input[:receipt_email], "receipt_email") if input.key?(:receipt_email)
35
+ options[:metadata] = metadata(input[:metadata]) if input.key?(:metadata)
36
+ options[:hooks] = hooks(input[:hooks]) if input.key?(:hooks)
37
+ options.freeze
38
+ end
39
+
40
+ def metadata(value)
41
+ raise ArgumentError, "payment_intent_options metadata must be a Hash" unless value.is_a?(Hash)
42
+
43
+ value.each_with_object({}) do |(key, entry), result|
44
+ valid_key = key.is_a?(String) || key.is_a?(Symbol)
45
+ unless valid_key && entry.is_a?(String)
46
+ raise ArgumentError, "payment_intent_options metadata must contain string values"
47
+ end
48
+ result[key.to_s] = entry
49
+ end.freeze
50
+ end
51
+
52
+ def hooks(value)
53
+ hooks = symbolize_hash(value, "hooks")
54
+ inputs = symbolize_hash(hooks[:inputs], "hooks.inputs")
55
+ tax = symbolize_hash(inputs[:tax], "hooks.inputs.tax")
56
+ calculation = non_empty_string(tax[:calculation], "hooks.inputs.tax.calculation")
57
+ {inputs: {tax: {calculation: calculation}}}.freeze
58
+ end
59
+
60
+ def non_empty_string(value, name)
61
+ unless value.is_a?(String) && !value.empty?
62
+ raise ArgumentError, "payment_intent_options #{name} must be a non-empty String"
63
+ end
64
+ value
65
+ end
66
+
67
+ def symbolize_hash(value, name)
68
+ raise ArgumentError, "payment_intent_options #{name} must be a Hash" unless value.is_a?(Hash)
69
+
70
+ symbolize(value)
71
+ end
72
+
73
+ def symbolize(value)
74
+ value.each_with_object({}) do |(key, entry), result|
75
+ result[key.respond_to?(:to_sym) ? key.to_sym : key] = entry
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -48,6 +48,14 @@ module Mpp
48
48
  @can_offer.call(request)
49
49
  end
50
50
 
51
+ # Keep Stripe-only request input out of the canonical challenge while
52
+ # retaining it on an isolated SPT intent for terminal verification.
53
+ def prepare_intent(intent, input)
54
+ options_input = input[:payment_intent_options]
55
+ PaymentIntentOptions.validate_input(options_input)
56
+ [intent.with_payment_intent_options(options_input), input.except(:payment_intent_options)]
57
+ end
58
+
51
59
  # Transform request - injects Stripe-specific methodDetails.
52
60
  def transform_request(request, _credential)
53
61
  method_details = request.fetch("methodDetails", {})
@@ -12,7 +12,10 @@ module Mpp
12
12
  autoload :ChargeIntent, "mpp/methods/stripe/charge_intent"
13
13
  autoload :ClientMethod, "mpp/methods/stripe/client_method"
14
14
  autoload :CryptoPaymentRecorder, "mpp/methods/stripe/crypto_payment_recorder"
15
+ autoload :AnalyticsMetadata, "mpp/methods/stripe/analytics_metadata"
15
16
  autoload :MachinePayments, "mpp/methods/stripe/machine_payments"
17
+ autoload :PaymentIntentMethod, "mpp/methods/stripe/payment_intent_method"
18
+ autoload :PaymentIntentOptions, "mpp/methods/stripe/payment_intent_options"
16
19
 
17
20
  sig do
18
21
  params(