A lib/call_attempt.rb => lib/call_attempt.rb +61 -0
@@ 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
A lib/call_attempt_repo.rb => lib/call_attempt_repo.rb +45 -0
@@ 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
M lib/plan.rb => lib/plan.rb +20 -1
@@ 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
M test/test_helper.rb => test/test_helper.rb +1 -1
@@ 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",
M test/test_plan.rb => test/test_plan.rb +1 -1
@@ 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
M test/test_web.rb => test/test_web.rb +101 -2
@@ 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(
+ "<?xml version=\"1.0\" encoding=\"utf-8\" ?><Response>" \
+ "<SpeakSentence>Your balance of $0.01 is not enough to " \
+ "complete this call.</SpeakSentence></Response>",
+ 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(
+ "<?xml version=\"1.0\" encoding=\"utf-8\" ?><Response>" \
+ "<SpeakSentence>The number you have dialled is not " \
+ "supported on your account.</SpeakSentence></Response>",
+ 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(
+ "<?xml version=\"1.0\" encoding=\"utf-8\" ?><Response>" \
+ "<Gather gatherUrl=\"\/outbound/calls\" maxDigits=\"1\" " \
+ "repeatCount=\"3\"><SpeakSentence>This call will take you over " \
+ "your configured monthly overage limit.</SpeakSentence><SpeakSentence>" \
+ "Change your limit in your account settings or press 1 to accept the " \
+ "charges. You can hang up to cancel.</SpeakSentence></Gather></Response>",
+ 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(
+ "<?xml version=\"1.0\" encoding=\"utf-8\" ?><Response>" \
+ "<Forward from=\"+15551234567\" to=\"+15557654321\" />" \
+ "</Response>",
+ last_response.body
+ )
+ end
+ em :test_outbound_atlimit_digits
+
def test_voicemail
Customer::BLATHER.expect(
:<<,
A views/outbound/at_limit.slim => views/outbound/at_limit.slim +5 -0
@@ 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.
A views/outbound/no_balance.slim => views/outbound/no_balance.slim +3 -0
@@ 0,0 1,3 @@
+doctype xml
+Response
+ SpeakSentence= "Your balance of $#{'%.2f' % balance} is not enough to complete this call."
A views/outbound/unsupported.slim => views/outbound/unsupported.slim +3 -0
@@ 0,0 1,3 @@
+doctype xml
+Response
+ SpeakSentence The number you have dialled is not supported on your account.
M web.rb => web.rb +10 -12
@@ 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