Customer Info Forms and More Info

Christopher Vollick created

There's a bit extra info I wanted about users, so since I was doing this
anyway I figured I may as well port the existing forms to the new form
renderer.

Then, in order to fit within the guidelines I needed subforms, so
partials were added.

Change summary

Gemfile                               |   1 
forms/admin_info.rb                   |  53 ++++++++++
forms/admin_plan_info.rb              |   6 +
forms/customer_info.rb                |   4 
forms/customer_info_partial.rb        |  29 ++++++
forms/customer_picker.rb              |  14 ++
forms/legacy_customer_admin_info.rb   |  29 ++++++
forms/legacy_customer_info.rb         |   4 
forms/legacy_customer_info_partial.rb |  11 ++
forms/no_customer_info.rb             |   8 +
forms/plan_info.rb                    |  38 +++++++
lib/customer.rb                       |   4 
lib/customer_fwd.rb                   |   4 
lib/customer_info.rb                  | 140 ++++++++++++++++------------
lib/customer_info_form.rb             |   8 -
lib/customer_plan.rb                  |  12 ++
lib/form_template.rb                  |  72 ++++++++++++++
lib/legacy_customer.rb                |  21 +--
sgx_jmp.rb                            |  15 --
test/test_customer_info.rb            |  58 +++++++----
20 files changed, 414 insertions(+), 117 deletions(-)

Detailed changes

Gemfile 🔗

@@ -18,6 +18,7 @@ gem "money-open-exchange-rates"
 gem "multibases"
 gem "multihashes"
 gem "ougai"
+gem "relative_time"
 gem "roda"
 gem "ruby-bandwidth-iris", git: "https://github.com/singpolyma/ruby-bandwidth-iris", branch: "sip_credential"
 gem "sentry-ruby", "<= 4.3.1"

forms/admin_info.rb 🔗

@@ -0,0 +1,53 @@
+result!
+title "Customer Info"
+
+render "customer_info_partial", info: @admin_info.info
+
+render @admin_info.info.plan_info.admin_template
+
+field(
+	var: "jid",
+	label: "JID",
+	value: @admin_info.jid.unproxied.to_s
+)
+
+field(
+	var: "cheo_jid",
+	label: "Cheo JID",
+	value: @admin_info.jid.to_s
+)
+
+field(
+	var: "customer_id",
+	label: "Customer ID",
+	value: @admin_info.customer_id
+)
+
+if @admin_info.fwd.uri
+	field(
+		var: "fwd",
+		label: "Fwd",
+		value: @admin_info.fwd.uri
+	)
+
+	field(
+		var: "fwd_timeout",
+		label: "Fwd Timeout",
+		value: @admin_info.fwd.timeout.to_s
+	)
+end
+
+field(
+	var: "api",
+	label: "API",
+	value: @admin_info.api.to_s
+)
+
+if @admin_info.info.tel
+	field(
+		var: "link",
+		label: "Link",
+		type: "jid-single",
+		value: "#{@admin_info.info.tel}@#{CONFIG[:upstream_domain]}"
+	)
+end

forms/admin_plan_info.rb 🔗

@@ -0,0 +1,6 @@
+field(
+	var: "start_date",
+	label: "Customer since",
+	description: @plan_info.relative_start_date,
+	value: @plan_info.formatted_start_date
+)

forms/customer_info_partial.rb 🔗

@@ -0,0 +1,29 @@
+field(
+	var: "account_status",
+	label: "Account Status",
+	value: @info.plan_info.status
+)
+
+if @info.tel
+	field(
+		var: "tel",
+		label: "Phone Number",
+		value: @info.tel
+	)
+end
+
+if @info.cnam
+	field(
+		var: "lidb_name",
+		label: "CNAM",
+		value: @info.cnam
+	)
+end
+
+field(
+	var: "balance",
+	label: "Balance",
+	value: "$%.4f" % @info.balance
+)
+
+render @info.plan_info.template

forms/customer_picker.rb 🔗

