Outbound call logic for overages

Stephen Paul Weber created

If cannot find an acceptable rate for the number, cannot call.
If balance is too low, cannot call.
If too close to limit, warn.
Else, call.

Change summary

lib/call_attempt.rb             |  61 ++++++++++++++++++++
lib/call_attempt_repo.rb        |  45 +++++++++++++++
lib/plan.rb                     |  21 ++++++
test/test_helper.rb             |   2 
test/test_plan.rb               |   2 
test/test_web.rb                | 103 ++++++++++++++++++++++++++++++++++
views/outbound/at_limit.slim    |   5 +
views/outbound/no_balance.slim  |   3 +
views/outbound/unsupported.slim |   3 +
web.rb                          |  22 +++----
10 files changed, 250 insertions(+), 17 deletions(-)

Detailed changes

lib/call_attempt.rb 🔗

@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require "value_semantics/monkey_patched"
+
+class CallAttempt
+	EXPENSIVE_ROUTE = {
+		"usd_beta_unlimited-v20210223" => 0.9,
+		"cad_beta_unlimited-v20210223" => 1.1
+	}.freeze
+
+	def self.for(customer, other_tel, rate, usage, digits)
+		included_credit = [customer.minute_limit.to_d - usage, 0].max
+		if !rate || rate >= EXPENSIVE_ROUTE.fetch(customer.plan_name, 0.1)
+			Unsupported.new
+		elsif included_credit + customer.balance < rate * 10
+			NoBalance.new(balance: customer.balance)
+		else
+			for_ask_or_go(customer, other_tel, rate, usage, digits)
+		end
+	end
+
+	def self.for_ask_or_go(customer, other_tel, rate, usage, digits)
+		can_use = customer.minute_limit.to_d + customer.monthly_overage_limit
+		if digits != "1" && can_use - usage < rate * 10
+			AtLimit.new
+		else
+			new(from: customer.registered?.phone, to: other_tel)
+		end
+	end
+
+	value_semantics do
+		from(/\A\+\d+\Z/)
+		to(/\A\+\d+\Z/)
+	end
+
+	def to_render
+		[:forward, { locals: to_h }]
+	end
+
+	class Unsupported
+		def to_render
+			["outbound/unsupported"]
+		end
+	end
+
+	class NoBalance
+		value_semantics do
+			balance Numeric
+		end
+
+		def to_render
+			["outbound/no_balance", { locals: to_h }]
+		end
+	end
+
+	class AtLimit
+		def to_render
+			["outbound/at_limit"]
+		end
+	end
+end

lib/call_attempt_repo.rb 🔗

@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require "value_semantics/monkey_patched"
+
+require_relative "call_attempt"
+
+class CallAttemptRepo
+	value_semantics do
+		db Anything(), default: LazyObject.new { DB }
+	end
+
+	def find(customer, other_tel, digits=nil, direction=:outbound)
+		EMPromise.all([
+			find_rate(customer.plan_name, other_tel, direction),
+			find_usage(customer.customer_id)
+		]).then do |(rate, usage)|
+			CallAttempt.for(customer, other_tel, rate, usage, digits)
+		end
+	end
+
+protected
+
+	def find_usage(customer_id)
+		promise = db.query_defer(<<~SQL, [customer_id])
+			SELECT COALESCE(SUM(charge), 0) AS a FROM cdr_with_charge
+			WHERE
+				customer_id=$1 AND
+				start > DATE_TRUNC('month', LOCALTIMESTAMP)
+		SQL
+		promise.then { |rows| -(rows.first&.dig("a") || 0) }
+	end
+
+	def find_rate(plan_name, other_tel, direction)
+		promise = db.query_defer(<<~SQL, [plan_name, other_tel, direction])
+			SELECT rate FROM call_rates
+			WHERE
+				plan_name=$1 AND
+				$2 LIKE prefix || '%' AND
+				direction=$3
+			ORDER BY prefix DESC
+			LIMIT 1;
+		SQL
+		promise.then { |rows| rows.first&.dig("rate") }
+	end
+end

