Merge branch 'low-balance-target'

Stephen Paul Weber created

* low-balance-target:
  Fix linter, integrate patch feedback
  Add low balance/auto top up with target amount

Change summary

lib/customer.rb          | 11 -----
lib/low_balance.rb       | 49 +++++++++++++++++++--------
test/test_low_balance.rb | 74 ++++++++++++++++++++++++++++++++++++++++-
3 files changed, 107 insertions(+), 27 deletions(-)

Detailed changes

lib/customer.rb 🔗

@@ -24,7 +24,7 @@ class Customer
 	def_delegators :@plan, :active?, :activate_plan_starting_now, :bill_plan,
 	               :currency, :merchant_account, :plan_name, :minute_limit,
 	               :message_limit, :monthly_overage_limit, :activation_date,
-	               :expires_at, :monthly_price, :save_plan!
+	               :expires_at, :monthly_price, :save_plan!, :auto_top_up_amount
 	def_delegators :@sgx, :deregister!, :register!, :registered?, :set_ogm_url,
 	               :fwd, :transcription_enabled
 	def_delegators :@usage, :usage_report, :message_usage, :incr_message_usage,
@@ -85,15 +85,6 @@ class Customer
 		EMPromise.resolve(self)
 	end
 
-	def auto_top_up_amount
-		if @plan.auto_top_up_amount.positive? &&
-		   balance < -@plan.auto_top_up_amount + 5
-			-balance + @plan.auto_top_up_amount
-		else
-			@plan.auto_top_up_amount
-		end
-	end
-
 	def unused_invites
 		InvitesRepo.new(DB).unused_invites(customer_id)
 	end

lib/low_balance.rb 🔗

@@ -4,30 +4,33 @@ require_relative "expiring_lock"
 require_relative "transaction"
 
 class LowBalance
-	def self.for(customer)
+	def self.for(customer, transaction_amount=0)
 		return Locked.new unless customer.registered?
 
 		ExpiringLock.new(
 			"jmp_customer_low_balance-#{customer.billing_customer_id}",
 			expiry: 60 * 60 * 24 * 7
 		).with(-> { Locked.new }) do
-			customer.billing_customer.then(&method(:for_no_lock))
+			customer.billing_customer.then do |billing_customer|
+				for_no_lock(billing_customer, transaction_amount)
+			end
 		end
 	end
 
-	def self.for_no_lock(customer, auto: true)
+	def self.for_no_lock(customer, transaction_amount, auto: true)
 		if auto && customer.auto_top_up_amount.positive?
-			AutoTopUp.for(customer)
+			AutoTopUp.for(customer, transaction_amount)
 		else
 			customer.btc_addresses.then do |btc_addresses|
-				new(customer, btc_addresses)
+				new(customer, btc_addresses, transaction_amount)
 			end
 		end
 	end
 
-	def initialize(customer, btc_addresses)
+	def initialize(customer, btc_addresses, transaction_amount=0)
 		@customer = customer
 		@btc_addresses = btc_addresses
+		@transaction_amount = transaction_amount
 	end
 
 	def notify!
@@ -35,11 +38,21 @@ class LowBalance
 		m.from = CONFIG[:notify_from]
 		m.body =
 			"Your balance of $#{'%.4f' % @customer.balance} is low." \
+			"#{pending_cost_for_notification}" \
 			"#{btc_addresses_for_notification}"
 		@customer.stanza_to(m)
 		EMPromise.resolve(0)
 	end
 
+	def pending_cost_for_notification
+		return unless @transaction_amount&.positive?
+		return unless @transaction_amount > @customer.balance
+
+		"You need an additional " \
+		"$#{'%.2f' % (@transaction_amount - @customer.balance)} "\
+		"to complete this transaction."
+	end
+
 	def btc_addresses_for_notification
 		return if @btc_addresses.empty?
 
@@ -48,13 +61,13 @@ class LowBalance
 	end
 
 	class AutoTopUp
-		def self.for(customer)
+		def self.for(customer, target=0)
 			customer.payment_methods.then(&:default_payment_method).then do |method|
 				blocked?(method).then do |block|
-					next AutoTopUp.new(customer, method) if block.zero?
+					next AutoTopUp.new(customer, method, target) if block.zero?
 
 					log.info("#{customer.customer_id} auto top up blocked")
-					LowBalance.for_no_lock(customer, auto: false)
+					LowBalance.for_no_lock(customer, target, auto: false)
 				end
 			end
 		end
@@ -67,18 +80,24 @@ class LowBalance
 			)
 		end
 
-		def initialize(customer, method=nil)
+		def initialize(customer, method=nil, target=0, margin: 10)
 			@customer = customer
 			@method = method
