Merge branch 'new-signup-add-credit-card'

Stephen Paul Weber created

* new-signup-add-credit-card:
  Happy path for credit card signup
  Panic should work on any value for error
  Allow getting default payment method, not just index
  Object representing the backend SGX to use
  Stop polluting Object namespace with Blather DSL
  Work in the presence of em-synchrony
  Helper to allow ordering phone number from Bandwidth v2
  Helper to get a promise that resolves after N seconds
  Every payment kind will need the plan, so put it at the top
  Use registration pattern for Payment kinds
  Method to bill the plan of a Customer
  Use Forwardable for simple delegations
  Helper to allow using sync-style code in a Promise context
  New signup: go to web to choose credit card
  OOB helper
  Reject promise on stanza error
  Fix typo

Change summary

.rubocop.yml                         |   3 
Gemfile                              |   2 
config.dhall.sample                  |  14 +
lib/backend_sgx.rb                   |  35 +++++
lib/bandwidth_tn_order.rb            |  87 ++++++++++++
lib/btc_sell_prices.rb               |   4 
lib/customer.rb                      |  73 ++++++++--
lib/electrum.rb                      |   5 
lib/em.rb                            |  20 ++
lib/oob.rb                           |  54 +++++++
lib/payment_methods.rb               |  10 +
lib/plan.rb                          |   4 
lib/registration.rb                  | 115 ++++++++++++++-
sgx_jmp.rb                           |  39 ++++-
test/test_backend_sgx.rb             |  54 +++++++
test/test_bandwidth_tn_order.rb      |  89 ++++++++++++
test/test_buy_account_credit_form.rb |   1 
test/test_customer.rb                |  55 +++++++
test/test_helper.rb                  |  32 ++++
test/test_oob.rb                     |  49 +++++++
test/test_payment_methods.rb         |  12 +
test/test_registration.rb            | 208 ++++++++++++++++++++++++++++-
22 files changed, 902 insertions(+), 63 deletions(-)

Detailed changes

.rubocop.yml 🔗

@@ -45,6 +45,9 @@ Layout/SpaceAroundEqualsInParameterDefault:
 Layout/AccessModifierIndentation:
   EnforcedStyle: outdent
 
+Layout/FirstParameterIndentation:
+  EnforcedStyle: consistent
+
 Style/BlockDelimiters:
   EnforcedStyle: braces_for_chaining
 

Gemfile 🔗

@@ -8,9 +8,11 @@ gem "dhall"
 gem "em-hiredis"
 gem "em-http-request"
 gem "em-pg-client", git: "https://github.com/royaltm/ruby-em-pg-client"
+gem "em-synchrony"
 gem "em_promise.rb"
 gem "eventmachine"
 gem "money-open-exchange-rates"
+gem "ruby-bandwidth-iris"
 
 group(:development) do
 	gem "pry-reload"

config.dhall.sample 🔗

@@ -8,11 +8,12 @@
 		port = 5347
 	},
 	sgx = "component2.localhost",
-	creds = toMap {
-		nick = "userid",
-		username = "token",
-		password = "secret"
+	creds = {
+		account = "00000",
+		username = "dashboard user",
+		password = "dashboard password"
 	},
+	bandwidth_site = "",
 	braintree = {
 		environment = "sandbox",
 		merchant_id = "",
@@ -24,4 +25,9 @@
 		}
 	},
 	plans = ./plans.dhall
+	electrum = ./electrum.dhall,
+	oxr_app_id = "",
+	activation_amount = 15,
+	credit_card_url = \(jid: Text) -> \(customer_id: Text) ->
+		"https://pay.jmp.chat/${jid}/credit_cards?customer_id=${customer_id}"
 }

lib/backend_sgx.rb 🔗

@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+class BackendSgx
+	def initialize(jid=CONFIG[:sgx], creds=CONFIG[:creds])
+		@jid = jid
+		@creds = creds
+	end
+
+	def register!(customer_id, tel)
+		ibr = mkibr(:set, customer_id)
+		ibr.nick = @creds[:account]
+		ibr.username = @creds[:username]
+		ibr.password = @creds[:password]
+		ibr.phone = tel
+		IQ_MANAGER.write(ibr)
+	end
+
+	def registered?(customer_id)
+		IQ_MANAGER.write(mkibr(:get, customer_id)).catch { nil }.then do |result|
+			if result&.respond_to?(:registered?) && result&.registered?
+				result
+			else
+				false
+			end
+		end
+	end
+
+protected
+
+	def mkibr(type, customer_id)
+		ibr = IBR.new(type, @jid)
+		ibr.from = "customer_#{customer_id}@#{CONFIG[:component][:jid]}"
+		ibr
+	end
+end

lib/bandwidth_tn_order.rb 🔗

