Initial registration flow for Bitcoin

Stephen Paul Weber created

This is the Bitcoin "happy path" where there is already a tel and a customer_id.
Gets all the way to generating a BTC address for payment and quoting an amount.
Stubs out flows for credit card and activation code as well, but does not
implement those flows.

TBD: after bitcoin payment comes in and jmp-pay activates the account, we should
come back here to actually buy the number, select a new one if no longer
available, etc.

Change summary

.rubocop.yml                |   4 
lib/customer.rb             |  41 ++++++++
lib/plan.rb                 |   4 
lib/registration.rb         | 169 +++++++++++++++++++++++++++++++++++++++
lib/web_register_manager.rb |   2 
sgx_jmp.rb                  |  28 ++++++
test/test_helper.rb         |  11 ++
test/test_registration.rb   | 154 +++++++++++++++++++++++++++++++++++
8 files changed, 409 insertions(+), 4 deletions(-)

Detailed changes

.rubocop.yml 🔗

@@ -10,6 +10,10 @@ Metrics/MethodLength:
   Exclude:
     - test/*
 
+Metrics/AbcSize:
+  Exclude:
+    - test/*
+
 Style/Tab:
   Enabled: false
 

lib/customer.rb 🔗

@@ -13,7 +13,7 @@ class Customer
 
 	def self.for_customer_id(customer_id)
 		result = DB.query_defer(<<~SQL, [customer_id])
-			SELECT COALESCE(balance,0) AS balance, plan_name
+			SELECT COALESCE(balance,0) AS balance, plan_name, expires_at
 			FROM customer_plans LEFT JOIN balances USING (customer_id)
 			WHERE customer_id=$1 LIMIT 1
 		SQL
@@ -22,14 +22,37 @@ class Customer
 		end
 	end
 
-	attr_reader :balance
+	attr_reader :customer_id, :balance
 
-	def initialize(customer_id, plan_name: nil, balance: BigDecimal.new(0))
+	def initialize(
+		customer_id,
+		plan_name: nil,
+		expires_at: Time.now,
+		balance: BigDecimal.new(0)
+	)
 		@plan = plan_name && Plan.for(plan_name)
+		@expires_at = expires_at
 		@customer_id = customer_id
 		@balance = balance
 	end
 
+	def with_plan(plan_name)
+		self.class.new(
+			@customer_id,
+			balance: @balance,
+			expires_at: @expires_at,
+			plan_name: plan_name
+		)
+	end
+
+	def plan_name
+		@plan.name
+	end
+
+	def currency
+		@plan.currency
+	end
+
 	def merchant_account
 		@plan.merchant_account
 	end
@@ -41,4 +64,16 @@ class Customer
 			.find(@customer_id)
 			.then(PaymentMethods.method(:for_braintree_customer))
 	end
+
+	def active?
+		@plan && @expires_at > Time.now
+	end
+
+	def registered?
+		ibr = IBR.new(:get, CONFIG[:sgx])
+		ibr.from = "customer_#{@customer_id}@#{CONFIG[:component][:jid]}"
+		IQ_MANAGER.write(ibr).catch { nil }.then do |result|
+			result&.respond_to?(:registered?) && result&.registered?
+		end
+	end
 end

lib/plan.rb 🔗

@@ -12,6 +12,10 @@ class Plan
 		@plan = plan
 	end
 
+	def name
+		@plan[:name]
+	end
+
 	def currency
 		@plan[:currency]
 	end

lib/registration.rb 🔗

@@ -0,0 +1,169 @@
+# frozen_string_literal: true
+
+class Registration
+	def self.for(iq, customer, web_register_manager)
+		raise "TODO" if customer&.active?
+
+		EMPromise.resolve(customer&.registered?).then do |registered|
+			if registered
+				Registered.new(iq, result.phone)
+			else
+				web_register_manager.choose_tel(iq).then do |(riq, tel)|
+					Activation.for(riq, customer, tel)
+				end
+			end
+		end
+	end
+
+	class Registered
+		def initialize(iq, tel)
+			@reply = iq.reply
+			@reply.status = :completed
+			@tel = tel
+		end
+
+		def write
+			@reply.note_type = :error
+			@reply.note_text = <<~NOTE
+				You are already registered with JMP number #{@tel}
+			NOTE
+			BLATHER << @reply
+			nil
+		end
+	end
+
+	class Activation
+		def self.for(iq, customer, tel)
+			return EMPromise.resolve(new(iq, customer, tel)) if customer
+
+			# Create customer_id
+			raise "TODO"
+		end
+
+		def initialize(iq, customer, tel)
+			@reply = iq.reply
+			reply.allowed_actions = [:next]
+
+			@customer = customer
+			@tel = tel
+		end
+
+		attr_reader :reply, :customer, :tel
+
+		FORM_FIELDS = [
+			{
+				var: "activation_method",
+				type: "list-single",
+				label: "Activate using",
+				required: true,
+				options: [
+					{
+						value: "bitcoin",
+						label: "Bitcoin"
+					},
+					{
+						value: "credit_card",
+						label: "Credit Card"
+					},
+					{
+						value: "code",
+						label: "Referral or Activation Code"
+					}
+				]
+			},
+			{
+				var: "plan_name",
+				type: "list-single",
+				label: "What currency should your account balance be in?",
+				required: true,
+				options: [
+					{
+						value: "cad_beta_unlimited-v20210223",
+						label: "Canadian Dollars"
+					},
+					{
+						value: "usd_beta_unlimited-v20210223",
+						label: "United States Dollars"
+					}
+				]
+			}
+		].freeze
+
+		def write
+			form = reply.form
+			form.type = :form
+			form.title = "Activate JMP"
+			form.instructions = "Going to activate #{tel} (TODO RATE CTR)"
+			form.fields = FORM_FIELDS
+
+			COMMAND_MANAGER.write(reply).then { |iq|
+				Payment.for(iq, customer, tel)
+			}.then(&:write)
+		end
+	end
+
+	module Payment
+		def self.for(iq, customer, tel)
+			case iq.form.field("activation_method")&.value&.to_s
+			when "bitcoin"
+				Bitcoin.new(iq, customer, tel)
+			when "credit_card"
+				raise "TODO"
+			when "code"
+				raise "TODO"
+			else
+				raise "Invalid activation method"
+			end
+		end
+
+		class Bitcoin
+			def initialize(iq, customer, tel)
+				@reply = iq.reply
+				reply.note_type = :info
+				reply.status = :completed
+
+				plan_name = iq.form.field("plan_name").value.to_s
+				@customer = customer.with_plan(plan_name)
+				@customer_id = customer.customer_id
+				@tel = tel
+				@addr = ELECTRUM.createnewaddress
+			end
+
+			attr_reader :reply, :customer_id, :tel
+
+			def save
+				EMPromise.all([
+					REDIS.mset(
+						"pending_tel_for-#{customer_id}", tel,
+						"pending_plan_for-#{customer_id}", @customer.plan_name
+					),
+					@addr.then do |addr|
+						REDIS.sadd("jmp_customer_btc_addresses-#{customer_id}", addr)
+					end
+				])
+			end
+
+			def note_text(amount, addr)
+				<<~NOTE
+					Activate your account by sending at least #{'%.6f' % amount} BTC to
+					#{addr}
+
+					You will receive a notification when your payment is complete.
+				NOTE
+			end
+
+			def write
+				EMPromise.all([
+					@addr,
+					save,
+					BTC_SELL_PRICES.public_send(@customer.currency.to_s.downcase)
+				]).then do |(addr, _, rate)|
+					min = CONFIG[:activation_amount] / rate
+					reply.note_text = note_text(min, addr)
+					BLATHER << reply
+					nil
+				end
+			end
+		end
+	end
+end

lib/web_register_manager.rb 🔗

@@ -14,7 +14,7 @@ class WebRegisterManager
 	end
 
 	def choose_tel(iq)
-		self[iq.from.stripped].choose_tel(iq)
+		self[iq&.from&.stripped].choose_tel(iq)
 	end
 
 	class HaveTel

sgx_jmp.rb 🔗

@@ -8,17 +8,24 @@ require "dhall"
 require "em-hiredis"
 require "em_promise"
 
+require_relative "lib/btc_sell_prices"
 require_relative "lib/buy_account_credit_form"
 require_relative "lib/customer"
+require_relative "lib/electrum"
 require_relative "lib/em"
+require_relative "lib/existing_registration"
 require_relative "lib/payment_methods"
+require_relative "lib/registration"
 require_relative "lib/transaction"
+require_relative "lib/web_register_manager"
 
 CONFIG =
 	Dhall::Coder
 	.new(safe: Dhall::Coder::JSON_LIKE + [Symbol])
 	.load(ARGV[0], transform_keys: ->(k) { k&.to_sym })
 
+ELECTRUM = Electrum.new(**CONFIG[:electrum])
+
 # Braintree is not async, so wrap in EM.defer for now
 class AsyncBraintree
 	def initialize(environment:, merchant_id:, public_key:, private_key:, **)
@@ -60,13 +67,16 @@ Blather::DSL.append_features(self.class)
 
 def panic(e)
 	warn "Error raised during event loop: #{e.message}"
+	warn e.backtrace if e.respond_to?(:backtrace)
 	exit 1
 end
 
 EM.error_handler(&method(:panic))
 
 when_ready do
+	BLATHER = self
 	REDIS = EM::Hiredis.connect
+	BTC_SELL_PRICES = BTCSellPrices.new(REDIS, CONFIG[:oxr_app_id])
 	DB = PG::EM::Client.new(dbname: "jmp")
 	DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
 	DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
@@ -126,6 +136,7 @@ end
 
 IQ_MANAGER = SessionManager.new(self, :id)
 COMMAND_MANAGER = SessionManager.new(self, :sessionid, timeout: 60 * 60)
+web_register_manager = WebRegisterManager.new
 
 disco_items node: "http://jabber.org/protocol/commands" do |iq|
 	reply = iq.reply
@@ -136,11 +147,28 @@ disco_items node: "http://jabber.org/protocol/commands" do |iq|
 			iq.to,
 			"buy-credit",
 			"Buy account credit"
+		),
+		Blather::Stanza::DiscoItems::Item.new(
+			iq.to,
+			"jabber:iq:register",
+			"Register"
 		)
 	]
 	self << reply
 end
 
+command :execute?, node: "jabber:iq:register", sessionid: nil do |iq|
+	Customer.for_jid(iq.from.stripped).catch {
+		nil
+	}.then { |customer|
+		Registration.for(
+			iq,
+			customer,
+			web_register_manager
+		).then(&:write)
+	}.catch(&method(:panic))
+end
+
 def reply_with_note(iq, text, type: :info)
 	reply = iq.reply
 	reply.status = :completed

test/test_helper.rb 🔗

@@ -37,6 +37,7 @@ CONFIG = {
 	component: {
 		jid: "component"
 	},
+	activation_amount: 1,
 	plans: [
 		{
 			name: "test_usd",
@@ -58,6 +59,16 @@ BLATHER = Class.new {
 	def <<(*); end
 }.new.freeze
 
+class Matching
+	def initialize(&block)
+		@block = block
+	end
+
+	def ===(other)
+		@block.call(other)
+	end
+end
+
 module Minitest
 	class Test
 		def self.property(m, &block)

test/test_registration.rb 🔗

@@ -0,0 +1,154 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "registration"
+
+class RegistrationTest < Minitest::Test
+	Customer::IQ_MANAGER = Minitest::Mock.new
+
+	def test_for_activated
+		skip "Registration#for activated not implemented yet"
+		iq = Blather::Stanza::Iq::Command.new
+		Registration.for(iq, Customer.new("test"), Minitest::Mock.new).sync
+	end
+	em :test_for_activated
+
+	def test_for_not_activated_with_customer_id
+		Customer::IQ_MANAGER.expect(
+			:write,
+			EMPromise.resolve(nil),
+			[Blather::Stanza::Iq]
+		)
+		web_manager = WebRegisterManager.new
+		web_manager["test@example.com"] = "+15555550000"
+		iq = Blather::Stanza::Iq::Command.new
+		iq.from = "test@example.com"
+		result = Registration.for(
+			iq,
+			Customer.new("test"),
+			web_manager
+		).sync
+		assert_kind_of Registration::Activation, result
+	end
+	em :test_for_not_activated_with_customer_id
+
+	def test_for_not_activated_without_customer_id
+		skip "customer_id creation not implemented yet"
+		iq = Blather::Stanza::Iq::Command.new
+		Registration.for(iq, nil, Minitest::Mock.new).sync
+	end
+	em :test_for_not_activated_without_customer_id
+
+	class ActivationTest < Minitest::Test
+		Registration::Activation::COMMAND_MANAGER = Minitest::Mock.new
+		def setup
+			iq = Blather::Stanza::Iq::Command.new
+			@activation = Registration::Activation.new(iq, "test", "+15555550000")
+		end
+
+		def test_write
+			result = Minitest::Mock.new
+			result.expect(:then, result)
+			result.expect(:then, EMPromise.resolve(:test_result))
+			Registration::Activation::COMMAND_MANAGER.expect(
+				:write,
+				result,
+				[Blather::Stanza::Iq::Command]
+			)
+			assert_equal :test_result, @activation.write.sync
+		end
+		em :test_write
+	end
+
+	class PaymentTest < Minitest::Test
+		Registration::Payment::Bitcoin::ELECTRUM = Minitest::Mock.new
+
+		def test_for_bitcoin
+			Registration::Payment::Bitcoin::ELECTRUM.expect(:createnewaddress, "addr")
+			iq = Blather::Stanza::Iq::Command.new
+			iq.form.fields = [
+				{ var: "activation_method", value: "bitcoin" },
+				{ var: "plan_name", value: "test_usd" }
+			]
+			result = Registration::Payment.for(
+				iq,
+				Customer.new("test"),
+				"+15555550000"
+			)
+			assert_kind_of Registration::Payment::Bitcoin, result
+		end
+
+		def test_for_credit_card
+			skip "CreditCard not implemented yet"
+			iq = Blather::Stanza::Iq::Command.new
+			iq.form.fields = [
+				{ var: "activation_method", value: "credit_card" },
+				{ var: "plan_name", value: "test_usd" }
+			]
+			result = Registration::Payment.for(iq, "test", "+15555550000")
+			assert_kind_of Registration::Payment::CreditCard, result
+		end
+
+		def test_for_code
+			skip "Code not implemented yet"
+			iq = Blather::Stanza::Iq::Command.new
+			iq.form.fields = [
+				{ var: "activation_method", value: "code" },
+				{ var: "plan_name", value: "test_usd" }
+			]
+			result = Registration::Payment.for(iq, "test", "+15555550000")
+			assert_kind_of Registration::Payment::Code, result
+		end
+
+		class BitcoinTest < Minitest::Test
+			Registration::Payment::Bitcoin::ELECTRUM = Minitest::Mock.new
+			Registration::Payment::Bitcoin::REDIS = Minitest::Mock.new
+			Registration::Payment::Bitcoin::BTC_SELL_PRICES = Minitest::Mock.new
+			Registration::Payment::Bitcoin::BLATHER = Minitest::Mock.new
+
+			def setup
+				Registration::Payment::Bitcoin::ELECTRUM.expect(
+					:createnewaddress,
+					EMPromise.resolve("testaddr")
+				)
+				iq = Blather::Stanza::Iq::Command.new
+				iq.form.fields = [
+					{ var: "plan_name", value: "test_usd" }
+				]
+				@bitcoin = Registration::Payment::Bitcoin.new(
+					iq,
+					Customer.new("test"),
+					"+15555550000"
+				)
+			end
+
+			def test_write
+				reply_text = <<~NOTE
+					Activate your account by sending at least 1.000000 BTC to
+					testaddr
+
+					You will receive a notification when your payment is complete.
+				NOTE
+				Registration::Payment::Bitcoin::BLATHER.expect(
+					:<<,
+					nil,
+					[Matching.new do |reply|
+						assert_equal :completed, reply.status
+						assert_equal :info, reply.note_type
+						assert_equal reply_text, reply.note.content
+						true
+					end]
+				)
+				Registration::Payment::Bitcoin::BTC_SELL_PRICES.expect(
+					:usd,
+					EMPromise.resolve(BigDecimal.new(1))
+				)
+				@bitcoin.stub(:save, EMPromise.resolve(nil)) do
+					@bitcoin.write.sync
+				end
+				Registration::Payment::Bitcoin::BLATHER.verify
+			end
+			em :test_write
+		end
+	end
+end