@@ -0,0 +1,14 @@
+form!
+title "Pick Customer"
+
+instructions(
+	"Tell us something about the customer and we'll try to get more " \
+	"information for you"
+)
+
+field(
+	var: "q",
+	type: "text-single",
+	label: "Something about the customer",
+	description: "Supported things include: customer ID, JID, phone number"
+)

forms/legacy_customer_admin_info.rb 🔗

@@ -0,0 +1,29 @@
+result!
+title "Customer Info"
+
+render "legacy_customer_info_partial", info: @admin_info.info
+
+field(
+	var: "jid",
+	label: "JID",
+	value: @admin_info.jid.unproxied.to_s
+)
+
+field(
+	var: "cheo_jid",
+	label: "Cheo JID",
+	value: @admin_info.jid.to_s
+)
+
+field(
+	var: "api",
+	label: "API",
+	value: @admin_info.api.to_s
+)
+
+field(
+	var: "link",
+	label: "Link",
+	type: "jid-single",
+	value: "#{@admin_info.info.tel}@#{CONFIG[:upstream_domain]}"
+)

forms/legacy_customer_info_partial.rb 🔗

@@ -0,0 +1,11 @@
+field(
+	var: "account_status",
+	label: "Account Status",
+	value: "Legacy"
+)
+
+field(
+	var: "tel",
+	label: "Phone Number",
+	value: @info.tel
+)

forms/no_customer_info.rb 🔗

@@ -0,0 +1,8 @@
+result!
+title "Customer Info"
+
+field(
+	var: "account_status",
+	label: "Account Status",
+	value: "Not Found"
+)

forms/plan_info.rb 🔗

@@ -0,0 +1,38 @@
+if @admin_info
+	field(
+		var: "plan",
+		label: "Plan",
+		value: @plan_info.plan.plan_name
+	)
+end
+
+field(
+	var: "renewal",
+	label: "Renewal",
+	value: @plan_info.monthly_price
+)
+
+field(
+	var: "currency",
+	label: "Currency",
+	value: @plan_info.currency
+)
+
+field(
+	var: "expires_at",
+	label: @plan_info.plan.active? ? "Next renewal" : "Expired at",
+	value: @plan_info.expires_at.strftime("%Y-%m-%d")
+)
+
+if @plan_info.auto_top_up_amount.positive?
+	field(
+		var: "auto_top_up_amount",
+		label: "Auto Top-up",
+		value: @plan_info.auto_top_up_amount.to_s
+	)
+else
+	field(
+		label: "Auto Top-up",
+		value: "No"
+	)
+end

lib/customer.rb 🔗

@@ -129,11 +129,11 @@ class Customer
 	end
 
 	def admin_info
-		AdminInfo.for(self, @plan, expires_at)
+		AdminInfo.for(self, @plan)
 	end
 
 	def info
-		CustomerInfo.for(self, @plan, expires_at)
+		CustomerInfo.for(self, @plan)
 	end
 
 	protected def_delegator :@plan, :expires_at

lib/customer_fwd.rb 🔗

@@ -32,6 +32,10 @@ class CustomerFwd
 		def to_i
 			@timeout
 		end
+
+		def to_s
+			to_i.to_s
+		end
 	end
 
 	value_semantics do

lib/customer_info.rb 🔗

@@ -1,78 +1,101 @@
 # frozen_string_literal: true
 
 require "bigdecimal"
+require "relative_time"
 require "value_semantics/monkey_patched"
 require_relative "proxied_jid"
 require_relative "customer_plan"
+require_relative "form_template"
 
-class CustomerInfo
-	value_semantics do
-		plan CustomerPlan
-		auto_top_up_amount Integer
-		tel Either(String, nil)
-		balance BigDecimal
-		expires_at Either(Time, nil)
-		cnam Either(String, nil)
-	end
+class PlanInfo
+	def self.for(plan)
+		return EMPromise.resolve(NoPlan.new) unless plan&.plan_name
 
