Merge branch 'admin-actions'

Stephen Paul Weber created

* admin-actions:
  Admin Command Flash
  AddInvites Command
  SetTrustLevel Command
  ResetDeclines Command
  Undo and Undoable Command Harness
  Move Cancel and Financial Admin Commands
  AdminAction and AdminActionRepo
  Invites Repo
  Cleanup Admin Form

Change summary

forms/admin_add_invites.rb           |  10 +
forms/admin_menu.rb                  |  13 +
forms/admin_set_trust_level.rb       |  11 +
forms/customer_picker.rb             |   7 +
lib/admin_action.rb                  | 139 ++++++++++++++++++++++
lib/admin_action_repo.rb             |  86 ++++++++++++++
lib/admin_actions/add_invites.rb     | 116 +++++++++++++++++++
lib/admin_actions/cancel.rb          |  18 ++
lib/admin_actions/financial.rb       |  45 +++++++
lib/admin_actions/reset_declines.rb  |  44 +++++++
lib/admin_actions/set_trust_level.rb | 143 +++++++++++++++++++++++
lib/admin_command.rb                 | 183 +++++++++++++++++++++--------
lib/customer.rb                      |   6 
lib/customer_finacials.rb            |   8 +
lib/customer_info_form.rb            |  19 --
lib/financial_info.rb                |   2 
lib/invites_repo.rb                  |  79 ++++++++++++
lib/registration.rb                  |  19 --
lib/trust_level_repo.rb              |  15 ++
sgx_jmp.rb                           |   9 -
test/test_admin_command.rb           |   1 
test/test_customer_info.rb           |   6 
test/test_customer_info_form.rb      |  15 -
test/test_registration.rb            |   2 
24 files changed, 880 insertions(+), 116 deletions(-)

Detailed changes

forms/admin_add_invites.rb 🔗

@@ -0,0 +1,10 @@
+form!
+instructions "Add Invites"
+
+field(
+	var: "to_add",
+	type: "text-single",
+	datatype: "xs:integer",
+	label: "How many invites to add",
+	value: "0"
+)

forms/admin_menu.rb 🔗

@@ -1,6 +1,13 @@
 form!
 title "Menu"
 
+if @notice
+	field(
+		type: "fixed",
+		value: @notice
+	)
+end
+
 field(
 	var: "action",
 	type: "list-single",
@@ -11,6 +18,10 @@ field(
 		{ value: "info", label: "Customer Info" },
 		{ value: "financial", label: "Customer Billing Information" },
 		{ value: "bill_plan", label: "Bill Customer" },
-		{ value: "cancel_account", label: "Cancel Customer" }
+		{ value: "cancel_account", label: "Cancel Customer" },
+		{ value: "undo", label: "Undo" },
+		{ value: "reset_declines", label: "Reset Declines" },
+		{ value: "set_trust_level", label: "Set Trust Level" },
+		{ value: "add_invites", label: "Add Invites" }
 	]
 )

forms/admin_set_trust_level.rb 🔗

@@ -0,0 +1,11 @@
+form!
+instructions "Set Trust Level"
+
+field(
+	var: "new_trust_level",
+	type: "list-single",
+	label: "Trust Level",
+	value: @manual || "automatic",
+	options: @levels.map { |lvl| { label: lvl, value: lvl } } +
+		[{ label: "Automatic", value: "automatic" }]
+)

forms/customer_picker.rb 🔗

@@ -6,6 +6,13 @@ instructions(
 	"information for you"
 )
 
