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