Merge branch 'cancel-account'

Stephen Paul Weber created

* cancel-account:
  Admin command to cancel customer
  Move Customer factory/extractor to Customer

Change summary

Gemfile                    |   2 
config-schema.dhall        |   2 
config.dhall.sample        |   2 
forms/admin_menu.rb        |   3 
lib/admin_command.rb       |  13 ++++
lib/backend_sgx.rb         |   7 ++
lib/bandwidth_tn_repo.rb   |  22 +++++++
lib/customer.rb            |  11 +++
lib/customer_repo.rb       |  34 +++++------
lib/ibr.rb                 |  11 +++
test/test_admin_command.rb | 115 ++++++++++++++++++++++++++++++++++++++++
test/test_helper.rb        |   2 
12 files changed, 202 insertions(+), 22 deletions(-)

Detailed changes

Gemfile 🔗

@@ -20,7 +20,7 @@ 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 "ruby-bandwidth-iris", git: "https://github.com/singpolyma/ruby-bandwidth-iris", branch: "tn_move"
 gem "sentry-ruby", "<= 4.3.1"
 gem "slim"
 gem "statsd-instrument", git: "https://github.com/singpolyma/statsd-instrument.git", branch: "graphite"

config-schema.dhall 🔗

@@ -19,6 +19,8 @@
 , electrum_notify_url :
     forall (address : Text) -> forall (customer_id : Text) -> Text
 , interac : Text
+, keep_area_codes : List Text
+, keep_area_codes_in : { account : Text, site : Text }
 , notify_admin : Text
 , notify_from : Text
 , ogm_path : Text

config.dhall.sample 🔗

@@ -77,6 +77,8 @@ in
 	notify_from = "+15551234567@example.net",
 	admins = ["test\\40example.com@example.net"],
 	unbilled_targets = ["+14169938000"],
+	keep_area_codes = ["555"],
+	keep_area_codes_in = { account = "", site = "" },
 	upstream_domain = "example.net",
 	approved_domains = toMap { `example.com` = Some "customer_id" }
 }

forms/admin_menu.rb 🔗

@@ -10,6 +10,7 @@ field(
 	options: [
 		{ value: "info", label: "Customer Info" },
 		{ value: "financial", label: "Customer Billing Information" },
-		{ value: "bill_plan", label: "Bill Customer" }
+		{ value: "bill_plan", label: "Bill Customer" },
+		{ value: "cancel_account", label: "Cancel Customer" }
 	]
 )

lib/admin_command.rb 🔗

@@ -75,6 +75,19 @@ class AdminCommand
 		BillPlanCommand.for(@target_customer).call
 	end
 