+if @notice
+	field(
+		type: "fixed",
+		value: @notice
+	)
+end
+
 field(
 	var: "q",
 	type: "text-single",

lib/admin_action.rb 🔗

@@ -0,0 +1,139 @@
+# frozen_string_literal: true
+
+require "delegate"
+
+class AdminAction
+	class NoOp
+		def to_s
+			"NoOp"
+		end
+	end
+
+	module Direction
+		class InvalidDirection < StandardError; end
+
+		def self.for(direction)
+			{
+				forward: Forward,
+				reverse: Reverse,
+				reforward: Reforward
+			}.fetch(direction.to_sym) { raise InvalidDirection }
+		end
+
+		class Forward < SimpleDelegator
+			def with(**kwargs)
+				self.class.new(__getobj__.with(**kwargs))
+			end
+
+			def perform
+				check_forward.then { forward }.then { |x| self.class.new(x) }
+			end
+
+			def to_h
+				super.merge(direction: :forward)
+			end
+
+			def undo
+				Reverse.new(__getobj__.with(parent_id: id))
+			end
+		end
+
+		class Reverse < SimpleDelegator
+			def with(**kwargs)
+				self.class.new(__getobj__.with(**kwargs))
+			end
+
+			def perform
+				check_reverse.then { reverse }.then { |x| self.class.new(x) }
+			end
+
+			def to_s
+				"UNDO(#{parent_id}) #{super}"
+			end
+
+			def to_h
+				super.merge(direction: :reverse)
+			end
+
+			def undo
+				Reforward.new(__getobj__)
+			end
+		end
+
+		class Reforward < Forward
+			def with(**kwargs)
+				self.class.new(__getobj__.with(**kwargs))
+			end
+
+			def to_s
+				"REDO(#{parent_id}) #{super}"
+			end
+
+			def to_h
+				super.merge(direction: :reforward)
+			end
+
+			def undo
+				Reverse.new(__getobj__)
+			end
+		end
+	end
+
+	def self.for(**kwargs)
+		Direction::Forward.new(new(**kwargs))
+	end
+
+	def initialize(**kwargs)
+		@attributes = kwargs
+	end
+
+	def with(**kwargs)
+		self.class.new(@attributes.merge(kwargs))
+	end
+
+	def id
+		@attributes[:id]
+	end
+
+	def parent_id
+		@attributes[:parent_id]
+	end
+
+	def actor_id
+		@attributes[:actor_id]
+	end
+
+	def check_forward
+		EMPromise.resolve(nil)
+	end
+
+	def forward
+		EMPromise.resolve(self)
+	end
+
+	def check_reverse
+		EMPromise.resolve(nil)
+	end
+
+	def reverse
+		EMPromise.resolve(self)
+	end
+
+	def to_h
+		@attributes.merge({
+			class: self.class.to_s.delete_prefix("AdminAction::")
+		}.compact)
+	end
+
+	module Isomorphic
+		def check_reverse
+			to_reverse.check_forward
+		end
+
+		def reverse
+			# We don't want it to return the reversed one
+			# We want it to return itself but with the reverse state
+			to_reverse.forward.then { self }
+		end
+	end
+end

lib/admin_action_repo.rb 🔗

@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+class AdminActionRepo
+	class NotFound < StandardError; end
+
+	def initialize(redis: REDIS)
+		@redis = redis
+	end
+
+	def build(klass:, direction:, **kwargs)
+		dir = AdminAction::Direction.for(direction)
+		dir.new(AdminAction.const_get(klass).new(**kwargs))
+	end
+
+	# I'm using hash subset test for pred
+	# So if you give me any keys I'll find only things where those keys are
+	# present and set to that value
+	def find(limit, max="+", **pred)
+		return EMPromise.resolve([]) unless limit.positive?
+
+		xrevrange(
+			"admin_actions", max: max, min: "-", count: limit
+		).then { |new_max, results|
+			next [] if results.empty?
+
+			selected = results.select { |_id, values| pred < values }
+				.map { |id, values| build(id: id, **rename_class(values)) }
+
+			find(limit - selected.length, "(#{new_max}", **pred)
+				.then { |r| selected + r }
+		}
+	end
+
+	def create(action)
+		push_to_redis(**action.to_h).then { |id|
+			action.with(id: id)
+		}
+	end
+
+protected
+
+	def rename_class(hash)
+		hash.transform_keys { |k| k == :class ? :klass : k }
+	end
+
+	# Turn value into a hash, paper over redis version issue, return earliest ID
+	def xrevrange(stream, min:, max:, count:)
+		min = next_id(min[1..-1]) if min.start_with?("(")
+		max = previous_id(max[1..-1]) if max.start_with?("(")
+
+		@redis.xrevrange(stream, max, min, "COUNT", count).then { |result|
+			next ["+", []] if result.empty?
+
+			[
+				result.last.first, # Reverse order, so this is the lowest ID
+				result.map { |id, values| [id, Hash[*values].transform_keys(&:to_sym)] }
+			]
+		}
+	end
+
+	# Versions of REDIS after 6.2 can just do "(#{current_id}" to make an
+	# exclusive version
+	def previous_id(current_id)
+		time, seq = current_id.split("-")
+		if seq == "0"
+			"#{time.to_i - 1}-18446744073709551615"
+		else
+			"#{time}-#{seq.to_i - 1}"
+		end
+	end
+
+	# Versions of REDIS after 6.2 can just do "(#{current_id}" to make an
+	# exclusive version
+	def next_id(current_id)
+		time, seq = current_id.split("-")
+		if seq == "18446744073709551615"
+			"#{time.to_i + 1}-0"
+		else
+			"#{time}-#{seq.to_i + 1}"
+		end
+	end
+
+	def push_to_redis(**kwargs)
+		@redis.xadd("admin_actions", "*", *kwargs.flatten)
+	end
+end

lib/admin_actions/add_invites.rb 🔗

@@ -0,0 +1,116 @@
+# frozen_string_literal: true
+
+require "value_semantics/monkey_patched"
+require_relative "../admin_action"
+require_relative "../form_to_h"
+
+class AdminAction
+	class AddInvites < AdminAction
+		class Command
+			using FormToH
+
+			def self.for(target_customer, reply:)
+				EMPromise.resolve(
+					new(customer_id: target_customer.customer_id)
+				).then { |x|
+					reply.call(x.form).then(&x.method(:create))
+				}
+			end
+
+			def initialize(**bag)
+				@bag = bag
+			end
+
+			def form
+				FormTemplate.render("admin_add_invites")
+			end
+
+			def create(result)
+				AdminAction::AddInvites.for(
+					**@bag,
+					**result.form.to_h
+						.reject { |_k, v| v == "nil" }.transform_keys(&:to_sym)
+				)
+			end
+		end
+
+		CodesTaken = Struct.new(:codes) do
+			def to_s
+				"One of these tokens already exists: #{codes.join(', ')}"
+			end
+		end
+
+		CodesClaimed = Struct.new(:codes) do
+			def to_s
+				"One of these tokens is already claimed: #{codes.join(', ')}"
+			end
+		end
+
+		class MustHaveCodes
+			def to_s
+				"Action must have list of codes to reverse"
+			end
+		end
+
+		def customer_id
+			@attributes[:customer_id]
+		end
+
+		def to_add
+			@attributes[:to_add].to_i
+		end
+
+		def check_forward
+			EMPromise.resolve(nil)
+				.then { check_noop }
+				.then {
+					next nil if chosen_invites.empty?
+
+					InvitesRepo.new.any_existing?(chosen_invites).then { |taken|
+						EMPromise.reject(CodesTaken.new(chosen_invites)) if taken
+					}
+				}
+		end
+
+		def forward
+			if chosen_invites.empty?
+				InvitesRepo.new.create_n_codes(customer_id, to_add).then { |codes|
+					with(invites: codes.join("\n"))
+				}
+			else
+				InvitesRepo.new.create_codes(customer_id, chosen_invites).then { self }
+			end
+		end
+
+		def check_reverse
+			return EMPromise.reject(MustHaveCodes.new) if chosen_invites.empty?
+
+			InvitesRepo.new.any_claimed?(chosen_invites).then { |claimed|
+				EMPromise.reject(CodesClaimed.new(chosen_invites)) if claimed
+			}
+		end
+
+		def reverse
+			InvitesRepo.new.delete_codes(chosen_invites).then { self }
+		end
+
+		def to_s
+			"add_invites(#{customer_id}): #{chosen_invites.join(', ')}"
+		end
+
+	protected
+
+		def check_noop
+			EMPromise.reject(NoOp.new) if to_add.zero?
+		end
+
+		def check_too_many
+			max = split_invites.length
+			EMPromise.reject(TooMany.new(to_add, max)) if to_add > max
+		end
+
+		def chosen_invites
+			@attributes[:invites]&.split("\n") || []
+		end
+	end
+end

lib/admin_actions/cancel.rb 🔗

@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class AdminAction
+	class CancelCustomer
+		def self.call(customer, customer_repo:, **)
+			m = Blather::Stanza::Message.new
+			m.from = CONFIG[:notify_from]
+			m.body = "Your JMP account has been cancelled."
+			customer.stanza_to(m).then {
+				EMPromise.all([
+					customer.stanza_to(Blather::Stanza::Iq::IBR.new(:set).tap(&:remove!)),
+					customer.deregister!,
+					customer_repo.disconnect_tel(customer)
+				])
+			}
+		end
+	end
+end

lib/admin_actions/financial.rb 🔗

@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require_relative "../admin_action"
+require_relative "../financial_info"
+require_relative "../form_template"
+
+class AdminAction
+	class Financial
+		def self.call(customer_id, reply:, **)
+			new(customer_id, reply: reply).call
+		end
+
+		def initialize(customer_id, reply:)
+			@customer_id = customer_id
+			@reply = reply
+		end
+
+		def call
+			AdminFinancialInfo.for(@customer_id).then do |financial_info|
+				@reply.call(FormTemplate.render(
+					"admin_financial_info",
+					info: financial_info
+				)).then {
+					pay_methods(financial_info)
+				}.then {
+					transactions(financial_info)
+				}
+			end
+		end
+
+		def pay_methods(financial_info)
+			@reply.call(FormTemplate.render(
+				"admin_payment_methods",
+				**financial_info.to_h
+			))
+		end
+
+		def transactions(financial_info)
+			@reply.call(FormTemplate.render(
+				"admin_transaction_list",
+				transactions: financial_info.transactions
+			))
+		end
+	end
+end

lib/admin_actions/reset_declines.rb 🔗

@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require "value_semantics/monkey_patched"
+require_relative "../admin_action"
+
+class AdminAction
+	class ResetDeclines < AdminAction
+		class Command
+			def self.for(target_customer, **)
+				target_customer.declines.then { |declines|
+					AdminAction::ResetDeclines.for(
+						customer_id: target_customer.customer_id,
+						previous_value: declines
+					)
+				}
+			end
+		end
+
+		def customer_id
+			@attributes[:customer_id]
+		end
+
+		def previous_value
+			@attributes[:previous_value].to_i
+		end
+
+		def forward
+			CustomerFinancials.new(customer_id).set_declines(0).then { self }
+		end
+
+		# I could make sure here that they're still set to 0 in the reverse case, so
+		# I know there haven't been any declines since I ran the command, but I
+		# think I don't care actually, and I should just set it back to what it was
+		# and trust the human knows what they're doing
+		def reverse
+			CustomerFinancials.new(customer_id).set_declines(previous_value)
+				.then { self }
+		end
+
+		def to_s
+			"reset_declines(#{customer_id}): #{previous_value} -> 0"
+		end
+	end
+end

lib/admin_actions/set_trust_level.rb 🔗

@@ -0,0 +1,143 @@
+# frozen_string_literal: true
+
+require "value_semantics/monkey_patched"
+require_relative "../admin_action"
+require_relative "../form_to_h"
+require_relative "../trust_level_repo"
+
+class AdminAction
+	class SetTrustLevel < AdminAction
+		include Isomorphic
+
+		class Command
+			using FormToH
+
+			def self.for(target_customer, reply:)
+				TrustLevelRepo.new.find_manual(target_customer.customer_id).then { |man|
+					new(
+						man,
+						customer_id: target_customer.customer_id
+					)
+				}.then { |x|
+					reply.call(x.form).then(&x.method(:create))
+				}
+			end
+
+			def initialize(manual, **bag)
+				@manual = manual
+				@bag = bag.compact
+			end
+
+			def form
+				FormTemplate.render(
+					"admin_set_trust_level",
+					manual: @manual,
+					levels: TrustLevel.constants.map(&:to_s).reject { |x| x == "Manual" }
+				)
+			end
+
+			def create(result)
+				AdminAction::SetTrustLevel.for(
+					previous_trust_level: @manual,
+					**@bag,
+					**result.form.to_h
+						.reject { |_k, v| v == "automatic" }.transform_keys(&:to_sym)
+				)
+			end
+		end
+
+		InvalidLevel = Struct.new(:level, :levels) {
+			def to_s
+				"Trust level invalid: expected #{levels.join(', ')}, got #{level}"
+			end
+		}
+
+		NoMatch = Struct.new(:expected, :actual) {
+			def to_s
+				"Trust level doesn't match: expected #{expected}, got #{actual}"
+			end
+		}
+
+		def initialize(previous_trust_level: nil, new_trust_level: nil, **kwargs)
+			super(
+				previous_trust_level: previous_trust_level.presence,
+				new_trust_level: new_trust_level.presence,
+				**kwargs
+			)
+		end
+
+		def customer_id
+			@attributes[:customer_id]
+		end
+
+		def previous_trust_level
+			@attributes[:previous_trust_level]
+		end
+
+		def new_trust_level
+			@attributes[:new_trust_level]
+		end
+
+		# If I don't check previous_trust_level here I could get into this
+		# situation:
+		# 1. Set from automatic to Customer
+		# 2. Undo
+		# 3. Set from automatic to Paragon
+		# 4. Undo the undo (redo set from automatic to customer)
+		# Now if I don't check previous_trust_level we'll enqueue a thing that says
+		# we've set from manual to customer, but that's not actually what we did! We
+		# set from Paragon to customer. If I undo that now I won't end up back a
+		# paragon, I'll end up at automatic again, which isn't the state I was in a
+		# second ago
+		def check_forward
+			EMPromise.all([
+				check_noop,
+				check_valid,
+				check_consistent
+			])
+		end
+
+		def forward
+			TrustLevelRepo.new.put(customer_id, new_trust_level).then { self }
+		end
+
+		def to_reverse
+			with(
+				previous_trust_level: new_trust_level,
+				new_trust_level: previous_trust_level
+			)
+		end
+
+		def to_s
+			"set_trust_level(#{customer_id}): "\
+			"#{pretty(previous_trust_level)} -> #{pretty(new_trust_level)}"
+		end
+
+	protected
+
+		def check_noop
+			EMPromise.reject(NoOp.new) if new_trust_level == previous_trust_level
+		end
+
+		def check_valid
+			options = TrustLevel.constants.map(&:to_s)
+			return unless new_trust_level && !options.include?(new_trust_level)
+
+			EMPromise.reject(InvalidLevel.new(new_trust_level, options))
+		end
+
+		def check_consistent
+			TrustLevelRepo.new.find_manual(customer_id).then { |trust|
+				unless previous_trust_level == trust
+					EMPromise.reject(
+						NoMatch.new(pretty(previous_trust_level), pretty(trust))
+					)
+				end
+			}
+		end
+
+		def pretty(level)
+			level || "automatic"
+		end
+	end
+end

lib/admin_command.rb 🔗

@@ -1,20 +1,69 @@
 # frozen_string_literal: true
 
+require_relative "admin_action_repo"
+require_relative "admin_actions/add_invites"
+require_relative "admin_actions/cancel"
+require_relative "admin_actions/financial"
+require_relative "admin_actions/reset_declines"
+require_relative "admin_actions/set_trust_level"
 require_relative "bill_plan_command"
 require_relative "customer_info_form"
 require_relative "financial_info"
 require_relative "form_template"
 
 class AdminCommand
-	def initialize(target_customer, customer_repo)
+	def self.for(
+		target_customer,
+		customer_repo,
+		admin_action_repo=AdminActionRepo.new
+	)
+		if target_customer
+			new(target_customer, customer_repo, admin_action_repo)
+		else
+			NoUser.new(customer_repo, admin_action_repo, notice: "Customer Not Found")
+		end
+	end
+
+	class NoUser < AdminCommand
+		def initialize(
+			customer_repo,
+			admin_action_repo=AdminActionRepo.new,
+			notice: nil
+		)
+			@customer_repo = customer_repo
+			@admin_action_repo = admin_action_repo
+			@notice = notice
+		end
+
+		def start(command_action=:execute)
+			return Command.finish(@notice || "Done") if command_action == :complete
+
+			reply(
+				FormTemplate.render("customer_picker", notice: @notice)
+			).then { |response|
+				new_context(response.form.field("q").value, response.action)
+			}
+		end
+	end
+
+	def initialize(
+		target_customer,
+		customer_repo,
+		admin_action_repo=AdminActionRepo.new
+	)
 		@target_customer = target_customer
 		@customer_repo = customer_repo
+		@admin_action_repo = admin_action_repo
 	end
 
-	def start
+	def start(command_action=:execute)
 		@target_customer.admin_info.then { |info|
-			reply(info.form)
-		}.then { menu_or_done }
+			if command_action == :complete
+				Command.finish { |iq| iq.command << info.form }
+			else
+				reply(info.form)
+			end
+		}.then { |response| menu_or_done(response.action) }
 	end
 
 	def reply(form)