@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+require "forwardable"
+require "ruby-bandwidth-iris"
+Faraday.default_adapter = :em_synchrony
+
+class BandwidthTNOrder
+	def self.get(id)
+		EM.promise_fiber do
+			self.for(BandwidthIris::Order.get_order_response(
+				# https://github.com/Bandwidth/ruby-bandwidth-iris/issues/44
+				BandwidthIris::Client.new,
+				id
+			))
+		end
+	end
+
+	def self.create(tel, name: "sgx-jmp order #{tel}")
+		bw_tel = tel.sub(/^\+?1?/, "")
+		EM.promise_fiber do
+			Received.new(BandwidthIris::Order.create(
+				name: name,
+				site_id: CONFIG[:bandwidth_site],
+				existing_telephone_number_order_type: {
+					telephone_number_list: { telephone_number: [bw_tel] }
+				}
+			))
+		end
+	end
+
+	def self.for(bandwidth_order)
+		const_get(bandwidth_order.order_status.capitalize).new(bandwidth_order)
+	rescue NameError
+		new(bandwidth_order)
+	end
+
+	extend Forwardable
+	def_delegators :@order, :id
+
+	def initialize(bandwidth_order)
+		@order = bandwidth_order
+	end
+
+	def status
+		@order[:order_status]&.downcase&.to_sym
+	end
+
+	def error_description
+		@order[:error_list]&.dig(:error, :description)
+	end
+
+	def poll
+		raise "Unknown order status: #{status}"
+	end
+
+	class Received < BandwidthTNOrder
+		def status
+			:received
+		end
+
+		def poll
+			EM.promise_timer(1).then do
+				BandwidthTNOrder.get(id).then(&:poll)
+			end
+		end
+	end
+
+	class Complete < BandwidthTNOrder
+		def status
+			:complete
+		end
+
+		def poll
+			EMPromise.resolve(self)
+		end
+	end
+
+	class Failed < BandwidthTNOrder
+		def status
+			:failed
+		end
+
+		def poll
+			raise "Order failed: #{id} #{error_description}"
+		end
+	end
+end

lib/btc_sell_prices.rb 🔗

@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 require "em-http"
+require "em_promise"
+require "em-synchrony/em-http" # For aget vs get
 require "money/bank/open_exchange_rates_bank"
 require "nokogiri"
 
@@ -38,7 +40,7 @@ protected
 		EM::HttpRequest.new(
 			"https://www.canadianbitcoins.com",
 			tls: { verify_peer: true }
-		).get
+		).aget
 	end
 
 	def cad_to_usd

lib/customer.rb 🔗

@@ -1,5 +1,8 @@
 # frozen_string_literal: true
 
+require "forwardable"
+
+require_relative "./ibr"
 require_relative "./payment_methods"
 require_relative "./plan"
 
@@ -22,7 +25,11 @@ class Customer
 		end
 	end
 
+	extend Forwardable
+
 	attr_reader :customer_id, :balance
+	def_delegator :@plan, :name, :plan_name
+	def_delegators :@plan, :currency, :merchant_account
 
 	def initialize(
 		customer_id,
@@ -45,16 +52,13 @@ class Customer
 		)
 	end
 
-	def plan_name
-		@plan.name
-	end
-
-	def currency
-		@plan.currency
-	end
-
-	def merchant_account
-		@plan.merchant_account
+	def bill_plan
+		EM.promise_fiber do
+			DB.transaction do
+				charge_for_plan
+				add_one_month_to_current_plan unless activate_plan_starting_now
+			end
+		end
 	end
 
 	def payment_methods
@@ -69,11 +73,50 @@ class Customer
 		@plan && @expires_at > Time.now
 	end
 
+	def register!(tel)
+		BACKEND_SGX.register!(customer_id, tel)
+	end
+
 	def registered?
-		ibr = IBR.new(:get, CONFIG[:sgx])
-		ibr.from = "customer_#{@customer_id}@#{CONFIG[:component][:jid]}"
-		IQ_MANAGER.write(ibr).catch { nil }.then do |result|
-			result&.respond_to?(:registered?) && result&.registered?
-		end
+		BACKEND_SGX.registered?(customer_id)
+	end
+
+protected
+
+	def charge_for_plan
+		params = [
+			@customer_id,
+			"#{@customer_id}-bill-#{plan_name}-at-#{Time.now.to_i}",
+			-@plan.monthly_price
+		]
+		DB.exec(<<~SQL, params)
+			INSERT INTO transactions
+				(customer_id, transaction_id, created_at, amount)
+			VALUES ($1, $2, LOCALTIMESTAMP, $3)
+		SQL
+	end
+
+	def activate_plan_starting_now
+		DB.exec(<<~SQL, [@customer_id, plan_name]).cmd_tuples.positive?
+			INSERT INTO plan_log
+				(customer_id, plan_name, date_range)
+			VALUES ($1, $2, tsrange(LOCALTIMESTAMP, LOCALTIMESTAMP + '1 month'))
+			ON CONFLICT DO NOTHING
+		SQL
+	end
+
+	def add_one_month_to_current_plan
+		DB.exec(<<~SQL, [@customer_id])
+			UPDATE plan_log SET date_range=range_merge(
+				date_range,
+				tsrange(
+					LOCALTIMESTAMP,
+					GREATEST(upper(date_range), LOCALTIMESTAMP) + '1 month'
+				)
+			)
+			WHERE
+				customer_id=$1 AND
+				date_range && tsrange(LOCALTIMESTAMP, LOCALTIMESTAMP + '1 month')
+		SQL
 	end
 end