-	def self.for(customer, plan, expires_at)
-		plan.auto_top_up_amount.then do |auto_top_up_amount|
+		EMPromise.all([
+			plan.activation_date,
+			plan.auto_top_up_amount
+		]).then do |adate, atua|
 			new(
-				plan: plan,
-				auto_top_up_amount: auto_top_up_amount,
-				tel: customer.registered? ? customer.registered?.phone : nil,
-				balance: customer.balance,
-				expires_at: expires_at,
-				cnam: customer.tndetails&.dig(:features, :lidb, :subscriber_information)
+				plan: plan, start_date: adate,
+				auto_top_up_amount: atua
 			)
 		end
 	end
 
-	def account_status
-		if plan.plan_name.nil?
+	class NoPlan
+		def template
+			FormTemplate.new("")
+		end
+
+		def admin_template
+			FormTemplate.new("")
+		end
+
+		def status
 			"Transitional"
-		elsif plan.active?
-			"Active"
-		else
-			"Expired"
 		end
 	end
 
-	def next_renewal
-		return unless expires_at
+	value_semantics do
+		plan CustomerPlan
+		start_date Time
+		auto_top_up_amount Integer
+	end
 
-		{ var: "Next renewal", value: expires_at.strftime("%Y-%m-%d") }
+	def expires_at
+		plan.expires_at
 	end
 
-	def monthly_amount
-		return unless plan.monthly_price
+	def template
+		FormTemplate.for("plan_info", plan_info: self)
+	end
+
+	def admin_template
+		FormTemplate.for("admin_plan_info", plan_info: self)
+	end
 
-		{ var: "Renewal", value: "$%.4f / month" % plan.monthly_price }
+	def monthly_price
+		"$%.4f / month" % plan.monthly_price
 	end
 
-	def auto_top_up
-		{
-			label: "Auto Top-up"
-		}.merge(
-			if auto_top_up_amount.positive?
-				{ value: auto_top_up_amount.to_s, var: "auto_top_up_amount" }
-			else
-				{ value: "No" }
-			end
-		)
+	def relative_start_date
+		RelativeTime.in_words(start_date)
+	end
+
+	def formatted_start_date
+		start_date.strftime("%Y-%m-%d %H:%M:%S")
+	end
+
+	def currency
+		(plan.currency || "No Currency").to_s
+	end
+
+	def status
+		plan.active? ? "Active" : "Expired"
+	end
+end
+
+class CustomerInfo
+	value_semantics do
+		plan_info Either(PlanInfo, PlanInfo::NoPlan)
+		tel Either(String, nil)
+		balance BigDecimal
+		cnam Either(String, nil)
+	end
+
+	def self.for(customer, plan)
+		PlanInfo.for(plan).then do |plan_info|
+			new(
+				plan_info: plan_info,
+				tel: customer.registered? ? customer.registered?.phone : nil,
+				balance: customer.balance,
+				cnam: customer.tndetails&.dig(:features, :lidb, :subscriber_information)
+			)
+		end
 	end
 
-	def fields
-		[
-			{ var: "Account Status", value: account_status },
-			({ var: "tel", label: "Phone Number", value: tel } if tel),
-			({ var: "lidb_name", label: "CNAM", value: cnam } if cnam),
-			{ var: "Balance", value: "$%.4f" % balance },
-			monthly_amount,
-			next_renewal,
-			auto_top_up,
-			{ var: "Currency", value: (plan.currency || "No Currency").to_s }
-		].compact
+	def form
+		FormTemplate.render("customer_info", info: self)
 	end
 end
 
@@ -80,30 +103,25 @@ class AdminInfo
 	value_semantics do
 		jid ProxiedJID, coerce: ProxiedJID.method(:new)
 		customer_id String
+		fwd Either(CustomerFwd, nil)
 		info CustomerInfo
 		api API
 	end
 
