~singpolyma/sgx-jmp

8dd92b96258d14d431e9966d0534631c7fdc0214 — Stephen Paul Weber 9 months ago c6027db + e284468
Merge branch 'admin-actions'

* 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
A forms/admin_add_invites.rb => forms/admin_add_invites.rb +10 -0
@@ 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"
)

M forms/admin_menu.rb => forms/admin_menu.rb +12 -1
@@ 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" }
	]
)

A forms/admin_set_trust_level.rb => forms/admin_set_trust_level.rb +11 -0
@@ 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" }]
)

M forms/customer_picker.rb => forms/customer_picker.rb +7 -0
@@ 6,6 6,13 @@ instructions(
	"information for you"
)

if @notice
	field(
		type: "fixed",
		value: @notice
	)
end

field(
	var: "q",
	type: "text-single",

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

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

A lib/admin_actions/add_invites.rb => lib/admin_actions/add_invites.rb +116 -0
@@ 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

A lib/admin_actions/cancel.rb => lib/admin_actions/cancel.rb +18 -0
@@ 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

A lib/admin_actions/financial.rb => lib/admin_actions/financial.rb +45 -0
@@ 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

A lib/admin_actions/reset_declines.rb => lib/admin_actions/reset_declines.rb +44 -0
@@ 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

A lib/admin_actions/set_trust_level.rb => lib/admin_actions/set_trust_level.rb +143 -0
@@ 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

M lib/admin_command.rb => lib/admin_command.rb +131 -52
@@ 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

M lib/customer.rb => lib/customer.rb +2 -4
@@ 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)

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

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


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

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

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

M lib/trust_level_repo.rb => lib/trust_level_repo.rb +14 -1
@@ 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)

M sgx_jmp.rb => sgx_jmp.rb +1 -8
@@ 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))


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

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

M test/test_customer_info_form.rb => test/test_customer_info_form.rb +3 -12
@@ 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

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