TrustLevel{,Repo}

Stephen Paul Weber created

To determine how much permission a customer account should have to take risky
actions.  Seperate from their plan, this is not how much they could do in
theory, but how much the system will allow in practise due to perceived risk.

Starts out with a simple model based on amount of settled payments, and being
used to decide what is an "expensive" route for outbound calls.

Change summary

lib/call_attempt.rb           | 15 ++----
lib/call_attempt_repo.rb      | 11 ++-
lib/trust_level.rb            | 74 +++++++++++++++++++++++++++++++
lib/trust_level_repo.rb       | 34 ++++++++++++++
test/test_helper.rb           | 12 ++++
test/test_trust_level_repo.rb | 87 +++++++++++++++++++++++++++++++++++++
test/test_web.rb              | 16 +++++-
7 files changed, 231 insertions(+), 18 deletions(-)

Detailed changes

lib/call_attempt.rb 🔗

@@ -6,18 +6,13 @@ require_relative "tts_template"
 require_relative "low_balance"
 
 class CallAttempt
-	EXPENSIVE_ROUTE = {
-		"usd_beta_unlimited-v20210223" => 0.9,
-		"cad_beta_unlimited-v20210223" => 1.1
-	}.freeze
-
-	def self.for(customer, rate, usage, direction:, **kwargs)
+	def self.for(customer, rate, usage, trust_level, 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)
+		if !rate || !trust_level.support_call?(rate)
 			Unsupported.new(direction: direction)
 		elsif included_credit + customer.balance < rate * 10
-			NoBalance.for(customer, rate, usage, **kwargs)
+			NoBalance.for(customer, rate, usage, trust_level, **kwargs)
 		else
 			for_ask_or_go(customer, rate, usage, **kwargs)
 		end
@@ -90,12 +85,12 @@ class CallAttempt
 	end
 
 	class NoBalance
-		def self.for(customer, rate, usage, direction:, **kwargs)
+		def self.for(customer, rate, usage, trust_level, direction:, **kwargs)
 			LowBalance.for(customer).then(&:notify!).then do |amount|
 				if amount&.positive?
 					CallAttempt.for(
 						customer.with_balance(customer.balance + amount),
-						rate, usage, direction: direction, **kwargs
+						rate, usage, trust_level, direction: direction, **kwargs
 					)
 				else
 					NoBalance.new(balance: customer.balance, direction: direction)

lib/call_attempt_repo.rb 🔗

@@ -4,10 +4,12 @@ require "value_semantics/monkey_patched"
 require "lazy_object"
 
 require_relative "call_attempt"
+require_relative "trust_level_repo"
 
 class CallAttemptRepo
 	value_semantics do
-		db Anything(), default: LazyObject.new { DB }
+		db    Anything(), default: LazyObject.new { DB }
+		redis Anything(), default: LazyObject.new { REDIS }
 	end
 
 	def find_outbound(customer, to, **kwargs)
@@ -37,10 +39,11 @@ protected
 	def find(customer, other_tel, direction:, **kwargs)
 		EMPromise.all([
 			find_rate(customer.plan_name, other_tel, direction),
-			find_usage(customer.customer_id)
-		]).then do |(rate, usage)|
+			find_usage(customer.customer_id),
+			TrustLevelRepo.new(db: db, redis: redis).find(customer)
+		]).then do |(rate, usage, trust_level)|
 			CallAttempt.for(
-				customer, rate, usage, direction: direction, **kwargs
+				customer, rate, usage, trust_level, direction: direction, **kwargs
 			)
 		end
 	end

lib/trust_level.rb 🔗

