Support data-only registration

Phillip Davis created

Change summary

Gemfile                                |   1 
config-schema.dhall                    |   1 
config.dhall.sample                    |   1 
forms/registration/buy_number.rb       |  30 --
forms/registration/choose_sim_kind.rb  |  12 +
forms/registration/pay_without_code.rb |  28 ++
forms/tn_search.rb                     |   3 
lib/edit_sim_nicknames.rb              |   4 
lib/onboarding.rb                      |  11 +
lib/registration.rb                    | 165 ++++++++++++++--
lib/sim_kind.rb                        |  24 ++
lib/sim_order.rb                       |  67 +++++
lib/tel_selections.rb                  |  51 +++-
sgx_jmp.rb                             |   7 
test/test_helper.rb                    |  13 +
test/test_registration.rb              | 211 ++++++++++++++++++++
test/test_sim_kind.rb                  |  77 +++++++
test/test_sim_order.rb                 | 283 ++++++++++++++-------------
test/test_tel_selections.rb            |   5 
19 files changed, 771 insertions(+), 223 deletions(-)

Detailed changes

Gemfile 🔗

@@ -7,6 +7,7 @@ gem "bandwidth-sdk", "<= 6.1.0"
 gem "blather", git: "https://github.com/singpolyma/blather", branch: "better-ids"
 gem "braintree"
 gem "countries"
+gem "csv"
 gem "dhall", ">= 0.5.3.fixed"
 gem "em-hiredis"
 gem "em-http-request", git: "https://github.com/singpolyma/em-http-request", branch: "fix-letsencrypt"

config-schema.dhall 🔗

@@ -88,6 +88,7 @@
 , sip_host : Text
 , snikket_hosting_api : Text
 , support_link : forall (customer_jid : Text) -> Text
+, public_onboarding_url : Text
 , upstream_domain : Text
 , web : < Inet : { interface : Text, port : Natural } | Unix : Text >
 , web_register : { from : Text, to : Text }

config.dhall.sample 🔗

@@ -115,6 +115,7 @@ in
 	reachability_senders = [ "+14445556666" ],
 	support_link = \(customer_jid: Text) ->
 		"http://localhost:3002/app/accounts/2/contacts/custom_attributes/jid/${customer_jid}",