@@ -24,10 +73,10 @@ class AdminCommand
 		}
 	end
 
-	def menu_or_done(command_action=:execute)
+	def menu_or_done(command_action=:execute, notice: nil)
 		return Command.finish("Done") if command_action == :complete
 
-		reply(FormTemplate.render("admin_menu")).then do |response|
+		reply(FormTemplate.render("admin_menu", notice: notice)).then do |response|
 			if response.form.field("action")
 				handle(response.form.field("action").value, response.action)
 			end
@@ -36,20 +85,19 @@ class AdminCommand
 
 	def handle(action, command_action)
 		if respond_to?("action_#{action}")
-			send("action_#{action}")
+			send("action_#{action}").then do |notice|
+				menu_or_done(command_action, notice: notice)
+			end
 		else
 			new_context(action)
-		end.then { menu_or_done(command_action) }
+		end
 	end
 
-	def new_context(q)
+	def new_context(q, command_action=:execute)
 		CustomerInfoForm.new(@customer_repo)
 			.parse_something(q).then do |new_customer|
-				if new_customer.respond_to?(:customer_id)
-					AdminCommand.new(new_customer, @customer_repo).start
-				else
-					reply(new_customer.form)
-				end
+				AdminCommand.for(new_customer, @customer_repo, @admin_action_repo)
+					.then { |ac| ac.start(command_action) }
 			end
 	end
 
