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 +4 -4
- data/README.md +85 -0
- data/lib/mpp/extensions/mcp/verify.rb +1 -1
- data/lib/mpp/methods/evm/charge_intent.rb +38 -17
- data/lib/mpp/methods/stripe/analytics_metadata.rb +24 -0
- data/lib/mpp/methods/stripe/charge_intent.rb +29 -5
- data/lib/mpp/methods/stripe/crypto_payment_recorder.rb +46 -9
- data/lib/mpp/methods/stripe/machine_payments.rb +6 -8
- data/lib/mpp/methods/stripe/payment_intent_method.rb +103 -0
- data/lib/mpp/methods/stripe/payment_intent_options.rb +81 -0
- data/lib/mpp/methods/stripe/stripe_method.rb +8 -0
- data/lib/mpp/methods/stripe.rb +3 -0
- data/lib/mpp/methods/tempo/client_method.rb +40 -12
- data/lib/mpp/methods/tempo/defaults.rb +51 -0
- data/lib/mpp/methods/tempo/intents.rb +147 -89
- data/lib/mpp/methods/tempo/relay.rb +33 -31
- data/lib/mpp/methods/tempo/schemas.rb +3 -4
- data/lib/mpp/parsing.rb +9 -2
- data/lib/mpp/server/compose.rb +10 -5
- data/lib/mpp/server/intent.rb +6 -1
- data/lib/mpp/server/intent_lifecycle.rb +41 -0
- data/lib/mpp/server/method.rb +26 -0
- data/lib/mpp/server/middleware.rb +2 -0
- data/lib/mpp/server/mpp_handler.rb +21 -9
- data/lib/mpp/server/verify.rb +1 -1
- data/lib/mpp/server.rb +1 -0
- data/lib/mpp/validation.rb +27 -0
- data/lib/mpp/version.rb +1 -1
- data/lib/mpp.rb +1 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e5d5c20207eb5f09b4f077cea8bdd1fb1f2acc15d12c5d00928cc7e8eddcf627
|
|
4
|
+
data.tar.gz: 1f080945c2a322985b47d184ccaf00b543931bcb6f4623c070175d03efab5cd8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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] =
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
params[:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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", {})
|
data/lib/mpp/methods/stripe.rb
CHANGED
|
@@ -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(
|