mpp-rb 0.1.5 → 0.1.6

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: c38b87554f2c1cbef8acfb7d2ba2197b14140500e52bff79276602c76051f8ed
4
- data.tar.gz: c11b04aef7e116a9b298e3b4b4286dfc4194348b6bd2aca60b1d3f235911ac25
3
+ metadata.gz: d028d7ba9d07e95173c4d3618a688dd87d4674284195a58ecfa4d6d43a311bf7
4
+ data.tar.gz: 3a342589c1478477c42077297f11244550ce035e4d66f9e4afde2e9a125d091c
5
5
  SHA512:
6
- metadata.gz: 795895e5b9b8a768d96468c7f34b468b9a205e694c2b212dc1fca00ecfd2e5906c95ec95455ac79432f1ba95b21a91ba31cd71ccecbb1dbc1226a3cf274ccbf8
7
- data.tar.gz: 81855362a29e248846445b3cebb0a1bce2a4f2cc2164c49e462ac923ade2fc1d30ef527786579313ffe270d02c89ec8b32a0dd0c09a74403bc69820de112ee10
6
+ metadata.gz: bf7ad2c6e7db109dc6244609e24eaf42f444396f0df9a69dd428017635dca2abb001271d89cd6c1e715fa3c58bf352ee267455ea0e38623cfd6dc608cd2da609
7
+ data.tar.gz: 6f090b293910e66a99054316767d36913a1a258c9ae5e1989fd74b16f67c8bad236b2e5351f6c4582140f5f48a0f707f2395245196470ec4c2206b0fb41418ad
data/README.md CHANGED
@@ -49,6 +49,18 @@ else
49
49
  end
50
50
  ```
51
51
 
52
+ 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
+
54
+ ```ruby
55
+ server = Mpp.create(method: tempo, requires_auth: true)
56
+
57
+ result = server.charge(
58
+ env["HTTP_AUTHORIZATION"],
59
+ "0.50",
60
+ payment_authorization: env["HTTP_PAYMENT_AUTHORIZATION"],
61
+ )
62
+ ```
63
+
52
64
  ### Multiple methods
53
65
 
54
66
  ```ruby
