From 3c510ab78a18d9fd747ae0946dfa63807d439e91 Mon Sep 17 00:00:00 2001 From: Alessandro Desantis Date: Fri, 9 Oct 2020 14:03:44 +0200 Subject: [PATCH 1/6] Install and configure VCR --- solidus_subscriptions.gemspec | 2 ++ spec/support/vcr.rb | 10 ++++++++++ 2 files changed, 12 insertions(+) create mode 100644 spec/support/vcr.rb diff --git a/solidus_subscriptions.gemspec b/solidus_subscriptions.gemspec index a0844718..c909f929 100644 --- a/solidus_subscriptions.gemspec +++ b/solidus_subscriptions.gemspec @@ -39,6 +39,8 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'shoulda-matchers', '~> 4.4' spec.add_development_dependency 'solidus_dev_support', '~> 2.0' spec.add_development_dependency 'timecop' + spec.add_development_dependency 'vcr' spec.add_development_dependency 'versioncake' + spec.add_development_dependency 'webmock' spec.add_development_dependency 'yard' end diff --git a/spec/support/vcr.rb b/spec/support/vcr.rb new file mode 100644 index 00000000..ca8a14a4 --- /dev/null +++ b/spec/support/vcr.rb @@ -0,0 +1,10 @@ +require 'webmock/rspec' +require 'vcr' + +WebMock.disable_net_connect! + +VCR.configure do |config| + config.cassette_library_dir = "spec/fixtures/cassettes" + config.hook_into :webmock + config.configure_rspec_metadata! +end From d0d2abca6c2f3c57ed16087a816d2638134208d8 Mon Sep 17 00:00:00 2001 From: Alessandro Desantis Date: Fri, 9 Oct 2020 14:04:03 +0200 Subject: [PATCH 2/6] Fire events for subscription installment processing --- .../payment_failed_dispatcher.rb | 6 ++++++ .../solidus_subscriptions/success_dispatcher.rb | 6 ++++++ .../payment_failed_dispatcher_spec.rb | 15 +++++++++++++++ .../success_dispatcher_spec.rb | 15 +++++++++++++++ 4 files changed, 42 insertions(+) diff --git a/app/services/solidus_subscriptions/payment_failed_dispatcher.rb b/app/services/solidus_subscriptions/payment_failed_dispatcher.rb index 562992b9..29eb2913 100644 --- a/app/services/solidus_subscriptions/payment_failed_dispatcher.rb +++ b/app/services/solidus_subscriptions/payment_failed_dispatcher.rb @@ -9,6 +9,12 @@ def dispatch installments.each do |installment| installment.payment_failed!(order) end + + ::Spree::Event.fire( + 'solidus_subscriptions.installments_failed_payment', + installments: installments, + order: order, + ) end end end diff --git a/app/services/solidus_subscriptions/success_dispatcher.rb b/app/services/solidus_subscriptions/success_dispatcher.rb index 4ac5e945..ce552662 100644 --- a/app/services/solidus_subscriptions/success_dispatcher.rb +++ b/app/services/solidus_subscriptions/success_dispatcher.rb @@ -7,6 +7,12 @@ def dispatch installments.each do |installment| installment.success!(order) end + + ::Spree::Event.fire( + 'solidus_subscriptions.installments_succeeded', + installments: installments, + order: order, + ) end end end diff --git a/spec/services/solidus_subscriptions/payment_failed_dispatcher_spec.rb b/spec/services/solidus_subscriptions/payment_failed_dispatcher_spec.rb index 18a018d6..a6a8a0ca 100644 --- a/spec/services/solidus_subscriptions/payment_failed_dispatcher_spec.rb +++ b/spec/services/solidus_subscriptions/payment_failed_dispatcher_spec.rb @@ -23,5 +23,20 @@ expect(order.state).to eq('canceled') end + + it 'fires an installments_failed_payment event' do + stub_const('Spree::Event', class_spy(Spree::Event)) + installments = Array.new(2) { instance_spy(SolidusSubscriptions::Installment) } + order = create(:order_with_line_items) + + dispatcher = described_class.new(installments, order) + dispatcher.dispatch + + expect(Spree::Event).to have_received(:fire).with( + 'solidus_subscriptions.installments_failed_payment', + installments: installments, + order: order, + ) + end end end diff --git a/spec/services/solidus_subscriptions/success_dispatcher_spec.rb b/spec/services/solidus_subscriptions/success_dispatcher_spec.rb index e8a6c3d0..ce41638a 100644 --- a/spec/services/solidus_subscriptions/success_dispatcher_spec.rb +++ b/spec/services/solidus_subscriptions/success_dispatcher_spec.rb @@ -9,5 +9,20 @@ expect(installments).to all(have_received(:success!).with(order).once) end + + it 'fires an installments_succeeded event' do + stub_const('Spree::Event', class_spy(Spree::Event)) + installments = Array.new(2) { instance_spy(SolidusSubscriptions::Installment) } + order = create(:order_with_line_items) + + dispatcher = described_class.new(installments, order) + dispatcher.dispatch + + expect(Spree::Event).to have_received(:fire).with( + 'solidus_subscriptions.installments_succeeded', + installments: installments, + order: order, + ) + end end end From d7369ba04b3657e47e21e6d24af516886ef0dbc4 Mon Sep 17 00:00:00 2001 From: Alessandro Desantis Date: Fri, 9 Oct 2020 14:04:49 +0200 Subject: [PATCH 3/6] Fire events for subscription payment method updates --- .../report_default_change_to_subscriptions.rb | 28 +++++++++++++++++++ .../solidus_subscriptions/subscription.rb | 11 ++++++++ .../subscription_spec.rb | 16 +++++++++-- .../spree/wallet_payment_source_spec.rb | 18 ++++++++++++ 4 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 app/decorators/models/solidus_subscriptions/spree/wallet_payment_source/report_default_change_to_subscriptions.rb create mode 100644 spec/models/spree/wallet_payment_source_spec.rb diff --git a/app/decorators/models/solidus_subscriptions/spree/wallet_payment_source/report_default_change_to_subscriptions.rb b/app/decorators/models/solidus_subscriptions/spree/wallet_payment_source/report_default_change_to_subscriptions.rb new file mode 100644 index 00000000..6ee754e7 --- /dev/null +++ b/app/decorators/models/solidus_subscriptions/spree/wallet_payment_source/report_default_change_to_subscriptions.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module SolidusSubscriptions + module Spree + module WalletPaymentSource + module ReportDefaultChangeToSubscriptions + def self.prepended(base) + base.after_save :report_default_change_to_subscriptions + end + + private + + def report_default_change_to_subscriptions + return if !previous_changes.key?('default') || !default? + + user.subscriptions.with_default_payment_source.each do |subscription| + ::Spree::Event.fire( + 'solidus_subscriptions.subscription_payment_method_changed', + subscription: subscription, + ) + end + end + end + end + end +end + +Spree::WalletPaymentSource.prepend(SolidusSubscriptions::Spree::WalletPaymentSource::ReportDefaultChangeToSubscriptions) diff --git a/app/models/solidus_subscriptions/subscription.rb b/app/models/solidus_subscriptions/subscription.rb index d3dd2f5d..08de3c9d 100644 --- a/app/models/solidus_subscriptions/subscription.rb +++ b/app/models/solidus_subscriptions/subscription.rb @@ -74,6 +74,10 @@ class Subscription < ApplicationRecord joins(:installments).merge(Installment.unfulfilled) end) + scope :with_default_payment_source, (lambda do + where(payment_method: nil, payment_source: nil) + end) + def self.ransackable_scopes(_auth_object = nil) [:in_processing_state] end @@ -325,6 +329,13 @@ def emit_events_for_update subscription: self, ) end + + if previous_changes.key?('payment_source_id') || previous_changes.key?('payment_source_type') || previous_changes.key?('payment_method_id') + ::Spree::Event.fire( + 'solidus_subscriptions.subscription_payment_method_changed', + subscription: self, + ) + end end end end diff --git a/spec/models/solidus_subscriptions/subscription_spec.rb b/spec/models/solidus_subscriptions/subscription_spec.rb index 60a37e67..21e10153 100644 --- a/spec/models/solidus_subscriptions/subscription_spec.rb +++ b/spec/models/solidus_subscriptions/subscription_spec.rb @@ -64,6 +64,18 @@ subscription: subscription, ) end + + it 'tracks payment method changes' do + stub_const('Spree::Event', class_spy(Spree::Event)) + + subscription = create(:subscription) + subscription.update!(payment_source: create(:credit_card)) + + expect(Spree::Event).to have_received(:fire).with( + 'solidus_subscriptions.subscription_payment_method_changed', + subscription: subscription, + ) + end end describe '#cancel' do @@ -445,7 +457,7 @@ end context 'when the subscription has no payment method' do - it "returns the default source from the user's wallet" do + it "returns the default source from the user's wallet_payment_source" do user = create(:user) payment_source = create(:credit_card, gateway_customer_profile_id: 'BGS-123', user: user) wallet_payment_source = user.wallet.add(payment_source) @@ -487,7 +499,7 @@ end context 'when the subscription has no payment method' do - it "returns the method from the default source in the user's wallet" do + it "returns the method from the default source in the user's wallet_payment_source" do user = create(:user) payment_source = create(:credit_card, gateway_customer_profile_id: 'BGS-123', user: user) wallet_payment_source = user.wallet.add(payment_source) diff --git a/spec/models/spree/wallet_payment_source_spec.rb b/spec/models/spree/wallet_payment_source_spec.rb new file mode 100644 index 00000000..374e2351 --- /dev/null +++ b/spec/models/spree/wallet_payment_source_spec.rb @@ -0,0 +1,18 @@ +RSpec.describe Spree::WalletPaymentSource do + describe 'setting it as the default' do + it 'reports a payment method changed event for subscriptions that use the default payment source' do + stub_const('Spree::Event', class_spy(Spree::Event)) + user = create(:user) + subscription = create(:subscription, user: user) + payment_source = create(:credit_card, user: user) + wallet_payment_source = user.wallet.add(payment_source) + + user.wallet.default_wallet_payment_source = wallet_payment_source + + expect(Spree::Event).to have_received(:fire).with( + 'solidus_subscriptions.subscription_payment_method_changed', + subscription: subscription, + ) + end + end +end From 5a5b2a3201462532f4fa9d38e02867a243c68670 Mon Sep 17 00:00:00 2001 From: Alessandro Desantis Date: Fri, 9 Oct 2020 14:05:43 +0200 Subject: [PATCH 4/6] Implement Churn Buster API client --- .../install/templates/initializer.rb | 14 ++ lib/solidus_subscriptions.rb | 16 ++ .../churn_buster/client.rb | 48 ++++ .../churn_buster/order_serializer.rb | 19 ++ .../churn_buster/serializer.rb | 23 ++ .../subscription_customer_serializer.rb | 19 ++ .../subscription_payment_method_serializer.rb | 37 +++ .../churn_buster/subscription_serializer.rb | 17 ++ lib/solidus_subscriptions/configuration.rb | 8 +- solidus_subscriptions.gemspec | 1 + spec/fixtures/cassettes/churn_buster.yml | 229 ++++++++++++++++++ .../churn_buster/client_spec.rb | 57 +++++ spec/lib/solidus_subscriptions_spec.rb | 28 +++ 13 files changed, 515 insertions(+), 1 deletion(-) create mode 100644 lib/solidus_subscriptions/churn_buster/client.rb create mode 100644 lib/solidus_subscriptions/churn_buster/order_serializer.rb create mode 100644 lib/solidus_subscriptions/churn_buster/serializer.rb create mode 100644 lib/solidus_subscriptions/churn_buster/subscription_customer_serializer.rb create mode 100644 lib/solidus_subscriptions/churn_buster/subscription_payment_method_serializer.rb create mode 100644 lib/solidus_subscriptions/churn_buster/subscription_serializer.rb create mode 100644 spec/fixtures/cassettes/churn_buster.yml create mode 100644 spec/lib/solidus_subscriptions/churn_buster/client_spec.rb create mode 100644 spec/lib/solidus_subscriptions_spec.rb diff --git a/lib/generators/solidus_subscriptions/install/templates/initializer.rb b/lib/generators/solidus_subscriptions/install/templates/initializer.rb index 17a6f519..e1e97317 100644 --- a/lib/generators/solidus_subscriptions/install/templates/initializer.rb +++ b/lib/generators/solidus_subscriptions/install/templates/initializer.rb @@ -64,4 +64,18 @@ # :interval_units, # :end_date, # ] + + # ========================================= Churn Buster ========================================= + # + # This extension can integrate with Churn Buster for churn mitigation and failed payment recovery. + # If you want to integrate with Churn Buster, simply configure your credentials below. + # + # NOTE: If you integrate with Churn Buster and override any of the handlers, make sure to call + # `super` or copy-paste the original integration code or things won't work! + + # Your Churn Buster account ID. + # config.churn_buster_account_id = 'YOUR_CHURN_BUSTER_ACCOUNT_ID' + + # Your Churn Buster API key. + # config.churn_buster_api_key = 'YOUR_CHURN_BUSTER_API_KEY' end diff --git a/lib/solidus_subscriptions.rb b/lib/solidus_subscriptions.rb index 039b1729..e9dae93d 100644 --- a/lib/solidus_subscriptions.rb +++ b/lib/solidus_subscriptions.rb @@ -4,6 +4,7 @@ require 'solidus_support' require 'deface' +require 'httparty' require 'state_machines' require 'solidus_subscriptions/configuration' @@ -11,6 +12,12 @@ require 'solidus_subscriptions/permission_sets/subscription_management' require 'solidus_subscriptions/version' require 'solidus_subscriptions/engine' +require 'solidus_subscriptions/churn_buster/client' +require 'solidus_subscriptions/churn_buster/serializer' +require 'solidus_subscriptions/churn_buster/subscription_customer_serializer' +require 'solidus_subscriptions/churn_buster/subscription_payment_method_serializer' +require 'solidus_subscriptions/churn_buster/subscription_serializer' +require 'solidus_subscriptions/churn_buster/order_serializer' module SolidusSubscriptions class << self @@ -21,5 +28,14 @@ def configure def configuration @configuration ||= Configuration.new end + + def churn_buster + return unless configuration.churn_buster? + + @churn_buster ||= ChurnBuster::Client.new( + account_id: SolidusSubscriptions.configuration.churn_buster_account_id, + api_key: SolidusSubscriptions.configuration.churn_buster_api_key, + ) + end end end diff --git a/lib/solidus_subscriptions/churn_buster/client.rb b/lib/solidus_subscriptions/churn_buster/client.rb new file mode 100644 index 00000000..915bb409 --- /dev/null +++ b/lib/solidus_subscriptions/churn_buster/client.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module SolidusSubscriptions + module ChurnBuster + class Client + BASE_API_URL = 'https://api.churnbuster.io/v1' + + attr_reader :account_id, :api_key + + def initialize(account_id:, api_key:) + @account_id = account_id + @api_key = api_key + end + + def report_failed_payment(order) + post('/failed_payments', OrderSerializer.serialize(order)) + end + + def report_successful_payment(order) + post('/successful_payments', OrderSerializer.serialize(order)) + end + + def report_subscription_cancellation(subscription) + post('/cancellations', SubscriptionSerializer.serialize(subscription)) + end + + def report_payment_method_change(subscription) + post('/payment_methods', SubscriptionPaymentMethodSerializer.serialize(subscription)) + end + + private + + def post(path, body) + HTTParty.post( + "#{BASE_API_URL}#{path}", + body: body.to_json, + headers: { + 'Content-Type' => 'application/json', + }, + basic_auth: { + username: account_id, + password: api_key, + }, + ) + end + end + end +end diff --git a/lib/solidus_subscriptions/churn_buster/order_serializer.rb b/lib/solidus_subscriptions/churn_buster/order_serializer.rb new file mode 100644 index 00000000..5bc5dee7 --- /dev/null +++ b/lib/solidus_subscriptions/churn_buster/order_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module SolidusSubscriptions + module ChurnBuster + class OrderSerializer < Serializer + def to_h + { + payment: { + source: 'in_house', + source_id: object.number, + amount_in_cents: object.display_total.cents, + currency: object.currency, + }, + customer: SubscriptionCustomerSerializer.serialize(object.subscription), + } + end + end + end +end diff --git a/lib/solidus_subscriptions/churn_buster/serializer.rb b/lib/solidus_subscriptions/churn_buster/serializer.rb new file mode 100644 index 00000000..d8b83bba --- /dev/null +++ b/lib/solidus_subscriptions/churn_buster/serializer.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module SolidusSubscriptions + module ChurnBuster + class Serializer + attr_reader :object + + class << self + def serialize(object) + new(object).to_h + end + end + + def initialize(object) + @object = object + end + + def to_h + raise NotImplementedError + end + end + end +end diff --git a/lib/solidus_subscriptions/churn_buster/subscription_customer_serializer.rb b/lib/solidus_subscriptions/churn_buster/subscription_customer_serializer.rb new file mode 100644 index 00000000..700c1c6f --- /dev/null +++ b/lib/solidus_subscriptions/churn_buster/subscription_customer_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module SolidusSubscriptions + module ChurnBuster + class SubscriptionCustomerSerializer < Serializer + def to_h + { + source: 'in_house', + source_id: object.id, + email: object.user.email, + properties: { + first_name: object.shipping_address_to_use.firstname, + last_name: object.shipping_address_to_use.lastname, + }, + } + end + end + end +end diff --git a/lib/solidus_subscriptions/churn_buster/subscription_payment_method_serializer.rb b/lib/solidus_subscriptions/churn_buster/subscription_payment_method_serializer.rb new file mode 100644 index 00000000..984059da --- /dev/null +++ b/lib/solidus_subscriptions/churn_buster/subscription_payment_method_serializer.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module SolidusSubscriptions + module ChurnBuster + class SubscriptionPaymentMethodSerializer < Serializer + def to_h + { + payment_method: { + source: 'in_house', + source_id: [ + object.payment_method_to_use&.id, + object.payment_source_to_use&.id + ].compact.join('-'), + type: 'card', + properties: payment_source_properties, + }, + customer: SubscriptionCustomerSerializer.serialize(object), + } + end + + private + + def payment_source_properties + if object.payment_source.is_a?(::Spree::CreditCard) + { + brand: object.payment_source.cc_type, + last4: object.payment_source.last_digits, + exp_month: object.payment_source.month, + exp_year: object.payment_source.year, + } + else + {} + end + end + end + end +end diff --git a/lib/solidus_subscriptions/churn_buster/subscription_serializer.rb b/lib/solidus_subscriptions/churn_buster/subscription_serializer.rb new file mode 100644 index 00000000..06c08d16 --- /dev/null +++ b/lib/solidus_subscriptions/churn_buster/subscription_serializer.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module SolidusSubscriptions + module ChurnBuster + class SubscriptionSerializer < Serializer + def to_h + { + subscription: { + source: 'in_house', + source_id: object.id + }, + customer: SubscriptionCustomerSerializer.serialize(object), + } + end + end + end +end diff --git a/lib/solidus_subscriptions/configuration.rb b/lib/solidus_subscriptions/configuration.rb index d5dc80f1..22b7d843 100644 --- a/lib/solidus_subscriptions/configuration.rb +++ b/lib/solidus_subscriptions/configuration.rb @@ -2,7 +2,9 @@ module SolidusSubscriptions class Configuration - attr_accessor :maximum_total_skips + attr_accessor( + :maximum_total_skips, :churn_buster_account_id, :churn_buster_api_key, + ) attr_writer( :success_dispatcher_class, :failure_dispatcher_class, :payment_failed_dispatcher_class, @@ -73,5 +75,9 @@ def subscribable_class @subscribable_class ||= 'Spree::Variant' @subscribable_class.constantize end + + def churn_buster? + churn_buster_account_id.present? && churn_buster_api_key.present? + end end end diff --git a/solidus_subscriptions.gemspec b/solidus_subscriptions.gemspec index c909f929..46fec6ea 100644 --- a/solidus_subscriptions.gemspec +++ b/solidus_subscriptions.gemspec @@ -30,6 +30,7 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_dependency 'deface' + spec.add_dependency 'httparty', '~> 0.18' spec.add_dependency 'i18n' spec.add_dependency 'solidus_core', ['>= 2.0.0', '< 3'] spec.add_dependency 'solidus_support', '~> 0.5' diff --git a/spec/fixtures/cassettes/churn_buster.yml b/spec/fixtures/cassettes/churn_buster.yml new file mode 100644 index 00000000..0d3803a1 --- /dev/null +++ b/spec/fixtures/cassettes/churn_buster.yml @@ -0,0 +1,229 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.churnbuster.io/v1/successful_payments + body: + encoding: UTF-8 + string: '{"payment":{"source":"in_house","source_id":"R863897282","amount_in_cents":0,"currency":"USD"},"customer":{"source":"in_house","source_id":1,"email":"email2@example.com","properties":{"first_name":"John","last_name":"John"}}}' + headers: + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Server: + - Cowboy + Date: + - Thu, 01 Oct 2020 13:46:09 GMT + Connection: + - keep-alive + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Permitted-Cross-Domain-Policies: + - none + Referrer-Policy: + - strict-origin-when-cross-origin + Content-Type: + - application/json + Cache-Control: + - no-cache + X-Request-Id: + - a664eaa0-2735-4a88-990d-fb9c29766e00 + X-Runtime: + - '0.070480' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + Transfer-Encoding: + - chunked + Via: + - 1.1 vegur + body: + encoding: UTF-8 + string: '' + recorded_at: Thu, 01 Oct 2020 13:46:09 GMT +- request: + method: post + uri: https://api.churnbuster.io/v1/failed_payments + body: + encoding: UTF-8 + string: '{"payment":{"source":"in_house","source_id":"R276044153","amount_in_cents":0,"currency":"USD"},"customer":{"source":"in_house","source_id":1,"email":"email2@example.com","properties":{"first_name":"John","last_name":"John"}}}' + headers: + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Server: + - Cowboy + Date: + - Thu, 01 Oct 2020 14:24:32 GMT + Connection: + - keep-alive + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Permitted-Cross-Domain-Policies: + - none + Referrer-Policy: + - strict-origin-when-cross-origin + Content-Type: + - application/json + Cache-Control: + - no-cache + X-Request-Id: + - bfd0c00b-dafa-4122-95e3-6fae6676bf05 + X-Runtime: + - '0.052157' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + Transfer-Encoding: + - chunked + Via: + - 1.1 vegur + body: + encoding: UTF-8 + string: '' + recorded_at: Thu, 01 Oct 2020 14:24:32 GMT +- request: + method: post + uri: https://api.churnbuster.io/v1/cancellations + body: + encoding: UTF-8 + string: '{"subscription":{"source":"in_house","source_id":1},"customer":{"source":"in_house","source_id":1,"email":"email5@example.com","properties":{"first_name":"John","last_name":"Von + Doe"}}}' + headers: + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Server: + - Cowboy + Date: + - Thu, 01 Oct 2020 14:24:33 GMT + Connection: + - keep-alive + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Permitted-Cross-Domain-Policies: + - none + Referrer-Policy: + - strict-origin-when-cross-origin + Content-Type: + - application/json + Cache-Control: + - no-cache + X-Request-Id: + - aa57bbfb-aa49-4f4c-bad9-a3e9a87335bd + X-Runtime: + - '0.070606' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + Transfer-Encoding: + - chunked + Via: + - 1.1 vegur + body: + encoding: UTF-8 + string: '' + recorded_at: Thu, 01 Oct 2020 14:24:33 GMT +- request: + method: post + uri: https://api.churnbuster.io/v1/payment_methods + body: + encoding: UTF-8 + string: '{"payment_method":{"source":"in_house","source_id":"1-1","type":"card","properties":{}},"customer":{"source":"in_house","source_id":1,"email":"email6@example.com","properties":{"first_name":"John","last_name":"Von + Doe"}}}' + headers: + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Server: + - Cowboy + Date: + - Mon, 05 Oct 2020 10:55:30 GMT + Connection: + - keep-alive + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Permitted-Cross-Domain-Policies: + - none + Referrer-Policy: + - strict-origin-when-cross-origin + Content-Type: + - application/json + Cache-Control: + - no-cache + X-Request-Id: + - 585f1c6a-fb36-4cb4-9127-2f80636d33d9 + X-Runtime: + - '1.184629' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + Transfer-Encoding: + - chunked + Via: + - 1.1 vegur + body: + encoding: UTF-8 + string: '' + recorded_at: Mon, 05 Oct 2020 10:55:31 GMT +recorded_with: VCR 6.0.0 diff --git a/spec/lib/solidus_subscriptions/churn_buster/client_spec.rb b/spec/lib/solidus_subscriptions/churn_buster/client_spec.rb new file mode 100644 index 00000000..abb2b951 --- /dev/null +++ b/spec/lib/solidus_subscriptions/churn_buster/client_spec.rb @@ -0,0 +1,57 @@ +RSpec.describe SolidusSubscriptions::ChurnBuster::Client, vcr: { cassette_name: 'churn_buster', record: :new_episodes } do + describe '#report_failed_payment' do + it 'reports the failed payment to Churn Buster' do + client = described_class.new( + account_id: 'test_account_id', + api_key: 'test_api_key', + ) + + order = create(:order, subscription: create(:subscription)) + response = client.report_failed_payment(order) + + expect(response).to be_success + end + end + + describe '#report_successful_payment' do + it 'reports the successful payment to Churn Buster' do + client = described_class.new( + account_id: 'test_account_id', + api_key: 'test_api_key', + ) + + order = create(:order, subscription: create(:subscription)) + response = client.report_successful_payment(order) + + expect(response).to be_success + end + end + + describe '#report_subscription_cancellation' do + it 'reports the failed payment to Churn Buster' do + client = described_class.new( + account_id: 'test_account_id', + api_key: 'test_api_key', + ) + + subscription = create(:subscription) + response = client.report_subscription_cancellation(subscription) + + expect(response).to be_success + end + end + + describe '#report_payment_method_change' do + it 'reports the payment method change to Churn Buster' do + client = described_class.new( + account_id: 'test_account_id', + api_key: 'test_api_key', + ) + + subscription = create(:subscription) + response = client.report_payment_method_change(subscription) + + expect(response).to be_success + end + end +end diff --git a/spec/lib/solidus_subscriptions_spec.rb b/spec/lib/solidus_subscriptions_spec.rb new file mode 100644 index 00000000..201c5769 --- /dev/null +++ b/spec/lib/solidus_subscriptions_spec.rb @@ -0,0 +1,28 @@ +RSpec.describe SolidusSubscriptions do + describe '.churn_buster' do + context 'when Churn Buster was configured' do + it 'returns a Churn Buster client instance' do + allow(described_class.configuration).to receive_messages( + churn_buster?: true, + churn_buster_account_id: 'account_id', + churn_buster_api_key: 'api_key', + ) + churn_buster = instance_double(SolidusSubscriptions::ChurnBuster::Client) + allow(SolidusSubscriptions::ChurnBuster::Client).to receive(:new).with( + account_id: 'account_id', + api_key: 'api_key', + ).and_return(churn_buster) + + expect(described_class.churn_buster).to eq(churn_buster) + end + end + + context 'when Churn Buster was not configured' do + it 'returns nil' do + allow(described_class.configuration).to receive_messages(churn_buster?: false) + + expect(described_class.churn_buster).to be_nil + end + end + end +end From 4e17c67811cc1238964a4f1ad00ba53f80e75c1a Mon Sep 17 00:00:00 2001 From: Alessandro Desantis Date: Fri, 9 Oct 2020 14:08:50 +0200 Subject: [PATCH 5/6] Report subscription lifecycle events to Churn Buster --- README.md | 18 +++++ .../churn_buster_subscriber.rb | 39 ++++++++++ config/initializers/subscribers.rb | 1 + .../churn_buster_subscriber_spec.rb | 74 +++++++++++++++++++ 4 files changed, 132 insertions(+) create mode 100644 app/subscribers/solidus_subscriptions/churn_buster_subscriber.rb create mode 100644 spec/subscribers/solidus_subscriptions/churn_buster_subscriber_spec.rb diff --git a/README.md b/README.md index 81b4a29d..5dbacb6c 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,24 @@ We suggest using the [Whenever](https://github.com/javan/whenever) gem to schedu You can find the API documentation [here](https://stoplight.io/p/docs/gh/solidusio-contrib/solidus_subscriptions?group=master). +### Churn Buster integration + +This extension optionally integrates with [Churn Buster](https://churnbuster.io) for failed payment +recovery. In order to enable the integration, simply add your Churn Buster credentials to your +configuration: + +```ruby +SolidusSubscriptions.configure do |config| + # ... + + config.churn_buster_account_id = 'YOUR_CHURN_BUSTER_ACCOUNT_ID' + config.churn_buster_api_key = 'YOUR_CHURN_BUSTER_API_KEY' +end +``` + +The extension will take care of reporting successful/failed payments and payment method changes +to Churn Buster. + ## Development ### Testing the extension diff --git a/app/subscribers/solidus_subscriptions/churn_buster_subscriber.rb b/app/subscribers/solidus_subscriptions/churn_buster_subscriber.rb new file mode 100644 index 00000000..aa9dfa48 --- /dev/null +++ b/app/subscribers/solidus_subscriptions/churn_buster_subscriber.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module SolidusSubscriptions + module ChurnBusterSubscriber + include ::Spree::Event::Subscriber + + event_action :report_subscription_cancellation, event_name: 'solidus_subscriptions.subscription_canceled' + event_action :report_subscription_ending, event_name: 'solidus_subscriptions.subscription_ended' + event_action :report_payment_success, event_name: 'solidus_subscriptions.installments_succeeded' + event_action :report_payment_failure, event_name: 'solidus_subscriptions.installments_failed_payment' + event_action :report_payment_method_change, event_name: 'solidus_subscriptions.subscription_payment_method_changed' + + def report_subscription_cancellation(event) + churn_buster&.report_subscription_cancellation(event.payload.fetch(:subscription)) + end + + def report_subscription_ending(event) + churn_buster&.report_subscription_cancellation(event.payload.fetch(:subscription)) + end + + def report_payment_success(event) + churn_buster&.report_successful_payment(event.payload.fetch(:order)) + end + + def report_payment_failure(event) + churn_buster&.report_failed_payment(event.payload.fetch(:order)) + end + + def report_payment_method_change(event) + churn_buster&.report_payment_method_change(event.payload.fetch(:subscription)) + end + + private + + def churn_buster + SolidusSubscriptions.churn_buster + end + end +end diff --git a/config/initializers/subscribers.rb b/config/initializers/subscribers.rb index fe4c88be..ea016b87 100644 --- a/config/initializers/subscribers.rb +++ b/config/initializers/subscribers.rb @@ -2,4 +2,5 @@ Spree.config do |config| config.events.subscribers << 'SolidusSubscriptions::EventStorageSubscriber' + config.events.subscribers << 'SolidusSubscriptions::ChurnBusterSubscriber' end diff --git a/spec/subscribers/solidus_subscriptions/churn_buster_subscriber_spec.rb b/spec/subscribers/solidus_subscriptions/churn_buster_subscriber_spec.rb new file mode 100644 index 00000000..572913bd --- /dev/null +++ b/spec/subscribers/solidus_subscriptions/churn_buster_subscriber_spec.rb @@ -0,0 +1,74 @@ +RSpec.describe SolidusSubscriptions::ChurnBusterSubscriber do + describe '#report_subscription_cancellation' do + it 'reports the cancellation to Churn Buster' do + churn_buster = instance_spy(SolidusSubscriptions::ChurnBuster::Client) + allow(SolidusSubscriptions).to receive(:churn_buster).and_return(churn_buster) + + subscription = create(:subscription) + Spree::Event.fire('solidus_subscriptions.subscription_canceled', subscription: subscription) + + expect(churn_buster).to have_received(:report_subscription_cancellation).with(subscription) + end + end + + describe '#report_subscription_ending' do + it 'reports the cancellation to Churn Buster' do + churn_buster = instance_spy(SolidusSubscriptions::ChurnBuster::Client) + allow(SolidusSubscriptions).to receive(:churn_buster).and_return(churn_buster) + + subscription = create(:subscription) + Spree::Event.fire('solidus_subscriptions.subscription_ended', subscription: subscription) + + expect(churn_buster).to have_received(:report_subscription_cancellation).with(subscription) + end + end + + describe '#report_payment_success' do + it 'reports the success to Churn Buster' do + churn_buster = instance_spy(SolidusSubscriptions::ChurnBuster::Client) + allow(SolidusSubscriptions).to receive(:churn_buster).and_return(churn_buster) + + order = build_stubbed(:order) + installments = build_list(:installment, 2) + Spree::Event.fire( + 'solidus_subscriptions.installments_succeeded', + installments: installments, + order: order, + ) + + expect(churn_buster).to have_received(:report_successful_payment).with(order) + end + end + + describe '#report_payment_failure' do + it 'reports the failure to Churn Buster' do + churn_buster = instance_spy(SolidusSubscriptions::ChurnBuster::Client) + allow(SolidusSubscriptions).to receive(:churn_buster).and_return(churn_buster) + + order = build_stubbed(:order) + installments = build_list(:installment, 2) + Spree::Event.fire( + 'solidus_subscriptions.installments_failed_payment', + installments: installments, + order: order, + ) + + expect(churn_buster).to have_received(:report_failed_payment).with(order) + end + end + + describe '#report_payment_method_change' do + it 'reports the payment method change to Churn Buster' do + churn_buster = instance_spy(SolidusSubscriptions::ChurnBuster::Client) + allow(SolidusSubscriptions).to receive(:churn_buster).and_return(churn_buster) + + subscription = create(:subscription) + Spree::Event.fire( + 'solidus_subscriptions.subscription_payment_method_changed', + subscription: subscription, + ) + + expect(churn_buster).to have_received(:report_payment_method_change).with(subscription) + end + end +end From 5b1795151eb52ec8c5097f3ad58db055d9acb1bb Mon Sep 17 00:00:00 2001 From: Alessandro Desantis Date: Fri, 9 Oct 2020 14:28:34 +0200 Subject: [PATCH 6/6] Rely on automatic event listener subscription --- config/initializers/subscribers.rb | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/config/initializers/subscribers.rb b/config/initializers/subscribers.rb index ea016b87..486fe72b 100644 --- a/config/initializers/subscribers.rb +++ b/config/initializers/subscribers.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true -Spree.config do |config| - config.events.subscribers << 'SolidusSubscriptions::EventStorageSubscriber' - config.events.subscribers << 'SolidusSubscriptions::ChurnBusterSubscriber' +if Spree.solidus_gem_version < Gem::Version.new('2.11.0') + require SolidusSubscriptions::Engine.root.join('app/subscribers/solidus_subscriptions/event_storage_subscriber') + require SolidusSubscriptions::Engine.root.join('app/subscribers/solidus_subscriptions/churn_buster_subscriber') + + SolidusSubscriptions::ChurnBusterSubscriber.subscribe! + SolidusSubscriptions::EventStorageSubscriber.subscribe! end