-	def self.for(customer, plan, expires_at)
+	def self.for(customer, plan)
 		EMPromise.all([
-			CustomerInfo.for(customer, plan, expires_at),
+			CustomerInfo.for(customer, plan),
 			customer.api
 		]).then do |info, api_value|
 			new(
 				jid: customer.jid,
 				customer_id: customer.customer_id,
-				info: info, api: api_value
+				fwd: customer.fwd, info: info, api: api_value
 			)
 		end
 	end
 
-	def fields
-		info.fields + [
-			{ var: "JID", value: jid.unproxied.to_s },
-			{ var: "Cheo JID", value: jid.to_s },
-			{ var: "Customer ID", value: customer_id },
-			{ var: "Plan", value: info.plan.plan_name || "No Plan" },
-			{ var: "API", value: api.to_s }
-		]
+	def form
+		FormTemplate.render("admin_info", admin_info: self)
 	end
 end

lib/customer_info_form.rb 🔗

@@ -30,14 +30,12 @@ class CustomerInfoForm
 	end
 
 	class NoCustomer
-		class AdminInfo
-			def fields
-				[{ var: "Account Status", value: "Not Found" }]
-			end
+		def form
+			FormTemplate.render("no_customer_info")
 		end
 
 		def admin_info
-			AdminInfo.new
+			self
 		end
 	end
 

lib/customer_plan.rb 🔗

@@ -52,6 +52,18 @@ class CustomerPlan
 		SQL
 	end
 
+	def activation_date
+		dates = DB.query_defer(<<~SQL, [@customer_id])
+			SELECT
+				MIN(LOWER(date_range)) AS start_date
+			FROM plan_log WHERE customer_id = $1;
+		SQL
+
+		dates.then do |r|
+			r.first["start_date"]
+		end
+	end
+
 protected
 
 	def charge_for_plan

lib/form_template.rb 🔗

@@ -10,13 +10,23 @@ class FormTemplate
 		freeze
 	end
 
-	def self.render(path, **kwargs)
+	def self.for(path, **kwargs)
+		if path.is_a?(FormTemplate)
+			raise "Sent args and a FormTemplate" unless kwargs.empty?
+
+			return path
+		end
+
 		full_path = File.dirname(__dir__) + "/forms/#{path}.rb"
-		new(File.read(full_path), full_path, **kwargs).render
+		new(File.read(full_path), full_path, **kwargs)
 	end
 
-	def render(**kwargs)
-		one = OneRender.new(**@args.merge(kwargs))
+	def self.render(path, context=OneRender.new, **kwargs)
+		self.for(path).render(context, **kwargs)
+	end
+
+	def render(context=OneRender.new, **kwargs)
+		one = context.merge(**@args).merge(**kwargs)
 		one.instance_eval(@template, @filename)
 		one.form
 	end
@@ -30,6 +40,10 @@ class FormTemplate
 			@__builder = Nokogiri::XML::Builder.with(@__form)
 		end
 
+		def merge(**kwargs)
+			OneRender.new(**to_h.merge(kwargs))
+		end
+
 		def form!
 			@__type_set = true
 			@__form.type = :form
@@ -74,6 +88,23 @@ class FormTemplate
 			@__form.fields += [f]
 		end
 
+		def context
+			PartialRender.new(@__form, @__builder, **to_h)
+		end
+
+		def render(path, **kwargs)
+			FormTemplate.render(path, context, **kwargs)
+		end
+
+		def to_h
+			instance_variables
+				.reject { |sym| sym.to_s.start_with?("@__") }
+				.each_with_object({}) { |var, acc|
+					name = var.to_s[1..-1]
+					acc[name.to_sym] = instance_variable_get(var)
+				}
+		end
+
 		def xml
 			@__builder
 		end
@@ -83,5 +114,38 @@ class FormTemplate
 
 			@__form
 		end
