Accept referral code on activation screen

Stephen Paul Weber created

If it's not a one-use code, save it as a pending code for later use maybe.

Change summary

forms/registration/activate.rb |   6 +
lib/invites_repo.rb            |  18 +++
lib/registration.rb            |  47 ++++++---
test/test_registration.rb      | 165 ++++++++++++++++++++++++++++++++---
4 files changed, 201 insertions(+), 35 deletions(-)

Detailed changes

forms/registration/activate.rb 🔗

@@ -34,3 +34,9 @@ field(
 )
 
 instance_eval File.read("#{__dir__}/plan_name.rb")
+
+field(
+	var: "code",
+	type: "text-single",
+	label: "Optional referral code"
+)

lib/invites_repo.rb 🔗

@@ -15,13 +15,23 @@ class InvitesRepo
 		promise.then { |result| result.map { |row| row["code"] } }
 	end
 
+	def stash_code(customer_id, code)
+		return EMPromise.resolve(nil) if code.to_s.strip == ""
+
+		@redis.set("jmp_customer_pending_invite-#{customer_id}", code)
+	end
+
+	CLAIM_SQL = <<~SQL
+		UPDATE invites SET used_by_id=$1, used_at=LOCALTIMESTAMP
+		WHERE code=$2 AND used_by_id IS NULL
+	SQL
+
 	def claim_code(customer_id, code, &blk)
+		raise Invalid, "No code provided" if code.to_s.strip == ""
+
 		guard_too_many_tries(customer_id).then do
 			@db.transaction do
-				valid = @db.exec(<<~SQL, [customer_id, code]).cmd_tuples.positive?
-					UPDATE invites SET used_by_id=$1, used_at=LOCALTIMESTAMP
-					WHERE code=$2 AND used_by_id IS NULL
-				SQL
+				valid = @db.exec(CLAIM_SQL, [customer_id, code]).cmd_tuples.positive?
 				invalid_code(customer_id, code).sync unless valid
 
 				blk.call

lib/registration.rb 🔗

@@ -87,6 +87,7 @@ class Registration
 		def initialize(customer, tel)
 			@customer = customer
 			@tel = tel
+			@invites = InvitesRepo.new(DB, REDIS)
 		end
 
 		attr_reader :customer, :tel
@@ -109,13 +110,32 @@ class Registration
 		end
 
 		def next_step(iq)
-			EMPromise.resolve(nil).then {
-				Payment.for(iq, customer, tel)
-			}.then(&:write)
+			code = iq.form.field("code")&.value&.to_s
+			save_customer_plan(iq).then {
+				finish_if_valid_invite(code)
+			}.catch_only(InvitesRepo::Invalid) do
+				@invites.stash_code(customer.customer_id, code).then do
+					Payment.for(iq, @customer, @tel).then(&:write)
+				end
+			end
 		end
 
 	protected
 
+		def finish_if_valid_invite(code)
+			@invites.claim_code(@customer.customer_id, code) {
+				@customer.activate_plan_starting_now
+			}.then do
+				Finish.new(@customer, @tel).write
+			end
+		end
+
+		def save_customer_plan(iq)
+			plan_name = iq.form.field("plan_name").value.to_s
+			@customer = @customer.with_plan(plan_name)
+			@customer.save_plan!
+		end
+
 		def rate_center
 			EM.promise_fiber {
 				center = BandwidthIris::Tn.get(tel).get_rate_center
@@ -128,6 +148,7 @@ class Registration
 				@customer = customer
 				@google_play_userid = google_play_userid
 				@tel = tel
+				@invites = InvitesRepo.new(DB, REDIS)
 			end
 
 			def used
@@ -160,18 +181,18 @@ class Registration
 					@customer = @customer.with_plan(plan_name)
 					@customer.activate_plan_starting_now
 				}.then do
-					if iq.form.field("code")
-						use_referral_code(iq.form.field("code").value.to_s)
-					end
+					use_referral_code(iq.form.field("code")&.value&.to_s)
 				end
 			end
 
 		protected
 
 			def use_referral_code(code)
-				InvitesRepo.new.claim_code(@customer.customer_id, code) {
+				@invites.claim_code(@customer.customer_id, code) {
 					@customer.extend_plan
-				}.catch_only(InvitesRepo::Invalid) { nil }
+				}.catch_only(InvitesRepo::Invalid) do
+					@invites.stash_code(customer.customer_id, code)
+				end
 			end
 		end
 
@@ -225,13 +246,9 @@ class Registration
 		end
 
 		def self.for(iq, customer, tel, final_message: nil, finish: Finish)
