registration.rb

  1# frozen_string_literal: true
  2
  3require "erb"
  4require "ruby-bandwidth-iris"
  5require "securerandom"
  6
  7require_relative "./alt_top_up_form"
  8require_relative "./command"
  9require_relative "./bandwidth_tn_order"
 10require_relative "./em"
 11require_relative "./oob"
 12require_relative "./web_register_manager"
 13
 14class Registration
 15	def self.for(customer, web_register_manager)
 16		customer.registered?.then do |registered|
 17			if registered
 18				Registered.new(registered.phone)
 19			else
 20				web_register_manager[customer.jid].choose_tel.then do |tel|
 21					Activation.for(customer, tel)
 22				end
 23			end
 24		end
 25	end
 26
 27	class Registered
 28		def initialize(tel)
 29			@tel = tel
 30		end
 31
 32		def write
 33			Command.finish("You are already registered with JMP number #{@tel}")
 34		end
 35	end
 36
 37	class Activation
 38		def self.for(customer, tel)
 39			if customer.active?
 40				Finish.new(customer, tel)
 41			else
 42				EMPromise.resolve(new(customer, tel))
 43			end
 44		end
 45
 46		def initialize(customer, tel)
 47			@customer = customer
 48			@tel = tel
 49		end
 50
 51		attr_reader :customer, :tel
 52
 53		FORM_FIELDS = [
 54			{
 55				var: "activation_method",
 56				type: "list-single",
 57				label: "Activate using",
 58				required: true,
 59				options: [
 60					{
 61						value: "credit_card",
 62						label: "Credit Card"
 63					},
 64					{
 65						value: "bitcoin",
 66						label: "Bitcoin"
 67					},
 68					{
 69						value: "code",
 70						label: "Invite Code"
 71					},
 72					{
 73						value: "mail",
 74						label: "Mail or eTransfer"
 75					}
 76				]
 77			},
 78			{
 79				var: "plan_name",
 80				type: "list-single",
 81				label: "What currency should your account balance be in?",
 82				required: true,
 83				options: [
 84					{
 85						value: "cad_beta_unlimited-v20210223",
 86						label: "Canadian Dollars"
 87					},
 88					{
 89						value: "usd_beta_unlimited-v20210223",
 90						label: "United States Dollars"
 91					}
 92				]
 93			}
 94		].freeze
 95
 96		ACTIVATE_INSTRUCTION =
 97			"To activate your account, you can either deposit " \
 98			"$#{CONFIG[:activation_amount]} to your balance or enter " \
 99			"your invite code if you have one."
