~singpolyma/sgx-jmp

221f4dcd59f3570ff9e82770d7cc378ca62a17ec — Stephen Paul Weber 3 years ago 9c5695e + 8e4c1cc
Merge branch 'new-signup-add-credit-card'

* 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
M .rubocop.yml => .rubocop.yml +3 -0
@@ 45,6 45,9 @@ Layout/SpaceAroundEqualsInParameterDefault:
Layout/AccessModifierIndentation:
  EnforcedStyle: outdent

Layout/FirstParameterIndentation:
  EnforcedStyle: consistent

Style/BlockDelimiters:
  EnforcedStyle: braces_for_chaining


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

M config.dhall.sample => config.dhall.sample +10 -4
@@ 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}"
}

A lib/backend_sgx.rb => lib/backend_sgx.rb +35 -0
@@ 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

A lib/bandwidth_tn_order.rb => lib/bandwidth_tn_order.rb +87 -0
@@ 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

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

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

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

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

A lib/oob.rb => lib/oob.rb +54 -0
@@ 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

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

M lib/plan.rb => lib/plan.rb +4 -0
@@ 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"

M lib/registration.rb => lib/registration.rb +102 -13
@@ 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

M sgx_jmp.rb => sgx_jmp.rb +29 -10
@@ 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


A test/test_backend_sgx.rb => test/test_backend_sgx.rb +54 -0
@@ 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

A test/test_bandwidth_tn_order.rb => test/test_bandwidth_tn_order.rb +89 -0
@@ 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

M test/test_buy_account_credit_form.rb => test/test_buy_account_credit_form.rb +0 -1
@@ 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" }]
				),

M test/test_customer.rb => test/test_customer.rb +55 -0
@@ 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

M test/test_helper.rb => test/test_helper.rb +30 -2
@@ 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)

A test/test_oob.rb => test/test_oob.rb +49 -0
@@ 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

M test/test_payment_methods.rb => test/test_payment_methods.rb +10 -2
@@ 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" }
				]

M test/test_registration.rb => test/test_registration.rb +197 -11
@@ 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