lib/electrum.rb 🔗

@@ -1,9 +1,10 @@
 # frozen_string_literal: true
 
 require "bigdecimal"
+require "em-http"
 require "em_promise"
+require "em-synchrony/em-http" # For apost vs post
 require "json"
-require "net/http"
 require "securerandom"
 
 class Electrum
@@ -69,7 +70,7 @@ protected
 		EM::HttpRequest.new(
 			@rpc_uri,
 			tls: { verify_peer: true }
-		).post(
+		).apost(
 			head: {
 				"Authorization" => [@rpc_username, @rpc_password],
 				"Content-Type" => "application/json"

lib/em.rb 🔗

@@ -12,4 +12,24 @@ module EM
 		)
 		promise
 	end
+
+	def self.promise_fiber
+		promise = EMPromise.new
+		Fiber.new {
+			begin
+				promise.fulfill(yield)
+			rescue StandardError => e
+				promise.reject(e)
+			end
+		}.resume
+		promise
+	end
+
+	def self.promise_timer(timeout)
+		promise = EMPromise.new
+		EM.add_timer(timeout) do
+			promise.fulfill(nil)
+		end
+		promise
+	end
 end

lib/oob.rb 🔗

@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require "blather"
+
+class OOB < Blather::XMPPNode
+	register :oob, "jabber:x:oob"
+
+	def self.new(url=nil, desc: nil)
+		new_node = super :x
+
+		case url
+		when Nokogiri::XML::Node
+			new_node.inherit url
+		else
+			new_node.url = url if url
+			new_node.desc = desc if desc
+		end
+
+		new_node
+	end
+
+	def self.find_or_create(parent)
+		if (found_x = parent.find("ns:x", ns: registered_ns).first)
+			x = new(found_x)
+			found_x.remove
+		else
+			x = new
+		end
+		parent << x
+		x
+	end
+
+	def url
+		find("ns:url", ns: self.class.registered_ns).first&.content
+	end
+
+	def url=(u)
+		remove_children :url
+		i = Niceogiri::XML::Node.new(:url, document, namespace)
+		i.content = u
+		self << i
+	end
+
+	def desc
+		find("ns:desc", ns: self.class.registered_ns).first&.content
+	end
+
+	def desc=(d)
+		remove_children :desc
+		i = Niceogiri::XML::Node.new(:desc, document, namespace)
+		i.content = d
+		self << i
+	end
+end

lib/payment_methods.rb 🔗

@@ -19,7 +19,11 @@ class PaymentMethods
 	end
 
 	def default_payment_method
-		@methods.index(&:default?).to_s
+		@methods.find(&:default?)
+	end
+
+	def default_payment_method_index
+		@methods.index(&:default?)&.to_s
 	end
 
 	def to_options
@@ -37,12 +41,14 @@ class PaymentMethods
 			type: "list-single",
 			label: "Credit card to pay with",
 			required: true,
-			value: default_payment_method,
+			value: default_payment_method_index,
 			options: to_options
 		}.merge(kwargs)
 	end
 
 	class Empty
+		def default_payment_method; end
+
 		def to_list_single(*)
 			raise "No payment methods available"
 		end

lib/plan.rb 🔗

@@ -20,6 +20,10 @@ class Plan
 		@plan[:currency]
 	end
 
+	def monthly_price
+		BigDecimal.new(@plan[:monthly_price]) / 1000
+	end
+
 	def merchant_account
 		CONFIG[:braintree][:merchant_accounts].fetch(currency) do
 			raise "No merchant account for this currency"

lib/registration.rb 🔗

@@ -1,12 +1,14 @@
 # frozen_string_literal: true
 
+require_relative "./oob"
+
 class Registration
 	def self.for(iq, customer, web_register_manager)
 		raise "TODO" if customer&.active?
 
 		EMPromise.resolve(customer&.registered?).then do |registered|
 			if registered
-				Registered.new(iq, result.phone)
+				Registered.new(iq, registered.phone)
 			else
 				web_register_manager.choose_tel(iq).then do |(riq, tel)|
 					Activation.for(riq, customer, tel)