+	public_onboarding_url = "xmpp:example.com?register",
 	churnbuster = {
 		api_key = "",
 		account_id = ""

forms/registration/buy_number.rb 🔗

@@ -1,30 +0,0 @@
-form!
-
-title "Purchase Number"
-
-instructions <<~I
-	You've selected #{@tel} as your JMP number.
-	This number has a one-time price of $#{'%.2f' % @tel.price}.
-	(If you'd like to pay in another cryptocurrency, currently we recommend using a service like simpleswap.io, morphtoken.com, changenow.io, or godex.io.)
-I
-
-field(
-	var: "activation_method",
-	type: "list-single",
-	label: "Activate using",
-	required: true,
-	options: [
-		{
-			value: "credit_card",
-			label: "Credit Card"
-		},
-		{
-			value: "bitcoin",
-			label: "Bitcoin"
-		},
-		{
-			value: "bch",
-			label: "Bitcoin Cash"
-		}
-	]
-)

forms/registration/choose_sim_kind.rb 🔗

@@ -0,0 +1,12 @@
+form!
+title "Choose SIM Kind"
+
+field(
+	label: "SIM or eSIM Adapter",
+	var: "sim_kind",
+	type: "list-single",
+	options: [
+		{ label: "SIM", value: "sim" },
+		{ label: "eSIM Adapter", value: "esim" }
+	]
+)

forms/registration/pay_without_code.rb 🔗

@@ -0,0 +1,28 @@
+form!
+
+title @title
+
+instructions @instructions
+
+field(
+	var: "activation_method",
+	type: "list-single",
+	label: "Activate using",
+	required: true,
+	options: [
+		{
+			value: "credit_card",
+			label: "Credit Card"
+		},
+		{
+			value: "bitcoin",
+			label: "Bitcoin"
+		},
+		{
+			value: "bch",
+			label: "Bitcoin Cash"
+		}
+	]
+)
+
+instance_eval File.read("#{__dir__}/plan_name.rb") if @currency_required

forms/tn_search.rb 🔗

@@ -26,7 +26,8 @@ field(
 	var: "http://jabber.org/protocol/commands#actions",
 	options: [
 		{ label: "Search", value: "next" },
-		{ label: "Just Show Me Some Numbers", value: "feelinglucky" }
+		{ label: "Just Show Me Some Numbers", value: "feelinglucky" },
+		{ label: "Just Data, Please", value: "data_only" }
 	],
 	value: "next"
 )

lib/edit_sim_nicknames.rb 🔗

@@ -1,6 +1,10 @@
 # frozen_string_literal: true
 
+require_relative "sim_order"
+
 class EditSimNicknames
+	include SIMAction
+
 	# @param [Customer] customer the customer editing the sims
 	# @param [Array<Sim>] sims the sims whose nicknames
 	# 					  are up for editing

lib/onboarding.rb 🔗

@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require "blather"
+
+require_relative "proxied_jid"
+
+Blather::JID.class_eval do
+	def onboarding?
+		ProxiedJID.new(self).unproxied.domain == CONFIG[:onboarding_domain]
+	end
+end

lib/registration.rb 🔗

@@ -15,6 +15,8 @@ require_relative "./parent_code_repo"
 require_relative "./proxied_jid"
 require_relative "./tel_selections"
 require_relative "./welcome_message"
+require_relative "./sim_kind"
+require_relative "./onboarding"
 
 def reload_customer(customer)
 	EMPromise.resolve(nil).then do
@@ -22,14 +24,111 @@ def reload_customer(customer)
 	end
 end
 
+def handle_prev(customer, googleplay_user_id, product)
+	if product.is_a?(SIMKind)
+		Registration::DataOnly.for(customer, product)
+	else
+		Registration::Activation.for(customer, googleplay_user_id, product)
+	end
+end
+
 class Registration
+	class PayForSim
+		def initialize(customer, sim_kind)
+			@customer = customer
+			@sim_kind = sim_kind
+		end
+
+		def write
+			Command.reply { |reply|
+				reply.command << FormTemplate.render(
+					"registration/pay_without_code",
+					product: @sim_kind,
+					instructions: instructions,
+					currency_required: !@customer.currency,
+					title: "Pay for #{@sim_kind.klass.label}"
+				)
+			}.then(&method(:parse)).then(&:write)
+		end
+
+		def instructions
+			cfg = @sim_kind.cfg(@customer.currency)
+			return nil unless cfg
+
+			<<~I
+				To activate your data SIM, you need to deposit $#{'%.2f' % (cfg[:price] / 100.0)} to your balance.
+				(If you'd like to pay in another cryptocurrency, currently we recommend using a service like simpleswap.io, morphtoken.com, changenow.io, or godex.io.)
+			I
+		end
+
+		def parse(iq)
+			unless @customer.currency
+				plan = Plan.for_registration(iq.form.field("plan_name").value.to_s)
+				(@customer = @customer.with_plan(plan.name)).save_plan!
+			end.then { process_payment(iq) }
+		end
+
+		def process_payment(iq)
+			Payment.for(
+				iq, @customer, @sim_kind,
+				price: @sim_kind.cfg(@customer.currency)[:price],
+				maybe_bill: Registration::Payment::JustCharge
+			).write.then { reload_customer(@customer) }.then { |customer|
+				DataOnly.for(customer, @sim_kind)
+			}
+		end
+	end
+
+	class DataOnly
+		def self.for(customer, sim_kind)
+			cfg = sim_kind.cfg(customer.currency)
+			unless cfg && customer.balance > cfg[:price]
+				return PayForSim.new(customer, sim_kind)
+			end
+
+			new(customer, sim_kind)
+		end
+
+		def initialize(customer, sim_kind)
+			@customer = customer
+			@sim_kind = sim_kind
+		end
+
+		def write
+			@sim_kind.klass.for(
+				@customer,
+				**@sim_kind.cfg(@customer.currency)
+			).then { |order|
+				# NOTE: cheogram will swallow any stanza
+				# with type `completed`
+				# `can_complete: false` is needed to prevent customer
+				# from (possibly) permanently losing their eSIM QR code
+				order.process(can_complete: false)
+			}
+		end
+	end
+
+	class RegistrationType
+		def self.for(customer, google_play_userid, product)
+			if product.is_a?(SIMKind)
+				return Registration::DataOnly.for(
+					customer, product
+				)
+			end
+
+			Registration::FinishOrStartActivation.for(
+				customer, google_play_userid, product
+			)
+		end
+	end
+
 	def self.for(customer, google_play_userid, tel_selections)
 		if (reg = customer.registered?)
 			Registered.for(customer, reg.phone)
 		else
-			tel_selections[customer.jid].then(&:choose_tel).then do |tel|
-				reserve_and_continue(tel_selections, customer, tel).then do
-					FinishOrStartActivation.for(customer, google_play_userid, tel)
+			tel_selections[customer.jid].then(&:choose_tel_or_data).then do |product|
+				reserve_and_continue(tel_selections, customer, product).then do
+					RegistrationType.for(customer, google_play_userid, product)
 				end
 			end
 		end
@@ -40,7 +139,7 @@ class Registration
 			tel_selections.delete(customer.jid).then {
 				tel_selections[customer.jid]
 			}.then { |choose|
-				choose.choose_tel(
+				choose.choose_tel_or_data(
 					error: "The JMP number #{tel} is no longer available."
 				)
 			}.then { |n_tel| reserve_and_continue(tel_selections, customer, n_tel) }
@@ -49,8 +148,7 @@ class Registration
 
 	class Registered
 		def self.for(customer, tel)
-			jid = ProxiedJID.new(customer.jid).unproxied
-			if jid.domain == CONFIG[:onboarding_domain]
+			if customer.jid.onboarding?
 				FinishOnboarding.for(customer, tel)
 			else
 				new(tel)
@@ -260,14 +358,14 @@ class Registration
 		end
 
 		def self.for(
-			iq, customer, tel,
+			iq, customer, product,
 			finish: Finish, maybe_bill: MaybeBill,
-			price: CONFIG[:activation_amount] + tel.price
+			price: CONFIG[:activation_amount] + product.price
 		)
 			kinds.fetch(iq.form.field("activation_method")&.value&.to_s&.to_sym) {
 				raise "Invalid activation method"
 			}.call(
-				customer, tel, finish: finish, maybe_bill: maybe_bill, price: price
+				customer, product, finish: finish, maybe_bill: maybe_bill, price: price
 			)
 		end
 
@@ -285,20 +383,22 @@ class Registration
 			end
 
 			def initialize(
-				customer, tel,
-				price: CONFIG[:activation_amount] + tel.price, **
+				customer, product,
+				price: CONFIG[:activation_amount] + product.price, **
 			)
 				@customer = customer
 				@customer_id = customer.customer_id
-				@tel = tel
+				@product = product
 				@price = price
 			end
 
 			def save
-				TEL_SELECTIONS.set(@customer.jid, @tel)
+				return if product.is_a?(SIMKind)
+
+				TEL_SELECTIONS.set(@customer.jid, @product)
 			end
 
-			attr_reader :customer_id, :tel
+			attr_reader :customer_id, :product
 
 			def form(rate, addr)
 				FormTemplate.render(
@@ -323,7 +423,7 @@ class Registration
 			def handle_possible_prev(iq)
 				raise "Action not allowed" unless iq.prev?
 
-				Activation.for(@customer, nil, @tel).then(&:write)
+				handle_prev(@customer, nil, @product)
 			end
 
 			def addr_and_rate
@@ -408,14 +508,14 @@ class Registration
 			Payment.kinds[:credit_card] = method(:new)
 
 			def initialize(
-				customer, tel,
+				customer, product,
 				finish: Finish, maybe_bill: MaybeBill,
-				price: CONFIG[:activation_amount] + tel.price
+				price: CONFIG[:activation_amount] + product.price
 			)
 				@customer = customer
-				@tel = tel
+				@product = product
 				@finish = finish
-				@maybe_bill = maybe_bill.new(customer, tel, finish: finish)
+				@maybe_bill = maybe_bill.new(customer, product, finish: finish)
 				@price = price
 			end
 
@@ -436,7 +536,7 @@ class Registration
 					reply.note_type = :info
 					reply.note_text = "#{toob.desc}: #{toob.url}"
 				}.then do |iq|
-					next Activation.for(@customer, nil, @tel).then(&:write) if iq.prev?
+					handle_prev(@customer, nil, @product) if iq.prev?
 
 					@maybe_bill.call { self }&.then(&:write)
 				end
@@ -586,11 +686,21 @@ class Registration
 			@tel = tel
 		end
 
+		def instructions
+			<<~I
+				You've selected #{@tel} as your JMP number.
+				To activate your account, you can either deposit $#{'%.2f' % (CONFIG[:activation_amount] + @tel.price)} to your balance or enter your referral code if you have one.
+				(If you'd like to pay in another cryptocurrency, currently we recommend using a service like simpleswap.io, morphtoken.com, changenow.io, or godex.io.)
+			I
+		end
+
 		def write
 			Command.reply { |reply|
 				reply.command << FormTemplate.render(
-					"registration/buy_number",
-					tel: @tel
+					"registration/pay_without_code",
+					product: @tel,
+					instructions: instructions,
+					title: "Purchase Number"
 				)
 			}.then(&method(:parse)).then(&:write)
 		end
@@ -637,7 +747,7 @@ class Registration
 			TEL_SELECTIONS.delete(@customer.jid).then {
 				TEL_SELECTIONS[@customer.jid]
 			}.then { |choose|
-				choose.choose_tel(
+				choose.choose_tel_or_data(
 					error: "The JMP number #{@tel} is no longer available."
 				)
 			}.then { |tel| Finish.new(@customer, tel).write }
@@ -903,4 +1013,13 @@ class Registration
 			end
 		end
 	end
+
+	def self.public_onboarding_invite
+		Command.finish { |reply|
+			reply.allowed_actions = [:prev]
+			oob = OOB.find_or_create(reply.command)
+			oob.url = CONFIG[:public_onboarding_url]
+			oob.desc = "Get your XMPP account"
+		}
+	end
 end

lib/sim_kind.rb 🔗

@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class SIMKind
+	attr_reader :klass
+
+	def initialize(variant)
+		@klass =
+			case variant
+			when "sim"
+				SIMOrder
+			when "esim"
+				SIMOrder::ESIM
+			end
+		@variant = variant
+	end
+
+	def self.from_form(form)
+		new(form.field("sim_kind")&.value)
+	end
+
+	def cfg(currency)
+		CONFIG.dig(:sims, @variant.to_sym, currency)
+	end
+end

lib/sim_order.rb 🔗

@@ -3,9 +3,39 @@
 require "bigdecimal/util"
 
 require_relative "low_balance"
+require_relative "registration"
 require_relative "transaction"
+require_relative "onboarding"
+
+module SIMAction
+	# @param can_complete [TrueClass, FalseClass]
+	def process(can_complete: true)
+		Command.reply { |reply|
+			# Users shouldn't be allowed to abort in the middle
+			# of any flow that ends in them getting onboarded
+			if can_complete && !@customer.jid.onboarding?
+				reply.allowed_actions = [:complete]
+			end
+			reply.command << form
+		}.then { |iq| complete(iq) }
+	end
+end
+
+module SIMFinish
+	def finish
+		complete.then { |_iq|
+			if @customer.jid.onboarding?
+				Registration.public_onboarding_invite
+			else
+				Command.finish
+			end
+		}
+	end
+end
 
 class SIMOrder
+	include SIMAction
+
 	def self.for(customer, price:, **kwargs)
 		price = price.to_i / 100.to_d
 		return new(customer, price: price, **kwargs) if customer.balance >= price
@@ -66,25 +96,36 @@ class SIMOrder
 				@customer,
 				sim,
 				Array(form.field("addr").value).join("\n")
-			).complete
+			).write
 		end
 	end
 
 	class Ack
+		include SIMFinish
+
 		def initialize(customer, sim, addr)
 			@customer = customer
 			@sim = sim
 			@addr = addr
 		end
 
-		def complete
+		def write
+			notify.then { finish }
+		end
+
+		def notify
 			@customer.stanza_from(Blather::Stanza::Message.new(
 				Blather::JID.new(""), # Doesn't matter, sgx is set to direct target
 				"SIM ORDER: #{@sim.iccid}\n#{@addr}"
 			))
-			Command.finish(
-				"You will receive an notice from support when your SIM ships."
-			)
+		end
+
+		def complete
+			Command.reply { |iq|
+				iq.note_type = :info
+				iq.note_text =
+					"You will receive a notice from support when your SIM ships."
+			}
 		end
 	end
 
@@ -137,31 +178,37 @@ protected
 					iq.form.field("nickname")&.value.presence || self.class.label
 				)
 			}.then do |sim|
-				ActivationCode.new(sim).complete
+				ActivationCode.new(@customer, sim).finish
 			end
 		end
 	end
 
 	class ActivationCode
+		include SIMFinish
+
+		# @param [Customer] customer the customer who ordered
 		# @param [Sim] sim the sim which the customer
 		# 			   just ordered
-		def initialize(sim)
+		def initialize(customer, sim)
+			@customer = customer
 			@sim = sim
 		end
 
 		def complete
-			Command.finish do |reply|
+			Command.reply { |reply|
 				oob = OOB.find_or_create(reply.command)
 				oob.url = @sim.lpa_code
 				oob.desc = "LPA Activation Code"
 				reply.command << FormTemplate.render(
 					"order_sim/esim_complete", sim: @sim
 				)
-			end
+			}
 		end
 	end
 
 	class WithTopUp
+		include SIMAction
+
 		def initialize(customer, continue, price:, plan:, top_up:)
 			@customer = customer
 			@price = price
@@ -193,6 +240,8 @@ protected
 	end
 
 	class PleaseTopUp
+		include SIMAction
+
 		def initialize(price:, plan:)
 			@price = price
 			@plan = plan

lib/tel_selections.rb 🔗

@@ -8,6 +8,17 @@ require "countries"
 
 require_relative "area_code_repo"
 require_relative "form_template"
+require_relative "sim_kind"
+
+module NullReserve
+	def reserve(*)
+		EMPromise.resolve(nil)
+	end
+end
+
+SIMKind.class_eval do
+	include NullReserve
+end
 
 class TelSelections
 	THIRTY_DAYS = 60 * 60 * 24 * 30
@@ -46,7 +57,7 @@ class TelSelections
 			@tel = ChooseTel::Tn.for_pending_value(tel)
 		end
 
-		def choose_tel
+		def choose_tel_or_data
 			EMPromise.resolve(@tel)
 		end
 	end
@@ -59,18 +70,32 @@ class TelSelections
 			@memcache = memcache
 		end
 
-		def choose_tel(error: nil)
+		def choose_tel_or_data(error: nil)
 			Command.reply { |reply|
 				reply.allowed_actions = [:next]
 				reply.command << FormTemplate.render("tn_search", error: error)
-			}.then do |iq|
-				available = AvailableNumber.for(iq.form, db: @db, memcache: @memcache)
-				next available if available.is_a?(Tn::Bandwidth)
+			}.then { |iq|
+				response = iq.form.field(
+					"http://jabber.org/protocol/commands#actions"
+				)&.value.to_s.strip
+				response == "data_only" ? choose_sim_kind : choose_tel(iq)
+			}
+		end
 
-				choose_from_list(available.tns)
-			rescue Fail
-				choose_tel(error: $!.to_s)
-			end
+		def choose_sim_kind
+			Command.reply { |reply|
+				reply.command << FormTemplate.render("registration/choose_sim_kind")
+				reply.allowed_actions = [:cancel, :next]
+			}.then { |iq| SIMKind.from_form(iq.form) }
+		end
+
+		def choose_tel(iq)
+			available = AvailableNumber.for(iq.form, db: @db, memcache: @memcache)
+			return available if available.is_a?(Tn::Bandwidth)
+
+			choose_from_list(available.tns)
+		rescue Fail
+			choose_tel_or_data(error: $!.to_s)
 		end
 
 		def choose_from_list(tns)
@@ -86,7 +111,7 @@ class TelSelections
 
 		def choose_from_list_result(tns, iq)
 			tel = iq.form.field("tel")&.value
-			return choose_tel if iq.prev? || !tel
+			return choose_tel_or_data if iq.prev? || !tel
 
 			tns.find { |tn| tn.tel == tel } || Tn::Bandwidth.new(Tn.new(tel))
 		end
@@ -296,6 +321,8 @@ class TelSelections
 			end
 
 			class LocalInventory < SimpleDelegator
+				include NullReserve
+
 				attr_reader :price
 
 				def initialize(tn, bandwidth_account_id, price: 0)
@@ -352,10 +379,6 @@ class TelSelections
 					"LocalInventory/#{tel}/#{@bandwidth_account_id}/#{price}"
 				end
 
-				def reserve(*)
-					EMPromise.resolve(nil)
-				end
-
 				def order(db, _customer)
 					# Move always moves to wrong account, oops
 					# Also probably can't move from/to same account

sgx_jmp.rb 🔗

@@ -784,12 +784,7 @@ Command.new(
 			else
 				Command.finish
 			end
-		}.then { |action|
-			Command.reply { |reply|
-				reply.allowed_actions = [:complete]
-				reply.command << action.form
-			}.then(&action.method(:complete))
-		}
+		}.then(&:process)
 	end
 }.register(self).then(&CommandList.method(:register))
 