-			plan_name = iq.form.field("plan_name").value.to_s
-			customer = customer.with_plan(plan_name)
-			customer.save_plan!.then do
-				kinds.fetch(iq.form.field("activation_method")&.value&.to_s&.to_sym) {
-					raise "Invalid activation method"
-				}.call(customer, tel, final_message: final_message, finish: finish)
-			end
+			kinds.fetch(iq.form.field("activation_method")&.value&.to_s&.to_sym) {
+				raise "Invalid activation method"
+			}.call(customer, tel, final_message: final_message, finish: finish)
 		end
 
 		class Bitcoin

test/test_registration.rb 🔗

@@ -103,9 +103,15 @@ class RegistrationTest < Minitest::Test
 	em :test_for_not_activated_with_customer_id
 
 	class ActivationTest < Minitest::Test
+		Registration::Activation::DB = Minitest::Mock.new
+		Registration::Activation::REDIS = FakeRedis.new
+		Registration::Activation::Payment = Minitest::Mock.new
+		Registration::Activation::Finish = Minitest::Mock.new
 		Command::COMMAND_MANAGER = Minitest::Mock.new
+
 		def setup
-			@activation = Registration::Activation.new("test", "+15555550000")
+			@customer = Minitest::Mock.new(customer)
+			@activation = Registration::Activation.new(@customer, "+15555550000")
 		end
 
 		def test_write
@@ -130,7 +136,9 @@ class RegistrationTest < Minitest::Test
 			RESPONSE
 			Command::COMMAND_MANAGER.expect(
 				:write,
-				EMPromise.reject(:test_result),
+				EMPromise.resolve(Blather::Stanza::Iq::Command.new.tap { |iq|
+					iq.form.fields = [{ var: "plan_name", value: "test_usd" }]
+				}),
 				[Matching.new do |iq|
 					assert_equal :form, iq.form.type
 					assert_equal(
@@ -139,13 +147,149 @@ class RegistrationTest < Minitest::Test
 					)
 				end]
 			)
+			@customer.expect(:with_plan, @customer, ["test_usd"])
+			@customer.expect(:save_plan!, EMPromise.resolve(nil), [])
+			Registration::Activation::Payment.expect(
+				:for,
+				EMPromise.reject(:test_result),
+				[Blather::Stanza::Iq, @customer, "+15555550000"]
+			)
 			assert_equal(
 				:test_result,
 				execute_command { @activation.write.catch { |e| e } }
 			)
 			assert_mock Command::COMMAND_MANAGER
+			assert_mock @customer
+			assert_mock Registration::Activation::Payment
 		end
 		em :test_write