@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+module TrustLevel
+	def self.for(plan_name:, settled_amount: 0, manual: nil)
+		@levels.each do |level|
+			tl = level.call(
+				plan_name: plan_name,
+				settled_amount: settled_amount,
+				manual: manual
+			)
+			return tl if tl
+		end
+
+		raise "No TrustLevel matched"
+	end
+
+	def self.register(&maybe_mk)
+		@levels ||= []
+		@levels << maybe_mk
+	end
+
+	class Tomb
+		TrustLevel.register do |manual:, **|
+			new if manual == "Tomb"
+		end
+
+		def support_call?(*)
+			false
+		end
+	end
+
+	class Basement
+		TrustLevel.register do |manual:, settled_amount:, **|
+			new if manual == "Basement" || (!manual && settled_amount < 10)
+		end
+
+		def support_call?(rate)
+			rate <= 0.02
+		end
+	end
+
+	class Paragon
+		TrustLevel.register do |manual:, settled_amount:, **|
+			new if manual == "Paragon" || (!manual && settled_amount > 60)
+		end
+
+		def support_call?(*)
+			true
+		end
+	end
+
+	class Customer
+		TrustLevel.register do |manual:, plan_name:, **|
+			if manual && manual != "Customer"
+				Sentry.capture_message("Unknown TrustLevel: #{manual}")
+			end
+
+			new(plan_name)
+		end
+
+		EXPENSIVE_ROUTE = {
+			"usd_beta_unlimited-v20210223" => 0.9,
+			"cad_beta_unlimited-v20210223" => 1.1
+		}.freeze
+
+		def initialize(plan_name)
+			@max_rate = EXPENSIVE_ROUTE.fetch(plan_name, 0.1)
+		end
+
+		def support_call?(rate)
+			rate <= @max_rate
+		end
+	end
+end

lib/trust_level_repo.rb 🔗

@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require "value_semantics/monkey_patched"
+
+require_relative "trust_level"
+
+class TrustLevelRepo
+	value_semantics do
+		db    Anything(), default: LazyObject.new { DB }
+		redis Anything(), default: LazyObject.new { REDIS }
+	end
+
+	def find(customer)
+		EMPromise.all([
+			redis.get("jmp_customer_trust_level-#{customer.customer_id}"),
+			fetch_settled_amount(customer.customer_id)
+		]).then do |(manual, rows)|
+			TrustLevel.for(
+				manual: manual,
+				plan_name: customer.plan_name,
+				**(rows.first&.transform_keys(&:to_sym) || {})
+			)
+		end
+	end
+
+protected
+
+	def fetch_settled_amount(customer_id)
+		db.query_defer(<<~SQL, [customer_id])
+			SELECT SUM(amount) AS settled_amount FROM transactions
+			WHERE customer_id=$1 AND settled_after < LOCALTIMESTAMP AND amount > 0
+		SQL
+	end
+end

test/test_helper.rb 🔗

@@ -211,12 +211,22 @@ class FakeRedis
 end
 
 class FakeDB
+	class MultiResult
+		def initialize(*args)
+			@results = args
+		end
+
+		def to_a
+			@results.shift
+		end
+	end
+
 	def initialize(items={})
 		@items = items
 	end
 
 	def query_defer(_, args)
-		EMPromise.resolve(@items.fetch(args, []))
+		EMPromise.resolve(@items.fetch(args, []).to_a)
 	end
 end
 

test/test_trust_level_repo.rb 🔗