test/test_helper.rb 🔗

@@ -139,6 +139,16 @@ CONFIG = {
 	],
 	credit_card_url: ->(*) { "http://creditcard.example.com?" },
 	electrum_notify_url: ->(*) { "http://notify.example.com" },
+	sims: {
+		sim: {
+			USD: { price: 500, plan: "1GB" },
+			CAD: { price: 600, plan: "1GB" }
+		},
+		esim: {
+			USD: { price: 300, plan: "500MB" },
+			CAD: { price: 400, plan: "500MB" }
+		}
+	},
 	keep_area_codes: ["556"],
 	keep_area_codes_in: {
 		account: "moveto",
@@ -167,7 +177,8 @@ CONFIG = {
 	bulk_order_tokens: {
 		sometoken: { customer_id: "bulkcustomer", peer_id: "bulkpeer" },
 		lowtoken: { customer_id: "customerid_low", peer_id: "lowpeer" }
-	}
+	},
+	public_onboarding_url: "xmpp:example.com?register"
 }.freeze
 
 def panic(e)

test/test_registration.rb 🔗

@@ -2197,4 +2197,215 @@ class RegistrationTest < Minitest::Test
 		end
 		em :test_write_needs_dns
 	end
+
+	class RegistrationTypeTest < Minitest::Test
+		def test_for_with_sim_kind
+			cust = customer(plan_name: "test_usd").with_balance(1000)
+			sim_kind = SIMKind.new("sim")
+			result = Registration::RegistrationType.for(cust, nil, sim_kind)
+			assert_kind_of Registration::DataOnly, result
+		end
+
+		def test_for_with_esim_kind
+			cust = customer(plan_name: "test_usd").with_balance(1000)
+			sim_kind = SIMKind.new("esim")
+			result = Registration::RegistrationType.for(cust, nil, sim_kind)
+			assert_kind_of Registration::DataOnly, result
+		end
+
+		def test_for_with_telephone_number
+			cust = customer(plan_name: "test_usd", expires_at: Time.now + 999)
+			tel = TelSelections::ChooseTel::Tn.for_pending_value("+15555550000")
+			result = Registration::RegistrationType.for(cust, nil, tel)
+			assert_kind_of Registration::Finish, result
+		end
+	end
+
+	class DataOnlyTest < Minitest::Test
+		def setup
+			@customer = customer(plan_name: "test_usd").with_balance(1000)
+			@sim_kind = SIMKind.new("sim")
+		end
+
+		def test_for_returns_pay_for_sim_when_balance_insufficient
+			low_balance_customer = @customer.with_balance(0.50)
+			result = Registration::DataOnly.for(low_balance_customer, @sim_kind)
+			assert_kind_of Registration::PayForSim, result
+		end
+
+		def test_for_returns_pay_for_sim_when_config_missing
+			no_currency_customer = customer(currency: nil)
+			result = Registration::DataOnly.for(no_currency_customer, @sim_kind)
+			assert_kind_of Registration::PayForSim, result
+		end
+
+		def test_for_returns_data_only_when_balance_sufficient
+			result =
+				Registration::DataOnly.for(@customer, @sim_kind)
+			assert_kind_of Registration::DataOnly, result
+		end
+
+		def test_write_calls_sim_kind_for_and_process
+			result = execute_command do
+				Command::COMMAND_MANAGER.expect(
+					:write,
+					EMPromise.reject(:test_result),
+					[Matching.new do |iq|
+						assert_equal iq.form.title, "Order SIM"
+						assert(
+							iq.form.instructions.start_with?("Our SIMs provide a plan")
+						)
+					end]
+				)
+				Registration::DataOnly.new(
+					@customer,
+					@sim_kind
+				).write.catch { |e| e }.sync
+			end
+			assert_equal result, :test_result
+			assert_mock Command::COMMAND_MANAGER
+		end
+		em :test_write_calls_sim_kind_for_and_process
+	end
+
+	class PayForSimTest < Minitest::Test
+		Command::COMMAND_MANAGER = Minitest::Mock.new
+		Registration::PayForSim::Payment = Minitest::Mock.new
+
+		def setup
+			@customer = customer(plan_name: "test_usd")
+			@sim_kind = SIMKind.new("sim")
+			@pay_for_sim = Registration::PayForSim.new(@customer, @sim_kind)
+		end
+
+		def test_write_displays_payment_form
+			iq = Blather::Stanza::Iq::Command.new
+			iq.form.fields = [
+				{ var: "activation_method", value: "credit_card" }
+			]
+
+			result = execute_command do
+				Command::COMMAND_MANAGER.expect(
+					:write,
+					EMPromise.resolve(iq),
+					[Matching.new do |reply|
+						assert_equal :form, reply.form.type
+						assert_equal "Pay for SIM", reply.form.title
+					end]
+				)
+
+				ejector_mock = Minitest::Mock.new
+				ejector_mock.expect(
+					:write,
+					EMPromise.reject(:test_result),
+					[]
+				)
+				Registration::PayForSim::Payment.expect(
+					:for,
+					ejector_mock
+				) do |*, price:, maybe_bill:|
+					assert_equal 500, price
+					assert_equal(
+						Registration::Payment::JustCharge,
+						maybe_bill
+					)
+				end
+				@pay_for_sim.write.catch { |e| e }
+			end
+
+			assert_equal :test_result, result
+			assert_mock Command::COMMAND_MANAGER
+		end
+		em :test_write_displays_payment_form
+
+		def test_write_with_no_currency_shows_plan_selection
+			no_currency_customer = customer(currency: nil)
+			pay_for_sim = Registration::PayForSim.new(no_currency_customer, @sim_kind)
+
+			iq = Blather::Stanza::Iq::Command.new
+			iq.form.fields = [
+				{ var: "plan_name", value: "test_usd" },
+				{ var: "activation_method", value: "credit_card" }
+			]
+
+			result = execute_command do
+				Command::COMMAND_MANAGER.expect(
+					:write,
+					EMPromise.resolve(iq),
+					[Matching.new do |reply|
+						assert_equal :form, reply.form.type
+						assert_equal "Pay for SIM", reply.form.title
+					end]
+				)
+
+				CustomerPlan::DB.expect(
+					:exec_defer,
+					EMPromise.resolve(nil),
+					[String, ["test", "test_usd", nil]]
+				)
+				ejector_mock = Minitest::Mock.new
+				ejector_mock.expect(
+					:write,
+					EMPromise.reject(:test_result),
+					[]
+				)
+				Registration::PayForSim::Payment.expect(
+					:for,
+					ejector_mock
+				) do |*, price:, maybe_bill:|
+					assert_equal 500, price
+					assert_equal(
+						Registration::Payment::JustCharge,
+						maybe_bill
+					)
+				end
+				pay_for_sim.write.catch { |e| e }
+			end
+
+			assert_equal :test_result, result
+			assert_mock Command::COMMAND_MANAGER
+			assert_mock CustomerPlan::DB
+		end
+		em :test_write_with_no_currency_shows_plan_selection
+
+		def test_process_payment_uses_just_charge_strategy
+			iq = Blather::Stanza::Iq::Command.new
+			iq.form.fields = [
+				{ var: "activation_method", value: "credit_card" }
+			]
+
+			reloaded_customer = @customer.with_balance(100).with_plan("test_usd")
+
+			result = execute_command do |exe|
+				payment = Minitest::Mock.new
+				payment.expect(
+					:write,
+					EMPromise.resolve(nil),
+					[]
+				)
+				Registration::PayForSim::Payment.expect(
+					:for,
+					payment
+				) do |_, customer, sim_kind, price:, maybe_bill:|
+					assert_equal @customer, customer
+					assert_equal @sim_kind, sim_kind
+					assert_equal Registration::Payment::JustCharge, maybe_bill
+					assert_equal 500, price
+				end
+				exe.customer_repo.expect(
+					:find,
+					EMPromise.resolve(reloaded_customer),
+					[@customer.customer_id]
+				)
+
+				result_ = @pay_for_sim.process_payment(iq).sync
+				assert_mock payment
+				assert_mock Command.execution.customer_repo
+				assert_mock Registration::PayForSim::Payment
+				result_
+			end
+			assert_kind_of Registration::PayForSim, result
+		end
+		em :test_process_payment_uses_just_charge_strategy
+	end
 end

