tel_selections.rb

  1# frozen_string_literal: true
  2
  3require "ruby-bandwidth-iris"
  4Faraday.default_adapter = :em_synchrony
  5
  6require "cbor"
  7require "countries"
  8
  9require_relative "area_code_repo"
 10require_relative "form_template"
 11require_relative "sim_kind"
 12
 13module NullReserve
 14	def reserve(*)
 15		EMPromise.resolve(nil)
 16	end
 17end
 18
 19SIMKind.class_eval do
 20	include NullReserve
 21end
 22
 23class TelSelections
 24	THIRTY_DAYS = 60 * 60 * 24 * 30
 25
 26	def initialize(redis: REDIS, db: DB, memcache: MEMCACHE)
 27		@redis = redis
 28		@memcache = memcache
 29		@db = db
 30	end
 31
 32	def set(jid, tel)
 33		@redis.setex("pending_tel_for-#{jid}", THIRTY_DAYS, tel.pending_value)
 34	end
 35
 36	def set_tel(jid, tel)
 37		ChooseTel::Tn::LocalInventory.fetch(tel).then do |local_inv|
 38			set(
 39				jid,
 40				local_inv || ChooseTel::Tn::Bandwidth.new(ChooseTel::Tn.new(tel))
 41			)
 42		end
 43	end
 44
 45	def delete(jid)
 46		@redis.del("pending_tel_for-#{jid}")
 47	end
 48
 49	def [](jid)
 50		@redis.get("pending_tel_for-#{jid}").then do |tel|
 51			tel ? HaveTel.new(tel) : ChooseTel.new(db: @db, memcache: @memcache)
 52		end
 53	end
 54
 55	class HaveTel
 56		def initialize(tel)
 57			@tel = ChooseTel::Tn.for_pending_value(tel)
 58		end
 59
 60		def choose_tel_or_data
 61			EMPromise.resolve(@tel)
 62		end
 63	end
 64
 65	class ChooseTel
 66		class Fail < RuntimeError; end
 67
 68		def initialize(db: DB, memcache: MEMCACHE)
 69			@db = db
 70			@memcache = memcache
 71		end
 72
 73		def choose_tel_or_data(error: nil)
 74			Command.reply { |reply|
 75				reply.allowed_actions = [:next]
 76				reply.command << FormTemplate.render("tn_search", error: error)
 77			}.then { |iq|
 78				response = iq.form.field(
 79					"http://jabber.org/protocol/commands#actions"
 80				)&.value.to_s.strip
 81				response == "data_only" ? choose_sim_kind : choose_tel(iq)
 82			}
 83		end
 84
 85		def choose_sim_kind
 86			Command.reply { |reply|
 87				reply.command << FormTemplate.render("registration/choose_sim_kind")
 88				reply.allowed_actions = [:cancel, :next]
 89			}.then { |iq| SIMKind.from_form(iq.form) }
 90		end
 91
 92		def choose_tel(iq)
 93			available = AvailableNumber.for(iq.form, db: @db, memcache: @memcache)
 94			return available if available.is_a?(Tn::Bandwidth)
 95
 96			choose_from_list(available.tns)
 97		rescue Fail
 98			choose_tel_or_data(error: $!.to_s)
 99		end