100
101		CRYPTOCURRENCY_INSTRUCTION =
102			"(If you'd like to pay in a cryptocurrency other than " \
103			"Bitcoin, currently we recommend using a service like " \
104			"simpleswap.io, morphtoken.com, changenow.io, or godex.io. " \
105			"Manual payment via Bitcoin Cash is also available if you " \
106			"contact support.)"
107
108		def add_instructions(form, center)
109			center = " (#{center})" if center
110			[
111				"You've selected #{tel}#{center} as your JMP number",
112				ACTIVATE_INSTRUCTION,
113				CRYPTOCURRENCY_INSTRUCTION
114			].each do |txt|
115				form << Blather::XMPPNode.new(:instructions, form.document).tap do |i|
116					i << txt
117				end
118			end
119		end
120
121		def write
122			rate_center.then { |center|
123				Command.reply do |reply|
124					reply.allowed_actions = [:next]
125					form = reply.form
126					form.type = :form
127					form.title = "Activate JMP"
128					add_instructions(form, center)
129					form.fields = FORM_FIELDS
130				end
131			}.then { |iq| Payment.for(iq, customer, tel) }.then(&:write)
132		end
133
134	protected
135
136		def rate_center
137			EM.promise_fiber {
138				center = BandwidthIris::Tn.get(tel).get_rate_center
139				"#{center[:rate_center]}, #{center[:state]}"
140			}.catch { nil }
141		end
142	end
143
144	module Payment
145		def self.kinds
146			@kinds ||= {}
147		end
148
149		def self.for(iq, customer, tel)
150			plan_name = iq.form.field("plan_name").value.to_s
151			customer = customer.with_plan(plan_name)
152			kinds.fetch(iq.form.field("activation_method")&.value&.to_s&.to_sym) {
153				raise "Invalid activation method"
154			}.call(customer, tel)
155		end
156
157		class Bitcoin
158			Payment.kinds[:bitcoin] = method(:new)
159
160			def initialize(customer, tel)
161				@customer = customer
162				@customer_id = customer.customer_id
163				@tel = tel
164			end
165
166			attr_reader :customer_id, :tel
167
168			def legacy_session_save
169				sid = SecureRandom.hex
170				REDIS.mset(
171					"reg-sid_for-#{customer_id}", sid,
172					"reg-session_tel-#{sid}", tel
173				)
174			end
175
176			def save
177				EMPromise.all([
178					legacy_session_save,
179					REDIS.mset(
180						"pending_tel_for-#{customer_id}", tel,
181						"pending_plan_for-#{customer_id}", @customer.plan_name
182					)
183				])
184			end
185
186			def note_text(amount, addr)
187				<<~NOTE
188					Activate your account by sending at least #{'%.6f' % amount} BTC to
189					#{addr}
190
191					You will receive a notification when your payment is complete.
192				NOTE
193			end
194
195			def write
196				EMPromise.all([
197					addr,
198					save,
199					BTC_SELL_PRICES.public_send(@customer.currency.to_s.downcase)
200				]).then do |(addr, _, rate)|
201					min = CONFIG[:activation_amount] / rate
202					Command.finish(note_text(min, addr), status: :canceled)
203				end
204			end
205
206		protected
207
208			def addr
209				@addr ||= @customer.btc_addresses.then do |addrs|
210					addrs.first || @customer.add_btc_address
211				end
212			end
213		end
214
215		class CreditCard
216			Payment.kinds[:credit_card] = ->(*args) { self.for(*args) }
217
218			def self.for(customer, tel)
219				customer.payment_methods.then do |payment_methods|
220					if (method = payment_methods.default_payment_method)
221						Activate.new(customer, method, tel)
222					else
223						new(customer, tel)
224					end
225				end
226			end
227
228			def initialize(customer, tel)
229				@customer = customer
230				@tel = tel
231			end
232
233			def oob(reply)
234				oob = OOB.find_or_create(reply.command)
235				oob.url = CONFIG[:credit_card_url].call(
236					reply.to.stripped.to_s.gsub("\\", "%5C"),
237					@customer.customer_id
238				)
239				oob.desc = "Add credit card, then return here to continue"
240				oob
241			end
242
243			def write
244				Command.reply { |reply|
245					reply.allowed_actions = [:next]
246					reply.note_type = :info
247					reply.note_text = "#{oob(reply).desc}: #{oob(reply).url}"
248				}.then do
249					CreditCard.for(@customer, @tel).then(&:write)
250				end
251			end
252
253			class Activate
254				def initialize(customer, payment_method, tel)
255					@customer = customer
256					@payment_method = payment_method
257					@tel = tel
258				end
259
260				def write
261					Transaction.sale(
262						@customer,
263						amount: CONFIG[:activation_amount],
264						payment_method: @payment_method
265					).then(
266						method(:sold),
267						->(_) { declined }
268					)
269				end
270
271			protected
272
273				def sold(tx)
274					tx.insert.then {
275						@customer.bill_plan
276					}.then do
277						Finish.new(@customer, @tel).write
278					end
279				end
280
281				DECLINE_MESSAGE =
282					"Your bank declined the transaction. " \
283					"Often this happens when a person's credit card " \
284					"is a US card that does not support international " \
285					"transactions, as JMP is not based in the USA, though " \
286					"we do support transactions in USD.\n\n" \
287					"If you were trying a prepaid card, you may wish to use "\
288					"Privacy.com instead, as they do support international " \
289					"transactions.\n\n " \
290					"You may add another card and then return here"
291
292				def decline_oob(reply)
293					oob = OOB.find_or_create(reply.command)
294					oob.url = CONFIG[:credit_card_url].call(
295						reply.to.stripped.to_s.gsub("\\", "%5C"),
296						@customer.customer_id
297					)
298					oob.desc = DECLINE_MESSAGE
299					oob
300				end
301
302				def declined
303					Command.reply { |reply|
304						reply_oob = decline_oob(reply)
305						reply.allowed_actions = [:next]
306						reply.note_type = :error
307						reply.note_text = "#{reply_oob.desc}: #{reply_oob.url}"
308					}.then do
309						CreditCard.for(@customer, @tel).then(&:write)
310					end
311				end
312			end
313		end
314
315		class InviteCode
316			Payment.kinds[:code] = method(:new)
317
318			class Invalid < StandardError; end
319
320			FIELDS = [{
321				var: "code",
322				type: "text-single",
323				label: "Your invite code",
324				required: true
325			}].freeze
326
327			def initialize(customer, tel, error: nil)
328				@customer = customer
329				@tel = tel
330				@error = error
331			end
332
333			def add_form(reply)
334				form = reply.form
335				form.type = :form
336				form.title = "Enter Invite Code"
337				form.instructions = @error if @error
338				form.fields = FIELDS
339			end
340
341			def write
342				Command.reply { |reply|
343					reply.allowed_actions = [:next]
344					add_form(reply)
345				}.then(&method(:parse))
346			end
347
348			def parse(iq)
349				guard_too_many_tries.then {
350					verify(iq.form.field("code")&.value&.to_s)
351				}.then {
352					Finish.new(@customer, @tel)
353				}.catch_only(Invalid, &method(:invalid_code)).then(&:write)
354			end
355
356		protected
357
358			def guard_too_many_tries
359				REDIS.get("jmp_invite_tries-#{customer_id}").then do |t|
360					raise Invalid, "Too many wrong attempts" if t.to_i > 10
361				end
362			end
363
364			def invalid_code(e)
365				EMPromise.all([
366					REDIS.incr("jmp_invite_tries-#{customer_id}").then do
367						REDIS.expire("jmp_invite_tries-#{customer_id}", 60 * 60)
368					end,
369					InviteCode.new(@customer, @tel, error: e.message)
370				]).then(&:last)
371			end
372
373			def customer_id
374				@customer.customer_id
375			end
376
377			def verify(code)
378				EMPromise.resolve(nil).then do
379					DB.transaction do
380						valid = DB.exec(<<~SQL, [customer_id, code]).cmd_tuples.positive?
381							UPDATE invites SET used_by_id=$1, used_at=LOCALTIMESTAMP
382							WHERE code=$2 AND used_by_id IS NULL
383						SQL
384						raise Invalid, "Not a valid invite code: #{code}" unless valid
385						@customer.activate_plan_starting_now
386					end
387				end
388			end
389		end
390
391		class Mail
392			Payment.kinds[:mail] = method(:new)
393
394			def initialize(_customer, _tel); end
395
396			def form
397				form = Blather::Stanza::X.new(:result)
398				form.title = "Activate by Mail or eTransfer"
399				form.instructions =
400					"Activate your account by sending at least " \
401					"$#{CONFIG[:activation_amount]}\nWe support payment by " \
402					"postal mail or, in Canada, by Interac eTransfer.\n\n" \
403					"You will receive a notification when your payment is complete."
404
405				form.fields = fields.to_a
406				form
407			end
408
409			def fields
410				[
411					AltTopUpForm::MAILING_ADDRESS,
412					AltTopUpForm::IS_CAD
413				].flatten
414			end
415
416			def write
417				Command.finish(status: :canceled) do |reply|
418					reply.command << form
419				end
420			end
421		end
422	end
423
424	class Finish
425		def initialize(customer, tel)
426			@customer = customer
427			@tel = tel
428		end
429
430		def write
431			BandwidthTNOrder.create(@tel).then(&:poll).then(
432				->(_) { customer_active_tel_purchased },
433				lambda do |_|
434					Command.finish(
435						"The JMP number #{@tel} is no longer available, " \
436						"please visit https://jmp.chat and choose another.",
437						type: :error
438					)
439				end
440			)
441		end
442
443	protected
444
445		def cheogram_sip_addr
446			"sip:#{ERB::Util.url_encode(@customer.jid)}@sip.cheogram.com"
447		end
448
449		def raise_setup_error(e)
450			Command.log.error "@customer.register! failed", e
451			Command.finish(
452				"There was an error setting up your number, " \
453				"please contact JMP support.",
454				type: :error
455			)
456		end
457
458		def customer_active_tel_purchased
459			@customer.register!(@tel).catch(&method(:raise_setup_error)).then {
460				EMPromise.all([
461					REDIS.set("catapult_fwd-#{@tel}", cheogram_sip_addr),
462					@customer.fwd_timeout = 25 # ~5 seconds / ring, 5 rings
463				])
464			}.then do
465				Command.finish("Your JMP account has been activated as #{@tel}")
466			end
467		end
468	end
469end