~singpolyma/sgx-jmp

b16e8df70ec165e7dd21d13e63991ee0a2c9ba83 — Stephen Paul Weber 9 months ago 2b3ebc3
TrustLevel{,Repo}

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.
M lib/call_attempt.rb => lib/call_attempt.rb +5 -10
@@ 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)

M lib/call_attempt_repo.rb => lib/call_attempt_repo.rb +7 -4
@@ 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

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

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

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


A test/test_trust_level_repo.rb => test/test_trust_level_repo.rb +87 -0
@@ 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

M test/test_web.rb => test/test_web.rb +13 -3
@@ 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