+
+		class PartialRender < OneRender
+			def initialize(form, builder, **kwargs)
+				kwargs.each do |k, v|
+					instance_variable_set("@#{k}", v)
+				end
+				@__form = form
+				@__builder = builder
+			end
+
+			def merge(**kwargs)
+				PartialRender.new(@__form, @__builder, **to_h.merge(kwargs))
+			end
+
+			# As a partial, we are not a complete form
+			def form; end
+
+			def form!
+				raise "Invalid 'form!' in Partial"
+			end
+
+			def result!
+				raise "Invalid 'result!' in Partial"
+			end
+
+			def title(_)
+				raise "Invalid 'title' in Partial"
+			end
+
+			def instructions(_)
+				raise "Invalid 'instructions' in Partial"
+			end
+		end
 	end
 end

lib/legacy_customer.rb 🔗

@@ -17,7 +17,7 @@ class LegacyCustomer
 
 	def info
 		EMPromise.resolve(nil).then do
-			Info.new(jid: jid, tel: tel)
+			Info.new(tel: tel)
 		end
 	end
 
@@ -26,7 +26,7 @@ class LegacyCustomer
 			info,
 			api
 		]).then do |info, api|
-			AdminInfo.new(info: info, api: api)
+			AdminInfo.new(info: info, jid: jid, api: api)
 		end
 	end
 
@@ -36,30 +36,23 @@ class LegacyCustomer
 
 	class Info
 		value_semantics do
-			jid ProxiedJID, coerce: ProxiedJID.method(:new)
 			tel String
 		end
 
-		def fields
-			[
-				{ var: "JID", value: jid.unproxied.to_s },
-				{ var: "Phone Number", value: tel }
-			]
+		def form
+			FormTemplate.render("legacy_customer_info", info: self)
 		end
 	end
 
 	class AdminInfo
 		value_semantics do
 			info Info
+			jid ProxiedJID, coerce: ProxiedJID.method(:new)
 			api API
 		end
 
-		def fields
-			info.fields + [
-				{ var: "Account Status", value: "Legacy" },
-				{ var: "Cheo JID", value: info.jid.to_s },
-				{ var: "API", value: api.to_s }
-			]
+		def form
+			FormTemplate.render("legacy_customer_admin_info", admin_info: self)
 		end
 	end
 end

sgx_jmp.rb 🔗

@@ -434,10 +434,7 @@ Command.new(
 ) {
 	Command.customer.then(&:info).then do |info|
 		Command.finish do |reply|
-			form = Blather::Stanza::X.new(:result)
-			form.title = "Account Info"
-			form.fields = info.fields
-			reply.command << form
+			reply.command << info.form
 		end
 	end
 }.register(self).then(&CommandList.method(:register))
@@ -675,19 +672,15 @@ Command.new(
 	Command.customer.then do |customer|
 		raise AuthError, "You are not an admin" unless customer&.admin?
 
-		customer_info = CustomerInfoForm.new
 		Command.reply { |reply|
 			reply.allowed_actions = [:next]
-			reply.command << customer_info.picker_form
+			reply.command << FormTemplate.render("customer_picker")
 		}.then { |response|
-			customer_info.find_customer(response)
+			CustomerInfoForm.new.find_customer(response)
 		}.then do |target_customer|
 			target_customer.admin_info.then do |info|
 				Command.finish do |reply|
-					form = Blather::Stanza::X.new(:result)
-					form.title = "Customer Info"
-					form.fields = info.fields
-					reply.command << form
+					reply.command << info.form
 				end
 			end
 		end

test/test_customer_info.rb 🔗

@@ -5,6 +5,14 @@ require "customer_info"
 
 API::REDIS = FakeRedis.new
 CustomerPlan::REDIS = Minitest::Mock.new
+PlanInfo::DB = FakeDB.new(
+	["test"] => [
+		{
+			"start_date" => Time.parse("2020-01-01"),
+			"activation_date" => Time.parse("2021-01-01")
+		}
+	]
+)
 
 class CustomerInfoTest < Minitest::Test
 	def test_info_does_not_crash
@@ -18,8 +26,15 @@ class CustomerInfoTest < Minitest::Test
 			["jmp_customer_auto_top_up_amount-test"]
 		)
 
