~singpolyma/sgx-jmp

11ac6795b248f334ae53fca28148d254bed9a319 — Stephen Paul Weber 1 year, 7 months ago b69dfac
Outbound call logic for overages

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.
A lib/call_attempt.rb => lib/call_attempt.rb +61 -0
@@ 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

A lib/call_attempt_repo.rb => lib/call_attempt_repo.rb +45 -0
@@ 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

M lib/plan.rb => lib/plan.rb +20 -1
@@ 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

M test/test_helper.rb => test/test_helper.rb +1 -1
@@ 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",

M test/test_plan.rb => test/test_plan.rb +1 -1
@@ 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

M test/test_web.rb => test/test_web.rb +101 -2
@@ 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(
			:<<,

A views/outbound/at_limit.slim => views/outbound/at_limit.slim +5 -0
@@ 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.

A views/outbound/no_balance.slim => views/outbound/no_balance.slim +3 -0
@@ 0,0 1,3 @@
doctype xml
Response
	SpeakSentence= "Your balance of $#{'%.2f' % balance} is not enough to complete this call."

A views/outbound/unsupported.slim => views/outbound/unsupported.slim +3 -0
@@ 0,0 1,3 @@
doctype xml
Response
	SpeakSentence The number you have dialled is not supported on your account.

M web.rb => web.rb +10 -12
@@ 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