test/test_sim_kind.rb 🔗

@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+require "ostruct"
+require "customer"
+
+require_relative "../lib/sim_order"
+require_relative "../lib/sim_kind"
+
+class SIMKindTest < Minitest::Test
+	def setup
+		@sim_form = Blather::Stanza::Iq::Command.new.tap { |iq|
+			iq.form.fields = [{ var: "sim_kind", value: "sim" }]
+		}
+
+		@esim_form = Blather::Stanza::Iq::Command.new.tap { |iq|
+			iq.form.fields = [{ var: "sim_kind", value: "esim" }]
+		}
+
+		@invalid_form = Blather::Stanza::Iq::Command.new.tap { |iq|
+			iq.form.fields = [{ var: "sim_kind", value: "invalid" }]
+		}
+
+		@missing_form = Blather::Stanza::Iq::Command.new.tap { |iq|
+			iq.form.fields = []
+		}
+	end
+
+	def test_initialize_with_sim
+		kind = SIMKind.new("sim")
+		assert_equal SIMOrder, kind.klass
+	end
+
+	def test_initialize_with_esim
+		kind = SIMKind.new("esim")
+		assert_equal SIMOrder::ESIM, kind.klass
+	end
+
+	def test_initialize_with_invalid_variant
+		kind = SIMKind.new("invalid")
+		assert_nil kind.klass
+	end
+
+	def test_initialize_with_nil
+		kind = SIMKind.new(nil)
+		assert_nil kind.klass
+	end
+
+	def test_from_form_with_sim
+		kind = SIMKind.from_form(@sim_form.form)
+		assert_equal SIMOrder, kind.klass
+	end
+
+	def test_from_form_with_esim
+		kind = SIMKind.from_form(@esim_form.form)
+		assert_equal SIMOrder::ESIM, kind.klass
+	end
+
+	def test_cfg_returns_nil_when_customer_currency_nil
+		kind = SIMKind.new("sim")
+		cust = customer(currency: nil)
+		assert_nil kind.cfg(cust.currency)
+	end
+
+	def test_cfg_returns_nil_when_config_missing_variant
+		kind = SIMKind.new("invalid")
+		cust = customer(currency: :USD)
+		assert_nil kind.cfg(cust.currency)
+	end
+
+	def test_cfg_returns_nil_when_config_missing_currency
+		kind = SIMKind.new("sim")
+		cust = customer(currency: :EUR)
+		assert_nil kind.cfg(cust.currency)
+	end
+end