+
+		def test_write_with_code
+			stub_request(
+				:get,
+				"https://dashboard.bandwidth.com/v1.0/tns/+15555550000"
+			).to_return(status: 201, body: <<~RESPONSE)
+				<TelephoneNumberResponse>
+					<TelephoneNumber>5555550000</TelephoneNumber>
+				</TelephoneNumberResponse>
+			RESPONSE
+			stub_request(
+				:get,
+				"https://dashboard.bandwidth.com/v1.0/tns/5555550000/ratecenter"
+			).to_return(status: 201, body: <<~RESPONSE)
+				<TelephoneNumberResponse>
+					<TelephoneNumberDetails>
+						<State>KE</State>
+						<RateCenter>FA</RateCenter>
+					</TelephoneNumberDetails>
+				</TelephoneNumberResponse>
+			RESPONSE
+			Command::COMMAND_MANAGER.expect(
+				:write,
+				EMPromise.resolve(Blather::Stanza::Iq::Command.new.tap { |iq|
+					iq.form.fields = [
+						{ var: "plan_name", value: "test_usd" },
+						{ var: "code", value: "123" }
+					]
+				}),
+				[Matching.new do |iq|
+					assert_equal :form, iq.form.type
+					assert_equal(
+						"You've selected +15555550000 (FA, KE) as your JMP number.",
+						iq.form.instructions.lines.first.chomp
+					)
+				end]
+			)
+			@customer.expect(:with_plan, @customer, ["test_usd"])
+			@customer.expect(:save_plan!, EMPromise.resolve(nil), [])
+			@customer.expect(:activate_plan_starting_now, EMPromise.resolve(nil), [])
+			Registration::Activation::DB.expect(:transaction, []) { |&blk| blk.call }
+			Registration::Activation::DB.expect(
+				:exec,
+				OpenStruct.new(cmd_tuples: 1),
+				[String, ["test", "123"]]
+			)
+			Registration::Activation::Finish.expect(
+				:new,
+				OpenStruct.new(write: EMPromise.reject(:test_result)),
+				[@customer, "+15555550000"]
+			)
+			assert_equal(
+				:test_result,
+				execute_command { @activation.write.catch { |e| e } }
+			)
+			assert_mock Command::COMMAND_MANAGER
+			assert_mock @customer
+			assert_mock Registration::Activation::Payment
+			assert_mock Registration::Activation::DB
+		end
+		em :test_write_with_code
+
+		def test_write_with_group_code
+			stub_request(
+				:get,
+				"https://dashboard.bandwidth.com/v1.0/tns/+15555550000"
+			).to_return(status: 201, body: <<~RESPONSE)
+				<TelephoneNumberResponse>
+					<TelephoneNumber>5555550000</TelephoneNumber>
+				</TelephoneNumberResponse>
+			RESPONSE
+			stub_request(
+				:get,
+				"https://dashboard.bandwidth.com/v1.0/tns/5555550000/ratecenter"
+			).to_return(status: 201, body: <<~RESPONSE)
+				<TelephoneNumberResponse>
+					<TelephoneNumberDetails>
+						<State>KE</State>
+						<RateCenter>FA</RateCenter>
+					</TelephoneNumberDetails>
+				</TelephoneNumberResponse>
+			RESPONSE
+			Command::COMMAND_MANAGER.expect(
+				:write,
+				EMPromise.resolve(Blather::Stanza::Iq::Command.new.tap { |iq|
+					iq.form.fields = [
+						{ var: "plan_name", value: "test_usd" },
+						{ var: "code", value: "123" }
+					]
+				}),
+				[Matching.new do |iq|
+					assert_equal :form, iq.form.type
+					assert_equal(
+						"You've selected +15555550000 (FA, KE) as your JMP number.",
+						iq.form.instructions.lines.first.chomp
+					)
+				end]
+			)
+			@customer.expect(:with_plan, @customer, ["test_usd"])
+			@customer.expect(:save_plan!, EMPromise.resolve(nil), [])
+			Registration::Activation::DB.expect(:transaction, []) { |&blk| blk.call }
+			Registration::Activation::DB.expect(
+				:exec,
+				OpenStruct.new(cmd_tuples: 0),
+				[String, ["test", "123"]]
+			)
+			Registration::Activation::Payment.expect(
+				:for,
+				EMPromise.reject(:test_result),
+				[Blather::Stanza::Iq, @customer, "+15555550000"]
+			)
+			assert_equal(
+				:test_result,
+				execute_command { @activation.write.catch { |e| e } }
+			)
+			assert_equal(
+				"123",
+				Registration::Activation::REDIS.get(
+					"jmp_customer_pending_invite-test"
+				).sync
+			)
+			assert_mock Command::COMMAND_MANAGER
+			assert_mock @customer
+			assert_mock Registration::Activation::Payment
+			assert_mock Registration::Activation::DB
+		end
+		em :test_write_with_group_code
 	end
 
 	class AllowTest < Minitest::Test
@@ -272,23 +416,16 @@ class RegistrationTest < Minitest::Test
 		CustomerFinancials::BRAINTREE = Minitest::Mock.new
 
 		def test_for_bitcoin
-			cust = Minitest::Mock.new(customer)
-			cust.expect(:with_plan, cust, ["test_usd"])
-			cust.expect(:save_plan!, nil)
 			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, cust, "+15555550000")
+			result = Registration::Payment.for(iq, customer, "+15555550000")
 			assert_kind_of Registration::Payment::Bitcoin, result
-			assert_mock cust
 		end
 
 		def test_for_credit_card
-			cust = Minitest::Mock.new(customer)
-			cust.expect(:with_plan, cust, ["test_usd"])
-			cust.expect(:save_plan!, nil)
 			braintree_customer = Minitest::Mock.new
 			CustomerFinancials::BRAINTREE.expect(
 				:customer,
@@ -306,6 +443,7 @@ class RegistrationTest < Minitest::Test
 				{ var: "activation_method", value: "credit_card" },
 				{ var: "plan_name", value: "test_usd" }
 			]
+			cust = customer
 			result = execute_command do
 				Command.execution.customer_repo.expect(:find, cust, ["test"])
 				Registration::Payment.for(
@@ -315,14 +453,10 @@ class RegistrationTest < Minitest::Test
 				).sync
 			end
 			assert_kind_of Registration::Payment::CreditCard, result
-			assert_mock cust
 		end
 		em :test_for_credit_card
 
 		def test_for_code
-			cust = Minitest::Mock.new(customer)
-			cust.expect(:with_plan, cust, ["test_usd"])
-			cust.expect(:save_plan!, nil)
 			iq = Blather::Stanza::Iq::Command.new
 			iq.form.fields = [
 				{ var: "activation_method", value: "code" },
@@ -330,11 +464,10 @@ class RegistrationTest < Minitest::Test
 			]
 			result = Registration::Payment.for(
 				iq,
-				cust,
+				customer,
 				"+15555550000"
 			)
 			assert_kind_of Registration::Payment::InviteCode, result
-			assert_mock cust
 		end
 
 		class BitcoinTest < Minitest::Test