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