Allow setting parent during signup using a special referral code

Stephen Paul Weber created

Lookup any referral code to see if it is one for setting a parent, if so set the
parent when we set the plan.  In the invite code flow, reload customer and check
balance and if there is enough then we can bill customer and proceed, no need to
add more credit or another code.

Verify parent when setting to make sure it has the same currency as the child
plan at creation time (note that updating the parent plan in the future can
violate this, so be very careful if/when we allow for that!)

Change summary

lib/customer.rb            |  4 +-
lib/customer_plan.rb       | 19 +++++++++-
lib/registration.rb        | 51 +++++++++++++++++++++-----
test/test_customer.rb      |  5 ++
test/test_customer_repo.rb |  5 ++
test/test_registration.rb  | 75 +++++++++++++++++++++++++++++++++++----
6 files changed, 135 insertions(+), 24 deletions(-)

Detailed changes

lib/customer.rb 🔗

@@ -81,10 +81,10 @@ class Customer
 		)
 	end
 
-	def with_plan(plan_name)
+	def with_plan(plan_name, **kwargs)
 		self.class.new(
 			@customer_id, @jid,
-			plan: @plan.with_plan_name(plan_name),
+			plan: @plan.with_plan_name(plan_name, **kwargs),
 			balance: @balance, tndetails: @tndetails, sgx: @sgx
 		)
 	end

lib/customer_plan.rb 🔗

@@ -70,15 +70,29 @@ class CustomerPlan
 		:expired
 	end
 
-	def with_plan_name(plan_name)
+	def with_plan_name(plan_name, **kwargs)
 		self.class.new(
 			@customer_id,
 			plan: Plan.for(plan_name),
-			expires_at: @expires_at
+			expires_at: @expires_at, **kwargs
 		)
 	end
 
+	def verify_parent!
+		return unless @parent_customer_id
+
+		result = DB.query(<<~SQL, [@parent_customer_id])
+			SELECT plan_name FROM customer_plans WHERE customer_id=$1
+		SQL
+
+		raise "Invalid parent account" if !result || !result.first
+
+		plan = Plan.for(result.first["plan_name"])
+		raise "Parent currency mismatch" unless plan.currency == currency
+	end
+
 	def save_plan!
+		verify_parent!
 		DB.exec_defer(<<~SQL, [@customer_id, plan_name, @parent_customer_id])
 			INSERT INTO plan_log
 				(customer_id, plan_name, parent_customer_id, date_range)
@@ -107,6 +121,7 @@ class CustomerPlan
 	end
 
 	def activate_plan_starting_now
+		verify_parent!
 		activated = DB.exec(<<~SQL, [@customer_id, plan_name, @parent_customer_id])
 			INSERT INTO plan_log (customer_id, plan_name, date_range, parent_customer_id)
 			VALUES ($1, $2, tsrange(LOCALTIMESTAMP, LOCALTIMESTAMP + '1 month'), $3)

lib/registration.rb 🔗

@@ -11,6 +11,7 @@ require_relative "./command"
 require_relative "./em"
 require_relative "./invites_repo"
 require_relative "./oob"
+require_relative "./parent_code_repo"
 require_relative "./proxied_jid"
 require_relative "./tel_selections"
 require_relative "./welcome_message"
@@ -105,7 +106,7 @@ class Registration
 
 		def next_step(iq)
 			code = iq.form.field("code")&.value&.to_s