+			@target = target
+			@margin = margin
 			@message = Blather::Stanza::Message.new
 			@message.from = CONFIG[:notify_from]
 		end
 
+		def top_up_amount
+			[
+				(@target + @margin) - @customer.balance,
+				@customer.auto_top_up_amount
+			].max
+		end
+
 		def sale
-			Transaction.sale(
-				@customer,
-				amount: @customer.auto_top_up_amount
-			).then do |tx|
+			Transaction.sale(@customer, amount: top_up_amount).then do |tx|
 				tx.insert.then { tx }
 			end
 		end
@@ -91,7 +110,7 @@ class LowBalance
 			)
 			@message.body =
 				"Automatic top-up transaction for " \
-				"$#{@customer.auto_top_up_amount} failed: #{e.message}"
+				"$#{top_up_amount} failed: #{e.message}"
 			0
 		end
 

test/test_low_balance.rb 🔗

@@ -38,6 +38,43 @@ class LowBalanceTest < Minitest::Test
 	end
 	em :test_for_no_auto_top_up
 
+	def test_for_auto_top_up_on_transaction_amount
+		ExpiringLock::REDIS.expect(
+			:set,
+			EMPromise.resolve("OK"),
+			["jmp_customer_low_balance-test", Time, "EX", 604800, "NX"]
+		)
+		CustomerFinancials::REDIS.expect(
+			:smembers,
+			EMPromise.resolve([]),
+			["block_credit_cards"]
+		)
+		LowBalance::AutoTopUp::REDIS.expect(
+			:exists,
+			0,
+			["jmp_auto_top_up_block-abcd"]
+		)
+		braintree_customer = Minitest::Mock.new
+		CustomerFinancials::BRAINTREE.expect(:customer, braintree_customer)
+		payment_methods = OpenStruct.new(payment_methods: [
+			OpenStruct.new(default?: true, unique_number_identifier: "abcd")
+		])
+		braintree_customer.expect(
+			:find,
+			EMPromise.resolve(payment_methods),
+			["test"]
+		)
+		assert_kind_of(
+			LowBalance::AutoTopUp,
+			LowBalance.for(customer(auto_top_up_amount: 1), 15).sync
+		)
+		assert_mock ExpiringLock::REDIS
+		assert_mock CustomerFinancials::REDIS
+		assert_mock CustomerFinancials::BRAINTREE
+		assert_mock braintree_customer
+	end
+	em :test_for_auto_top_up_on_transaction_amount
+
 	def test_for_auto_top_up
 		ExpiringLock::REDIS.expect(
 			:set,
@@ -138,6 +175,39 @@ class LowBalanceTest < Minitest::Test
 		end
 		em :test_notify!
 
+		def test_top_up_amount_when_target_greater_than_expected_balance
+			customer = Minitest::Mock.new(customer(
+				balance: 10,
+				auto_top_up_amount: 15
+			))
+			auto_top_up = LowBalance::AutoTopUp.new(customer, nil, 30, margin: 5)
+
+			assert_equal 25, auto_top_up.top_up_amount
+		end
+		em :test_top_up_amount_when_target_greater_than_expected_balance
+
+		def test_top_up_amount_when_target_less_than_expected_balance
+			customer = Minitest::Mock.new(customer(
+				balance: 10,
+				auto_top_up_amount: 15
+			))
+			auto_top_up = LowBalance::AutoTopUp.new(customer, nil, 12, margin: 5)
+
+			assert_equal 15, auto_top_up.top_up_amount
+		end
+		em :test_top_up_amount_when_target_less_than_expected_balance
+
+		def test_negative_balance_target_less_than_expected_balance
+			customer = Minitest::Mock.new(customer(
+				balance: -11,
+				auto_top_up_amount: 15
+			))
+			auto_top_up = LowBalance::AutoTopUp.new(customer, nil, 35, margin: 5)
+
+			assert_equal 51, auto_top_up.top_up_amount
+		end
+		em :test_negative_balance_target_less_than_expected_balance
+
 		def test_very_low_balance_notify!
 			customer = Minitest::Mock.new(customer(
 				balance: -100,
@@ -150,7 +220,7 @@ class LowBalanceTest < Minitest::Test
 			LowBalance::AutoTopUp::Transaction.expect(
 				:sale,
 				tx,
-				[customer], amount: 115
+				[customer], amount: 110
 			)
 			auto_top_up.notify!
 			assert_mock tx
@@ -169,7 +239,7 @@ class LowBalanceTest < Minitest::Test
 			LowBalance::AutoTopUp::Transaction.expect(
 				:sale,
 				tx,
-				[customer], amount: 26
+				[customer], amount: 21
 			)
 			auto_top_up.notify!
 			assert_mock tx