~singpolyma/sgx-jmp

c7c48663714307e3598dc14848e539fb8672b0b7 — Christopher Vollick 8 months ago 6d7c013
Customer Info Forms and More Info

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.
M Gemfile => Gemfile +1 -0
@@ 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"

A forms/admin_info.rb => forms/admin_info.rb +53 -0
@@ 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

A forms/admin_plan_info.rb => forms/admin_plan_info.rb +6 -0
@@ 0,0 1,6 @@
field(
	var: "start_date",
	label: "Customer since",
	description: @plan_info.relative_start_date,
	value: @plan_info.formatted_start_date
)

A forms/customer_info.rb => forms/customer_info.rb +4 -0
@@ 0,0 1,4 @@
result!
title "Account Info"

render "customer_info_partial"

A forms/customer_info_partial.rb => forms/customer_info_partial.rb +29 -0
@@ 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

A forms/customer_picker.rb => forms/customer_picker.rb +14 -0
@@ 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"
)

A forms/legacy_customer_admin_info.rb => forms/legacy_customer_admin_info.rb +29 -0
@@ 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]}"
)

A forms/legacy_customer_info.rb => forms/legacy_customer_info.rb +4 -0
@@ 0,0 1,4 @@
result!
title "Account Info"

render "legacy_customer_info_partial"

A forms/legacy_customer_info_partial.rb => forms/legacy_customer_info_partial.rb +11 -0
@@ 0,0 1,11 @@
field(
	var: "account_status",
	label: "Account Status",
	value: "Legacy"
)

field(
	var: "tel",
	label: "Phone Number",
	value: @info.tel
)

A forms/no_customer_info.rb => forms/no_customer_info.rb +8 -0
@@ 0,0 1,8 @@
result!
title "Customer Info"

field(
	var: "account_status",
	label: "Account Status",
	value: "Not Found"
)

A forms/plan_info.rb => forms/plan_info.rb +38 -0
@@ 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

M lib/customer.rb => lib/customer.rb +2 -2
@@ 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

M lib/customer_fwd.rb => lib/customer_fwd.rb +4 -0
@@ 32,6 32,10 @@ class CustomerFwd
		def to_i
			@timeout
		end

		def to_s
			to_i.to_s
		end
	end

	value_semantics do

M lib/customer_info.rb => lib/customer_info.rb +79 -61
@@ 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

M lib/customer_info_form.rb => lib/customer_info_form.rb +3 -5
@@ 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


M lib/customer_plan.rb => lib/customer_plan.rb +12 -0
@@ 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

M lib/form_template.rb => lib/form_template.rb +68 -4
@@ 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

M lib/legacy_customer.rb => lib/legacy_customer.rb +7 -14
@@ 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

M sgx_jmp.rb => sgx_jmp.rb +4 -11
@@ 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

M test/test_customer_info.rb => test/test_customer_info.rb +38 -20
@@ 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