Initial churnbuster integration

Stephen Paul Weber created

Change summary

config-schema.dhall           |  1 
config.dhall.sample           |  6 ++
lib/churnbuster.rb            | 63 +++++++++++++++++++++++++++++++++++++
lib/credit_card_sale.rb       | 11 +++++
lib/low_balance.rb            | 14 +++++++
test/test_credit_card_sale.rb |  2 
test/test_low_balance.rb      | 31 +++++++++++++++++-
7 files changed, 122 insertions(+), 6 deletions(-)

Detailed changes

config-schema.dhall 🔗

@@ -12,6 +12,7 @@
     , private_key : Text
     , public_key : Text
     }
+, churnbuster : { account_id : Text, api_key : Text }
 , component : { jid : Text, secret : Text }
 , credit_card_url : forall (jid : Text) -> forall (customer_id : Text) -> Text
 , creds : { account : Text, password : Text, username : Text }

config.dhall.sample 🔗

@@ -114,5 +114,9 @@ in
 	simpleswap_api_key = "",
 	reachability_senders = [ "+14445556666" ],
 	support_link = \(customer_jid: Text) ->
-		"http://localhost:3002/app/accounts/2/contacts/custom_attributes/jid/${customer_jid}"
+		"http://localhost:3002/app/accounts/2/contacts/custom_attributes/jid/${customer_jid}",
+	churnbuster = {
+		api_key = "",
+		account_id = ""
+	}
 }

lib/churnbuster.rb 🔗

@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require "bigdecimal"
+require "em-http"
+require "em_promise"
+require "em-synchrony/em-http" # For apost vs post
+require "json"
+require "securerandom"
+
+class Churnbuster
+	def initialize(
+		account_id: CONFIG.dig(:churnbuster, :account_id),
+		api_key: CONFIG.dig(:churnbuster, :api_key)
+	)
+		@account_id = account_id
+		@api_key = api_key
+	end
+
+	def failed_payment(customer, amount, txid)
+		post_json(
+			"https://api.churnbuster.io/v1/failed_payments",
+			{
+				customer: format_customer(customer),
+				payment: format_tx(customer, amount, txid)
+			}
+		)
+	end
+
+protected
+
+	def format_tx(customer, amount, txid)
+		{
+			source: "braintree",
+			source_id: txid,
+			amount_in_cents: (amount * 100).to_i,
+			currency: customer.currency
+		}
+	end
+
+	def format_customer(customer)
+		unprox = ProxiedJID.new(customer.jid).unproxied.to_s
+		email = "#{unprox.gsub(/@/, '=40').gsub(/\./, '=2e')}@smtp.cheogram.com"
+		{
+			source: "braintree",
+			source_id: customer.customer_id,
+			email: email,
+			properties: {}
+		}
+	end
+
+	def post_json(url, data)
+		EM::HttpRequest.new(
+			url,
+			tls: { verify_peer: true }
+		).apost(
+			head: {
+				"Authorization" => [@account_id, @api_key],
+				"Content-Type" => "application/json"
+			},
+			body: data.to_json
+		)
+	end
+end

lib/credit_card_sale.rb 🔗

@@ -91,6 +91,15 @@ protected
 		end
 
 		@customer.mark_decline
-		raise response.message
+		raise BraintreeFailure, response
+	end
+end
+
+class BraintreeFailure < StandardError
+	attr_reader :response
+
+	def initialize(response)
+		super response.message
+		@response = response
 	end
 end

lib/low_balance.rb 🔗

@@ -1,8 +1,9 @@
 # frozen_string_literal: true
 
+require_relative "churnbuster"
+require_relative "credit_card_sale"
 require_relative "expiring_lock"
 require_relative "transaction"
-require_relative "credit_card_sale"
 
 class LowBalance
 	def self.for(customer, transaction_amount=0)
@@ -122,7 +123,18 @@ class LowBalance
 			CreditCardSale.create(@customer, amount: top_up_amount)
 		end
 
+		def churnbuster(e)
+			return unless e.is_a?(BraintreeFailure)
+
+			Churnbuster.new.failed_payment(
+				@customer,
+				top_up_amount,
+				e.response.transaction.id
+			)
+		end
+
 		def failed(e)
+			churnbuster(e)
 			@method && REDIS.setex(
 				"jmp_auto_top_up_block-#{@method.unique_number_identifier}",
 				60 * 60 * 24 * 30,

test/test_credit_card_sale.rb 🔗

@@ -62,7 +62,7 @@ class CreditCardSaleTest < Minitest::Test
 			options: { submit_for_settlement: true },
 			payment_method_token: "token"
 		)
-		assert_raises(RuntimeError) do
+		assert_raises(BraintreeFailure) do
 			CreditCardSale.new(
 				customer(plan_name: "test_usd"),
 				amount: 99,

test/test_low_balance.rb 🔗

@@ -159,7 +159,9 @@ class LowBalanceTest < Minitest::Test
 		LowBalance::AutoTopUp::CreditCardSale = Minitest::Mock.new
 
 		def setup
-			@customer = Minitest::Mock.new(customer(auto_top_up_amount: 100))
+			@customer = Minitest::Mock.new(
+				customer(auto_top_up_amount: 100, plan_name: "test_usd")
+			)
 			@auto_top_up = LowBalance::AutoTopUp.new(@customer)
 		end
 
@@ -242,6 +244,28 @@ class LowBalanceTest < Minitest::Test
 		em :test_border_low_balance_notify!
 
 		def test_decline_notify!
+			stub_request(:post, "https://api.churnbuster.io/v1/failed_payments")
+				.with(
+					body: {
+						customer: {
+							source: "braintree",
+							source_id: "test",
+							email: "test@smtp.cheogram.com",
+							properties: {}
+						},
+						payment: {
+							source: "braintree",
+							source_id: "tx",
+							amount_in_cents: 10000,
+							currency: "USD"
+						}
+					}.to_json,
+					headers: {
+						"Authorization" => ["", ""],
+						"Content-Type" => "application/json"
+					}
+				).to_return(status: 200, body: "", headers: {})
+
 			@customer.expect(
 				:stanza_to,
 				nil,
@@ -254,7 +278,10 @@ class LowBalanceTest < Minitest::Test
 			)
 			LowBalance::AutoTopUp::CreditCardSale.expect(
 				:create,
-				EMPromise.reject(RuntimeError.new("test")),
+				EMPromise.reject(BraintreeFailure.new(OpenStruct.new(
+					message: "test",
+					transaction: OpenStruct.new(id: "tx")
+				))),
 				[@customer], amount: 100.to_d
 			)
 			@auto_top_up.notify!.sync