@@ -63,7 +65,7 @@ class Registration
 					},
 					{
 						value: "credit_card",
-						label: "Credit Card"
+						label: "Credit Card ($#{CONFIG[:activation_amount]})"
 					},
 					{
 						value: "code",
@@ -103,27 +105,27 @@ class Registration
 	end
 
 	module Payment
+		def self.kinds
+			@kinds ||= {}
+		end
+
 		def self.for(iq, customer, tel)
-			case iq.form.field("activation_method")&.value&.to_s
-			when "bitcoin"
-				Bitcoin.new(iq, customer, tel)
-			when "credit_card"
-				raise "TODO"
-			when "code"
-				raise "TODO"
-			else
+			plan_name = iq.form.field("plan_name").value.to_s
+			customer = customer.with_plan(plan_name)
+			kinds.fetch(iq.form.field("activation_method")&.value&.to_s&.to_sym) {
 				raise "Invalid activation method"
-			end
+			}.call(iq, customer, tel)
 		end
 
 		class Bitcoin
+			Payment.kinds[:bitcoin] = method(:new)
+
 			def initialize(iq, customer, tel)
 				@reply = iq.reply
 				reply.note_type = :info
 				reply.status = :completed
 
-				plan_name = iq.form.field("plan_name").value.to_s
-				@customer = customer.with_plan(plan_name)
+				@customer = customer
 				@customer_id = customer.customer_id
 				@tel = tel
 				@addr = ELECTRUM.createnewaddress
@@ -165,5 +167,92 @@ class Registration
 				end
 			end
 		end
+
+		class CreditCard
+			Payment.kinds[:credit_card] = ->(*args) { self.for(*args) }
+
+			def self.for(iq, customer, tel)
+				customer.payment_methods.then do |payment_methods|
+					if (method = payment_methods.default_payment_method)
+						Activate.new(iq, customer, method, tel)
+					else
+						new(iq, customer, tel)
+					end
+				end
+			end
+
+			def initialize(iq, customer, tel)
+				@customer = customer
+				@tel = tel
+
+				@reply = iq.reply
+				@reply.allowed_actions = [:next]
+				@reply.note_type = :info
+				@reply.note_text = "#{oob.desc}: #{oob.url}"
+			end
+
+			attr_reader :reply
+
+			def oob
+				oob = OOB.find_or_create(@reply.command)
+				oob.url = CONFIG[:credit_card_url].call(
+					@reply.to.stripped.to_s,
+					@customer.customer_id
+				)
+				oob.desc = "Add credit card, then return here and choose next"
+				oob
+			end
+
+			def write
+				COMMAND_MANAGER.write(@reply).then do |riq|
+					CreditCard.for(riq, @customer, @tel)
+				end
+			end
+
+			class Activate
+				def initialize(iq, customer, payment_method, tel)
+					@iq = iq
+					@customer = customer
+					@payment_method = payment_method
+					@tel = tel
+				end
+
+				def write
+					Transaction.sale(
+						@customer.merchant_account,
+						@payment_method,
+						CONFIG[:activation_amount]
+					).then(&:insert).then {
+						@customer.bill_plan
+					}.then do
+						Finish.new(@iq, @customer, @tel).write
+					end
+				end
+			end
+		end
+	end
+
+	class Finish
+		def initialize(iq, customer, tel)
+			@reply = iq.reply
+			@reply.status = :completed
+			@reply.note_type = :info
+			@reply.note_text = "Your JMP account has been activated as #{tel}"
+			@customer = customer
+			@tel = tel
+		end
+
+		def write
+			BandwidthTNOrder.create(@tel).then(&:poll).then(
+				->(_) { @customer.register!(@tel).then { BLATHER << @reply } },
+				lambda do |_|
+					@reply.note_type = :error
+					@reply.note_text =
+						"The JMP number #{@tel} is no longer available, " \
+						"please visit https://jmp.chat and choose another."
+					BLATHER << @reply
+				end
+			)
+		end
 	end
 end

sgx_jmp.rb 🔗

@@ -2,30 +2,45 @@
 
 require "pg/em"
 require "bigdecimal"
+require "blather/client/dsl" # Require this first to not auto-include
 require "blather/client"
 require "braintree"
 require "dhall"
 require "em-hiredis"
 require "em_promise"
+require "ruby-bandwidth-iris"
 
+CONFIG =
+	Dhall::Coder
+	.new(safe: Dhall::Coder::JSON_LIKE + [Symbol, Proc])
+	.load(ARGV[0], transform_keys: ->(k) { k&.to_sym })
+
+singleton_class.class_eval do
+	include Blather::DSL
+	Blather::DSL.append_features(self)
+end
+
+require_relative "lib/backend_sgx"
+require_relative "lib/bandwidth_tn_order"
 require_relative "lib/btc_sell_prices"
 require_relative "lib/buy_account_credit_form"
 require_relative "lib/customer"
 require_relative "lib/electrum"
 require_relative "lib/em"
-require_relative "lib/existing_registration"
 require_relative "lib/payment_methods"
 require_relative "lib/registration"
 require_relative "lib/transaction"
 require_relative "lib/web_register_manager"
 
-CONFIG =
-	Dhall::Coder
-	.new(safe: Dhall::Coder::JSON_LIKE + [Symbol])
-	.load(ARGV[0], transform_keys: ->(k) { k&.to_sym })
-
 ELECTRUM = Electrum.new(**CONFIG[:electrum])
 
+Faraday.default_adapter = :em_synchrony
+BandwidthIris::Client.global_options = {
+	account_id: CONFIG[:creds][:account],
+	username: CONFIG[:creds][:username],
+	password: CONFIG[:creds][:password]
+}
+
 # Braintree is not async, so wrap in EM.defer for now
 class AsyncBraintree
 	def initialize(environment:, merchant_id:, public_key:, private_key:, **)
@@ -62,11 +77,11 @@ class AsyncBraintree
 end
 
 BRAINTREE = AsyncBraintree.new(**CONFIG[:braintree])
-
-Blather::DSL.append_features(self.class)
+BACKEND_SGX = BackendSgx.new
 
 def panic(e)
-	warn "Error raised during event loop: #{e.message}"
+	m = e.respond_to?(:message) ? e.message : e
+	warn "Error raised during event loop: #{e.class}: #{m}"
 	warn e.backtrace if e.respond_to?(:backtrace)
 	exit 1
 end
@@ -130,7 +145,11 @@ class SessionManager
 
 	def fulfill(stanza)
 		id = "#{stanza.from.stripped}/#{stanza.public_send(@id_msg)}"
-		@sessions.delete(id)&.fulfill(stanza)
+		if stanza.error?
+			@sessions.delete(id)&.reject(stanza)
+		else
+			@sessions.delete(id)&.fulfill(stanza)
+		end
 	end
 end
 

test/test_backend_sgx.rb 🔗

@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "backend_sgx"
+
+BackendSgx::IQ_MANAGER = Minitest::Mock.new
+
+class BackendSgxTest < Minitest::Test
+	def setup
+		@sgx = BackendSgx.new
+	end
+
+	def test_registered
+		BackendSgx::IQ_MANAGER.expect(
+			:write,
+			EMPromise.resolve(IBR.new.tap { |ibr| ibr.registered = true }),
+			[Matching.new do |ibr|
+				assert_equal :get, ibr.type
+				assert_equal "customer_test@component", ibr.from.to_s
+			end]
+		)
+		assert @sgx.registered?("test").sync
+	end
+	em :test_registered
+
+	def test_registered_not_registered
+		BackendSgx::IQ_MANAGER.expect(
+			:write,
+			EMPromise.resolve(IBR.new.tap { |ibr| ibr.registered = false }),
+			[Matching.new do |ibr|
+				assert_equal :get, ibr.type
+				assert_equal "customer_test@component", ibr.from.to_s
+			end]
+		)
+		refute @sgx.registered?("test").sync
+	end
+	em :test_registered_not_registered
+
+	def test_register!
+		BackendSgx::IQ_MANAGER.expect(
+			:write,
+			EMPromise.resolve(OpenStruct.new(error?: false)),
+			[Matching.new do |ibr|
+				assert_equal "customer_test@component", ibr.from.to_s
+				assert_equal "test_bw_account", ibr.nick
+				assert_equal "test_bw_user", ibr.username
+				assert_equal "test_bw_password", ibr.password
+				assert_equal "+15555550000", ibr.phone
+			end]
+		)
+		@sgx.register!("test", "+15555550000")
+		BackendSgx::IQ_MANAGER.verify
+	end
+end

test/test_bandwidth_tn_order.rb 🔗

@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "bandwidth_tn_order"
+
+class BandwidthTNOrderTest < Minitest::Test
+	def test_for_received
+		order = BandwidthTNOrder.for(BandwidthIris::Order.new(
+			order_status: "RECEIVED"
+		))
+		assert_kind_of BandwidthTNOrder::Received, order
+	end
+
+	def test_for_complete
+		order = BandwidthTNOrder.for(BandwidthIris::Order.new(
+			order_status: "COMPLETE"
+		))
+		assert_kind_of BandwidthTNOrder::Complete, order
+	end
+
+	def test_for_failed
+		order = BandwidthTNOrder.for(BandwidthIris::Order.new(
+			order_status: "FAILED"
+		))
+		assert_kind_of BandwidthTNOrder::Failed, order
+	end
+
+	def test_for_unknown
+		order = BandwidthTNOrder.for(BandwidthIris::Order.new(
+			order_status: "randOmgarBagE"
+		))
+		assert_kind_of BandwidthTNOrder, order
+		assert_equal :randomgarbage, order.status
+	end
+
+	def test_poll
+		order = BandwidthTNOrder.new(BandwidthIris::Order.new)
+		assert_raises { order.poll.sync }
+	end
+	em :test_poll
+
+	class TestReceived < Minitest::Test
+		def setup
+			@order = BandwidthTNOrder::Received.new(
+				BandwidthIris::Order.new(id: "oid")
+			)
+		end
+
+		def test_poll
+			req = stub_request(
+				:get,
+				"https://dashboard.bandwidth.com/v1.0/accounts//orders/oid"
+			).to_return(status: 200, body: <<~RESPONSE)
+				<OrderResponse>
+					<OrderStatus>COMPLETE</OrderStatus>
+				</OrderResponse>
+			RESPONSE
+			new_order = PromiseMock.new
+			new_order.expect(:poll, nil)
+			@order.poll.sync
+			assert_requested req
+		end
+		em :test_poll
+	end
+
+	class TestComplete < Minitest::Test
+		def setup
+			@order = BandwidthTNOrder::Complete.new(BandwidthIris::Order.new)
+		end
+
+		def test_poll
+			assert_equal @order, @order.poll.sync
+		end
+		em :test_poll
+	end
+
+	class TestFailed < Minitest::Test
+		def setup
+			@order = BandwidthTNOrder::Failed.new(
+				BandwidthIris::Order.new(id: "oid")
+			)
+		end
+
+		def test_poll
+			assert_raises { @order.poll.sync }
+		end
+		em :test_poll
+	end
+end

test/test_buy_account_credit_form.rb 🔗

@@ -42,7 +42,6 @@ class BuyAccountCreditFormTest < Minitest::Test
 					type: "list-single",
 					var: "payment_method",
 					label: "Credit card to pay with",
-					value: "",
 					required: true,
 					options: [{ label: "Test 1234", value: "0" }]
 				),