test/test_sim_order.rb 🔗

@@ -62,157 +62,164 @@ class SIMOrderTest < Minitest::Test
 	end
 
 	def test_complete_nil_nick
-		customer = Minitest::Mock.new(customer("123", balance: 100))
-		customer.expect(
-			:stanza_from,
-			EMPromise.resolve(nil),
-			[Blather::Stanza::Message]
-		)
-
-		SIMOrder::DB.expect(
-			:transaction,
-			EMPromise.resolve(@sim)
-		) do |&blk|
-			blk.call
-		end
-
-		Transaction::DB.expect(
-			:exec,
-			nil,
-			[
-				String,
-				Matching.new { |params|
-					assert_equal "123", params[0]
-					assert_equal "tx123", params[1]
-					assert_kind_of Time, params[2]
-					assert_kind_of Time, params[3]
-					assert_in_delta(-(@price / 100).to_d, params[4], 0.05)
-					assert_equal "SIM Activation 123", params[5]
-				}
-			]
-		)
-
-		@sim_repo.expect(:available, EMPromise.resolve(@sim), [])
-		@sim_repo.expect(:put_owner, nil, [@sim, customer, "SIM"])
-		@sim_repo.expect(
-			:refill,
-			EMPromise.resolve(OpenStruct.new(ack: "success", transaction_id: "tx123"))
-		) do |refill_sim, amount_mb:|
-			@sim == refill_sim && amount_mb == 1024
-		end
+		execute_command {
+			customer = Minitest::Mock.new(customer("123", balance: 100))
+			customer.expect(
+				:stanza_from,
+				EMPromise.resolve(nil),
+				[Blather::Stanza::Message]
+			)
 
-		order_form = Blather::Stanza::Iq::Command.new.tap { |iq|
-			iq.form.fields = [
-				{ var: "addr", value: "123 Main St" },
-				{ var: "nickname", value: nil }
-			]
-		}
+			SIMOrder::DB.expect(
+				:transaction,
+				EMPromise.resolve(@sim)
+			) do |&blk|
+				blk.call
+			end
+
+			Transaction::DB.expect(
+				:exec,
+				nil,
+				[
+					String,
+					Matching.new { |params|
+						assert_equal "123", params[0]
+						assert_equal "tx123", params[1]
+						assert_kind_of Time, params[2]
+						assert_kind_of Time, params[3]
+						assert_in_delta(-(@price / 100).to_d, params[4], 0.05)
+						assert_equal "SIM Activation 123", params[5]
+					}
+				]
+			)
 
-		blather = Minitest::Mock.new
-		blather.expect(
-			:<<,
-			EMPromise.resolve(:test_result),
-			[Matching.new do |reply|
-				assert_equal :completed, reply.status
-				assert_equal :info, reply.note_type
-				assert_equal(
-					"You will receive an notice from support when your SIM ships.",
-					reply.note.content
+			@sim_repo.expect(:available, EMPromise.resolve(@sim), [])
+			@sim_repo.expect(:put_owner, nil, [@sim, customer, "SIM"])
+			@sim_repo.expect(
+				:refill,
+				EMPromise.resolve(
+					OpenStruct.new(ack: "success", transaction_id: "tx123")
 				)
-			end]
-		)
-		sim_order = SIMOrder.for(customer, price: @price, plan: @plan_name)
-		sim_order.instance_variable_set(:@sim_repo, @sim_repo)
+			) do |refill_sim, amount_mb:|
+				@sim == refill_sim && amount_mb == 1024
+			end
+
+			order_form = Blather::Stanza::Iq::Command.new.tap { |iq|
+				iq.form.fields = [
+					{ var: "addr", value: "123 Main St" },
+					{ var: "nickname", value: nil }
+				]
+			}
+
+			Command::COMMAND_MANAGER.expect(
+				:write,
+				EMPromise.reject(:test_result),
+				[Matching.new do |reply|
+					assert_equal :executing, reply.status
+					assert_equal :info, reply.note_type
+					assert_equal(
+						"You will receive a notice from support when your SIM ships.",
+						reply.note.content
+					)
+				end]
+			)
 
-		assert_equal(
-			:test_result,
-			execute_command(blather: blather) { sim_order.complete(order_form) }
-		)
+			sim_order = SIMOrder.for(customer, price: @price, plan: @plan_name)
+			sim_order.instance_variable_set(:@sim_repo, @sim_repo)
+
+			assert_equal(
+				:test_result,
+				sim_order.complete(order_form).catch { |e| e }.sync
+			)
 
-		assert_mock Transaction::DB
-		assert_mock SIMOrder::DB
-		assert_mock customer
-		assert_mock @sim_repo
-		assert_mock blather
+			assert_mock Transaction::DB
+			assert_mock SIMOrder::DB
+			assert_mock customer
+			assert_mock @sim_repo
+		}
 	end
 	em :test_complete_nil_nick
 
 	def test_complete_nick_present
-		customer = Minitest::Mock.new(customer("123", balance: 100))
-		customer.expect(
-			:stanza_from,
-			EMPromise.resolve(nil),
-			[Blather::Stanza::Message]
-		)
-		Transaction::DB.expect(
-			:exec,
-			nil,
-			[
-				String,
-				Matching.new { |params|
-					assert_equal "123", params[0]
-					assert_equal "tx123", params[1]
-					assert_kind_of Time, params[2]
-					assert_kind_of Time, params[3]
-					assert_in_delta(-(@price / 100).to_d, params[4], 0.05)
-					assert_equal "SIM Activation 123", params[5]
-				}
-			]
-		)
+		execute_command {
+			Command::COMMAND_MANAGER.expect(
+				:write,
+				EMPromise.reject(:test_result),
+				[Matching.new do |reply|
+					assert_equal :info, reply.note_type
+					assert_equal :executing, reply.status
+					assert_equal(
+						"You will receive a notice from support when your SIM ships.",
+						reply.note.content
+					)
+				end]
+			)
 
-		SIMOrder::DB.expect(
-			:transaction,
-			@sim
-		) do |&blk|
-			blk.call
-		end
+			customer = Minitest::Mock.new(customer("123", balance: 100))
+			customer.expect(
+				:stanza_from,
+				EMPromise.resolve(nil),
+				[Blather::Stanza::Message]
+			)
+			Transaction::DB.expect(
+				:exec,
+				nil,
+				[
+					String,
+					Matching.new { |params|
+						assert_equal "123", params[0]
+						assert_equal "tx123", params[1]
+						assert_kind_of Time, params[2]
+						assert_kind_of Time, params[3]
+						assert_in_delta(-(@price / 100).to_d, params[4], 0.05)
+						assert_equal "SIM Activation 123", params[5]
+					}
+				]
+			)
 
-		@sim_repo.expect(:available, EMPromise.resolve(@sim), [])
-		@sim_repo.expect(
-			:put_owner,
-			nil,
-			[@sim, customer, "test_nick"]
-		)
-		@sim_repo.expect(
-			:refill,
-			EMPromise.resolve(OpenStruct.new(ack: "success", transaction_id: "tx123"))
-		) do |refill_sim, amount_mb:|
-			@sim == refill_sim && amount_mb == 1024
-		end
+			SIMOrder::DB.expect(
+				:transaction,
+				@sim
+			) do |&blk|
+				blk.call
+			end
+
+			@sim_repo.expect(:available, EMPromise.resolve(@sim), [])
+			@sim_repo.expect(
+				:put_owner,
+				nil,
+				[@sim, customer, "test_nick"]
+			)
+			@sim_repo.expect(
+				:refill,
+				EMPromise.resolve(
+					OpenStruct.new(ack: "success", transaction_id: "tx123")
+				)
+			) do |refill_sim, amount_mb:|
+				@sim == refill_sim && amount_mb == 1024
+			end
+
+			order_form = Blather::Stanza::Iq::Command.new.tap { |iq|
+				iq.form.fields = [
+					{ var: "addr", value: "123 Main St" },
+					{ var: "nickname", value: "test_nick" }
+				]
+			}
+
+			sim_order =
+				SIMOrder.for(customer, price: @price, plan: @plan_name)
+			sim_order.instance_variable_set(:@sim_repo, @sim_repo)
 
-		order_form = Blather::Stanza::Iq::Command.new.tap { |iq|
-			iq.form.fields = [
-				{ var: "addr", value: "123 Main St" },
-				{ var: "nickname", value: "test_nick" }
-			]
-		}
+			assert_equal(
+				:test_result,
+				sim_order.complete(order_form).catch { |e| e }.sync
+			)
 
-		blather = Minitest::Mock.new
-		blather.expect(
-			:<<,
-			EMPromise.resolve(:test_result),
-			[Matching.new do |reply|
-				assert_equal :completed, reply.status
-				assert_equal :info, reply.note_type
-				assert_equal(
-					"You will receive an notice from support when your SIM ships.",
-					reply.note.content
-				)
-			end]
-		)
-		sim_order = SIMOrder.for(customer, price: @price, plan: @plan_name)
-		sim_order.instance_variable_set(:@sim_repo, @sim_repo)
-
-		assert_equal(
-			:test_result,
-			execute_command(blather: blather) { sim_order.complete(order_form) }
-  )
-
-		assert_mock Transaction::DB
-		assert_mock SIMOrder::DB
-		assert_mock customer
-		assert_mock @sim_repo
-		assert_mock blather
+			assert_mock Transaction::DB
+			assert_mock SIMOrder::DB
+			assert_mock customer
+			assert_mock @sim_repo
+		}
 	end
 	em :test_complete_nick_present
 end

test/test_tel_selections.rb 🔗

@@ -28,7 +28,10 @@ class TelSelectionsTest < Minitest::Test
 			jid,
 			TelSelections::ChooseTel::Tn.for_pending_value("+15555550000")
 		).sync
-		assert_equal "+15555550000", @manager[jid].then(&:choose_tel).sync.tel
+		assert_equal(
+			"+15555550000",
+			@manager[jid].then(&:choose_tel_or_data).sync.tel
+		)
 	end
 	em :test_choose_tel_have_tel