From 11ac6795b248f334ae53fca28148d254bed9a319 Mon Sep 17 00:00:00 2001 From: Stephen Paul Weber Date: Mon, 31 Jan 2022 16:30:25 -0500 Subject: [PATCH] Outbound call logic for overages If cannot find an acceptable rate for the number, cannot call. If balance is too low, cannot call. If too close to limit, warn. Else, call. --- lib/call_attempt.rb | 61 +++++++++++++++++++ lib/call_attempt_repo.rb | 45 ++++++++++++++ lib/plan.rb | 21 ++++++- test/test_helper.rb | 2 +- test/test_plan.rb | 2 +- test/test_web.rb | 103 +++++++++++++++++++++++++++++++- views/outbound/at_limit.slim | 5 ++ views/outbound/no_balance.slim | 3 + views/outbound/unsupported.slim | 3 + web.rb | 22 ++++--- 10 files changed, 250 insertions(+), 17 deletions(-) create mode 100644 lib/call_attempt.rb create mode 100644 lib/call_attempt_repo.rb create mode 100644 views/outbound/at_limit.slim create mode 100644 views/outbound/no_balance.slim create mode 100644 views/outbound/unsupported.slim diff --git a/lib/call_attempt.rb b/lib/call_attempt.rb new file mode 100644 index 0000000000000000000000000000000000000000..53301146b2120049582c0111d696097ecb10a916 --- /dev/null +++ b/lib/call_attempt.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "value_semantics/monkey_patched" + +class CallAttempt + EXPENSIVE_ROUTE = { + "usd_beta_unlimited-v20210223" => 0.9, + "cad_beta_unlimited-v20210223" => 1.1 + }.freeze + + def self.for(customer, other_tel, rate, usage, digits) + included_credit = [customer.minute_limit.to_d - usage, 0].max + if !rate || rate >= EXPENSIVE_ROUTE.fetch(customer.plan_name, 0.1) + Unsupported.new + elsif included_credit + customer.balance < rate * 10 + NoBalance.new(balance: customer.balance) + else + for_ask_or_go(customer, other_tel, rate, usage, digits) + end + end + + def self.for_ask_or_go(customer, other_tel, rate, usage, digits) + can_use = customer.minute_limit.to_d + customer.monthly_overage_limit + if digits != "1" && can_use - usage < rate * 10 + AtLimit.new + else + new(from: customer.registered?.phone, to: other_tel) + end + end + + value_semantics do + from(/\A\+\d+\Z/) + to(/\A\+\d+\Z/) + end + + def to_render + [:forward, { locals: to_h }] + end + + class Unsupported + def to_render + ["outbound/unsupported"] + end + end + + class NoBalance + value_semantics do + balance Numeric + end + + def to_render + ["outbound/no_balance", { locals: to_h }] + end + end + + class AtLimit + def to_render + ["outbound/at_limit"] + end + end +end diff --git a/lib/call_attempt_repo.rb b/lib/call_attempt_repo.rb new file mode 100644 index 0000000000000000000000000000000000000000..1bf751a1cef92dec275e6f81620b32a550464976 --- /dev/null +++ b/lib/call_attempt_repo.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "value_semantics/monkey_patched" + +require_relative "call_attempt" + +class CallAttemptRepo + value_semantics do + db Anything(), default: LazyObject.new { DB } + end + + def find(customer, other_tel, digits=nil, direction=:outbound) + EMPromise.all([ + find_rate(customer.plan_name, other_tel, direction), + find_usage(customer.customer_id) + ]).then do |(rate, usage)| + CallAttempt.for(customer, other_tel, rate, usage, digits) + end + end + +protected + + def find_usage(customer_id) + promise = db.query_defer(<<~SQL, [customer_id]) + SELECT COALESCE(SUM(charge), 0) AS a FROM cdr_with_charge + WHERE + customer_id=$1 AND + start > DATE_TRUNC('month', LOCALTIMESTAMP) + SQL + promise.then { |rows| -(rows.first&.dig("a") || 0) } + end + + def find_rate(plan_name, other_tel, direction) + promise = db.query_defer(<<~SQL, [plan_name, other_tel, direction]) + SELECT rate FROM call_rates + WHERE + plan_name=$1 AND + $2 LIKE prefix || '%' AND + direction=$3 + ORDER BY prefix DESC + LIMIT 1; + SQL + promise.then { |rows| rows.first&.dig("rate") } + end +end diff --git a/lib/plan.rb b/lib/plan.rb index 37c0ba394d9350716ade6d7e4dafe0355eb67e18..2db0dad2b4fae843461047ab60b30e5427e0b81f 100644 --- a/lib/plan.rb +++ b/lib/plan.rb @@ -31,7 +31,7 @@ class Plan end def minute_limit - Limit.for("minute", @plan[:minutes]) + CallingLimit.new(Limit.for("minute", @plan[:minutes])) end def message_limit @@ -59,6 +59,10 @@ class Plan "(overage $#{'%.4f' % (price.to_d / 10000)} / #{unit})" end + def to_d + included.to_d / 10000 + end + class Unlimited def initialize(unit) @unit = unit @@ -69,4 +73,19 @@ class Plan end end end + + class CallingLimit + def initialize(limit) + @limit = limit + end + + def to_d + @limit.to_d + end + + def to_s + "#{'$%.4f' % to_d} of calling credit per calendar month " \ + "(overage $#{'%.4f' % (@limit.price.to_d / 10000)} / minute)" + end + end end diff --git a/test/test_helper.rb b/test/test_helper.rb index d0a163d50cd2e6e915fec4d367de98746ec78d13..53169be94161977ad5c8945c302cfac9c2378323 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -73,7 +73,7 @@ CONFIG = { currency: :USD, monthly_price: 10000, messages: :unlimited, - minutes: { included: 120, price: 87 } + minutes: { included: 10440, price: 87 } }, { name: "test_bad_currency", diff --git a/test/test_plan.rb b/test/test_plan.rb index 908d73b85e87299264dfc1dde572192da6362e1e..f2f5562508620b933e6bb6bd45c1c602f416213e 100644 --- a/test/test_plan.rb +++ b/test/test_plan.rb @@ -30,7 +30,7 @@ class PlanTest < Minitest::Test def test_minute_limit assert_equal( - "120 minutes (overage $0.0087 / minute)", + "$1.0440 of calling credit per calendar month (overage $0.0087 / minute)", Plan.for("test_usd").minute_limit.to_s ) end diff --git a/test/test_web.rb b/test/test_web.rb index d0efb8d730e13ff8dd948e4525f9f477c244bc6f..b9fbb0c894bb631af23f83fe5badefb6ad677f48 100644 --- a/test/test_web.rb +++ b/test/test_web.rb @@ -13,20 +13,48 @@ class WebTest < Minitest::Test Web.opts[:customer_repo] = CustomerRepo.new( redis: FakeRedis.new( "jmp_customer_jid-customerid" => "customer@example.com", - "catapult_jid-+15551234567" => "customer_customerid@component" + "catapult_jid-+15551234567" => "customer_customerid@component", + "jmp_customer_jid-customerid_low" => "customer@example.com", + "jmp_customer_jid-customerid_limit" => "customer@example.com" + ), + db: FakeDB.new( + ["customerid"] => [{ + "balance" => BigDecimal(10), + "plan_name" => "test_usd", + "expires_at" => Time.now + 100 + }], + ["customerid_low"] => [{ + "balance" => BigDecimal("0.01"), + "plan_name" => "test_usd", + "expires_at" => Time.now + 100 + }], + ["customerid_limit"] => [{ + "balance" => BigDecimal(10), + "plan_name" => "test_usd", + "expires_at" => Time.now + 100 + }] ), - db: FakeDB.new, sgx_repo: Bwmsgsv2Repo.new( redis: FakeRedis.new, ibr_repo: FakeIBRRepo.new( "sgx" => { "customer_customerid@component" => IBR.new.tap do |ibr| ibr.phone = "+15551234567" + end, + "customer_customerid_limit@component" => IBR.new.tap do |ibr| + ibr.phone = "+15551234567" end } ) ) ) + Web.opts[:call_attempt_repo] = CallAttemptRepo.new( + db: FakeDB.new( + ["test_usd", "+15557654321", :outbound] => [{ "rate" => 0.01 }], + ["customerid_limit"] => [{ "a" => -1000 }], + ["customerid_low"] => [{ "a" => -1000 }] + ) + ) Web.app end @@ -47,6 +75,77 @@ class WebTest < Minitest::Test end em :test_outbound_forwards + def test_outbound_low_balance + post( + "/outbound/calls", + { from: "customerid_low", to: "+15557654321" }.to_json, + { "CONTENT_TYPE" => "application/json" } + ) + + assert last_response.ok? + assert_equal( + "" \ + "Your balance of $0.01 is not enough to " \ + "complete this call.", + last_response.body + ) + end + em :test_outbound_low_balance + + def test_outbound_unsupported + post( + "/outbound/calls", + { from: "customerid", to: "+95557654321" }.to_json, + { "CONTENT_TYPE" => "application/json" } + ) + + assert last_response.ok? + assert_equal( + "" \ + "The number you have dialled is not " \ + "supported on your account.", + last_response.body + ) + end + em :test_outbound_unsupported + + def test_outbound_atlimit + post( + "/outbound/calls", + { from: "customerid_limit", to: "+15557654321" }.to_json, + { "CONTENT_TYPE" => "application/json" } + ) + + assert last_response.ok? + assert_equal( + "" \ + "This call will take you over " \ + "your configured monthly overage limit." \ + "Change your limit in your account settings or press 1 to accept the " \ + "charges. You can hang up to cancel.", + last_response.body + ) + end + em :test_outbound_atlimit + + def test_outbound_atlimit_digits + post( + "/outbound/calls", + { from: "customerid_limit", to: "+15557654321", digits: "1" }.to_json, + { "CONTENT_TYPE" => "application/json" } + ) + + assert last_response.ok? + assert_equal( + "" \ + "" \ + "", + last_response.body + ) + end + em :test_outbound_atlimit_digits + def test_voicemail Customer::BLATHER.expect( :<<, diff --git a/views/outbound/at_limit.slim b/views/outbound/at_limit.slim new file mode 100644 index 0000000000000000000000000000000000000000..ed82a0e5c30a2290f0895b8dc166df9da4927e2e --- /dev/null +++ b/views/outbound/at_limit.slim @@ -0,0 +1,5 @@ +doctype xml +Response + Gather gatherUrl="/outbound/calls" maxDigits="1" repeatCount="3" + SpeakSentence This call will take you over your configured monthly overage limit. + SpeakSentence Change your limit in your account settings or press 1 to accept the charges. You can hang up to cancel. diff --git a/views/outbound/no_balance.slim b/views/outbound/no_balance.slim new file mode 100644 index 0000000000000000000000000000000000000000..12179809a5a0927a7424a67c678f63c9f2ade5a3 --- /dev/null +++ b/views/outbound/no_balance.slim @@ -0,0 +1,3 @@ +doctype xml +Response + SpeakSentence= "Your balance of $#{'%.2f' % balance} is not enough to complete this call." diff --git a/views/outbound/unsupported.slim b/views/outbound/unsupported.slim new file mode 100644 index 0000000000000000000000000000000000000000..15a7a15c6e75fa3c7d588ddb5c5be1f0738573be --- /dev/null +++ b/views/outbound/unsupported.slim @@ -0,0 +1,3 @@ +doctype xml +Response + SpeakSentence The number you have dialled is not supported on your account. diff --git a/web.rb b/web.rb index 8eeb22ff82601f6b90dee40dfb18fcc0ae96f8bc..4b85d3ec62a9a50e9c93eb65806e3c5447b3f26d 100644 --- a/web.rb +++ b/web.rb @@ -8,6 +8,7 @@ require "roda" require "thin" require "sentry-ruby" +require_relative "lib/call_attempt_repo" require_relative "lib/cdr" require_relative "lib/roda_capture" require_relative "lib/roda_em_promise" @@ -102,6 +103,10 @@ class Web < Roda opts[:customer_repo] || CustomerRepo.new(**kwargs) end + def call_attempt_repo + opts[:call_attempt_repo] || CallAttemptRepo.new + end + TEL_CANDIDATES = { "Restricted" => "14", "anonymous" => "15", @@ -280,18 +285,11 @@ class Web < Roda customer_repo( sgx_repo: Bwmsgsv2Repo.new ).find_by_format(from).then do |c| - r.json do - { - from: c.registered?.phone, - to: params["to"], - customer_id: c.customer_id - }.to_json - end - - render :forward, locals: { - from: c.registered?.phone, - to: params["to"] - } + call_attempt_repo.find( + c, + params["to"], + params["digits"] + ).then { |ca| render(*ca.to_render) } end end end