Allow user to activate using invite code

Stephen Paul Weber created

Checks if the code is available and marks it used, then activates. Tracks who
invited and who used in the table for later reward or punishment.

Change summary

Gemfile                   |  2 
lib/customer.rb           | 18 +++---
lib/registration.rb       | 56 +++++++++++++++++++++++
schemas                   |  2 
test/test_registration.rb | 95 +++++++++++++++++++++++++++++++++++++++-
5 files changed, 158 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,60 @@ 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|
+					verify(iq.form.field("code")&.value&.to_s).then {
+						Finish.new(iq, @customer, @tel)
+					}.catch_only(Invalid) { |e|
+						InviteCode.new(iq, @customer, @tel, error: e.message)
+					}.then(&:write)
+				end
+			end
+
+		protected
+
+			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,92 @@ class RegistrationTest < Minitest::Test
 			end
 			em :test_write_declines
 		end
+
+		class InviteCodeTest < Minitest::Test
+			Registration::Payment::InviteCode::DB =
+				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::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::Finish.verify
+			end
+			em :test_write
+
+			def test_write_bad_code
+				customer = Customer.new("test", plan_name: "test_usd")
+				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::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::Finish.verify
+			end
+			em :test_write_bad_code
+		end
 	end
 
 	class FinishTest < Minitest::Test