Merge branch 'trustlevel-repo'

Stephen Paul Weber created

* trustlevel-repo:
  TrustLevel{,Repo}

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