@@ -62,6 +74,7 @@ paid = server.compose(
62
74
 
63
75
  result = paid.call(
64
76
  authorization: env["HTTP_AUTHORIZATION"],
77
+ payment_authorization: env["HTTP_PAYMENT_AUTHORIZATION"],
65
78
  payment_signature: env["HTTP_PAYMENT_SIGNATURE"],
66
79
  accept_payment: env["HTTP_ACCEPT_PAYMENT"],
67
80
  url: request.url,
@@ -75,6 +88,46 @@ else
75
88
  end
76
89
  ```
77
90
 
91
+ ### Stripe machine payments
92
+
93
+ `Mpp::Methods::Stripe.create` configures Stripe Payment Tokens and static
94
+ crypto deposit addresses from an injected Stripe client. Add the `stripe` gem
95
+ to your application, then configure the methods:
96
+
97
+ ```ruby
98
+ require "stripe"
99
+
100
+ stripe_client = Stripe::StripeClient.new(ENV.fetch("STRIPE_SECRET_KEY"))
101
+ payments = Mpp::Methods::Stripe.create(
102
+ client: stripe_client,
103
+ network_id: ENV.fetch("STRIPE_NETWORK_ID"),
104
+ livemode: false,
105
+ deposit_addresses: {
106
+ tempo: ENV.fetch("TEMPO_DEPOSIT_ADDRESS"),
107
+ base: ENV.fetch("BASE_DEPOSIT_ADDRESS")
108
+ },
109
+ metadata: {"product" => "example"}
110
+ )
111
+
112
+ server = Mpp.create(methods: payments.default_methods)
113
+ ```
114
+
115
+ With no deposit addresses, defaults are Stripe Payment Tokens only.
116
+ `default_methods` adds Tempo when its static address is configured. Add Base
117
+ explicitly with its x402 facilitator:
118
+
119
+ ```ruby
120
+ base = payments.base.charge(
121
+ x402: {facilitator: "https://x402.org/facilitator"}
122
+ )
123
+ server = Mpp.create(methods: [*payments.default_methods, base])
124
+ ```
125
+
126
+ When composed, SPT offers below $0.50 and Tempo/Base offers below one cent are
127
+ not advertised. Successful Tempo and Base payments are best-effort recorded as
128
+ Stripe crypto transaction-verification PaymentIntents. Metadata is included on
129
+ every Stripe PaymentIntent.
130
+
78
131
  `evm.charge` additionally emits `PAYMENT-REQUIRED` and accepts `PAYMENT-SIGNATURE` (x402 v2 exact) when a facilitator is configured:
79
132
 
80
133
  ```ruby
@@ -152,6 +205,18 @@ end
152
205
 
153
206
  Client events are `challenge.received`, `credential.created`, `payment.response`, and `payment.failed`. Server events are `challenge.created`, `payment.success`, and `payment.failed`.
154
207
 
208
+ When a side effect belongs to one payment method, attach it to the method instead. The hook only receives successful payments for that method and intent; an exception in the hook does not invalidate the payment.
209
+
210
+ ```ruby
211
+ method = Mpp::Methods::Tempo.tempo(
212
+ intents: {"charge" => Mpp::Methods::Tempo::ChargeIntent.new},
213
+ recipient: "0x0000000000000000000000000000000000000001",
214
+ on_payment_success: ->(payload) {
215
+ record_payment(payload[:receipt], payload[:request])
216
+ },
217
+ )
218
+ ```
219
+
155
220
  ### Rack Middleware
156
221
 
157
222
  ```ruby
data/lib/mpp/challenge.rb CHANGED
@@ -16,16 +16,30 @@ module Mpp
16
16
  :digest,
17
17
  :expires,
18
18
  :description,
19
- :opaque
19
+ :opaque,
20
+ :header
20
21
  ) do
21
22
  def initialize(id:, method:, intent:, request:, realm: "", request_b64: "", digest: nil, expires: nil,
22
- description: nil, opaque: nil)
23
- super
23
+ description: nil, opaque: nil, header: nil)
24
+ super(
25
+ id: id,
26
+ method: method,
27
+ intent: intent,
28
+ request: request,
29
+ realm: realm,
30
+ request_b64: request_b64,
31
+ digest: digest,
32
+ expires: expires,
33
+ description: description,
34
+ opaque: opaque,
35
+ header: Mpp.advertised_credential_header(header)
36
+ )
24
37
  end
25
38
 
26
39
  # Create a Challenge with an HMAC-bound ID.
27
40
  def self.create(secret_key:, realm:, method:, intent:, request:, expires: nil, digest: nil, description: nil,
28
- meta: nil)
41
+ meta: nil, header: nil)
42
+ header = Mpp.advertised_credential_header(header)
29
43
  challenge_id = Mpp.generate_challenge_id(
30
44
  secret_key: secret_key,
31
45
  realm: realm,
@@ -34,7 +48,8 @@ module Mpp
34
48
  request: request,
35
49
  expires: expires,
36
50
  digest: digest,
37
- opaque: meta
51
+ opaque: meta,
52
+ header: header
38
53
  )
39
54
  request_json = Mpp::Json.compact_encode(request)
40
55
  request_b64 = Mpp.b64url_encode(request_json)
@@ -49,7 +64,8 @@ module Mpp
49
64
  digest: digest,
50
65
  expires: expires,
51
66
  description: description,
52
- opaque: meta
67
+ opaque: meta,
68
+ header: header
53
69
  )
54
70
  end
55
71
 
@@ -152,6 +168,11 @@ module Mpp
152
168
  Mpp::Parsing.format_www_authenticate(self, realm)
153
169
  end
154
170
 
171
+ # HTTP field a client must use for the payment credential.
172
+ def credential_header
173
+ header || Mpp::AUTHORIZATION_HEADER
174
+ end
175
+
155
176
  # Verify the challenge ID matches the expected HMAC.
156
177
  def verify(secret_key, realm)
157
178
  expected_id = Mpp.generate_challenge_id(
@@ -162,7 +183,8 @@ module Mpp
162
183
  request: request,
163
184
  expires: expires,
164
185
  digest: digest,
165
- opaque: opaque
186
+ opaque: opaque,
187
+ header: header
166
188
  )
167
189
  Mpp.secure_compare(id, expected_id)
168
190
  end
@@ -183,7 +205,8 @@ module Mpp
183
205
  request: request_b64,
184
206
  expires: expires,
185
207
  digest: digest,
186
- opaque: opaque_b64
208
+ opaque: opaque_b64,
209
+ header: header
187
210
  )
188
211
  end
189
212
  end
@@ -10,10 +10,21 @@ module Mpp
10
10
  :request,
11
11
  :expires,
12
12
  :digest,
13
- :opaque
13
+ :opaque,
14
+ :header
14
15
  ) do
15
- def initialize(id:, realm:, method:, intent:, request:, expires: nil, digest: nil, opaque: nil)
16
- super
16
+ def initialize(id:, realm:, method:, intent:, request:, expires: nil, digest: nil, opaque: nil, header: nil)
17
+ super(
18
+ id: id,
19
+ realm: realm,
20
+ method: method,
21
+ intent: intent,
22
+ request: request,
23
+ expires: expires,
24
+ digest: digest,
25
+ opaque: opaque,
26
+ header: Mpp.advertised_credential_header(header)
27
+ )
17
28
  end
18
29
  end
19
30
  end
@@ -9,13 +9,18 @@ module Mpp
9
9
 
10
10
  module_function
11
11
 
12
+ # RFC 9110 token used as an HTTP field name.
13
+ HTTP_HEADER_NAME_RE = T.let(/\A[!#$%&'*+.^_`|~0-9A-Za-z-]+\z/, Regexp)
14
+
12
15
  # Generate HMAC-SHA256 challenge ID per spec.
