diff --git a/.env b/.env new file mode 100644 index 0000000..843558d --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +STRIPE_TEST_SECRET_KEY=sk_test_eFvAvN5rz4GqAbsWxg63Jx79 +STRIPE_TEST_OAUTH_ACCESS_TOKEN=sk_test_WnZmEBIHhMcDltNe98sqWN7z \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c053d63 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +html/ +pkg/ +.DS_Store +stripe-mock-server.pid +Gemfile.lock +stripe-mock-server.log +.idea +.ruby-version diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..660778b --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--colour --format documentation diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..e209a0c --- /dev/null +++ b/.travis.yml @@ -0,0 +1,25 @@ +sudo: required +language: ruby +rvm: + - 2.4.6 + - 2.5.5 + - 2.6.3 + - 2.7.0 +before_install: + - gem install bundler -v '< 2' +before_script: + - "sudo touch /var/log/stripe-mock-server.log" + - "sudo chown travis /var/log/stripe-mock-server.log" +script: "bundle exec rspec && bundle exec rspec -t live" + +env: + global: + - IS_TRAVIS=true STRIPE_TEST_SECRET_KEY_A=sk_test_BsztzqQjzd7lqkgo1LjEG5DF00KzH7tWKF STRIPE_TEST_SECRET_KEY_B=sk_test_rKCEu0x8jzg6cKPqoey8kUPQ00usQO3KYE STRIPE_TEST_SECRET_KEY_C=sk_test_qeaB7R6Ywp8sC9pzd1ZIABH700YLC7nhmZ STRIPE_TEST_SECRET_KEY_D=sk_test_r1NwHkUW7UyoozyP4aEBD6cs00CI5uDiGq + +notifications: + webhooks: + urls: + - https://webhooks.gitter.im/e/44a1f4718ae2efb67eac + on_success: change # options: [always|never|change] default: always + on_failure: always # options: [always|never|change] default: always + on_start: false # default: false diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f76a14f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,47 @@ +### Unreleased + +- [#806](https://github.com/stripe-ruby-mock/stripe-ruby-mock/pull/806) - Remove `payment_method_types` from required arguments for `Stripe::Checkout::Session` +- [#806](https://github.com/stripe-ruby-mock/stripe-ruby-mock/pull/806) - Raise more helpful exception when Stripe::Price cannot be found within a `Stripe::Checkout::Session` `line_items` argument. + + +### 3.1.0.rc3 (pre-release 2021-07-14) + +- [#785](https://github.com/stripe-ruby-mock/stripe-ruby-mock/pull/785): `Stripe::Product` no longer requires `type`. [@TastyPi](https://github.com/TastyPi) +- [#784](https://github.com/stripe-ruby-mock/stripe-ruby-mock/pull/784): Fix "Wrong number of arguments" error in tests. [@TastyPi](https://github.com/TastyPi) +- [#782](https://github.com/stripe-ruby-mock/stripe-ruby-mock/pull/782): Support expanding `setup_intent` in `Stripe::Checkout::Session`. [@TastyPi](https://github.com/TastyPi) + +### 3.1.0.rc2 (pre-release 2021-03-03) + +- [#767](https://github.com/stripe-ruby-mock/stripe-ruby-mock/pull/767): Fixes tests and more [@lpsBetty](https://github.com/lpsBetty) + +### 3.1.0.rc1 (pre-release 2021-02-17) + +- [#765](https://github.com/stripe-ruby-mock/stripe-ruby-mock/pull/765): Properly set the status of a trialing subscription. [@csalvato](https://github.com/csalvato) +- [#764](https://github.com/stripe-ruby-mock/stripe-ruby-mock/pull/764): Fixes erroneous error message when fetching upcoming invoices. [@csalvato](https://github.com/csalvato) +- [#762](https://github.com/stripe-ruby-mock/stripe-ruby-mock/pull/762): Support Stripe Connect with Customers by adding stripe_account header namespace for customer object [@csalvato](https://github.com/csalvato) +- [#755](https://github.com/stripe-ruby-mock/stripe-ruby-mock/pull/755): Add allowed params to subscriptions [@dominikdarnel ](https://github.com/dominikdarnel) +- [#748](https://github.com/stripe-ruby-mock/stripe-ruby-mock/pull/758): Support Prices - [@hidenba](https://github.com/hidenba) and [@jamesprior](https://github.com/jamesprior). +- [#747](https://github.com/stripe-ruby-mock/stripe-ruby-mock/pull/747/files): Fix ruby 2.7 deprecation warnings. Adds Ruby 3.0.0 compatibility. [@coding-chimp](https://github.com/coding-chimp) +- [#715](https://github.com/stripe-ruby-mock/stripe-ruby-mock/pull/715): Added application_fee_amount to mock charge object - [@espen](https://github.com/espen) +- [#709](https://github.com/stripe-ruby-mock/stripe-ruby-mock/pull/709): Remove unnecessary check on customer's currency - [@coorasse](https://github.com/coorasse) + +### 3.0.1 (TBD) + +- Added Changelog file +- [#640](https://github.com/stripe-ruby-mock/stripe-ruby-mock/pull/640): Support Payment Intent status requires_capture - [@theodorton](https://github.com/theodorton). +- [#685](https://github.com/stripe-ruby-mock/stripe-ruby-mock/pull/685): Adds support for pending_invoice_item_interval - [@joshcass](https://github.com/joshcass). +- [#682](https://github.com/stripe-ruby-mock/stripe-ruby-mock/pull/682): Prevent customer metadata from being overwritten with each update - [@sethkrasnianski](https://github.com/sethkrasnianski). +- [#679](https://github.com/stripe-ruby-mock/stripe-ruby-mock/pull/679): Fix for [#678](https://github.com/stripe-ruby-mock/stripe-ruby-mock/issues/678) Add active filter to Data::List - [@rnmp](https://github.com/rnmp). +- [#668](https://github.com/stripe-ruby-mock/stripe-ruby-mock/pull/668): Fix for [#665](https://github.com/stripe-ruby-mock/stripe-ruby-mock/issues/665) Allow to remove discount from customer - [@mnin](https://github.com/mnin). +- [#667](https://github.com/stripe-ruby-mock/stripe-ruby-mock/pull/667): + Remove empty and duplicated methods from payment methods - [@mnin](https://github.com/mnin). +- [#664](https://github.com/stripe-ruby-mock/stripe-ruby-mock/pull/664): Bugfix: pass through PaymentIntent amount to mocked Charge - [@typeoneerror](https://github.com/typeoneerror). +- [#654](https://github.com/stripe-ruby-mock/stripe-ruby-mock/pull/654): fix for [#626](https://github.com/stripe-ruby-mock/stripe-ruby-mock/issues/626) Added missing decline codes - [@iCreateJB](https://github.com/iCreateJB). +- [#648](https://github.com/stripe-ruby-mock/stripe-ruby-mock/pull/648): Initial implementation of checkout session API - [@fauxparse](https://github.com/fauxparse). +- [#644](https://github.com/stripe-ruby-mock/stripe-ruby-mock/pull/644): Allow payment_behavior attribute on subscription create - [@j15e](https://github.com/j15e). + +### 3.0.0 (2019-12-17) + +##### the main thing is: + +- [#658](https://github.com/stripe-ruby-mock/stripe-ruby-mock/pull/658) Make the gem compatible with Stripe Gem v.5 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..8cc610a --- /dev/null +++ b/Gemfile @@ -0,0 +1,13 @@ +source 'https://rubygems.org' + +platforms :ruby_19 do + gem 'mime-types', '~> 2.6' + gem 'rest-client', '~> 1.8' +end + +group :test do + gem 'rake' + gem 'dotenv' +end + +gemspec diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..1d13106 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,22 @@ +(The MIT License) + +Copyright (c) 2013 Gilbert + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..144d19b --- /dev/null +++ b/Rakefile @@ -0,0 +1,14 @@ +# encoding: utf-8 + +require 'rubygems' +require 'rake' + +begin + gem 'rubygems-tasks', '~> 0.2' + require 'rubygems/tasks' + + Gem::Tasks.new +rescue LoadError => e + warn e.message + warn "Run `gem install rubygems-tasks` to install Gem::Tasks." +end diff --git a/bin/stripe-mock-server b/bin/stripe-mock-server new file mode 100755 index 0000000..11b6769 --- /dev/null +++ b/bin/stripe-mock-server @@ -0,0 +1,19 @@ +#!/usr/bin/env ruby + +lib = File.expand_path(File.dirname(__FILE__) + '/../lib') +$LOAD_PATH.unshift(lib) if File.directory?(lib) && !$LOAD_PATH.include?(lib) + +require 'trollop' + +opts = Trollop::options do + opt :port, "Listening port", :type => :int, :default => 4999 + opt :host, "Host to listen on", :type => :string, :default => '0.0.0.0' + opt :server, "Server to use", :type => :string, :default => 'thin' + opt :debug, "Request and response output", :default => true + opt :pid_path, "Location to put server pid file", :type => :string, :default => './stripe-mock-server.pid' +end + +require 'stripe_mock' +require 'stripe_mock/server' + +StripeMock::Server.start_new(opts) diff --git a/lib/stripe_mock.rb b/lib/stripe_mock.rb new file mode 100644 index 0000000..543ae1b --- /dev/null +++ b/lib/stripe_mock.rb @@ -0,0 +1,102 @@ +require 'ostruct' +require 'multi_json' +require 'dante' +require 'time' + +require 'stripe' + +require 'stripe_mock/version' +require 'stripe_mock/util' +require 'stripe_mock/error_queue' + +require 'stripe_mock/data' +require 'stripe_mock/data/list' + +require 'stripe_mock/errors/stripe_mock_error' +require 'stripe_mock/errors/unsupported_request_error' +require 'stripe_mock/errors/uninitialized_instance_error' +require 'stripe_mock/errors/unstarted_state_error' +require 'stripe_mock/errors/server_timeout_error' +require 'stripe_mock/errors/closed_client_connection_error' + +require 'stripe_mock/client' +require 'stripe_mock/server' + +require 'stripe_mock/api/instance' +require 'stripe_mock/api/client' +require 'stripe_mock/api/server' + +require 'stripe_mock/api/bank_tokens' +require 'stripe_mock/api/account_balance' +require 'stripe_mock/api/conversion_rate' +require 'stripe_mock/api/card_tokens' +require 'stripe_mock/api/debug' +require 'stripe_mock/api/errors' +require 'stripe_mock/api/global_id_prefix' +require 'stripe_mock/api/live' +require 'stripe_mock/api/test_helpers' +require 'stripe_mock/api/webhooks' + +require 'stripe_mock/request_handlers/helpers/bank_account_helpers.rb' +require 'stripe_mock/request_handlers/helpers/external_account_helpers.rb' +require 'stripe_mock/request_handlers/helpers/card_helpers.rb' +require 'stripe_mock/request_handlers/helpers/charge_helpers.rb' +require 'stripe_mock/request_handlers/helpers/coupon_helpers.rb' +require 'stripe_mock/request_handlers/helpers/subscription_helpers.rb' +require 'stripe_mock/request_handlers/helpers/token_helpers.rb' + +require 'stripe_mock/request_handlers/validators/param_validators.rb' + +require 'stripe_mock/request_handlers/account_links.rb' +require 'stripe_mock/request_handlers/express_login_links.rb' +require 'stripe_mock/request_handlers/accounts.rb' +require 'stripe_mock/request_handlers/external_accounts.rb' +require 'stripe_mock/request_handlers/balance.rb' +require 'stripe_mock/request_handlers/balance_transactions.rb' +require 'stripe_mock/request_handlers/charges.rb' +require 'stripe_mock/request_handlers/cards.rb' +require 'stripe_mock/request_handlers/sources.rb' +require 'stripe_mock/request_handlers/customers.rb' +require 'stripe_mock/request_handlers/coupons.rb' +require 'stripe_mock/request_handlers/disputes.rb' +require 'stripe_mock/request_handlers/events.rb' +require 'stripe_mock/request_handlers/invoices.rb' +require 'stripe_mock/request_handlers/invoice_items.rb' +require 'stripe_mock/request_handlers/orders.rb' +require 'stripe_mock/request_handlers/plans.rb' +require 'stripe_mock/request_handlers/prices.rb' +require 'stripe_mock/request_handlers/recipients.rb' +require 'stripe_mock/request_handlers/refunds.rb' +require 'stripe_mock/request_handlers/transfers.rb' +require 'stripe_mock/request_handlers/payment_intents.rb' +require 'stripe_mock/request_handlers/payment_methods.rb' +require 'stripe_mock/request_handlers/setup_intents.rb' +require 'stripe_mock/request_handlers/payouts.rb' +require 'stripe_mock/request_handlers/subscriptions.rb' +require 'stripe_mock/request_handlers/subscription_items.rb' +require 'stripe_mock/request_handlers/tokens.rb' +require 'stripe_mock/request_handlers/country_spec.rb' +require 'stripe_mock/request_handlers/ephemeral_key.rb' +require 'stripe_mock/request_handlers/products.rb' +require 'stripe_mock/request_handlers/tax_rates.rb' +require 'stripe_mock/request_handlers/checkout_session.rb' +require 'stripe_mock/instance' + +require 'stripe_mock/test_strategies/base.rb' +require 'stripe_mock/test_strategies/mock.rb' +require 'stripe_mock/test_strategies/live.rb' + +module StripeMock + + @default_currency = 'usd' + @checkout_base = "https://checkout.stripe.com/pay/" + lib_dir = File.expand_path(File.dirname(__FILE__), '../..') + @webhook_fixture_path = './spec/fixtures/stripe_webhooks/' + @webhook_fixture_fallback_path = File.join(lib_dir, 'stripe_mock/webhook_fixtures') + + class << self + attr_accessor :default_currency + attr_accessor :checkout_base + attr_accessor :webhook_fixture_path + end +end diff --git a/lib/stripe_mock/api/account_balance.rb b/lib/stripe_mock/api/account_balance.rb new file mode 100644 index 0000000..d09ddaa --- /dev/null +++ b/lib/stripe_mock/api/account_balance.rb @@ -0,0 +1,14 @@ +module StripeMock + + def self.set_account_balance(value) + case @state + when 'local' + instance.account_balance = value + when 'remote' + client.set_account_balance(value) + else + raise UnstartedStateError + end + end + +end diff --git a/lib/stripe_mock/api/bank_tokens.rb b/lib/stripe_mock/api/bank_tokens.rb new file mode 100644 index 0000000..00f2b0f --- /dev/null +++ b/lib/stripe_mock/api/bank_tokens.rb @@ -0,0 +1,13 @@ +module StripeMock + + def self.generate_bank_token(bank_params = {}) + case @state + when 'local' + instance.generate_bank_token(bank_params) + when 'remote' + client.generate_bank_token(bank_params) + else + raise UnstartedStateError + end + end +end diff --git a/lib/stripe_mock/api/card_tokens.rb b/lib/stripe_mock/api/card_tokens.rb new file mode 100644 index 0000000..6a842dd --- /dev/null +++ b/lib/stripe_mock/api/card_tokens.rb @@ -0,0 +1,13 @@ +module StripeMock + + def self.generate_card_token(card_params = {}) + case @state + when 'local' + instance.generate_card_token(card_params) + when 'remote' + client.generate_card_token(card_params) + else + raise UnstartedStateError + end + end +end diff --git a/lib/stripe_mock/api/client.rb b/lib/stripe_mock/api/client.rb new file mode 100644 index 0000000..08a14f6 --- /dev/null +++ b/lib/stripe_mock/api/client.rb @@ -0,0 +1,41 @@ +module StripeMock + + def self.client + @client + end + + def self.start_client(port=4999) + return false if @state == 'live' + return @client unless @client.nil? + + Stripe::StripeClient.send(:define_method, :execute_request) { |*args, **keyword_args| StripeMock.redirect_to_mock_server(*args, **keyword_args) } + @client = StripeMock::Client.new(port) + @state = 'remote' + @client + end + + def self.stop_client(opts={}) + return false unless @state == 'remote' + @state = 'ready' + + restore_stripe_execute_request_method + @client.clear_server_data if opts[:clear_server_data] == true + @client.cleanup + @client = nil + true + end + + private + + def self.redirect_to_mock_server(method, url, api_key: nil, api_base: nil, params: {}, headers: {}) + handler = Instance.handler_for_method_url("#{method} #{url}") + + if mock_error = client.error_queue.error_for_handler_name(handler[:name]) + client.error_queue.dequeue + raise mock_error + end + + Stripe::Util.symbolize_names client.mock_request(method, url, api_key: api_key, params: params, headers: headers) + end + +end diff --git a/lib/stripe_mock/api/conversion_rate.rb b/lib/stripe_mock/api/conversion_rate.rb new file mode 100644 index 0000000..aeb5409 --- /dev/null +++ b/lib/stripe_mock/api/conversion_rate.rb @@ -0,0 +1,14 @@ +module StripeMock + + def self.set_conversion_rate(value) + case @state + when 'local' + instance.conversion_rate = value + when 'remote' + client.set_conversion_rate(value) + else + raise UnstartedStateError + end + end + +end diff --git a/lib/stripe_mock/api/debug.rb b/lib/stripe_mock/api/debug.rb new file mode 100644 index 0000000..184e934 --- /dev/null +++ b/lib/stripe_mock/api/debug.rb @@ -0,0 +1,11 @@ +module StripeMock + + def self.toggle_debug(toggle) + if @state == 'local' + @instance.debug = toggle + elsif @state == 'remote' + @client.set_server_debug(toggle) + end + end + +end diff --git a/lib/stripe_mock/api/errors.rb b/lib/stripe_mock/api/errors.rb new file mode 100644 index 0000000..97f987b --- /dev/null +++ b/lib/stripe_mock/api/errors.rb @@ -0,0 +1,71 @@ +module StripeMock + def self.prepare_error(stripe_error, *handler_names) + handler_names.push(:all) if handler_names.count == 0 + + if @state == 'local' + instance + elsif @state == 'remote' + client + else + raise UnstartedStateError + end.error_queue.queue stripe_error, handler_names + end + + def self.prepare_card_error(code, *handler_names) + handler_names.push(:new_charge) if handler_names.count == 0 + + error = CardErrors.build_error_for(code) + if error.nil? + raise StripeMockError, "Unrecognized stripe card error code: #{code}" + end + + prepare_error error, *handler_names + end + + module CardErrors + def self.build_error_for(code) + case code + when :incorrect_number then build_card_error('The card number is incorrect', 'number', code: 'incorrect_number', http_status: 402) + when :invalid_number then build_card_error('The card number is not a valid credit card number', 'number', code: 'invalid_number', http_status: 402) + when :invalid_expiry_month then build_card_error("The card's expiration month is invalid", 'exp_month', code: 'invalid_expiry_month', http_status: 402) + when :invalid_expiry_year then build_card_error("The card's expiration year is invalid", 'exp_year', code: 'invalid_expiry_year', http_status: 402) + when :invalid_cvc then build_card_error("The card's security code is invalid", 'cvc', code: 'invalid_cvc', http_status: 402) + when :expired_card then build_card_error('The card has expired', 'exp_month', code: 'expired_card', http_status: 402) + when :incorrect_cvc then build_card_error("The card's security code is incorrect", 'cvc', code: 'incorrect_cvc', http_status: 402) + when :card_declined then build_card_error('The card was declined', nil, code: 'card_declined', http_status: 402) + when :missing then build_card_error('There is no card on a customer that is being charged.', nil, code: 'missing', http_status: 402) + when :processing_error then build_card_error('An error occurred while processing the card', nil, code: 'processing_error', http_status: 402) + when :card_error then build_card_error('The card number is not a valid credit card number.', 'number', code: 'invalid_number', http_status: 402) + when :incorrect_zip then build_card_error('The zip code you supplied failed validation.', 'address_zip', code: 'incorrect_zip', http_status: 402) + when :insufficient_funds then build_card_error('The card has insufficient funds to complete the purchase.', nil, code: 'insufficient_funds', http_status: 402) + when :lost_card then build_card_error('The payment has been declined because the card is reported lost.', nil, code: 'lost_card', http_status: 402) + when :stolen_card then build_card_error('The payment has been declined because the card is reported stolen.', nil, code: 'stolen_card', http_status: 402) + end + end + + def self.get_decline_code(code) + decline_code_map = { + card_declined: 'do_not_honor', + missing: nil + } + decline_code_map.default = code.to_s + + code_key = code.to_sym + decline_code_map[code_key] + end + + def self.build_card_error(message, param, **kwargs) + json_hash = { + message: message, + param: param, + code: kwargs[:code], + type: 'card_error', + decline_code: get_decline_code(kwargs[:code]) + } + + error_keyword_args = kwargs.merge(json_body: { error: json_hash }, http_body: { error: json_hash }.to_json) + + Stripe::CardError.new(message, param, **error_keyword_args) + end + end +end diff --git a/lib/stripe_mock/api/global_id_prefix.rb b/lib/stripe_mock/api/global_id_prefix.rb new file mode 100644 index 0000000..dcaf3fe --- /dev/null +++ b/lib/stripe_mock/api/global_id_prefix.rb @@ -0,0 +1,22 @@ +module StripeMock + + def self.global_id_prefix + if StripeMock.client + StripeMock.client.server_global_id_prefix + else + case @global_id_prefix + when false then "" + when nil then "test_" + else @global_id_prefix + end + end + end + + def self.global_id_prefix=(value) + if StripeMock.client + StripeMock.client.set_server_global_id_prefix(value) + else + @global_id_prefix = value + end + end +end diff --git a/lib/stripe_mock/api/instance.rb b/lib/stripe_mock/api/instance.rb new file mode 100644 index 0000000..65ed2c7 --- /dev/null +++ b/lib/stripe_mock/api/instance.rb @@ -0,0 +1,38 @@ +module StripeMock + + @state = 'ready' + @instance = nil + @original_execute_request_method = Stripe::StripeClient.instance_method(:execute_request) + + def self.start + return false if @state == 'live' + @instance = instance = Instance.new + Stripe::StripeClient.send(:define_method, :execute_request) { |*args, **keyword_args| instance.mock_request(*args, **keyword_args) } + @state = 'local' + end + + def self.stop + return unless @state == 'local' + restore_stripe_execute_request_method + @instance = nil + @state = 'ready' + end + + # Yield the given block between StripeMock.start and StripeMock.stop + def self.mock(&block) + begin + self.start + yield + ensure + self.stop + end + end + + def self.restore_stripe_execute_request_method + Stripe::StripeClient.send(:define_method, :execute_request, @original_execute_request_method) + end + + def self.instance; @instance; end + def self.state; @state; end + +end diff --git a/lib/stripe_mock/api/live.rb b/lib/stripe_mock/api/live.rb new file mode 100644 index 0000000..a913b97 --- /dev/null +++ b/lib/stripe_mock/api/live.rb @@ -0,0 +1,15 @@ +module StripeMock + + def self.toggle_live(toggle) + if @state != 'ready' && @state != 'live' + raise "You cannot toggle StripeMock live when it has already started." + end + if toggle + @state = 'live' + StripeMock.set_default_test_helper_strategy(:live) + else + @state = 'ready' + StripeMock.set_default_test_helper_strategy(:mock) + end + end +end diff --git a/lib/stripe_mock/api/server.rb b/lib/stripe_mock/api/server.rb new file mode 100644 index 0000000..5fbfdc9 --- /dev/null +++ b/lib/stripe_mock/api/server.rb @@ -0,0 +1,39 @@ +module StripeMock + @default_server_pid_path = './stripe-mock-server.pid' + @default_server_log_path = './stripe-mock-server.log' + + class << self + attr_writer :default_server_pid_path, :default_server_log_path + + ["pid", "log"].each do |config_type| + define_method("default_server_#{config_type}_path") do + instance_variable_get("@default_server_#{config_type}_path") || "./stripe-mock-server.#{config_type}" + end + end + + def spawn_server(opts={}) + pid_path = opts[:pid_path] || default_server_pid_path + log_path = opts[:log_path] || default_server_log_path + + Dante::Runner.new('stripe-mock-server').execute( + :daemonize => true, :pid_path => pid_path, :log_path => log_path + ){ + StripeMock::Server.start_new(opts) + } + at_exit { + begin + e = $! # last exception + kill_server(pid_path) + ensure + raise e if $! != e + end + } + end + + def kill_server(pid_path=nil) + puts "Killing server at #{pid_path}" + path = pid_path || default_server_pid_path + Dante::Runner.new('stripe-mock-server').execute(:kill => true, :pid_path => path) + end + end +end diff --git a/lib/stripe_mock/api/test_helpers.rb b/lib/stripe_mock/api/test_helpers.rb new file mode 100644 index 0000000..5720065 --- /dev/null +++ b/lib/stripe_mock/api/test_helpers.rb @@ -0,0 +1,24 @@ +module StripeMock + + def self.create_test_helper(strategy=nil) + if strategy + get_test_helper_strategy(strategy).new + elsif @__test_strat + @__test_strat.new + else + TestStrategies::Mock.new + end + end + + def self.set_default_test_helper_strategy(strategy) + @__test_strat = get_test_helper_strategy(strategy) + end + + def self.get_test_helper_strategy(strategy) + case strategy.to_sym + when :mock then TestStrategies::Mock + when :live then TestStrategies::Live + else raise "Invalid test helper strategy: #{strategy.inspect}" + end + end +end diff --git a/lib/stripe_mock/api/webhooks.rb b/lib/stripe_mock/api/webhooks.rb new file mode 100644 index 0000000..0b92a4b --- /dev/null +++ b/lib/stripe_mock/api/webhooks.rb @@ -0,0 +1,99 @@ +module StripeMock + + def self.mock_webhook_payload(type, params = {}) + + fixture_file = File.join(@webhook_fixture_path, "#{type}.json") + + unless File.exist?(fixture_file) + unless Webhooks.event_list.include?(type) + raise UnsupportedRequestError.new "Unsupported webhook event `#{type}` (Searched in #{@webhook_fixture_path})" + end + fixture_file = File.join(@webhook_fixture_fallback_path, "#{type}.json") + end + + json = MultiJson.load File.read(fixture_file) + + json = Stripe::Util.symbolize_names(json) + params = Stripe::Util.symbolize_names(params) + json[:account] = params.delete(:account) if params.key?(:account) + json[:data][:object] = Util.rmerge(json[:data][:object], params) + json.delete(:id) + json[:created] = params[:created] || Time.now.to_i + + if @state == 'local' + event_data = instance.generate_webhook_event(json) + elsif @state == 'remote' + event_data = client.generate_webhook_event(json) + else + raise UnstartedStateError + end + event_data + end + + def self.mock_webhook_event(type, params={}) + Stripe::Event.construct_from(mock_webhook_payload(type, params)) + end + + module Webhooks + def self.event_list + @__list = [ + 'account.updated', + 'account.application.deauthorized', + 'account.external_account.created', + 'account.external_account.updated', + 'account.external_account.deleted', + 'balance.available', + 'charge.succeeded', + 'charge.updated', + 'charge.failed', + 'charge.refunded', + 'charge.dispute.created', + 'charge.dispute.updated', + 'charge.dispute.closed', + 'charge.dispute.funds_reinstated', + 'charge.dispute.funds_withdrawn', + 'checkout.session.completed.payment_mode', + 'checkout.session.completed.setup_mode', + 'customer.source.created', + 'customer.source.deleted', + 'customer.source.updated', + 'customer.created', + 'customer.updated', + 'customer.deleted', + 'customer.subscription.created', + 'customer.subscription.updated', + 'customer.subscription.deleted', + 'customer.subscription.trial_will_end', + 'customer.discount.created', + 'customer.discount.updated', + 'customer.discount.deleted', + 'invoice.created', + 'invoice.updated', + 'invoice.payment_succeeded', + 'invoice.payment_failed', + 'invoice.upcoming', + 'invoiceitem.created', + 'invoiceitem.updated', + 'invoiceitem.deleted', + 'mandate.updated', + 'payment_intent.canceled', + 'payment_intent.processing', + 'payment_intent.succeeded', + 'payment_intent.payment_failed', + 'plan.created', + 'plan.updated', + 'plan.deleted', + 'product.created', + 'product.updated', + 'product.deleted', + 'coupon.created', + 'coupon.deleted', + 'transfer.created', + 'transfer.paid', + 'transfer.updated', + 'transfer.failed' + ] + end + end + +end diff --git a/lib/stripe_mock/client.rb b/lib/stripe_mock/client.rb new file mode 100644 index 0000000..615fcdd --- /dev/null +++ b/lib/stripe_mock/client.rb @@ -0,0 +1,128 @@ +module StripeMock + class Client + attr_reader :port, :state + + def initialize(port) + @port = port + + DRb.start_service + @pipe = DRbObject.new_with_uri "druby://localhost:#{port}" + + # Ensure client can connect to server + timeout_wrap(5) { @pipe.ping } + @state = 'ready' + end + + def mock_request(method, url, api_key: nil, params: {}, headers: {}) + timeout_wrap do + @pipe.mock_request(method, url, api_key: api_key, params: params, headers: headers).tap {|result| + response, api_key = result + if response.is_a?(Hash) && response[:error_raised] == 'invalid_request' + args, keyword_args = response[:error_params].first(2), response[:error_params].last + raise Stripe::InvalidRequestError.new(*args, **keyword_args) + end + } + end + end + + def get_server_data(key) + timeout_wrap { + # Massage the data make this behave the same as the local StripeMock.start + result = {} + @pipe.get_data(key).each {|k,v| result[k] = Stripe::Util.symbolize_names(v) } + result + } + end + + def error_queue + timeout_wrap { @pipe.error_queue } + end + + def set_server_debug(toggle) + timeout_wrap { @pipe.set_debug(toggle) } + end + + def server_debug? + timeout_wrap { @pipe.debug? } + end + + def set_server_global_id_prefix(value) + timeout_wrap { @pipe.set_global_id_prefix(value) } + end + + def server_global_id_prefix + timeout_wrap { @pipe.global_id_prefix } + end + + def generate_bank_token(recipient_params) + timeout_wrap { @pipe.generate_bank_token(recipient_params) } + end + + def generate_card_token(card_params) + timeout_wrap { @pipe.generate_card_token(card_params) } + end + + def generate_webhook_event(event_data) + timeout_wrap { Stripe::Util.symbolize_names @pipe.generate_webhook_event(event_data) } + end + + def get_conversion_rate + timeout_wrap { @pipe.get_data(:conversion_rate) } + end + + def set_conversion_rate(value) + timeout_wrap { @pipe.set_conversion_rate(value) } + end + + def set_account_balance(value) + timeout_wrap { @pipe.set_account_balance(value) } + end + + def destroy_resource(type, id) + timeout_wrap { @pipe.destroy_resource(type, id) } + end + + def clear_server_data + timeout_wrap { @pipe.clear_data } + end + + def upsert_stripe_object(object, attributes) + timeout_wrap { @pipe.upsert_stripe_object(object, attributes) } + end + + def close! + self.cleanup + StripeMock.stop_client(:clear_server_data => false) + end + + def cleanup + return if @state == 'closed' + set_server_debug(false) + @state = 'closed' + end + + def timeout_wrap(tries=1) + original_tries = tries + begin + raise ClosedClientConnectionError if @state == 'closed' + yield + rescue ClosedClientConnectionError + raise + rescue Errno::ECONNREFUSED, DRb::DRbConnError => e + tries -= 1 + if tries > 0 + if tries == original_tries - 1 + print "Waiting for StripeMock Server.." + else + print '.' + end + sleep 1 + retry + else + raise StripeMock::ServerTimeoutError.new(e) + end + end + end + end + +end diff --git a/lib/stripe_mock/data.rb b/lib/stripe_mock/data.rb new file mode 100644 index 0000000..f3153f9 --- /dev/null +++ b/lib/stripe_mock/data.rb @@ -0,0 +1,1388 @@ +module StripeMock + module Data + + def self.mock_account(params = {}) + id = params[:id] || 'acct_103ED82ePvKYlo2C' + currency = params[:currency] || StripeMock.default_currency + { + id: id, + email: "bob@example.com", + statement_descriptor: nil, + display_name: "Stripe.com", + timezone: "US/Pacific", + details_submitted: false, + charges_enabled: false, + payouts_enabled: false, + currencies_supported: [ + "usd" + ], + default_currency: currency, + country: "US", + object: "account", + business_name: "Stripe.com", + business_url: nil, + support_phone: nil, + managed: false, + product_description: nil, + debit_negative_balances: true, + bank_accounts: { + object: "list", + total_count: 0, + has_more: false, + url: "/v1/accounts/#{id}/bank_accounts", + data: [ + + ] + }, + verification: { + fields_needed: [], + due_by: nil, + contacted: false + }, + transfer_schedule: { + delay_days: 7, + interval: "daily" + }, + tos_acceptance: { + ip: nil, + date: nil, + user_agent: nil + }, + external_accounts: { + object: "list", + data: [ + + ], + has_more: false, + total_count: 0, + url: "/v1/accounts/#{id}/external_accounts" + }, + legal_entity: { + type: nil, + business_name: nil, + address: { + line1: nil, + line2: nil, + city: nil, + state: nil, + postal_code: nil, + country: "US" + }, + first_name: nil, + last_name: nil, + personal_address: { + line1: nil, + line2: nil, + city: nil, + state: nil, + postal_code: nil, + country: nil + }, + dob: { + day: nil, + month: nil, + year: nil + }, + additional_owners: nil, + verification: { + status: "unverified", + document: nil, + details: nil + } + }, + decline_charge_on: { + cvc_failure: false, + avs_failure: false + }, + keys: { + secret: "sk_test_AmJhMTLPtY9JL4c6EG0", + publishable: "pk_test_2rSaMTLPtY9JL449dsf" + } + }.merge(params) + end + + def self.mock_account_link(params = {}) + now = Time.now.to_i + { + object: 'account_link', + created: now, + expires_at: now + 300, + url: 'https://connect.stripe.com/setup/c/iB0ph1cPnRLY', + data: {} + }.merge(params) + end + + def self.mock_express_login_link(params = {}) + now = Time.now.to_i + { + object: 'login_link', + created: now, + url: 'https://connect.stripe.com/express/Ln7FfnNpUcCU', + data: {} + }.merge(params) + end + + def self.mock_tax_rate(params) + { + id: 'test_cus_default', + object: 'tax_rate', + active: true, + created: 1559079603, + description: nil, + display_name: 'VAT', + inclusive: false, + jurisdiction: 'EU', + livemode: false, + metadata: {}, + percentage: 21.0 + }.merge(params) + end + + def self.mock_customer(sources, params) + cus_id = params[:id] || "test_cus_default" + currency = params[:currency] + sources.each {|source| source[:customer] = cus_id} + { + email: 'stripe_mock@example.com', + description: 'an auto-generated stripe customer data mock', + object: "customer", + created: 1372126710, + id: cus_id, + name: nil, + preferred_locales: [], + livemode: false, + delinquent: false, + discount: nil, + account_balance: 0, + currency: currency, + invoice_settings: { + default_payment_method: nil, + custom_fields: nil, + footer: nil + }, + sources: { + object: "list", + total_count: sources.size, + has_more: false, + url: "/v1/customers/#{cus_id}/sources", + data: sources + }, + subscriptions: { + object: "list", + total_count: 0, + has_more: false, + url: "/v1/customers/#{cus_id}/subscriptions", + data: [] + }, + default_source: nil + }.merge(params) + end + + def self.mock_charge(params={}) + charge_id = params[:id] || "ch_1fD6uiR9FAA2zc" + currency = params[:currency] || StripeMock.default_currency + { + id: charge_id, + object: "charge", + created: 1366194027, + livemode: false, + paid: true, + amount: 0, + application_fee: nil, + application_fee_amount: nil, + currency: currency, + destination: nil, + fraud_details: {}, + payment_method_details: { + card: { + brand: "visa", + checks: { + address_line1_check: nil, + address_postal_code_check: nil, + cvc_check: "pass" + }, + country: "US", + exp_month: 12, + exp_year: 2013, + fingerprint: "3TQGpK9JoY1GgXPw", + funding: "credit", + installments: nil, + last4: "4242", + network: "visa", + three_d_secure: nil, + wallet: nil + }, + type: "card" + }, + receipt_email: nil, + receipt_number: nil, + refunded: false, + shipping: {}, + statement_descriptor: "Charge #{charge_id}", + status: 'succeeded', + source: { + object: "card", + last4: "4242", + type: "Visa", + brand: "Visa", + funding: "credit", + exp_month: 12, + exp_year: 2013, + fingerprint: "3TQGpK9JoY1GgXPw", + country: "US", + name: "name", + address_line1: nil, + address_line2: nil, + address_city: nil, + address_state: nil, + address_zip: nil, + address_country: nil, + cvc_check: nil, + address_line1_check: nil, + address_zip_check: nil + }, + captured: params.has_key?(:capture) ? params.delete(:capture) : true, + refunds: { + object: "list", + total_count: 0, + has_more: false, + url: "/v1/charges/#{charge_id}/refunds", + data: [] + }, + transfer: nil, + balance_transaction: params[:balance_transaction] || "txn_2dyYXXP90MN26R", + failure_message: nil, + failure_code: nil, + amount_refunded: 0, + customer: nil, + invoice: nil, + description: nil, + dispute: nil, + metadata: { + } + }.merge(params) + end + + def self.mock_refund(params={}) + currency = params[:currency] || StripeMock.default_currency + { + id: "re_4fWhgUh5si7InF", + amount: 1, + currency: currency, + created: 1409165988, + object: "refund", + balance_transaction: "txn_4fWh2RKvgxcXqV", + metadata: {}, + charge: "ch_4fWhYjzQ23UFWT", + receipt_number: nil, + status: "succeeded", + reason: "requested_by_customer" + }.merge(params) + end + + def self.mock_charge_array + { + :data => [test_charge, test_charge, test_charge], + :object => 'list', + :url => '/v1/charges' + } + end + + def self.mock_card(params={}) + StripeMock::Util.card_merge({ + id: "test_cc_default", + object: "card", + last4: "4242", + type: "Visa", + brand: "Visa", + funding: "credit", + exp_month: 4, + exp_year: 2016, + fingerprint: "wXWJT135mEK107G8", + customer: "test_cus_default", + country: "US", + name: "Johnny App", + address_line1: nil, + address_line2: nil, + address_city: nil, + address_state: nil, + address_zip: nil, + address_country: nil, + cvc_check: nil, + address_line1_check: nil, + address_zip_check: nil, + tokenization_method: nil, + metadata: {} + }, params) + end + + def self.mock_bank_account(params={}) + currency = params[:currency] || StripeMock.default_currency + { + id: "test_ba_default", + object: "bank_account", + bank_name: "STRIPEMOCK TEST BANK", + last4: "6789", + routing_number: '110000000', + country: "US", + currency: currency, + validated: false, + status: 'new', + account_holder_name: 'John Doe', + account_holder_type: 'individual', + fingerprint: "aBcFinGerPrINt123", + metadata: {} + }.merge(params) + end + + def self.mock_coupon(params={}) + { + :duration_in_months => 3, + :percent_off => 25, + :amount_off => nil, + :currency => nil, + :id => "co_test_coupon", + :object => "coupon", + :max_redemptions => nil, + :redeem_by => nil, + :times_redeemed => 0, + :valid => true, + :metadata => {}, + }.merge(params) + end + + #FIXME nested overrides would be better than hardcoding plan_id + def self.mock_subscription(params={}) + StripeMock::Util.rmerge({ + created: 1478204116, + billing: 'charge_automatically', + current_period_start: 1308595038, + current_period_end: 1308681468, + status: 'trialing', + trial_from_plan: false, + plan: { + interval: 'month', + amount: 7500, + trial_period_days: 30, + object: 'plan', + id: '__test_plan_id__' + }, + items: { + object: 'list', + data: [{ + id: 'si_1AwFf62eZvKYlo2C9u6Dhf9', + created: 1504035973, + metadata: {}, + object: 'subscription_item', + plan: { + amount: 999, + created: 1504035972, + currency: StripeMock.default_currency + }, + quantity: 1 + }] + }, + cancel_at_period_end: false, + canceled_at: nil, + collection_method: 'charge_automatically', + ended_at: nil, + start_date: 1308595038, + object: 'subscription', + trial_start: 1308595038, + trial_end: 1308681468, + customer: 'c_test_customer', + quantity: 1, + tax_percent: nil, + discount: nil, + metadata: {}, + default_tax_rates: nil, + default_payment_method: nil, + pending_invoice_item_interval: nil, + next_pending_invoice_item_invoice: nil, + latest_invoice: nil + }, params) + end + + def self.mock_invoice(lines, params={}) + in_id = params[:id] || "test_in_default" + currency = params[:currency] || StripeMock.default_currency + lines << Data.mock_line_item() if lines.empty? + invoice = { + id: 'in_test_invoice', + status: 'open', + invoice_pdf: 'pdf_url', + hosted_invoice_url: 'hosted_invoice_url', + created: 1349738950, + period_end: 1349738950, + period_start: 1349738950, + due_date: nil, + lines: { + object: "list", + total_count: lines.count, + has_more: false, + url: "/v1/invoices/#{in_id}/lines", + data: lines + }, + subtotal: lines.map {|line| line[:amount]}.reduce(0, :+), + customer: "test_customer", + object: 'invoice', + attempted: false, + application_fee: nil, + closed: false, + description: nil, + forgiven: false, + metadata: {}, + paid: false, + receipt_number: nil, + statement_descriptor: nil, + tax: 10, + tax_percent: nil, + webhooks_delivered_at: 1349825350, + livemode: false, + attempt_count: 0, + amount_due: 100, + amount_paid: 0, + currency: currency, + starting_balance: 0, + ending_balance: 0, + next_payment_attempt: 1349825350, + charge: nil, + discount: nil, + subscription: nil + }.merge(params) + if invoice[:discount] + invoice[:total] = [0, invoice[:subtotal] - invoice[:discount][:coupon][:amount_off]].max if invoice[:discount][:coupon][:amount_off] + invoice[:total] = invoice[:subtotal] * invoice[:discount][:coupon][:percent_off] / 100 if invoice[:discount][:coupon][:percent_off] + else + invoice[:total] = invoice[:subtotal] + end + due = invoice[:total] + invoice[:starting_balance] + invoice[:amount_due] = due < 0 ? 0 : due + invoice[:ending_balance] = invoice[:starting_balance] + invoice[:total] if invoice[:amount_due] == 0 + invoice + end + + def self.mock_line_item(params = {}) + currency = params[:currency] || StripeMock.default_currency + { + id: "ii_test", + object: "line_item", + type: "invoiceitem", + livemode: false, + amount: 1000, + currency: currency, + discountable: false, + proration: false, + period: { + start: 1349738920, + end: 1349738920 + }, + tax_amounts: [ + { + amount: 10 + } + ], + quantity: nil, + subscription: nil, + plan: nil, + description: "Test invoice item", + metadata: {} + }.merge(params) + end + + def self.mock_invoice_item(params = {}) + currency = params[:currency] || StripeMock.default_currency + { + id: "test_ii", + object: "invoiceitem", + created: 1349738920, + amount: 1099, + livemode: false, + proration: false, + currency: currency, + customer: "cus_test", + description: "invoice item desc", + metadata: {}, + invoice: nil, + subscription: nil + }.merge(params) + end + + def self.mock_paid_invoice + test_invoice.merge({ + :attempt_count => 1, + :attempted => true, + :closed => true, + :paid => true, + :charge => 'ch_test_charge', + :ending_balance => 0, + :next_payment_attempt => nil, + }) + end + + def self.mock_invoice_customer_array + { + :data => [test_invoice], + :object => 'list', + :url => '/v1/invoices?customer=test_customer' + } + end + + def self.mock_order(order_items, params) + or_id = params[:id] || "test_or_default" + currency = params[:currency] || 'eur' + order_items << Data.mock_order_item if order_items.empty? + { + id: or_id, + object: "order", + amount: 5000, + application: nil, + application_fee: nil, + charge: nil, + created: 1448272783, + currency: currency, + customer: nil, + email: nil, + items: order_items, + livemode: false, + metadata: {}, + selected_shipping_method: nil, + shipping: { + address: { + city: "Anytown", + country: "US", + line1: "1234 Main street", + line2: nil, + postal_code: "123456", + state: nil + }, + name: "Jenny Rosen", + phone: nil + }, + shipping_methods: nil, + status: "created", + updated: 1448272783 + }.merge(params) + end + + def self.mock_order_item(params={}) + currency = params[:currency] || 'eur' + { + object: "order_item", + amount: 5000, + currency: currency, + description: "Anyitem", + parent: "sku_parent", + quantity: 1, + type: "sku" + }.merge(params) + end + + def self.mock_plan(params={}) + currency = params[:currency] || StripeMock.default_currency + { + id: "mock_plan_123", + object: "plan", + active: true, + aggregate_usage: nil, + amount: 2300, + billing_scheme: "per_unit", + created: 1466698898, + currency: currency, + interval: "month", + interval_count: 1, + livemode: false, + metadata: {}, + nickname: "My Mock Plan", + product: "mock_prod_NONEXIST", # override this with your own existing product id + tiers: nil, + tiers_mode: nil, + transform_usage: nil, + trial_period_days: nil, + usage_type: "licensed" + }.merge(params) + end + + def self.mock_price(params={}) + currency = params[:currency] || StripeMock.default_currency + { + id: "mock_price_123", + object: "price", + active: true, + billing_scheme: "per_unit", + created: 1593044959, + currency: currency, + livemode: false, + lookup_key: nil, + metadata: {}, + nickname: 'My Mock Price', + product: "mock_prod_NONEXIST", # override this with your own existing product id + recurring: { + aggregate_usage: nil, + interval: "month", + interval_count: 1, + usage_type: "licensed" + }, + tiers_mode: nil, + transform_quantity: nil, + type: "recurring", + unit_amount: 2000, + unit_amount_decimal: "2000" + }.merge(params) + end + + def self.mock_product(params={}) + { + id: "mock_prod_abc123", + object: "product", + active: true, + attributes:[], + caption: nil, + created: 1466698000, + deactivate_on: [], + description: nil, + images: [], + livemode: false, + metadata: {}, + name: "The Mock Product", + package_dimensions: nil, + shippable: nil, + statement_descriptor: nil, + type: "service", + unit_label: "my_unit", + updated: 1537939442, + url: nil + }.merge(params) + end + + def self.mock_recipient(cards, params={}) + rp_id = params[:id] || "test_rp_default" + cards.each {|card| card[:recipient] = rp_id} + { + name: "Stripe User", + type: "individual", + livemode: false, + object: "recipient", + id: rp_id, + active_account: { + last4: "6789", + bank_name: "STRIPE TEST BANK", + country: "US", + object: "bank_account" + }, + created: 1304114758, + verified: true, + metadata: { + }, + cards: { + object: "list", + url: "/v1/recipients/#{rp_id}/cards", + data: cards, + has_more: false, + total_count: cards.count + }, + default_card: nil + }.merge(params) + end + + def self.mock_recipient_array + { + :data => [test_recipient, test_recipient, test_recipient], + :object => 'list', + :url => '/v1/recipients' + } + end + + def self.mock_card_token(params={}) + { + :id => 'tok_default', + :livemode => false, + :used => false, + :object => 'token', + :type => 'card', + :card => { + :id => 'card_default', + :object => 'card', + :last4 => '2222', + :type => 'Visa', + :brand => 'Visa', + :funding => 'credit', + :exp_month => 9, + :exp_year => 2017, + :fingerprint => 'JRRLXGh38NiYygM7', + :customer => nil, + :country => 'US', + :name => nil, + :address_line1 => nil, + :address_line2 => nil, + :address_city => nil, + :address_state => nil, + :address_zip => nil, + :address_country => nil + } + }.merge(params) + end + + def self.mock_bank_account_token(params={}) + { + :id => 'tok_default', + :livemode => false, + :used => false, + :object => 'token', + :type => 'bank_account', + :bank_account => { + :id => 'bank_account_default', + :object => 'bank_account', + :last4 => '2222', + :fingerprint => 'JRRLXGh38NiYygM7', + } + }.merge(params) + end + + def self.mock_transfer(params={}) + currency = params[:currency] || StripeMock.default_currency + id = params[:id] || 'tr_test_transfer' + { + :amount => 100, + :amount_reversed => 0, + :balance_transaction => "txn_2dyYXXP90MN26R", + :id => id, + :livemode => false, + :metadata => {}, + :currency => currency, + :object => "transfer", + :created => 1304114826, + :description => "Transfer description", + :reversed => false, + :reversals => { + :object => "list", + :data => [], + :total_count => 0, + :has_more => false, + :url => "/v1/transfers/#{id}/reversals" + }, + :destination => "acct_164wxjKbnvuxQXGu", + :destination_payment => "py_164xRvKbnvuxQXGuVFV2pZo1", + :source_transaction => "ch_164xRv2eZvKYlo2Clu1sIJWB", + :source_type => "card", + :transfer_group => "group_ch_164xRv2eZvKYlo2Clu1sIJWB", + }.merge(params) + end + + def self.mock_payout(params={}) + currency = params[:currency] || StripeMock.default_currency + id = params[:id] || 'po_test_payout' + { + :amount => 100, + :id => id, + :livemode => false, + :metadata => {}, + :currency => currency, + :object => "payout", + :date => 1304114826, + :description => "Payout description", + }.merge(params) + end + + def self.mock_disputes(ids=[]) + disputes = {} + ids.each do |id| + disputes[id] = self.mock_dispute(id: id) + end + disputes + end + + def self.mock_dispute(params={}) + @timestamp ||= Time.now.to_i + currency = params[:currency] || StripeMock.default_currency + id = params[:id] || "dp_test_dispute" + { + :id => id, + :object => "dispute", + :amount => 195, + :balance_transactions => [], + :charge => "ch_15RsQR2eZvKYlo2CA8IfzCX0", + :created => @timestamp += 1, + :currency => currency, + :evidence => self.mock_dispute_evidence, + :evidence_details => self.mock_dispute_evidence_details, + :is_charge_refundable => false, + :livemode => false, + :metadata => {}, + :reason => "general", + :status => "under_review" + }.merge(params) + end + + def self.mock_dispute_evidence + { + :access_activity_log => nil, + :billing_address => nil, + :cancellation_policy => nil, + :cancellation_policy_disclosure => nil, + :cancellation_rebuttal => nil, + :customer_communication => nil, + :customer_email_address => nil, + :customer_name => nil, + :customer_purchase_ip => nil, + :customer_signature => nil, + :duplicate_charge_documentation => nil, + :duplicate_charge_explanation => nil, + :duplicate_charge_id => nil, + :product_description => nil, + :receipt => nil, + :refund_policy => nil, + :refund_policy_disclosure => nil, + :refund_refusal_explanation => nil, + :service_date => nil, + :service_documentation => nil, + :shipping_address => nil, + :shipping_carrier => nil, + :shipping_date => nil, + :shipping_documentation => nil, + :shipping_tracking_number => nil, + :uncategorized_file => nil, + :uncategorized_text => nil + } + end + + def self.mock_dispute_evidence_details + { + :due_by => 1424303999, + :has_evidence => false, + :past_due => false, + :submission_count => 0 + } + end + + def self.mock_transfer_array + { + :data => [test_transfer, test_transfer, test_transfer], + :object => 'list', + :url => '/v1/transfers' + } + end + + def self.mock_invalid_api_key_error + { + "error" => { + "type" => "invalid_request_error", + "message" => "Invalid API Key provided: invalid" + } + } + end + + def self.mock_invalid_exp_year_error + { + "error" => { + "code" => "invalid_expiry_year", + "param" => "exp_year", + "type" => "card_error", + "message" => "Your card's expiration year is invalid" + } + } + end + + def self.mock_missing_id_error + { + :error => { + :param => "id", + :type => "invalid_request_error", + :message => "Missing id" + } + } + end + + def self.mock_delete_subscription(params={}) + { + deleted: true + }.merge(params) + end + + def self.mock_api_error + { + :error => { + :type => "api_error" + } + } + end + + def self.mock_delete_discount_response + { + :deleted => true, + :id => "di_test_coupon" + } + end + + def self.mock_list_object(data, params={}) + list = StripeMock::Data::List.new(data, params) + list.to_h + end + + def self.mock_country_spec(country_code) + id = country_code || "US" + { + "id"=> "US", + "object"=> "country_spec", + "default_currency"=> "usd", + "supported_bank_account_currencies"=> {"usd"=>["US"]}, + "supported_payment_currencies"=> [ + "usd", + "aed", + "afn", + "all", + "amd", + "ang", + "aoa", + "ars", + "aud", + "awg", + "azn", + "bam", + "bbd", + "bdt", + "bgn", + "bif", + "bmd", + "bnd", + "bob", + "brl", + "bsd", + "bwp", + "bzd", + "cad", + "cdf", + "chf", + "clp", + "cny", + "cop", + "crc", + "cve", + "czk", + "djf", + "dkk", + "dop", + "dzd", + "egp", + "etb", + "eur", + "fjd", + "fkp", + "gbp", + "gel", + "gip", + "gmd", + "gnf", + "gtq", + "gyd", + "hkd", + "hnl", + "hrk", + "htg", + "huf", + "idr", + "ils", + "inr", + "isk", + "jmd", + "jpy", + "kes", + "kgs", + "khr", + "kmf", + "krw", + "kyd", + "kzt", + "lak", + "lbp", + "lkr", + "lrd", + "lsl", + "ltl", + "mad", + "mdl", + "mga", + "mkd", + "mnt", + "mop", + "mro", + "mur", + "mvr", + "mwk", + "mxn", + "myr", + "mzn", + "nad", + "ngn", + "nio", + "nok", + "npr", + "nzd", + "pab", + "pen", + "pgk", + "php", + "pkr", + "pln", + "pyg", + "qar", + "ron", + "rsd", + "rub", + "rwf", + "sar", + "sbd", + "scr", + "sek", + "sgd", + "shp", + "sll", + "sos", + "srd", + "std", + "svc", + "szl", + "thb", + "tjs", + "top", + "try", + "ttd", + "twd", + "tzs", + "uah", + "ugx", + "uyu", + "uzs", + "vnd", + "vuv", + "wst", + "xaf", + "xcd", + "xof", + "xpf", + "yer", + "zar", + "zmw" + ], + "supported_payment_methods"=> [ + "alipay", + "card", + "stripe" + ], + "verification_fields"=> {"individual"=>{"minimum"=>["external_account","legal_entity.address.city","legal_entity.address.line1","legal_entity.address.postal_code","legal_entity.address.state","legal_entity.dob.day","legal_entity.dob.month","legal_entity.dob.year","legal_entity.first_name","legal_entity.last_name","legal_entity.personal_id_number","legal_entity.ssn_last_4","legal_entity.type","tos_acceptance.date","tos_acceptance.ip"],"additional"=>["legal_entity.personal_id_number","legal_entity.verification.document"]},"company"=>{"minimum"=>["external_account","legal_entity.address.city","legal_entity.address.line1","legal_entity.address.postal_code","legal_entity.address.state","legal_entity.business_name","legal_entity.business_tax_id","legal_entity.dob.day","legal_entity.dob.month","legal_entity.dob.year","legal_entity.first_name","legal_entity.last_name","legal_entity.ssn_last_4","legal_entity.type","tos_acceptance.date","tos_acceptance.ip"],"additional"=>["legal_entity.personal_id_number","legal_entity.verification.document"]}} + } + end + + def self.mock_balance(usd_balance = 10000) + { + object: "balance", + available: [ + { + currency: "usd", + amount: usd_balance, + source_types: { + card: 25907032203, + bank_account: 108476658, + bitcoin_receiver: 1545182 + } + }], + instant_available: [ + { + currency: "usd", + amount: usd_balance, + source_types: { + card: 25907032203, + bank_account: 108476658, + bitcoin_receiver: 1545182 + } + }], + connect_reserved: [ + { + currency: "usd", + amount: 4700 + }], + livemode: false, + pending: [ + { + currency: "usd", + amount: 22738833554, + source_types: { + card: 22738826610, + bank_account: 0, + bitcoin_receiver: 6944 + } + }] + } + end + + def self.mock_balance_transactions(ids=[]) + bts = {} + ids.each do |id| + bts[id] = self.mock_balance_transaction(id: id) + end + bts + end + + def self.mock_balance_transaction(params = {}) + currency = params[:currency] || StripeMock.default_currency + bt_id = params[:id] || 'test_txn_default' + source = params[:source] || 'ch_test_charge' + { + id: bt_id, + object: "balance_transaction", + amount: 10000, + available_on: 1462406400, + created: 1461880226, + currency: currency, + description: nil, + fee: 320, + fee_details: [ + { + amount: 320, + application: nil, + currency: currency, + description: "Stripe processing fees", + type: "stripe_fee" + } + ], + net: 9680, + source: source, + sourced_transfers: { + object: "list", + data: [], + has_more: false, + total_count: 0, + url: "/v1/transfers?source_transaction=#{source}" + }, + status: "pending", + type: "charge" + }.merge(params) + end + + def self.mock_subscription_item(params = {}) + id = params[:id] || 'test_si_default' + { + id: id, + object: 'subscription_item', + created: 1504716183, + metadata: {}, + plan: { + id: 'PER_USER_PLAN1', + object: 'plan', + amount: 1337, + created: 1504716177, + currency: StripeMock.default_currency, + interval: 'month', + interval_count: 1, + livemode: false, + metadata: {}, + name: 'StripeMock Default Plan ID', + statement_descriptor: nil, + trial_period_days: nil + }, + quantity: 2 + }.merge(params) + end + + def self.mock_ephemeral_key(**params) + created = Time.now.to_i + expires = created + 34_000 + { + id: "ephkey_default", + object: "ephemeral_key", + associated_objects: [ + { + id: params[:customer], + type: "customer" + } + ], + created: created, + expires: expires, + livemode: false, + secret: "ek_test_default" + } + end + + def self.mock_payment_intent(params = {}) + payment_intent_id = params[:id] || "pi_1EwXFB2eZvKYlo2CggNnFBo8" + amount = params[:amount] || 49900 + currency = params[:currency] || StripeMock.default_currency + { + id: payment_intent_id, + object: "payment_intent", + amount: amount, + amount_capturable: 0, + amount_received: 0, + application: nil, + application_fee_amount: nil, + canceled_at: nil, + cancellation_reason: nil, + capture_method: "automatic", + charges: { + object: "list", + data: [], + has_more: false, + total_count: 1, + url: "/v1/charges?payment_intent=pi_1EwXFB2eZvKYlo2CggNnFBo8" + }, + client_secret: "pi_1EwXFB2eZvKYlo2CggNnFBo8_secret_vOMkpqZu8ca7hxhfiO80tpT3v", + confirmation_method: "manual", + created: 1563208901, + currency: currency, + customer: nil, + description: nil, + invoice: nil, + last_payment_error: nil, + livemode: false, + metadata: {}, + next_action: { type: "use_stripe_sdk" }, + on_behalf_of: nil, + payment_method: nil, + payment_method_types: [ + "card" + ], + receipt_email: nil, + review: nil, + setup_future_usage: nil, + shipping: nil, + source: nil, + statement_descriptor: nil, + status: "requires_action", + transfer_data: nil, + transfer_group: nil + }.merge(params) + end + + def self.mock_payment_method(params = {}) + payment_method_id = params[:id] || 'pm_1ExEuFL2DI6wht39WNJgbybl' + + type = params[:type].to_sym + data = { + card: { + brand: case params.dig(:card, :number)&.to_s + when /^4/, nil + 'visa' + when /^5[1-5]/ + 'mastercard' + else + 'unknown' + end, + checks: { + address_line1_check: nil, + address_postal_code_check: nil, + cvc_check: 'pass' + }, + country: 'FR', + exp_month: params.dig(:card, :exp_month) || 2, + exp_year: params.dig(:card, :exp_year) || 2022, + fingerprint: 'Hr3Ly5z5IYxsokWA', + funding: 'credit', + generated_from: nil, + last4: params.dig(:card, :number)&.[](-4..) || '3155', + three_d_secure_usage: { supported: true }, + wallet: nil + }, + ideal: { + bank: 'ing', + bic: 'INGBNL2A', + iban_last4: '****', + verified_name: 'JENNY ROSEN' + }, + sepa_debit: { + bank_code: '37040044', + branch_code: '', + country: 'DE', + fingerprint: 'FD81kbVPe7M05BMj', + last4: params.dig(:sepa_debit, :iban)&.[](-4..) || '3000' + } + } + + { + id: payment_method_id, + object: 'payment_method', + type: params[:type], + billing_details: { + address: { + city: 'New Orleans', + country: 'US', + line1: 'Bourbon Street 23', + line2: nil, + postal_code: '10000', + state: nil + }, + email: 'foo@bar.com', + name: 'John Dolton', + phone: nil + }, + customer: params[:customer] || nil, + metadata: { + order_id: '123456789' + } + }.merge(params).merge(type => data[type]) + end + + def self.mock_setup_intent(params = {}) + setup_intent_id = params[:id] || "seti_1F96eK2aLAadsDqo0AVIyPmC" + { + :id => setup_intent_id, + :object => "setup_intent", + :application => nil, + :cancellation_reason => nil, + :client_secret => "seti_1F96eK2aLAadsDqo0AVIyPmC_secret_FePTYgOoPFxDOUL53fFMSoTAyiXsWAV", + :created => 1566204936, + :customer => nil, + :description => nil, + :last_setup_error => nil, + :livemode => false, + :metadata => {}, + :next_action => nil, + :on_behalf_of => nil, + :payment_method => nil, + :payment_method_options => { + card: {request_three_d_secure: "automatic"} + }, + :payment_method_types => ["card"], + :status => "requires_payment_method", + :usage => "off_session" + }.merge(params) + end + + def self.mock_checkout_session(params = {}) + cs_id = params[:id] || "test_cs_default" + currency = params[:currency] || StripeMock.default_currency + { + id: cs_id, + object: 'checkout.session', + billing_address_collection: nil, + cancel_url: 'https://example.com/cancel', + client_reference_id: nil, + customer: nil, + customer_email: nil, + display_items: [ + { + amount: 1500, + currency: currency, + custom: { + description: 'Comfortable cotton t-shirt', + images: nil, + name: 'T-shirt' + }, + quantity: 2, + type: 'custom' + } + ], + livemode: false, + locale: nil, + mode: nil, + payment_intent: mock_payment_intent[:id], + payment_method_types: [ + 'card' + ], + setup_intent: nil, + submit_type: nil, + subscription: nil, + success_url: 'https://example.com/success' + }.merge(params) + end + end +end diff --git a/lib/stripe_mock/data/list.rb b/lib/stripe_mock/data/list.rb new file mode 100644 index 0000000..a3f10e9 --- /dev/null +++ b/lib/stripe_mock/data/list.rb @@ -0,0 +1,106 @@ +module StripeMock + module Data + class List + attr_reader :data, :limit, :offset, :starting_after, :ending_before, :active + + def initialize(data, options = {}) + @data = Array(data.clone) + @limit = [[options[:limit] || 10, 100].min, 1].max # restrict @limit to 1..100 + @starting_after = options[:starting_after] + @ending_before = options[:ending_before] + @active = options[:active] + if contains_stripe_objects? + prune_deleted_data + sort_data + end + end + + def url + "/v1/#{object_types}" + end + + def to_hash + { object: "list", data: data_page, url: url, has_more: has_more? } + end + alias_method :to_h, :to_hash + + def has_more? + (offset + limit) < data.size + end + + def method_missing(method_name, *args, &block) + hash = to_hash + + if hash.keys.include?(method_name) + hash[method_name] + else + super + end + end + + def respond_to?(method_name, priv = false) + to_hash.keys.include?(method_name) || super + end + + private + + def offset + case + when starting_after + index = data.index { |datum| datum[:id] == starting_after } + (index || raise("No such object id: #{starting_after}")) + 1 + when ending_before + index = data.index { |datum| datum[:id] == ending_before } + (index || raise("No such object id: #{ending_before}")) - 1 + else + 0 + end + end + + def data_page + filtered_data[offset, limit] + end + + def filtered_data + filtered_data = data + filtered_data = filtered_data.select { |d| d[:active] == active } unless active.nil? + + filtered_data + end + + def object_types + if first_object = data[0] + "#{first_object.class.to_s.split('::')[-1].downcase}s" + end + end + + def contains_stripe_objects? + return false if data.empty? + + object = data.first + object.is_a?(Stripe::StripeObject) || ( + object.is_a?(Hash) && [:created, :deleted].any? { |k| object.key?(k) } + ) + end + + def prune_deleted_data + data.reject! do |object| + (object.is_a?(Hash) && object[:deleted]) || + (object.is_a?(Stripe::StripeObject) && object.deleted?) + end + end + + def sort_data + # Reverse must follow sort to preserve existing test dependencies. The + # alternative would be to simply reverse lhs and rhs in the comparison, + # however, being a stable sort this breaks the existing dependency when + # more than one record share the same `created` value. + @data = data.sort { |lhs, rhs| sort_val(lhs) <=> sort_val(rhs) }.reverse + end + + def sort_val(object) + object.is_a?(Stripe::StripeObject) ? object.created : object[:created] + end + end + end +end diff --git a/lib/stripe_mock/error_queue.rb b/lib/stripe_mock/error_queue.rb new file mode 100644 index 0000000..e57fa19 --- /dev/null +++ b/lib/stripe_mock/error_queue.rb @@ -0,0 +1,27 @@ +require 'drb/drb' + +module StripeMock + class ErrorQueue + include DRb::DRbUndumped + extend DRb::DRbUndumped + + def initialize + @queue = [] + end + + def queue(error, handler_names) + @queue << handler_names.map {|n| [n, error]} + end + + def error_for_handler_name(handler_name) + return nil if @queue.count == 0 + triggers = @queue.first + (triggers.assoc(:all) || triggers.assoc(handler_name) || [])[1] + end + + def dequeue + @queue.shift + end + + end +end diff --git a/lib/stripe_mock/errors/closed_client_connection_error.rb b/lib/stripe_mock/errors/closed_client_connection_error.rb new file mode 100644 index 0000000..a2e15c8 --- /dev/null +++ b/lib/stripe_mock/errors/closed_client_connection_error.rb @@ -0,0 +1,9 @@ +module StripeMock + class ClosedClientConnectionError < StripeMockError + + def initialize + super("This StripeMock client has already been closed.") + end + + end +end diff --git a/lib/stripe_mock/errors/server_timeout_error.rb b/lib/stripe_mock/errors/server_timeout_error.rb new file mode 100644 index 0000000..d527aaa --- /dev/null +++ b/lib/stripe_mock/errors/server_timeout_error.rb @@ -0,0 +1,12 @@ +module StripeMock + class ServerTimeoutError < StripeMockError + + attr_reader :associated_error + + def initialize(associated_error) + @associated_error = associated_error + super("Unable to connect to stripe mock server (did you forget to run `$ stripe-mock-server`?)") + end + + end +end diff --git a/lib/stripe_mock/errors/stripe_mock_error.rb b/lib/stripe_mock/errors/stripe_mock_error.rb new file mode 100644 index 0000000..25b6ced --- /dev/null +++ b/lib/stripe_mock/errors/stripe_mock_error.rb @@ -0,0 +1,15 @@ +module StripeMock + class StripeMockError < StandardError + + attr_reader :message + + def initialize(message) + @message = message + end + + def to_s + @message + end + + end +end diff --git a/lib/stripe_mock/errors/uninitialized_instance_error.rb b/lib/stripe_mock/errors/uninitialized_instance_error.rb new file mode 100644 index 0000000..87ba8aa --- /dev/null +++ b/lib/stripe_mock/errors/uninitialized_instance_error.rb @@ -0,0 +1,9 @@ +module StripeMock + class UninitializedInstanceError < StripeMockError + + def initialize + super("StripeMock instance is nil (did you forget to call `StripeMock.start`?)") + end + + end +end diff --git a/lib/stripe_mock/errors/unstarted_state_error.rb b/lib/stripe_mock/errors/unstarted_state_error.rb new file mode 100644 index 0000000..d0e28e1 --- /dev/null +++ b/lib/stripe_mock/errors/unstarted_state_error.rb @@ -0,0 +1,9 @@ +module StripeMock + class UnstartedStateError < StripeMockError + + def initialize + super("StripeMock has not been started. Please call StripeMock.start or StripeMock.start_client") + end + + end +end diff --git a/lib/stripe_mock/errors/unsupported_request_error.rb b/lib/stripe_mock/errors/unsupported_request_error.rb new file mode 100644 index 0000000..0115d53 --- /dev/null +++ b/lib/stripe_mock/errors/unsupported_request_error.rb @@ -0,0 +1,4 @@ +module StripeMock + class UnsupportedRequestError < StripeMockError + end +end diff --git a/lib/stripe_mock/instance.rb b/lib/stripe_mock/instance.rb new file mode 100644 index 0000000..b1ad87d --- /dev/null +++ b/lib/stripe_mock/instance.rb @@ -0,0 +1,248 @@ +module StripeMock + class Instance + + include StripeMock::RequestHandlers::Helpers + include StripeMock::RequestHandlers::ParamValidators + + DUMMY_API_KEY = (0...32).map { (65 + rand(26)).chr }.join.downcase + + # Handlers are ordered by priority + @@handlers = [] + + def self.add_handler(route, name) + @@handlers << { + :route => %r{^#{route}$}, + :name => name + } + end + + def self.handler_for_method_url(method_url) + @@handlers.find {|h| method_url =~ h[:route] } + end + + include StripeMock::RequestHandlers::PaymentIntents + include StripeMock::RequestHandlers::PaymentMethods + include StripeMock::RequestHandlers::SetupIntents + include StripeMock::RequestHandlers::ExternalAccounts + include StripeMock::RequestHandlers::AccountLinks + include StripeMock::RequestHandlers::ExpressLoginLinks + include StripeMock::RequestHandlers::Accounts + include StripeMock::RequestHandlers::Balance + include StripeMock::RequestHandlers::BalanceTransactions + include StripeMock::RequestHandlers::Charges + include StripeMock::RequestHandlers::Cards + include StripeMock::RequestHandlers::Sources + include StripeMock::RequestHandlers::Subscriptions # must be before Customers + include StripeMock::RequestHandlers::SubscriptionItems + include StripeMock::RequestHandlers::Customers + include StripeMock::RequestHandlers::Coupons + include StripeMock::RequestHandlers::Disputes + include StripeMock::RequestHandlers::Events + include StripeMock::RequestHandlers::Invoices + include StripeMock::RequestHandlers::InvoiceItems + include StripeMock::RequestHandlers::Orders + include StripeMock::RequestHandlers::Plans + include StripeMock::RequestHandlers::Prices + include StripeMock::RequestHandlers::Products + include StripeMock::RequestHandlers::Refunds + include StripeMock::RequestHandlers::Recipients + include StripeMock::RequestHandlers::Transfers + include StripeMock::RequestHandlers::Tokens + include StripeMock::RequestHandlers::CountrySpec + include StripeMock::RequestHandlers::Payouts + include StripeMock::RequestHandlers::EphemeralKey + include StripeMock::RequestHandlers::TaxRates + include StripeMock::RequestHandlers::Checkout + include StripeMock::RequestHandlers::Checkout::Session + + attr_reader :accounts, :balance, :balance_transactions, :bank_tokens, :charges, :coupons, :customers, + :disputes, :events, :invoices, :invoice_items, :orders, :payment_intents, :payment_methods, + :setup_intents, :plans, :prices, :recipients, :refunds, :transfers, :payouts, :subscriptions, :country_spec, + :subscriptions_items, :products, :tax_rates, :checkout_sessions, :checkout_session_line_items + + attr_accessor :error_queue, :debug, :conversion_rate, :account_balance + + def initialize + @accounts = {} + @balance = Data.mock_balance + @balance_transactions = Data.mock_balance_transactions(['txn_05RsQX2eZvKYlo2C0FRTGSSA','txn_15RsQX2eZvKYlo2C0ERTYUIA', 'txn_25RsQX2eZvKYlo2C0ZXCVBNM', 'txn_35RsQX2eZvKYlo2C0QAZXSWE', 'txn_45RsQX2eZvKYlo2C0EDCVFRT', 'txn_55RsQX2eZvKYlo2C0OIKLJUY', 'txn_65RsQX2eZvKYlo2C0ASDFGHJ', 'txn_75RsQX2eZvKYlo2C0EDCXSWQ', 'txn_85RsQX2eZvKYlo2C0UJMCDET', 'txn_95RsQX2eZvKYlo2C0EDFRYUI']) + @bank_tokens = {} + @card_tokens = {} + @customers = { Stripe.api_key => {} } + @charges = {} + @payment_intents = {} + @payment_methods = {} + @setup_intents = {} + @coupons = {} + @disputes = Data.mock_disputes(['dp_05RsQX2eZvKYlo2C0FRTGSSA','dp_15RsQX2eZvKYlo2C0ERTYUIA', 'dp_25RsQX2eZvKYlo2C0ZXCVBNM', 'dp_35RsQX2eZvKYlo2C0QAZXSWE', 'dp_45RsQX2eZvKYlo2C0EDCVFRT', 'dp_55RsQX2eZvKYlo2C0OIKLJUY', 'dp_65RsQX2eZvKYlo2C0ASDFGHJ', 'dp_75RsQX2eZvKYlo2C0EDCXSWQ', 'dp_85RsQX2eZvKYlo2C0UJMCDET', 'dp_95RsQX2eZvKYlo2C0EDFRYUI']) + @events = {} + @invoices = {} + @invoice_items = {} + @orders = {} + @payment_methods = {} + @plans = {} + @prices = {} + @products = {} + @recipients = {} + @refunds = {} + @transfers = {} + @payouts = {} + @subscriptions = {} + @subscriptions_items = {} + @country_spec = {} + @tax_rates = {} + @checkout_sessions = {} + @checkout_session_line_items = {} + + @debug = false + @error_queue = ErrorQueue.new + @id_counter = 0 + @balance_transaction_counter = 0 + @dispute_counter = 0 + @conversion_rate = 1.0 + @account_balance = 10000 + + # This is basically a cache for ParamValidators + @base_strategy = TestStrategies::Base.new + end + + def mock_request(method, url, api_key: nil, api_base: nil, params: {}, headers: {}) + return {} if method == :xtest + + api_key ||= (Stripe.api_key || DUMMY_API_KEY) + + # Ensure params hash has symbols as keys + params = Stripe::Util.symbolize_names(params) + + method_url = "#{method} #{url}" + + if handler = Instance.handler_for_method_url(method_url) + if @debug == true + puts "- - - - " * 8 + puts "[StripeMock req]::#{handler[:name]} #{method} #{url}" + puts " #{params}" + end + + if mock_error = @error_queue.error_for_handler_name(handler[:name]) + @error_queue.dequeue + raise mock_error + else + res = self.send(handler[:name], handler[:route], method_url, params, headers) + puts " [res] #{res}" if @debug == true + [to_faraday_hash(res), api_key] + end + else + puts "[StripeMock] Warning : Unrecognized endpoint + method : [#{method} #{url}]" + puts "[StripeMock] params: #{params}" unless params.empty? + [{}, api_key] + end + end + + def generate_webhook_event(event_data) + event_data[:id] ||= new_id 'evt' + @events[ event_data[:id] ] = symbolize_names(event_data) + end + + def upsert_stripe_object(object, attributes) + # Most Stripe entities can be created via the API. However, some entities are created when other Stripe entities are + # created - such as when BalanceTransactions are created when Charges are created. This method provides the ability + # to create these internal entities. + # It also provides the ability to modify existing Stripe entities. + id = attributes[:id] + if id.nil? || id == "" + # Insert new Stripe object + case object + when :balance_transaction + id = new_balance_transaction('txn', attributes) + when :dispute + id = new_dispute('dp', attributes) + else + raise UnsupportedRequestError.new "Unsupported stripe object `#{object}`" + end + else + # Update existing Stripe object + case object + when :balance_transaction + btxn = assert_existence :balance_transaction, id, @balance_transactions[id] + btxn.merge!(attributes) + when :dispute + dispute = assert_existence :dispute, id, @disputes[id] + dispute.merge!(attributes) + else + raise UnsupportedRequestError.new "Unsupported stripe object `#{object}`" + end + end + id + end + + private + + def assert_existence(type, id, obj, message=nil) + if obj.nil? + msg = message || "No such #{type}: #{id}" + raise Stripe::InvalidRequestError.new(msg, type.to_s, http_status: 404) + end + obj + end + + def new_id(prefix) + # Stripe ids must be strings + "#{StripeMock.global_id_prefix}#{prefix}_#{@id_counter += 1}" + end + + def new_balance_transaction(prefix, params = {}) + # balance transaction ids must be strings + id = "#{StripeMock.global_id_prefix}#{prefix}_#{@balance_transaction_counter += 1}" + amount = params[:amount] + unless amount.nil? + # Fee calculation + calculate_fees(params) unless params[:fee] + params[:net] = amount - params[:fee] + params[:amount] = amount * @conversion_rate + end + @balance_transactions[id] = Data.mock_balance_transaction(params.merge(id: id)) + id + end + + def new_dispute(prefix, params = {}) + id = "#{StripeMock.global_id_prefix}#{prefix}_#{@dispute_counter += 1}" + @disputes[id] = Data.mock_dispute(params.merge(id: id)) + id + end + + def symbolize_names(hash) + Stripe::Util.symbolize_names(hash) + end + + def to_faraday_hash(hash) + response = Struct.new(:data) + response.new(hash) + end + + def calculate_fees(params) + application_fee = params[:application_fee] || 0 + params[:fee] = processing_fee(params[:amount]) + application_fee + params[:fee_details] = [ + { + amount: processing_fee(params[:amount]), + application: nil, + currency: params[:currency] || StripeMock.default_currency, + description: "Stripe processing fees", + type: "stripe_fee" + } + ] + if application_fee + params[:fee_details] << { + amount: application_fee, + currency: params[:currency] || StripeMock.default_currency, + description: "Application fee", + type: "application_fee" + } + end + end + + def processing_fee(amount) + (30 + (amount.abs * 0.029).ceil) * (amount > 0 ? 1 : -1) + end + end +end diff --git a/lib/stripe_mock/request_handlers/account_links.rb b/lib/stripe_mock/request_handlers/account_links.rb new file mode 100644 index 0000000..5d99e7e --- /dev/null +++ b/lib/stripe_mock/request_handlers/account_links.rb @@ -0,0 +1,15 @@ +module StripeMock + module RequestHandlers + module AccountLinks + + def AccountLinks.included(klass) + klass.add_handler 'post /v1/account_links', :new_account_link + end + + def new_account_link(route, method_url, params, headers) + route =~ method_url + Data.mock_account_link(params) + end + end + end +end diff --git a/lib/stripe_mock/request_handlers/accounts.rb b/lib/stripe_mock/request_handlers/accounts.rb new file mode 100644 index 0000000..bac6796 --- /dev/null +++ b/lib/stripe_mock/request_handlers/accounts.rb @@ -0,0 +1,86 @@ +module StripeMock + module RequestHandlers + module Accounts + VALID_START_YEAR = 2009 + + def Accounts.included(klass) + klass.add_handler 'post /v1/accounts', :new_account + klass.add_handler 'get /v1/account', :get_account + klass.add_handler 'get /v1/accounts/(.*)', :get_account + klass.add_handler 'post /v1/accounts/(.*)', :update_account + klass.add_handler 'get /v1/accounts', :list_accounts + klass.add_handler 'post /oauth/deauthorize',:deauthorize + end + + def new_account(route, method_url, params, headers) + params[:id] ||= new_id('acct') + route =~ method_url + accounts[params[:id]] ||= Data.mock_account(params) + end + + def get_account(route, method_url, params, headers) + route =~ method_url + init_account + id = $1 || accounts.keys[0] + assert_existence :account, id, accounts[id] + end + + def update_account(route, method_url, params, headers) + route =~ method_url + account = assert_existence :account, $1, accounts[$1] + account.merge!(params) + if blank_value?(params[:tos_acceptance], :date) + raise Stripe::InvalidRequestError.new("Invalid integer: ", "tos_acceptance[date]", http_status: 400) + elsif params[:tos_acceptance] && params[:tos_acceptance][:date] + validate_acceptance_date(params[:tos_acceptance][:date]) + end + account + end + + def list_accounts(route, method_url, params, headers) + init_account + Data.mock_list_object(accounts.values, params) + end + + def deauthorize(route, method_url, params, headers) + init_account + route =~ method_url + Stripe::StripeObject.construct_from(:stripe_user_id => params[:stripe_user_id]) + end + + private + + def init_account + if accounts == {} + acc = Data.mock_account + accounts[acc[:id]] = acc + end + end + + # Checks if setting a blank value + # + # returns true if the key is included in the hash + # and its value is empty or nil + def blank_value?(hash, key) + if hash.key?(key) + value = hash[key] + return true if value.nil? || "" == value + end + false + end + + def validate_acceptance_date(unix_date) + unix_now = Time.now.strftime("%s").to_i + formatted_date = Time.at(unix_date) + + return if formatted_date.year >= VALID_START_YEAR && unix_now >= unix_date + + raise Stripe::InvalidRequestError.new( + "ToS acceptance date is not valid. Dates are expected to be integers, measured in seconds, not in the future, and after 2009", + "tos_acceptance[date]", + http_status: 400 + ) + end + end + end +end diff --git a/lib/stripe_mock/request_handlers/balance.rb b/lib/stripe_mock/request_handlers/balance.rb new file mode 100644 index 0000000..1220b87 --- /dev/null +++ b/lib/stripe_mock/request_handlers/balance.rb @@ -0,0 +1,17 @@ +module StripeMock + module RequestHandlers + module Balance + + def Balance.included(klass) + klass.add_handler 'get /v1/balance', :get_balance + end + + def get_balance(route, method_url, params, headers) + route =~ method_url + + return_balance = Data.mock_balance(account_balance) + return_balance + end + end + end +end diff --git a/lib/stripe_mock/request_handlers/balance_transactions.rb b/lib/stripe_mock/request_handlers/balance_transactions.rb new file mode 100644 index 0000000..50d29a0 --- /dev/null +++ b/lib/stripe_mock/request_handlers/balance_transactions.rb @@ -0,0 +1,37 @@ +module StripeMock + module RequestHandlers + module BalanceTransactions + + def BalanceTransactions.included(klass) + klass.add_handler 'get /v1/balance_transactions/(.*)', :get_balance_transaction + klass.add_handler 'get /v1/balance_transactions', :list_balance_transactions + end + + def get_balance_transaction(route, method_url, params, headers) + route =~ method_url + assert_existence :balance_transaction, $1, hide_additional_attributes(balance_transactions[$1]) + end + + def list_balance_transactions(route, method_url, params, headers) + values = balance_transactions.values + if params.has_key?(:transfer) + # If transfer supplied as params, need to filter the btxns returned to only include those with the specified transfer id + values = values.select{|btxn| btxn[:transfer] == params[:transfer]} + end + Data.mock_list_object(values.map{|btxn| hide_additional_attributes(btxn)}, params) + end + + private + + def hide_additional_attributes(btxn) + # For automatic Stripe transfers, the transfer attribute on balance_transaction stores the transfer which + # included this balance_transaction. However, it is not exposed as a field returned on a balance_transaction. + # Therefore, need to not show this attribute if it exists. + if !btxn.nil? + btxn.reject{|k,v| k == :transfer } + end + end + + end + end +end diff --git a/lib/stripe_mock/request_handlers/cards.rb b/lib/stripe_mock/request_handlers/cards.rb new file mode 100644 index 0000000..7829e2e --- /dev/null +++ b/lib/stripe_mock/request_handlers/cards.rb @@ -0,0 +1,35 @@ +module StripeMock + module RequestHandlers + module Cards + + def Cards.included(klass) + klass.add_handler 'get /v1/recipients/(.*)/cards', :retrieve_recipient_cards + klass.add_handler 'get /v1/recipients/(.*)/cards/(.*)', :retrieve_recipient_card + klass.add_handler 'post /v1/recipients/(.*)/cards', :create_recipient_card + klass.add_handler 'delete /v1/recipients/(.*)/cards/(.*)', :delete_recipient_card + end + + def create_recipient_card(route, method_url, params, headers) + route =~ method_url + add_card_to(:recipient, $1, params, recipients) + end + + def retrieve_recipient_cards(route, method_url, params, headers) + route =~ method_url + retrieve_object_cards(:recipient, $1, recipients) + end + + def retrieve_recipient_card(route, method_url, params, headers) + route =~ method_url + recipient = assert_existence :recipient, $1, recipients[$1] + + assert_existence :card, $2, get_card(recipient, $2, "Recipient") + end + + def delete_recipient_card(route, method_url, params, headers) + route =~ method_url + delete_card_from(:recipient, $1, $2, recipients) + end + end + end +end diff --git a/lib/stripe_mock/request_handlers/charges.rb b/lib/stripe_mock/request_handlers/charges.rb new file mode 100644 index 0000000..54c3050 --- /dev/null +++ b/lib/stripe_mock/request_handlers/charges.rb @@ -0,0 +1,184 @@ +module StripeMock + module RequestHandlers + module Charges + + def Charges.included(klass) + klass.add_handler 'post /v1/charges', :new_charge + klass.add_handler 'get /v1/charges', :get_charges + klass.add_handler 'get /v1/charges/(.*)', :get_charge + klass.add_handler 'post /v1/charges/(.*)/capture', :capture_charge + klass.add_handler 'post /v1/charges/(.*)/refund', :refund_charge + klass.add_handler 'post /v1/charges/(.*)/refunds', :refund_charge + klass.add_handler 'post /v1/charges/(.*)', :update_charge + end + + def new_charge(route, method_url, params, headers = {}) + stripe_account = headers && headers[:stripe_account] || Stripe.api_key + + if headers && headers[:idempotency_key] + params[:idempotency_key] = headers[:idempotency_key] + if charges.any? + original_charge = charges.values.find { |c| c[:idempotency_key] == headers[:idempotency_key]} + return charges[original_charge[:id]] if original_charge + end + end + + id = new_id('ch') + + if params[:source] + if params[:source].is_a?(String) + # if a customer is provided, the card parameter is assumed to be the actual + # card id, not a token. in this case we'll find the card in the customer + # object and return that. + if params[:customer] + params[:source] = get_card(customers[stripe_account][params[:customer]], params[:source]) + else + params[:source] = get_card_or_bank_by_token(params[:source]) + end + elsif params[:source][:id] + raise Stripe::InvalidRequestError.new("Invalid token id: #{params[:source]}", 'card', http_status: 400) + end + elsif params[:customer] + customer = customers[stripe_account][params[:customer]] + if customer && customer[:default_source] + params[:source] = get_card(customer, customer[:default_source]) + end + end + + ensure_required_params(params) + bal_trans_params = { amount: params[:amount], source: id, application_fee: params[:application_fee] } + + balance_transaction_id = new_balance_transaction('txn', bal_trans_params) + + charges[id] = Data.mock_charge( + params.merge :id => id, + :balance_transaction => balance_transaction_id) + + charge = charges[id].clone + if params[:expand] == ['balance_transaction'] + charge[:balance_transaction] = + balance_transactions[balance_transaction_id] + end + + charge + end + + def update_charge(route, method_url, params, headers) + route =~ method_url + id = $1 + + charge = assert_existence :charge, id, charges[id] + allowed = allowed_params(params) + disallowed = params.keys - allowed + if disallowed.count > 0 + raise Stripe::InvalidRequestError.new("Received unknown parameters: #{disallowed.join(', ')}" , '', http_status: 400) + end + + charges[id] = Util.rmerge(charge, params) + end + + def get_charges(route, method_url, params, headers) + params[:offset] ||= 0 + params[:limit] ||= 10 + + clone = charges.clone + + if params[:customer] + clone.delete_if { |k,v| v[:customer] != params[:customer] } + end + + Data.mock_list_object(clone.values, params) + end + + def get_charge(route, method_url, params, headers) + route =~ method_url + charge_id = $1 || params[:charge] + charge = assert_existence :charge, charge_id, charges[charge_id] + + charge = charge.clone + if params[:expand] == ['balance_transaction'] + balance_transaction = balance_transactions[charge[:balance_transaction]] + charge[:balance_transaction] = balance_transaction + end + + charge + end + + def capture_charge(route, method_url, params, headers) + route =~ method_url + charge = assert_existence :charge, $1, charges[$1] + + if params[:amount] + refund = Data.mock_refund( + :balance_transaction => new_balance_transaction('txn'), + :id => new_id('re'), + :amount => charge[:amount] - params[:amount] + ) + add_refund_to_charge(refund, charge) + end + + if params[:application_fee] + charge[:application_fee] = params[:application_fee] + end + + charge[:captured] = true + charge + end + + def refund_charge(route, method_url, params, headers) + charge = get_charge(route, method_url, params, headers) + + new_refund( + route, + method_url, + params.merge(:charge => charge[:id]), + headers + ) + end + + private + + def ensure_required_params(params) + if params[:amount].nil? + require_param(:amount) + elsif params[:currency].nil? + require_param(:currency) + elsif non_integer_charge_amount?(params) + raise Stripe::InvalidRequestError.new("Invalid integer: #{params[:amount]}", 'amount', http_status: 400) + elsif non_positive_charge_amount?(params) + raise Stripe::InvalidRequestError.new('Invalid positive integer', 'amount', http_status: 400) + elsif params[:source].nil? && params[:customer].nil? + raise Stripe::InvalidRequestError.new('Must provide source or customer.', nil, http_status: nil) + end + end + + def non_integer_charge_amount?(params) + params[:amount] && !params[:amount].is_a?(Integer) + end + + def non_positive_charge_amount?(params) + params[:amount] && params[:amount] < 1 + end + + def allowed_params(params) + allowed = [:description, :metadata, :receipt_email, :fraud_details, :shipping, :destination] + + # This is a workaround for the way the Stripe API sends params even when they aren't modified. + # Stipe will include those params even when they aren't modified. + allowed << :fee_details if params.has_key?(:fee_details) && params[:fee_details].nil? + allowed << :source if params.has_key?(:source) && params[:source].empty? + if params.has_key?(:refunds) && (params[:refunds].empty? || + params[:refunds].has_key?(:data) && params[:refunds][:data].nil?) + allowed << :refunds + end + if params.has_key?(:payment_method_details) && (params[:payment_method_details].empty? || + params[:payment_method_details].has_key?(:card) && (params[:payment_method_details][:card].empty? || + params[:payment_method_details][:card].has_key?(:checks) && params[:payment_method_details][:card][:checks].empty?)) + allowed << :payment_method_details + end + + allowed + end + end + end +end diff --git a/lib/stripe_mock/request_handlers/checkout_session.rb b/lib/stripe_mock/request_handlers/checkout_session.rb new file mode 100644 index 0000000..c0d408b --- /dev/null +++ b/lib/stripe_mock/request_handlers/checkout_session.rb @@ -0,0 +1,179 @@ +module StripeMock + module RequestHandlers + module Checkout + module Session + def Session.included(klass) + klass.add_handler 'post /v1/checkout/sessions', :new_session + klass.add_handler 'get /v1/checkout/sessions', :list_checkout_sessions + klass.add_handler 'get /v1/checkout/sessions/([^/]*)', :get_checkout_session + klass.add_handler 'get /v1/checkout/sessions/([^/]*)/line_items', :list_line_items + end + + def new_session(route, method_url, params, headers) + id = params[:id] || new_id('cs') + + [:cancel_url, :success_url].each do |p| + require_param(p) if params[p].nil? || params[p].empty? + end + + line_items = nil + if params[:line_items] + line_items = params[:line_items].each_with_index.map do |line_item, i| + throw Stripe::InvalidRequestError("Quantity is required. Add `quantity` to `line_items[#{i}]`") unless line_item[:quantity] + unless line_item[:price] || line_item[:price_data] || (line_item[:amount] && line_item[:currency] && line_item[:name]) + throw Stripe::InvalidRequestError("Price or amount and currency is required. Add `price`, `price_data`, or `amount`, `currency` and `name` to `line_items[#{i}]`") + end + { + id: new_id("li"), + price: if line_item[:price] + line_item[:price] + elsif line_item[:price_data] + new_price(nil, nil, line_item[:price_data], nil)[:id] + else + new_price(nil, nil, { + unit_amount: line_item[:amount], + currency: line_item[:currency], + product_data: { + name: line_item[:name] + } + }, nil)[:id] + end, + quantity: line_item[:quantity] + } + end + end + + amount = nil + currency = nil + if line_items + amount = 0 + + line_items.each do |line_item| + price = prices[line_item[:price]] + + if price.nil? + raise StripeMock::StripeMockError.new("Price not found for ID: #{line_item[:price]}") + end + + amount += (price[:unit_amount] * line_item[:quantity]) + end + + currency = prices[line_items.first[:price]][:currency] + end + + payment_status = "unpaid" + payment_intent = nil + setup_intent = nil + case params[:mode] + when nil, "payment" + params[:customer] ||= new_customer(nil, nil, {email: params[:customer_email]}, nil)[:id] + require_param(:line_items) if params[:line_items].nil? || params[:line_items].empty? + payment_intent = new_payment_intent(nil, nil, { + amount: amount, + currency: currency, + customer: params[:customer], + payment_method_options: params[:payment_method_options], + payment_method_types: params[:payment_method_types] + }.merge(params[:payment_intent_data] || {}), nil)[:id] + checkout_session_line_items[id] = line_items + when "setup" + if !params[:line_items].nil? && !params[:line_items].empty? + throw Stripe::InvalidRequestError.new("You cannot pass `line_items` in `setup` mode", :line_items, http_status: 400) + end + setup_intent = new_setup_intent(nil, nil, { + customer: params[:customer], + payment_method_options: params[:payment_method_options], + payment_method_types: params[:payment_method_types] + }.merge(params[:setup_intent_data] || {}), nil)[:id] + payment_status = "no_payment_required" + when "subscription" + params[:customer] ||= new_customer(nil, nil, {email: params[:customer_email]}, nil)[:id] + require_param(:line_items) if params[:line_items].nil? || params[:line_items].empty? + checkout_session_line_items[id] = line_items + else + throw Stripe::InvalidRequestError.new("Invalid mode: must be one of payment, setup, or subscription", :mode, http_status: 400) + end + + checkout_sessions[id] = { + id: id, + object: "checkout.session", + allow_promotion_codes: nil, + amount_subtotal: amount, + amount_total: amount, + automatic_tax: { + enabled: false, + status: nil + }, + billing_address_collection: nil, + cancel_url: params[:cancel_url], + client_reference_id: nil, + currency: currency, + customer: params[:customer], + customer_details: nil, + customer_email: params[:customer_email], + livemode: false, + locale: nil, + metadata: params[:metadata], + mode: params[:mode], + payment_intent: payment_intent, + payment_method_options: params[:payment_method_options], + payment_method_types: params[:payment_method_types], + payment_status: payment_status, + setup_intent: setup_intent, + shipping: nil, + shipping_address_collection: nil, + submit_type: nil, + subscription: nil, + success_url: params[:success_url], + total_details: nil, + url: URI.join(StripeMock.checkout_base, id).to_s + } + end + + def list_checkout_sessions(route, method_url, params, headers) + Data.mock_list_object(checkout_sessions.values) + end + + def get_checkout_session(route, method_url, params, headers) + route =~ method_url + checkout_session = assert_existence :checkout_session, $1, checkout_sessions[$1] + + checkout_session = checkout_session.clone + if params[:expand]&.include?('setup_intent') && checkout_session[:setup_intent] + checkout_session[:setup_intent] = setup_intents[checkout_session[:setup_intent]] + end + checkout_session + end + + def list_line_items(route, method_url, params, headers) + route =~ method_url + checkout_session = assert_existence :checkout_session, $1, checkout_sessions[$1] + + case checkout_session[:mode] + when "payment", "subscription" + line_items = assert_existence :checkout_session_line_items, $1, checkout_session_line_items[$1] + line_items.map do |line_item| + price = prices[line_item[:price]].clone + + if price.nil? + raise StripeMock::StripeMockError.new("Price not found for ID: #{line_item[:price]}") + end + + { + id: line_item[:id], + object: "item", + amount_subtotal: price[:unit_amount] * line_item[:quantity], + amount_total: price[:unit_amount] * line_item[:quantity], + currency: price[:currency], + price: price.clone, + quantity: line_item[:quantity] + } + end + else + throw Stripe::InvalidRequestError("Only payment and subscription sessions have line items") + end + end + end + end + end +end diff --git a/lib/stripe_mock/request_handlers/country_spec.rb b/lib/stripe_mock/request_handlers/country_spec.rb new file mode 100644 index 0000000..6c681e8 --- /dev/null +++ b/lib/stripe_mock/request_handlers/country_spec.rb @@ -0,0 +1,22 @@ +module StripeMock + module RequestHandlers + module CountrySpec + + def CountrySpec.included(klass) + klass.add_handler 'get /v1/country_specs/(.*)', :retrieve_country_spec + end + + def retrieve_country_spec(route, method_url, params, headers) + route =~ method_url + + unless ["AT", "AU", "BE", "CA", "DE", "DK", "ES", "FI", "FR", "GB", "IE", "IT", "JP", "LU", "NL", "NO", "SE", "SG", "US"].include?($1) + raise Stripe::InvalidRequestError.new("#{$1} is not currently supported by Stripe.", $1.to_s) + end + + country_spec[$1] ||= Data.mock_country_spec($1) + + assert_existence :country_spec, $1, country_spec[$1] + end + end + end +end \ No newline at end of file diff --git a/lib/stripe_mock/request_handlers/coupons.rb b/lib/stripe_mock/request_handlers/coupons.rb new file mode 100644 index 0000000..3b4bd34 --- /dev/null +++ b/lib/stripe_mock/request_handlers/coupons.rb @@ -0,0 +1,35 @@ +module StripeMock + module RequestHandlers + module Coupons + + def Coupons.included(klass) + klass.add_handler 'post /v1/coupons', :new_coupon + klass.add_handler 'get /v1/coupons/(.*)', :get_coupon + klass.add_handler 'delete /v1/coupons/(.*)', :delete_coupon + klass.add_handler 'get /v1/coupons', :list_coupons + end + + def new_coupon(route, method_url, params, headers) + params[:id] ||= new_id('coupon') + raise Stripe::InvalidRequestError.new('Missing required param: duration', 'coupon', http_status: 400) unless params[:duration] + raise Stripe::InvalidRequestError.new('You must pass currency when passing amount_off', 'coupon', http_status: 400) if params[:amount_off] && !params[:currency] + coupons[ params[:id] ] = Data.mock_coupon({amount_off: nil, percent_off:nil}.merge(params)) + end + + def get_coupon(route, method_url, params, headers) + route =~ method_url + assert_existence :coupon, $1, coupons[$1] + end + + def delete_coupon(route, method_url, params, headers) + route =~ method_url + assert_existence :coupon, $1, coupons.delete($1) + end + + def list_coupons(route, method_url, params, headers) + Data.mock_list_object(coupons.values, params) + end + + end + end +end diff --git a/lib/stripe_mock/request_handlers/customers.rb b/lib/stripe_mock/request_handlers/customers.rb new file mode 100644 index 0000000..32946ab --- /dev/null +++ b/lib/stripe_mock/request_handlers/customers.rb @@ -0,0 +1,154 @@ +module StripeMock + module RequestHandlers + module Customers + + def Customers.included(klass) + klass.add_handler 'post /v1/customers', :new_customer + klass.add_handler 'post /v1/customers/([^/]*)', :update_customer + klass.add_handler 'get /v1/customers/([^/]*)', :get_customer + klass.add_handler 'delete /v1/customers/([^/]*)', :delete_customer + klass.add_handler 'get /v1/customers', :list_customers + klass.add_handler 'delete /v1/customers/([^/]*)/discount', :delete_customer_discount + end + + def new_customer(route, method_url, params, headers) + stripe_account = headers && headers[:stripe_account] || Stripe.api_key + params[:id] ||= new_id('cus') + sources = [] + + if params[:source] + new_card = + if params[:source].is_a?(Hash) + unless params[:source][:object] && params[:source][:number] && params[:source][:exp_month] && params[:source][:exp_year] + raise Stripe::InvalidRequestError.new('You must supply a valid card', nil, http_status: 400) + end + card_from_params(params[:source]) + else + get_card_or_bank_by_token(params.delete(:source)) + end + sources << new_card + params[:default_source] = sources.first[:id] + end + + customers[stripe_account] ||= {} + customers[stripe_account][params[:id]] = Data.mock_customer(sources, params) + + if params[:plan] + plan_id = params[:plan].to_s + plan = assert_existence :plan, plan_id, plans[plan_id] + + if params[:default_source].nil? && params[:trial_end].nil? && plan[:trial_period_days].nil? && plan[:amount] != 0 + raise Stripe::InvalidRequestError.new('You must supply a valid card', nil, http_status: 400) + end + + subscription = Data.mock_subscription({ id: new_id('su') }) + subscription = resolve_subscription_changes(subscription, [plan], customers[stripe_account][params[:id]], params) + add_subscription_to_customer(customers[stripe_account][params[:id]], subscription) + subscriptions[subscription[:id]] = subscription + elsif params[:trial_end] + raise Stripe::InvalidRequestError.new('Received unknown parameter: trial_end', nil, http_status: 400) + end + + if params[:coupon] + coupon = coupons[params[:coupon]] + assert_existence :coupon, params[:coupon], coupon + add_coupon_to_object(customers[stripe_account][params[:id]], coupon) + end + + customers[stripe_account][params[:id]] + end + + def update_customer(route, method_url, params, headers) + stripe_account = headers && headers[:stripe_account] || Stripe.api_key + route =~ method_url + cus = assert_existence :customer, $1, customers[stripe_account][$1] + + # get existing and pending metadata + metadata = cus.delete(:metadata) || {} + metadata_updates = params.delete(:metadata) || {} + + # Delete those params if their value is nil. Workaround of the problematic way Stripe serialize objects + params.delete(:sources) if params[:sources] && params[:sources][:data].nil? + params.delete(:subscriptions) if params[:subscriptions] && params[:subscriptions][:data].nil? + # Delete those params if their values aren't valid. Workaround of the problematic way Stripe serialize objects + if params[:sources] && !params[:sources][:data].nil? + params.delete(:sources) unless params[:sources][:data].any?{ |v| !!v[:type]} + end + if params[:subscriptions] && !params[:subscriptions][:data].nil? + params.delete(:subscriptions) unless params[:subscriptions][:data].any?{ |v| !!v[:type]} + end + cus.merge!(params) + cus[:metadata] = {**metadata, **metadata_updates} + + if params[:source] + if params[:source].is_a?(String) + new_card = get_card_or_bank_by_token(params.delete(:source)) + elsif params[:source].is_a?(Stripe::Token) + new_card = get_card_or_bank_by_token(params[:source][:id]) + elsif params[:source].is_a?(Hash) + unless params[:source][:object] && params[:source][:number] && params[:source][:exp_month] && params[:source][:exp_year] + raise Stripe::InvalidRequestError.new('You must supply a valid card', nil, http_status: 400) + end + new_card = card_from_params(params.delete(:source)) + end + add_card_to_object(:customer, new_card, cus, true) + cus[:default_source] = new_card[:id] + end + + if params[:coupon] + if params[:coupon] == '' + delete_coupon_from_object(cus) + else + coupon = coupons[params[:coupon]] + assert_existence :coupon, params[:coupon], coupon + + add_coupon_to_object(cus, coupon) + end + end + + cus + end + + def delete_customer(route, method_url, params, headers) + stripe_account = headers && headers[:stripe_account] || Stripe.api_key + route =~ method_url + assert_existence :customer, $1, customers[stripe_account][$1] + + customers[stripe_account][$1] = { + id: customers[stripe_account][$1][:id], + deleted: true + } + end + + def get_customer(route, method_url, params, headers) + stripe_account = headers && headers[:stripe_account] || Stripe.api_key + route =~ method_url + customer = assert_existence :customer, $1, customers[stripe_account][$1] + + customer = customer.clone + if params[:expand] == ['default_source'] && customer[:sources][:data] + customer[:default_source] = customer[:sources][:data].detect do |source| + source[:id] == customer[:default_source] + end + end + + customer + end + + def list_customers(route, method_url, params, headers) + stripe_account = headers && headers[:stripe_account] || Stripe.api_key + Data.mock_list_object(customers[stripe_account]&.values, params) + end + + def delete_customer_discount(route, method_url, params, headers) + stripe_account = headers && headers[:stripe_account] || Stripe.api_key + route =~ method_url + cus = assert_existence :customer, $1, customers[stripe_account][$1] + + cus[:discount] = nil + + cus + end + end + end +end diff --git a/lib/stripe_mock/request_handlers/disputes.rb b/lib/stripe_mock/request_handlers/disputes.rb new file mode 100644 index 0000000..beec4c5 --- /dev/null +++ b/lib/stripe_mock/request_handlers/disputes.rb @@ -0,0 +1,35 @@ +module StripeMock + module RequestHandlers + module Disputes + + def Disputes.included(klass) + klass.add_handler 'get /v1/disputes/(.*)', :get_dispute + klass.add_handler 'post /v1/disputes/(.*)/close', :close_dispute + klass.add_handler 'post /v1/disputes/(.*)', :update_dispute + klass.add_handler 'get /v1/disputes', :list_disputes + end + + def get_dispute(route, method_url, params, headers) + route =~ method_url + assert_existence :dispute, $1, disputes[$1] + end + + def update_dispute(route, method_url, params, headers) + dispute = get_dispute(route, method_url, params, headers) + dispute.merge!(params) + dispute + end + + def close_dispute(route, method_url, params, headers) + dispute = get_dispute(route, method_url, params, headers) + dispute.merge!({:status => 'lost'}) + dispute + end + + def list_disputes(route, method_url, params, headers) + Data.mock_list_object(disputes.values, params) + end + + end + end +end diff --git a/lib/stripe_mock/request_handlers/ephemeral_key.rb b/lib/stripe_mock/request_handlers/ephemeral_key.rb new file mode 100644 index 0000000..c0fef51 --- /dev/null +++ b/lib/stripe_mock/request_handlers/ephemeral_key.rb @@ -0,0 +1,13 @@ +module StripeMock + module RequestHandlers + module EphemeralKey + def self.included(klass) + klass.add_handler 'post /v1/ephemeral_keys', :create_ephemeral_key + end + + def create_ephemeral_key(route, method_url, params, headers) + Data.mock_ephemeral_key(**params) + end + end + end +end diff --git a/lib/stripe_mock/request_handlers/events.rb b/lib/stripe_mock/request_handlers/events.rb new file mode 100644 index 0000000..8999aa5 --- /dev/null +++ b/lib/stripe_mock/request_handlers/events.rb @@ -0,0 +1,48 @@ +module StripeMock + module RequestHandlers + module Events + + def Events.included(klass) + klass.add_handler 'get /v1/events/(.*)', :retrieve_event + klass.add_handler 'get /v1/events', :list_events + end + + def retrieve_event(route, method_url, params, headers) + route =~ method_url + assert_existence :event, $1, events[$1] + end + + def list_events(route, method_url, params, headers) + values = filter_by_created(events.values, params: params) + Data.mock_list_object(values, params) + end + + private + + def filter_by_created(event_list, params:) + if params[:created].nil? + return event_list + end + + if params[:created].is_a?(Hash) + if params[:created][:gt] + event_list = event_list.select { |event| event[:created] > params[:created][:gt].to_i } + end + if params[:created][:gte] + event_list = event_list.select { |event| event[:created] >= params[:created][:gte].to_i } + end + if params[:created][:lt] + event_list = event_list.select { |event| event[:created] < params[:created][:lt].to_i } + end + if params[:created][:lte] + event_list = event_list.select { |event| event[:created] <= params[:created][:lte].to_i } + end + else + event_list = event_list.select { |event| event[:created] == params[:created].to_i } + end + event_list + end + + end + end +end diff --git a/lib/stripe_mock/request_handlers/express_login_links.rb b/lib/stripe_mock/request_handlers/express_login_links.rb new file mode 100644 index 0000000..8b5e97e --- /dev/null +++ b/lib/stripe_mock/request_handlers/express_login_links.rb @@ -0,0 +1,15 @@ +module StripeMock + module RequestHandlers + module ExpressLoginLinks + + def ExpressLoginLinks.included(klass) + klass.add_handler 'post /v1/accounts/(.*)/login_links', :new_account_login_link + end + + def new_account_login_link(route, method_url, params, headers) + route =~ method_url + Data.mock_express_login_link(params) + end + end + end +end diff --git a/lib/stripe_mock/request_handlers/external_accounts.rb b/lib/stripe_mock/request_handlers/external_accounts.rb new file mode 100644 index 0000000..958914e --- /dev/null +++ b/lib/stripe_mock/request_handlers/external_accounts.rb @@ -0,0 +1,55 @@ +module StripeMock + module RequestHandlers + module ExternalAccounts + + def ExternalAccounts.included(klass) + klass.add_handler 'get /v1/accounts/(.*)/external_accounts', :retrieve_external_accounts + klass.add_handler 'post /v1/accounts/(.*)/external_accounts', :create_external_account + klass.add_handler 'post /v1/accounts/(.*)/external_accounts/(.*)/verify', :verify_external_account + klass.add_handler 'get /v1/accounts/(.*)/external_accounts/(.*)', :retrieve_external_account + klass.add_handler 'delete /v1/accounts/(.*)/external_accounts/(.*)', :delete_external_account + klass.add_handler 'post /v1/accounts/(.*)/external_accounts/(.*)', :update_external_account + end + + def create_external_account(route, method_url, params, headers) + route =~ method_url + add_external_account_to(:account, $1, params, accounts) + end + + def retrieve_external_accounts(route, method_url, params, headers) + route =~ method_url + retrieve_object_cards(:account, $1, accounts) + end + + def retrieve_external_account(route, method_url, params, headers) + route =~ method_url + account = assert_existence :account, $1, accounts[$1] + + assert_existence :card, $2, get_card(account, $2) + end + + def delete_external_account(route, method_url, params, headers) + route =~ method_url + delete_card_from(:account, $1, $2, accounts) + end + + def update_external_account(route, method_url, params, headers) + route =~ method_url + account = assert_existence :account, $1, accounts[$1] + + card = assert_existence :card, $2, get_card(account, $2) + card.merge!(params) + card + end + + def verify_external_account(route, method_url, params, headers) + route =~ method_url + account = assert_existence :account, $1, accounts[$1] + + external_account = assert_existence :bank_account, $2, verify_bank_account(account, $2) + external_account + end + + end + end +end diff --git a/lib/stripe_mock/request_handlers/helpers/bank_account_helpers.rb b/lib/stripe_mock/request_handlers/helpers/bank_account_helpers.rb new file mode 100644 index 0000000..ddf7f23 --- /dev/null +++ b/lib/stripe_mock/request_handlers/helpers/bank_account_helpers.rb @@ -0,0 +1,14 @@ +module StripeMock + module RequestHandlers + module Helpers + + def verify_bank_account(object, bank_account_id, class_name='Customer') + bank_accounts = object[:external_accounts] || object[:bank_accounts] || object[:sources] + bank_account = bank_accounts[:data].find{|acc| acc[:id] == bank_account_id } + return if bank_account.nil? + bank_account['status'] = 'verified' + bank_account + end + end + end +end diff --git a/lib/stripe_mock/request_handlers/helpers/card_helpers.rb b/lib/stripe_mock/request_handlers/helpers/card_helpers.rb new file mode 100644 index 0000000..98d76a6 --- /dev/null +++ b/lib/stripe_mock/request_handlers/helpers/card_helpers.rb @@ -0,0 +1,127 @@ +module StripeMock + module RequestHandlers + module Helpers + + def get_card(object, card_id, class_name='Customer') + cards = object[:cards] || object[:sources] || object[:external_accounts] + card = cards[:data].find{|cc| cc[:id] == card_id } + if card.nil? + if class_name == 'Recipient' + msg = "#{class_name} #{object[:id]} does not have a card with ID #{card_id}" + raise Stripe::InvalidRequestError.new(msg, 'card', http_status: 404) + else + msg = "There is no source with ID #{card_id}" + raise Stripe::InvalidRequestError.new(msg, 'id', http_status: 404) + end + end + card + end + + def add_source_to_object(type, source, object, replace_current=false) + source[type] = object[:id] + sources = object[:sources] + + if replace_current && sources[:data] + sources[:data].delete_if {|source| source[:id] == object[:default_source]} + object[:default_source] = source[:id] + sources[:data] = [source] + else + sources[:total_count] = (sources[:total_count] || 0) + 1 + (sources[:data] ||= []) << source + end + object[:default_source] = source[:id] if object[:default_source].nil? + + source + end + + def add_card_to_object(type, card, object, replace_current=false) + card[type] = object[:id] + cards_or_sources = object[:cards] || object[:sources] || object[:external_accounts] + + is_customer = object.has_key?(:sources) + + if replace_current && cards_or_sources[:data] + cards_or_sources[:data].delete_if {|card| card[:id] == object[:default_card]} + object[:default_card] = card[:id] unless is_customer + object[:default_source] = card[:id] if is_customer + cards_or_sources[:data] = [card] + else + cards_or_sources[:total_count] = (cards_or_sources[:total_count] || 0) + 1 + (cards_or_sources[:data] ||= []) << card + end + + object[:default_card] = card[:id] if !is_customer && object[:default_card].nil? + object[:default_source] = card[:id] if is_customer && object[:default_source].nil? + + card + end + + def retrieve_object_cards(type, type_id, objects) + resource = assert_existence type, type_id, objects[type_id] + cards = resource[:cards] || resource[:sources] || resource[:external_accounts] + + Data.mock_list_object(cards[:data]) + end + + def delete_card_from(type, type_id, card_id, objects) + resource = assert_existence type, type_id, objects[type_id] + + assert_existence :card, card_id, get_card(resource, card_id) + + card = { id: card_id, deleted: true } + cards_or_sources = resource[:cards] || resource[:sources] || resource[:external_accounts] + cards_or_sources[:data].reject!{|cc| + cc[:id] == card[:id] + } + + is_customer = resource.has_key?(:sources) + new_default = cards_or_sources[:data].count > 0 ? cards_or_sources[:data].first[:id] : nil + resource[:default_card] = new_default unless is_customer + resource[:sources][:total_count] = cards_or_sources[:data].count if is_customer + resource[:default_source] = new_default if is_customer + card + end + + def add_source_to(type, type_id, params, objects) + resource = assert_existence type, type_id, objects[type_id] + + source = + if params[:card] + card_from_params(params[:card]) + elsif params[:bank_account] + get_bank_by_token(params[:bank_account]) + else + begin + get_card_by_token(params[:source]) + rescue Stripe::InvalidRequestError + get_bank_by_token(params[:source]) + end + end + source[:metadata].merge!(params[:metadata]) if params[:metadata] + add_source_to_object(type, source, resource) + end + + def add_card_to(type, type_id, params, objects) + resource = assert_existence type, type_id, objects[type_id] + + card = card_from_params(params[:card] || params[:source] || params[:external_accounts]) + add_card_to_object(type, card, resource) + end + + def validate_card(card) + [:exp_month, :exp_year].each do |field| + card[field] = card[field].to_i + end + card + end + + def card_from_params(attrs_or_token) + if attrs_or_token.is_a? Hash + attrs_or_token = generate_card_token(attrs_or_token) + end + card = get_card_by_token(attrs_or_token) + validate_card(card) + end + end + end +end diff --git a/lib/stripe_mock/request_handlers/helpers/charge_helpers.rb b/lib/stripe_mock/request_handlers/helpers/charge_helpers.rb new file mode 100644 index 0000000..63270b0 --- /dev/null +++ b/lib/stripe_mock/request_handlers/helpers/charge_helpers.rb @@ -0,0 +1,16 @@ +module StripeMock + module RequestHandlers + module Helpers + + def add_refund_to_charge(refund, charge) + refunds = charge[:refunds] + refunds[:data] << refund + refunds[:total_count] = refunds[:data].count + + charge[:amount_refunded] = refunds[:data].reduce(0) {|sum, r| sum + r[:amount].to_i } + charge[:refunded] = true + end + + end + end +end diff --git a/lib/stripe_mock/request_handlers/helpers/coupon_helpers.rb b/lib/stripe_mock/request_handlers/helpers/coupon_helpers.rb new file mode 100644 index 0000000..0e5fcb9 --- /dev/null +++ b/lib/stripe_mock/request_handlers/helpers/coupon_helpers.rb @@ -0,0 +1,22 @@ +module StripeMock + module RequestHandlers + module Helpers + def add_coupon_to_object(object, coupon) + discount_attrs = {}.tap do |attrs| + attrs[object[:object]] = object[:id] + attrs[:coupon] = coupon + attrs[:start] = Time.now.to_i + attrs[:end] = (DateTime.now >> coupon[:duration_in_months].to_i).to_time.to_i if coupon[:duration] == 'repeating' + end + + object[:discount] = Stripe::Discount.construct_from(discount_attrs) + object + end + + def delete_coupon_from_object(object) + object[:discount] = nil + object + end + end + end +end diff --git a/lib/stripe_mock/request_handlers/helpers/external_account_helpers.rb b/lib/stripe_mock/request_handlers/helpers/external_account_helpers.rb new file mode 100644 index 0000000..7b06821 --- /dev/null +++ b/lib/stripe_mock/request_handlers/helpers/external_account_helpers.rb @@ -0,0 +1,49 @@ +module StripeMock + module RequestHandlers + module Helpers + + def add_external_account_to(type, type_id, params, objects) + resource = assert_existence type, type_id, objects[type_id] + + source = + if params[:card] + card_from_params(params[:card]) + elsif params[:bank_account] + bank_from_params(params[:bank_account]) + else + begin + get_card_by_token(params[:external_account]) + rescue Stripe::InvalidRequestError + bank_from_params(params[:external_account]) + end + end + add_external_account_to_object(type, source, resource) + end + + def add_external_account_to_object(type, source, object, replace_current=false) + source[type] = object[:id] + accounts = object[:external_accounts] + + if replace_current && accounts[:data] + accounts[:data].delete_if {|source| source[:id] == object[:default_source]} + object[:default_source] = source[:id] + accounts[:data] = [source] + else + accounts[:total_count] = (accounts[:total_count] || 0) + 1 + (accounts[:data] ||= []) << source + end + object[:default_source] = source[:id] if object[:default_source].nil? + + source + end + + def bank_from_params(attrs_or_token) + if attrs_or_token.is_a? Hash + attrs_or_token = generate_bank_token(attrs_or_token) + end + get_bank_by_token(attrs_or_token) + end + + end + end +end diff --git a/lib/stripe_mock/request_handlers/helpers/subscription_helpers.rb b/lib/stripe_mock/request_handlers/helpers/subscription_helpers.rb new file mode 100644 index 0000000..33bfd37 --- /dev/null +++ b/lib/stripe_mock/request_handlers/helpers/subscription_helpers.rb @@ -0,0 +1,130 @@ +module StripeMock + module RequestHandlers + module Helpers + + def get_customer_subscription(customer, sub_id) + customer[:subscriptions][:data].find{|sub| sub[:id] == sub_id } + end + + def resolve_subscription_changes(subscription, plans, customer, options = {}) + subscription.merge!(custom_subscription_params(plans, customer, options)) + items = options[:items] + items = items.values if items.respond_to?(:values) + subscription[:items][:data] = plans.map do |plan| + matching_item = items && items.detect { |item| [item[:price], item[:plan]].include? plan[:id] } + if matching_item + matching_item[:quantity] ||= 1 + matching_item[:id] ||= new_id('si') + params = matching_item.merge(plan: plan) + params[:price] = plan if plan[:object] == "price" + Data.mock_subscription_item(params) + else + params = { plan: plan, id: new_id('si') } + params[:price] = plan if plan[:object] == "price" + Data.mock_subscription_item(params) + end + end + subscription + end + + def custom_subscription_params(plans, cus, options = {}) + verify_trial_end(options[:trial_end]) if options[:trial_end] + + plan = plans.first if plans.size == 1 + + now = Time.now.utc.to_i + created_time = options[:created] || now + start_time = options[:current_period_start] || now + params = { customer: cus[:id], current_period_start: start_time, created: created_time } + params.merge!({ :plan => (plans.size == 1 ? plans.first : nil) }) + keys_to_merge = /application_fee_percent|quantity|metadata|tax_percent|billing|days_until_due|default_tax_rates|pending_invoice_item_interval|default_payment_method|collection_method/ + params.merge! options.select {|k,v| k =~ keys_to_merge} + + if options[:cancel_at_period_end] == true + params.merge!(cancel_at_period_end: true, canceled_at: now) + elsif options[:cancel_at_period_end] == false + params.merge!(cancel_at_period_end: false, canceled_at: nil) + end + + # TODO: Implement coupon logic + + if (((plan && plan[:trial_period_days]) || 0) == 0 && options[:trial_end].nil?) || options[:trial_end] == "now" + end_time = options[:billing_cycle_anchor] || get_ending_time(start_time, plan) + params.merge!({status: 'active', current_period_end: end_time, trial_start: nil, trial_end: nil, billing_cycle_anchor: options[:billing_cycle_anchor] || created_time}) + else + end_time = options[:trial_end] || (Time.now.utc.to_i + plan[:trial_period_days]*86400) + params.merge!({status: 'trialing', current_period_end: end_time, trial_start: start_time, trial_end: end_time, billing_cycle_anchor: options[:billing_cycle_anchor] || created_time}) + end + + params + end + + def add_subscription_to_customer(cus, sub) + if sub[:trial_end].nil? || sub[:trial_end] == "now" + id = new_id('ch') + charges[id] = Data.mock_charge( + :id => id, + :customer => cus[:id], + :amount => (sub[:plan] ? sub[:plan][:amount] : total_items_amount(sub[:items][:data])) + ) + end + + if cus[:currency].nil? + cus[:currency] = sub[:items][:data][0][:plan][:currency] + elsif cus[:currency] != sub[:items][:data][0][:plan][:currency] + raise Stripe::InvalidRequestError.new( "Can't combine currencies on a single customer. This customer has had a subscription, coupon, or invoice item with currency #{cus[:currency]}", 'currency', http_status: 400) + end + cus[:subscriptions][:total_count] = (cus[:subscriptions][:total_count] || 0) + 1 + cus[:subscriptions][:data].unshift sub + end + + def delete_subscription_from_customer(cus, subscription) + cus[:subscriptions][:data].reject!{|sub| + sub[:id] == subscription[:id] + } + cus[:subscriptions][:total_count] -=1 + end + + # `intervals` is set to 1 when calculating current_period_end from current_period_start & plan + # `intervals` is set to 2 when calculating Stripe::Invoice.upcoming end from current_period_start & plan + def get_ending_time(start_time, plan, intervals = 1) + return start_time unless plan + + interval = plan[:interval] || plan.dig(:recurring, :interval) + interval_count = plan[:interval_count] || plan.dig(:recurring, :interval_count) || 1 + case interval + when "week" + start_time + (604800 * (interval_count) * intervals) + when "month" + (Time.at(start_time).to_datetime >> ((interval_count) * intervals)).to_time.to_i + when "year" + (Time.at(start_time).to_datetime >> (12 * intervals)).to_time.to_i # max period is 1 year + else + start_time + end + end + + def verify_trial_end(trial_end) + if trial_end != "now" + if !trial_end.is_a? Integer + raise Stripe::InvalidRequestError.new('Invalid timestamp: must be an integer', nil, http_status: 400) + elsif trial_end < Time.now.utc.to_i + raise Stripe::InvalidRequestError.new('Invalid timestamp: must be an integer Unix timestamp in the future', nil, http_status: 400) + elsif trial_end > Time.now.utc.to_i + 31557600*5 # five years + raise Stripe::InvalidRequestError.new('Invalid timestamp: can be no more than five years in the future', nil, http_status: 400) + end + end + end + + def total_items_amount(items) + total = 0 + items.each do |item| + quantity = item[:quantity] || 1 + amount = item[:plan][:unit_amount] || item[:plan][:amount] + total += quantity * amount + end + total + end + end + end +end diff --git a/lib/stripe_mock/request_handlers/helpers/token_helpers.rb b/lib/stripe_mock/request_handlers/helpers/token_helpers.rb new file mode 100644 index 0000000..666ddcb --- /dev/null +++ b/lib/stripe_mock/request_handlers/helpers/token_helpers.rb @@ -0,0 +1,44 @@ +module StripeMock + module RequestHandlers + module Helpers + + def generate_bank_token(bank_params = {}) + token = new_id 'btok' + bank_params[:id] = new_id 'bank_account' + @bank_tokens[token] = Data.mock_bank_account bank_params + token + end + + def generate_card_token(card_params = {}) + token = new_id 'tok' + card_params[:id] = new_id 'cc' + @card_tokens[token] = Data.mock_card symbolize_names(card_params) + token + end + + def get_bank_by_token(token) + if token.nil? || @bank_tokens[token].nil? + Data.mock_bank_account + else + @bank_tokens.delete(token) + end + end + + def get_card_by_token(token) + if token.nil? || @card_tokens[token].nil? + # TODO: Make this strict + msg = "Invalid token id: #{token}" + raise Stripe::InvalidRequestError.new(msg, 'tok', http_status: 404) + else + @card_tokens.delete(token) + end + end + + def get_card_or_bank_by_token(token) + token_id = token['id'] || token + @card_tokens[token_id] || @bank_tokens[token_id] || raise(Stripe::InvalidRequestError.new("Invalid token id: #{token_id}", 'tok', http_status: 404)) + end + + end + end +end diff --git a/lib/stripe_mock/request_handlers/invoice_items.rb b/lib/stripe_mock/request_handlers/invoice_items.rb new file mode 100644 index 0000000..49d2eea --- /dev/null +++ b/lib/stripe_mock/request_handlers/invoice_items.rb @@ -0,0 +1,45 @@ +module StripeMock + module RequestHandlers + module InvoiceItems + + def InvoiceItems.included(klass) + klass.add_handler 'post /v1/invoiceitems', :new_invoice_item + klass.add_handler 'post /v1/invoiceitems/(.*)', :update_invoice_item + klass.add_handler 'get /v1/invoiceitems/(.*)', :get_invoice_item + klass.add_handler 'get /v1/invoiceitems', :list_invoice_items + klass.add_handler 'delete /v1/invoiceitems/(.*)', :delete_invoice_item + end + + def new_invoice_item(route, method_url, params, headers) + params[:id] ||= new_id('ii') + invoice_items[params[:id]] = Data.mock_invoice_item(params) + end + + def update_invoice_item(route, method_url, params, headers) + route =~ method_url + list_item = assert_existence :list_item, $1, invoice_items[$1] + list_item.merge!(params) + end + + def delete_invoice_item(route, method_url, params, headers) + route =~ method_url + assert_existence :list_item, $1, invoice_items[$1] + + invoice_items[$1] = { + id: invoice_items[$1][:id], + deleted: true + } + end + + def list_invoice_items(route, method_url, params, headers) + Data.mock_list_object(invoice_items.values, params) + end + + def get_invoice_item(route, method_url, params, headers) + route =~ method_url + assert_existence :invoice_item, $1, invoice_items[$1] + end + + end + end +end diff --git a/lib/stripe_mock/request_handlers/invoices.rb b/lib/stripe_mock/request_handlers/invoices.rb new file mode 100644 index 0000000..6530966 --- /dev/null +++ b/lib/stripe_mock/request_handlers/invoices.rb @@ -0,0 +1,183 @@ +module StripeMock + module RequestHandlers + module Invoices + + def Invoices.included(klass) + klass.add_handler 'post /v1/invoices', :new_invoice + klass.add_handler 'get /v1/invoices/upcoming', :upcoming_invoice + klass.add_handler 'get /v1/invoices/(.*)/lines', :get_invoice_line_items + klass.add_handler 'get /v1/invoices/(.*)', :get_invoice + klass.add_handler 'get /v1/invoices', :list_invoices + klass.add_handler 'post /v1/invoices/(.*)/pay', :pay_invoice + klass.add_handler 'post /v1/invoices/(.*)', :update_invoice + end + + def new_invoice(route, method_url, params, headers) + id = new_id('in') + invoice_item = Data.mock_line_item() + invoices[id] = Data.mock_invoice([invoice_item], params.merge(:id => id)) + end + + def update_invoice(route, method_url, params, headers) + route =~ method_url + params.delete(:lines) if params[:lines] + assert_existence :invoice, $1, invoices[$1] + invoices[$1].merge!(params) + end + + def list_invoices(route, method_url, params, headers) + params[:offset] ||= 0 + params[:limit] ||= 10 + + result = invoices.clone + + if params[:customer] + result.delete_if { |k,v| v[:customer] != params[:customer] } + end + + Data.mock_list_object(result.values, params) + end + + def get_invoice(route, method_url, params, headers) + route =~ method_url + assert_existence :invoice, $1, invoices[$1] + end + + def get_invoice_line_items(route, method_url, params, headers) + route =~ method_url + assert_existence :invoice, $1, invoices[$1] + invoices[$1][:lines] + end + + def pay_invoice(route, method_url, params, headers) + route =~ method_url + assert_existence :invoice, $1, invoices[$1] + charge = invoice_charge(invoices[$1]) + invoices[$1].merge!(:paid => true, :attempted => true, :charge => charge[:id]) + end + + def upcoming_invoice(route, method_url, params, headers = {}) + stripe_account = headers && headers[:stripe_account] || Stripe.api_key + route =~ method_url + raise Stripe::InvalidRequestError.new('Missing required param: customer if subscription is not provided', nil, http_status: 400) if params[:customer].nil? && params[:subscription].nil? + raise Stripe::InvalidRequestError.new('When previewing changes to a subscription, you must specify either `subscription` or `subscription_items`', nil, http_status: 400) if !params[:subscription_proration_date].nil? && params[:subscription].nil? && params[:subscription_plan].nil? + raise Stripe::InvalidRequestError.new('Cannot specify proration date without specifying a subscription', nil, http_status: 400) if !params[:subscription_proration_date].nil? && params[:subscription].nil? + + customer = customers[stripe_account][params[:customer]] + assert_existence :customer, params[:customer], customer + + raise Stripe::InvalidRequestError.new("No upcoming invoices for customer: #{customer[:id]}", nil, http_status: 404) if customer[:subscriptions][:data].length == 0 + + subscription = + if params[:subscription] + customer[:subscriptions][:data].select{|s|s[:id] == params[:subscription]}.first + else + customer[:subscriptions][:data].min_by { |sub| sub[:current_period_end] } + end + + if params[:subscription_proration_date] && !((subscription[:current_period_start]..subscription[:current_period_end]) === params[:subscription_proration_date]) + raise Stripe::InvalidRequestError.new('Cannot specify proration date outside of current subscription period', nil, http_status: 400) + end + + prorating = false + subscription_proration_date = nil + subscription_plan_id = params[:subscription_plan] || subscription[:plan][:id] + subscription_quantity = params[:subscription_quantity] || subscription[:quantity] + if subscription_plan_id != subscription[:plan][:id] || subscription_quantity != subscription[:quantity] + prorating = true + invoice_date = Time.now.to_i + subscription_plan = assert_existence :plan, subscription_plan_id, plans[subscription_plan_id.to_s] + preview_subscription = Data.mock_subscription + preview_subscription = resolve_subscription_changes(preview_subscription, [subscription_plan], customer, { trial_end: params[:subscription_trial_end] }) + preview_subscription[:id] = subscription[:id] + preview_subscription[:quantity] = subscription_quantity + subscription_proration_date = params[:subscription_proration_date] || Time.now + else + preview_subscription = subscription + invoice_date = subscription[:current_period_end] + end + + invoice_lines = [] + + if prorating + unused_amount = ( + subscription[:plan][:amount].to_f * + subscription[:quantity] * + (subscription[:current_period_end] - subscription_proration_date.to_i) / (subscription[:current_period_end] - subscription[:current_period_start]) + ).ceil + + invoice_lines << Data.mock_line_item( + id: new_id('ii'), + amount: -unused_amount, + description: 'Unused time', + plan: subscription[:plan], + period: { + start: subscription_proration_date.to_i, + end: subscription[:current_period_end] + }, + quantity: subscription[:quantity], + proration: true + ) + + preview_plan = assert_existence :plan, params[:subscription_plan], plans[params[:subscription_plan]] + if preview_plan[:interval] == subscription[:plan][:interval] && preview_plan[:interval_count] == subscription[:plan][:interval_count] && params[:subscription_trial_end].nil? + remaining_amount = preview_plan[:amount] * subscription_quantity * (subscription[:current_period_end] - subscription_proration_date.to_i) / (subscription[:current_period_end] - subscription[:current_period_start]) + invoice_lines << Data.mock_line_item( + id: new_id('ii'), + amount: remaining_amount, + description: 'Remaining time', + plan: preview_plan, + period: { + start: subscription_proration_date.to_i, + end: subscription[:current_period_end] + }, + quantity: subscription_quantity, + proration: true + ) + end + end + + subscription_line = get_mock_subscription_line_item(preview_subscription) + invoice_lines << subscription_line + + Data.mock_invoice(invoice_lines, + id: new_id('in'), + customer: customer[:id], + discount: customer[:discount], + created: invoice_date, + starting_balance: customer[:account_balance], + subscription: preview_subscription[:id], + period_start: prorating ? invoice_date : preview_subscription[:current_period_start], + period_end: prorating ? invoice_date : preview_subscription[:current_period_end], + next_payment_attempt: preview_subscription[:current_period_end] + 3600 ) + end + + private + + def get_mock_subscription_line_item(subscription) + Data.mock_line_item( + id: subscription[:id], + type: "subscription", + plan: subscription[:plan], + amount: subscription[:status] == 'trialing' ? 0 : subscription[:plan][:amount] * subscription[:quantity], + discountable: true, + quantity: subscription[:quantity], + period: { + start: subscription[:current_period_end], + end: get_ending_time(subscription[:current_period_start], subscription[:plan], 2) + }) + end + + ## charge the customer on the invoice, if one does not exist, create + #anonymous charge + def invoice_charge(invoice) + begin + new_charge(nil, nil, {customer: invoice[:customer]["id"], amount: invoice[:amount_due], currency: StripeMock.default_currency}, nil) + rescue Stripe::InvalidRequestError + new_charge(nil, nil, {source: generate_card_token, amount: invoice[:amount_due], currency: StripeMock.default_currency}, nil) + end + end + + end + end +end diff --git a/lib/stripe_mock/request_handlers/orders.rb b/lib/stripe_mock/request_handlers/orders.rb new file mode 100644 index 0000000..39dc298 --- /dev/null +++ b/lib/stripe_mock/request_handlers/orders.rb @@ -0,0 +1,80 @@ +module StripeMock + module RequestHandlers + module Orders + + def Orders.included(klass) + klass.add_handler 'post /v1/orders', :new_order + klass.add_handler 'post /v1/orders/(.*)/pay', :pay_order + klass.add_handler 'post /v1/orders/(.*)', :update_order + klass.add_handler 'get /v1/orders/(.*)', :get_order + klass.add_handler 'get /v1/orders', :list_orders + end + + def new_order(route, method_url, params, headers) + params[:id] ||= new_id('or') + order_items = [] + + unless params[:currency].to_s.size == 3 + raise Stripe::InvalidRequestError.new('You must supply a currency', nil, http_status: 400) + end + + if params[:items] + unless params[:items].is_a? Array + raise Stripe::InvalidRequestError.new('You must supply a list of items', nil, http_status: 400) + end + + unless params[:items].first.is_a? Hash + raise Stripe::InvalidRequestError.new('You must supply an item', nil, http_status: 400) + end + end + + orders[ params[:id] ] = Data.mock_order(order_items, params) + + orders[ params[:id] ] + end + + def update_order(route, method_url, params, headers) + route =~ method_url + order = assert_existence :order, $1, orders[$1] + + if params[:metadata] + if params[:metadata].empty? + order[:metadata] = {} + else + order[:metadata].merge(params[:metadata]) + end + end + + if %w(created paid canceled fulfilled returned).include? params[:status] + order[:status] = params[:status] + end + order + end + + def get_order(route, method_url, params, headers) + route =~ method_url + assert_existence :order, $1, orders[$1] + end + + def pay_order(route, method_url, params, headers) + route =~ method_url + order = assert_existence :order, $1, orders[$1] + + if params[:source].blank? && params[:customer].blank? + raise Stripe::InvalidRequestError.new('You must supply a source or customer', nil, http_status: 400) + end + + charge_id = new_id('ch') + charges[charge_id] = Data.mock_charge(id: charge_id) + order[:charge] = charge_id + order[:status] = "paid" + order + end + + def list_orders(route, method_url, params, headers) + Data.mock_list_object(orders.values, params) + end + + end + end +end diff --git a/lib/stripe_mock/request_handlers/payment_intents.rb b/lib/stripe_mock/request_handlers/payment_intents.rb new file mode 100644 index 0000000..5cefdd3 --- /dev/null +++ b/lib/stripe_mock/request_handlers/payment_intents.rb @@ -0,0 +1,187 @@ +module StripeMock + module RequestHandlers + module PaymentIntents + ALLOWED_PARAMS = [:description, :metadata, :receipt_email, :shipping, :destination, :payment_method, :payment_method_types, :setup_future_usage, :transfer_data, :amount, :currency] + + def PaymentIntents.included(klass) + klass.add_handler 'post /v1/payment_intents', :new_payment_intent + klass.add_handler 'get /v1/payment_intents', :get_payment_intents + klass.add_handler 'get /v1/payment_intents/(.*)', :get_payment_intent + klass.add_handler 'post /v1/payment_intents/(.*)/confirm', :confirm_payment_intent + klass.add_handler 'post /v1/payment_intents/(.*)/capture', :capture_payment_intent + klass.add_handler 'post /v1/payment_intents/(.*)/cancel', :cancel_payment_intent + klass.add_handler 'post /v1/payment_intents/(.*)', :update_payment_intent + end + + def new_payment_intent(route, method_url, params, headers) + id = new_id('pi') + + ensure_payment_intent_required_params(params) + status = case params[:amount] + when 3184 then 'requires_action' + when 3178 then 'requires_payment_method' + when 3055 then 'requires_capture' + else + 'succeeded' + end + last_payment_error = params[:amount] == 3178 ? last_payment_error_generator(code: 'card_declined', decline_code: 'insufficient_funds', message: 'Not enough funds.') : nil + payment_intents[id] = Data.mock_payment_intent( + params.merge( + id: id, + status: status, + last_payment_error: last_payment_error + ) + ) + + if params[:confirm] && status == 'succeeded' + payment_intents[id] = succeeded_payment_intent(payment_intents[id]) + end + + payment_intents[id].clone + end + + def update_payment_intent(route, method_url, params, headers) + route =~ method_url + id = $1 + + payment_intent = assert_existence :payment_intent, id, payment_intents[id] + payment_intents[id] = Util.rmerge(payment_intent, params.select{ |k,v| ALLOWED_PARAMS.include?(k)}) + end + + def get_payment_intents(route, method_url, params, headers) + params[:offset] ||= 0 + params[:limit] ||= 10 + + clone = payment_intents.clone + + if params[:customer] + clone.delete_if { |k,v| v[:customer] != params[:customer] } + end + + Data.mock_list_object(clone.values, params) + end + + def get_payment_intent(route, method_url, params, headers) + route =~ method_url + payment_intent_id = $1 || params[:payment_intent] + payment_intent = assert_existence :payment_intent, payment_intent_id, payment_intents[payment_intent_id] + + payment_intent = payment_intent.clone + payment_intent + end + + def capture_payment_intent(route, method_url, params, headers) + route =~ method_url + payment_intent = assert_existence :payment_intent, $1, payment_intents[$1] + + succeeded_payment_intent(payment_intent) + end + + def confirm_payment_intent(route, method_url, params, headers) + route =~ method_url + payment_intent = assert_existence :payment_intent, $1, payment_intents[$1] + + if params[:payment_method] + payment_intent[:payment_method] = params[:payment_method] + end + + succeeded_payment_intent(payment_intent) + end + + def cancel_payment_intent(route, method_url, params, headers) + route =~ method_url + payment_intent = assert_existence :payment_intent, $1, payment_intents[$1] + + payment_intent[:status] = 'canceled' + payment_intent + end + + private + + def ensure_payment_intent_required_params(params) + if params[:amount].nil? + require_param(:amount) + elsif params[:currency].nil? + require_param(:currency) + elsif non_integer_charge_amount?(params) + raise Stripe::InvalidRequestError.new("Invalid integer: #{params[:amount]}", 'amount', http_status: 400) + elsif non_positive_charge_amount?(params) + raise Stripe::InvalidRequestError.new('Invalid positive integer', 'amount', http_status: 400) + end + end + + def non_integer_charge_amount?(params) + params[:amount] && !params[:amount].is_a?(Integer) + end + + def non_positive_charge_amount?(params) + params[:amount] && params[:amount] < 1 + end + + def last_payment_error_generator(code: nil, message: nil, decline_code: nil) + { + code: code, + doc_url: "https://stripe.com/docs/error-codes/payment-intent-authentication-failure", + message: message, + decline_code: decline_code, + payment_method: { + id: "pm_1EwXFA2eZvKYlo2C0tlY091l", + object: "payment_method", + billing_details: { + address: { + city: nil, + country: nil, + line1: nil, + line2: nil, + postal_code: nil, + state: nil + }, + email: nil, + name: "seller_08072019090000", + phone: nil + }, + card: { + brand: "visa", + checks: { + address_line1_check: nil, + address_postal_code_check: nil, + cvc_check: "unchecked" + }, + country: "US", + exp_month: 12, + exp_year: 2021, + fingerprint: "LQBhEmJnItuj3mxf", + funding: "credit", + generated_from: nil, + last4: "1629", + three_d_secure_usage: { + supported: true + }, + wallet: nil + }, + created: 1563208900, + customer: nil, + livemode: false, + metadata: {}, + type: "card" + }, + type: "invalid_request_error" + } + end + + def succeeded_payment_intent(payment_intent) + payment_intent[:status] = 'succeeded' + btxn = new_balance_transaction('txn', { source: payment_intent[:id] }) + + payment_intent[:charges][:data] << Data.mock_charge( + balance_transaction: btxn, + amount: payment_intent[:amount], + currency: payment_intent[:currency], + payment_method: payment_intent[:payment_method] + ) + + payment_intent + end + end + end +end diff --git a/lib/stripe_mock/request_handlers/payment_methods.rb b/lib/stripe_mock/request_handlers/payment_methods.rb new file mode 100644 index 0000000..301e2be --- /dev/null +++ b/lib/stripe_mock/request_handlers/payment_methods.rb @@ -0,0 +1,124 @@ +module StripeMock + module RequestHandlers + module PaymentMethods + ALLOWED_PARAMS = [:customer, :type] + + def PaymentMethods.included(klass) + klass.add_handler 'post /v1/payment_methods', :new_payment_method + klass.add_handler 'get /v1/payment_methods/(.*)', :get_payment_method + klass.add_handler 'get /v1/payment_methods', :get_payment_methods + klass.add_handler 'post /v1/payment_methods/(.*)/attach', :attach_payment_method + klass.add_handler 'post /v1/payment_methods/(.*)/detach', :detach_payment_method + klass.add_handler 'post /v1/payment_methods/(.*)', :update_payment_method + end + + # post /v1/payment_methods + def new_payment_method(route, method_url, params, headers) + id = new_id('pm') + + ensure_payment_method_required_params(params) + + payment_methods[id] = Data.mock_payment_method( + params.merge( + id: id + ) + ) + + payment_methods[id].clone + end + + # + # params: {:type=>"card", :customer=>"test_cus_3"} + # + # get /v1/payment_methods/:id + def get_payment_method(route, method_url, params, headers) + id = method_url.match(route)[1] || params[:payment_method] + payment_method = assert_existence :payment_method, id, payment_methods[id] + + payment_method.clone + end + + # get /v1/payment_methods + def get_payment_methods(route, method_url, params, headers) + params[:offset] ||= 0 + params[:limit] ||= 10 + + clone = payment_methods.clone + + if params[:customer] + clone.delete_if { |_k, v| v[:customer] != params[:customer] } + end + + Data.mock_list_object(clone.values, params) + end + + # post /v1/payment_methods/:id/attach + def attach_payment_method(route, method_url, params, headers) + stripe_account = headers && headers[:stripe_account] || Stripe.api_key + allowed_params = [:customer] + + id = method_url.match(route)[1] + + assert_existence :customer, params[:customer], customers[stripe_account][params[:customer]] + + payment_method = assert_existence :payment_method, id, payment_methods[id] + payment_methods[id] = Util.rmerge(payment_method, params.select { |k, _v| allowed_params.include?(k) }) + payment_methods[id].clone + end + + # post /v1/payment_methods/:id/detach + def detach_payment_method(route, method_url, params, headers) + id = method_url.match(route)[1] + + payment_method = assert_existence :payment_method, id, payment_methods[id] + payment_method[:customer] = nil + + payment_method.clone + end + + # post /v1/payment_methods/:id + def update_payment_method(route, method_url, params, headers) + allowed_params = [:billing_details, :card, :metadata] + disallowed_params = params.keys - allowed_params + unless disallowed_params.empty? + raise Stripe::InvalidRequestError.new("Received unknown parameter: #{disallowed_params.first}", disallowed_params.first) + end + + id = method_url.match(route)[1] + + payment_method = assert_existence :payment_method, id, payment_methods[id] + + if payment_method[:customer].nil? + raise Stripe::InvalidRequestError.new( + 'You must save this PaymentMethod to a customer before you can update it.', + nil, + http_status: 400 + ) + end + + payment_methods[id] = + Util.rmerge(payment_method, params.select { |k, _v| allowed_params.include?(k)} ) + + payment_methods[id].clone + end + + private + + def ensure_payment_method_required_params(params) + require_param(:type) if params[:type].nil? + + if invalid_type?(params[:type]) + raise Stripe::InvalidRequestError.new( + 'Invalid type: must be one of card, ideal or sepa_debit', + nil, + http_status: 400 + ) + end + end + + def invalid_type?(type) + !%w(card ideal sepa_debit).include?(type) + end + end + end +end diff --git a/lib/stripe_mock/request_handlers/payouts.rb b/lib/stripe_mock/request_handlers/payouts.rb new file mode 100644 index 0000000..c23b833 --- /dev/null +++ b/lib/stripe_mock/request_handlers/payouts.rb @@ -0,0 +1,32 @@ +module StripeMock + module RequestHandlers + module Payouts + + def Payouts.included(klass) + klass.add_handler 'post /v1/payouts', :new_payout + klass.add_handler 'get /v1/payouts', :list_payouts + klass.add_handler 'get /v1/payouts/(.*)', :get_payout + end + + def new_payout(route, method_url, params, headers) + id = new_id('po') + + unless params[:amount].is_a?(Integer) || (params[:amount].is_a?(String) && /^\d+$/.match(params[:amount])) + raise Stripe::InvalidRequestError.new("Invalid integer: #{params[:amount]}", 'amount', http_status: 400) + end + + payouts[id] = Data.mock_payout(params.merge :id => id) + end + + def list_payouts(route, method_url, params, headers) + Data.mock_list_object(payouts.clone.values, params) + end + + def get_payout(route, method_url, params, headers) + route =~ method_url + assert_existence :payout, $1, payouts[$1] + payouts[$1] ||= Data.mock_payout(:id => $1) + end + end + end +end diff --git a/lib/stripe_mock/request_handlers/plans.rb b/lib/stripe_mock/request_handlers/plans.rb new file mode 100644 index 0000000..feae495 --- /dev/null +++ b/lib/stripe_mock/request_handlers/plans.rb @@ -0,0 +1,42 @@ +module StripeMock + module RequestHandlers + module Plans + + def Plans.included(klass) + klass.add_handler 'post /v1/plans', :new_plan + klass.add_handler 'post /v1/plans/(.*)', :update_plan + klass.add_handler 'get /v1/plans/(.*)', :get_plan + klass.add_handler 'delete /v1/plans/(.*)', :delete_plan + klass.add_handler 'get /v1/plans', :list_plans + end + + def new_plan(route, method_url, params, headers) + params[:id] ||= new_id('plan') + validate_create_plan_params(params) + plans[ params[:id] ] = Data.mock_plan(params) + end + + def update_plan(route, method_url, params, headers) + route =~ method_url + assert_existence :plan, $1, plans[$1] + plans[$1].merge!(params) + end + + def get_plan(route, method_url, params, headers) + route =~ method_url + assert_existence :plan, $1, plans[$1] + end + + def delete_plan(route, method_url, params, headers) + route =~ method_url + assert_existence :plan, $1, plans.delete($1) + end + + def list_plans(route, method_url, params, headers) + limit = params[:limit] ? params[:limit] : 10 + Data.mock_list_object(plans.values.first(limit), params.merge!(limit: limit)) + end + + end + end +end diff --git a/lib/stripe_mock/request_handlers/prices.rb b/lib/stripe_mock/request_handlers/prices.rb new file mode 100644 index 0000000..46146a4 --- /dev/null +++ b/lib/stripe_mock/request_handlers/prices.rb @@ -0,0 +1,50 @@ +module StripeMock + module RequestHandlers + module Prices + + def Prices.included(klass) + klass.add_handler 'post /v1/prices', :new_price + klass.add_handler 'post /v1/prices/(.*)', :update_price + klass.add_handler 'get /v1/prices/(.*)', :get_price + klass.add_handler 'get /v1/prices', :list_prices + end + + def new_price(route, method_url, params, headers) + params[:id] ||= new_id('price') + + if params[:product_data] + params[:product] = create_product(nil, nil, params[:product_data], nil)[:id] unless params[:product] + params.delete(:product_data) + end + + validate_create_price_params(params) + prices[ params[:id] ] = Data.mock_price(params) + end + + def update_price(route, method_url, params, headers) + route =~ method_url + assert_existence :price, $1, prices[$1] + prices[$1].merge!(params) + end + + def get_price(route, method_url, params, headers) + route =~ method_url + assert_existence :price, $1, prices[$1] + end + + def list_prices(route, method_url, params, headers) + limit = params[:limit] ? params[:limit] : 10 + price_data = prices.values + validate_list_prices_params(params) + + if params.key?(:lookup_keys) + price_data.select! do |price| + params[:lookup_keys].include?(price[:lookup_key]) + end + end + + Data.mock_list_object(price_data.first(limit), params.merge!(limit: limit)) + end + end + end +end diff --git a/lib/stripe_mock/request_handlers/products.rb b/lib/stripe_mock/request_handlers/products.rb new file mode 100644 index 0000000..a75461c --- /dev/null +++ b/lib/stripe_mock/request_handlers/products.rb @@ -0,0 +1,44 @@ +module StripeMock + module RequestHandlers + module Products + def self.included(base) + base.add_handler 'post /v1/products', :create_product + base.add_handler 'get /v1/products/(.*)', :retrieve_product + base.add_handler 'post /v1/products/(.*)', :update_product + base.add_handler 'get /v1/products', :list_products + base.add_handler 'delete /v1/products/(.*)', :destroy_product + end + + def create_product(_route, _method_url, params, _headers) + params[:id] ||= new_id('prod') + validate_create_product_params(params) + products[params[:id]] = Data.mock_product(params) + end + + def retrieve_product(route, method_url, _params, _headers) + id = method_url.match(route).captures.first + assert_existence :product, id, products[id] + end + + def update_product(route, method_url, params, _headers) + id = method_url.match(route).captures.first + product = assert_existence :product, id, products[id] + + product.merge!(params) + end + + def list_products(_route, _method_url, params, _headers) + limit = params[:limit] || 10 + Data.mock_list_object(products.values.take(limit), params) + end + + def destroy_product(route, method_url, _params, _headers) + id = method_url.match(route).captures.first + assert_existence :product, id, products[id] + + products.delete(id) + { id: id, object: 'product', deleted: true } + end + end + end +end diff --git a/lib/stripe_mock/request_handlers/recipients.rb b/lib/stripe_mock/request_handlers/recipients.rb new file mode 100644 index 0000000..956e136 --- /dev/null +++ b/lib/stripe_mock/request_handlers/recipients.rb @@ -0,0 +1,60 @@ +module StripeMock + module RequestHandlers + module Recipients + + def Recipients.included(klass) + klass.add_handler 'post /v1/recipients', :new_recipient + klass.add_handler 'post /v1/recipients/(.*)', :update_recipient + klass.add_handler 'get /v1/recipients/(.*)', :get_recipient + end + + def new_recipient(route, method_url, params, headers) + params[:id] ||= new_id('rp') + cards = [] + + if params[:name].nil? + raise StripeMock::StripeMockError.new("Missing required parameter name for recipients.") + end + + if params[:type].nil? + raise StripeMock::StripeMockError.new("Missing required parameter type for recipients.") + end + + unless %w(individual corporation).include?(params[:type]) + raise StripeMock::StripeMockError.new("Type must be either individual or corporation..") + end + + if params[:bank_account] + params[:active_account] = get_bank_by_token(params.delete(:bank_account)) + end + + if params[:card] + cards << get_card_by_token(params.delete(:card)) + params[:default_card] = cards.first[:id] + end + + recipients[ params[:id] ] = Data.mock_recipient(cards, params) + recipients[ params[:id] ] + end + + def update_recipient(route, method_url, params, headers) + route =~ method_url + recipient = assert_existence :recipient, $1, recipients[$1] + recipient.merge!(params) + + if params[:card] + new_card = get_card_by_token(params.delete(:card)) + add_card_to_object(:recipient, new_card, recipient, true) + recipient[:default_card] = new_card[:id] + end + + recipient + end + + def get_recipient(route, method_url, params, headers) + route =~ method_url + assert_existence :recipient, $1, recipients[$1] + end + end + end +end diff --git a/lib/stripe_mock/request_handlers/refunds.rb b/lib/stripe_mock/request_handlers/refunds.rb new file mode 100644 index 0000000..62aabde --- /dev/null +++ b/lib/stripe_mock/request_handlers/refunds.rb @@ -0,0 +1,102 @@ +module StripeMock + module RequestHandlers + module Refunds + + def Refunds.included(klass) + klass.add_handler 'post /v1/refunds', :new_refund + klass.add_handler 'get /v1/refunds', :get_refunds + klass.add_handler 'get /v1/refunds/(.*)', :get_refund + klass.add_handler 'post /v1/refunds/(.*)', :update_refund + end + + def new_refund(route, method_url, params, headers) + if headers && headers[:idempotency_key] + params[:idempotency_key] = headers[:idempotency_key] + if refunds.any? + original_refund = refunds.values.find { |c| c[:idempotency_key] == headers[:idempotency_key]} + return refunds[original_refund[:id]] if original_refund + end + end + + if params[:payment_intent] + payment_intent = assert_existence( + :payment_intent, + params[:payment_intent], + payment_intents[params[:payment_intent]] + ) + charge = {} + else + charge = assert_existence :charge, params[:charge], charges[params[:charge]] + payment_intent = {} + end + params[:amount] ||= payment_intent[:amount] + params[:amount] ||= charge[:amount] + id = new_id('re') + bal_trans_params = { + amount: params[:amount] * -1, + source: id, + type: 'refund' + } + balance_transaction_id = new_balance_transaction('txn', bal_trans_params) + refund = Data.mock_refund params.merge( + :balance_transaction => balance_transaction_id, + :id => id, + :charge => charge[:id], + ) + add_refund_to_charge(refund, charge) unless charge.empty? + refunds[id] = refund + + if params[:expand] == ['balance_transaction'] + refunds[id][:balance_transaction] = + balance_transactions[balance_transaction_id] + end + refund + end + + def update_refund(route, method_url, params, headers) + route =~ method_url + id = $1 + + refund = assert_existence :refund, id, refunds[id] + allowed = allowed_refund_params(params) + disallowed = params.keys - allowed + if disallowed.count > 0 + raise Stripe::InvalidRequestError.new("Received unknown parameters: #{disallowed.join(', ')}" , '', http_status: 400) + end + + refunds[id] = Util.rmerge(refund, params) + end + + def get_refunds(route, method_url, params, headers) + params[:offset] ||= 0 + params[:limit] ||= 10 + + clone = refunds.clone + + Data.mock_list_object(clone.values, params) + end + + def get_refund(route, method_url, params, headers) + route =~ method_url + refund_id = $1 || params[:refund] + assert_existence :refund, refund_id, refunds[refund_id] + end + + private + + def ensure_refund_required_params(params) + if non_integer_charge_amount?(params) + raise Stripe::InvalidRequestError.new("Invalid integer: #{params[:amount]}", 'amount', http_status: 400) + elsif non_positive_charge_amount?(params) + raise Stripe::InvalidRequestError.new('Invalid positive integer', 'amount', http_status: 400) + elsif params[:charge].nil? + raise Stripe::InvalidRequestError.new('Must provide the identifier of the charge to refund.', nil) + end + end + + def allowed_refund_params(params) + [:metadata] + end + end + end +end diff --git a/lib/stripe_mock/request_handlers/setup_intents.rb b/lib/stripe_mock/request_handlers/setup_intents.rb new file mode 100644 index 0000000..8c6cd42 --- /dev/null +++ b/lib/stripe_mock/request_handlers/setup_intents.rb @@ -0,0 +1,98 @@ +module StripeMock + module RequestHandlers + module SetupIntents + ALLOWED_PARAMS = [ + :confirm, + :customer, + :description, + :metadata, + :on_behalf_of, + :payment_method, + :payment_method_options, + :payment_method_types, + :return_url, + :usage + ] + + def SetupIntents.included(klass) + klass.add_handler 'post /v1/setup_intents', :new_setup_intent + klass.add_handler 'get /v1/setup_intents', :get_setup_intents + klass.add_handler 'get /v1/setup_intents/(.*)', :get_setup_intent + klass.add_handler 'post /v1/setup_intents/(.*)/confirm', :confirm_setup_intent + klass.add_handler 'post /v1/setup_intents/(.*)/cancel', :cancel_setup_intent + klass.add_handler 'post /v1/setup_intents/(.*)', :update_setup_intent + end + + def new_setup_intent(route, method_url, params, headers) + id = new_id('si') + + setup_intents[id] = Data.mock_setup_intent( + params.merge( + id: id + ) + ) + + setup_intents[id].clone + end + + def update_setup_intent(route, method_url, params, headers) + route =~ method_url + id = $1 + + setup_intent = assert_existence :setup_intent, id, setup_intents[id] + setup_intents[id] = Util.rmerge(setup_intent, params.select { |k, v| ALLOWED_PARAMS.include?(k) }) + end + + def get_setup_intents(route, method_url, params, headers) + params[:offset] ||= 0 + params[:limit] ||= 10 + + clone = setup_intents.clone + + if params[:customer] + clone.delete_if { |k, v| v[:customer] != params[:customer] } + end + + Data.mock_list_object(clone.values, params) + end + + def get_setup_intent(route, method_url, params, headers) + route =~ method_url + setup_intent_id = $1 || params[:setup_intent] + setup_intent = assert_existence :setup_intent, setup_intent_id, setup_intents[setup_intent_id] + + setup_intent = setup_intent.clone + + if params[:expand]&.include?("payment_method") + setup_intent[:payment_method] = assert_existence :payment_method, setup_intent[:payment_method], payment_methods[setup_intent[:payment_method]] + end + + setup_intent + end + + def capture_setup_intent(route, method_url, params, headers) + route =~ method_url + setup_intent = assert_existence :setup_intent, $1, setup_intents[$1] + + setup_intent[:status] = 'succeeded' + setup_intent + end + + def confirm_setup_intent(route, method_url, params, headers) + route =~ method_url + setup_intent = assert_existence :setup_intent, $1, setup_intents[$1] + + setup_intent[:status] = 'succeeded' + setup_intent + end + + def cancel_setup_intent(route, method_url, params, headers) + route =~ method_url + setup_intent = assert_existence :setup_intent, $1, setup_intents[$1] + + setup_intent[:status] = 'canceled' + setup_intent + end + end + end +end diff --git a/lib/stripe_mock/request_handlers/sources.rb b/lib/stripe_mock/request_handlers/sources.rb new file mode 100644 index 0000000..3439691 --- /dev/null +++ b/lib/stripe_mock/request_handlers/sources.rb @@ -0,0 +1,61 @@ +module StripeMock + module RequestHandlers + module Sources + + def Sources.included(klass) + klass.add_handler 'get /v1/customers/(.*)/sources', :retrieve_sources + klass.add_handler 'post /v1/customers/(.*)/sources', :create_source + klass.add_handler 'post /v1/customers/(.*)/sources/(.*)/verify', :verify_source + klass.add_handler 'get /v1/customers/(.*)/sources/(.*)', :retrieve_source + klass.add_handler 'delete /v1/customers/(.*)/sources/(.*)', :delete_source + klass.add_handler 'post /v1/customers/(.*)/sources/(.*)', :update_source + end + + def create_source(route, method_url, params, headers) + stripe_account = headers && headers[:stripe_account] || Stripe.api_key + route =~ method_url + add_source_to(:customer, $1, params, customers[stripe_account]) + end + + def retrieve_sources(route, method_url, params, headers) + stripe_account = headers && headers[:stripe_account] || Stripe.api_key + route =~ method_url + retrieve_object_cards(:customer, $1, customers[stripe_account]) + end + + def retrieve_source(route, method_url, params, headers) + stripe_account = headers && headers[:stripe_account] || Stripe.api_key + route =~ method_url + customer = assert_existence :customer, $1, customers[stripe_account][$1] + + assert_existence :card, $2, get_card(customer, $2) + end + + def delete_source(route, method_url, params, headers) + stripe_account = headers && headers[:stripe_account] || Stripe.api_key + route =~ method_url + delete_card_from(:customer, $1, $2, customers[stripe_account]) + end + + def update_source(route, method_url, params, headers) + stripe_account = headers && headers[:stripe_account] || Stripe.api_key + route =~ method_url + customer = assert_existence :customer, $1, customers[stripe_account][$1] + + card = assert_existence :card, $2, get_card(customer, $2) + card.merge!(params) + card + end + + def verify_source(route, method_url, params, headers) + stripe_account = headers && headers[:stripe_account] || Stripe.api_key + route =~ method_url + customer = assert_existence :customer, $1, customers[stripe_account][$1] + + bank_account = assert_existence :bank_account, $2, verify_bank_account(customer, $2) + bank_account + end + + end + end +end diff --git a/lib/stripe_mock/request_handlers/subscription_items.rb b/lib/stripe_mock/request_handlers/subscription_items.rb new file mode 100644 index 0000000..1c33b4d --- /dev/null +++ b/lib/stripe_mock/request_handlers/subscription_items.rb @@ -0,0 +1,36 @@ +module StripeMock + module RequestHandlers + module SubscriptionItems + + def SubscriptionItems.included(klass) + klass.add_handler 'get /v1/subscription_items', :retrieve_subscription_items + klass.add_handler 'post /v1/subscription_items/([^/]*)', :update_subscription_item + klass.add_handler 'post /v1/subscription_items', :create_subscription_items + end + + def retrieve_subscription_items(route, method_url, params, headers) + route =~ method_url + + require_param(:subscription) unless params[:subscription] + + Data.mock_list_object(subscriptions_items, params) + end + + def create_subscription_items(route, method_url, params, headers) + params[:id] ||= new_id('si') + + require_param(:subscription) unless params[:subscription] + require_param(:plan) unless params[:plan] + + subscriptions_items[params[:id]] = Data.mock_subscription_item(params.merge(plan: plans[params[:plan]])) + end + + def update_subscription_item(route, method_url, params, headers) + route =~ method_url + + subscription_item = assert_existence :subscription_item, $1, subscriptions_items[$1] + subscription_item.merge!(params.merge(plan: plans[params[:plan]])) + end + end + end +end diff --git a/lib/stripe_mock/request_handlers/subscriptions.rb b/lib/stripe_mock/request_handlers/subscriptions.rb new file mode 100644 index 0000000..d8c31d1 --- /dev/null +++ b/lib/stripe_mock/request_handlers/subscriptions.rb @@ -0,0 +1,359 @@ +module StripeMock + module RequestHandlers + module Subscriptions + + def Subscriptions.included(klass) + klass.add_handler 'get /v1/subscriptions', :retrieve_subscriptions + klass.add_handler 'post /v1/subscriptions', :create_subscription + klass.add_handler 'get /v1/subscriptions/(.*)', :retrieve_subscription + klass.add_handler 'post /v1/subscriptions/(.*)', :update_subscription + klass.add_handler 'delete /v1/subscriptions/(.*)', :cancel_subscription + + klass.add_handler 'post /v1/customers/(.*)/subscription(?:s)?', :create_customer_subscription + klass.add_handler 'get /v1/customers/(.*)/subscription(?:s)?/(.*)', :retrieve_customer_subscription + klass.add_handler 'get /v1/customers/(.*)/subscription(?:s)?', :retrieve_customer_subscriptions + klass.add_handler 'post /v1/customers/(.*)subscription(?:s)?/(.*)', :update_subscription + klass.add_handler 'delete /v1/customers/(.*)/subscription(?:s)?/(.*)', :cancel_subscription + end + + def retrieve_customer_subscription(route, method_url, params, headers) + stripe_account = headers && headers[:stripe_account] || Stripe.api_key + route =~ method_url + + customer = assert_existence :customer, $1, customers[stripe_account][$1] + subscription = get_customer_subscription(customer, $2) + + assert_existence :subscription, $2, subscription + end + + def retrieve_customer_subscriptions(route, method_url, params, headers) + stripe_account = headers && headers[:stripe_account] || Stripe.api_key + route =~ method_url + + customer = assert_existence :customer, $1, customers[stripe_account][$1] + customer[:subscriptions] + end + + def create_customer_subscription(route, method_url, params, headers) + stripe_account = headers && headers[:stripe_account] || Stripe.api_key + route =~ method_url + + subscription_plans = get_subscription_plans_from_params(params) + customer = assert_existence :customer, $1, customers[stripe_account][$1] + + if params[:source] + new_card = get_card_by_token(params.delete(:source)) + add_card_to_object(:customer, new_card, customer) + customer[:default_source] = new_card[:id] + end + + subscription = Data.mock_subscription({ id: (params[:id] || new_id('su')) }) + subscription = resolve_subscription_changes(subscription, subscription_plans, customer, params) + + # Ensure customer has card to charge if plan has no trial and is not free + # Note: needs updating for subscriptions with multiple plans + verify_card_present(customer, subscription_plans.first, subscription, params) + + if params[:coupon] + coupon_id = params[:coupon] + + # assert_existence returns 404 error code but Stripe returns 400 + # coupon = assert_existence :coupon, coupon_id, coupons[coupon_id] + + coupon = coupons[coupon_id] + + if coupon + add_coupon_to_object(subscription, coupon) + else + raise Stripe::InvalidRequestError.new("No such coupon: #{coupon_id}", 'coupon', http_status: 400) + end + end + + subscriptions[subscription[:id]] = subscription + add_subscription_to_customer(customer, subscription) + + subscriptions[subscription[:id]] + end + + def create_subscription(route, method_url, params, headers) + stripe_account = headers && headers[:stripe_account] || Stripe.api_key + if headers && headers[:idempotency_key] + if subscriptions.any? + original_subscription = subscriptions.values.find { |c| c[:idempotency_key] == headers[:idempotency_key]} + return subscriptions[original_subscription[:id]] if original_subscription + end + end + route =~ method_url + + subscription_plans = get_subscription_plans_from_params(params) + + customer = params[:customer] + customer_id = customer.is_a?(Stripe::Customer) ? customer[:id] : customer.to_s + customer = assert_existence :customer, customer_id, customers[stripe_account][customer_id] + + if params[:source] + new_card = get_card_by_token(params.delete(:source)) + add_card_to_object(:customer, new_card, customer) + customer[:default_source] = new_card[:id] + end + + allowed_params = %w(customer application_fee_percent coupon items metadata plan quantity source tax_percent trial_end trial_period_days current_period_start created prorate billing_cycle_anchor billing days_until_due idempotency_key enable_incomplete_payments cancel_at_period_end default_tax_rates payment_behavior pending_invoice_item_interval default_payment_method collection_method off_session trial_from_plan proration_behavior backdate_start_date transfer_data expand automatic_tax) + unknown_params = params.keys - allowed_params.map(&:to_sym) + if unknown_params.length > 0 + raise Stripe::InvalidRequestError.new("Received unknown parameter: #{unknown_params.join}", unknown_params.first.to_s, http_status: 400) + end + + subscription = Data.mock_subscription({ id: (params[:id] || new_id('su')) }) + subscription = resolve_subscription_changes(subscription, subscription_plans, customer, params) + if headers[:idempotency_key] + subscription[:idempotency_key] = headers[:idempotency_key] + end + + # Ensure customer has card to charge if plan has no trial and is not free + # Note: needs updating for subscriptions with multiple plans + verify_card_present(customer, subscription_plans.first, subscription, params) + + if params[:coupon] + coupon_id = params[:coupon] + + # assert_existence returns 404 error code but Stripe returns 400 + # coupon = assert_existence :coupon, coupon_id, coupons[coupon_id] + + coupon = coupons[coupon_id] + + if coupon + add_coupon_to_object(subscription, coupon) + else + raise Stripe::InvalidRequestError.new("No such coupon: #{coupon_id}", 'coupon', http_status: 400) + end + end + + if params[:trial_period_days] + subscription[:status] = 'trialing' + end + + if params[:payment_behavior] == 'default_incomplete' + subscription[:status] = 'incomplete' + end + + if params[:cancel_at_period_end] + subscription[:cancel_at_period_end] = true + subscription[:canceled_at] = Time.now.utc.to_i + end + + if params[:transfer_data] && !params[:transfer_data].empty? + throw Stripe::InvalidRequestError.new(missing_param_message("transfer_data[destination]")) unless params[:transfer_data][:destination] + subscription[:transfer_data] = params[:transfer_data].dup + subscription[:transfer_data][:amount_percent] ||= 100 + end + + if (s = params[:expand]&.find { |s| s.start_with? 'latest_invoice' }) + payment_intent = nil + unless subscription[:status] == 'trialing' + intent_status = subscription[:status] == 'incomplete' ? 'requires_payment_method' : 'succeeded' + intent = Data.mock_payment_intent({ + status: intent_status, + amount: subscription[:plan][:amount], + currency: subscription[:plan][:currency] + }) + payment_intent = s.include?('latest_invoice.payment_intent') ? intent : intent.id + end + invoice = Data.mock_invoice([], { payment_intent: payment_intent }) + subscription[:latest_invoice] = invoice + end + + subscriptions[subscription[:id]] = subscription + add_subscription_to_customer(customer, subscription) + + subscriptions[subscription[:id]] + end + + def retrieve_subscription(route, method_url, params, headers) + route =~ method_url + + assert_existence :subscription, $1, subscriptions[$1] + end + + def retrieve_subscriptions(route, method_url, params, headers) + # stripe_account = headers && headers[:stripe_account] || Stripe.api_key + route =~ method_url + + subs = subscriptions.values + + case params[:status] + when nil + subs = subs.filter {|subscription| subscription[:status] != "canceled"} + when "all" + # Include all subscriptions + else + subs = subs.filter {|subscription| subscription[:status] == params[:status]} + end + + Data.mock_list_object(subs, params) + end + + def update_subscription(route, method_url, params, headers) + stripe_account = headers && headers[:stripe_account] || Stripe.api_key + route =~ method_url + + if params[:billing_cycle_anchor] == 'now' + params[:billing_cycle_anchor] = Time.now.utc.to_i + end + + subscription_id = $2 ? $2 : $1 + subscription = assert_existence :subscription, subscription_id, subscriptions[subscription_id] + verify_active_status(subscription) + + customer_id = subscription[:customer] + customer = assert_existence :customer, customer_id, customers[stripe_account][customer_id] + + if params[:source] + new_card = get_card_by_token(params.delete(:source)) + add_card_to_object(:customer, new_card, customer) + customer[:default_source] = new_card[:id] + end + + subscription_plans = get_subscription_plans_from_params(params) + + # subscription plans are not being updated but load them for the response + if subscription_plans.empty? + subscription_plans = subscription[:items][:data].map { |item| item[:plan] } + end + + if params[:coupon] + coupon_id = params[:coupon] + + # assert_existence returns 404 error code but Stripe returns 400 + # coupon = assert_existence :coupon, coupon_id, coupons[coupon_id] + + coupon = coupons[coupon_id] + if coupon + add_coupon_to_object(subscription, coupon) + elsif coupon_id == "" + subscription[:discount] = nil + else + raise Stripe::InvalidRequestError.new("No such coupon: #{coupon_id}", 'coupon', http_status: 400) + end + end + + if params[:trial_period_days] + subscription[:status] = 'trialing' + end + + if params[:cancel_at_period_end] + subscription[:cancel_at_period_end] = true + subscription[:canceled_at] = Time.now.utc.to_i + elsif params.has_key?(:cancel_at_period_end) + subscription[:cancel_at_period_end] = false + subscription[:canceled_at] = nil + end + + params[:current_period_start] = subscription[:current_period_start] + params[:trial_end] = params[:trial_end] || subscription[:trial_end] + + plan_amount_was = subscription.dig(:plan, :amount) + + subscription = resolve_subscription_changes(subscription, subscription_plans, customer, params) + + verify_card_present(customer, subscription_plans.first, subscription, params) if plan_amount_was == 0 && subscription.dig(:plan, :amount) && subscription.dig(:plan, :amount) > 0 + + # delete the old subscription, replace with the new subscription + customer[:subscriptions][:data].reject! { |sub| sub[:id] == subscription[:id] } + customer[:subscriptions][:data] << subscription + + subscription + end + + def cancel_subscription(route, method_url, params, headers) + stripe_account = headers && headers[:stripe_account] || Stripe.api_key + route =~ method_url + + subscription_id = $2 ? $2 : $1 + subscription = assert_existence :subscription, subscription_id, subscriptions[subscription_id] + + customer_id = subscription[:customer] + customer = assert_existence :customer, customer_id, customers[stripe_account][customer_id] + + cancel_params = { canceled_at: Time.now.utc.to_i } + cancelled_at_period_end = (params[:at_period_end] == true) + if cancelled_at_period_end + cancel_params[:cancel_at_period_end] = true + else + cancel_params[:status] = 'canceled' + cancel_params[:cancel_at_period_end] = false + cancel_params[:ended_at] = Time.now.utc.to_i + end + + subscription.merge!(cancel_params) + + unless cancelled_at_period_end + delete_subscription_from_customer customer, subscription + end + + subscription + end + + private + + def get_subscription_plans_from_params(params) + plan_ids = if params[:plan] + [params[:plan].to_s] + elsif params[:items] + items = params[:items] + items = items.values if items.respond_to?(:values) + items.map { |item| item[:plan] ? item[:plan] : item[:price] } + else + [] + end + plan_ids.compact! + plan_ids.each do |plan_id| + assert_existence :plan, plan_id, plans[plan_id] + rescue Stripe::InvalidRequestError + assert_existence :price, plan_id, prices[plan_id] + end + plan_ids.map { |plan_id| plans[plan_id] || prices[plan_id]} + end + + # Ensure customer has card to charge unless one of the following criterias is met: + # 1) is in trial + # 2) is free + # 3) has billing set to send invoice + def verify_card_present(customer, plan, subscription, params={}) + return if customer[:default_source] + return if customer[:invoice_settings][:default_payment_method] + return if customer[:trial_end] + return if params[:trial_end] + return if params[:payment_behavior] == 'default_incomplete' + return if subscription[:default_payment_method] + + plan_trial_period_days = plan[:trial_period_days] || 0 + plan_has_trial = plan_trial_period_days != 0 || plan[:amount] == 0 || plan[:trial_end] + return if plan && plan_has_trial + + return if subscription && subscription[:trial_end] && subscription[:trial_end] != 'now' + + if subscription[:items] + trial = subscription[:items][:data].none? do |item| + plan = item[:plan] + (plan[:trial_period_days].nil? || plan[:trial_period_days] == 0) && + (plan[:trial_end].nil? || plan[:trial_end] == 'now') + end + return if trial + end + + return if params[:billing] == 'send_invoice' + + raise Stripe::InvalidRequestError.new('This customer has no attached payment source', nil, http_status: 400) + end + + def verify_active_status(subscription) + id, status = subscription.values_at(:id, :status) + + if status == 'canceled' + message = "No such subscription: #{id}" + raise Stripe::InvalidRequestError.new(message, 'subscription', http_status: 404) + end + end + end + end +end diff --git a/lib/stripe_mock/request_handlers/tax_rates.rb b/lib/stripe_mock/request_handlers/tax_rates.rb new file mode 100644 index 0000000..212ad3f --- /dev/null +++ b/lib/stripe_mock/request_handlers/tax_rates.rb @@ -0,0 +1,36 @@ +module StripeMock + module RequestHandlers + module TaxRates + def TaxRates.included(klass) + klass.add_handler 'post /v1/tax_rates', :new_tax_rate + klass.add_handler 'post /v1/tax_rates/([^/]*)', :update_tax_rate + klass.add_handler 'get /v1/tax_rates/([^/]*)', :get_tax_rate + klass.add_handler 'get /v1/tax_rates', :list_tax_rates + end + + def update_tax_rate(route, method_url, params, headers) + route =~ method_url + rate = assert_existence :tax_rate, $1, tax_rates[$1] + rate.merge!(params) + rate + end + + def new_tax_rate(route, method_url, params, headers) + params[:id] ||= new_id('txr') + tax_rates[ params[:id] ] = Data.mock_tax_rate(params) + tax_rates[ params[:id] ] + end + + def list_tax_rates(route, method_url, params, headers) + Data.mock_list_object(tax_rates.values, params) + end + + def get_tax_rate(route, method_url, params, headers) + route =~ method_url + tax_rate = assert_existence :tax_rate, $1, tax_rates[$1] + tax_rate.clone + end + end + end +end + diff --git a/lib/stripe_mock/request_handlers/tokens.rb b/lib/stripe_mock/request_handlers/tokens.rb new file mode 100644 index 0000000..d46ecf3 --- /dev/null +++ b/lib/stripe_mock/request_handlers/tokens.rb @@ -0,0 +1,77 @@ +module StripeMock + module RequestHandlers + module Tokens + + def Tokens.included(klass) + klass.add_handler 'post /v1/tokens', :create_token + klass.add_handler 'get /v1/tokens/(.*)', :get_token + end + + def create_token(route, method_url, params, headers) + stripe_account = headers && headers[:stripe_account] || Stripe.api_key + + if params[:customer].nil? && params[:card].nil? && params[:bank_account].nil? + raise Stripe::InvalidRequestError.new('You must supply either a card, customer, or bank account to create a token.', nil, http_status: 400) + end + + cus_id = params[:customer] + + if cus_id && params[:source] + customer = assert_existence :customer, cus_id, customers[stripe_account][cus_id] + + # params[:card] is an id; grab it from the db + customer_card = get_card(customer, params[:source]) + assert_existence :card, params[:source], customer_card + elsif params[:card].is_a?(String) + customer = assert_existence :customer, cus_id, customers[stripe_account][cus_id] + + # params[:card] is an id; grab it from the db + customer_card = get_card(customer, params[:card]) + assert_existence :card, params[:card], customer_card + elsif params[:card] + # params[:card] is a hash of cc info; "Sanitize" the card number + params[:card][:fingerprint] = StripeMock::Util.fingerprint(params[:card][:number]) + params[:card][:last4] = params[:card][:number][-4,4] + customer_card = params[:card] + elsif params[:bank_account].is_a?(String) + customer = assert_existence :customer, cus_id, customers[stripe_account][cus_id] + + # params[:bank_account] is an id; grab it from the db + bank_account = verify_bank_account(customer, params[:bank_account]) + assert_existence :bank_account, params[:bank_account], bank_account + elsif params[:bank_account] + # params[:card] is a hash of cc info; "Sanitize" the card number + bank_account = params[:bank_account] + else + customer = assert_existence :customer, cus_id, customers[stripe_account][cus_id] || customers[Stripe.api_key][cus_id] + customer_card = get_card(customer, customer[:default_source]) + end + + if bank_account + token_id = generate_bank_token(bank_account.dup) + bank_account = @bank_tokens[token_id] + + Data.mock_bank_account_token(params.merge :id => token_id, :bank_account => bank_account) + else + token_id = generate_card_token(customer_card.dup) + card = @card_tokens[token_id] + + Data.mock_card_token(params.merge :id => token_id, :card => card) + end + end + + def get_token(route, method_url, params, headers) + route =~ method_url + # A Stripe token can be either a bank token or a card token + bank_or_card = @bank_tokens[$1] || @card_tokens[$1] + assert_existence :token, $1, bank_or_card + + if bank_or_card[:object] == 'card' + Data.mock_card_token(:id => $1, :card => bank_or_card) + elsif bank_or_card[:object] == 'bank_account' + Data.mock_bank_account_token(:id => $1, :bank_account => bank_or_card) + end + end + end + end +end diff --git a/lib/stripe_mock/request_handlers/transfers.rb b/lib/stripe_mock/request_handlers/transfers.rb new file mode 100644 index 0000000..6e04ea3 --- /dev/null +++ b/lib/stripe_mock/request_handlers/transfers.rb @@ -0,0 +1,65 @@ +module StripeMock + module RequestHandlers + module Transfers + + def Transfers.included(klass) + klass.add_handler 'post /v1/transfers', :new_transfer + klass.add_handler 'get /v1/transfers', :get_all_transfers + klass.add_handler 'get /v1/transfers/(.*)', :get_transfer + klass.add_handler 'post /v1/transfers/(.*)/cancel', :cancel_transfer + end + + def get_all_transfers(route, method_url, params, headers) + extra_params = params.keys - [:created, :destination, :ending_before, + :limit, :starting_after, :transfer_group] + unless extra_params.empty? + raise Stripe::InvalidRequestError.new("Received unknown parameter: #{extra_params[0]}", extra_params[0].to_s, http_status: 400) + end + + if destination = params[:destination] + assert_existence :destination, destination, accounts[destination] + end + + _transfers = transfers.each_with_object([]) do |(_, transfer), array| + if destination + array << transfer if transfer[:destination] == destination + else + array << transfer + end + end + + if params[:limit] + _transfers = _transfers.first([params[:limit], _transfers.size].min) + end + + Data.mock_list_object(_transfers, params) + end + + def new_transfer(route, method_url, params, headers) + id = new_id('tr') + if params[:bank_account] + params[:account] = get_bank_by_token(params.delete(:bank_account)) + end + + unless params[:amount].is_a?(Integer) || (params[:amount].is_a?(String) && /^\d+$/.match(params[:amount])) + raise Stripe::InvalidRequestError.new("Invalid integer: #{params[:amount]}", 'amount', http_status: 400) + end + + transfers[id] = Data.mock_transfer(params.merge :id => id) + end + + def get_transfer(route, method_url, params, headers) + route =~ method_url + assert_existence :transfer, $1, transfers[$1] + transfers[$1] ||= Data.mock_transfer(:id => $1) + end + + def cancel_transfer(route, method_url, params, headers) + route =~ method_url + assert_existence :transfer, $1, transfers[$1] + t = transfers[$1] ||= Data.mock_transfer(:id => $1) + t.merge!({:status => "canceled"}) + end + end + end +end diff --git a/lib/stripe_mock/request_handlers/validators/param_validators.rb b/lib/stripe_mock/request_handlers/validators/param_validators.rb new file mode 100644 index 0000000..aa5f239 --- /dev/null +++ b/lib/stripe_mock/request_handlers/validators/param_validators.rb @@ -0,0 +1,147 @@ +module StripeMock + module RequestHandlers + module ParamValidators + + def already_exists_message(obj_class) + "#{obj_class.to_s.split("::").last} already exists." + end + + def not_found_message(obj_class, obj_id) + "No such #{obj_class.to_s.split("::").last.downcase}: #{obj_id}" + end + + def missing_param_message(attr_name) + "Missing required param: #{attr_name}." + end + + def invalid_integer_message(my_val) + "Invalid integer: #{my_val}" + end + + # + # ProductValidator + # + + + def validate_create_product_params(params) + params[:id] = params[:id].to_s + @base_strategy.create_product_params.keys.reject{ |k,_| k == :id }.each do |k| + raise Stripe::InvalidRequestError.new(missing_param_message(k), k) if params[k].nil? + end + + if products[ params[:id] ] + raise Stripe::InvalidRequestError.new(already_exists_message(Stripe::Product), :id) + end + end + + # + # PlanValidator + # + + def missing_plan_amount_message + "Plans require an `amount` parameter to be set." + end + + SUPPORTED_PLAN_INTERVALS = ["month", "year", "week", "day"] + + def invalid_plan_interval_message + "Invalid interval: must be one of day, month, week, or year" + end + + SUPPORTED_CURRENCIES = [ + "usd", "aed", "afn", "all", "amd", "ang", "aoa", "ars", "aud", "awg", "azn", "bam", "bbd", "bdt", "bgn", + "bif", "bmd", "bnd", "bob", "brl", "bsd", "bwp", "bzd", "cad", "cdf", "chf", "clp", "cny", "cop", "crc", + "cve", "czk", "djf", "dkk", "dop", "dzd", "egp", "etb", "eur", "fjd", "fkp", "gbp", "gel", "gip", "gmd", + "gnf", "gtq", "gyd", "hkd", "hnl", "hrk", "htg", "huf", "idr", "ils", "inr", "isk", "jmd", "jpy", "kes", + "kgs", "khr", "kmf", "krw", "kyd", "kzt", "lak", "lbp", "lkr", "lrd", "lsl", "mad", "mdl", "mga", "mkd", + "mmk", "mnt", "mop", "mro", "mur", "mvr", "mwk", "mxn", "myr", "mzn", "nad", "ngn", "nio", "nok", "npr", + "nzd", "pab", "pen", "pgk", "php", "pkr", "pln", "pyg", "qar", "ron", "rsd", "rub", "rwf", "sar", "sbd", + "scr", "sek", "sgd", "shp", "sll", "sos", "srd", "std", "szl", "thb", "tjs", "top", "try", "ttd", "twd", + "tzs", "uah", "ugx", "uyu", "uzs", "vnd", "vuv", "wst", "xaf", "xcd", "xof", "xpf", "yer", "zar", "zmw", + "eek", "lvl", "svc", "vef", "ltl" + ] + + def invalid_currency_message(my_val) + "Invalid currency: #{my_val.downcase}. Stripe currently supports these currencies: #{SUPPORTED_CURRENCIES.join(", ")}" + end + + def validate_create_plan_params(params) + plan_id = params[:id].to_s + product_id = params[:product] + + @base_strategy.create_plan_params.keys.each do |attr_name| + message = + if attr_name == :amount + "Plans require an `#{attr_name}` parameter to be set." + else + "Missing required param: #{attr_name}." + end + raise Stripe::InvalidRequestError.new(message, attr_name) if params[attr_name].nil? + end + + if plans[plan_id] + message = already_exists_message(Stripe::Plan) + raise Stripe::InvalidRequestError.new(message, :id) + end + + unless products[product_id] + message = not_found_message(Stripe::Product, product_id) + raise Stripe::InvalidRequestError.new(message, :product) + end + + unless SUPPORTED_PLAN_INTERVALS.include?(params[:interval]) + message = invalid_plan_interval_message + raise Stripe::InvalidRequestError.new(message, :interval) + end + + unless SUPPORTED_CURRENCIES.include?(params[:currency]) + message = invalid_currency_message(params[:currency]) + raise Stripe::InvalidRequestError.new(message, :currency) + end + + unless params[:amount].integer? + message = invalid_integer_message(params[:amount]) + raise Stripe::InvalidRequestError.new(message, :amount) + end + + end + + def validate_create_price_params(params) + price_id = params[:id].to_s + + require_param(:currency) unless params[:currency] + unless params[:product] || params[:product_data] + raise Stripe::InvalidRequestError("Requires product or product_data") + end + + product_id = params[:product] || create_product(nil, nil, params[:product_data], nil).id + + if prices[price_id] + message = already_exists_message(Stripe::Price) + raise Stripe::InvalidRequestError.new(message, :id) + end + + unless products[product_id] + message = not_found_message(Stripe::Product, product_id) + raise Stripe::InvalidRequestError.new(message, :product) + end + + unless SUPPORTED_CURRENCIES.include?(params[:currency]) + message = invalid_currency_message(params[:currency]) + raise Stripe::InvalidRequestError.new(message, :currency) + end + end + + def validate_list_prices_params(params) + if params[:lookup_keys] && !params[:lookup_keys].is_a?(Array) + raise Stripe::InvalidRequestError.new('Invalid array', :lookup_keys) + end + end + + def require_param(param_name) + raise Stripe::InvalidRequestError.new("Missing required param: #{param_name}.", param_name.to_s, http_status: 400) + end + + end + end +end diff --git a/lib/stripe_mock/server.rb b/lib/stripe_mock/server.rb new file mode 100644 index 0000000..6f2cda2 --- /dev/null +++ b/lib/stripe_mock/server.rb @@ -0,0 +1,93 @@ +require 'drb/drb' + +module StripeMock + class Server + def self.start_new(opts) + puts "Starting StripeMock server on port #{opts[:port] || 4999}" + + host = opts.fetch :host,'0.0.0.0' + port = opts.fetch :port, 4999 + + DRb.start_service "druby://#{host}:#{port}", Server.new + DRb.thread.join + end + + def initialize + self.clear_data + end + + def mock_request(*args, **kwargs) + begin + @instance.mock_request(*args, **kwargs) + rescue Stripe::InvalidRequestError => e + { + :error_raised => 'invalid_request', + :error_params => [ + e.message, e.param, { http_status: e.http_status, http_body: e.http_body, json_body: e.json_body} + ] + } + end + end + + def get_data(key) + @instance.send(key) + end + + def destroy_resource(type, id) + @instance.send(type).delete(id) + end + + def clear_data + @instance = Instance.new + end + + def set_debug(toggle) + @instance.debug = toggle + end + + def set_global_id_prefix(value) + StripeMock.global_id_prefix = value + end + + def global_id_prefix + StripeMock.global_id_prefix + end + + def generate_card_token(card_params) + @instance.generate_card_token(card_params) + end + + def generate_bank_token(recipient_params) + @instance.generate_bank_token(recipient_params) + end + + def generate_webhook_event(event_data) + @instance.generate_webhook_event(event_data) + end + + def set_conversion_rate(value) + @instance.conversion_rate = value + end + + def set_account_balance(value) + @instance.account_balance = value + end + + def error_queue + @instance.error_queue + end + + def debug? + @instance.debug + end + + def ping + true + end + + def upsert_stripe_object(object, attributes) + @instance.upsert_stripe_object(object, attributes) + end + + end +end diff --git a/lib/stripe_mock/test_strategies/base.rb b/lib/stripe_mock/test_strategies/base.rb new file mode 100644 index 0000000..09a8f85 --- /dev/null +++ b/lib/stripe_mock/test_strategies/base.rb @@ -0,0 +1,167 @@ +module StripeMock + module TestStrategies + class Base + + def list_products(limit) + Stripe::Product.list(limit: limit) + end + + def create_product(params = {}) + Stripe::Product.create create_product_params(params) + end + + def create_product_params(params = {}) + { + :id => 'stripe_mock_default_product_id', + :name => 'Default Product', + }.merge(params) + end + + def retrieve_product(product_id) + Stripe::Product.retrieve(product_id) + end + + def list_plans(limit) + Stripe::Plan.list(limit: limit) + end + + def create_plan(params = {}) + Stripe::Plan.create create_plan_params(params) + end + + def create_plan_params(params = {}) + { + :id => 'stripe_mock_default_plan_id', + :interval => 'month', + :currency => StripeMock.default_currency, + :product => nil, # need to override yourself to pass validations + :amount => 1337 + }.merge(params) + end + + def create_price(params = {}) + Stripe::Price.create create_price_params(params) + end + + def create_price_params(params = {}) + price_params = { + currency: StripeMock.default_currency, + }.merge(params) + unless price_params.key?(:product) || price_params.key?(:product_data) + price_params[:product_data] = { + name: 'Product created for price' + } + end + price_params + end + + def list_subscriptions(limit) + Stripe::Subscription.list(limit: limit) + end + + def generate_card_token(card_params = {}) + card_data = {:number => "4242424242424242", :exp_month => 9, :exp_year => (Time.now.year + 5), :cvc => "999", :tokenization_method => nil} + card = StripeMock::Util.card_merge(card_data, card_params) + card[:fingerprint] = StripeMock::Util.fingerprint(card[:number]) if StripeMock.state == 'local' + + stripe_token = Stripe::Token.create(:card => card) + stripe_token.id + end + + def generate_bank_token(bank_account_params = {}) + currency = bank_account_params[:currency] || StripeMock.default_currency + bank_account = { + :country => "US", + :currency => currency, + :account_holder_name => "Jane Austen", + :account_holder_type => "individual", + :routing_number => "110000000", + :account_number => "000123456789" + }.merge(bank_account_params) + bank_account[:fingerprint] = StripeMock::Util.fingerprint(bank_account[:account_number]) if StripeMock.state == 'local' + + stripe_token = Stripe::Token.create(:bank_account => bank_account) + stripe_token.id + end + + def create_coupon_params(params = {}) + currency = params[:currency] || StripeMock.default_currency + { + id: '10BUCKS', + amount_off: 1000, + currency: currency, + max_redemptions: 100, + metadata: { + created_by: 'admin_acct_1' + }, + duration: 'once' + }.merge(params) + end + + def create_coupon_percent_of_params(params = {}) + { + id: '25PERCENT', + percent_off: 25, + redeem_by: nil, + duration_in_months: 3, + duration: :repeating + }.merge(params) + end + + def create_checkout_session(params = {}) + Stripe::Checkout::Session.create create_checkout_session_params(params) + end + + def create_checkout_session_params(params = {}) + { + payment_method_types: ['card'], + line_items: params[:mode] == "setup" ? nil : [{ + name: 'T-shirt', + quantity: 1, + amount: 500, + currency: 'usd', + }], + cancel_url: "https://example.com/cancel", + success_url: "https://example.com/success", + }.merge(params) + end + + def complete_checkout_session(session, payment_method) + session = session.is_a?(Stripe::Checkout::Session) ? session : Stripe::Checkout::Session.retrieve(session) + payment_method = payment_method.is_a?(Stripe::PaymentMethod) ? payment_method : Stripe::PaymentMethod.retrieve(payment_method) + case session.mode + when "payment" + Stripe::PaymentIntent.retrieve(session.payment_intent).confirm(payment_method: payment_method.id) + when "setup" + Stripe::SetupIntent.update(session.setup_intent, {payment_method: payment_method.id}) + when "subscription" + line_items = Stripe::Checkout::Session.list_line_items(session.id) + Stripe::Subscription.create({ + customer: session.customer, + items: line_items.map do |line_item| + { + price: line_item.price.id, + quantity: line_item.quantity + } + end, + default_payment_method: payment_method.id + }) + end + end + + def create_coupon(params = {}) + Stripe::Coupon.create create_coupon_params(params) + end + + def delete_all_coupons + coupons = Stripe::Coupon.list + coupons.data.map(&:delete) if coupons.data.count > 0 + end + + def prepare_card_error + StripeMock.prepare_card_error(:card_error, :new_customer) if StripeMock.state == 'local' + end + + end + end +end diff --git a/lib/stripe_mock/test_strategies/live.rb b/lib/stripe_mock/test_strategies/live.rb new file mode 100644 index 0000000..258b207 --- /dev/null +++ b/lib/stripe_mock/test_strategies/live.rb @@ -0,0 +1,51 @@ +module StripeMock + module TestStrategies + class Live < Base + + def create_product(params={}) + params = create_product_params(params) + raise "create_product requires an :id" if params[:id].nil? + delete_product(params[:id]) + Stripe::Product.create params + end + + def delete_product(product_id) + product = Stripe::Product.retrieve(product_id) + Stripe::Plan.list(product: product_id).each(&:delete) if product.type == 'service' + product.delete + rescue Stripe::StripeError => e + # do nothing + end + + def create_plan(params={}) + raise "create_plan requires an :id" if params[:id].nil? + delete_plan(params[:id]) + Stripe::Plan.create create_plan_params(params) + end + + def delete_plan(plan_id) + plan = Stripe::Plan.retrieve(plan_id) + plan.delete + rescue Stripe::StripeError => e + # do nothing + end + + def create_coupon(params={}) + delete_coupon create_coupon_params(params)[:id] + super + end + + def delete_coupon(id) + coupon = Stripe::Coupon.retrieve(id) + coupon.delete + rescue Stripe::StripeError + # do nothing + end + + def upsert_stripe_object(object, attributes) + raise UnsupportedRequestError.new "Updating or inserting Stripe objects in Live mode not supported" + end + + end + end +end diff --git a/lib/stripe_mock/test_strategies/mock.rb b/lib/stripe_mock/test_strategies/mock.rb new file mode 100644 index 0000000..4241d78 --- /dev/null +++ b/lib/stripe_mock/test_strategies/mock.rb @@ -0,0 +1,31 @@ +module StripeMock + module TestStrategies + class Mock < Base + + def delete_product(product_id) + if StripeMock.state == 'remote' + StripeMock.client.destroy_resource('products', product_id) + elsif StripeMock.state == 'local' + StripeMock.instance.products.delete(product_id) + end + end + + def delete_plan(plan_id) + if StripeMock.state == 'remote' + StripeMock.client.destroy_resource('plans', plan_id) + elsif StripeMock.state == 'local' + StripeMock.instance.plans.delete(plan_id) + end + end + + def upsert_stripe_object(object, attributes = {}) + if StripeMock.state == 'remote' + StripeMock.client.upsert_stripe_object(object, attributes) + elsif StripeMock.state == 'local' + StripeMock.instance.upsert_stripe_object(object, attributes) + end + end + + end + end +end diff --git a/lib/stripe_mock/util.rb b/lib/stripe_mock/util.rb new file mode 100644 index 0000000..e776218 --- /dev/null +++ b/lib/stripe_mock/util.rb @@ -0,0 +1,44 @@ +module StripeMock + module Util + + def self.rmerge(desh_hash, source_hash) + return source_hash if desh_hash.nil? + return nil if source_hash.nil? + + desh_hash.merge(source_hash) do |key, oldval, newval| + if oldval.is_a?(Array) && newval.is_a?(Array) + oldval.fill(nil, oldval.length...newval.length) + oldval.zip(newval).map {|elems| + if elems[1].nil? + elems[0] + elsif elems[1].is_a?(Hash) && elems[1].is_a?(Hash) + rmerge(elems[0], elems[1]) + else + [elems[0], elems[1]].compact + end + }.flatten + elsif oldval.is_a?(Hash) && newval.is_a?(Hash) + rmerge(oldval, newval) + else + newval + end + end + end + + def self.fingerprint(source) + Digest::SHA1.base64digest(source).gsub(/[^a-z]/i, '')[0..15] + end + + def self.card_merge(old_param, new_param) + if new_param[:number] ||= old_param[:number] + if new_param[:last4] + new_param[:number] = new_param[:number][0..-5] + new_param[:last4] + else + new_param[:last4] = new_param[:number][-4..-1] + end + end + old_param.merge(new_param) + end + + end +end diff --git a/lib/stripe_mock/version.rb b/lib/stripe_mock/version.rb new file mode 100644 index 0000000..a1fd0ea --- /dev/null +++ b/lib/stripe_mock/version.rb @@ -0,0 +1,4 @@ +module StripeMock + # stripe-ruby-mock version + VERSION = "3.1.0.rc3" +end diff --git a/lib/stripe_mock/webhook_fixtures/account.application.deauthorized.json b/lib/stripe_mock/webhook_fixtures/account.application.deauthorized.json new file mode 100644 index 0000000..0f7b660 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/account.application.deauthorized.json @@ -0,0 +1,12 @@ +{ + "type": "account.application.deauthorized", + "object": "event", + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "data": { + "object": { + "id": "cus_00000000000000" + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/account.external_account.created.json b/lib/stripe_mock/webhook_fixtures/account.external_account.created.json new file mode 100644 index 0000000..b1307db --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/account.external_account.created.json @@ -0,0 +1,27 @@ +{ + "created":1326853478, + "livemode":false, + "id":"evt_00000000000000", + "type":"account.external_account.created", + "object":"event", + "data":{ + "object":{ + "id":"ba_00000000000000", + "object":"bank_account", + "account":"acct_00000000000000", + "account_holder_name":"Jane Austen", + "account_holder_type":"individual", + "bank_name":"STRIPE TEST BANK", + "country":"US", + "currency":"eur", + "default_for_currency":false, + "fingerprint":"efGCBmiwp56O1lsN", + "last4":"6789", + "metadata":{ + + }, + "routing_number":"110000000", + "status":"new" + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/account.external_account.deleted.json b/lib/stripe_mock/webhook_fixtures/account.external_account.deleted.json new file mode 100644 index 0000000..25725d0 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/account.external_account.deleted.json @@ -0,0 +1,27 @@ +{ + "created":1326853478, + "livemode":false, + "id":"evt_00000000000000", + "type":"account.external_account.deleted", + "object":"event", + "data":{ + "object":{ + "id":"ba_00000000000000", + "object":"bank_account", + "account":"acct_00000000000000", + "account_holder_name":"Jane Austen", + "account_holder_type":"individual", + "bank_name":"STRIPE TEST BANK", + "country":"US", + "currency":"eur", + "default_for_currency":false, + "fingerprint":"efGCBmiwp56O1lsN", + "last4":"6789", + "metadata":{ + + }, + "routing_number":"110000000", + "status":"new" + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/account.external_account.updated.json b/lib/stripe_mock/webhook_fixtures/account.external_account.updated.json new file mode 100644 index 0000000..f8932ba --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/account.external_account.updated.json @@ -0,0 +1,27 @@ +{ + "created":1326853478, + "livemode":false, + "id":"evt_00000000000000", + "type":"account.external_account.updated", + "object":"event", + "data":{ + "object":{ + "id":"ba_00000000000000", + "object":"bank_account", + "account":"acct_00000000000000", + "account_holder_name":"Jane Austen", + "account_holder_type":"individual", + "bank_name":"STRIPE TEST BANK", + "country":"US", + "currency":"eur", + "default_for_currency":false, + "fingerprint":"efGCBmiwp56O1lsN", + "last4":"6789", + "metadata":{ + + }, + "routing_number":"110000000", + "status":"new" + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/account.updated.json b/lib/stripe_mock/webhook_fixtures/account.updated.json new file mode 100644 index 0000000..255b8e3 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/account.updated.json @@ -0,0 +1,26 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "account.updated", + "object": "event", + "data": { + "object": { + "id": "acct_00000000000000", + "email": "test@stripe.com", + "statement_descriptor": "TEST", + "details_submitted": true, + "charge_enabled": false, + "payouts_enabled": false, + "currencies_supported": [ + "USD" + ], + "default_currency": "USD", + "country": "US", + "object": "account" + }, + "previous_attributes": { + "details_submitted": false + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/balance.available.json b/lib/stripe_mock/webhook_fixtures/balance.available.json new file mode 100644 index 0000000..60cbe7b --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/balance.available.json @@ -0,0 +1,31 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "balance.available", + "object": "event", + "data": { + "object": { + "pending": [ + { + "amount": 2217, + "currency": "usd" + } + ], + "available": [ + { + "amount": 0, + "currency": "usd" + } + ], + "instant_available": [ + { + "amount": 0, + "currency": "usd" + } + ], + "livemode": false, + "object": "balance" + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/charge.dispute.closed.json b/lib/stripe_mock/webhook_fixtures/charge.dispute.closed.json new file mode 100644 index 0000000..3d6399b --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/charge.dispute.closed.json @@ -0,0 +1,22 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "charge.dispute.closed", + "object": "event", + "data": { + "object": { + "charge": "ch_00000000000000", + "amount": 1000, + "created": 1381080229, + "status": "won", + "livemode": false, + "currency": "usd", + "object": "dispute", + "reason": "general", + "balance_transaction": "txn_00000000000000", + "evidence_due_by": 1382745599, + "evidence": "Here is some evidence" + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/charge.dispute.created.json b/lib/stripe_mock/webhook_fixtures/charge.dispute.created.json new file mode 100644 index 0000000..96de1e5 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/charge.dispute.created.json @@ -0,0 +1,22 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "charge.dispute.created", + "object": "event", + "data": { + "object": { + "charge": "ch_00000000000000", + "amount": 1000, + "created": 1381080223, + "status": "needs_response", + "livemode": false, + "currency": "usd", + "object": "dispute", + "reason": "general", + "balance_transaction": "txn_00000000000000", + "evidence_due_by": 1382745599, + "evidence": null + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/charge.dispute.funds_reinstated.json b/lib/stripe_mock/webhook_fixtures/charge.dispute.funds_reinstated.json new file mode 100644 index 0000000..22411c8 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/charge.dispute.funds_reinstated.json @@ -0,0 +1,88 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "charge.dispute.funds_reinstated", + "object": "event", + "request": null, + "pending_webhooks": 1, + "api_version": "2017-12-14", + "data": { + "object": { + "id": "dp_00000000000000", + "object": "dispute", + "amount": 25000, + "balance_transaction": "txn_00000000000000", + "balance_transactions": [ + { + "id": "txn_1Bl3mMGWCopOTFn18p8iALq8", + "object": "balance_transaction", + "amount": -25000, + "available_on": 1516233600, + "created": 1516145022, + "currency": "usd", + "description": "Chargeback withdrawal for ch_1Bl3mKGWCopOTFn1LKoN557r", + "exchange_rate": null, + "fee": 1500, + "fee_details": [ + { + "amount": 1500, + "application": null, + "currency": "usd", + "description": "Dispute fee", + "type": "stripe_fee" + } + ], + "net": -26500, + "source": "dp_1Bl3mMGWCopOTFn1jpVaJDrU", + "status": "pending", + "type": "adjustment" + } + ], + "charge": "ch_00000000000000", + "created": 1516145022, + "currency": "usd", + "evidence": { + "access_activity_log": null, + "billing_address": null, + "cancellation_policy": null, + "cancellation_policy_disclosure": null, + "cancellation_rebuttal": null, + "customer_communication": null, + "customer_email_address": "amitree.apu@gmail.com", + "customer_name": "amitree.apu@gmail.com", + "customer_purchase_ip": "157.131.133.10", + "customer_signature": null, + "duplicate_charge_documentation": null, + "duplicate_charge_explanation": null, + "duplicate_charge_id": null, + "product_description": null, + "receipt": null, + "refund_policy": null, + "refund_policy_disclosure": null, + "refund_refusal_explanation": null, + "service_date": null, + "service_documentation": null, + "shipping_address": null, + "shipping_carrier": null, + "shipping_date": null, + "shipping_documentation": null, + "shipping_tracking_number": null, + "uncategorized_file": null, + "uncategorized_text": null + }, + "evidence_details": { + "due_by": 1517529599, + "has_evidence": false, + "past_due": false, + "submission_count": 0 + }, + "is_charge_refundable": false, + "livemode": false, + "metadata": { + }, + "reason": "fraudulent", + "status": "needs_response" + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/charge.dispute.funds_withdrawn.json b/lib/stripe_mock/webhook_fixtures/charge.dispute.funds_withdrawn.json new file mode 100644 index 0000000..50210ff --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/charge.dispute.funds_withdrawn.json @@ -0,0 +1,88 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "charge.dispute.funds_withdrawn", + "object": "event", + "request": null, + "pending_webhooks": 1, + "api_version": "2017-12-14", + "data": { + "object": { + "id": "dp_00000000000000", + "object": "dispute", + "amount": 25000, + "balance_transaction": "txn_00000000000000", + "balance_transactions": [ + { + "id": "txn_1Bl3mMGWCopOTFn18p8iALq8", + "object": "balance_transaction", + "amount": -25000, + "available_on": 1516233600, + "created": 1516145022, + "currency": "usd", + "description": "Chargeback withdrawal for ch_1Bl3mKGWCopOTFn1LKoN557r", + "exchange_rate": null, + "fee": 1500, + "fee_details": [ + { + "amount": 1500, + "application": null, + "currency": "usd", + "description": "Dispute fee", + "type": "stripe_fee" + } + ], + "net": -26500, + "source": "dp_1Bl3mMGWCopOTFn1jpVaJDrU", + "status": "pending", + "type": "adjustment" + } + ], + "charge": "ch_00000000000000", + "created": 1516145022, + "currency": "usd", + "evidence": { + "access_activity_log": null, + "billing_address": null, + "cancellation_policy": null, + "cancellation_policy_disclosure": null, + "cancellation_rebuttal": null, + "customer_communication": null, + "customer_email_address": "amitree.apu@gmail.com", + "customer_name": "amitree.apu@gmail.com", + "customer_purchase_ip": "157.131.133.10", + "customer_signature": null, + "duplicate_charge_documentation": null, + "duplicate_charge_explanation": null, + "duplicate_charge_id": null, + "product_description": null, + "receipt": null, + "refund_policy": null, + "refund_policy_disclosure": null, + "refund_refusal_explanation": null, + "service_date": null, + "service_documentation": null, + "shipping_address": null, + "shipping_carrier": null, + "shipping_date": null, + "shipping_documentation": null, + "shipping_tracking_number": null, + "uncategorized_file": null, + "uncategorized_text": null + }, + "evidence_details": { + "due_by": 1517529599, + "has_evidence": false, + "past_due": false, + "submission_count": 0 + }, + "is_charge_refundable": false, + "livemode": false, + "metadata": { + }, + "reason": "fraudulent", + "status": "needs_response" + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/charge.dispute.updated.json b/lib/stripe_mock/webhook_fixtures/charge.dispute.updated.json new file mode 100644 index 0000000..921e830 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/charge.dispute.updated.json @@ -0,0 +1,25 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "charge.dispute.updated", + "object": "event", + "data": { + "object": { + "charge": "ch_00000000000000", + "amount": 1000, + "created": 1381080226, + "status": "under_review", + "livemode": false, + "currency": "usd", + "object": "dispute", + "reason": "general", + "balance_transaction": "txn_00000000000000", + "evidence_due_by": 1382745599, + "evidence": "Here is some evidence" + }, + "previous_attributes": { + "evidence": null + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/charge.failed.json b/lib/stripe_mock/webhook_fixtures/charge.failed.json new file mode 100644 index 0000000..2d55151 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/charge.failed.json @@ -0,0 +1,184 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "charge.failed", + "object": "event", + "request": null, + "pending_webhooks": 1, + "api_version": "2018-02-28", + "data": { + "object": { + "id": "ch_00000000000000", + "object": "charge", + "amount": 100, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": "txn_00000000000000", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": "Jenny Rosen", + "phone": null + }, + "captured": false, + "created": 1572389205, + "currency": "cad", + "customer": null, + "description": "My First Test Charge (created for API docs)", + "dispute": null, + "failure_code": null, + "failure_message": null, + "fraud_details": {}, + "invoice": null, + "livemode": false, + "metadata": {}, + "on_behalf_of": null, + "order": null, + "outcome": null, + "paid": false, + "payment_intent": null, + "payment_method": "card_00000000000000", + "payment_method_details": { + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": null + }, + "country": "US", + "exp_month": 8, + "exp_year": 2019, + "fingerprint": "C8aRpBae2T8GeJcn", + "funding": "credit", + "installments": null, + "last4": "4242", + "network": "visa", + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/acct_1C0lZEHvOcc4e36o/ch_1FZ3SbHsssvOcc4e36o87NHFK7i/rcpt_G5DuglJFhskXjsEDnLzxF6ESuGQe0Qj", + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "url": "/v1/charges/ch_1FZ3SbHvOcc4e36o87NHFK7i/refunds" + }, + "review": null, + "shipping": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "failed", + "transfer_data": null, + "transfer_group": null + } + }, + "webhook": { + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "charge.failed", + "object": "event", + "request": null, + "pending_webhooks": 1, + "api_version": "2018-02-28", + "data": { + "object": { + "id": "ch_00000000000000", + "object": "charge", + "amount": 100, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": "txn_00000000000000", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": "Jenny Rosen", + "phone": null + }, + "captured": false, + "created": 1572389205, + "currency": "cad", + "customer": null, + "description": "My First Test Charge (created for API docs)", + "dispute": null, + "failure_code": null, + "failure_message": null, + "fraud_details": {}, + "invoice": null, + "livemode": false, + "metadata": {}, + "on_behalf_of": null, + "order": null, + "outcome": null, + "paid": false, + "payment_intent": null, + "payment_method": "card_00000000000000", + "payment_method_details": { + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": null + }, + "country": "US", + "exp_month": 8, + "exp_year": 2019, + "fingerprint": "C8aRpBae2T8GeJcn", + "funding": "credit", + "installments": null, + "last4": "4242", + "network": "visa", + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/acct_1C0lZEHvOcc4e36o/ch_1FZ3SbHvOcc4sssse36o87NHFK7i/rcpt_G5DuglJFhskXjsEDnLzxF6ESuGQe0Qj", + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "url": "/v1/charges/ch_1FZ3SbHvOccssssss4e36o87NHFK7i/refunds" + }, + "review": null, + "shipping": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "failed", + "transfer_data": null, + "transfer_group": null + } + } + } +} \ No newline at end of file diff --git a/lib/stripe_mock/webhook_fixtures/charge.refunded.json b/lib/stripe_mock/webhook_fixtures/charge.refunded.json new file mode 100644 index 0000000..482d879 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/charge.refunded.json @@ -0,0 +1,69 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "charge.refunded", + "object": "event", + "data": { + "object": { + "id": "ch_00000000000000", + "object": "charge", + "created": 1380933505, + "livemode": false, + "paid": true, + "amount": 1000, + "currency": "usd", + "refunded": true, + "source": { + "id": "cc_00000000000000", + "object": "card", + "last4": "4242", + "type": "Visa", + "brand": "Visa", + "funding": "credit", + "exp_month": 12, + "exp_year": 2013, + "fingerprint": "wXWJT135mEK107G8", + "customer": "cus_00000000000000", + "country": "US", + "name": "Actual Nothing", + "address_line1": null, + "address_line2": null, + "address_city": null, + "address_state": null, + "address_zip": null, + "address_country": null, + "cvc_check": null, + "address_line1_check": null, + "address_zip_check": null + }, + "captured": true, + "refunds": { + "object": "list", + "total_count": 1, + "has_more": false, + "data": [ + { + "amount": 1000, + "currency": "usd", + "created": 1381080103, + "object": "refund", + "balance_transaction": "txn_2hkjgg43ucu7K1", + "id": "re_00000000000000" + } + ] + }, + "balance_transaction": "txn_00000000000000", + "failure_message": null, + "failure_code": null, + "amount_refunded": 1000, + "customer": "cus_00000000000000", + "invoice": "in_00000000000000", + "description": null, + "dispute": null, + "metadata": { + }, + "fee": 0 + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/charge.succeeded.json b/lib/stripe_mock/webhook_fixtures/charge.succeeded.json new file mode 100644 index 0000000..2058b94 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/charge.succeeded.json @@ -0,0 +1,55 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "charge.succeeded", + "object": "event", + "data": { + "object": { + "id": "ch_00000000000000", + "object": "charge", + "created": 1380933505, + "livemode": false, + "paid": true, + "amount": 1000, + "currency": "usd", + "refunded": false, + "source": { + "id": "cc_00000000000000", + "object": "card", + "last4": "4242", + "type": "Visa", + "brand": "Visa", + "exp_month": 12, + "exp_year": 2013, + "fingerprint": "wXWJT135mEK107G8", + "customer": "cus_00000000000000", + "country": "US", + "name": "Actual Nothing", + "address_line1": null, + "address_line2": null, + "address_city": null, + "address_state": null, + "address_zip": null, + "address_country": null, + "cvc_check": null, + "address_line1_check": null, + "address_zip_check": null + }, + "captured": true, + "refunds": { + + }, + "balance_transaction": "txn_00000000000000", + "failure_message": null, + "failure_code": null, + "amount_refunded": 0, + "customer": "cus_00000000000000", + "invoice": "in_00000000000000", + "description": null, + "dispute": null, + "metadata": { + } + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/charge.updated.json b/lib/stripe_mock/webhook_fixtures/charge.updated.json new file mode 100644 index 0000000..707aedd --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/charge.updated.json @@ -0,0 +1,58 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "charge.updated", + "object": "event", + "data": { + "object": { + "id": "ch_00000000000000", + "object": "charge", + "created": 1380933505, + "livemode": false, + "paid": true, + "amount": 1000, + "currency": "usd", + "refunded": false, + "source": { + "id": "cc_00000000000000", + "object": "card", + "last4": "4242", + "type": "Visa", + "brand": "Visa", + "exp_month": 12, + "exp_year": 2013, + "fingerprint": "wXWJT135mEK107G8", + "customer": "cus_00000000000000", + "country": "US", + "name": "Actual Nothing", + "address_line1": null, + "address_line2": null, + "address_city": null, + "address_state": null, + "address_zip": null, + "address_country": null, + "cvc_check": null, + "address_line1_check": null, + "address_zip_check": null + }, + "captured": true, + "refunds": { + + }, + "balance_transaction": "txn_00000000000000", + "failure_message": null, + "failure_code": null, + "amount_refunded": 0, + "customer": "cus_00000000000000", + "invoice": "in_00000000000000", + "description": "Sample description" , + "dispute": null, + "metadata": { + } + }, + "previous_attributes": { + "description": null + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/checkout.session.completed.payment_mode.json b/lib/stripe_mock/webhook_fixtures/checkout.session.completed.payment_mode.json new file mode 100644 index 0000000..e7531dc --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/checkout.session.completed.payment_mode.json @@ -0,0 +1,53 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "checkout.session.completed", + "object": "event", + "data": { + "object": { + "id": "cs_00000000000000", + "object": "checkout.session", + "allow_promotion_codes": null, + "amount_subtotal": 25000, + "amount_total": 25000, + "automatic_tax": { + "enabled": false, + "status": null + }, + "billing_address_collection": null, + "cancel_url": "https://example.com/cancel", + "client_reference_id": null, + "currency": "usd", + "customer": "cus_00000000000000", + "customer_details": { + "email": "example@example.com", + "tax_exempt": "none", + "tax_ids": [] + }, + "customer_email": null, + "livemode": false, + "locale": null, + "metadata": {}, + "mode": "payment", + "payment_intent": "pi_00000000000000", + "payment_method_options": {}, + "payment_method_types": [ + "card" + ], + "payment_status": "paid", + "setup_intent": null, + "shipping": null, + "shipping_address_collection": null, + "submit_type": null, + "subscription": null, + "success_url": "https://example.com/success", + "total_details": { + "amount_discount": 0, + "amount_shipping": 0, + "amount_tax": 0 + }, + "url": null + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/checkout.session.completed.setup_mode.json b/lib/stripe_mock/webhook_fixtures/checkout.session.completed.setup_mode.json new file mode 100644 index 0000000..3be9b68 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/checkout.session.completed.setup_mode.json @@ -0,0 +1,45 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "checkout.session.completed", + "object": "event", + "data": { + "object": { + "id": "cs_00000000000000", + "object": "checkout.session", + "allow_promotion_codes": null, + "amount_subtotal": null, + "amount_total": null, + "automatic_tax": { + "enabled": false, + "status": null + }, + "billing_address_collection": null, + "cancel_url": "https://example.com/cancel", + "client_reference_id": null, + "currency": null, + "customer": null, + "customer_details": null, + "customer_email": null, + "livemode": false, + "locale": null, + "metadata": {}, + "mode": "setup", + "payment_intent": null, + "payment_method_options": {}, + "payment_method_types": [ + "card" + ], + "payment_status": "no_payment_required", + "setup_intent": "seti_00000000000000", + "shipping": null, + "shipping_address_collection": null, + "submit_type": null, + "subscription": null, + "success_url": "https://example.com/success", + "total_details": null, + "url": null + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/coupon.created.json b/lib/stripe_mock/webhook_fixtures/coupon.created.json new file mode 100644 index 0000000..3f128c3 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/coupon.created.json @@ -0,0 +1,23 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "coupon.created", + "object": "event", + "data": { + "object": { + "id": "25OFF_00000000000000", + "percent_off": 25, + "amount_off": null, + "currency": "usd", + "object": "coupon", + "livemode": false, + "duration": "repeating", + "redeem_by": null, + "max_redemptions": null, + "times_redeemed": 0, + "duration_in_months": 3, + "valid": true + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/coupon.deleted.json b/lib/stripe_mock/webhook_fixtures/coupon.deleted.json new file mode 100644 index 0000000..656adb8 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/coupon.deleted.json @@ -0,0 +1,23 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "coupon.deleted", + "object": "event", + "data": { + "object": { + "id": "25OFF_00000000000000", + "percent_off": 25, + "amount_off": null, + "currency": "usd", + "object": "coupon", + "livemode": false, + "duration": "repeating", + "redeem_by": null, + "max_redemptions": null, + "times_redeemed": 0, + "duration_in_months": 3, + "valid": false + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/customer.created.json b/lib/stripe_mock/webhook_fixtures/customer.created.json new file mode 100644 index 0000000..7d6569a --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/customer.created.json @@ -0,0 +1,55 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "customer.created", + "object": "event", + "data": { + "object": { + "object": "customer", + "created": 1375148334, + "id": "cus_00000000000000", + "livemode": false, + "description": null, + "email": "bond@mailinator.com", + "delinquent": true, + "metadata": { + }, + "preferred_locales": [], + "subscription": null, + "discount": null, + "account_balance": 0, + "sources": { + "object": "list", + "count": 1, + "url": "/v1/customers/cus_2I2AhGQOPmEFeu/cards", + "data": [ + { + "id": "cc_2I2akIhmladin5", + "object": "card", + "last4": "0341", + "type": "Visa", + "brand": "Visa", + "funding": "credit", + "exp_month": 12, + "exp_year": 2013, + "fingerprint": "fWvZEzdbEIFF8QrK", + "customer": "cus_2I2AhGQOPmEFeu", + "country": "US", + "name": "Johnny Goodman", + "address_line1": null, + "address_line2": null, + "address_city": null, + "address_state": null, + "address_zip": null, + "address_country": null, + "cvc_check": "pass", + "address_line1_check": null, + "address_zip_check": null + } + ] + }, + "default_card": "cc_2I2akIhmladin5" + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/customer.deleted.json b/lib/stripe_mock/webhook_fixtures/customer.deleted.json new file mode 100644 index 0000000..70e6560 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/customer.deleted.json @@ -0,0 +1,42 @@ +{ + "created": 1326853478, + "data": { + "object": { + "account_balance": 0, + "active_card": { + "address_city": null, + "address_country": null, + "address_line1": null, + "address_line1_check": null, + "address_line2": null, + "address_state": null, + "address_zip": null, + "address_zip_check": null, + "country": "US", + "cvc_check": "pass", + "exp_month": 12, + "exp_year": 2013, + "fingerprint": "wXWJT135mEK107G8", + "last4": "4242", + "name": "1231", + "object": "card", + "type": "Visa", + "brand": "Visa", + "funding": "credit" + }, + "created": 1359947599, + "delinquent": false, + "description": null, + "discount": null, + "email": "ajoe@mailinator.com", + "id": "cus_00000000000000", + "livemode": false, + "object": "customer", + "subscription": null + } + }, + "id": "evt_00000000000000", + "livemode": false, + "object": "event", + "type": "customer.deleted" +} diff --git a/lib/stripe_mock/webhook_fixtures/customer.discount.created.json b/lib/stripe_mock/webhook_fixtures/customer.discount.created.json new file mode 100644 index 0000000..0edf201 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/customer.discount.created.json @@ -0,0 +1,28 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "customer.discount.created", + "object": "event", + "data": { + "object": { + "coupon": { + "id": "25OFF_00000000000000", + "percent_off": 25, + "amount_off": null, + "currency": "usd", + "object": "coupon", + "livemode": false, + "duration": "repeating", + "redeem_by": null, + "max_redemptions": null, + "times_redeemed": 0, + "duration_in_months": 3 + }, + "start": 1381080505, + "object": "discount", + "customer": "cus_00000000000000", + "end": 1389029305 + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/customer.discount.deleted.json b/lib/stripe_mock/webhook_fixtures/customer.discount.deleted.json new file mode 100644 index 0000000..8c26dfe --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/customer.discount.deleted.json @@ -0,0 +1,28 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "customer.discount.deleted", + "object": "event", + "data": { + "object": { + "coupon": { + "id": "25OFF_00000000000000", + "percent_off": 25, + "amount_off": null, + "currency": "usd", + "object": "coupon", + "livemode": false, + "duration": "repeating", + "redeem_by": null, + "max_redemptions": null, + "times_redeemed": 0, + "duration_in_months": 3 + }, + "start": 1381080512, + "object": "discount", + "customer": "cus_00000000000000", + "end": 1389029312 + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/customer.discount.updated.json b/lib/stripe_mock/webhook_fixtures/customer.discount.updated.json new file mode 100644 index 0000000..ff448ee --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/customer.discount.updated.json @@ -0,0 +1,43 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "customer.discount.updated", + "object": "event", + "data": { + "object": { + "coupon": { + "id": "25OFF_00000000000000", + "percent_off": 25, + "amount_off": null, + "currency": "usd", + "object": "coupon", + "livemode": false, + "duration": "repeating", + "redeem_by": null, + "max_redemptions": null, + "times_redeemed": 0, + "duration_in_months": 3 + }, + "start": 1381080509, + "object": "discount", + "customer": "cus_00000000000000", + "end": 1389029309 + }, + "previous_attributes": { + "coupon": { + "id": "OLD_COUPON_ID", + "percent_off": 25, + "amount_off": null, + "currency": "usd", + "object": "coupon", + "livemode": false, + "duration": "repeating", + "redeem_by": null, + "max_redemptions": null, + "times_redeemed": 0, + "duration_in_months": 3 + } + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/customer.source.created.json b/lib/stripe_mock/webhook_fixtures/customer.source.created.json new file mode 100644 index 0000000..91eba12 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/customer.source.created.json @@ -0,0 +1,32 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "customer.source.created", + "object": "event", + "data": { + "object": { + "id": "card_VALID", + "object": "card", + "last4": "4242", + "type": "Visa", + "brand": "Visa", + "funding": "credit", + "exp_month": 3, + "exp_year": 2020, + "fingerprint": "wXWJT135mEK107G8", + "customer": "cus_VALID", + "country": "US", + "name": "Testy Tester", + "address_line1": null, + "address_line2": null, + "address_city": null, + "address_state": null, + "address_zip": null, + "address_country": null, + "cvc_check": "pass", + "address_line1_check": null, + "address_zip_check": null + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/customer.source.deleted.json b/lib/stripe_mock/webhook_fixtures/customer.source.deleted.json new file mode 100644 index 0000000..665e26f --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/customer.source.deleted.json @@ -0,0 +1,32 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "customer.source.deleted", + "object": "event", + "data": { + "object": { + "id": "card_VALID", + "object": "card", + "last4": "4242", + "type": "Visa", + "brand": "Visa", + "funding": "credit", + "exp_month": 3, + "exp_year": 2020, + "fingerprint": "wXWJT135mEK107G8", + "customer": "cus_VALID", + "country": "US", + "name": "Testy Tester", + "address_line1": null, + "address_line2": null, + "address_city": null, + "address_state": null, + "address_zip": null, + "address_country": null, + "cvc_check": "pass", + "address_line1_check": null, + "address_zip_check": null + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/customer.source.updated.json b/lib/stripe_mock/webhook_fixtures/customer.source.updated.json new file mode 100644 index 0000000..2634deb --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/customer.source.updated.json @@ -0,0 +1,36 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "customer.source.updated", + "object": "event", + "data": { + "object": { + "id": "card_VALID", + "object": "card", + "last4": "4242", + "type": "Visa", + "brand": "Visa", + "funding": "credit", + "exp_month": 3, + "exp_year": 2020, + "fingerprint": "wXWJT135mEK107G8", + "customer": "cus_VALID", + "country": "US", + "name": "Testy Tester", + "address_line1": null, + "address_line2": null, + "address_city": null, + "address_state": null, + "address_zip": null, + "address_country": null, + "cvc_check": "pass", + "address_line1_check": null, + "address_zip_check": null + }, + "previous_attributes": + { + "name": "Testy Tester Jr." + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/customer.subscription.created.json b/lib/stripe_mock/webhook_fixtures/customer.subscription.created.json new file mode 100644 index 0000000..7218cbb --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/customer.subscription.created.json @@ -0,0 +1,66 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "customer.subscription.created", + "object": "event", + "data": { + "object": { + "id": "su_00000000000000", + "items": { + "object": "list", + "data": [ + { + "id": "si_00000000000000", + "object": "subscription_item", + "created": 1497881783, + "plan": { + "interval": "month", + "product": "pr_00000000000000", + "amount": 100, + "currency": "usd", + "id": "fkx0AFo_00000000000000", + "object": "plan", + "livemode": false, + "interval_count": 1, + "trial_period_days": null, + "metadata": {} + }, + "quantity": 1 + }, + { + "id": "si_00000000000001", + "object": "subscription_item", + "created": 1497881788, + "plan": { + "interval": "month", + "product": "pr_00000000000001", + "amount": 200, + "currency": "eur", + "id": "fkx0AFo_00000000000001", + "object": "plan", + "livemode": false, + "interval_count": 1, + "trial_period_days": null, + "metadata": {} + }, + "quantity": 5 + } + ] + }, + "object": "subscription", + "start": 1381080557, + "status": "active", + "customer": "cus_00000000000000", + "cancel_at_period_end": false, + "current_period_start": 1381080557, + "current_period_end": 1383758957, + "ended_at": null, + "trial_start": null, + "trial_end": null, + "canceled_at": null, + "quantity": 1, + "application_fee_percent": null + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/customer.subscription.deleted.json b/lib/stripe_mock/webhook_fixtures/customer.subscription.deleted.json new file mode 100644 index 0000000..c9c3ebe --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/customer.subscription.deleted.json @@ -0,0 +1,65 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "customer.subscription.deleted", + "object": "event", + "data": { + "object": { + "id": "su_00000000000000", + "items": { + "object": "list", + "data": [{ + "id": "si_00000000000000", + "object": "subscription_item", + "created": 1497881783, + "plan": { + "interval": "month", + "product": "pr_00000000000000", + "amount": 100, + "currency": "usd", + "id": "fkx0AFo_00000000000000", + "object": "plan", + "livemode": false, + "interval_count": 1, + "trial_period_days": null, + "metadata": {} + }, + "quantity": 1 + }, + { + "id": "si_00000000000001", + "object": "subscription_item", + "created": 1497881788, + "plan": { + "interval": "month", + "product": "pr_00000000000001", + "amount": 200, + "currency": "eur", + "id": "fkx0AFo_00000000000001", + "object": "plan", + "livemode": false, + "interval_count": 1, + "trial_period_days": null, + "metadata": {} + }, + "quantity": 5 + } + ] + }, + "object": "subscription", + "start": 1381080564, + "status": "canceled", + "customer": "cus_00000000000000", + "cancel_at_period_end": false, + "current_period_start": 1381080564, + "current_period_end": 1383758964, + "ended_at": 1381021514, + "trial_start": null, + "trial_end": null, + "canceled_at": null, + "quantity": 1, + "application_fee_percent": null + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/customer.subscription.trial_will_end.json b/lib/stripe_mock/webhook_fixtures/customer.subscription.trial_will_end.json new file mode 100644 index 0000000..5261cd9 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/customer.subscription.trial_will_end.json @@ -0,0 +1,65 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "customer.subscription.trial_will_end", + "object": "event", + "data": { + "object": { + "id": "su_00000000000000", + "items": { + "object": "list", + "data": [{ + "id": "si_00000000000000", + "object": "subscription_item", + "created": 1497881783, + "plan": { + "interval": "month", + "product": "pr_00000000000000", + "amount": 100, + "currency": "usd", + "id": "fkx0AFo_00000000000000", + "object": "plan", + "livemode": false, + "interval_count": 1, + "trial_period_days": null, + "metadata": {} + }, + "quantity": 1 + }, + { + "id": "si_00000000000001", + "object": "subscription_item", + "created": 1497881788, + "plan": { + "interval": "month", + "product": "pr_00000000000001", + "amount": 200, + "currency": "eur", + "id": "fkx0AFo_00000000000001", + "object": "plan", + "livemode": false, + "interval_count": 1, + "trial_period_days": null, + "metadata": {} + }, + "quantity": 5 + } + ] + }, + "object": "subscription", + "start": 1381080623, + "status": "trialing", + "customer": "cus_00000000000000", + "cancel_at_period_end": false, + "current_period_start": 1381080623, + "current_period_end": 1383759023, + "ended_at": null, + "trial_start": 1381021530, + "trial_end": 1381280730, + "canceled_at": null, + "quantity": 1, + "application_fee_percent": null + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/customer.subscription.updated.json b/lib/stripe_mock/webhook_fixtures/customer.subscription.updated.json new file mode 100644 index 0000000..2ca10fe --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/customer.subscription.updated.json @@ -0,0 +1,78 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "customer.subscription.updated", + "object": "event", + "data": { + "object": { + "id": "su_00000000000000", + "items": { + "object": "list", + "data": [{ + "id": "si_00000000000000", + "object": "subscription_item", + "created": 1497881783, + "plan": { + "interval": "month", + "product": "pr_00000000000000", + "amount": 100, + "currency": "usd", + "id": "fkx0AFo_00000000000000", + "object": "plan", + "livemode": false, + "interval_count": 1, + "trial_period_days": null, + "metadata": {} + }, + "quantity": 1 + }, + { + "id": "si_00000000000001", + "object": "subscription_item", + "created": 1497881788, + "plan": { + "interval": "month", + "product": "pr_00000000000001", + "amount": 200, + "currency": "eur", + "id": "fkx0AFo_00000000000001", + "object": "plan", + "livemode": false, + "interval_count": 1, + "trial_period_days": null, + "metadata": {} + }, + "quantity": 5 + } + ] + }, + "object": "subscription", + "start": 1381080561, + "status": "active", + "customer": "cus_00000000000000", + "cancel_at_period_end": false, + "current_period_start": 1381080561, + "current_period_end": 1383758961, + "ended_at": null, + "trial_start": null, + "trial_end": null, + "canceled_at": null, + "quantity": 1, + "application_fee_percent": null + }, + "previous_attributes": { + "plan": { + "interval": "month", + "product": "pr_00000000000002", + "amount": 100, + "currency": "usd", + "id": "OLD_PLAN_ID", + "object": "plan", + "livemode": false, + "interval_count": 1, + "trial_period_days": null + } + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/customer.updated.json b/lib/stripe_mock/webhook_fixtures/customer.updated.json new file mode 100644 index 0000000..afffec5 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/customer.updated.json @@ -0,0 +1,58 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "customer.updated", + "object": "event", + "data": { + "object": { + "object": "customer", + "created": 1375148334, + "id": "cus_00000000000000", + "livemode": false, + "description": null, + "email": "bond@mailinator.com", + "delinquent": true, + "metadata": { + }, + "preferred_locales": [], + "subscription": null, + "discount": null, + "account_balance": 0, + "sources": { + "object": "list", + "count": 1, + "url": "/v1/customers/cus_2I2AhGQOPmEFeu/cards", + "data": [ + { + "id": "cc_2I2akIhmladin5", + "object": "card", + "last4": "0341", + "type": "Visa", + "brand": "Visa", + "funding": "credit", + "exp_month": 12, + "exp_year": 2013, + "fingerprint": "fWvZEzdbEIFF8QrK", + "customer": "cus_2I2AhGQOPmEFeu", + "country": "US", + "name": "Johnny Goodman", + "address_line1": null, + "address_line2": null, + "address_city": null, + "address_state": null, + "address_zip": null, + "address_country": null, + "cvc_check": "pass", + "address_line1_check": null, + "address_zip_check": null + } + ] + }, + "default_source": "cc_2I2akIhmladin5" + }, + "previous_attributes": { + "description": "Old description" + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/invoice.created.json b/lib/stripe_mock/webhook_fixtures/invoice.created.json new file mode 100644 index 0000000..c4aa066 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/invoice.created.json @@ -0,0 +1,71 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "invoice.created", + "status": "paid", + "object": "event", + "data": { + "object": { + "created": 1380674206, + "id": "in_00000000000000", + "period_start": 1378082075, + "period_end": 1380674075, + "lines": { + "count": 1, + "object": "list", + "url": "/v1/invoices/in_00000000000000/lines", + "data": [ + { + "id": "su_2hksGtIPylSBg2", + "object": "line_item", + "type": "subscription", + "livemode": true, + "amount": 100, + "currency": "usd", + "proration": false, + "period": { + "start": 1383759042, + "end": 1386351042 + }, + "quantity": 1, + "plan": { + "interval": "month", + "amount": 100, + "currency": "usd", + "id": "fkx0AFo", + "object": "plan", + "livemode": false, + "interval_count": 1, + "trial_period_days": null, + "product": "pr_00000000000000", + "metadata": {} + }, + "description": null, + "metadata": null + } + ] + }, + "subtotal": 1000, + "total": 1000, + "customer": "cus_00000000000000", + "object": "invoice", + "attempted": false, + "closed": true, + "paid": true, + "livemode": false, + "attempt_count": 1, + "amount_due": 1000, + "currency": "usd", + "starting_balance": 0, + "ending_balance": 0, + "next_payment_attempt": null, + "charge": "ch_00000000000000", + "discount": null, + "application_fee": null, + "subscription": "sub_00000000000000", + "metadata": {}, + "description": null + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/invoice.payment_failed.json b/lib/stripe_mock/webhook_fixtures/invoice.payment_failed.json new file mode 100644 index 0000000..294270e --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/invoice.payment_failed.json @@ -0,0 +1,105 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "invoice.payment_failed", + "object": "event", + "data": { + "object": { + "date": 1380674206, + "id": "in_00000000000000", + "period_start": 1378082075, + "period_end": 1380674075, + "lines": { + "object": "list", + "count": 3, + "url": "/v1/invoices/in_00000000000000/lines", + "data": [ + { + "id": "ii_00000000000000", + "object": "line_item", + "type": "invoiceitem", + "livemode": false, + "amount": 19000, + "currency": "usd", + "proration": true, + "period": { + "start": 1393765661, + "end": 1393765661 + }, + "quantity": null, + "plan": null, + "description": "Remaining time on Platinum after 02 Mar 2014", + "metadata": {} + }, + { + "id": "ii_00000000000001", + "object": "line_item", + "type": "invoiceitem", + "livemode": false, + "amount": -9000, + "currency": "usd", + "proration": true, + "period": { + "start": 1393765661, + "end": 1393765661 + }, + "quantity": null, + "plan": null, + "description": "Unused time on Gold after 05 Mar 2014", + "metadata": {} + }, + { + "id": "su_00000000000000", + "object": "line_item", + "type": "subscription", + "livemode": false, + "amount": 20000, + "currency": "usd", + "proration": false, + "period": { + "start": 1383759053, + "end": 1386351053 + }, + "quantity": 1, + "plan": { + "interval": "month", + "product": "pr_00000000000000", + "created": 1300000000, + "amount": 20000, + "currency": "usd", + "id": "platinum", + "object": "plan", + "livemode": false, + "interval_count": 1, + "trial_period_days": null, + "metadata": {} + }, + "description": null, + "metadata": null + } + ] + }, + "subtotal": 30000, + "total": 30000, + "customer": "cus_00000000000000", + "object": "invoice", + "attempted": true, + "closed": false, + "paid": false, + "livemode": false, + "attempt_count": 1, + "amount_due": 30000, + "currency": "usd", + "starting_balance": 0, + "ending_balance": 0, + "next_payment_attempt": 1380760475, + "charge": "ch_00000000000000", + "discount": null, + "application_fee": null, + "subscription": "su_00000000000000", + "metadata": {}, + "description": null + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/invoice.payment_succeeded.json b/lib/stripe_mock/webhook_fixtures/invoice.payment_succeeded.json new file mode 100644 index 0000000..9be3043 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/invoice.payment_succeeded.json @@ -0,0 +1,112 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "invoice.payment_succeeded", + "object": "event", + "data": { + "object": { + "id": "in_00000000000000", + "object": "invoice", + "amount_due": 999, + "application_fee": null, + "attempt_count": 1, + "attempted": true, + "charge": "ch_18EcOcLrgDIZ7iq8TaNlErVv", + "closed": true, + "currency": "eur", + "customer": "cus_00000000000000", + "created": 1464084258, + "description": null, + "discount": null, + "ending_balance": 0, + "forgiven": false, + "lines": { + "data": [{ + "id": "sub_00000000000000", + "object": "line_item", + "amount": 50, + "currency": "eur", + "description": null, + "discountable": true, + "livemode": true, + "metadata": {}, + "period": { + "start": 1500637196, + "end": 1532173196 + }, + "plan": { + "id": "platinum", + "object": "plan", + "amount": 500, + "created": 1499943145, + "currency": "eur", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "name": "New Plan Test", + "statement_descriptor": null, + "trial_period_days": null + }, + "proration": false, + "quantity": 1, + "subscription": null, + "subscription_item": "si_18ZfWyLrgDIZ7iq8fSlSNGIV", + "type": "subscription" + }, + { + "id": "sub_00000000000000", + "object": "line_item", + "amount": 50, + "currency": "eur", + "description": null, + "discountable": true, + "livemode": true, + "metadata": {}, + "period": { + "start": 1500637196, + "end": 1532173196 + }, + "plan": { + "id": "gold", + "object": "plan", + "amount": 300, + "created": 1499943155, + "currency": "eur", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "name": "New gold Plan Test", + "statement_descriptor": null, + "trial_period_days": null + }, + "proration": false, + "quantity": 1, + "subscription": null, + "subscription_item": "si_18ZfWyLrgDIZ7iq8fSlSNGIV", + "type": "subscription" + }], + "total_count": 1, + "object": "list", + "url": "/v1/invoices/in_18EcOcLrgDIZ7iq8zsDkunZ0/lines" + }, + "livemode": false, + "metadata": {}, + "next_payment_attempt": null, + "paid": true, + "period_end": 1464084258, + "period_start": 1464084258, + "receipt_number": null, + "starting_balance": 0, + "statement_descriptor": null, + "subscription": "sub_00000000000000", + "subtotal": 999, + "tax": null, + "tax_percent": null, + "total": 999, + "webhooks_delivered_at": 1464084258 + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/invoice.upcoming.json b/lib/stripe_mock/webhook_fixtures/invoice.upcoming.json new file mode 100644 index 0000000..72b1860 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/invoice.upcoming.json @@ -0,0 +1,70 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "invoice.upcoming", + "status": "draft", + "object": "event", + "data": { + "object": { + "created": 1380674206, + "period_start": 1378082075, + "period_end": 1380674075, + "lines": { + "count": 1, + "object": "list", + "url": "/v1/invoices/upcoming/lines?customer=cus_00000000000000", + "data": [ + { + "id": "su_2hksGtIPylSBg2", + "object": "line_item", + "type": "subscription", + "livemode": true, + "amount": 100, + "currency": "usd", + "proration": false, + "period": { + "start": 1383759042, + "end": 1386351042 + }, + "quantity": 1, + "plan": { + "interval": "month", + "amount": 100, + "currency": "usd", + "id": "fkx0AFo", + "object": "plan", + "livemode": false, + "interval_count": 1, + "trial_period_days": null, + "product": "pr_00000000000000", + "metadata": {} + }, + "description": null, + "metadata": null + } + ] + }, + "subtotal": 1000, + "total": 1000, + "customer": "cus_00000000000000", + "object": "invoice", + "attempted": false, + "closed": false, + "paid": false, + "livemode": false, + "attempt_count": 0, + "amount_due": 1000, + "currency": "usd", + "starting_balance": 0, + "ending_balance": 0, + "next_payment_attempt": null, + "charge": null, + "discount": null, + "application_fee": null, + "subscription": "sub_00000000000000", + "metadata": {}, + "description": null + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/invoice.updated.json b/lib/stripe_mock/webhook_fixtures/invoice.updated.json new file mode 100644 index 0000000..e27be0d --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/invoice.updated.json @@ -0,0 +1,74 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "invoice.updated", + "status": "paid", + "object": "event", + "data": { + "object": { + "created": 1380674206, + "id": "in_00000000000000", + "period_start": 1378082075, + "period_end": 1380674075, + "lines": { + "count": 1, + "object": "list", + "url": "/v1/invoices/in_00000000000000/lines", + "data": [ + { + "id": "su_00000000000000", + "object": "line_item", + "type": "subscription", + "livemode": true, + "amount": 100, + "currency": "usd", + "proration": false, + "period": { + "start": 1383759047, + "end": 1386351047 + }, + "quantity": 1, + "plan": { + "interval": "month", + "product": "pr_00000000000000", + "amount": 100, + "currency": "usd", + "id": "fkx0AFo", + "object": "plan", + "livemode": false, + "interval_count": 1, + "trial_period_days": null, + "metadata": {} + }, + "description": null, + "metadata": null + } + ] + }, + "subtotal": 1000, + "total": 1000, + "customer": "cus_00000000000000", + "object": "invoice", + "attempted": true, + "closed": true, + "paid": true, + "livemode": false, + "attempt_count": 1, + "amount_due": 1000, + "currency": "usd", + "starting_balance": 0, + "ending_balance": 0, + "next_payment_attempt": null, + "charge": "ch_00000000000000", + "discount": null, + "application_fee": null, + "subscription": "sub_00000000000000", + "metadata": {}, + "description": null + }, + "previous_attributes": { + "lines": [] + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/invoiceitem.created.json b/lib/stripe_mock/webhook_fixtures/invoiceitem.created.json new file mode 100644 index 0000000..a51ce88 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/invoiceitem.created.json @@ -0,0 +1,21 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "invoiceitem.created", + "object": "event", + "data": { + "object": { + "object": "invoiceitem", + "id": "ii_00000000000000", + "date": 1372126711, + "amount": 2500, + "livemode": false, + "proration": false, + "currency": "usd", + "customer": "cus_00000000000000", + "description": "Plan: Veteran's Club Signup Fee Plan Id: 4", + "invoice": "in_00000000000000" + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/invoiceitem.deleted.json b/lib/stripe_mock/webhook_fixtures/invoiceitem.deleted.json new file mode 100644 index 0000000..b566ca8 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/invoiceitem.deleted.json @@ -0,0 +1,21 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "invoiceitem.deleted", + "object": "event", + "data": { + "object": { + "object": "invoiceitem", + "id": "ii_00000000000000", + "date": 1372126711, + "amount": 2500, + "livemode": false, + "proration": false, + "currency": "usd", + "customer": "cus_00000000000000", + "description": "Plan: Veteran's Club Signup Fee Plan Id: 4", + "invoice": "in_00000000000000" + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/invoiceitem.updated.json b/lib/stripe_mock/webhook_fixtures/invoiceitem.updated.json new file mode 100644 index 0000000..c049c8d --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/invoiceitem.updated.json @@ -0,0 +1,24 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "invoiceitem.updated", + "object": "event", + "data": { + "object": { + "object": "invoiceitem", + "id": "ii_00000000000000", + "date": 1372126711, + "amount": 2500, + "livemode": false, + "proration": false, + "currency": "usd", + "customer": "cus_00000000000000", + "description": "Plan: Veteran's Club Signup Fee Plan Id: 4", + "invoice": "in_00000000000000" + }, + "previous_attributes": { + "amount": 2121 + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/mandate.updated.json b/lib/stripe_mock/webhook_fixtures/mandate.updated.json new file mode 100644 index 0000000..858fe36 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/mandate.updated.json @@ -0,0 +1,34 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "mandate.updated", + "object": "event", + "data": { + "object": { + "id": "mandate_000000000000000000000000", + "object": "mandate", + "customer_acceptance": { + "accepted_at": 1326853478, + "online": { + "ip_address": "0.0.0.0", + "user_agent": "UserAgent" + }, + "type": "online" + }, + "livemode": false, + "multi_use": {}, + "payment_method": "pm_000000000000000000000000", + "payment_method_details": {}, + "status": "active", + "type": "multi_use" + }, + "previous_attributes": { + "payment_method_details": { + "bacs_debit": { + "network_status": "pending" + } + } + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/payment_intent.canceled.json b/lib/stripe_mock/webhook_fixtures/payment_intent.canceled.json new file mode 100644 index 0000000..46f5142 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/payment_intent.canceled.json @@ -0,0 +1,68 @@ +{ + "id": "evt_00000000000000", + "object": "event", + "api_version": "2018-02-28", + "created": 1578499109, + "data": { + "object": { + "id": "pi_00000000000000", + "object": "payment_intent", + "amount": 900, + "amount_capturable": 0, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "canceled_at": 1578499109, + "cancellation_reason": null, + "capture_method": "automatic", + "charges": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges?payment_intent=pi_00000000000000" + }, + "client_secret": "pi_00000000000000", + "confirmation_method": "automatic", + "created": 1578499108, + "currency": "eur", + "customer": "cus_00000000000000", + "description": null, + "invoice": null, + "last_payment_error": null, + "livemode": false, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": "pm_00000000000000", + "payment_method_options": { + "card": { + "installments": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card", + "sepa_debit" + ], + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "canceled", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_00000000000000", + "idempotency_key": null + }, + "type": "payment_intent.canceled" +} diff --git a/lib/stripe_mock/webhook_fixtures/payment_intent.payment_failed.json b/lib/stripe_mock/webhook_fixtures/payment_intent.payment_failed.json new file mode 100644 index 0000000..cd10f1e --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/payment_intent.payment_failed.json @@ -0,0 +1,186 @@ +{ + "id": "evt_00000000000000", + "object": "event", + "api_version": "2018-02-28", + "created": 1578401135, + "data": { + "object": { + "id": "pi_00000000000000", + "object": "payment_intent", + "allowed_source_types": ["card", "sepa_debit"], + "amount": 200, + "amount_capturable": 0, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "charges": { + "object": "list", + "data": [ + { + "id": "py_00000000000000", + "object": "charge", + "amount": 200, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": null, + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": "john.doe@example.com", + "name": "John Doe", + "phone": null + }, + "captured": true, + "created": 1578401129, + "currency": "eur", + "customer": "cus_00000000000000", + "description": null, + "destination": "acct_00000000000000", + "dispute": null, + "disputed": false, + "failure_code": null, + "failure_message": null, + "fraud_details": {}, + "invoice": null, + "livemode": false, + "metadata": {}, + "on_behalf_of": null, + "order": null, + "outcome": { + "network_status": "approved_by_network", + "reason": null, + "risk_level": "not_assessed", + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": false, + "payment_intent": "pi_00000000000000", + "payment_method": "pm_00000000000000", + "payment_method_details": { + "sepa_debit": { + "bank_code": "37040044", + "branch_code": null, + "country": "DE", + "fingerprint": "00000000000000", + "last4": "3001", + "mandate": "mandate_00000000000000" + }, + "type": "sepa_debit" + }, + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/acct_00000000000000/py_00000000000000/rcpt_00000000000000", + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/py_00000000000000/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": "ACME Corp", + "statement_descriptor_suffix": null, + "status": "failed", + "transfer_data": { + "amount": null, + "destination": "acct_00000000000000" + }, + "transfer_group": "group_pi_00000000000000" + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/charges?payment_intent=pi_00000000000000" + }, + "client_secret": "pi_00000000000000", + "confirmation_method": "automatic", + "created": 1578401129, + "currency": "eur", + "customer": "cus_00000000000000", + "description": null, + "invoice": null, + "last_payment_error": { + "code": "payment_intent_payment_attempt_failed", + "doc_url": "https://stripe.com/docs/error-codes/payment-intent-payment-attempt-failed", + "message": "The payment failed.", + "payment_method": { + "id": "pm_00000000000000", + "object": "payment_method", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": "john.doe@example.com", + "name": "John Doe", + "phone": null + }, + "created": 1578400666, + "customer": "cus_00000000000000", + "livemode": false, + "metadata": {}, + "sepa_debit": { + "bank_code": "37040044", + "branch_code": "", + "country": "DE", + "fingerprint": "00000000000000", + "last4": "3001" + }, + "type": "sepa_debit" + }, + "type": "invalid_request_error" + }, + "livemode": false, + "metadata": {}, + "next_action": null, + "next_source_action": null, + "on_behalf_of": null, + "payment_method": null, + "payment_method_options": { + "card": { + "installments": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": ["card", "sepa_debit"], + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": "ACME Corp", + "statement_descriptor_suffix": null, + "status": "requires_source", + "transfer_data": { + "destination": "acct_00000000000000" + }, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 3, + "request": { + "id": null, + "idempotency_key": null + }, + "type": "payment_intent.payment_failed" +} diff --git a/lib/stripe_mock/webhook_fixtures/payment_intent.processing.json b/lib/stripe_mock/webhook_fixtures/payment_intent.processing.json new file mode 100644 index 0000000..69c7181 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/payment_intent.processing.json @@ -0,0 +1,162 @@ +{ + "id": "evt_00000000000000", + "object": "event", + "api_version": "2018-02-28", + "created": 1578499109, + "data": { + "object": { + "id": "pi_00000000000000", + "object": "payment_intent", + "allowed_source_types": ["card", "sepa_debit"], + "amount": 900, + "amount_capturable": 0, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "charges": { + "object": "list", + "data": [ + { + "id": "ch_00000000000000", + "object": "charge", + "amount": 900, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": "txn_00000000000000", + "billing_details": { + "address": { + "city": null, + "country": "DE", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "captured": true, + "created": 1578499109, + "currency": "eur", + "customer": "cus_00000000000000", + "description": null, + "destination": "acct_00000000000000", + "dispute": null, + "disputed": false, + "failure_code": null, + "failure_message": null, + "fraud_details": {}, + "invoice": null, + "livemode": false, + "metadata": {}, + "on_behalf_of": null, + "order": null, + "outcome": { + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 40, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": false, + "payment_intent": "pi_00000000000000", + "payment_method": "pm_00000000000000", + "payment_method_details": { + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": null + }, + "country": "US", + "exp_month": 4, + "exp_year": 2024, + "fingerprint": "00000000000000", + "funding": "credit", + "installments": null, + "last4": "4242", + "network": "visa", + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/acct_00000000000000/ch_00000000000000/rcpt_00000000000000", + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_00000000000000/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": "ACME Corp", + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer": "tr_00000000000000", + "transfer_data": { + "amount": null, + "destination": "acct_00000000000000" + }, + "transfer_group": "group_pi_00000000000000" + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/charges?payment_intent=pi_00000000000000" + }, + "client_secret": "pi_00000000000000", + "confirmation_method": "automatic", + "created": 1578499108, + "currency": "eur", + "customer": "cus_00000000000000", + "description": null, + "invoice": null, + "last_payment_error": null, + "livemode": false, + "metadata": {}, + "next_action": null, + "next_source_action": null, + "on_behalf_of": null, + "payment_method": "pm_00000000000000", + "payment_method_options": { + "card": { + "installments": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": ["card", "sepa_debit"], + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": "ACME Corp", + "statement_descriptor_suffix": null, + "status": "processing", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_00000000000000", + "idempotency_key": null + }, + "type": "payment_intent.processing" +} diff --git a/lib/stripe_mock/webhook_fixtures/payment_intent.succeeded.json b/lib/stripe_mock/webhook_fixtures/payment_intent.succeeded.json new file mode 100644 index 0000000..7ea356a --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/payment_intent.succeeded.json @@ -0,0 +1,164 @@ +{ + "id": "evt_00000000000000", + "object": "event", + "api_version": "2018-02-28", + "created": 1578499109, + "data": { + "object": { + "id": "pi_00000000000000", + "object": "payment_intent", + "allowed_source_types": ["card", "sepa_debit"], + "amount": 900, + "amount_capturable": 0, + "amount_received": 900, + "application": null, + "application_fee_amount": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "charges": { + "object": "list", + "data": [ + { + "id": "ch_00000000000000", + "object": "charge", + "amount": 900, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": "txn_00000000000000", + "billing_details": { + "address": { + "city": null, + "country": "DE", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "captured": true, + "created": 1578499109, + "currency": "eur", + "customer": "cus_00000000000000", + "description": null, + "destination": "acct_00000000000000", + "dispute": null, + "disputed": false, + "failure_code": null, + "failure_message": null, + "fraud_details": {}, + "invoice": null, + "livemode": false, + "metadata": {}, + "on_behalf_of": null, + "order": null, + "outcome": { + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 40, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_00000000000000", + "payment_method": "pm_00000000000000", + "payment_method_details": { + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": null + }, + "country": "US", + "exp_month": 4, + "exp_year": 2024, + "fingerprint": "00000000000000", + "funding": "credit", + "installments": null, + "last4": "4242", + "network": "visa", + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/acct_00000000000000/ch_00000000000000/rcpt_00000000000000", + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_00000000000000/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": "ACME Corp", + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer": "tr_00000000000000", + "transfer_data": { + "amount": null, + "destination": "acct_00000000000000" + }, + "transfer_group": "group_pi_00000000000000" + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/charges?payment_intent=pi_00000000000000" + }, + "client_secret": "pi_00000000000000", + "confirmation_method": "automatic", + "created": 1578499108, + "currency": "eur", + "customer": "cus_00000000000000", + "description": null, + "invoice": null, + "last_payment_error": null, + "livemode": false, + "metadata": {}, + "next_action": null, + "next_source_action": null, + "on_behalf_of": null, + "payment_method": "pm_00000000000000", + "payment_method_options": { + "card": { + "installments": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": ["card", "sepa_debit"], + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": "ACME Corp", + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": { + "destination": "acct_00000000000000" + }, + "transfer_group": "group_pi_00000000000000" + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_00000000000000", + "idempotency_key": null + }, + "type": "payment_intent.succeeded" +} diff --git a/lib/stripe_mock/webhook_fixtures/plan.created.json b/lib/stripe_mock/webhook_fixtures/plan.created.json new file mode 100644 index 0000000..64e94a5 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/plan.created.json @@ -0,0 +1,20 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "plan.created", + "object": "event", + "data": { + "object": { + "interval": "month", + "product": "pr_00000000000000", + "amount": 100, + "currency": "usd", + "id": "fkx0AFo_00000000000000", + "object": "plan", + "livemode": false, + "interval_count": 1, + "trial_period_days": null + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/plan.deleted.json b/lib/stripe_mock/webhook_fixtures/plan.deleted.json new file mode 100644 index 0000000..3669585 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/plan.deleted.json @@ -0,0 +1,20 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "plan.deleted", + "object": "event", + "data": { + "object": { + "interval": "month", + "product": "pr_00000000000000", + "amount": 100, + "currency": "usd", + "id": "fkx0AFo_00000000000000", + "object": "plan", + "livemode": false, + "interval_count": 1, + "trial_period_days": null + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/plan.updated.json b/lib/stripe_mock/webhook_fixtures/plan.updated.json new file mode 100644 index 0000000..9f241e8 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/plan.updated.json @@ -0,0 +1,23 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "plan.updated", + "object": "event", + "data": { + "object": { + "interval": "month", + "product": "pr_00000000000000", + "amount": 100, + "currency": "usd", + "id": "fkx0AFo_00000000000000", + "object": "plan", + "livemode": false, + "interval_count": 1, + "trial_period_days": null + }, + "previous_attributes": { + "name": "Old name" + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/product.created.json b/lib/stripe_mock/webhook_fixtures/product.created.json new file mode 100644 index 0000000..5dc5ec9 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/product.created.json @@ -0,0 +1,34 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "product.created", + "object": "event", + "data": { + "object": { + "id": "prod_00000000000000", + "object": "product", + "active": true, + "attributes": [ + ], + "caption": null, + "created": 1558795883, + "deactivate_on": [ + ], + "description": null, + "images": [ + ], + "livemode": false, + "metadata": { + }, + "name": "Test Product", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "type": "service", + "unit_label": null, + "updated": 1558795883, + "url": null + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/product.deleted.json b/lib/stripe_mock/webhook_fixtures/product.deleted.json new file mode 100644 index 0000000..a0499c5 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/product.deleted.json @@ -0,0 +1,34 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "product.deleted", + "object": "event", + "data": { + "object": { + "id": "prod_00000000000000", + "object": "product", + "active": true, + "attributes": [ + ], + "caption": null, + "created": 1558795883, + "deactivate_on": [ + ], + "description": null, + "images": [ + ], + "livemode": false, + "metadata": { + }, + "name": "Test Product", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "type": "service", + "unit_label": null, + "updated": 1558795883, + "url": null + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/product.updated.json b/lib/stripe_mock/webhook_fixtures/product.updated.json new file mode 100644 index 0000000..37c8024 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/product.updated.json @@ -0,0 +1,38 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "product.updated", + "object": "event", + "data": { + "object": { + "id": "prod_00000000000000", + "object": "product", + "active": true, + "attributes": [ + ], + "caption": null, + "created": 1558795883, + "deactivate_on": [ + ], + "description": null, + "images": [ + ], + "livemode": false, + "metadata": { + }, + "name": "Test Product", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "type": "service", + "unit_label": null, + "updated": 1558795883, + "url": null + }, + "previous_attributes": { + "name": "Product Test", + "updated": 1558873981 + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/transfer.created.json b/lib/stripe_mock/webhook_fixtures/transfer.created.json new file mode 100644 index 0000000..4431164 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/transfer.created.json @@ -0,0 +1,89 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "transfer.created", + "object": "event", + "data": { + "object": { + "id": "tr_00000000000000", + "object": "transfer", + "date": 1381104000, + "livemode": false, + "amount": 67, + "currency": "usd", + "status": "pending", + "balance_transaction": "txn_00000000000000", + "summary": { + "charge_gross": 100, + "charge_fees": 33, + "charge_fee_details": [ + { + "amount": 33, + "currency": "usd", + "type": "stripe_fee", + "description": null, + "application": null + } + ], + "refund_gross": 0, + "refund_fees": 0, + "refund_fee_details": [ + + ], + "adjustment_gross": 0, + "adjustment_fees": 0, + "adjustment_fee_details": [ + + ], + "validation_fees": 0, + "validation_count": 0, + "charge_count": 1, + "refund_count": 0, + "adjustment_count": 0, + "net": 67, + "currency": "usd", + "collected_fee_gross": 0, + "collected_fee_count": 0, + "collected_fee_refund_gross": 0, + "collected_fee_refund_count": 0 + }, + "transactions": { + "object": "list", + "count": 1, + "url": "/v1/transfers/tr_2h8RC13PPvwDZs/transactions", + "has_more": false, + "data": [ + { + "id": "ch_2fb4RERw49oI8s", + "type": "charge", + "amount": 100, + "currency": "usd", + "net": 67, + "created": 1380582860, + "description": null, + "fee": 33, + "fee_details": [ + { + "amount": 33, + "currency": "usd", + "type": "stripe_fee", + "description": "Stripe processing fees", + "application": null + } + ] + } + ] + }, + "other_transfers": [ + "tr_2h8RC13PPvwDZs" + ], + "account": null, + "description": "STRIPE TRANSFER", + "metadata": { + }, + "statement_descriptor": null, + "recipient": null + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/transfer.failed.json b/lib/stripe_mock/webhook_fixtures/transfer.failed.json new file mode 100644 index 0000000..32f5398 --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/transfer.failed.json @@ -0,0 +1,89 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "transfer.failed", + "object": "event", + "data": { + "object": { + "id": "tr_00000000000000", + "object": "transfer", + "date": 1381104000, + "livemode": false, + "amount": 67, + "currency": "usd", + "status": "failed", + "balance_transaction": "txn_00000000000000", + "summary": { + "charge_gross": 100, + "charge_fees": 33, + "charge_fee_details": [ + { + "amount": 33, + "currency": "usd", + "type": "stripe_fee", + "description": null, + "application": null + } + ], + "refund_gross": 0, + "refund_fees": 0, + "refund_fee_details": [ + + ], + "adjustment_gross": 0, + "adjustment_fees": 0, + "adjustment_fee_details": [ + + ], + "validation_fees": 0, + "validation_count": 0, + "charge_count": 1, + "refund_count": 0, + "adjustment_count": 0, + "net": 67, + "currency": "usd", + "collected_fee_gross": 0, + "collected_fee_count": 0, + "collected_fee_refund_gross": 0, + "collected_fee_refund_count": 0 + }, + "transactions": { + "object": "list", + "count": 1, + "url": "/v1/transfers/tr_2h8RC13PPvwDZs/transactions", + "has_more": false, + "data": [ + { + "id": "ch_2fb4RERw49oI8s", + "type": "charge", + "amount": 100, + "currency": "usd", + "net": 67, + "created": 1380582860, + "description": null, + "fee": 33, + "fee_details": [ + { + "amount": 33, + "currency": "usd", + "type": "stripe_fee", + "description": "Stripe processing fees", + "application": null + } + ] + } + ] + }, + "other_transfers": [ + "tr_2h8RC13PPvwDZs" + ], + "account": null, + "description": "STRIPE TRANSFER", + "metadata": { + }, + "statement_descriptor": null, + "recipient": null + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/transfer.paid.json b/lib/stripe_mock/webhook_fixtures/transfer.paid.json new file mode 100644 index 0000000..537213c --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/transfer.paid.json @@ -0,0 +1,89 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "transfer.paid", + "object": "event", + "data": { + "object": { + "id": "tr_00000000000000", + "object": "transfer", + "date": 1381104000, + "livemode": false, + "amount": 67, + "currency": "usd", + "status": "paid", + "balance_transaction": "txn_00000000000000", + "summary": { + "charge_gross": 100, + "charge_fees": 33, + "charge_fee_details": [ + { + "amount": 33, + "currency": "usd", + "type": "stripe_fee", + "description": null, + "application": null + } + ], + "refund_gross": 0, + "refund_fees": 0, + "refund_fee_details": [ + + ], + "adjustment_gross": 0, + "adjustment_fees": 0, + "adjustment_fee_details": [ + + ], + "validation_fees": 0, + "validation_count": 0, + "charge_count": 1, + "refund_count": 0, + "adjustment_count": 0, + "net": 67, + "currency": "usd", + "collected_fee_gross": 0, + "collected_fee_count": 0, + "collected_fee_refund_gross": 0, + "collected_fee_refund_count": 0 + }, + "transactions": { + "object": "list", + "count": 1, + "url": "/v1/transfers/tr_2h8RC13PPvwDZs/transactions", + "has_more": false, + "data": [ + { + "id": "ch_2fb4RERw49oI8s", + "type": "charge", + "amount": 100, + "currency": "usd", + "net": 67, + "created": 1380582860, + "description": null, + "fee": 33, + "fee_details": [ + { + "amount": 33, + "currency": "usd", + "type": "stripe_fee", + "description": "Stripe processing fees", + "application": null + } + ] + } + ] + }, + "other_transfers": [ + "tr_2h8RC13PPvwDZs" + ], + "account": null, + "description": "STRIPE TRANSFER", + "metadata": { + }, + "statement_descriptor": null, + "recipient": null + } + } +} diff --git a/lib/stripe_mock/webhook_fixtures/transfer.updated.json b/lib/stripe_mock/webhook_fixtures/transfer.updated.json new file mode 100644 index 0000000..7e825fd --- /dev/null +++ b/lib/stripe_mock/webhook_fixtures/transfer.updated.json @@ -0,0 +1,92 @@ +{ + "created": 1326853478, + "livemode": false, + "id": "evt_00000000000000", + "type": "transfer.updated", + "object": "event", + "data": { + "object": { + "id": "tr_00000000000000", + "object": "transfer", + "date": 1381104000, + "livemode": false, + "amount": 67, + "currency": "usd", + "status": "pending", + "balance_transaction": "txn_00000000000000", + "summary": { + "charge_gross": 100, + "charge_fees": 33, + "charge_fee_details": [ + { + "amount": 33, + "currency": "usd", + "type": "stripe_fee", + "description": null, + "application": null + } + ], + "refund_gross": 0, + "refund_fees": 0, + "refund_fee_details": [ + + ], + "adjustment_gross": 0, + "adjustment_fees": 0, + "adjustment_fee_details": [ + + ], + "validation_fees": 0, + "validation_count": 0, + "charge_count": 1, + "refund_count": 0, + "adjustment_count": 0, + "net": 67, + "currency": "usd", + "collected_fee_gross": 0, + "collected_fee_count": 0, + "collected_fee_refund_gross": 0, + "collected_fee_refund_count": 0 + }, + "transactions": { + "object": "list", + "count": 1, + "url": "/v1/transfers/tr_2h8RC13PPvwDZs/transactions", + "has_more": false, + "data": [ + { + "id": "ch_2fb4RERw49oI8s", + "type": "charge", + "amount": 100, + "currency": "usd", + "net": 67, + "created": 1380582860, + "description": null, + "fee": 33, + "fee_details": [ + { + "amount": 33, + "currency": "usd", + "type": "stripe_fee", + "description": "Stripe processing fees", + "application": null + } + ] + } + ] + }, + "other_transfers": [ + "tr_2h8RC13PPvwDZs" + ], + "account": null, + "description": "STRIPE TRANSFER", + "metadata": { + }, + "statement_descriptor": null, + "recipient": null + }, + "previous_attributes": { + "amount": 123 + } + } +} diff --git a/lib/trollop.rb b/lib/trollop.rb new file mode 100644 index 0000000..d7f7bcc --- /dev/null +++ b/lib/trollop.rb @@ -0,0 +1,782 @@ +## lib/trollop.rb -- trollop command-line processing library +## Author:: William Morgan (mailto: wmorgan-trollop@masanjin.net) +## Copyright:: Copyright 2007 William Morgan +## License:: the same terms as ruby itself + +require 'date' + +module Trollop + +VERSION = "2.0" + +## Thrown by Parser in the event of a commandline error. Not needed if +## you're using the Trollop::options entry. +class CommandlineError < StandardError; end + +## Thrown by Parser if the user passes in '-h' or '--help'. Handled +## automatically by Trollop#options. +class HelpNeeded < StandardError; end + +## Thrown by Parser if the user passes in '-h' or '--version'. Handled +## automatically by Trollop#options. +class VersionNeeded < StandardError; end + +## Regex for floating point numbers +FLOAT_RE = /^-?((\d+(\.\d+)?)|(\.\d+))([eE][-+]?[\d]+)?$/ + +## Regex for parameters +PARAM_RE = /^-(-|\.$|[^\d\.])/ + +## The commandline parser. In typical usage, the methods in this class +## will be handled internally by Trollop::options. In this case, only the +## #opt, #banner and #version, #depends, and #conflicts methods will +## typically be called. +## +## If you want to instantiate this class yourself (for more complicated +## argument-parsing logic), call #parse to actually produce the output hash, +## and consider calling it from within +## Trollop::with_standard_exception_handling. +class Parser + + ## The set of values that indicate a flag option when passed as the + ## +:type+ parameter of #opt. + FLAG_TYPES = [:flag, :bool, :boolean] + + ## The set of values that indicate a single-parameter (normal) option when + ## passed as the +:type+ parameter of #opt. + ## + ## A value of +io+ corresponds to a readable IO resource, including + ## a filename, URI, or the strings 'stdin' or '-'. + SINGLE_ARG_TYPES = [:int, :integer, :string, :double, :float, :io, :date] + + ## The set of values that indicate a multiple-parameter option (i.e., that + ## takes multiple space-separated values on the commandline) when passed as + ## the +:type+ parameter of #opt. + MULTI_ARG_TYPES = [:ints, :integers, :strings, :doubles, :floats, :ios, :dates] + + ## The complete set of legal values for the +:type+ parameter of #opt. + TYPES = FLAG_TYPES + SINGLE_ARG_TYPES + MULTI_ARG_TYPES + + INVALID_SHORT_ARG_REGEX = /[\d-]/ #:nodoc: + + ## The values from the commandline that were not interpreted by #parse. + attr_reader :leftovers + + ## The complete configuration hashes for each option. (Mainly useful + ## for testing.) + attr_reader :specs + + ## Initializes the parser, and instance-evaluates any block given. + def initialize *a, &b + @version = nil + @leftovers = [] + @specs = {} + @long = {} + @short = {} + @order = [] + @constraints = [] + @stop_words = [] + @stop_on_unknown = false + + #instance_eval(&b) if b # can't take arguments + cloaker(&b).bind(self).call(*a) if b + end + + ## Define an option. +name+ is the option name, a unique identifier + ## for the option that you will use internally, which should be a + ## symbol or a string. +desc+ is a string description which will be + ## displayed in help messages. + ## + ## Takes the following optional arguments: + ## + ## [+:long+] Specify the long form of the argument, i.e. the form with two dashes. If unspecified, will be automatically derived based on the argument name by turning the +name+ option into a string, and replacing any _'s by -'s. + ## [+:short+] Specify the short form of the argument, i.e. the form with one dash. If unspecified, will be automatically derived from +name+. + ## [+:type+] Require that the argument take a parameter or parameters of type +type+. For a single parameter, the value can be a member of +SINGLE_ARG_TYPES+, or a corresponding Ruby class (e.g. +Integer+ for +:int+). For multiple-argument parameters, the value can be any member of +MULTI_ARG_TYPES+ constant. If unset, the default argument type is +:flag+, meaning that the argument does not take a parameter. The specification of +:type+ is not necessary if a +:default+ is given. + ## [+:default+] Set the default value for an argument. Without a default value, the hash returned by #parse (and thus Trollop::options) will have a +nil+ value for this key unless the argument is given on the commandline. The argument type is derived automatically from the class of the default value given, so specifying a +:type+ is not necessary if a +:default+ is given. (But see below for an important caveat when +:multi+: is specified too.) If the argument is a flag, and the default is set to +true+, then if it is specified on the the commandline the value will be +false+. + ## [+:required+] If set to +true+, the argument must be provided on the commandline. + ## [+:multi+] If set to +true+, allows multiple occurrences of the option on the commandline. Otherwise, only a single instance of the option is allowed. (Note that this is different from taking multiple parameters. See below.) + ## + ## Note that there are two types of argument multiplicity: an argument + ## can take multiple values, e.g. "--arg 1 2 3". An argument can also + ## be allowed to occur multiple times, e.g. "--arg 1 --arg 2". + ## + ## Arguments that take multiple values should have a +:type+ parameter + ## drawn from +MULTI_ARG_TYPES+ (e.g. +:strings+), or a +:default:+ + ## value of an array of the correct type (e.g. [String]). The + ## value of this argument will be an array of the parameters on the + ## commandline. + ## + ## Arguments that can occur multiple times should be marked with + ## +:multi+ => +true+. The value of this argument will also be an array. + ## In contrast with regular non-multi options, if not specified on + ## the commandline, the default value will be [], not nil. + ## + ## These two attributes can be combined (e.g. +:type+ => +:strings+, + ## +:multi+ => +true+), in which case the value of the argument will be + ## an array of arrays. + ## + ## There's one ambiguous case to be aware of: when +:multi+: is true and a + ## +:default+ is set to an array (of something), it's ambiguous whether this + ## is a multi-value argument as well as a multi-occurrence argument. + ## In thise case, Trollop assumes that it's not a multi-value argument. + ## If you want a multi-value, multi-occurrence argument with a default + ## value, you must specify +:type+ as well. + + def opt name, desc="", opts={} + raise ArgumentError, "you already have an argument named '#{name}'" if @specs.member? name + + ## fill in :type + opts[:type] = # normalize + case opts[:type] + when :boolean, :bool; :flag + when :integer; :int + when :integers; :ints + when :double; :float + when :doubles; :floats + when Class + case opts[:type].name + when 'TrueClass', 'FalseClass'; :flag + when 'String'; :string + when 'Integer'; :int + when 'Float'; :float + when 'IO'; :io + when 'Date'; :date + else + raise ArgumentError, "unsupported argument type '#{opts[:type].class.name}'" + end + when nil; nil + else + raise ArgumentError, "unsupported argument type '#{opts[:type]}'" unless TYPES.include?(opts[:type]) + opts[:type] + end + + ## for options with :multi => true, an array default doesn't imply + ## a multi-valued argument. for that you have to specify a :type + ## as well. (this is how we disambiguate an ambiguous situation; + ## see the docs for Parser#opt for details.) + disambiguated_default = if opts[:multi] && opts[:default].is_a?(Array) && !opts[:type] + opts[:default].first + else + opts[:default] + end + + type_from_default = + case disambiguated_default + when Integer; :int + when Numeric; :float + when TrueClass, FalseClass; :flag + when String; :string + when IO; :io + when Date; :date + when Array + if opts[:default].empty? + raise ArgumentError, "multiple argument type cannot be deduced from an empty array for '#{opts[:default][0].class.name}'" + end + case opts[:default][0] # the first element determines the types + when Integer; :ints + when Numeric; :floats + when String; :strings + when IO; :ios + when Date; :dates + else + raise ArgumentError, "unsupported multiple argument type '#{opts[:default][0].class.name}'" + end + when nil; nil + else + raise ArgumentError, "unsupported argument type '#{opts[:default].class.name}'" + end + + raise ArgumentError, ":type specification and default type don't match (default type is #{type_from_default})" if opts[:type] && type_from_default && opts[:type] != type_from_default + + opts[:type] = opts[:type] || type_from_default || :flag + + ## fill in :long + opts[:long] = opts[:long] ? opts[:long].to_s : name.to_s.gsub("_", "-") + opts[:long] = case opts[:long] + when /^--([^-].*)$/; $1 + when /^[^-]/; opts[:long] + else; raise ArgumentError, "invalid long option name #{opts[:long].inspect}" + end + raise ArgumentError, "long option name #{opts[:long].inspect} is already taken; please specify a (different) :long" if @long[opts[:long]] + + ## fill in :short + opts[:short] = opts[:short].to_s if opts[:short] unless opts[:short] == :none + opts[:short] = case opts[:short] + when /^-(.)$/; $1 + when nil, :none, /^.$/; opts[:short] + else raise ArgumentError, "invalid short option name '#{opts[:short].inspect}'" + end + + if opts[:short] + raise ArgumentError, "short option name #{opts[:short].inspect} is already taken; please specify a (different) :short" if @short[opts[:short]] + raise ArgumentError, "a short option name can't be a number or a dash" if opts[:short] =~ INVALID_SHORT_ARG_REGEX + end + + ## fill in :default for flags + opts[:default] = false if opts[:type] == :flag && opts[:default].nil? + + ## autobox :default for :multi (multi-occurrence) arguments + opts[:default] = [opts[:default]] if opts[:default] && opts[:multi] && !opts[:default].is_a?(Array) + + ## fill in :multi + opts[:multi] ||= false + + opts[:desc] ||= desc + @long[opts[:long]] = name + @short[opts[:short]] = name if opts[:short] && opts[:short] != :none + @specs[name] = opts + @order << [:opt, name] + end + + ## Sets the version string. If set, the user can request the version + ## on the commandline. Should probably be of the form " + ## ". + def version s=nil; @version = s if s; @version end + + ## Adds text to the help display. Can be interspersed with calls to + ## #opt to build a multi-section help page. + def banner s; @order << [:text, s] end + alias :text :banner + + ## Marks two (or more!) options as requiring each other. Only handles + ## undirected (i.e., mutual) dependencies. Directed dependencies are + ## better modeled with Trollop::die. + def depends *syms + syms.each { |sym| raise ArgumentError, "unknown option '#{sym}'" unless @specs[sym] } + @constraints << [:depends, syms] + end + + ## Marks two (or more!) options as conflicting. + def conflicts *syms + syms.each { |sym| raise ArgumentError, "unknown option '#{sym}'" unless @specs[sym] } + @constraints << [:conflicts, syms] + end + + ## Defines a set of words which cause parsing to terminate when + ## encountered, such that any options to the left of the word are + ## parsed as usual, and options to the right of the word are left + ## intact. + ## + ## A typical use case would be for subcommand support, where these + ## would be set to the list of subcommands. A subsequent Trollop + ## invocation would then be used to parse subcommand options, after + ## shifting the subcommand off of ARGV. + def stop_on *words + @stop_words = [*words].flatten + end + + ## Similar to #stop_on, but stops on any unknown word when encountered + ## (unless it is a parameter for an argument). This is useful for + ## cases where you don't know the set of subcommands ahead of time, + ## i.e., without first parsing the global options. + def stop_on_unknown + @stop_on_unknown = true + end + + ## Parses the commandline. Typically called by Trollop::options, + ## but you can call it directly if you need more control. + ## + ## throws CommandlineError, HelpNeeded, and VersionNeeded exceptions. + def parse cmdline=ARGV + vals = {} + required = {} + + opt :version, "Print version and exit" if @version unless @specs[:version] || @long["version"] + opt :help, "Show this message" unless @specs[:help] || @long["help"] + + @specs.each do |sym, opts| + required[sym] = true if opts[:required] + vals[sym] = opts[:default] + vals[sym] = [] if opts[:multi] && !opts[:default] # multi arguments default to [], not nil + end + + resolve_default_short_options! + + ## resolve symbols + given_args = {} + @leftovers = each_arg cmdline do |arg, params| + ## handle --no- forms + arg, negative_given = if arg =~ /^--no-([^-]\S*)$/ + ["--#{$1}", true] + else + [arg, false] + end + + sym = case arg + when /^-([^-])$/; @short[$1] + when /^--([^-]\S*)$/; @long[$1] || @long["no-#{$1}"] + else; raise CommandlineError, "invalid argument syntax: '#{arg}'" + end + + sym = nil if arg =~ /--no-/ # explicitly invalidate --no-no- arguments + + raise CommandlineError, "unknown argument '#{arg}'" unless sym + + if given_args.include?(sym) && !@specs[sym][:multi] + raise CommandlineError, "option '#{arg}' specified multiple times" + end + + given_args[sym] ||= {} + given_args[sym][:arg] = arg + given_args[sym][:negative_given] = negative_given + given_args[sym][:params] ||= [] + + # The block returns the number of parameters taken. + num_params_taken = 0 + + unless params.nil? + if SINGLE_ARG_TYPES.include?(@specs[sym][:type]) + given_args[sym][:params] << params[0, 1] # take the first parameter + num_params_taken = 1 + elsif MULTI_ARG_TYPES.include?(@specs[sym][:type]) + given_args[sym][:params] << params # take all the parameters + num_params_taken = params.size + end + end + + num_params_taken + end + + ## check for version and help args + raise VersionNeeded if given_args.include? :version + raise HelpNeeded if given_args.include? :help + + ## check constraint satisfaction + @constraints.each do |type, syms| + constraint_sym = syms.find { |sym| given_args[sym] } + next unless constraint_sym + + case type + when :depends + syms.each { |sym| raise CommandlineError, "--#{@specs[constraint_sym][:long]} requires --#{@specs[sym][:long]}" unless given_args.include? sym } + when :conflicts + syms.each { |sym| raise CommandlineError, "--#{@specs[constraint_sym][:long]} conflicts with --#{@specs[sym][:long]}" if given_args.include?(sym) && (sym != constraint_sym) } + end + end + + required.each do |sym, val| + raise CommandlineError, "option --#{@specs[sym][:long]} must be specified" unless given_args.include? sym + end + + ## parse parameters + given_args.each do |sym, given_data| + arg, params, negative_given = given_data.values_at :arg, :params, :negative_given + + opts = @specs[sym] + raise CommandlineError, "option '#{arg}' needs a parameter" if params.empty? && opts[:type] != :flag + + vals["#{sym}_given".intern] = true # mark argument as specified on the commandline + + case opts[:type] + when :flag + vals[sym] = (sym.to_s =~ /^no_/ ? negative_given : !negative_given) + when :int, :ints + vals[sym] = params.map { |pg| pg.map { |p| parse_integer_parameter p, arg } } + when :float, :floats + vals[sym] = params.map { |pg| pg.map { |p| parse_float_parameter p, arg } } + when :string, :strings + vals[sym] = params.map { |pg| pg.map { |p| p.to_s } } + when :io, :ios + vals[sym] = params.map { |pg| pg.map { |p| parse_io_parameter p, arg } } + when :date, :dates + vals[sym] = params.map { |pg| pg.map { |p| parse_date_parameter p, arg } } + end + + if SINGLE_ARG_TYPES.include?(opts[:type]) + unless opts[:multi] # single parameter + vals[sym] = vals[sym][0][0] + else # multiple options, each with a single parameter + vals[sym] = vals[sym].map { |p| p[0] } + end + elsif MULTI_ARG_TYPES.include?(opts[:type]) && !opts[:multi] + vals[sym] = vals[sym][0] # single option, with multiple parameters + end + # else: multiple options, with multiple parameters + end + + ## modify input in place with only those + ## arguments we didn't process + cmdline.clear + @leftovers.each { |l| cmdline << l } + + ## allow openstruct-style accessors + class << vals + def method_missing(m, *args) + self[m] || self[m.to_s] + end + end + vals + end + + def parse_date_parameter param, arg #:nodoc: + begin + begin + time = Chronic.parse(param) + rescue NameError + # chronic is not available + end + time ? Date.new(time.year, time.month, time.day) : Date.parse(param) + rescue ArgumentError + raise CommandlineError, "option '#{arg}' needs a date" + end + end + + ## Print the help message to +stream+. + def educate stream=$stdout + width # hack: calculate it now; otherwise we have to be careful not to + # call this unless the cursor's at the beginning of a line. + left = {} + @specs.each do |name, spec| + left[name] = "--#{spec[:long]}" + + (spec[:type] == :flag && spec[:default] ? ", --no-#{spec[:long]}" : "") + + (spec[:short] && spec[:short] != :none ? ", -#{spec[:short]}" : "") + + case spec[:type] + when :flag; "" + when :int; " " + when :ints; " " + when :string; " " + when :strings; " " + when :float; " " + when :floats; " " + when :io; " " + when :ios; " " + when :date; " " + when :dates; " " + end + end + + leftcol_width = left.values.map { |s| s.length }.max || 0 + rightcol_start = leftcol_width + 6 # spaces + + unless @order.size > 0 && @order.first.first == :text + stream.puts "#@version\n" if @version + stream.puts "Options:" + end + + @order.each do |what, opt| + if what == :text + stream.puts wrap(opt) + next + end + + spec = @specs[opt] + stream.printf " %#{leftcol_width}s: ", left[opt] + desc = spec[:desc] + begin + default_s = case spec[:default] + when $stdout; "" + when $stdin; "" + when $stderr; "" + when Array + spec[:default].join(", ") + else + spec[:default].to_s + end + + if spec[:default] + if spec[:desc] =~ /\.$/ + " (Default: #{default_s})" + else + " (default: #{default_s})" + end + else + "" + end + end + stream.puts wrap(desc, :width => width - rightcol_start - 1, :prefix => rightcol_start) + end + end + + def width #:nodoc: + @width ||= if $stdout.tty? + begin + require 'curses' + Curses::init_screen + x = Curses::cols + Curses::close_screen + x + rescue Exception + 80 + end + else + 80 + end + end + + def wrap str, opts={} # :nodoc: + if str == "" + [""] + else + str.split("\n").map { |s| wrap_line s, opts }.flatten + end + end + + ## The per-parser version of Trollop::die (see that for documentation). + def die arg, msg + if msg + $stderr.puts "Error: argument --#{@specs[arg][:long]} #{msg}." + else + $stderr.puts "Error: #{arg}." + end + $stderr.puts "Try --help for help." + exit(-1) + end + +private + + ## yield successive arg, parameter pairs + def each_arg args + remains = [] + i = 0 + + until i >= args.length + if @stop_words.member? args[i] + remains += args[i .. -1] + return remains + end + case args[i] + when /^--$/ # arg terminator + remains += args[(i + 1) .. -1] + return remains + when /^--(\S+?)=(.*)$/ # long argument with equals + yield "--#{$1}", [$2] + i += 1 + when /^--(\S+)$/ # long argument + params = collect_argument_parameters(args, i + 1) + unless params.empty? + num_params_taken = yield args[i], params + unless num_params_taken + if @stop_on_unknown + remains += args[i + 1 .. -1] + return remains + else + remains += params + end + end + i += 1 + num_params_taken + else # long argument no parameter + yield args[i], nil + i += 1 + end + when /^-(\S+)$/ # one or more short arguments + shortargs = $1.split(//) + shortargs.each_with_index do |a, j| + if j == (shortargs.length - 1) + params = collect_argument_parameters(args, i + 1) + unless params.empty? + num_params_taken = yield "-#{a}", params + unless num_params_taken + if @stop_on_unknown + remains += args[i + 1 .. -1] + return remains + else + remains += params + end + end + i += 1 + num_params_taken + else # argument no parameter + yield "-#{a}", nil + i += 1 + end + else + yield "-#{a}", nil + end + end + else + if @stop_on_unknown + remains += args[i .. -1] + return remains + else + remains << args[i] + i += 1 + end + end + end + + remains + end + + def parse_integer_parameter param, arg + raise CommandlineError, "option '#{arg}' needs an integer" unless param =~ /^\d+$/ + param.to_i + end + + def parse_float_parameter param, arg + raise CommandlineError, "option '#{arg}' needs a floating-point number" unless param =~ FLOAT_RE + param.to_f + end + + def parse_io_parameter param, arg + case param + when /^(stdin|-)$/i; $stdin + else + require 'open-uri' + begin + open param + rescue SystemCallError => e + raise CommandlineError, "file or url for option '#{arg}' cannot be opened: #{e.message}" + end + end + end + + def collect_argument_parameters args, start_at + params = [] + pos = start_at + while args[pos] && args[pos] !~ PARAM_RE && !@stop_words.member?(args[pos]) do + params << args[pos] + pos += 1 + end + params + end + + def resolve_default_short_options! + @order.each do |type, name| + next unless type == :opt + opts = @specs[name] + next if opts[:short] + + c = opts[:long].split(//).find { |d| d !~ INVALID_SHORT_ARG_REGEX && !@short.member?(d) } + if c # found a character to use + opts[:short] = c + @short[c] = name + end + end + end + + def wrap_line str, opts={} + prefix = opts[:prefix] || 0 + width = opts[:width] || (self.width - 1) + start = 0 + ret = [] + until start > str.length + nextt = + if start + width >= str.length + str.length + else + x = str.rindex(/\s/, start + width) + x = str.index(/\s/, start) if x && x < start + x || str.length + end + ret << (ret.empty? ? "" : " " * prefix) + str[start ... nextt] + start = nextt + 1 + end + ret + end + + ## instance_eval but with ability to handle block arguments + ## thanks to _why: http://redhanded.hobix.com/inspect/aBlockCostume.html + def cloaker &b + (class << self; self; end).class_eval do + define_method :cloaker_, &b + meth = instance_method :cloaker_ + remove_method :cloaker_ + meth + end + end +end + +## The easy, syntactic-sugary entry method into Trollop. Creates a Parser, +## passes the block to it, then parses +args+ with it, handling any errors or +## requests for help or version information appropriately (and then exiting). +## Modifies +args+ in place. Returns a hash of option values. +## +## The block passed in should contain zero or more calls to +opt+ +## (Parser#opt), zero or more calls to +text+ (Parser#text), and +## probably a call to +version+ (Parser#version). +## +## The returned block contains a value for every option specified with +## +opt+. The value will be the value given on the commandline, or the +## default value if the option was not specified on the commandline. For +## every option specified on the commandline, a key "