Skip to content

Commit

Permalink
Merge pull request #152 from solidusio-contrib/aldesantis/churn-buster
Browse files Browse the repository at this point in the history
Integrate Churn Buster
  • Loading branch information
aldesantis authored Oct 10, 2020
2 parents ceb0b5a + 5b17951 commit 6b5c54f
Show file tree
Hide file tree
Showing 26 changed files with 777 additions and 5 deletions.
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
11 changes: 11 additions & 0 deletions app/models/solidus_subscriptions/subscription.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions app/services/solidus_subscriptions/success_dispatcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
39 changes: 39 additions & 0 deletions app/subscribers/solidus_subscriptions/churn_buster_subscriber.rb
Original file line number Diff line number Diff line change
@@ -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
8 changes: 6 additions & 2 deletions config/initializers/subscribers.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# frozen_string_literal: true

Spree.config do |config|
config.events.subscribers << 'SolidusSubscriptions::EventStorageSubscriber'
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 16 additions & 0 deletions lib/solidus_subscriptions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,20 @@
require 'solidus_support'

require 'deface'
require 'httparty'
require 'state_machines'

require 'solidus_subscriptions/configuration'
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
Expand All @@ -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
48 changes: 48 additions & 0 deletions lib/solidus_subscriptions/churn_buster/client.rb
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions lib/solidus_subscriptions/churn_buster/order_serializer.rb
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions lib/solidus_subscriptions/churn_buster/serializer.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions lib/solidus_subscriptions/churn_buster/subscription_serializer.rb
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 6b5c54f

Please sign in to comment.