13
16
  #
14
17
  # HMAC input format: realm|method|intent|request_b64|expires|digest|opaque_b64
15
- # All fields always included; absent optional fields use empty string.
18
+ # Challenges that advertise a credential header insert it immediately before
19
+ # the final opaque slot. Authorization is the implicit default and is never
20
+ # included, preserving the legacy binding.
16
21
  # Output: base64url(HMAC-SHA256(secret_key, input))
17
- sig { params(secret_key: T.untyped, realm: T.untyped, method: T.untyped, intent: T.untyped, request: T.untyped, expires: T.untyped, digest: T.untyped, opaque: T.untyped).returns(String) }
18
- def generate_challenge_id(secret_key:, realm:, method:, intent:, request:, expires: nil, digest: nil, opaque: nil)
22
+ sig { params(secret_key: T.untyped, realm: T.untyped, method: T.untyped, intent: T.untyped, request: T.untyped, expires: T.untyped, digest: T.untyped, opaque: T.untyped, header: T.untyped).returns(String) }
23
+ def generate_challenge_id(secret_key:, realm:, method:, intent:, request:, expires: nil, digest: nil, opaque: nil, header: nil)
19
24
  request_json = Json.compact_encode(request)
20
25
  request_b64 = b64url_encode(request_json)
21
26
 
@@ -32,14 +37,33 @@ module Mpp
32
37
  intent,
33
38
  request_b64,
34
39
  expires || "",
35
- digest || "",
36
- opaque_b64
37
- ].join("|")
40
+ digest || ""
41
+ ]
42
+ advertised = advertised_credential_header(header)
43
+ hmac_input << advertised if advertised
44
+ hmac_input << opaque_b64
45
+ hmac_input = hmac_input.join("|")
38
46
 
39
47
  mac = OpenSSL::HMAC.digest("SHA256", secret_key.encode(Encoding::UTF_8), hmac_input.encode(Encoding::UTF_8))
40
48
  Base64.urlsafe_encode64(mac, padding: false)
41
49
  end
42
50
 
51
+ # True when the credential header is omitted or is the implicit Authorization default.
52
+ sig { params(header: T.untyped).returns(T::Boolean) }
53
+ def default_credential_header?(header)
54
+ header.nil? || header.to_s.empty? || header.to_s.casecmp(AUTHORIZATION_HEADER).zero?
55
+ end
56
+
57
+ # Returns an advertised credential header, or nil for the Authorization default.
58
+ sig { params(header: T.untyped).returns(T.nilable(String)) }
59
+ def advertised_credential_header(header)
60
+ return nil if default_credential_header?(header)
61
+
62
+ name = header.to_s
63
+ Kernel.raise ArgumentError, "Invalid HTTP header name" unless HTTP_HEADER_NAME_RE.match?(name)
64
+ name
65
+ end
66
+
43
67
  # Encode string to base64url without padding.
