New bulk order API

Stephen Paul Weber created

Change summary

.rubocop.yml              |   1 
config-schema.dhall       |   2 
config.dhall.sample       |   3 
lib/bandwidth_tn_order.rb |  23 ++
lib/transaction.rb        |   2 
test/test_helper.rb       |   6 
test/test_web.rb          | 290 ++++++++++++++++++++++++++++++++++++++++
web.rb                    |  69 +++++++++
8 files changed, 388 insertions(+), 8 deletions(-)

Detailed changes

.rubocop.yml 🔗

@@ -16,6 +16,7 @@ Metrics/BlockLength:
   ExcludedMethods:
     - route
     - "on"
+    - json
   Exclude:
     - test/*
 

config-schema.dhall 🔗

@@ -12,6 +12,8 @@
     , private_key : Text
     , public_key : Text
     }
+, bulk_order_tokens :
+    List { mapKey : Text, mapValue : { customer_id : Text, peer_id : Text } }
 , churnbuster : { account_id : Text, api_key : Text }
 , component : { jid : Text, secret : Text }
 , credit_card_url : forall (jid : Text) -> forall (customer_id : Text) -> Text

config.dhall.sample 🔗

@@ -118,5 +118,6 @@ in
 	churnbuster = {
 		api_key = "",
 		account_id = ""
-	}
+	},
+	bulk_order_tokens = toMap { sometoken = { customer_id = "somecustomer", peer_id = "" } }
 }

lib/bandwidth_tn_order.rb 🔗

@@ -26,12 +26,18 @@ class BandwidthTNOrder
 		reservation_id: nil,
 		**kwargs
 	)
+		create_custom(
+			name: name, **kwargs,
+			existing_telephone_number_order_type: {
+				telephone_number_list: { telephone_number: [tel.sub(/^\+?1?/, "")] }
+			}.merge(ReservationIdList.new(reservation_id).to_h)
+		)
+	end
+
+	def self.create_custom(name:, **kwargs)
 		EMPromise.resolve(nil).then do
 			Received.new(BandwidthIris::Order.create(
-				name: name, **ORDER_DEFAULTS, **kwargs,
-				existing_telephone_number_order_type: {
-					telephone_number_list: { telephone_number: [tel.sub(/^\+?1?/, "")] }
-				}.merge(ReservationIdList.new(reservation_id).to_h)
+				name: name, **ORDER_DEFAULTS, **kwargs
 			))
 		end
 	end
@@ -79,7 +85,14 @@ class BandwidthTNOrder
 		end
 
 		def tel
-			"+1#{@order.completed_numbers[:telephone_number][:full_number]}"
+			tels[0]
+		end
+
+		def tels
+			tns = @order.completed_numbers[:telephone_number]
+			(tns.is_a?(Array) ? tns : [tns]).map do |item|
+				"+1#{item[:full_number]}"
+			end
 		end
 
 		def poll

lib/transaction.rb 🔗

@@ -15,6 +15,7 @@ class Transaction
 		amount BigDecimal, coerce: ->(x) { BigDecimal(x, 4) }
 		note String
 		bonus_eligible? Bool(), default: true
+		ignore_duplicate Bool(), default: false
 	end
 
 	def insert
@@ -72,6 +73,7 @@ class Transaction
 				(customer_id, transaction_id, created_at, settled_after, amount, note)
 			VALUES
 				($1, $2, $3, $4, $5, $6)
+			#{ignore_duplicate ? 'ON CONFLICT (transaction_id) DO NOTHING' : ''}
 		SQL
 	end
 

test/test_helper.rb 🔗

@@ -163,7 +163,11 @@ CONFIG = {
 	onboarding_domain: "onboarding.example.com",
 	adr: "A Mailing Address",
 	interac: "interac@example.com",
-	support_link: ->(*) { "https://support.com" }
+	support_link: ->(*) { "https://support.com" },
+	bulk_order_tokens: {
+		sometoken: { customer_id: "bulkcustomer", peer_id: "bulkpeer" },
+		lowtoken: { customer_id: "customerid_low", peer_id: "lowpeer" }
+	}
 }.freeze
 
 def panic(e)

test/test_web.rb 🔗

@@ -11,6 +11,7 @@ Customer::BLATHER = Minitest::Mock.new
 CustomerFwd::BANDWIDTH_VOICE = Minitest::Mock.new
 Web::BANDWIDTH_VOICE = Minitest::Mock.new
 LowBalance::AutoTopUp::CreditCardSale = Minitest::Mock.new
+Transaction::DB = Minitest::Mock.new
 
 ReachableRedis = Minitest::Mock.new
 
@@ -38,7 +39,8 @@ class WebTest < Minitest::Test
 				"catapult_jid-+15551234563" => "customer_customerid_reach@component",
 				"jmp_customer_jid-customerid2" => "customer2@example.com",
 				"catapult_jid-+15551230000" => "customer_customerid2@component",
-				"jmp_customer_jid-customerid_tombed" => "customer_tombed@example.com"
+				"jmp_customer_jid-customerid_tombed" => "customer_tombed@example.com",
+				"jmp_customer_jid-bulkcustomer" => "bulk@example.com"
 			),
 			db: FakeDB.new(
 				["customerid"] => [{
@@ -75,6 +77,11 @@ class WebTest < Minitest::Test
 					"balance" => BigDecimal(10),
 					"plan_name" => "test_usd",
 					"expires_at" => Time.now + 100
+				}],
+				["bulkcustomer"] => [{
+					"balance" => BigDecimal(1000),
+					"plan_name" => "test_usd",
+					"expires_at" => Time.now + 100
 				}]
 			),
 			sgx_repo: Bwmsgsv2Repo.new(
@@ -865,4 +872,285 @@ class WebTest < Minitest::Test
 		assert_mock ReachableRedis
 	end
 	em :test_inbound_from_reachability_during_reachability
+
+	def test_bulk_order
+		create_order = stub_request(
+			:post,
+			"https://dashboard.bandwidth.com/v1.0/accounts//orders"
+		).to_return(status: 201, body: <<~RESPONSE)
+			<OrderResponse>
+				<Order>
+					<id>test_order</id>
+				</Order>
+			</OrderResponse>
+		RESPONSE
+
+		post(
+			"/orders",
+			{ quantity: 100 },
+			"HTTP_ACCEPT" => "application/json",
+			"HTTP_AUTHORIZATION" => "Bearer sometoken"
+		)
+
+		assert last_response.ok?
+		assert_equal(
+			{ id: "test_order" }.to_json,
+			last_response.body
+		)
+		assert_requested create_order
+	end
+	em :test_bulk_order
+
+	def test_bulk_order_low_balance
+		post(
+			"/orders",
+			{ quantity: 100 },
+			"HTTP_ACCEPT" => "application/json",
+			"HTTP_AUTHORIZATION" => "Bearer lowtoken"
+		)
+
+		assert_equal 402, last_response.status
+	end
+	em :test_bulk_order_low_balance
+
+	def test_bulk_order_bad_token
+		post(
+			"/orders",
+			{ quantity: 100 },
+			"HTTP_ACCEPT" => "application/json",
+			"HTTP_AUTHORIZATION" => "Bearer badtoken"
+		)
+
+		assert_equal 401, last_response.status
+	end
+	em :test_bulk_order_bad_token
+
+	def test_order_get
+		stub_request(
+			:get,
+			"https://dashboard.bandwidth.com/v1.0/accounts//orders/testorder"
+		).to_return(status: 200, body: <<~RESPONSE)
+			<OrderResponse>
+				<id>testorder</id>
+				<OrderStatus>complete</OrderStatus>
+				<CompletedNumbers>
+					<TelephoneNumber>
+						<FullNumber>5551234567</FullNumber>
+					</TelephoneNumber>
+				</CompletedNumbers>
+			</OrderResponse>
+		RESPONSE
+
+		Transaction::DB.expect(:transaction, nil) do |&blk|
+			blk.call
+			true
+		end
+		Transaction::DB.expect(
+			:exec,
+			nil,
+			[String, Matching.new { |params|
+				assert_equal "bulkcustomer", params[0]
+				assert_equal "testorder", params[1]
+				assert_equal(-1.75, params[4])
+				assert_equal "Bulk order", params[5]
+			}]
+		)
+
+		get(
+			"/orders/testorder",
+			"",
+			"HTTP_ACCEPT" => "application/json",
+			"HTTP_AUTHORIZATION" => "Bearer sometoken"
+		)
+
+		assert last_response.ok?
+		assert_equal(
+			{ id: "testorder", status: "complete", tels: ["+15551234567"] }.to_json,
+			last_response.body
+		)
+		assert_mock Transaction::DB
+	end
+	em :test_order_get
+
+	def test_order_get_multi
+		stub_request(
+			:get,
+			"https://dashboard.bandwidth.com/v1.0/accounts//orders/testorder"
+		).to_return(status: 200, body: <<~RESPONSE)
+			<OrderResponse>
+				<id>testorder</id>
+				<OrderStatus>complete</OrderStatus>
+				<CompletedNumbers>
+					<TelephoneNumber>
+						<FullNumber>5551234567</FullNumber>
+					</TelephoneNumber>
+					<TelephoneNumber>
+						<FullNumber>5551234568</FullNumber>
+					</TelephoneNumber>
+				</CompletedNumbers>
+			</OrderResponse>
+		RESPONSE
+
+		Transaction::DB.expect(:transaction, nil) do |&blk|
+			blk.call
+			true
+		end
+		Transaction::DB.expect(
+			:exec,
+			nil,
+			[String, Matching.new { |params|
+				assert_equal "bulkcustomer", params[0]
+				assert_equal "testorder", params[1]
+				assert_equal (-1.75 * 2), params[4]
+				assert_equal "Bulk order", params[5]
+			}]
+		)
+
+		get(
+			"/orders/testorder",
+			"",
+			"HTTP_ACCEPT" => "application/json",
+			"HTTP_AUTHORIZATION" => "Bearer sometoken"
+		)
+
+		assert last_response.ok?
+		assert_equal(
+			{
+				id: "testorder",
+				status: "complete",
+				tels: ["+15551234567", "+15551234568"]
+			}.to_json,
+			last_response.body
+		)
+		assert_mock Transaction::DB
+	end
+	em :test_order_get_multi
+
+	def test_order_get_received
+		stub_request(
+			:get,
+			"https://dashboard.bandwidth.com/v1.0/accounts//orders/testorder"
+		).to_return(status: 200, body: <<~RESPONSE)
+			<OrderResponse>
+				<id>testorder</id>
+				<OrderStatus>received</OrderStatus>
+			</OrderResponse>
+		RESPONSE
+
+		get(
+			"/orders/testorder",
+			"",
+			"HTTP_ACCEPT" => "application/json",
+			"HTTP_AUTHORIZATION" => "Bearer sometoken"
+		)
+
+		assert last_response.ok?
+		assert_equal(
+			{ id: "testorder", status: "received" }.to_json,
+			last_response.body
+		)
+	end
+	em :test_order_get_received
+
+	def test_order_get_failed
+		stub_request(
+			:get,
+			"https://dashboard.bandwidth.com/v1.0/accounts//orders/testorder"
+		).to_return(status: 200, body: <<~RESPONSE)
+			<OrderResponse>
+				<id>testorder</id>
+				<OrderStatus>failed</OrderStatus>
+			</OrderResponse>
+		RESPONSE
+
+		get(
+			"/orders/testorder",
+			"",
+			"HTTP_ACCEPT" => "application/json",
+			"HTTP_AUTHORIZATION" => "Bearer sometoken"
+		)
+
+		assert last_response.ok?
+		assert_equal(
+			{ id: "testorder", status: "failed" }.to_json,
+			last_response.body
+		)
+	end
+	em :test_order_get_failed
+
+	def test_order_get_bad_token
+		get(
+			"/orders/testorder",
+			"",
+			"HTTP_ACCEPT" => "application/json",
+			"HTTP_AUTHORIZATION" => "Bearer badtoken"
+		)
+
+		assert_equal 401, last_response.status
+	end
+	em :test_order_get_bad_token
+
+	def test_delete_tel
+		stub_request(
+			:get,
+			"https://dashboard.bandwidth.com/v1.0/tns/+15551234567/tndetails"
+		).to_return(status: 200, body: <<~RESPONSE)
+			<Response>
+				<TelephoneNumberDetails>
+					<SipPeer><PeerId>bulkpeer</PeerId></SipPeer>
+				</TelephoneNumberDetails>
+			</Response>
+		RESPONSE
+
+		req = stub_request(
+			:post,
+			"https://dashboard.bandwidth.com/v1.0/accounts//disconnects"
+		).to_return(status: 200, body: "")
+
+		delete(
+			"/orders/tels/+15551234567",
+			"",
+			"HTTP_ACCEPT" => "application/json",
+			"HTTP_AUTHORIZATION" => "Bearer sometoken"
+		)
+
+		assert last_response.ok?
+		assert_requested req
+	end
+	em :test_delete_tel
+
+	def test_delete_tel_not_yours
+		stub_request(
+			:get,
+			"https://dashboard.bandwidth.com/v1.0/tns/+15551234567/tndetails"
+		).to_return(status: 200, body: <<~RESPONSE)
+			<Response>
+				<TelephoneNumberDetails>
+					<SipPeer><PeerId>mainpeer</PeerId></SipPeer>
+				</TelephoneNumberDetails>
+			</Response>
+		RESPONSE
+
+		delete(
+			"/orders/tels/+15551234567",
+			"",
+			"HTTP_ACCEPT" => "application/json",
+			"HTTP_AUTHORIZATION" => "Bearer sometoken"
+		)
+
+		assert_equal 401, last_response.status
+	end
+	em :test_delete_tel_not_yours
+
+	def test_delete_tel_bad_token
+		delete(
+			"/orders/tels/+15551234567",
+			"",
+			"HTTP_ACCEPT" => "application/json",
+			"HTTP_AUTHORIZATION" => "Bearer badtoken"
+		)
+
+		assert_equal 401, last_response.status
+	end
+	em :test_delete_tel_bad_token
 end

web.rb 🔗

@@ -9,6 +9,7 @@ require "roda"
 require "sentry-ruby"
 require "thin"
 
+require_relative "lib/bandwidth_tn_order"
 require_relative "lib/call_attempt_repo"
 require_relative "lib/trust_level_repo"
 require_relative "lib/cdr"
@@ -503,6 +504,74 @@ class Web < Roda
 			end
 		end
 
+		r.on "orders" do
+			token = r.env["HTTP_AUTHORIZATION"].to_s.sub(/\ABearer\s+/, "")
+
+			r.json do
+				if (orderer = CONFIG[:bulk_order_tokens][token.to_sym])
+					r.get :order_id do |order_id|
+						BandwidthTNOrder.get(order_id).then do |order|
+							if order.status == :complete
+								Transaction.new(
+									customer_id: orderer[:customer_id],
+									transaction_id: order.id,
+									amount: order.tels.length * -1.75,
+									note: "Bulk order",
+									ignore_duplicate: true
+								).insert.then do
+									{
+										id: order.id,
+										status: order.status,
+										tels: order.tels
+									}.to_json
+								end
+							else
+								{ id: order.id, status: order.status }.to_json
+							end
+						end
+					end
+
+					r.on "tels" do
+						r.on :tel, method: :delete do |tel|
+							tn_repo = BandwidthTnRepo.new
+							tn = tn_repo.find(tel)
+							if tn&.dig(:sip_peer, :peer_id).to_s == orderer[:peer_id]
+								tn_repo.disconnect(tel, orderer[:customer_id])
+								{ status: "disconnected" }.to_json
+							else
+								response.status = 401
+								{ error: "Number not found" }.to_json
+							end
+						end
+					end
+
+					r.post do
+						customer_repo.find(orderer[:customer_id]).then do |customer|
+							if customer.balance >= 1.75 * params["quantity"].to_i
+								BandwidthTNOrder.create_custom(
+									name: "Bulk order",
+									customer_order_id: orderer[:customer_id],
+									peer_id: orderer[:peer_id],
+									state_search_and_order_type: {
+										quantity: params["quantity"].to_i,
+										state: ["CA", "TX", "IL", "NY", "FL"].sample
+									}
+								).then do |order|
+									{ id: order.id }.to_json
+								end
+							else
+								response.status = 402
+								{ error: "Balance too low" }.to_json
+							end
+						end
+					end
+				else
+					response.status = 401
+					{ error: "Bad token" }.to_json
+				end
+			end
+		end
+
 		r.public
 	end
 end