Merge branch 'child-signup'

Stephen Paul Weber created

* child-signup:
  Refactor CutomerPlan to use value semantics
  Allow setting parent during signup using a special referral code

Change summary

lib/customer.rb            |  4 
lib/customer_plan.rb       | 89 +++++++++++++++++++++------------------
lib/plan.rb                |  2 
lib/registration.rb        | 51 +++++++++++++++++----
test/test_customer.rb      |  5 ++
test/test_customer_repo.rb |  5 ++
test/test_registration.rb  | 75 +++++++++++++++++++++++++++++----
7 files changed, 168 insertions(+), 63 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 🔗

@@ -1,6 +1,7 @@
 # frozen_string_literal: true
 
 require "forwardable"
+require "value_semantics/monkey_patched"
 
 require_relative "em"
 require_relative "plan"
@@ -8,21 +9,24 @@ require_relative "plan"
 class CustomerPlan
 	extend Forwardable
 
-	attr_reader :expires_at, :auto_top_up_amount, :monthly_overage_limit,
-	            :parent_customer_id
-
-	def_delegator :@plan, :name, :plan_name
-	def_delegators :@plan, :currency, :merchant_account, :monthly_price,
+	def_delegator :plan, :name, :plan_name
+	def_delegators :plan, :currency, :merchant_account, :monthly_price,
 	               :minute_limit, :message_limit
 
-	def self.for(customer_id, plan_name: nil, **kwargs)
-		new(customer_id, plan: plan_name&.then(&Plan.method(:for)), **kwargs)
+	value_semantics do
+		customer_id           String
+		plan                  Anything(), default: nil, coerce: true
+		expires_at            Either(Time, nil), default_generator: -> { Time.now }
+		auto_top_up_amount    Integer, default: 0
+		monthly_overage_limit Integer, default: 0
+		pending               Bool(), default: false
+		parent_customer_id    Either(String, nil), default: nil
 	end
 
 	def self.default(customer_id, jid)
 		config = CONFIG[:parented_domains][Blather::JID.new(jid).domain]
 		if config
-			self.for(
+			new(
 				customer_id,
 				plan_name: config[:plan_name],
 				parent_customer_id: config[:customer_id]
@@ -33,7 +37,7 @@ class CustomerPlan
 	end
 
 	def self.extract(customer_id, **kwargs)
-		self.for(
+		new(
 			customer_id,
 			**kwargs.slice(
 				:plan_name, :expires_at, :parent_customer_id, :pending,
@@ -42,44 +46,44 @@ class CustomerPlan
 		)
 	end
 
-	def initialize(
-		customer_id,
-		plan: nil,
-		expires_at: Time.now,
-		auto_top_up_amount: 0,
-		monthly_overage_limit: 0,
-		pending: false, parent_customer_id: nil
-	)
-		@customer_id = customer_id
-		@plan = plan || OpenStruct.new
-		@expires_at = expires_at
-		@auto_top_up_amount = auto_top_up_amount || 0
-		@monthly_overage_limit = monthly_overage_limit || 0
-		@pending = pending
-		@parent_customer_id = parent_customer_id
+	def self.coerce_plan(plan_or_name_or_nil)
+		return OpenStruct.new unless plan_or_name_or_nil
+
+		Plan.for(plan_or_name_or_nil)
+	end
+
+	def initialize(customer_id=nil, **kwargs)
+		kwargs[:plan] = kwargs.delete(:plan_name) if kwargs.key?(:plan_name)
+		super(customer_id ? kwargs.merge(customer_id: customer_id) : kwargs)
 	end
 
 	def active?
-		plan_name && @expires_at > Time.now
+		plan_name && expires_at > Time.now
 	end
 
 	def status
 		return :active if active?
-		return :pending if @pending
+		return :pending if pending
 
 		:expired
 	end
 
-	def with_plan_name(plan_name)
-		self.class.new(
-			@customer_id,
-			plan: Plan.for(plan_name),
-			expires_at: @expires_at
-		)
+	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!
-		DB.exec_defer(<<~SQL, [@customer_id, plan_name, @parent_customer_id])
+		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)
 			VALUES (
@@ -107,7 +111,8 @@ class CustomerPlan
 	end
 
 	def activate_plan_starting_now
-		activated = DB.exec(<<~SQL, [@customer_id, plan_name, @parent_customer_id])
+		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)
 			ON CONFLICT DO NOTHING
@@ -115,7 +120,7 @@ class CustomerPlan
 		activated = activated.cmd_tuples.positive?
 		return false unless activated
 
-		DB.exec(<<~SQL, [@customer_id])
+		DB.exec(<<~SQL, [customer_id])
 			DELETE FROM plan_log WHERE customer_id=$1 AND date_range << '[now,now]'
 				AND upper(date_range) - lower(date_range) < '2 seconds'
 		SQL
@@ -126,22 +131,24 @@ class CustomerPlan
 	end
 
 	def activation_date
-		DB.query_one(<<~SQL, @customer_id).then { |r| r[:start_date] }
+		DB.query_one(<<~SQL, customer_id).then { |r| r[:start_date] }
 			SELECT
 				MIN(LOWER(date_range)) AS start_date
 			FROM plan_log WHERE customer_id = $1;
 		SQL
 	end
 
+	protected :customer_id, :plan, :pending, :[]
+
 protected
 
 	def charge_for_plan(note)
-		raise "No plan setup" unless @plan
+		raise "No plan setup" unless plan
 
 		params = [
-			@customer_id,
-			"#{@customer_id}-bill-#{plan_name}-at-#{Time.now.to_i}",
-			-@plan.monthly_price,
+			customer_id,
+			"#{customer_id}-bill-#{plan_name}-at-#{Time.now.to_i}",
+			-plan.monthly_price,
 			note
 		]
 		DB.exec(<<~SQL, params)
@@ -152,7 +159,7 @@ protected
 	end
 
 	def add_one_month_to_current_plan
-		DB.exec(<<~SQL, [@customer_id])
+		DB.exec(<<~SQL, [customer_id])
 			UPDATE plan_log SET date_range=range_merge(
 				date_range,
 				tsrange(

lib/plan.rb 🔗

@@ -2,6 +2,8 @@
 
 class Plan
 	def self.for(plan_name)
+		return plan_name if plan_name.is_a?(Plan)
+
 		plan = CONFIG[:plans].find { |p| p[:name] == plan_name }
 		raise "No plan by that name" unless plan
 

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