+	def action_cancel_account
+		m = Blather::Stanza::Message.new
+		m.from = CONFIG[:notify_from]
+		m.body = "Your JMP account has been cancelled."
+		@target_customer.stanza_to(m).then {
+			EMPromise.all([
+				@target_customer.stanza_to(IBR.new(:set).tap(&:remove!)),
+				@target_customer.deregister!,
+				@customer_repo.disconnect_tel(@target_customer)
+			])
+		}
+	end
+
 	def pay_methods(financial_info)
 		reply(FormTemplate.render(
 			"admin_payment_methods",

lib/backend_sgx.rb 🔗

@@ -27,6 +27,13 @@ class BackendSgx
 		IQ_MANAGER.write(ibr)
 	end
 
+	def deregister!
+		ibr = IBR.new(:set, @jid)
+		ibr.from = from_jid
+		ibr.remove!
+		IQ_MANAGER.write(ibr)
+	end
+
 	def stanza(s)
 		s.dup.tap do |stanza|
 			stanza.to = stanza.to.with(domain: jid.domain)

lib/bandwidth_tn_repo.rb 🔗

@@ -3,6 +3,15 @@
 require "ruby-bandwidth-iris"
 
 class BandwidthTnRepo
+	def initialize
+		@move_client =
+			BandwidthIris::Client.new(
+				account_id: CONFIG[:keep_area_codes_in][:account],
+				username: CONFIG[:creds][:username],
+				password: CONFIG[:creds][:password]
+			)
+	end
+
 	def find(tel)
 		BandwidthIris::Tn.new(telephone_number: tel).get_details
 	end
@@ -18,4 +27,17 @@ class BandwidthTnRepo
 	rescue BandwidthIris::Errors::GenericError
 		raise "Could not set CNAM, please contact support"
 	end
+
+	def disconnect(tel, order_name)
+		tn = tel.sub(/\A\+1/, "")
+		if CONFIG[:keep_area_codes].find { |area| tn.start_with?(area) }
+			BandwidthIris::Tn.new({ telephone_number: tn }, @move_client).move(
+				site_id: CONFIG[:keep_area_codes_in][:site],
+				customer_order_id: order_name,
+				source_account_id: CONFIG[:creds][:account]
+			)
+		else
+			BandwidthIris::Disconnect.create(order_name, tn)
+		end
+	end
 end

lib/customer.rb 🔗

@@ -26,13 +26,21 @@ class Customer
 	               :currency, :merchant_account, :plan_name, :minute_limit,
 	               :message_limit, :auto_top_up_amount, :monthly_overage_limit,
 	               :monthly_price, :save_plan!
-	def_delegators :@sgx, :register!, :registered?, :set_ogm_url,
+	def_delegators :@sgx, :deregister!, :register!, :registered?, :set_ogm_url,
 	               :fwd, :transcription_enabled
 	def_delegators :@usage, :usage_report, :message_usage, :incr_message_usage
 	def_delegators :@financials, :payment_methods, :btc_addresses,
 	               :add_btc_address, :declines, :mark_decline,
 	               :transactions
 
+	def self.extract(customer_id, jid, **kwargs)
+		Customer.new(
+			customer_id, jid,
+			plan: CustomerPlan.extract(customer_id, kwargs),
+			**kwargs.slice(:balance, :sgx, :tndetails)
+		)
+	end
+
 	def initialize(
 		customer_id,
 		jid,
@@ -77,6 +85,7 @@ class Customer
 	def stanza_to(stanza)
 		stanza = stanza.dup
 		stanza.to = jid.with(resource: stanza.to&.resource)
+		stanza.from ||= Blather::JID.new("")
 		stanza.from = stanza.from.with(domain: CONFIG[:component][:jid])
 		block_given? ? yield(stanza) : (BLATHER << stanza)
 	end

lib/customer_repo.rb 🔗

@@ -38,7 +38,7 @@ class CustomerRepo
 				redis.get("jmp_customer_jid-#{customer_id}").then do |jid|
 					raise NotFound, "No jid" unless jid
 
-					[customer_id, jid]
+					[customer_id, Blather::JID.new(jid)]
 				end
 			end
 		end
@@ -56,7 +56,7 @@ class CustomerRepo
 				redis.get("jmp_customer_id-#{jid}").then do |customer_id|
 					raise NotFound, "No customer" unless customer_id
 
-					[customer_id, jid]
+					[customer_id, Blather::JID.new(jid)]
 				end
 			end
 		end
@@ -107,6 +107,11 @@ class CustomerRepo
 		end
 	end
 
+	def disconnect_tel(customer)
+		tel = customer.registered?.phone
+		@bandwidth_tn_repo.disconnect(tel, customer.customer_id)
+	end
+
 	def put_lidb_name(customer, lidb_name)
 		@bandwidth_tn_repo.put_lidb_name(customer.registered?.phone, lidb_name)
 	end
@@ -173,29 +178,20 @@ protected
 		WHERE customer_id=$1 LIMIT 1
 	SQL
 
-	def fetch_all(customer_id)
-		EMPromise.all([
-			@sgx_repo.get(customer_id).then { |sgx| { sgx: sgx } },
-			@db.query_one(SQL, customer_id, default: {}),
-			fetch_redis(customer_id)
-		]).then { |all| all.reduce(&:merge) }
-	end
-
 	def tndetails(sgx)
 		return {} unless sgx.registered?
 
 		LazyObject.new { @bandwidth_tn_repo.find(sgx.registered?.phone) || {} }
 	end
 
-	def find_inner(customer_id, jid)
-		set_user.call(customer_id: customer_id, jid: jid)
-		fetch_all(customer_id).then do |data|
-			Customer.new(
-				customer_id, Blather::JID.new(jid),
-				tndetails: tndetails(data[:sgx]),
-				plan: CustomerPlan.extract(customer_id, data),
-				**data.slice(:balance, :sgx, :tndetails)
-			)
+	def find_inner(cid, jid)
+		set_user.call(customer_id: cid, jid: jid)
+		EMPromise.all([
+			@sgx_repo.get(cid).then { |sgx| { sgx: sgx } },
+			@db.query_one(SQL, cid, default: {}),
+			fetch_redis(cid)
+		]).then { |all| all.reduce(&:merge) }.then do |data|
+			Customer.extract(cid, jid, tndetails: tndetails(data[:sgx]), **data)
 		end
 	end
 end

lib/ibr.rb 🔗

@@ -16,6 +16,17 @@ class IBR < Blather::Stanza::Iq::Query
 		!!query.at_xpath("./ns:registered", ns: self.class.registered_ns)
 	end
 
+	def remove!
+		query.children.remove
+		node = Nokogiri::XML::Node.new("remove", document)
+		node.default_namespace = self.class.registered_ns
+		query << node
+	end
+
+	def remove?
+		!!query.at_xpath("./ns:remove", ns: self.class.registered_ns)
+	end
+
 	[
 		"instructions",
 		"username",

test/test_admin_command.rb 🔗

@@ -0,0 +1,115 @@
+# frozen_string_literal: true
+
+require "admin_command"
+
+BackendSgx::IQ_MANAGER = Minitest::Mock.new
+Customer::BLATHER = Minitest::Mock.new
+
+class AdminCommandTest < Minitest::Test
+	def admin_command(tel="+15556667777")
+		sgx = Minitest::Mock.new(OpenStruct.new(
+			registered?: OpenStruct.new(phone: tel)
+		))
+		[sgx, AdminCommand.new(customer(sgx: sgx), CustomerRepo.new)]
+	end
+
+	def test_action_cancel_account
+		sgx, admin = admin_command
+
+		Customer::BLATHER.expect(
+			:<<,
+			EMPromise.resolve(nil),
+			[
+				Matching.new do |m|
+					assert_equal "Your JMP account has been cancelled.", m.body
+					assert_equal "test@example.net", m.to.to_s
+					assert_equal "notify_from@component", m.from.to_s
+				end
+			]
+		)
+
+		Customer::BLATHER.expect(
+			:<<,
+			EMPromise.resolve(nil),
+			[
+				Matching.new do |iq|
+					assert iq.remove?
+					assert_equal "test@example.net", iq.to.to_s
+					assert_equal "component", iq.from.to_s
+				end
+			]
+		)
+
+		sgx.expect(:deregister!, EMPromise.resolve(nil))
+
+		stub_request(
+			:post,
+			"https://dashboard.bandwidth.com/v1.0/accounts//disconnects"
+		).with(
+			body: {
+				name: "test",
+				DisconnectTelephoneNumberOrderType: {
+					TelephoneNumberList: {
+						TelephoneNumber: "5556667777"
+					}
+				}
+			}.to_xml(indent: 0, root: "DisconnectTelephoneNumberOrder")
+		).to_return(status: 200, body: "")
+
+		admin.action_cancel_account.sync
+
+		assert_mock sgx
+		assert_mock BackendSgx::IQ_MANAGER
+		assert_mock Customer::BLATHER
+	end
+	em :test_action_cancel_account
+
+	def test_action_cancel_account_keep_number
+		sgx, admin = admin_command("+15566667777")
+
+		Customer::BLATHER.expect(
+			:<<,
+			EMPromise.resolve(nil),
+			[
+				Matching.new do |m|
+					assert_equal "Your JMP account has been cancelled.", m.body
+					assert_equal "test@example.net", m.to.to_s
+					assert_equal "notify_from@component", m.from.to_s
+				end
+			]
+		)
+
+		Customer::BLATHER.expect(
+			:<<,
+			EMPromise.resolve(nil),
+			[
+				Matching.new do |iq|
+					assert iq.remove?
+					assert_equal "test@example.net", iq.to.to_s
+					assert_equal "component", iq.from.to_s
+				end
+			]
+		)
+
+		sgx.expect(:deregister!, EMPromise.resolve(nil))
+
+		stub_request(
+			:post,
+			"https://dashboard.bandwidth.com/v1.0/accounts/moveto/moveTns"
+		).with(
+			body: {
+				SiteId: "movetosite",
+				CustomerOrderId: "test",
+				SourceAccountId: "test_bw_account",
+				TelephoneNumbers: { TelephoneNumber: "5566667777" }
+			}.to_xml(indent: 0, root: "MoveTnsOrder")
+		).to_return(status: 200, body: "")
+
+		admin.action_cancel_account.sync
+
+		assert_mock sgx
+		assert_mock BackendSgx::IQ_MANAGER
+		assert_mock Customer::BLATHER
+	end
+	em :test_action_cancel_account_keep_number
+end

test/test_helper.rb 🔗

@@ -98,6 +98,8 @@ CONFIG = {
 	},
 	credit_card_url: ->(*) { "http://creditcard.example.com" },
 	electrum_notify_url: ->(*) { "http://notify.example.com" },
+	keep_area_codes: ["556"],
+	keep_area_codes_in: { account: "moveto", site: "movetosite" },
 	upstream_domain: "example.net",
 	approved_domains: {
 		"approved.example.com": nil,