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