@@ -58,53 +106,84 @@ class AdminCommand
 		new_context(@target_customer.customer_id)
 	end
 
-	def action_financial
-		AdminFinancialInfo.for(@target_customer).then do |financial_info|
-			reply(FormTemplate.render(
-				"admin_financial_info",
-				info: financial_info
-			)).then {
-				pay_methods(financial_info)
-			}.then {
-				transactions(financial_info)
-			}
-		end
-	end
-
 	def action_bill_plan
 		BillPlanCommand.for(@target_customer).call
 	end
 
-	def notify_customer(body)
-		m = Blather::Stanza::Message.new
-		m.from = CONFIG[:notify_from]
-		m.body = body
-		@target_customer.stanza_to(m)
+	class Undoable
+		def initialize(klass)
+			@klass = klass
+		end
+
+		def call(customer, admin_action_repo:, **)
+			@klass.for(customer, reply: method(:reply)).then { |action|
+				Command.customer.then { |actor|
+					action.with(actor_id: actor.customer_id).perform.then do |performed|
+						admin_action_repo.create(performed)
+					end
+				}
+			}.then { |action| "Action #{action.id}: #{action}" }
+		end
+
+		def reply(form=nil, note_type: nil, note_text: nil)
+			Command.reply { |reply|
+				reply.allowed_actions = [:next, :complete]
+				reply.command << form if form
+				reply.note_type = note_type if note_type
+				reply.note_text = note_text if note_text
+			}
+		end
 	end
 
