Merge branch 'monthly-billing'

Stephen Paul Weber created

* monthly-billing:
  Allow the DB to notify us to bill a customer
  Command.execution setter
  Bill plan command
  Clearer name for lock bypass factory

Change summary

forms/admin_menu.rb      |  3 +
lib/admin_command.rb     |  5 +++
lib/bill_plan_command.rb | 69 ++++++++++++++++++++++++++++++++++++++++++
lib/command.rb           |  6 +++
lib/customer.rb          |  3 +
lib/db_notification.rb   | 17 ++++++++++
lib/dummy_command.rb     | 17 ++++++++++
lib/low_balance.rb       |  4 +-
sgx_jmp.rb               | 28 ++++++++++++++---
9 files changed, 142 insertions(+), 10 deletions(-)

Detailed changes

forms/admin_menu.rb 🔗

@@ -9,6 +9,7 @@ field(
 	description: "or put a new customer info",
 	options: [
 		{ value: "info", label: "Customer Info" },
-		{ value: "financial", label: "Customer Billing Information" }
+		{ value: "financial", label: "Customer Billing Information" },
+		{ value: "bill_plan", label: "Bill Customer" }
 	]
 )

lib/admin_command.rb 🔗

@@ -1,5 +1,6 @@
 # frozen_string_literal: true
 
+require_relative "bill_plan_command"
 require_relative "customer_info_form"
 require_relative "financial_info"
 require_relative "form_template"
@@ -65,6 +66,10 @@ class AdminCommand
 		end
 	end
 