lib/plan.rb 🔗

@@ -31,7 +31,7 @@ class Plan
 	end
 
 	def minute_limit
-		Limit.for("minute", @plan[:minutes])
+		CallingLimit.new(Limit.for("minute", @plan[:minutes]))
 	end
 
 	def message_limit
@@ -59,6 +59,10 @@ class Plan
 			"(overage $#{'%.4f' % (price.to_d / 10000)} / #{unit})"
 		end
 
+		def to_d
+			included.to_d / 10000
+		end
+
 		class Unlimited
 			def initialize(unit)
 				@unit = unit
@@ -69,4 +73,19 @@ class Plan
 			end
 		end
 	end
+
+	class CallingLimit
+		def initialize(limit)
+			@limit = limit
+		end
+
+		def to_d
+			@limit.to_d
+		end
+
+		def to_s
+			"#{'$%.4f' % to_d} of calling credit per calendar month " \
+			"(overage $#{'%.4f' % (@limit.price.to_d / 10000)} / minute)"
+		end
+	end
 end

test/test_helper.rb 🔗

@@ -73,7 +73,7 @@ CONFIG = {
 			currency: :USD,
 			monthly_price: 10000,
 			messages: :unlimited,
-			minutes: { included: 120, price: 87 }
+			minutes: { included: 10440, price: 87 }
 		},
 		{
 			name: "test_bad_currency",

test/test_plan.rb 🔗

@@ -30,7 +30,7 @@ class PlanTest < Minitest::Test
 
 	def test_minute_limit
 		assert_equal(
-			"120 minutes (overage $0.0087 / minute)",
+			"$1.0440 of calling credit per calendar month (overage $0.0087 / minute)",
 			Plan.for("test_usd").minute_limit.to_s
 		)
 	end

test/test_web.rb 🔗

@@ -13,20 +13,48 @@ class WebTest < Minitest::Test
 		Web.opts[:customer_repo] = CustomerRepo.new(
 			redis: FakeRedis.new(
 				"jmp_customer_jid-customerid" => "customer@example.com",
-				"catapult_jid-+15551234567" => "customer_customerid@component"
+				"catapult_jid-+15551234567" => "customer_customerid@component",
+				"jmp_customer_jid-customerid_low" => "customer@example.com",
+				"jmp_customer_jid-customerid_limit" => "customer@example.com"
+			),
+			db: FakeDB.new(
+				["customerid"] => [{
+					"balance" => BigDecimal(10),
+					"plan_name" => "test_usd",
+					"expires_at" => Time.now + 100
+				}],
+				["customerid_low"] => [{
+					"balance" => BigDecimal("0.01"),
+					"plan_name" => "test_usd",
+					"expires_at" => Time.now + 100
+				}],
+				["customerid_limit"] => [{
+					"balance" => BigDecimal(10),
+					"plan_name" => "test_usd",
+					"expires_at" => Time.now + 100
+				}]
 			),
-			db: FakeDB.new,
 			sgx_repo: Bwmsgsv2Repo.new(
 				redis: FakeRedis.new,
 				ibr_repo: FakeIBRRepo.new(
 					"sgx" => {
 						"customer_customerid@component" => IBR.new.tap do |ibr|
 							ibr.phone = "+15551234567"
+						end,
+						"customer_customerid_limit@component" => IBR.new.tap do |ibr|
+							ibr.phone = "+15551234567"
 						end
 					}
 				)
 			)
 		)
+		Web.opts[:call_attempt_repo] = CallAttemptRepo.new(
+			db: FakeDB.new(
+				["test_usd", "+15557654321", :outbound] => [{ "rate" => 0.01 }],
+				["customerid_limit"] => [{ "a" => -1000 }],
+				["customerid_low"] => [{ "a" => -1000 }]
+			)
+		)
 		Web.app
 	end
 
@@ -47,6 +75,77 @@ class WebTest < Minitest::Test
 	end
 	em :test_outbound_forwards
 
+	def test_outbound_low_balance
+		post(
+			"/outbound/calls",
+			{ from: "customerid_low", to: "+15557654321" }.to_json,
+			{ "CONTENT_TYPE" => "application/json" }
+		)
+
+		assert last_response.ok?
+		assert_equal(
+			"<?xml version=\"1.0\" encoding=\"utf-8\" ?><Response>" \
+			"<SpeakSentence>Your balance of $0.01 is not enough to " \
+			"complete this call.</SpeakSentence></Response>",
+			last_response.body
+		)
+	end
+	em :test_outbound_low_balance
+
+	def test_outbound_unsupported
+		post(
+			"/outbound/calls",
+			{ from: "customerid", to: "+95557654321" }.to_json,
+			{ "CONTENT_TYPE" => "application/json" }
+		)
+
+		assert last_response.ok?
+		assert_equal(
+			"<?xml version=\"1.0\" encoding=\"utf-8\" ?><Response>" \
+			"<SpeakSentence>The number you have dialled is not " \
+			"supported on your account.</SpeakSentence></Response>",
+			last_response.body
+		)
+	end
+	em :test_outbound_unsupported
+
+	def test_outbound_atlimit
+		post(
+			"/outbound/calls",
+			{ from: "customerid_limit", to: "+15557654321" }.to_json,
+			{ "CONTENT_TYPE" => "application/json" }
+		)
+
+		assert last_response.ok?
+		assert_equal(
+			"<?xml version=\"1.0\" encoding=\"utf-8\" ?><Response>" \
+			"<Gather gatherUrl=\"\/outbound/calls\" maxDigits=\"1\" " \
+			"repeatCount=\"3\"><SpeakSentence>This call will take you over " \
+			"your configured monthly overage limit.</SpeakSentence><SpeakSentence>" \
+			"Change your limit in your account settings or press 1 to accept the " \
+			"charges. You can hang up to cancel.</SpeakSentence></Gather></Response>",
+			last_response.body
+		)
+	end
+	em :test_outbound_atlimit
+
+	def test_outbound_atlimit_digits
+		post(
+			"/outbound/calls",
+			{ from: "customerid_limit", to: "+15557654321", digits: "1" }.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
+		)
+	end
+	em :test_outbound_atlimit_digits
+
 	def test_voicemail
 		Customer::BLATHER.expect(
 			:<<,

views/outbound/at_limit.slim 🔗

@@ -0,0 +1,5 @@
+doctype xml
+Response
+	Gather gatherUrl="/outbound/calls" maxDigits="1" repeatCount="3"
+		SpeakSentence This call will take you over your configured monthly overage limit.
+		SpeakSentence Change your limit in your account settings or press 1 to accept the charges. You can hang up to cancel.

web.rb 🔗

@@ -8,6 +8,7 @@ require "roda"
 require "thin"
 require "sentry-ruby"
 
+require_relative "lib/call_attempt_repo"
 require_relative "lib/cdr"
 require_relative "lib/roda_capture"
 require_relative "lib/roda_em_promise"
@@ -102,6 +103,10 @@ class Web < Roda
 		opts[:customer_repo] || CustomerRepo.new(**kwargs)
 	end
 
+	def call_attempt_repo
+		opts[:call_attempt_repo] || CallAttemptRepo.new
+	end
+
 	TEL_CANDIDATES = {
 		"Restricted" => "14",
 		"anonymous" => "15",
@@ -280,18 +285,11 @@ class Web < Roda
 					customer_repo(
 						sgx_repo: Bwmsgsv2Repo.new
 					).find_by_format(from).then do |c|
-						r.json do
-							{
-								from: c.registered?.phone,
-								to: params["to"],
-								customer_id: c.customer_id
-							}.to_json
-						end
-
-						render :forward, locals: {
-							from: c.registered?.phone,
-							to: params["to"]
-						}
+						call_attempt_repo.find(
+							c,
+							params["to"],
+							params["digits"]
+						).then { |ca| render(*ca.to_render) }
 					end
 				end
 			end