-	def action_cancel_account
-		notify_customer("Your JMP account has been cancelled.").then {
-			EMPromise.all([
-				@target_customer.stanza_to(
-					Blather::Stanza::Iq::IBR.new(:set).tap(&:remove!)
-				),
-				@target_customer.deregister!,
-				@customer_repo.disconnect_tel(@target_customer)
-			])
-		}
+	class Simple
+		def initialize(klass)
+			@klass = klass
+		end
+
+		def call(customer_id, customer_repo:, **)
+			@klass.call(
+				customer_id,
+				reply: method(:reply),
+				customer_repo: customer_repo
+			).then { nil }
+		end
+
+		def reply(form=nil, note_type: nil, note_text: nil)
+			Command.reply { |reply|
+				reply.allowed_actions = [:next, :complete]
+				reply.command << form if form
+				reply.note_type = note_type if note_type
+				reply.note_text = note_text if note_text
+			}
+		end
 	end
 
-	def pay_methods(financial_info)
-		reply(FormTemplate.render(
-			"admin_payment_methods",
-			**financial_info.to_h
-		))
+	class Undo
+		def self.for(target_customer, **)
+			AdminActionRepo.new
+				.find(1, customer_id: target_customer.customer_id)
+				.then { |actions|
+					raise "No actions found" if actions.empty?
+
+					actions.first.undo
+				}
+		end
 	end
 
