Add low balance/auto top up with target amount

Osakpolor Obaseki created

Change summary

lib/customer.rb          |  6 ++-
lib/low_balance.rb       | 52 +++++++++++++++++++++++-------
test/test_low_balance.rb | 71 ++++++++++++++++++++++++++++++++++++++++++
3 files changed, 114 insertions(+), 15 deletions(-)

Detailed changes

lib/customer.rb 🔗

@@ -33,6 +33,8 @@ class Customer
 	               :add_btc_address, :declines, :mark_decline,
 	               :transactions
 
+	TOP_UP_ADJUSTEMENT_THRESHOLD = 5
+
 	def self.extract(customer_id, jid, **kwargs)
 		klass, *keys = if kwargs[:parent_customer_id]
 			[ChildCustomer, :parent_customer_id]
@@ -87,8 +89,8 @@ class Customer
 
 	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
+		   balance + @plan.auto_top_up_amount < TOP_UP_ADJUSTEMENT_THRESHOLD
+			-balance + @plan.auto_top_up_amount # amount needed to set balance to @plan.auto_top_up_amount
 		else
 			@plan.auto_top_up_amount
 		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 |customer|
+				self.for_no_lock(customer, transaction_amount)
+			end
 		end
 	end
 
-	def self.for_no_lock(customer, auto: true)
-		if auto && customer.auto_top_up_amount.positive?
-			AutoTopUp.for(customer)
+	def self.for_no_lock(customer, transaction_amount=0, auto: true)
+		if auto && (customer.auto_top_up_amount.positive? || transaction_amount.positive?)
+			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,20 @@ 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? && @transaction_amount > @customer.balance)
+
+		"\nYou tried to perform an activity that cost #{@transaction_amount}"\
+		"You need an additional #{'%.4f' % (@transaction_amount - @customer.balance)} "\
+		"to perform this activity."
+	end
+
 	def btc_addresses_for_notification
 		return if @btc_addresses.empty?
 
@@ -48,13 +60,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,17 +79,31 @@ 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()
+			expected_balance = @customer.balance + @customer.auto_top_up_amount # @cutomer.auto_top_up is the adjusted auto_to_up
+			if expected_balance < @target
+				deficit = @target - expected_balance
+				@customer.auto_top_up_amount + deficit + @margin
+			else
+				@customer.auto_top_up_amount
+			end
+		end
+
 		def sale
 			Transaction.sale(
 				@customer,
-				amount: @customer.auto_top_up_amount
+				amount: top_up_amount()
 			).then do |tx|
 				tx.insert.then { tx }
 			end
@@ -91,7 +117,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,44 @@ 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: 0), transaction_amount = 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 +176,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, 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, 5)
+
+			assert_equal 15, auto_top_up.top_up_amount()
+		end
+		em :test_top_up_amount_when_target_less_than_expected_balance
+
+		def test_top_up_amount_when_balance_is_negative_and_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, 5)
+
+			assert_equal 51, auto_top_up.top_up_amount()
+		end
+		em :test_top_up_amount_when_balance_is_negative_and_target_less_than_expected_balance
+
 		def test_very_low_balance_notify!
 			customer = Minitest::Mock.new(customer(
 				balance: -100,