100
101		def choose_from_list(tns)
102			raise Fail, "No numbers found, try another search." if tns.empty?
103
104			Command.reply { |reply|
105				reply.allowed_actions = [:next, :prev]
106				reply.command << FormTemplate.render("tn_list", tns: tns)
107			}.then { |iq|
108				choose_from_list_result(tns, iq)
109			}
110		end
111
112		def choose_from_list_result(tns, iq)
113			tel = iq.form.field("tel")&.value
114			return choose_tel_or_data if iq.prev? || !tel
115
116			tns.find { |tn| tn.tel == tel } || Tn::Bandwidth.new(Tn.new(tel))
117		end
118
119		class AvailableNumber
120			def self.for(form, db: DB, memcache: MEMCACHE)
121				qs = form.field("q")&.value.to_s.strip
122				return Tn.for_pending_value(qs) if qs =~ /\A\+1\d{10}\Z/
123
124				quantity = Quantity.for(form)
125				q = Q.for(feelinglucky(qs, form), db: db, memcache: memcache)
126
127				new(
128					q.iris_query
129					.merge(enableTNDetail: true, LCA: false),
130					q.sql_query, quantity,
131					fallback: q.fallback, memcache: memcache, db: db
132				)
133			end
134
135			ACTION_FIELD = "http://jabber.org/protocol/commands#actions"
136
137			def self.feelinglucky(q, form)
138				return q unless q.empty?
139				return q unless form.field(ACTION_FIELD)&.value == "feelinglucky"
140
141				"810"
142			end
143
144			def initialize(
145				iris_query, sql_query, quantity,
146				fallback: [], memcache: MEMCACHE, db: DB
147			)
148				@iris_query = iris_query.merge(quantity.iris_query)
149				@sql_query = sql_query
150				@quantity = quantity
151				@fallback = fallback
152				@memcache = memcache
153				@db = db
154			end
155
156			def tns
157				Command.log.debug("BandwidthIris::AvailableNumber.list", @iris_query)
158				unless (result = fetch_cache)
159					result = fetch_bandwidth_inventory + fetch_local_inventory.sync
160				end
161				return next_fallback if result.empty? && !@fallback.empty?
162
163				@quantity.limit(result)
164			end
165
166			def fetch_bandwidth_inventory
167				BandwidthIris::AvailableNumber
168					.list(@iris_query)
169					.map { |tn| Tn::Bandwidth.new(Tn::Option.new(**tn)) }
170			rescue BandwidthIris::APIError
171				raise Fail, $!.message
172			end
173
174			def fetch_local_inventory
175				return EMPromise.resolve([]) unless @sql_query
176
177				@db.query_defer(@sql_query[0], @sql_query[1..-1]).then { |rows|
178					rows.map { |row|
179						Tn::LocalInventory.new(Tn::Option.new(
180							full_number: row["tel"].sub(/\A\+1/, ""),
181							city: row["locality"],
182							state: row["region"]
183						), row["bandwidth_account_id"], price: row["premium_price"])
184					}
185				}
186			end
187
188			def next_fallback
189				@memcache.set(cache_key, CBOR.encode([]), 43200)
190				fallback = @fallback.shift
191				self.class.new(
192					fallback.iris_query.merge(enableTNDetail: true),
193					fallback.sql_query,
194					@quantity,
195					fallback: @fallback,
196					memcache: @memcache, db: @db
197				).tns
198			end
199
200			def fetch_cache
201				promise = EMPromise.new
202				@memcache.get(cache_key, &promise.method(:fulfill))
203				result = promise.sync
204				result ? CBOR.decode(result) : nil
205			end
206
207			def cache_key
208				"BandwidthIris_#{@iris_query.to_a.flatten.join(',')}"
209			end
210
211			class Quantity
212				def self.for(form)
213					return new(10) if form.field(ACTION_FIELD)&.value == "feelinglucky"
214
215					rsm_max = form.find(
216						"ns:set/ns:max",
217						ns: "http://jabber.org/protocol/rsm"
218					).first
219					return new(rsm_max.content.to_i) if rsm_max
220
221					new(10)
222				end
223
224				def initialize(quantity)
225					@quantity = quantity
226				end
227
228				def limit(result)
229					(result || [])[0..@quantity - 1]
230				end
231
232				def iris_query
233					{ quantity: [@quantity, 500].min }
234				end
235			end
236		end
237
238		class Tn
239			attr_reader :tel
240
241			def self.for_pending_value(value)
242				if value.start_with?("LocalInventory/")
243					tel, account, price =
244						value.sub(/\ALocalInventory\//, "").split("/", 3)
245					LocalInventory.new(Tn.new(tel), account, price: price.to_d)
246				else
247					Bandwidth.new(Tn.new(value))
248				end
249			end
250
251			def initialize(tel)
252				@tel = tel
253			end
254
255			def formatted_tel
256				@tel =~ /\A\+1(\d{3})(\d{3})(\d+)\Z/
257				"(#{$1}) #{$2}-#{$3}"
258			end
259
260			def to_s
261				formatted_tel
262			end
263
264			def price
265				0
266			end
267
268			def charge(*); end
269
270			class Option < Tn
271				def initialize(full_number:, city:, state:, **)
272					@tel = "+1#{full_number}"
273					@locality = city
274					@region = state
275				end
276
277				def option(label: nil)
278					op = Blather::Stanza::X::Field::Option.new(
279						value: tel,
280						label: label || to_s
281					)
282					op << reference
283					op
284				end
285
286				def reference
287					Nokogiri::XML::Builder.new { |xml|
288						xml.reference(
289							xmlns: "urn:xmpp:reference:0",
290							begin: 0,
291							end: formatted_tel.length - 1,
292							type: "data",
293							uri: "tel:#{tel}"
294						)
295					}.doc.root
296				end
297
298				def to_s
299					"#{formatted_tel} (#{@locality}, #{@region})"
300				end
301			end
302
303			class Bandwidth < SimpleDelegator
304				def pending_value
305					tel
306				end
307
308				def reserve(customer)
309					BandwidthTnReservationRepo.new.ensure(customer, tel)
310				end
311
312				def order(_, customer)
313					BandwidthTnReservationRepo.new.get(customer, tel).then do |rid|
314						BandwidthTNOrder.create(
315							tel,
316							customer_order_id: customer.customer_id,
317							reservation_id: rid
318						).then(&:poll)
319					end
320				end
321			end
322
323			class LocalInventory < SimpleDelegator
324				include NullReserve
325
326				attr_reader :price
327
328				def initialize(tn, bandwidth_account_id, price: 0)
329					super(tn)
330					@bandwidth_account_id = bandwidth_account_id
331					@price = price || 0
332				end
333
334				def option
335					super(label: to_s)
336				end
337
338				# Creates and inserts transaction charging the customer
339				# for the phone number. If price <= 0 this is a noop.
340				# This method never checks customer balance.
341				#
342				# @param customer [Customer] the customer to charge
343				def charge(customer)
344					return if price <= 0
345
346					transaction(customer).insert
347				end
348
349				# @param customer [Customer] the customer to charge
350				def transaction(customer)
351					Transaction.new(
352						customer_id: customer.customer_id,
353						transaction_id:
354							"#{customer.customer_id}-bill-#{@tel}-at-#{Time.now.to_i}",
355						amount: -price,
356						note: "One-time charge for number: #{formatted_tel}",
357						ignore_duplicate: false
358					)
359				end
360
361				def to_s
362					super + (price.positive? ? " +$%.2f" % price : "")
363				end
364
365				def self.fetch(tn, db: DB)
366					db.query_defer("SELECT * FROM tel_inventory WHERE tel = $1", [tn])
367						.then { |rows|
368						rows.first&.then { |row|
369							new(Tn::Option.new(
370								full_number: row["tel"].sub(/\A\+1/, ""),
371								city: row["locality"],
372								state: row["region"]
373							), row["bandwidth_account_id"], price: row["premium_price"])
374						}
375					}
376				end
377
378				def pending_value
379					"LocalInventory/#{tel}/#{@bandwidth_account_id}/#{price}"
380				end
381
382				def order(db, _customer)
383					# Move always moves to wrong account, oops
384					# Also probably can't move from/to same account
385					# BandwidthTnRepo.new.move(
386					# 	tel, customer.customer_id, @bandwidth_account_id
387					# )
388					db.exec_defer("DELETE FROM tel_inventory WHERE tel = $1", [tel])
389						.then { |r| raise unless r.cmd_tuples.positive? }
390				end
391			end
392		end
393
394		class Q
395			def self.register(regex, &block)
396				@queries ||= []
397				@queries << [regex, block]
398			end
399
400			def self.for(q, **kwa)
401				q = replace_region_names(q) unless q.start_with?("~")
402
403				@queries.each do |(regex, block)|
404					match_data = (q =~ regex)
405					return block.call($1 || $&, *$~.to_a[2..-1], **kwa) if match_data
406				end
407
408				raise Fail, "Format not recognized: #{q}"
409			end
410
411			def self.replace_region_names(query)
412				ISO3166::Country[:US].subdivisions.merge(
413					ISO3166::Country[:CA].subdivisions
414				).reduce(query) do |q, (code, region)|
415					([region.name] + Array(region.unofficial_names))
416						.reduce(q) do |r, name|
417							r.sub(/#{name}\s*(?!,)/i, code)
418						end
419				end
420			end
421
422			def initialize(q, **)
423				@q = q
424			end
425
426			def fallback
427				[]
428			end
429
430			{
431				areaCode: [:AreaCode, /\A[2-9][0-9]{2}\Z/],
432				npaNxx: [:NpaNxx, /\A(?:[2-9][0-9]{2}){2}\Z/],
433				npaNxxx: [:NpaNxxx, /\A(?:[2-9][0-9]{2}){2}[0-9]\Z/]
434			}.each do |k, args|
435				klass = const_set(
436					args[0],
437					Class.new(Q) {
438						define_method(:iris_query) do
439							{ k => @q }
440						end
441
442						define_method(:sql_query) do
443							[
444								"SELECT * FROM tel_inventory " \
445								"WHERE available_after < LOCALTIMESTAMP AND tel LIKE $1",
446								"+1#{@q}%"
447							]
448						end
449					}
450				)
451
452				args[1..-1].each do |regex|
453					register(regex) { |q, **| klass.new(q) }
454				end
455			end
456
457			class PostalCode < Q
458				Q.register(/\A\d{5}(?:-\d{4})?\Z/, &method(:new))
459
460				def iris_query
461					{ zip: @q }
462				end
463
464				def sql_query
465					nil
466				end
467			end
468
469			class LocalVanity < Q
470				Q.register(/\A~(.+)\Z/, &method(:new))
471
472				def iris_query
473					{ localVanity: @q }
474				end
475
476				def sql_query
477					[
478						"SELECT * FROM tel_inventory " \
479						"WHERE available_after < LOCALTIMESTAMP AND tel LIKE $1",
480						"%#{q_digits}%"
481					]
482				end
483
484				def q_digits
485					@q
486						.gsub(/[ABC]/i, "2")
487						.gsub(/[DEF]/i, "3")
488						.gsub(/[GHI]/i, "4")
489						.gsub(/[JKL]/i, "5")
490						.gsub(/[MNO]/i, "6")
491						.gsub(/[PQRS]/i, "7")
492						.gsub(/[TUV]/i, "8")
493						.gsub(/[WXYZ]/i, "9")
494				end
495			end
496
497			class State
498				Q.register(/\A[a-zA-Z]{2}\Z/, &method(:new))
499
500				STATE_MAP = {
501					"QC" => "PQ"
502				}.freeze
503
504				def initialize(state, **)
505					@state = STATE_MAP.fetch(state.upcase, state.upcase)
506				end
507
508				def fallback
509					[]
510				end
511
512				def iris_query
513					{ state: @state }
514				end
515
516				def sql_query
517					[
518						"SELECT * FROM tel_inventory " \
519						"WHERE available_after < LOCALTIMESTAMP AND region = $1",
520						@state
521					]
522				end
523
524				def to_s
525					@state
526				end
527			end
528
529			class CityState
530				Q.register(/\A([^,]+)\s*,\s*([a-zA-Z]{2})\Z/, &method(:new))
531
532				CITY_MAP = {
533					"ajax" => "Ajax-Pickering",
534					"kitchener" => "Kitchener-Waterloo",
535					"new york" => "New York City",
536					"pickering" => "Ajax-Pickering",
537					"sault ste marie" => "sault sainte marie",
538					"sault ste. marie" => "sault sainte marie",
539					"south durham" => "Durham",
540					"township of langley" => "Langley",
541					"waterloo" => "Kitchener-Waterloo",
542					"west durham" => "Durham"
543				}.freeze
544
545				def initialize(city, state, db: DB, memcache: MEMCACHE)
546					@city = CITY_MAP.fetch(city.downcase, city)
547					@state = State.new(state)
548					@db = db
549					@memcache = memcache
550				end
551
552				def fallback
553					LazyObject.new do
554						AreaCodeRepo.new(
555							db: @db,
556							geo_code_repo: GeoCodeRepo.new(memcache: @memcache)
557						).find(to_s).sync.map { |area_code|
558							AreaCode.new(area_code)
559						}
560					end
561				end
562
563				def iris_query
564					@state.iris_query.merge(city: @city)
565				end
566
567				def sql_query
568					[
569						"SELECT * FROM tel_inventory " \
570						"WHERE available_after < LOCALTIMESTAMP " \
571						"AND region = $1 AND locality = $2",
572						@state.to_s, @city
573					]
574				end
575
576				def to_s
577					"#{@city}, #{@state}"
578				end
579			end
580		end
581	end
582end