-	def transactions(financial_info)
-		reply(FormTemplate.render(
-			"admin_transaction_list",
-			transactions: financial_info.transactions
-		))
+	[
+		[:cancel_account, Simple.new(AdminAction::CancelCustomer)],
+		[:financial, Simple.new(AdminAction::Financial)],
+		[:undo, Undoable.new(Undo)],
+		[:reset_declines, Undoable.new(AdminAction::ResetDeclines::Command)],
+		[:set_trust_level, Undoable.new(AdminAction::SetTrustLevel::Command)],
+		[:add_invites, Undoable.new(AdminAction::AddInvites::Command)]
+	].each do |action, handler|
+		define_method("action_#{action}") do
+			handler.call(
+				@target_customer,
+				admin_action_repo: @admin_action_repo,
+				customer_repo: @customer_repo
+			)
+		end
 	end
 end

lib/customer.rb 🔗

@@ -10,6 +10,7 @@ require_relative "./customer_ogm"
 require_relative "./customer_info"
 require_relative "./customer_finacials"
 require_relative "./backend_sgx"
+require_relative "./invites_repo"
 require_relative "./payment_methods"
 require_relative "./plan"
 require_relative "./proxied_jid"
@@ -75,10 +76,7 @@ class Customer
 	end
 
 	def unused_invites
-		promise = DB.query_defer(<<~SQL, [customer_id])
-			SELECT code FROM unused_invites WHERE creator_id=$1
-		SQL
-		promise.then { |result| result.map { |row| row["code"] } }
+		InvitesRepo.new(DB).unused_invites(customer_id)
 	end
 
 	def stanza_to(stanza)

lib/customer_finacials.rb 🔗

@@ -42,6 +42,14 @@ class CustomerFinancials
 		end
 	end
 
+	def set_declines(num)
+		if num.positive?
+			REDIS.set("jmp_pay_decline-#{@customer_id}", num)
+		else
+			REDIS.del("jmp_pay_decline-#{@customer_id}")
+		end
+	end
+
 	class TransactionInfo
 		value_semantics do
 			transaction_id String

lib/customer_info_form.rb 🔗

@@ -13,29 +13,14 @@ class CustomerInfoForm
 		parse_something(response.form.field("q").value)
 	end
 
-	class NoCustomer
-		def form
-			FormTemplate.render("no_customer_info")
-		end
-
-		def admin_info
-			self
-		end
-
-		def registered?
-			false
-		end
-	end
-
 	def parse_something(value)