@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+require "trust_level_repo"
+
+class TrustLevelRepoTest < Minitest::Test
+	def test_manual_tomb
+		trust_level = TrustLevelRepo.new(
+			db: FakeDB.new,
+			redis: FakeRedis.new(
+				"jmp_customer_trust_level-test" => "Tomb"
+			)
+		).find(OpenStruct.new(customer_id: "test", plan_name: "usd")).sync
+		assert_kind_of TrustLevel::Tomb, trust_level
+	end
+	em :test_manual_tomb
+
+	def test_manual_basement
+		trust_level = TrustLevelRepo.new(
+			db: FakeDB.new,
+			redis: FakeRedis.new(
+				"jmp_customer_trust_level-test" => "Basement"
+			)
+		).find(OpenStruct.new(customer_id: "test", plan_name: "usd")).sync
+		assert_kind_of TrustLevel::Basement, trust_level
+	end
+	em :test_manual_basement
+
+	def test_manual_customer
+		trust_level = TrustLevelRepo.new(
+			db: FakeDB.new,
+			redis: FakeRedis.new(
+				"jmp_customer_trust_level-test" => "Customer"
+			)
+		).find(OpenStruct.new(customer_id: "test", plan_name: "usd")).sync
+		assert_kind_of TrustLevel::Customer, trust_level
+	end
+	em :test_manual_customer
+
+	def test_manual_paragon
+		trust_level = TrustLevelRepo.new(
+			db: FakeDB.new,
+			redis: FakeRedis.new(
+				"jmp_customer_trust_level-test" => "Paragon"
+			)
+		).find(OpenStruct.new(customer_id: "test", plan_name: "usd")).sync
+		assert_kind_of TrustLevel::Paragon, trust_level
+	end
+	em :test_manual_paragon
+
+	def test_manual_unknown
+		trust_level = TrustLevelRepo.new(
+			db: FakeDB.new,
+			redis: FakeRedis.new(
+				"jmp_customer_trust_level-test" => "UNKNOWN"
+			)
+		).find(OpenStruct.new(customer_id: "test", plan_name: "usd")).sync
+		assert_kind_of TrustLevel::Customer, trust_level
+	end
+	em :test_manual_unknown
+
+	def test_new_customer
+		trust_level = TrustLevelRepo.new(
+			db: FakeDB.new,
+			redis: FakeRedis.new
+		).find(OpenStruct.new(customer_id: "test", plan_name: "usd")).sync
+		assert_kind_of TrustLevel::Basement, trust_level
+	end
+	em :test_new_customer
+
+	def test_regular_customer
+		trust_level = TrustLevelRepo.new(
+			db: FakeDB.new(["test"] => [{ "settled_amount" => 15 }]),
+			redis: FakeRedis.new
+		).find(OpenStruct.new(customer_id: "test", plan_name: "usd")).sync
+		assert_kind_of TrustLevel::Customer, trust_level
+	end
+	em :test_regular_customer
+
+	def test_settled_customer
+		trust_level = TrustLevelRepo.new(
+			db: FakeDB.new(["test"] => [{ "settled_amount" => 61 }]),
+			redis: FakeRedis.new
+		).find(OpenStruct.new(customer_id: "test", plan_name: "usd")).sync
+		assert_kind_of TrustLevel::Paragon, trust_level
+	end
+	em :test_settled_customer
+end

test/test_web.rb 🔗

@@ -79,12 +79,22 @@ class WebTest < Minitest::Test
 			)
 		)
 		Web.opts[:call_attempt_repo] = CallAttemptRepo.new(
+			redis: FakeRedis.new,
 			db: FakeDB.new(
 				["test_usd", "+15557654321", :outbound] => [{ "rate" => 0.01 }],
 				["test_usd", "+15557654321", :inbound] => [{ "rate" => 0.01 }],
-				["customerid_limit"] => [{ "a" => 1000 }],
-				["customerid_low"] => [{ "a" => 1000 }],
-				["customerid_topup"] => [{ "a" => 1000 }]
+				["customerid_limit"] => FakeDB::MultiResult.new(
+					[{ "a" => 1000 }],
+					[{ "settled_amount" => 15 }]
+				),
+				["customerid_low"] => FakeDB::MultiResult.new(
+					[{ "a" => 1000 }],
+					[{ "settled_amount" => 15 }]
+				),
+				["customerid_topup"] => FakeDB::MultiResult.new(
+					[{ "a" => 1000 }],
+					[{ "settled_amount" => 15 }]
+				)
 			)
 		)
 		Web.opts[:common_logger] = FakeLog.new