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