-		return EMPromise.resolve(NoCustomer.new) if value.to_s.empty?
+		return EMPromise.resolve(nil) if value.to_s.empty?
 
 		EMPromise.all([
 			find_customer_one(value),
 			find_customer_one(Blather::JID.new(value)),
 			find_customer_one(ProxiedJID.proxy(value)),
-			find_customer_by_phone(value),
-			EMPromise.resolve(NoCustomer.new)
+			find_customer_by_phone(value)
 		]).then { |approaches| approaches.compact.first }
 	end
 

lib/financial_info.rb 🔗

@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 require "value_semantics/monkey_patched"
+require_relative "customer_finacials"
+require_relative "payment_methods"
 
 class AdminFinancialInfo
 	value_semantics do

lib/invites_repo.rb 🔗

@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+class InvitesRepo
+	class Invalid < StandardError; end
+
+	def initialize(db=DB)
+		@db = db
+	end
+
+	def unused_invites(customer_id)
+		promise = @db.query_defer(<<~SQL, [customer_id])
+			SELECT code FROM unused_invites WHERE creator_id=$1
+		SQL
+		promise.then { |result| result.map { |row| row["code"] } }
+	end
+
+	def claim_code(customer_id, code, &blk)
+		EMPromise.resolve(nil).then do
+			@db.transaction do
+				valid = @db.exec(<<~SQL, [customer_id, code]).cmd_tuples.positive?
+					UPDATE invites SET used_by_id=$1, used_at=LOCALTIMESTAMP
+					WHERE code=$2 AND used_by_id IS NULL
+				SQL
+				raise Invalid, "Not a valid invite code: #{code}" unless valid
+
+				blk.call
+			end
+		end
+	end
+
+	CREATE_N_SQL = <<~SQL
+		INSERT INTO invites
+			SELECT unnest(array_fill($1::text, array[$2::int]))
+		RETURNING code
+	SQL
+
+	def create_n_codes(customer_id, num)
+		EMPromise.resolve(nil).then {
+			codes = @db.exec(CREATE_N_SQL, [customer_id, num])
+			raise Invalid, "Failed to fetch codes" unless codes.cmd_tuples.positive?
+
+			codes.map { |row| row["code"] }
+		}
+	end
+
+	def any_existing?(codes)
+		promise = @db.query_one(<<~SQL, [codes])
+			SELECT count(1) FROM invites WHERE code = ANY($1)
+		SQL
+		promise.then { |result| result[:count].positive? }
+	end
+
+	def any_claimed?(codes)
+		promise = @db.query_one(<<~SQL, [codes])
+			SELECT count(1) FROM invites WHERE code = ANY($1) AND used_by_id IS NOT NULL
+		SQL
+		promise.then { |result| result[:count].positive? }
+	end
+
+	def create_codes(customer_id, codes)
+		custs = [customer_id] * codes.length
+		EMPromise.resolve(nil).then {
+			@db.transaction do
+				valid = @db.exec(<<~SQL, [custs, codes]).cmd_tuples.positive?
+					INSERT INTO invites(creator_id, code) SELECT unnest($1), unnest($2)
+				SQL
+				raise Invalid, "Failed to insert one of: #{codes}" unless valid
+			end
+		}
+	end
+
+	def delete_codes(codes)
+		EMPromise.resolve(nil).then {
+			@db.exec(<<~SQL, [codes])
+				DELETE FROM invites WHERE code = ANY($1)
+			SQL
+		}
+	end
+end

lib/registration.rb 🔗

@@ -8,6 +8,7 @@ require_relative "./alt_top_up_form"
 require_relative "./bandwidth_tn_order"
 require_relative "./command"
 require_relative "./em"
+require_relative "./invites_repo"
 require_relative "./oob"
 require_relative "./proxied_jid"
 require_relative "./tel_selections"
@@ -318,8 +319,6 @@ class Registration
 		class InviteCode
 			Payment.kinds[:code] = method(:new)
 
-			class Invalid < StandardError; end
-
 			FIELDS = [{
 				var: "code",
 				type: "text-single",
@@ -353,14 +352,14 @@ class Registration
 					verify(iq.form.field("code")&.value&.to_s)
 				}.then {
 					Finish.new(@customer, @tel)
-				}.catch_only(Invalid, &method(:invalid_code)).then(&:write)
+				}.catch_only(InvitesRepo::Invalid, &method(:invalid_code)).then(&:write)
 			end
 
 		protected
 
 			def guard_too_many_tries
 				REDIS.get("jmp_invite_tries-#{customer_id}").then do |t|
-					raise Invalid, "Too many wrong attempts" if t.to_i > 10
+					raise InvitesRepo::Invalid, "Too many wrong attempts" if t.to_i > 10
 				end
 			end
 
@@ -378,16 +377,8 @@ class Registration
 			end
 
 			def verify(code)