test/test_customer.rb 🔗

@@ -47,4 +47,59 @@ class CustomerTest < Minitest::Test
 		assert_equal BigDecimal.new(0), customer.balance
 	end
 	em :test_for_customer_id_not_found
+
+	def test_bill_plan_activate
+		Customer::DB.expect(:transaction, nil) do |&block|
+			block.call
+			true
+		end
+		Customer::DB.expect(
+			:exec,
+			nil,
+			[
+				String,
+				Matching.new do |params|
+					params[0] == "test" &&
+					params[1].is_a?(String) &&
+					BigDecimal.new(-1) == params[2]
+				end
+			]
+		)
+		Customer::DB.expect(
+			:exec,
+			OpenStruct.new(cmd_tuples: 1),
+			[String, ["test", "test_usd"]]
+		)
+		Customer.new("test", plan_name: "test_usd").bill_plan.sync
+		Customer::DB.verify
+	end
+	em :test_bill_plan_activate
+
+	def test_bill_plan_update
+		Customer::DB.expect(:transaction, nil) do |&block|
+			block.call
+			true
+		end
+		Customer::DB.expect(
+			:exec,
+			nil,
+			[
+				String,
+				Matching.new do |params|
+					params[0] == "test" &&
+					params[1].is_a?(String) &&
+					BigDecimal.new(-1) == params[2]
+				end
+			]
+		)
+		Customer::DB.expect(
+			:exec,
+			OpenStruct.new(cmd_tuples: 0),
+			[String, ["test", "test_usd"]]
+		)
+		Customer::DB.expect(:exec, nil, [String, ["test"]])
+		Customer.new("test", plan_name: "test_usd").bill_plan.sync
+		Customer::DB.verify
+	end
+	em :test_bill_plan_update
 end

