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