From b16e8df70ec165e7dd21d13e63991ee0a2c9ba83 Mon Sep 17 00:00:00 2001 From: Stephen Paul Weber Date: Mon, 11 Apr 2022 14:49:24 -0500 Subject: [PATCH] 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. --- 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(-) create mode 100644 lib/trust_level.rb create mode 100644 lib/trust_level_repo.rb create mode 100644 test/test_trust_level_repo.rb diff --git a/lib/call_attempt.rb b/lib/call_attempt.rb index 34b1280..d63738b 100644 --- a/lib/call_attempt.rb +++ b/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) diff --git a/lib/call_attempt_repo.rb b/lib/call_attempt_repo.rb index fd7e8d7..c0a28aa 100644 --- a/lib/call_attempt_repo.rb +++ b/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 diff --git a/lib/trust_level.rb b/lib/trust_level.rb new file mode 100644 index 0000000..e08036f --- /dev/null +++ b/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 diff --git a/lib/trust_level_repo.rb b/lib/trust_level_repo.rb new file mode 100644 index 0000000..3e064d8 --- /dev/null +++ b/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 diff --git a/test/test_helper.rb b/test/test_helper.rb index 981decc..ce7746d 100644 --- a/test/test_helper.rb +++ b/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 diff --git a/test/test_trust_level_repo.rb b/test/test_trust_level_repo.rb new file mode 100644 index 0000000..a6b8add --- /dev/null +++ b/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 diff --git a/test/test_web.rb b/test/test_web.rb index e83ba2a..aacabc5 100644 --- a/test/test_web.rb +++ b/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 -- 2.38.5