+	def action_bill_plan
+		BillPlanCommand.for(@target_customer).call
+	end
+
 	def pay_methods(financial_info)
 		reply(FormTemplate.render(
 			"admin_payment_methods",

lib/bill_plan_command.rb 🔗

@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+class BillPlanCommand
+	def self.for(customer)
+		return ForUnregistered.new unless customer.registered?
+
+		unless customer.balance > customer.monthly_price
+			return ForLowBalance.new(customer)
+		end
+
+		new(customer)
+	end
+
+	def initialize(customer)
+		@customer = customer
+	end
+
+	def call
+		@customer.bill_plan
+		Command.reply do |reply|
+			reply.note_type = :info
+			reply.note_text = "Customer billed"
+		end
+	end
+
+	class ForLowBalance
+		def initialize(customer)
+			@customer = customer
+		end
+
+		def call
+			LowBalance.for(@customer).then(&:notify!).then do |amount|
+				return command_for(amount).call if amount&.positive?
+
+				notify_failure
+				Command.reply do |reply|
+					reply.note_type = :error
+					reply.note_text = "Customer balance is too low"
+				end
+			end
+		end
+
+	protected
+
+		def notify_failure
+			m = Blather::Stanza::Message.new
+			m.from = CONFIG[:notify_from]
+			m.body =
+				"Failed to renew account for #{@customer.registered?.phone}. " \
+				"To keep your number, please buy more credit soon."
+			@customer.stanza_to(m)
+		end
+
+		def command_for(amount)
+			BillPlanCommand.for(
+				@customer.with_balance(@customer.balance + amount)
+			)
+		end
+	end
+
+	class ForUnregistered
+		def call
+			Command.reply do |reply|
+				reply.note_type = :error
+				reply.note_text = "Customer is not registered"
+			end
+		end
+	end
+end

lib/command.rb 🔗

@@ -11,6 +11,10 @@ class Command
 		Thread.current[:execution]
 	end
 
+	def self.execution=(exe)
+		Thread.current[:execution] = exe
+	end
+
 	def self.reply(stanza=nil, &blk)
 		execution.reply(stanza, &blk)
 	end
@@ -51,7 +55,7 @@ class Command
 		def execute
 			StatsD.increment("command", tags: ["node:#{iq.node}"])
 			EMPromise.resolve(nil).then {
-				Thread.current[:execution] = self
+				Command.execution = self
 				sentry_hub
 				catch_after(EMPromise.resolve(yield self))
 			}.catch(&method(:panic))

lib/customer.rb 🔗

@@ -24,7 +24,8 @@ class Customer
 
 	def_delegators :@plan, :active?, :activate_plan_starting_now, :bill_plan,
 	               :currency, :merchant_account, :plan_name, :minute_limit,
-	               :message_limit, :auto_top_up_amount, :monthly_overage_limit
+	               :message_limit, :auto_top_up_amount, :monthly_overage_limit,
+	               :monthly_price
 	def_delegators :@sgx, :register!, :registered?, :set_ogm_url,
 	               :fwd, :transcription_enabled
 	def_delegators :@usage, :usage_report, :message_usage, :incr_message_usage

lib/db_notification.rb 🔗

@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+require_relative "dummy_command"
+
+module DbNotification
+	def self.for(notify, customer)
+		case notify[:relname]
+		when "low_balance"
+			LowBalance.for(customer).then { |lb| lb.method(:notify!) }
+		when "possible_renewal"
+			Command.execution = DummyCommand.new(customer)
+			BillPlanCommand.for(customer)
+		else
+			raise "Unknown notification: #{notify[:relname]}"
+		end
+	end
+end

lib/dummy_command.rb 🔗

@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class DummyCommand
+	attr_reader :customer
+
+	def initialize(customer)
+		@customer = customer
+	end
+
+	def reply(*); end
+
+	def finish(*); end
+
+	def log
+		::LOG
+	end
+end

lib/low_balance.rb 🔗

@@ -11,11 +11,11 @@ class LowBalance
 			"jmp_customer_low_balance-#{customer.customer_id}",
 			expiry: 60 * 60 * 24 * 7
 		).with(-> { Locked.new }) do
-			for_auto_top_up_amount(customer)
+			for_no_lock(customer)
 		end
 	end
 
-	def self.for_auto_top_up_amount(customer)
+	def self.for_no_lock(customer)
 		if customer.auto_top_up_amount.positive?
 			AutoTopUp.new(customer)
 		else

sgx_jmp.rb 🔗

@@ -81,6 +81,8 @@ require_relative "lib/command_list"
 require_relative "lib/customer"
 require_relative "lib/customer_info_form"
 require_relative "lib/customer_repo"
+require_relative "lib/dummy_command"
+require_relative "lib/db_notification"
 require_relative "lib/electrum"
 require_relative "lib/empty_repo"
 require_relative "lib/expiring_lock"
@@ -178,10 +180,27 @@ end
 
 EM.error_handler(&method(:panic))
 
+# Infer anything we might have been notified about while we were down
+def catchup_notify(db)
+	db.query("SELECT customer_id FROM balances WHERE balance < 5").each do |c|
+		db.query("SELECT pg_notify('low_balance', $1)", c.values)
+	end
+	db.query(<<~SQL).each do |c|
+		SELECT customer_id
+		FROM customer_plans INNER JOIN balances USING (customer_id)
+		WHERE expires_at < LOCALTIMESTAMP AND balance >= 5
+	SQL
+		db.query("SELECT pg_notify('possible_renewal', $1)", c.values)
+	end
+end
+
 def poll_for_notify(db)
 	db.wait_for_notify_defer.then { |notify|
-		CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new).find(notify[:extra])
-	}.then(&LowBalance.method(:for)).then(&:notify!).then {
+		CustomerRepo
+			.new(sgx_repo: Bwmsgsv2Repo.new)
+			.find(notify[:extra])
+			.then { |customer| DbNotification.for(notify, customer) }
+	}.then(&:call).then {
 		poll_for_notify(db)
 	}.catch(&method(:panic))
 end
@@ -208,9 +227,8 @@ when_ready do
 
 	DB.hold do |conn|
 		conn.query("LISTEN low_balance")
-		conn.query("SELECT customer_id FROM balances WHERE balance < 5").each do |c|
-			conn.query("SELECT pg_notify('low_balance', $1)", c.values)
-		end
+		conn.query("LISTEN possible_renewal")
+		catchup_notify(conn)
 		poll_for_notify(conn)
 	end