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 34b12805fe32eab0132292197e4c0fbc0abb6040..d63738b37a2a8e34f0219c32c29fb124b3d3ab3c 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 fd7e8d7801d065f9a263ae35e0cf35be9015c59a..c0a28aaa7ccf16fe7d51106960403c2650cfe071 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 0000000000000000000000000000000000000000..e08036fb9c16cacdb3b7017f0961cd2e1843f669 --- /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 0000000000000000000000000000000000000000..3e064d85b5b5b44ff39fc3bde5c7e264fdb9eb47 --- /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 981decc44f4161de852b4cf79386c510f1100a92..ce7746d9c67b5c6ca9827c1269c66741bf8a06b4 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 0000000000000000000000000000000000000000..a6b8add0ef06db2cb9dad503746e06afe925d4a9 --- /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 e83ba2aad9f423f4d89db24fe0e03a0a65e518ea..aacabc5ea1dadecdc642e1102ac86548de3efcd3 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