Merge branch 'invites'

Stephen Paul Weber created

* invites:
  Block repeated invite code tries by customer id
  Allow user to activate using invite code

Change summary

Gemfile                   |   2 
lib/customer.rb           |  18 ++--
lib/registration.rb       |  73 +++++++++++++++++
schemas                   |   2 
test/test_registration.rb | 169 ++++++++++++++++++++++++++++++++++++++++
5 files changed, 249 insertions(+), 15 deletions(-)

Detailed changes

Gemfile 🔗

@@ -9,7 +9,7 @@ gem "em-hiredis"
 gem "em-http-request"
 gem "em-pg-client", git: "https://github.com/royaltm/ruby-em-pg-client"
 gem "em-synchrony"
-gem "em_promise.rb"
+gem "em_promise.rb", "~> 0.0.2"
 gem "eventmachine"
 gem "money-open-exchange-rates"
 gem "ruby-bandwidth-iris"

lib/customer.rb 🔗

@@ -61,6 +61,15 @@ class Customer
 		end
 	end
 
+	def activate_plan_starting_now
+		DB.exec(<<~SQL, [@customer_id, plan_name]).cmd_tuples.positive?
+			INSERT INTO plan_log
+				(customer_id, plan_name, date_range)
+			VALUES ($1, $2, tsrange(LOCALTIMESTAMP, LOCALTIMESTAMP + '1 month'))
+			ON CONFLICT DO NOTHING
+		SQL
+	end
+
 	def payment_methods
 		@payment_methods ||=
 			BRAINTREE
@@ -96,15 +105,6 @@ protected
 		SQL
 	end
 
-	def activate_plan_starting_now
-		DB.exec(<<~SQL, [@customer_id, plan_name]).cmd_tuples.positive?
-			INSERT INTO plan_log
-				(customer_id, plan_name, date_range)
-			VALUES ($1, $2, tsrange(LOCALTIMESTAMP, LOCALTIMESTAMP + '1 month'))
-			ON CONFLICT DO NOTHING
-		SQL
-	end
-
 	def add_one_month_to_current_plan
 		DB.exec(<<~SQL, [@customer_id])
 			UPDATE plan_log SET date_range=range_merge(

lib/registration.rb 🔗

@@ -73,7 +73,7 @@ class Registration
 					},
 					{
 						value: "code",
-						label: "Referral or Activation Code"
+						label: "Invite Code"
 					}
 				]
 			},
@@ -286,6 +286,77 @@ class Registration
 				end
 			end
 		end
+
+		class InviteCode
+			Payment.kinds[:code] = method(:new)
+
+			class Invalid < StandardError; end
+
+			FIELDS = [{
+				var: "code",
+				type: "text-single",
+				label: "Your invite code",
+				required: true
+			}].freeze
+
+			def initialize(iq, customer, tel, error: nil)
+				@customer = customer
+				@tel = tel
+				@reply = iq.reply
+				@reply.allowed_actions = [:next]
+				@form = @reply.form
+				@form.type = :form
+				@form.title = "Enter Invite Code"
+				@form.instructions = error
+				@form.fields = FIELDS
+			end
+
+			def write
+				COMMAND_MANAGER.write(@reply).then do |iq|
+					guard_too_many_tries.then {
+						verify(iq.form.field("code")&.value&.to_s)
+					}.then {
+						Finish.new(iq, @customer, @tel)
+					}.catch_only(Invalid) { |e|
+						invalid_code(iq, e)
+					}.then(&:write)
+				end
+			end
+
+		protected
+
+			def guard_too_many_tries
+				REDIS.get("jmp_invite_tries-#{@customer.customer_id}").then do |t|
+					raise Invalid, "Too many wrong attempts" if t > 10
+				end
+			end
+
+			def invalid_code(iq, e)
+				EMPromise.all([
+					REDIS.incr("jmp_invite_tries-#{@customer.customer_id}").then do
+						REDIS.expire("jmp_invite_tries-#{@customer.customer_id}", 60 * 60)
+					end,
+					InviteCode.new(iq, @customer, @tel, error: e.message)
+				]).then(&:last)
+			end
+
+			def customer_id
+				@customer.customer_id
+			end
+
+			def verify(code)
+				EM.promise_fiber 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
+						raise Invalid, "Not a valid invite code: #{code}" unless valid
+						@customer.activate_plan_starting_now
+					end
+				end
+			end
+		end
 	end
 
 	class Finish

schemas 🔗

@@ -1 +1 @@
-Subproject commit e005a4d6b09636d21614be0c513ce9360cef2ccb
+Subproject commit 1bef640493ff0409838c71e72dd105fb61473cb5

test/test_registration.rb 🔗

@@ -154,14 +154,17 @@ class RegistrationTest < Minitest::Test
 		em :test_for_credit_card
 
 		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
+			result = Registration::Payment.for(
+				iq,
+				Customer.new("test"),
+				"+15555550000"
+			)
+			assert_kind_of Registration::Payment::InviteCode, result
 		end
 
 		class BitcoinTest < Minitest::Test
@@ -335,6 +338,166 @@ class RegistrationTest < Minitest::Test
 			end
 			em :test_write_declines
 		end