test/test_helper.rb 🔗

@@ -32,16 +32,24 @@ rescue LoadError
 	nil
 end
 
+require "backend_sgx"
+
 CONFIG = {
 	sgx: "sgx",
 	component: {
 		jid: "component"
 	},
+	creds: {
+		account: "test_bw_account",
+		username: "test_bw_user",
+		password: "test_bw_password"
+	},
 	activation_amount: 1,
 	plans: [
 		{
 			name: "test_usd",
-			currency: :USD
+			currency: :USD,
+			monthly_price: 1000
 		},
 		{
 			name: "test_bad_currency",
@@ -52,9 +60,12 @@ CONFIG = {
 		merchant_accounts: {
 			USD: "merchant_usd"
 		}
-	}
+	},
+	credit_card_url: ->(*) { "http://creditcard.example.com" }
 }.freeze
 
+BACKEND_SGX = Minitest::Mock.new(BackendSgx.new)
+
 BLATHER = Class.new {
 	def <<(*); end
 }.new.freeze
@@ -69,6 +80,23 @@ class Matching
 	end
 end
 
+class PromiseMock < Minitest::Mock
+	def then
+		yield self
+	end
+end
+
+module EventMachine
+	class << self
+		# Patch EM.add_timer to be instant in tests
+		alias old_add_timer add_timer
+		def add_timer(*args, &block)
+			args[0] = 0
+			old_add_timer(*args, &block)
+		end
+	end
+end
+
 module Minitest
 	class Test
 		def self.property(m, &block)

test/test_oob.rb 🔗

@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require "oob"
+
+class OOBTest < Minitest::Test
+	def test_new
+		oob = OOB.new
+		assert_kind_of OOB, oob
+		assert_nil oob.url
+		assert_nil oob.desc
+	end
+
+	def test_new_with_node
+		assert_kind_of OOB, OOB.new(Blather::XMPPNode.new)
+	end
+
+	property(:new_with_attrs) { [string(:alnum), string] }
+	def new_with_attrs(u, d)
+		oob = OOB.new(u, desc: d)
+		assert_kind_of OOB, oob
+		assert_equal u, oob.url
+		assert_equal d, oob.desc
+	end
+
+	def test_find_or_create_not_found
+		assert_kind_of OOB, OOB.find_or_create(Blather::XMPPNode.new)
+	end
+
+	def test_find_or_create_found
+		parent = Blather::XMPPNode.new
+		parent << OOB.new("http://example.com")
+		assert_kind_of OOB, OOB.find_or_create(parent)
+		assert_equal "http://example.com", OOB.find_or_create(parent).url
+	end
+
+	property(:url) { string(:alnum) }
+	def url(u)
+		oob = OOB.new
+		oob.url = u
+		assert_equal u, oob.url
+	end
+
+	property(:desc) { string }
+	def desc(d)
+		oob = OOB.new
+		oob.desc = d
+		assert_equal d, oob.desc
+	end
+end

test/test_payment_methods.rb 🔗

@@ -27,7 +27,15 @@ class PaymentMethodsTest < Minitest::Test
 			OpenStruct.new(card_type: "Test", last_4: "1234"),
 			OpenStruct.new(card_type: "Test", last_4: "1234", default?: true)
 		])
