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 +4 -4
- data/README.md +37 -5
- data/lib/mpp/body_digest.rb +1 -1
- data/lib/mpp/challenge.rb +71 -6
- data/lib/mpp/client/transport.rb +153 -18
- data/lib/mpp/errors.rb +3 -0
- data/lib/mpp/events.rb +124 -0
- data/lib/mpp/extensions/mcp/types.rb +2 -1
- data/lib/mpp/methods/stripe/charge_intent.rb +41 -16
- data/lib/mpp/methods/stripe/client_method.rb +5 -1
- data/lib/mpp/methods/stripe/stripe_method.rb +15 -4
- data/lib/mpp/methods/tempo/client_method.rb +28 -8
- data/lib/mpp/methods/tempo/fee_payer_policy.rb +45 -0
- data/lib/mpp/methods/tempo/intents.rb +394 -64
- data/lib/mpp/methods/tempo/proof.rb +19 -15
- data/lib/mpp/methods/tempo/transaction.rb +4 -3
- data/lib/mpp/methods/tempo.rb +1 -0
- data/lib/mpp/parsing.rb +14 -2
- data/lib/mpp/receipt.rb +3 -2
- data/lib/mpp/server/decorator.rb +1 -0
- data/lib/mpp/server/middleware.rb +101 -2
- data/lib/mpp/server/mpp_handler.rb +40 -8
- data/lib/mpp/server/verify.rb +173 -27
- data/lib/mpp/version.rb +1 -1
- data/lib/mpp.rb +5 -3
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cfa6aaca1aa33a23ec690b0c9cae8e04fe54ed71891fb82fd59703af067ec41e
|
|
4
|
+
data.tar.gz: 05fc8e20c0213027578d160f2b80fd953a4ea0e2d2ddd89bb6e1a78c971bfb11
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
[](https://rubygems.org/gems/mpp-rb)
|
|
5
|
+
[](https://rubygems.org/gems/mpp-rb)
|
|
6
6
|
[](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.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
data/lib/mpp/body_digest.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Mpp
|
|
|
21
21
|
when String
|
|
22
22
|
# use as-is
|
|
23
23
|
end
|
|
24
|
-
body = body.
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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)
|
data/lib/mpp/client/transport.rb
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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,
|