summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAlessandro Desantis <desa.alessandro@gmail.com>2020-10-09 14:05:43 +0200
committerAlessandro Desantis <desa.alessandro@gmail.com>2020-10-09 14:09:01 +0200
commit5a5b2a3201462532f4fa9d38e02867a243c68670 (patch)
tree004eedfd40d8405f1bd3390d3fa983e0cfe02830
parentd7369ba04b3657e47e21e6d24af516886ef0dbc4 (diff)
Implement Churn Buster API client
-rw-r--r--lib/generators/solidus_subscriptions/install/templates/initializer.rb14
-rw-r--r--lib/solidus_subscriptions.rb16
-rw-r--r--lib/solidus_subscriptions/churn_buster/client.rb48
-rw-r--r--lib/solidus_subscriptions/churn_buster/order_serializer.rb19
-rw-r--r--lib/solidus_subscriptions/churn_buster/serializer.rb23
-rw-r--r--lib/solidus_subscriptions/churn_buster/subscription_customer_serializer.rb19
-rw-r--r--lib/solidus_subscriptions/churn_buster/subscription_payment_method_serializer.rb37
-rw-r--r--lib/solidus_subscriptions/churn_buster/subscription_serializer.rb17
-rw-r--r--lib/solidus_subscriptions/configuration.rb8
-rw-r--r--solidus_subscriptions.gemspec1
-rw-r--r--spec/fixtures/cassettes/churn_buster.yml229
-rw-r--r--spec/lib/solidus_subscriptions/churn_buster/client_spec.rb57
-rw-r--r--spec/lib/solidus_subscriptions_spec.rb28
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