Try auto top up / low balance notify when not enough balance for a call

Stephen Paul Weber created

There is an edge case where a customer might not have auto-topped up yet but
they don't have enough balance for this call, so try to charge their card first
before telling them the call is a no go.

Change summary

lib/call_attempt.rb      | 22 ++++++++-
lib/call_attempt_repo.rb |  1 
lib/customer.rb          | 14 ++++-
test/test_helper.rb      |  1 
test/test_web.rb         | 86 +++++++++++++++++++++++++++++++++++++++++
web.rb                   |  1 
6 files changed, 116 insertions(+), 9 deletions(-)

Detailed changes

lib/call_attempt.rb 🔗

@@ -2,6 +2,8 @@
 
 require "value_semantics/monkey_patched"
 
+require_relative "low_balance"
+
 class CallAttempt
 	EXPENSIVE_ROUTE = {
 		"usd_beta_unlimited-v20210223" => 0.9,
@@ -9,15 +11,14 @@ class CallAttempt
 	}.freeze
 
 	def self.for(customer, other_tel, rate, usage, direction:, **kwargs)
+		kwargs.merge!(direction: direction)
 		included_credit = [customer.minute_limit.to_d - usage, 0].max
 		if !rate || rate >= EXPENSIVE_ROUTE.fetch(customer.plan_name, 0.1)
 			Unsupported.new(direction: direction)
 		elsif included_credit + customer.balance < rate * 10
-			NoBalance.new(balance: customer.balance, direction: direction)
+			NoBalance.for(customer, other_tel, rate, usage, **kwargs)
 		else
-			for_ask_or_go(
-				customer, other_tel, rate, usage, direction: direction, **kwargs
-			)
+			for_ask_or_go(customer, other_tel, rate, usage, **kwargs)
 		end
 	end
 
@@ -58,6 +59,19 @@ class CallAttempt
 	end
 
 	class NoBalance
+		def self.for(customer, other_tel, rate, usage, direction:, **kwargs)
+			LowBalance.for(customer).then(&:notify!).then do |amount|
+				if amount&.positive?
+					CallAttempt.for(
+						customer.with_balance(customer.balance + amount),
+						other_tel, rate, usage, direction: direction, **kwargs
+					)
+				else
+					NoBalance.new(balance: customer.balance, direction: direction)
+				end
+			end
+		end
+
 		value_semantics do
 			balance Numeric
 			direction Either(:inbound, :outbound)

lib/call_attempt_repo.rb 🔗

@@ -1,6 +1,7 @@
 # frozen_string_literal: true
 
 require "value_semantics/monkey_patched"
+require "lazy_object"
 
 require_relative "call_attempt"
 

lib/customer.rb 🔗

@@ -45,13 +45,19 @@ class Customer
 		@sgx = sgx
 	end
 
+	def with_balance(balance)
+		self.class.new(
+			@customer_id, @jid,
+			plan: @plan, balance: balance,
+			tndetails: @tndetails, sgx: @sgx
+		)
+	end
+
 	def with_plan(plan_name)
 		self.class.new(
-			@customer_id,
-			@jid,
+			@customer_id, @jid,
 			plan: @plan.with_plan_name(plan_name),
-			balance: @balance,
-			sgx: @sgx
+			balance: @balance, tndetails: @tndetails, sgx: @sgx
 		)
 	end
 

test/test_helper.rb 🔗

@@ -66,6 +66,7 @@ CONFIG = {
 		username: "test_bw_user",
 		password: "test_bw_password"
 	},
+	notify_from: "notify_from@example.org",
 	activation_amount: 1,
 	plans: [
 		{

test/test_web.rb 🔗

@@ -2,11 +2,15 @@
 
 require "rack/test"
 require "test_helper"
+require "bwmsgsv2_repo"
+require "customer_repo"
 require_relative "../web"
 
+ExpiringLock::REDIS = Minitest::Mock.new
 Customer::BLATHER = Minitest::Mock.new
 CustomerFwd::BANDWIDTH_VOICE = Minitest::Mock.new
 Web::BANDWIDTH_VOICE = Minitest::Mock.new
+LowBalance::AutoTopUp::Transaction = Minitest::Mock.new
 
 class WebTest < Minitest::Test
 	include Rack::Test::Methods
@@ -18,6 +22,10 @@ class WebTest < Minitest::Test
 				"catapult_jid-+15551234567" => "customer_customerid@component",
 				"jmp_customer_jid-customerid_low" => "customer@example.com",
 				"catapult_jid-+15551234560" => "customer_customerid_low@component",
+				"jmp_customer_jid-customerid_topup" => "customer@example.com",
+				"jmp_customer_auto_top_up_amount-customerid_topup" => "15",
+				"jmp_customer_monthly_overage_limit-customerid_topup" => "99999",
+				"catapult_jid-+15551234562" => "customer_customerid_topup@component",
 				"jmp_customer_jid-customerid_limit" => "customer@example.com",
 				"catapult_jid-+15551234561" => "customer_customerid_limit@component"
 			),
@@ -32,6 +40,11 @@ class WebTest < Minitest::Test
 					"plan_name" => "test_usd",
 					"expires_at" => Time.now + 100
 				}],