-		cust = customer(sgx: sgx)
-		assert cust.info.sync.fields
+		CustomerPlan::DB.expect(
+			:query_defer,
+			EMPromise.resolve([{ "start_date" => Time.now }]),
+			[String, ["test"]]
+		)
+
+		cust = customer(sgx: sgx, plan_name: "test_usd")
+
+		assert cust.info.sync.form
 		assert_mock sgx
 	end
 	em :test_info_does_not_crash
@@ -28,6 +43,8 @@ class CustomerInfoTest < Minitest::Test
 		sgx = Minitest::Mock.new
 		sgx.expect(:registered?, false)
 		sgx.expect(:registered?, false)
+		fwd = CustomerFwd.for(uri: "tel:+12223334444", timeout: 15)
+		sgx.expect(:fwd, fwd)
 
 		CustomerPlan::REDIS.expect(
 			:get,
@@ -35,8 +52,14 @@ class CustomerInfoTest < Minitest::Test
 			["jmp_customer_auto_top_up_amount-test"]
 		)
 
-		cust = customer(sgx: sgx)
-		assert cust.admin_info.sync.fields
+		CustomerPlan::DB.expect(
+			:query_defer,
+			EMPromise.resolve([{ "start_date" => Time.now }]),
+			[String, ["test"]]
+		)
+
+		cust = customer(sgx: sgx, plan_name: "test_usd")
+		assert cust.admin_info.sync.form
 		assert_mock sgx
 	end
 	em :test_admin_info_does_not_crash
@@ -46,12 +69,6 @@ class CustomerInfoTest < Minitest::Test
 		sgx.expect(:registered?, false)
 		sgx.expect(:registered?, false)
 
-		CustomerPlan::REDIS.expect(
-			:get,
-			EMPromise.resolve(nil),
-			["jmp_customer_auto_top_up_amount-test"]
-		)
-
 		plan = CustomerPlan.new("test", plan: nil, expires_at: nil)
 		cust = Customer.new(
 			"test",
@@ -59,7 +76,7 @@ class CustomerInfoTest < Minitest::Test
 			plan: plan,
 			sgx: sgx
 		)
-		assert cust.info.sync.fields
+		assert cust.info.sync.form
 		assert_mock sgx
 	end
 	em :test_inactive_info_does_not_crash
@@ -68,12 +85,7 @@ class CustomerInfoTest < Minitest::Test
 		sgx = Minitest::Mock.new
 		sgx.expect(:registered?, false)
 		sgx.expect(:registered?, false)
-
-		CustomerPlan::REDIS.expect(
-			:get,
-			EMPromise.resolve(nil),
-			["jmp_customer_auto_top_up_amount-test"]
-		)
+		sgx.expect(:fwd, CustomerFwd::None.new(uri: nil, timeout: nil))
 
 		plan = CustomerPlan.new("test", plan: nil, expires_at: nil)
 		cust = Customer.new(
@@ -83,7 +95,7 @@ class CustomerInfoTest < Minitest::Test
 			sgx: sgx
 		)
 
-		assert cust.admin_info.sync.fields
+		assert cust.admin_info.sync.form
 		assert_mock sgx
 	end
 	em :test_inactive_admin_info_does_not_crash
@@ -93,7 +105,7 @@ class CustomerInfoTest < Minitest::Test
 			Blather::JID.new("legacy@example.com"),
 			"+12223334444"
 		)
-		assert cust.info.sync.fields
+		assert cust.info.sync.form
 	end
 	em :test_legacy_customer_info_does_not_crash
 
@@ -102,7 +114,13 @@ class CustomerInfoTest < Minitest::Test
 			Blather::JID.new("legacy@example.com"),
 			"+12223334444"
 		)
-		assert cust.admin_info.sync.fields
+		assert cust.admin_info.sync.form
 	end
 	em :test_legacy_customer_admin_info_does_not_crash
+
+	def test_missing_customer_admin_info_does_not_crash
+		cust = CustomerInfoForm::NoCustomer.new
+		assert cust.admin_info.form
+	end
+	em :test_missing_customer_admin_info_does_not_crash
 end