44
68
  sig { params(data: T.untyped).returns(String) }
45
69
  def b64url_encode(data)
@@ -117,7 +117,7 @@ module Mpp
117
117
  raise
118
118
  end
119
119
 
120
- retry_headers = headers.merge("Authorization" => auth_header)
120
+ retry_headers = headers.merge(challenge.credential_header => auth_header)
121
121
  payment_response = nil
122
122
  begin
123
123
  payment_response = send_request(uri, method, retry_headers, body)
@@ -6,10 +6,18 @@ module Mpp
6
6
  module Evm
7
7
  # EVM payment method. Speaks native Payment-auth and x402 exact.
8
8
  class EvmMethod
9
- attr_reader :name, :currency, :recipient, :decimals, :chain_id, :authorization, :x402
9
+ attr_reader :name, :currency, :recipient, :decimals, :chain_id, :authorization, :x402,
10
+ :on_payment_success
10
11
  attr_accessor :intents
11
12
 
12
- def initialize(currency:, recipient:, decimals:, chain_id:, authorization:, x402:)
13
+ def initialize(currency:, recipient:, decimals:, chain_id:, authorization:, x402:, on_payment_success: nil, can_offer: nil)
14
+ if !on_payment_success.nil? && !on_payment_success.respond_to?(:call)
15
+ raise ArgumentError, "on_payment_success must be callable"
16
+ end
17
+ if !can_offer.nil? && !can_offer.respond_to?(:call)
18
+ raise ArgumentError, "can_offer must be callable"
19
+ end
20
+
13
21
  @name = "evm"
14
22
  @currency = Authorization.checksum_address(currency)
15
23
  @recipient = Authorization.checksum_address(recipient)
@@ -17,9 +25,17 @@ module Mpp
17
25
  @chain_id = chain_id
18
26
  @authorization = authorization
19
27
  @x402 = x402
28
+ @on_payment_success = on_payment_success
29
+ @can_offer = can_offer
20
30
  @intents = {}
21
31
  end
22
32
 
33
+ def can_offer?(request)
34
+ return true unless @can_offer
35
+
36
+ @can_offer.call(request)
37
+ end
38
+
23
39
  def transform_request(request, _credential)
24
40
  method_details = request.fetch("methodDetails", {})
25
41
  method_details = {} unless method_details.is_a?(Hash)
@@ -10,7 +10,8 @@ module Mpp
10
10
  autoload :EvmMethod, "mpp/methods/evm/evm_method"
11
11
 
12
12
  # Factory for a server-side EVM charge method with inline x402 exact support.
13
- def self.charge(currency:, recipient:, x402:, authorization: nil, chain_id: nil, decimals: nil)
13
+ def self.charge(currency:, recipient:, x402:, authorization: nil, chain_id: nil, decimals: nil,
14
+ on_payment_success: nil, can_offer: nil)
14
15
  resolved = Assets.resolve(
15
16
  currency,
16
17
  authorization: authorization,
@@ -32,7 +33,9 @@ module Mpp
32
33
  decimals: resolved.fetch(:decimals),
33
34
  chain_id: resolved.fetch(:chain_id),
34
35
  authorization: resolved.fetch(:authorization),
35
- x402: x402_options
36
+ x402: x402_options,
37
+ on_payment_success: on_payment_success,
38
+ can_offer: can_offer
36
39
  )
37
40
  method.intents = {"charge" => charge_intent}
38
41
  method
@@ -61,10 +61,8 @@ module Mpp
61
61
  payment_method_types: payment_method_types
62
62
  }
63
63
 
64
- # Include metadata from methodDetails if present
65
- if method_details["metadata"].is_a?(Hash)
66
- params[:metadata] = method_details["metadata"].transform_values(&:to_s)
67
- end
64
+ metadata = method_details["metadata"].is_a?(Hash) ? method_details["metadata"].transform_values(&:to_s) : {}
65
+ params[:metadata] = metadata.merge("machine_payment" => "true")
68
66
 
69
67
  unless @client
70
68
  begin
