mpp-rb 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +150 -0
- data/lib/mpp/challenge.rb +31 -8
- data/lib/mpp/challenge_echo.rb +14 -3
- data/lib/mpp/challenge_id.rb +30 -6
- data/lib/mpp/client/transport.rb +1 -1
- data/lib/mpp/extensions/mcp/verify.rb +1 -1
- data/lib/mpp/methods/evm/charge_intent.rb +38 -17
- data/lib/mpp/methods/evm/evm_method.rb +18 -2
- data/lib/mpp/methods/evm.rb +5 -2
- data/lib/mpp/methods/stripe/analytics_metadata.rb +24 -0
- data/lib/mpp/methods/stripe/charge_intent.rb +34 -11
- data/lib/mpp/methods/stripe/crypto_payment_recorder.rb +92 -0
- data/lib/mpp/methods/stripe/defaults.rb +1 -0
- data/lib/mpp/methods/stripe/machine_payments.rb +128 -0
- 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 +30 -3
- data/lib/mpp/methods/stripe.rb +26 -0
- data/lib/mpp/methods/tempo/client_method.rb +58 -12
- data/lib/mpp/methods/tempo/defaults.rb +51 -0
- data/lib/mpp/methods/tempo/intents.rb +195 -125
- data/lib/mpp/methods/tempo/relay.rb +33 -31
- data/lib/mpp/methods/tempo/schemas.rb +3 -4
- data/lib/mpp/parsing.rb +29 -4
- data/lib/mpp/server/compose.rb +32 -15
- data/lib/mpp/server/decorator.rb +3 -1
- data/lib/mpp/server/intent.rb +6 -1
- data/lib/mpp/server/intent_lifecycle.rb +41 -0
- data/lib/mpp/server/method.rb +41 -0
- data/lib/mpp/server/middleware.rb +16 -2
- data/lib/mpp/server/mpp_handler.rb +87 -21
- data/lib/mpp/server/verify.rb +25 -15
- 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 +14 -3
- metadata +22 -1
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Mpp
|
|
5
|
+
module Methods
|
|
6
|
+
module Stripe
|
|
7
|
+
# Records an already-settled 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
|
+
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 = {
|
|
30
|
+
amount: cents,
|
|
31
|
+
currency: "usd",
|
|
32
|
+
confirm: true,
|
|
33
|
+
payment_method_data: {type: "crypto"},
|
|
34
|
+
payment_method_types: ["crypto"],
|
|
35
|
+
payment_method_options: {
|
|
36
|
+
crypto: {
|
|
37
|
+
mode: "transaction_verification",
|
|
38
|
+
transaction_verification_options: {network: @network, transaction_hash: reference}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
metadata: analytics
|
|
42
|
+
}
|
|
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
|
|
51
|
+
|
|
52
|
+
create(params, reference)
|
|
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
|
+
|
|
66
|
+
Kernel.warn(
|
|
67
|
+
"[stripe] failed to record crypto payment " \
|
|
68
|
+
"network=#{@network.inspect} transaction_hash=#{reference.inspect}: #{error.class}: #{error.message}"
|
|
69
|
+
)
|
|
70
|
+
nil
|
|
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
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
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
|
+
method = Mpp::Methods::Tempo.tempo(
|
|
94
|
+
intents: {"charge" => Mpp::Methods::Tempo::ChargeIntent.new(chain_id: chain_id)},
|
|
95
|
+
chain_id: chain_id,
|
|
96
|
+
currency: currency,
|
|
97
|
+
recipient: @recipient,
|
|
98
|
+
decimals: Mpp::Methods::Tempo::Defaults::PATH_USD_DECIMALS,
|
|
99
|
+
can_offer: MachinePayments.minimum_amount(CryptoPaymentRecorder::RAW_UNITS_PER_CENT)
|
|
100
|
+
)
|
|
101
|
+
PaymentIntentMethod.new(method: method, client: @client, network: "tempo", metadata: @metadata)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
class BaseFactory
|
|
106
|
+
def initialize(livemode:, client:, recipient:, metadata:)
|
|
107
|
+
@livemode = livemode
|
|
108
|
+
@client = client
|
|
109
|
+
@recipient = recipient
|
|
110
|
+
@metadata = metadata
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def charge(x402:)
|
|
114
|
+
raise ArgumentError, "deposit_addresses[:base] is required for Base payments" if @recipient.nil?
|
|
115
|
+
|
|
116
|
+
currency = @livemode ? Mpp::Methods::Evm::Assets::BASE_USDC : Mpp::Methods::Evm::Assets::BASE_SEPOLIA_USDC
|
|
117
|
+
method = Mpp::Methods::Evm.charge(
|
|
118
|
+
currency: currency,
|
|
119
|
+
recipient: @recipient,
|
|
120
|
+
x402: x402,
|
|
121
|
+
can_offer: MachinePayments.minimum_amount(CryptoPaymentRecorder::RAW_UNITS_PER_CENT)
|
|
122
|
+
)
|
|
123
|
+
PaymentIntentMethod.new(method: method, client: @client, network: "base", metadata: @metadata)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
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
|
|
@@ -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,28 @@ 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
|
+
|
|
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
|
+
|
|
36
59
|
# Transform request - injects Stripe-specific methodDetails.
|
|
37
60
|
def transform_request(request, _credential)
|
|
38
61
|
method_details = request.fetch("methodDetails", {})
|
|
@@ -53,6 +76,8 @@ module Mpp
|
|
|
53
76
|
metadata: nil, currency: Defaults::DEFAULT_CURRENCY,
|
|
54
77
|
decimals: Defaults::DEFAULT_DECIMALS,
|
|
55
78
|
external_id: nil,
|
|
79
|
+
on_payment_success: nil,
|
|
80
|
+
can_offer: nil,
|
|
56
81
|
api_base: Defaults::STRIPE_API_BASE)
|
|
57
82
|
charge_intent = ChargeIntent.new(secret_key: secret_key, api_base: api_base)
|
|
58
83
|
|
|
@@ -63,7 +88,9 @@ module Mpp
|
|
|
63
88
|
metadata: metadata,
|
|
64
89
|
currency: currency,
|
|
65
90
|
decimals: decimals,
|
|
66
|
-
external_id: external_id
|
|
91
|
+
external_id: external_id,
|
|
92
|
+
on_payment_success: on_payment_success,
|
|
93
|
+
can_offer: can_offer
|
|
67
94
|
)
|
|
68
95
|
|
|
69
96
|
method.intents = {"charge" => charge_intent}
|
data/lib/mpp/methods/stripe.rb
CHANGED
|
@@ -4,11 +4,37 @@
|
|
|
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 :AnalyticsMetadata, "mpp/methods/stripe/analytics_metadata"
|
|
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"
|
|
19
|
+
|
|
20
|
+
sig do
|
|
21
|
+
params(
|
|
22
|
+
network_id: String,
|
|
23
|
+
livemode: T::Boolean,
|
|
24
|
+
client: T.untyped,
|
|
25
|
+
deposit_addresses: T.untyped,
|
|
26
|
+
metadata: T.untyped
|
|
27
|
+
).returns(MachinePayments)
|
|
28
|
+
end
|
|
29
|
+
def self.create(network_id:, livemode:, client:, deposit_addresses: nil, metadata: nil)
|
|
30
|
+
MachinePayments.new(
|
|
31
|
+
network_id: network_id,
|
|
32
|
+
livemode: livemode,
|
|
33
|
+
client: client,
|
|
34
|
+
deposit_addresses: deposit_addresses,
|
|
35
|
+
metadata: metadata
|
|
36
|
+
)
|
|
37
|
+
end
|
|
12
38
|
end
|
|
13
39
|
end
|
|
14
40
|
end
|
|
@@ -18,32 +18,50 @@ module Mpp
|
|
|
18
18
|
# Handles client-side credential creation for Tempo payments.
|
|
19
19
|
class TempoMethod
|
|
20
20
|
attr_reader :name, :account, :fee_payer, :fee_payer_allowed_fee_tokens,
|
|
21
|
-
:root_account, :rpc_url, :chain_id, :currency, :recipient, :decimals, :client_id,
|
|
22
|
-
:expected_recipients, :relay
|
|
21
|
+
:root_account, :rpc_url, :chain_id, :currency, :currencies, :recipient, :decimals, :client_id,
|
|
22
|
+
:expected_recipients, :relay, :on_payment_success, :fee_token
|
|
23
23
|
attr_accessor :intents
|
|
24
24
|
|
|
25
25
|
def initialize(account: nil, fee_payer: nil, root_account: nil,
|
|
26
26
|
rpc_url: Defaults::RPC_URL, chain_id: nil, currency: nil,
|
|
27
27
|
recipient: nil, decimals: 6, client_id: nil,
|
|
28
28
|
expected_recipients: nil, fee_payer_allowed_fee_tokens: nil,
|
|
29
|
-
relay: nil)
|
|
29
|
+
relay: nil, on_payment_success: nil, can_offer: nil, currencies: nil, fee_token: nil)
|
|
30
|
+
unless on_payment_success.nil? || on_payment_success.respond_to?(:call)
|
|
31
|
+
raise ArgumentError, "on_payment_success must be callable"
|
|
32
|
+
end
|
|
33
|
+
if !can_offer.nil? && !can_offer.respond_to?(:call)
|
|
34
|
+
raise ArgumentError, "can_offer must be callable"
|
|
35
|
+
end
|
|
36
|
+
|
|
30
37
|
@name = "tempo"
|
|
31
38
|
@account = account
|
|
32
39
|
@fee_payer = fee_payer
|
|
33
40
|
@relay = relay
|
|
41
|
+
@fee_token = fee_token
|
|
34
42
|
@fee_payer_allowed_fee_tokens =
|
|
35
43
|
fee_payer_allowed_fee_tokens&.map { |token| token.to_s.downcase }
|
|
36
44
|
@root_account = root_account
|
|
37
45
|
@rpc_url = rpc_url
|
|
38
46
|
@chain_id = chain_id
|
|
39
|
-
|
|
47
|
+
# Ordered currencies offered by a server; the first is the primary.
|
|
48
|
+
@currencies = (currencies || [currency].compact).dup.freeze
|
|
49
|
+
@currency = currency || @currencies.first
|
|
40
50
|
@recipient = recipient
|
|
41
51
|
@decimals = decimals
|
|
42
52
|
@client_id = client_id
|
|
43
53
|
@expected_recipients = expected_recipients&.map(&:downcase)&.to_set
|
|
54
|
+
@on_payment_success = on_payment_success
|
|
55
|
+
@can_offer = can_offer
|
|
44
56
|
@intents = {}
|
|
45
57
|
end
|
|
46
58
|
|
|
59
|
+
def can_offer?(request)
|
|
60
|
+
return true unless @can_offer
|
|
61
|
+
|
|
62
|
+
@can_offer.call(request)
|
|
63
|
+
end
|
|
64
|
+
|
|
47
65
|
# Create a credential to satisfy the given challenge.
|
|
48
66
|
#
|
|
49
67
|
# mode: :pull (default) — return signed transaction for server to broadcast
|
|
@@ -67,8 +85,7 @@ module Mpp
|
|
|
67
85
|
nonce_key = nonce_key.start_with?("0x") ? nonce_key.to_i(16) : nonce_key.to_i
|
|
68
86
|
end
|
|
69
87
|
|
|
70
|
-
memo =
|
|
71
|
-
memo ||= Attribution.encode(server_id: challenge.realm, client_id: @client_id, challenge_id: challenge.id)
|
|
88
|
+
memo = Attribution.encode(server_id: challenge.realm, client_id: @client_id, challenge_id: challenge.id)
|
|
72
89
|
|
|
73
90
|
# Resolve RPC URL from challenge's chainId. Normalize the configured pin
|
|
74
91
|
# once (it may be a String from ENV/config) so it compares equal to
|
|
@@ -249,31 +266,60 @@ module Mpp
|
|
|
249
266
|
end
|
|
250
267
|
|
|
251
268
|
# Factory function to create a configured TempoMethod.
|
|
252
|
-
|
|
269
|
+
#
|
|
270
|
+
# A server offers one charge per accepted currency, in order. Without
|
|
271
|
+
# `currencies:` it offers OUSD then USDC.e on mainnet and OUSD then
|
|
272
|
+
# pathUSD on Moderato; other chains keep their single default.
|
|
273
|
+
# `currencies:` replaces the defaults. The deprecated `currency:` accepts
|
|
274
|
+
# exactly that token. `currency` keeps its previous default for clients.
|
|
275
|
+
#
|
|
276
|
+
# A local fee payer pays gas in `fee_token:` when set, else in the first
|
|
277
|
+
# allowed fee token it holds, independent of the charge currency.
|
|
278
|
+
def self.tempo(intents:, account: nil, fee_payer: nil, chain_id: Defaults::CHAIN_ID, rpc_url: nil,
|
|
253
279
|
root_account: nil, currency: nil, recipient: nil, decimals: 6, client_id: nil,
|
|
254
|
-
expected_recipients: nil, fee_payer_allowed_fee_tokens: nil, relay: nil
|
|
280
|
+
expected_recipients: nil, fee_payer_allowed_fee_tokens: nil, relay: nil,
|
|
281
|
+
on_payment_success: nil, can_offer: nil, currencies: nil, fee_token: nil)
|
|
255
282
|
rpc_url ||= chain_id ? Defaults.rpc_url_for_chain(chain_id) : Defaults::RPC_URL
|
|
256
|
-
currency ||= Defaults.default_currency_for_chain(chain_id)
|
|
257
283
|
|
|
258
284
|
if fee_payer == true
|
|
259
285
|
raise ArgumentError, "fee_payer: true requires account:" unless account
|
|
260
286
|
|
|
261
287
|
fee_payer = account
|
|
262
288
|
end
|
|
289
|
+
fee_payer = FeePayerClient.resolve_optional(fee_payer)
|
|
290
|
+
if fee_token && (fee_payer.nil? || FeePayerClient.hosted_config?(fee_payer))
|
|
291
|
+
raise ArgumentError, "fee_token can only be configured for a local fee payer"
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
if fee_token && !Defaults::ADDRESS_PATTERN.match?(fee_token)
|
|
295
|
+
raise ArgumentError, "Invalid Tempo fee token address: #{fee_token.inspect}"
|
|
296
|
+
end
|
|
297
|
+
if fee_payer_allowed_fee_tokens&.empty?
|
|
298
|
+
raise ArgumentError, "fee_payer_allowed_fee_tokens must contain at least one token"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
legacy_currency = Defaults.default_currency_for_chain(chain_id)
|
|
302
|
+
primary_currency = currency || currencies&.first || legacy_currency
|
|
303
|
+
# `currency:` is deprecated in favor of `currencies: [currency]`.
|
|
304
|
+
currencies = Defaults.accepted_currencies(chain_id: chain_id, currency: currency, currencies: currencies)
|
|
263
305
|
|
|
264
306
|
method = TempoMethod.new(
|
|
265
307
|
account: account,
|
|
266
|
-
fee_payer:
|
|
308
|
+
fee_payer: fee_payer,
|
|
267
309
|
rpc_url: rpc_url,
|
|
268
310
|
chain_id: chain_id,
|
|
269
311
|
root_account: root_account,
|
|
270
|
-
currency:
|
|
312
|
+
currency: primary_currency,
|
|
313
|
+
currencies: currencies,
|
|
271
314
|
recipient: recipient,
|
|
272
315
|
decimals: decimals,
|
|
273
316
|
client_id: client_id,
|
|
274
317
|
expected_recipients: expected_recipients,
|
|
275
318
|
fee_payer_allowed_fee_tokens: fee_payer_allowed_fee_tokens,
|
|
276
|
-
relay: Relay.resolve_optional(relay)
|
|
319
|
+
relay: Relay.resolve_optional(relay),
|
|
320
|
+
on_payment_success: on_payment_success,
|
|
321
|
+
can_offer: can_offer,
|
|
322
|
+
fee_token: fee_token
|
|
277
323
|
)
|
|
278
324
|
|
|
279
325
|
intents.each_value do |intent|
|