+
+		class InviteCodeTest < Minitest::Test
+			Registration::Payment::InviteCode::DB =
+				Minitest::Mock.new
+			Registration::Payment::InviteCode::REDIS =
+				Minitest::Mock.new
+			Registration::Payment::InviteCode::COMMAND_MANAGER =
+				Minitest::Mock.new
+			Registration::Payment::InviteCode::Finish =
+				Minitest::Mock.new
+
+			def test_write
+				customer = Customer.new("test", plan_name: "test_usd")
+				Registration::Payment::InviteCode::REDIS.expect(
+					:get,
+					EMPromise.resolve(0),
+					["jmp_invite_tries-test"]
+				)
+				Registration::Payment::InviteCode::COMMAND_MANAGER.expect(
+					:write,
+					EMPromise.resolve(
+						Blather::Stanza::Iq::Command.new.tap { |iq|
+							iq.form.fields = [{ var: "code", value: "abc" }]
+						}
+					),
+					[Matching.new do |reply|
+						assert_equal :form, reply.form.type
+						assert_nil reply.form.instructions
+					end]
+				)
+				Registration::Payment::InviteCode::DB.expect(:transaction, true, [])
+				Registration::Payment::InviteCode::Finish.expect(
+					:new,
+					OpenStruct.new(write: nil),
+					[
+						Blather::Stanza::Iq::Command,
+						customer,
+						"+15555550000"
+					]
+				)
+				iq = Blather::Stanza::Iq::Command.new
+				iq.from = "test@example.com"
+				Registration::Payment::InviteCode.new(
+					iq,
+					customer,
+					"+15555550000"
+				).write.sync
+				Registration::Payment::InviteCode::COMMAND_MANAGER.verify
+				Registration::Payment::InviteCode::DB.verify
+				Registration::Payment::InviteCode::REDIS.verify
+				Registration::Payment::InviteCode::Finish.verify
+			end
+			em :test_write
+
+			def test_write_bad_code
+				customer = Customer.new("test", plan_name: "test_usd")
+				Registration::Payment::InviteCode::REDIS.expect(
+					:get,
+					EMPromise.resolve(0),
+					["jmp_invite_tries-test"]
+				)
+				Registration::Payment::InviteCode::COMMAND_MANAGER.expect(
+					:write,
+					EMPromise.resolve(
+						Blather::Stanza::Iq::Command.new.tap { |iq|
+							iq.form.fields = [{ var: "code", value: "abc" }]
+						}
+					),
+					[Matching.new do |reply|
+						assert_equal :form, reply.form.type
+						assert_nil reply.form.instructions
+					end]
+				)
+				Registration::Payment::InviteCode::DB.expect(:transaction, []) do
+					raise Registration::Payment::InviteCode::Invalid, "wut"
+				end
+				Registration::Payment::InviteCode::REDIS.expect(
+					:incr,
+					EMPromise.resolve(nil),
+					["jmp_invite_tries-test"]
+				)
+				Registration::Payment::InviteCode::REDIS.expect(
+					:expire,
+					EMPromise.resolve(nil),
+					["jmp_invite_tries-test", 60 * 60]
+				)
+				Registration::Payment::InviteCode::COMMAND_MANAGER.expect(
+					:write,
+					EMPromise.reject(Promise::Error.new),
+					[Matching.new do |reply|
+						assert_equal :form, reply.form.type
+						assert_equal "wut", reply.form.instructions
+					end]
+				)
+				iq = Blather::Stanza::Iq::Command.new
+				iq.from = "test@example.com"
+				assert_raises Promise::Error do
+					Registration::Payment::InviteCode.new(
+						iq,
+						customer,
+						"+15555550000"
+					).write.sync
+				end
+				Registration::Payment::InviteCode::COMMAND_MANAGER.verify
+				Registration::Payment::InviteCode::DB.verify
+				Registration::Payment::InviteCode::REDIS.verify
+			end
+			em :test_write_bad_code
+
+			def test_write_bad_code_over_limit
+				customer = Customer.new("test", plan_name: "test_usd")
+				Registration::Payment::InviteCode::REDIS.expect(
+					:get,
+					EMPromise.resolve(11),
+					["jmp_invite_tries-test"]
+				)
+				Registration::Payment::InviteCode::COMMAND_MANAGER.expect(
+					:write,
+					EMPromise.resolve(
+						Blather::Stanza::Iq::Command.new.tap { |iq|
+							iq.form.fields = [{ var: "code", value: "abc" }]
+						}
+					),
+					[Matching.new do |reply|
+						assert_equal :form, reply.form.type
+						assert_nil reply.form.instructions
+					end]
+				)
+				Registration::Payment::InviteCode::REDIS.expect(
+					:incr,
+					EMPromise.resolve(nil),
+					["jmp_invite_tries-test"]
+				)
+				Registration::Payment::InviteCode::REDIS.expect(
+					:expire,
+					EMPromise.resolve(nil),
+					["jmp_invite_tries-test", 60 * 60]
+				)
+				Registration::Payment::InviteCode::COMMAND_MANAGER.expect(
+					:write,
+					EMPromise.reject(Promise::Error.new),
+					[Matching.new do |reply|
+						assert_equal :form, reply.form.type
+						assert_equal "Too many wrong attempts", reply.form.instructions
+					end]
+				)
+				iq = Blather::Stanza::Iq::Command.new
+				iq.from = "test@example.com"
+				assert_raises Promise::Error do
+					Registration::Payment::InviteCode.new(
+						iq,
+						customer,
+						"+15555550000"
+					).write.sync
+				end
+				Registration::Payment::InviteCode::COMMAND_MANAGER.verify
+				Registration::Payment::InviteCode::REDIS.verify
+			end
+			em :test_write_bad_code_over_limit
+		end
 	end
 
 	class FinishTest < Minitest::Test