@@ -78,15 +76,16 @@ module Mpp
78
76
  client = @client || ::Stripe::StripeClient.new(@secret_key)
79
77
  result = client.v1.payment_intents.create(
80
78
  params,
81
- {idempotency_key: stripe_idempotency_key(credential)}
79
+ {stripe_version: Defaults::MACHINE_PAYMENTS_API_VERSION, idempotency_key: stripe_idempotency_key(credential)}
82
80
  )
83
81
  rescue => e
84
82
  raise Mpp::VerificationError, e.message
85
83
  end
86
84
 
87
85
  # https://docs.stripe.com/error-low-level#idempotency
88
- if result.respond_to?(:last_response) &&
89
- result.last_response&.headers&.[]("idempotent-replayed") == "true"
86
+ last_response = result.last_response if result.respond_to?(:last_response)
87
+ response_headers = last_response.respond_to?(:http_headers) ? last_response.http_headers : last_response&.headers
88
+ if response_headers&.[]("idempotent-replayed") == "true"
90
89
  raise Mpp::VerificationError, "Payment has already been processed."
91
90
  end
92
91
 
@@ -0,0 +1,55 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Stripe
7
+ # Records an already-verified crypto transfer as a Stripe PaymentIntent.
8
+ class CryptoPaymentRecorder
9
+ RAW_UNITS_PER_CENT = 10_000
10
+
11
+ def initialize(client:, network:, metadata: nil)
12
+ @client = client
13
+ @network = network
14
+ @metadata = metadata
15
+ end
16
+
17
+ def call(payload)
18
+ reference = payload[:receipt]&.reference
19
+ return unless reference.is_a?(String)
20
+
21
+ amount = Integer(payload.fetch(:request).fetch("amount"))
22
+ cents = (amount + (RAW_UNITS_PER_CENT / 2)) / RAW_UNITS_PER_CENT
23
+ return if cents < 1
24
+
25
+ params = {
26
+ amount: cents,
27
+ currency: "usd",
28
+ confirm: true,
29
+ payment_method_data: {type: "crypto"},
30
+ payment_method_types: ["crypto"],
31
+ payment_method_options: {
32
+ crypto: {
33
+ mode: "transaction_verification",
34
+ transaction_verification_options: {network: @network, transaction_hash: reference}
35
+ }
36
+ }
37
+ }
38
+ metadata = @metadata.is_a?(Hash) ? @metadata.transform_values(&:to_s) : {}
39
+ params[:metadata] = metadata.merge("machine_payment" => "true")
40
+
41
+ @client.v1.payment_intents.create(
42
+ params,
43
+ {stripe_version: Defaults::MACHINE_PAYMENTS_API_VERSION, idempotency_key: reference}
44
+ )
45
+ rescue => error
46
+ Kernel.warn(
47
+ "[stripe] failed to record crypto payment " \
48
+ "network=#{@network.inspect} transaction_hash=#{reference.inspect}: #{error.class}: #{error.message}"
49
+ )
50
+ nil
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -6,6 +6,7 @@ module Mpp
6
6
  module Stripe
7
7
  module Defaults
8
8
  STRIPE_API_BASE = "https://api.stripe.com"
9
+ MACHINE_PAYMENTS_API_VERSION = "2026-07-29.preview"
9
10
  DEFAULT_CURRENCY = "usd"
10
11
  DEFAULT_DECIMALS = 2
11
12
  end
