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 => +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,