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