-			save_customer_plan(iq).then {
+			save_customer_plan(iq, code).then {
 				finish_if_valid_invite(code)
 			}.catch_only(InvitesRepo::Invalid) do
 				@invites.stash_code(customer.customer_id, code).then do
@@ -124,10 +125,12 @@ class Registration
 			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!
+		def save_customer_plan(iq, code)
+			ParentCodeRepo.new(REDIS).find(code).then do |parent|
+				plan_name = iq.form.field("plan_name").value.to_s
+				@customer = @customer.with_plan(plan_name, parent_customer_id: parent)
+				@customer.save_plan!
+			end
 		end
 
 		class GooglePlay
@@ -136,6 +139,7 @@ class Registration
 				@google_play_userid = google_play_userid
 				@tel = tel
 				@invites = InvitesRepo.new(DB, REDIS)
+				@parent_code_repo = ParentCodeRepo.new(REDIS)
 			end
 
 			def used
@@ -163,17 +167,25 @@ class Registration
 			end
 
 			def activate(iq)
-				REDIS.sadd("google_play_userids", @google_play_userid).then {
-					plan_name = iq.form.field("plan_name").value.to_s
-					@customer = @customer.with_plan(plan_name)
-					@customer.activate_plan_starting_now
+				plan_name = iq.form.field("plan_name").value
+				code = iq.form.field("code")&.value
+				EMPromise.all([
+					@parent_code_repo.find(code),
+					REDIS.sadd("google_play_userids", @google_play_userid)
+				]).then { |(parent, _)|
+					save_active_plan(plan_name, parent)
 				}.then do
-					use_referral_code(iq.form.field("code")&.value&.to_s)
+					use_referral_code(code)
 				end
 			end
 
 		protected
 
+			def save_active_plan(plan_name, parent)
+				@customer = @customer.with_plan(plan_name, parent_customer_id: parent)
+				@customer.activate_plan_starting_now
+			end
+
 			def use_referral_code(code)
 				EMPromise.resolve(nil).then {
 					@invites.claim_code(@customer.customer_id, code) {
@@ -406,7 +418,24 @@ class Registration
 		end
 
 		class InviteCode
-			Payment.kinds[:code] = method(:new)
+			Payment.kinds[:code] = ->(*args, **kw) { self.for(*args, **kw) }
+
+			def self.for(in_customer, tel, finish: Finish, **)
+				reload_customer(in_customer).then do |customer|
+					if customer.balance >= CONFIG[:activation_amount_accept]
+						next BillPlan.new(customer, tel, finish: finish)
+					end
+
+					msg = if customer.balance.positive?
+						"Account balance not enough to cover the activation"
+					end
+					new(customer, tel, error: msg)
+				end
+			end
+
+			def self.reload_customer(customer)
+				Command.execution.customer_repo.find(customer.customer_id)
+			end
 
 			FIELDS = [{
 				var: "code",

test/test_customer.rb 🔗

@@ -49,6 +49,11 @@ class CustomerTest < Minitest::Test
 	em :test_bill_plan_activate
 
 	def test_bill_plan_reactivate_child
+		CustomerPlan::DB.expect(
+			:query,
+			[{ "plan_name" => "test_usd" }],
+			[String, ["parent"]]
+		)
 		CustomerPlan::DB.expect(:transaction, nil) do |&block|
 			block.call
 			true

test/test_customer_repo.rb 🔗

@@ -194,6 +194,11 @@ class CustomerRepoTest < Minitest::Test
 			EMPromise.resolve([]),
 			["jmp_customer_feature_flags-testp"]
 		)
+		CustomerPlan::DB.expect(
+			:query,
+			[{ "plan_name" => "test_usd" }],
+			[String, ["1234"]]
+		)
 		CustomerPlan::DB.expect(
 			:exec_defer,
 			EMPromise.resolve(nil),

test/test_registration.rb 🔗

@@ -112,7 +112,9 @@ class RegistrationTest < Minitest::Test
 
 	class ActivationTest < Minitest::Test
 		Registration::Activation::DB = Minitest::Mock.new
-		Registration::Activation::REDIS = FakeRedis.new
+		Registration::Activation::REDIS = FakeRedis.new(
+			"jmp_parent_codes" => { "PARENT_CODE" => 1 }
+		)
 		Registration::Activation::Payment = Minitest::Mock.new
 		Registration::Activation::Finish = Minitest::Mock.new
 		Command::COMMAND_MANAGER = Minitest::Mock.new
@@ -136,7 +138,9 @@ class RegistrationTest < Minitest::Test
 					)
 				end]
 			)
-			@customer.expect(:with_plan, @customer, ["test_usd"])
+			@customer.expect(:with_plan, @customer) do |*args, **|
+				assert_equal ["test_usd"], args
+			end
 			@customer.expect(:save_plan!, EMPromise.resolve(nil), [])
 			Registration::Activation::Payment.expect(
 				:for,
@@ -170,7 +174,9 @@ class RegistrationTest < Minitest::Test
 					)
 				end]
 			)
-			@customer.expect(:with_plan, @customer, ["test_usd"])
+			@customer.expect(:with_plan, @customer) do |*args, **|
+				assert_equal ["test_usd"], args
+			end
 			@customer.expect(:save_plan!, EMPromise.resolve(nil), [])
 			@customer.expect(:activate_plan_starting_now, EMPromise.resolve(nil), [])
 			Registration::Activation::DB.expect(:transaction, []) { |&blk| blk.call }
@@ -212,7 +218,9 @@ class RegistrationTest < Minitest::Test
 					)
 				end]
 			)
-			@customer.expect(:with_plan, @customer, ["test_usd"])
+			@customer.expect(:with_plan, @customer) do |*args, **|
+				assert_equal ["test_usd"], args
+			end
 			@customer.expect(:save_plan!, EMPromise.resolve(nil), [])
 			Registration::Activation::DB.expect(:transaction, []) { |&blk| blk.call }
 			Registration::Activation::DB.expect(
@@ -241,6 +249,50 @@ class RegistrationTest < Minitest::Test
 			assert_mock Registration::Activation::DB
 		end
 		em :test_write_with_group_code
+
+		def test_write_with_parent_code
+			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: "PARENT_CODE" }
+					]
+				}),
+				[Matching.new do |iq|
+					assert_equal :form, iq.form.type
+					assert_equal(
+						"You've selected +15555550000 as your JMP number.",
+						iq.form.instructions.lines.first.chomp
+					)
+				end]
+			)
+			@customer.expect(:with_plan, @customer) do |*args, **kwargs|
+				assert_equal ["test_usd"], args
+				assert_equal({ parent_customer_id: 1 }, kwargs)
+			end
+			@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", "PARENT_CODE"]]
+			)
+			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
+			assert_mock Registration::Activation::DB
+		end
+		em :test_write_with_parent_code
 	end
 
 	class AllowTest < Minitest::Test
@@ -426,13 +478,18 @@ class RegistrationTest < Minitest::Test
 				{ var: "activation_method", value: "code" },
 				{ var: "plan_name", value: "test_usd" }
 			]
-			result = Registration::Payment.for(
-				iq,
-				customer,
-				"+15555550000"
-			)
+			cust = customer
+			result = execute_command do
+				Command.execution.customer_repo.expect(:find, cust, ["test"])
+				Registration::Payment.for(
+					iq,
+					cust,
+					"+15555550000"
+				)
+			end
 			assert_kind_of Registration::Payment::InviteCode, result
 		end
+		em :test_for_code
 
 		class BitcoinTest < Minitest::Test
 			Registration::Payment::Bitcoin::BTC_SELL_PRICES = Minitest::Mock.new