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