@@ -0,0 +1,130 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Mpp
5
+ module Methods
6
+ module Stripe
7
+ # Configures Stripe-backed MPP methods with static deposit addresses.
8
+ class MachinePayments
9
+ SPT_MINIMUM_MINOR_UNITS = 50
10
+
11
+ attr_reader :spt, :tempo, :base
12
+
13
+ def initialize(network_id:, livemode:, client:, deposit_addresses: nil, metadata: nil)
14
+ raise ArgumentError, "client must respond to #v1" unless client.respond_to?(:v1)
15
+
16
+ addresses = normalize_deposit_addresses(deposit_addresses)
17
+ @spt = SptFactory.new(network_id: network_id, client: client, metadata: metadata)
18
+ @tempo = TempoFactory.new(livemode: livemode, client: client, recipient: addresses[:tempo], metadata: metadata)
19
+ @base = BaseFactory.new(livemode: livemode, client: client, recipient: addresses[:base], metadata: metadata)
20
+ end
21
+
22
+ # Tempo is preferred when configured; Base requires an explicit facilitator.
23
+ def default_methods
24
+ methods = [spt.charge]
25
+ methods.unshift(tempo.charge) if tempo.configured?
26
+ methods
27
+ end
28
+
29
+ def self.minimum_amount(minimum)
30
+ lambda do |request|
31
+ Integer(request.fetch("amount")) >= minimum
32
+ rescue ArgumentError, TypeError, KeyError
33
+ false
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def normalize_deposit_addresses(deposit_addresses)
40
+ return {} if deposit_addresses.nil?
41
+ raise ArgumentError, "deposit_addresses must be a Hash" unless deposit_addresses.is_a?(Hash)
42
+
43
+ deposit_addresses.each_with_object({}) do |(network, address), result|
44
+ unless [:tempo, :base].include?(network)
45
+ raise ArgumentError, "unsupported deposit address network: #{network}"
46
+ end
47
+ unless address.is_a?(String) && address.match?(/\A0x[0-9a-fA-F]{40}\z/)
48
+ raise ArgumentError, "deposit_addresses[:#{network}] must be a 0x-prefixed 40-hex-character address"
49
+ end
50
+
51
+ result[network] = address.dup.freeze
52
+ end
53
+ end
54
+ end
55
+
56
+ class SptFactory
57
+ def initialize(network_id:, client:, metadata:)
58
+ @network_id = network_id
59
+ @client = client
60
+ @metadata = metadata
61
+ end
62
+
63
+ def charge
64
+ method = Mpp::Methods::Stripe::StripeMethod.new(
65
+ secret_key: nil,
66
+ network_id: @network_id,
67
+ payment_methods: ["card", "link"],
68
+ metadata: @metadata,
69
+ can_offer: MachinePayments.minimum_amount(MachinePayments::SPT_MINIMUM_MINOR_UNITS)
70
+ )
71
+ method.intents = {"charge" => Mpp::Methods::Stripe::ChargeIntent.new(secret_key: nil, client: @client)}
72
+ method
73
+ end
74
+ end
75
+
76
+ class TempoFactory
77
+ def initialize(livemode:, client:, recipient:, metadata:)
78
+ @livemode = livemode
79
+ @client = client
80
+ @recipient = recipient
81
+ @metadata = metadata
82
+ end
83
+
84
+ def configured?
85
+ !@recipient.nil?
86
+ end
87
+
88
+ def charge
89
+ raise ArgumentError, "deposit_addresses[:tempo] is required for Tempo payments" unless configured?
90
+
91
+ chain_id = @livemode ? Mpp::Methods::Tempo::Defaults::CHAIN_ID : Mpp::Methods::Tempo::Defaults::TESTNET_CHAIN_ID
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(
95
+ intents: {"charge" => Mpp::Methods::Tempo::ChargeIntent.new(chain_id: chain_id)},
96
+ chain_id: chain_id,
97
+ currency: currency,
98
+ recipient: @recipient,
99
+ 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) }
102
+ )
103
+ end
104
+ end
105
+
106
+ class BaseFactory
107
+ def initialize(livemode:, client:, recipient:, metadata:)
108
+ @livemode = livemode
109
+ @client = client
110
+ @recipient = recipient
111
+ @metadata = metadata
112
+ end
113
+
114
+ def charge(x402:)
115
+ raise ArgumentError, "deposit_addresses[:base] is required for Base payments" if @recipient.nil?
116
+
117
+ 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(
120
+ currency: currency,
121
+ recipient: @recipient,
122
+ x402: x402,
123
+ can_offer: MachinePayments.minimum_amount(CryptoPaymentRecorder::RAW_UNITS_PER_CENT),
124
+ on_payment_success: ->(payload) { recorder.call(payload) }
125
+ )
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -9,17 +9,24 @@ module Mpp
9
9
  # Stripe payment method implementation.
10
10
  # Handles SPT-based payments through Stripe's Business Network.
11
11
  class StripeMethod
12
- attr_reader :name, :currency, :recipient, :decimals
12
+ attr_reader :name, :currency, :recipient, :decimals, :on_payment_success
13
13
  attr_accessor :intents
