tel_selections.rb

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