diff options
13 files changed, 515 insertions, 1 deletions
diff --git a/lib/generators/solidus_subscriptions/install/templates/initializer.rb b/lib/generators/solidus_subscriptions/install/templates/initializer.rb index 17a6f51..e1e9731 100644 --- a/lib/generators/solidus_subscriptions/install/templates/initializer.rb +++ b/lib/generators/solidus_subscriptions/install/templates/initializer.rb @@ -64,4 +64,18 @@ SolidusSubscriptions.configure do |config| # :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 039b172..e9dae93 100644 --- a/lib/solidus_subscriptions.rb +++ b/lib/solidus_subscriptions.rb @@ -4,6 +4,7 @@ require 'solidus_core' require 'solidus_support' require 'deface' +require 'httparty' require 'state_machines' require 'solidus_subscriptions/configuration' @@ -11,6 +12,12 @@ require 'solidus_subscriptions/permission_sets/default_customer' 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 @@ module SolidusSubscriptions 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 0000000..915bb40 --- /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 0000000..5bc5dee --- /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 0000000..d8b83bb --- /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 0000000..700c1c6 --- /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 0000000..984059d --- /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 0000000..06c08d1 --- /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 d5dc80f..22b7d84 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 @@ module SolidusSubscriptions @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 c909f92..46fec6e 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 0000000..0d3803a --- /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 0000000..abb2b95 --- /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 0000000..201c576 --- /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 |