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