-		assert_equal "1", methods.default_payment_method
+		assert_equal methods.fetch(1), methods.default_payment_method
+	end
+
+	def test_default_payment_method_index
+		methods = PaymentMethods.new([
+			OpenStruct.new(card_type: "Test", last_4: "1234"),
+			OpenStruct.new(card_type: "Test", last_4: "1234", default?: true)
+		])
+		assert_equal "1", methods.default_payment_method_index
 	end
 
 	def test_to_options
@@ -52,7 +60,7 @@ class PaymentMethodsTest < Minitest::Test
 				type: "list-single",
 				label: "Credit card to pay with",
 				required: true,
-				value: "",
+				value: nil,
 				options: [
 					{ value: "0", label: "Test 1234" }
 				]

test/test_registration.rb 🔗

@@ -4,8 +4,6 @@ require "test_helper"
 require "registration"
 
 class RegistrationTest < Minitest::Test
-	Customer::IQ_MANAGER = Minitest::Mock.new
-
 	def test_for_activated
 		skip "Registration#for activated not implemented yet"
 		iq = Blather::Stanza::Iq::Command.new
@@ -14,10 +12,10 @@ class RegistrationTest < Minitest::Test
 	em :test_for_activated
 
 	def test_for_not_activated_with_customer_id
-		Customer::IQ_MANAGER.expect(
-			:write,
+		BACKEND_SGX.expect(
+			:registered?,
 			EMPromise.resolve(nil),
-			[Blather::Stanza::Iq]
+			["test"]
 		)
 		web_manager = WebRegisterManager.new
 		web_manager["test@example.com"] = "+15555550000"
@@ -61,6 +59,7 @@ class RegistrationTest < Minitest::Test
 	end
 
 	class PaymentTest < Minitest::Test
+		Customer::BRAINTREE = Minitest::Mock.new
 		Registration::Payment::Bitcoin::ELECTRUM = Minitest::Mock.new
 
 		def test_for_bitcoin
@@ -79,15 +78,30 @@ class RegistrationTest < Minitest::Test
 		end
 
 		def test_for_credit_card
-			skip "CreditCard not implemented yet"
+			braintree_customer = Minitest::Mock.new
+			Customer::BRAINTREE.expect(
+				:customer,
+				braintree_customer
+			)
+			braintree_customer.expect(
+				:find,
+				EMPromise.resolve(OpenStruct.new(payment_methods: [])),
+				["test"]
+			)
 			iq = Blather::Stanza::Iq::Command.new
+			iq.from = "test@example.com"
 			iq.form.fields = [
 				{ var: "activation_method", value: "credit_card" },
 				{ var: "plan_name", value: "test_usd" }
 			]
-			result = Registration::Payment.for(iq, "test", "+15555550000")
+			result = Registration::Payment.for(
+				iq,
+				Customer.new("test"),
+				"+15555550000"
+			).sync
 			assert_kind_of Registration::Payment::CreditCard, result
 		end
+		em :test_for_credit_card
 
 		def test_for_code
 			skip "Code not implemented yet"
@@ -112,12 +126,9 @@ class RegistrationTest < Minitest::Test
 					EMPromise.resolve("testaddr")
 				)
 				iq = Blather::Stanza::Iq::Command.new
-				iq.form.fields = [
-					{ var: "plan_name", value: "test_usd" }
-				]
 				@bitcoin = Registration::Payment::Bitcoin.new(
 					iq,
-					Customer.new("test"),
+					Customer.new("test", plan_name: "test_usd"),
 					"+15555550000"
 				)
 			end
@@ -150,5 +161,180 @@ class RegistrationTest < Minitest::Test
 			end
 			em :test_write
 		end
