feat: add command to set port-out PIN

Amolith created

Currently, port-out PINs require manual intervention from support. This
command allows customers to self-manage their port-out PINs. Actual
functionality is delegated to the user's backend. For now, that's just
sgx-bwmsgsv2.

References: https://todo.sr.ht/~singpolyma/soprani.ca/380

Change summary

.rubocop.yml              |   1 
forms/set_port_out_pin.rb |  22 +++++++
lib/backend_sgx.rb        |  65 ++++++++++++++++++++++
lib/command_list.rb       |  16 ++++-
lib/customer.rb           |   2 
sgx_jmp.rb                |  27 +++++++++
test/test_backend_sgx.rb  | 118 +++++++++++++++++++++++++++++++++++++++++
7 files changed, 246 insertions(+), 5 deletions(-)

Detailed changes

.rubocop.yml 🔗

@@ -13,6 +13,7 @@ Metrics/MethodLength:
     - test/*
   CountAsOne:
     - array
+    - hash
 
 Metrics/BlockLength:
   ExcludedMethods:

forms/set_port_out_pin.rb 🔗

@@ -0,0 +1,22 @@
+form!
+title "Set port-out PIN"
+instructions(
+	"Your port-out PIN must be between 4 and 10 alphanumeric characters. " \
+	"It'll be required when porting your number to another carrier." \
+)
+
+field(
+	var: "pin",
+	type: "text-private",
+	label: "Port-out PIN",
+	required: true,
+	description: "Enter 4-10 alphanumeric characters"
+)
+
+field(
+	var: "confirm_pin",
+	type: "text-private",
+	label: "Confirm PIN",
+	required: true,
+	description: "Re-enter the same PIN to confirm"
+)

lib/backend_sgx.rb 🔗

@@ -1,12 +1,17 @@
 # frozen_string_literal: true
 
 require "blather"
+require "blather/stanza/iq/command"
 require "value_semantics/monkey_patched"
 
 require_relative "customer_fwd"
 require_relative "not_loaded"
+require_relative "blather_notify"
+require_relative "form_to_h"
 
 class BackendSgx
+	using FormToH
+
 	value_semantics do
 		jid Blather::JID
 		creds HashOf(Symbol => String)
@@ -46,6 +51,21 @@ class BackendSgx
 		REDIS.set("catapult_ogm_url-#{from_jid}", url)
 	end
 
+	def set_port_out_pin(pin)
+		cmd = build_port_out_command(:execute)
+
+		IQ_MANAGER.write(cmd).then { |reply|
+			session_id = reply.command[:sessionid]
+			submit_cmd = build_submit_form(pin, session_id)
+
+			IQ_MANAGER.write(submit_cmd).then { |submit_reply|
+				validate_submit_reply!(submit_reply)
+			}.catch { |e|
+				handle_pin_submission_error(e)
+			}
+		}
+	end
+
 protected
 
 	def ibr
@@ -56,4 +76,49 @@ protected
 		s.password = creds[:password]
 		s
 	end
+
+	def build_submit_form(pin, session_id)
+		build_port_out_command(:complete, session_id: session_id).tap { |iq|
+			iq.form.type = :submit
+			iq.form.fields = [
+				{ var: "pin", value: pin, type: "text-private" },
+				{ var: "confirm_pin", value: pin, type: "text-private" }
+			]
+		}
+	end
+
+	def build_port_out_command(action, session_id: nil)
+		Blather::Stanza::Iq::Command.new.tap { |iq|
+			iq.to = jid
+			iq.from = from_jid
+			iq.node = "set-port-out-pin"
+			iq.action = action
+			iq.sessionid = session_id if session_id
+		}
+	end
+
+	def validate_submit_reply!(submit_reply)
+		sub_text = submit_reply.note&.text
+		case submit_reply.status
+		when :completed
+			raise sub_text if submit_reply.note&.[]("type") == "error"
+		when :canceled
+			raise CanceledError, reply.note&.text
+		else
+			raise sub_text
+		end
+	end
+
+	def handle_pin_submission_error(e)
+		if e.is_a?(Blather::StanzaError) || e.is_a?(RuntimeError)
+			EMPromise.reject(e)
+		else
+			Sentry.capture_exception(e)
+			EMPromise.reject(
+				RuntimeError.new(
+					"Unable to communicate with service. Please try again later."
+				)
+			)
+		end
+	end
 end

lib/command_list.rb 🔗

@@ -15,16 +15,24 @@ class CommandList
 	end
 
 	def self.args_for(customer, from_jid)
+		registered = customer&.registered
 		args = {
 			from_jid: from_jid, customer: customer,
-			tel: customer&.registered? ? customer&.registered?&.phone : nil,
+			tel: registered&.phone,
 			fwd: customer&.fwd, feature_flags: customer&.feature_flags || [],
-			payment_methods: []
+			payment_methods: [],
+			tn_portable: false
 		}
 		return EMPromise.resolve(args) unless customer&.plan_name
 
-		customer.payment_methods.then do |payment_methods|
-			args.merge(payment_methods: payment_methods)
+		EMPromise.all([
+			customer.payment_methods,
+			args[:tel] && customer.active? && customer.tn_portable?
+		]).then do |payment_methods, eligible|
+			args.merge(
+				payment_methods: payment_methods,
+				tn_portable: eligible
+			)
 		end
 	end
 

lib/customer.rb 🔗

@@ -27,7 +27,7 @@ class Customer
 	               :expires_at, :monthly_price, :save_plan!, :auto_top_up_amount,
 	               :extend_plan, :status
 	def_delegators :@sgx, :deregister!, :register!, :registered?, :set_ogm_url,
-	               :fwd, :transcription_enabled
+	               :fwd, :transcription_enabled, :set_port_out_pin, :tn_portable?
 	def_delegators :@usage, :usage_report, :message_usage, :incr_message_usage,
 	               :calling_charges_this_month
 	def_delegators :@financials, :payment_methods, :declines, :mark_decline,

sgx_jmp.rb 🔗

@@ -877,6 +877,33 @@ Command.new(
 	end
 }.register(self).then(&CommandList.method(:register))
 
+Command.new(
+	"set-port-out-pin",
+	"🔐 Set Port-out PIN",
+	list_for: lambda do |customer:, tn_portable:, **|
+		customer&.active? && tn_portable
+	end
+) {
+	Command.customer.then do |customer|
+		Command.reply { |reply|
+			reply.command << FormTemplate.render("set_port_out_pin")
+		}.then { |iq|
+			pin = iq.form.field("pin")&.value.to_s
+			confirm_pin = iq.form.field("confirm_pin")&.value.to_s
+
+			unless pin.match?(/\A[\w\d]{4,10}\Z/)
+				raise "PIN must be between 4 and 10 alphanumeric characters."
+			end
+
+			raise "PIN and confirm PIN must match." unless pin == confirm_pin
+
+			customer.set_port_out_pin(pin)
+		}.then do
+			Command.finish("Your port-out PIN has been set.")
+		end
+	end
+}.register(self).then(&CommandList.method(:register))
+
 Command.new(
 	"terminate account",
 	"❌ Cancel your account and terminate your phone number",

test/test_backend_sgx.rb 🔗

@@ -4,6 +4,7 @@ require "test_helper"
 require "bwmsgsv2_repo"
 require "backend_sgx"
 require "trivial_backend_sgx_repo"
+require "customer"
 
 BackendSgx::IQ_MANAGER = Minitest::Mock.new
 IBRRepo::IQ_MANAGER = Minitest::Mock.new
@@ -58,4 +59,121 @@ class BackendSgxTest < Minitest::Test
 		BackendSgx::IQ_MANAGER.verify
 	end
 	em :test_register!
+
+	def test_set_port_out_pin_happy_path
+		sgx = TrivialBackendSgxRepo.new(redis: FakeRedis.new).get("test").sync
+		cust = customer("test", sgx: sgx)
+
+		port_out_pin = "74hwsn"
+		session_id = "session_yay_awesome"
+
+		BackendSgx::IQ_MANAGER.expect(
+			:write,
+			EMPromise.resolve(Blather::Stanza::Iq::Command.new.tap { |iq|
+				iq.command[:sessionid] = session_id
+			}),
+			[Matching.new do |iq|
+				assert_equal CONFIG[:sgx], iq.to.to_s
+				assert_equal "customer_test@component", iq.from.to_s
+				assert_equal "set-port-out-pin", iq.node
+				assert_equal :execute, iq.action
+			end]
+		)
+
+		BackendSgx::IQ_MANAGER.expect(
+			:write,
+			EMPromise.resolve(OpenStruct.new(
+				status: :completed,
+				note_type: :info
+			)),
+			[Matching.new do |iq|
+				assert_equal :complete, iq.action
+				assert_equal :submit, iq.form.type
+				assert_equal CONFIG[:sgx], iq.to.to_s
+				assert_equal "customer_test@component", iq.from.to_s
+				assert_equal "set-port-out-pin", iq.node
+				assert_equal session_id, iq.sessionid
+
+				pin_field = iq.form.fields.find { |f| f.var == "pin" }
+				assert_equal port_out_pin, pin_field.value
+				assert_equal "text-private", pin_field.type
+
+				confirm_field = iq.form.fields.find { |f| f.var == "confirm_pin" }
+				assert_equal port_out_pin, confirm_field.value
+				assert_equal "text-private", confirm_field.type
+			end]
+		)
+
+		result = sgx.set_port_out_pin(cust, port_out_pin).sync
+		assert_nil result
+		assert_mock BackendSgx::IQ_MANAGER
+	end
+	em :test_set_port_out_pin_happy_path
+
+	def test_set_port_out_pin_validation
+		sgx = TrivialBackendSgxRepo.new(redis: FakeRedis.new).get("test").sync
+		cust = customer("test", sgx: sgx)
+
+		[
+			["123", "PIN must be 4-10 alphanumeric characters"],
+			["12345678901", "PIN must be 4-10 alphanumeric characters"],
+			["123!", "PIN must be 4-10 alphanumeric characters"],
+			["pin with spaces", "PIN must be 4-10 alphanumeric characters"],
+			["", "PIN must be 4-10 alphanumeric characters"]
+		].each do |invalid_pin, expected_error|
+			session_id = "session_validation_#{invalid_pin.gsub(/[^a-zA-Z0-9]/, '')}"
+
+			BackendSgx::IQ_MANAGER.expect(
+				:write,
+				EMPromise.resolve(Blather::Stanza::Iq::Command.new.tap { |iq|
+					iq.command[:sessionid] = session_id
+				}),
+				[Matching.new do |iq|
+					assert_equal CONFIG[:sgx], iq.to.to_s
+					assert_equal "customer_test@component", iq.from.to_s
+					assert_equal "set-port-out-pin", iq.node
+					assert_equal :execute, iq.action
+				end]
+			)
+
+			note = Struct.new(:text) do
+				def [](key)
+					"error" if key == "type"
+				end
+			end.new(expected_error)
+
+			BackendSgx::IQ_MANAGER.expect(
+				:write,
+				EMPromise.resolve(OpenStruct.new(
+					status: :completed,
+					note: note
+				)),
+				[Matching.new do |iq|
+					assert_equal :complete, iq.action
+					assert_equal :submit, iq.form.type
+					assert_equal CONFIG[:sgx], iq.to.to_s
+					assert_equal "customer_test@component", iq.from.to_s
+					assert_equal "set-port-out-pin", iq.node
+					assert_equal session_id, iq.sessionid
+
+					pin_field = iq.form.fields.find { |f| f.var == "pin" }
+					assert_equal invalid_pin, pin_field.value
+					assert_equal "text-private", pin_field.type
+
+					confirm_field = iq.form.fields.find { |f| f.var == "confirm_pin" }
+					assert_equal invalid_pin, confirm_field.value
+					assert_equal "text-private", confirm_field.type
+				end]
+			)
+
+			error = assert_raises(RuntimeError) {
+				sgx.set_port_out_pin(cust, invalid_pin).sync
+			}
+
+			assert_equal expected_error, error.message
+		end
+
+		assert_mock BackendSgx::IQ_MANAGER
+	end
+	em :test_set_port_out_pin_validation
 end