-				EMPromise.resolve(nil).then do
-					DB.transaction do
-						valid = DB.exec(<<~SQL, [customer_id, code]).cmd_tuples.positive?
-							UPDATE invites SET used_by_id=$1, used_at=LOCALTIMESTAMP
-							WHERE code=$2 AND used_by_id IS NULL
-						SQL
-						raise Invalid, "Not a valid invite code: #{code}" unless valid
-
-						@customer.activate_plan_starting_now
-					end
+				InvitesRepo.new(DB).claim_code(customer_id, code) do
+					@customer.activate_plan_starting_now
 				end
 			end
 		end

lib/trust_level_repo.rb 🔗

@@ -1,5 +1,6 @@
 # frozen_string_literal: true
 
+require "lazy_object"
 require "value_semantics/monkey_patched"
 
 require_relative "trust_level"
@@ -12,7 +13,7 @@ class TrustLevelRepo
 
 	def find(customer)
 		EMPromise.all([
-			redis.get("jmp_customer_trust_level-#{customer.customer_id}"),
+			find_manual(customer.customer_id),
 			fetch_settled_amount(customer.customer_id)
 		]).then do |(manual, row)|
 			TrustLevel.for(
@@ -23,6 +24,18 @@ class TrustLevelRepo
 		end
 	end
 
+	def find_manual(customer_id)
+		redis.get("jmp_customer_trust_level-#{customer_id}")
+	end
+
+	def put(customer_id, trust_level)
+		if trust_level
+			redis.set("jmp_customer_trust_level-#{customer_id}", trust_level)
+		else
+			redis.del("jmp_customer_trust_level-#{customer_id}")
+		end
+	end
+
 protected
 
 	def fetch_settled_amount(customer_id)

sgx_jmp.rb 🔗

@@ -746,14 +746,7 @@ Command.new(
 			bandwidth_tn_repo: EmptyRepo.new(BandwidthTnRepo.new) # No CNAM in admin
 		)
 
-		Command.reply { |reply|
-			reply.allowed_actions = [:next]
-			reply.command << FormTemplate.render("customer_picker")
-		}.then { |response|
-			CustomerInfoForm.new(customer_repo).find_customer(response)
-		}.then do |target_customer|
-			AdminCommand.new(target_customer, customer_repo).start
-		end
+		AdminCommand::NoUser.new(customer_repo).start
 	end
 }.register(self).then(&CommandList.method(:register))
 

test/test_admin_command.rb 🔗

@@ -4,6 +4,7 @@ require "admin_command"
 
 BackendSgx::IQ_MANAGER = Minitest::Mock.new
 Customer::BLATHER = Minitest::Mock.new
+AdminActionRepo::REDIS = Minitest::Mock.new
 
 class AdminCommandTest < Minitest::Test
 	def admin_command(tel="+15556667777")

test/test_customer_info.rb 🔗

@@ -129,10 +129,4 @@ class CustomerInfoTest < Minitest::Test
 		assert_mock trust_repo
 	end
 	em :test_inactive_admin_info_does_not_crash
-
-	def test_missing_customer_admin_info_does_not_crash
-		cust = CustomerInfoForm::NoCustomer.new
-		assert cust.admin_info.form
-	end
-	em :test_missing_customer_admin_info_does_not_crash
 end

test/test_customer_info_form.rb 🔗

@@ -48,10 +48,7 @@ class CustomerInfoFormTest < Minitest::Test
 	end
 
 	def test_nothing
-		assert_kind_of(
-			CustomerInfoForm::NoCustomer,
-			@info_form.parse_something("").sync
-		)
+		assert_nil(@info_form.parse_something("").sync)
 	end
 	em :test_nothing
 
@@ -101,19 +98,13 @@ class CustomerInfoFormTest < Minitest::Test
 
 	def test_missing_customer_by_phone
 		result = @info_form.parse_something("+17778889999").sync
-		assert_kind_of(
-			CustomerInfoForm::NoCustomer,
-			result
-		)
+		assert_nil(result)
 	end
 	em :test_missing_customer_by_phone
 
 	def test_garbage
 		result = @info_form.parse_something("garbage").sync
-		assert_kind_of(
-			CustomerInfoForm::NoCustomer,
-			result
-		)
+		assert_nil(result)
 	end
 	em :test_garbage
 end

test/test_registration.rb 🔗

@@ -551,7 +551,7 @@ class RegistrationTest < Minitest::Test
 						["jmp_invite_tries-test"]
 					)
 					Registration::Payment::InviteCode::DB.expect(:transaction, []) do
-						raise Registration::Payment::InviteCode::Invalid, "wut"
+						raise InvitesRepo::Invalid, "wut"
 					end
 					Registration::Payment::InviteCode::REDIS.expect(
 						:incr,