+
+		class CreditCardTest < Minitest::Test
+			def setup
+				@iq = Blather::Stanza::Iq::Command.new
+				@iq.from = "test@example.com"
+				@credit_card = Registration::Payment::CreditCard.new(
+					@iq,
+					Customer.new("test"),
+					"+15555550000"
+				)
+			end
+
+			def test_for
+				customer = Minitest::Mock.new(Customer.new("test"))
+				customer.expect(
+					:payment_methods,
+					EMPromise.resolve(OpenStruct.new(default_payment_method: :test))
+				)
+				assert_kind_of(
+					Registration::Payment::CreditCard::Activate,
+					Registration::Payment::CreditCard.for(
+						@iq,
+						customer,
+						"+15555550000"
+					).sync
+				)
+			end
+			em :test_for
+
+			def test_reply
+				assert_equal [:execute, :next], @credit_card.reply.allowed_actions
+				assert_equal(
+					"Add credit card, then return here and choose next: " \
+					"http://creditcard.example.com",
+					@credit_card.reply.note.content
+				)
+			end
+		end
+
+		class ActivateTest < Minitest::Test
+			Registration::Payment::CreditCard::Activate::Finish =
+				Minitest::Mock.new
+			Registration::Payment::CreditCard::Activate::Transaction =
+				Minitest::Mock.new
+
+			def test_write
+				transaction = PromiseMock.new
+				transaction.expect(
+					:insert,
+					EMPromise.resolve(nil)
+				)
+				Registration::Payment::CreditCard::Activate::Transaction.expect(
+					:sale,
+					transaction,
+					[
+						"merchant_usd",
+						:test_default_method,
+						CONFIG[:activation_amount]
+					]
+				)
+				iq = Blather::Stanza::Iq::Command.new
+				customer = Minitest::Mock.new(
+					Customer.new("test", plan_name: "test_usd")
+				)
+				customer.expect(:bill_plan, nil)
+				Registration::Payment::CreditCard::Activate::Finish.expect(
+					:new,
+					OpenStruct.new(write: nil),
+					[Blather::Stanza::Iq, customer, "+15555550000"]
+				)
+				Registration::Payment::CreditCard::Activate.new(
+					iq,
+					customer,
+					:test_default_method,
+					"+15555550000"
+				).write.sync
+				Registration::Payment::CreditCard::Activate::Transaction.verify
+				transaction.verify
+				customer.verify
+			end
+			em :test_write
+		end
+	end
+
+	class FinishTest < Minitest::Test
+		Registration::Finish::BLATHER = Minitest::Mock.new
+
+		def setup
+			@finish = Registration::Finish.new(
+				Blather::Stanza::Iq::Command.new,
+				Customer.new("test"),
+				"+15555550000"
+			)
+		end
+
+		def test_write
+			create_order = stub_request(
+				:post,
+				"https://dashboard.bandwidth.com/v1.0/accounts//orders"
+			).to_return(status: 201, body: <<~RESPONSE)
+				<OrderResponse>
+					<Order>
+						<id>test_order</id>
+					</Order>
+				</OrderResponse>
+			RESPONSE
+			stub_request(
+				:get,
+				"https://dashboard.bandwidth.com/v1.0/accounts//orders/test_order"
+			).to_return(status: 201, body: <<~RESPONSE)
+				<OrderResponse>
+					<OrderStatus>COMPLETE</OrderStatus>
+				</OrderResponse>
+			RESPONSE
+			BACKEND_SGX.expect(
+				:register!,
+				EMPromise.resolve(OpenStruct.new(error?: false)),
+				["test", "+15555550000"]
+			)
+			Registration::Finish::BLATHER.expect(
+				:<<,
+				nil,
+				[Matching.new do |reply|
+					assert_equal :completed, reply.status
+					assert_equal :info, reply.note_type
+					assert_equal(
+						"Your JMP account has been activated as +15555550000",
+						reply.note.content
+					)
+				end]
+			)
+			@finish.write.sync
+			assert_requested create_order
+			BACKEND_SGX.verify
+			Registration::Finish::BLATHER.verify
+		end
+		em :test_write
+
+		def test_write_tn_fail
+			create_order = stub_request(
+				:post,
+				"https://dashboard.bandwidth.com/v1.0/accounts//orders"
+			).to_return(status: 201, body: <<~RESPONSE)
+				<OrderResponse>
+					<Order>
+						<id>test_order</id>
+					</Order>
+				</OrderResponse>
+			RESPONSE
+			stub_request(
+				:get,
+				"https://dashboard.bandwidth.com/v1.0/accounts//orders/test_order"
+			).to_return(status: 201, body: <<~RESPONSE)
+				<OrderResponse>
+					<OrderStatus>FAILED</OrderStatus>
+				</OrderResponse>
+			RESPONSE
+			Registration::Finish::BLATHER.expect(
+				:<<,
+				nil,
+				[Matching.new do |reply|
+					assert_equal :completed, reply.status
+					assert_equal :error, reply.note_type
+					assert_equal(
+						"The JMP number +15555550000 is no longer available, " \
+						"please visit https://jmp.chat and choose another.",
+						reply.note.content
+					)
+				end]
+			)
+			@finish.write.sync
+			assert_requested create_order
+			Registration::Finish::BLATHER.verify
+		end
+		em :test_write_tn_fail
 	end
 end