14
14
 
15
15
  def initialize(secret_key:, network_id:, payment_methods: nil,
16
16
  metadata: nil, currency: Defaults::DEFAULT_CURRENCY,
17
- decimals: Defaults::DEFAULT_DECIMALS, external_id: nil)
17
+ decimals: Defaults::DEFAULT_DECIMALS, external_id: nil,
18
+ on_payment_success: nil, can_offer: nil)
18
19
  unless payment_methods.is_a?(Array) &&
19
20
  payment_methods.any? &&
20
21
  payment_methods.all? { |type| type.is_a?(String) && !type.strip.empty? }
21
22
  raise ArgumentError, "payment_methods must be a non-empty array of Stripe payment method type strings"
22
23
  end
24
+ unless on_payment_success.nil? || on_payment_success.respond_to?(:call)
25
+ raise ArgumentError, "on_payment_success must be callable"
26
+ end
27
+ unless can_offer.nil? || can_offer.respond_to?(:call)
28
+ raise ArgumentError, "can_offer must be callable"
29
+ end
23
30
 
24
31
  @name = "stripe"
25
32
  @secret_key = secret_key
@@ -27,12 +34,20 @@ module Mpp
27
34
  @payment_methods = payment_methods
28
35
  @metadata = metadata
29
36
  @external_id = external_id
37
+ @on_payment_success = on_payment_success
38
+ @can_offer = can_offer
30
39
  @currency = currency
31
40
  @recipient = network_id
32
41
  @decimals = decimals
33
42
  @intents = {}
34
43
  end
35
44
 
45
+ def can_offer?(request)
46
+ return true unless @can_offer
47
+
48
+ @can_offer.call(request)
49
+ end
50
+
36
51
  # Transform request - injects Stripe-specific methodDetails.
37
52
  def transform_request(request, _credential)
38
53
  method_details = request.fetch("methodDetails", {})
@@ -53,6 +68,8 @@ module Mpp
53
68
  metadata: nil, currency: Defaults::DEFAULT_CURRENCY,
54
69
  decimals: Defaults::DEFAULT_DECIMALS,
55
70
  external_id: nil,
71
+ on_payment_success: nil,
72
+ can_offer: nil,
56
73
  api_base: Defaults::STRIPE_API_BASE)
57
74
  charge_intent = ChargeIntent.new(secret_key: secret_key, api_base: api_base)
58
75
 
@@ -63,7 +80,9 @@ module Mpp
63
80
  metadata: metadata,
64
81
  currency: currency,
65
82
  decimals: decimals,
66
- external_id: external_id
83
+ external_id: external_id,
84
+ on_payment_success: on_payment_success,
85
+ can_offer: can_offer
67
86
  )
68
87
 
69
88
  method.intents = {"charge" => charge_intent}
@@ -4,11 +4,34 @@
4
4
  module Mpp
5
5
  module Methods
6
6
  module Stripe
7
+ extend T::Sig
8
+
7
9
  autoload :Defaults, "mpp/methods/stripe/defaults"
8
10
  # Eagerly require stripe_method so the Stripe.stripe factory method is available
9
11
  require_relative "stripe/stripe_method"
10
12
  autoload :ChargeIntent, "mpp/methods/stripe/charge_intent"
11
13
  autoload :ClientMethod, "mpp/methods/stripe/client_method"
14
+ autoload :CryptoPaymentRecorder, "mpp/methods/stripe/crypto_payment_recorder"
15
+ autoload :MachinePayments, "mpp/methods/stripe/machine_payments"
16
+
17
+ sig do
18
+ params(
19
+ network_id: String,
20
+ livemode: T::Boolean,
21
+ client: T.untyped,
22
+ deposit_addresses: T.untyped,
23
+ metadata: T.untyped
24
+ ).returns(MachinePayments)
25
+ end
26
+ def self.create(network_id:, livemode:, client:, deposit_addresses: nil, metadata: nil)
27
+ MachinePayments.new(
28
+ network_id: network_id,
29
+ livemode: livemode,
30
+ client: client,
31
+ deposit_addresses: deposit_addresses,
32
+ metadata: metadata
33
+ )
34
+ end
12
35
  end
13
36
  end
14
37
  end