+				["customerid_topup"] => [{
+					"balance" => BigDecimal("0.01"),
+					"plan_name" => "test_usd",
+					"expires_at" => Time.now + 100
+				}],
 				["customerid_limit"] => [{
 					"balance" => BigDecimal(10),
 					"plan_name" => "test_usd",
@@ -52,6 +65,12 @@ class WebTest < Minitest::Test
 						"customer_customerid@component" => IBR.new.tap do |ibr|
 							ibr.phone = "+15551234567"
 						end,
+						"customer_customerid_low@component" => IBR.new.tap do |ibr|
+							ibr.phone = "+15551234567"
+						end,
+						"customer_customerid_topup@component" => IBR.new.tap do |ibr|
+							ibr.phone = "+15551234567"
+						end,
 						"customer_customerid_limit@component" => IBR.new.tap do |ibr|
 							ibr.phone = "+15551234567"
 						end
@@ -64,7 +83,8 @@ class WebTest < Minitest::Test
 				["test_usd", "+15557654321", :outbound] => [{ "rate" => 0.01 }],
 				["test_usd", "+15557654321", :inbound] => [{ "rate" => 0.01 }],
 				["customerid_limit"] => [{ "a" => -1000 }],
-				["customerid_low"] => [{ "a" => -1000 }]
+				["customerid_low"] => [{ "a" => -1000 }],
+				["customerid_topup"] => [{ "a" => -1000 }]
 			)
 		)
 		Web.opts[:common_logger] = FakeLog.new
@@ -94,6 +114,12 @@ class WebTest < Minitest::Test
 	em :test_outbound_forwards
 
 	def test_outbound_low_balance
+		ExpiringLock::REDIS.expect(
+			:exists,
+			EMPromise.resolve(1),
+			["jmp_customer_low_balance-customerid_low"]
+		)
+
 		post(
 			"/outbound/calls",
 			{
@@ -111,9 +137,60 @@ class WebTest < Minitest::Test
 			"complete this call.</SpeakSentence></Response>",
 			last_response.body
 		)
+		assert_mock ExpiringLock::REDIS
 	end
 	em :test_outbound_low_balance
 
+	def test_outbound_low_balance_top_up
+		LowBalance::AutoTopUp::Transaction.expect(
+			:sale,
+			EMPromise.resolve(
+				OpenStruct.new(insert: EMPromise.resolve(nil), total: 15)
+			),
+			[Customer, { amount: 15 }]
+		)
+
+		ExpiringLock::REDIS.expect(
+			:exists,
+			nil,
+			["jmp_customer_low_balance-customerid_topup"]
+		)
+
+		ExpiringLock::REDIS.expect(
+			:setex,
+			nil,
+			["jmp_customer_low_balance-customerid_topup", Integer, Time]
+		)
+
+		Customer::BLATHER.expect(
+			:<<,
+			nil,
+			[Blather::Stanza]
+		)
+
+		post(
+			"/outbound/calls",
+			{
+				from: "customerid_topup",
+				to: "+15557654321",
+				callId: "acall"
+			}.to_json,
+			{ "CONTENT_TYPE" => "application/json" }
+		)
+
+		assert last_response.ok?
+		assert_equal(
+			"<?xml version=\"1.0\" encoding=\"utf-8\" ?><Response>" \
+			"<Forward from=\"+15551234567\" to=\"+15557654321\" />" \
+			"</Response>",
+			last_response.body
+		)
+		assert_mock ExpiringLock::REDIS
+		assert_mock Customer::BLATHER
+		assert_mock LowBalance::AutoTopUp::Transaction
+	end
+	em :test_outbound_low_balance_top_up
+
 	def test_outbound_unsupported
 		post(
 			"/outbound/calls",
@@ -213,6 +290,12 @@ class WebTest < Minitest::Test
 	em :test_inbound
 
 	def test_inbound_low
+		ExpiringLock::REDIS.expect(
+			:exists,
+			EMPromise.resolve(1),
+			["jmp_customer_low_balance-customerid_low"]
+		)
+
 		post(
 			"/inbound/calls",
 			{
@@ -231,6 +314,7 @@ class WebTest < Minitest::Test
 			last_response.body
 		)
 		assert_mock CustomerFwd::BANDWIDTH_VOICE
+		assert_mock ExpiringLock::REDIS
 	end
 	em :test_inbound_low
 

web.rb 🔗

@@ -10,6 +10,7 @@ require "sentry-ruby"
 
 require_relative "lib/call_attempt_repo"
 require_relative "lib/cdr"
+require_relative "lib/oob"
 require_relative "lib/roda_capture"
 require_relative "lib/roda_em_promise"
 require_relative "lib/rack_fiber"