Notify customer when renewal fails due to low balance

Stephen Paul Weber created

This is done by sending from a configured JID to <tel>@cheogram.com in order to
have them receive a message from support.

Change summary

Gemfile                     |   1 
bin/billing_monthly_cronjob | 136 ++++++++++++++++++++++++++++++--------
lib/blather_notify.rb       |  28 ++++++++
3 files changed, 136 insertions(+), 29 deletions(-)

Detailed changes

Gemfile 🔗

@@ -2,6 +2,7 @@
 
 source "https://rubygems.org"
 
+gem "blather"
 gem "braintree"
 gem "dhall"
 gem "money-open-exchange-rates"

bin/billing_monthly_cronjob 🔗

@@ -1,29 +1,76 @@
 #!/usr/bin/ruby
 # frozen_string_literal: true
 
-# Usage: ./billing_monthly_cronjob \
-#      '{ healthchecks_url = "https://hc-ping.com/...", plans = ./plans.dhall }'
+# Usage: ./billing_monthly_cronjob '{
+#        healthchecks_url = "https://hc-ping.com/...",
+#        notify_using = {
+#          jid = "",
+#          password = "",
+#          target = \(tel: Text) -> "${tel}@cheogram.com"
+#        },
+#        plans = ./plans.dhall
+#        }'
 
 require "bigdecimal"
 require "date"
 require "dhall"
 require "net/http"
 require "pg"
-
-CONFIG = Dhall.load(ARGV[0]).sync
+require "redis"
+
+require_relative "../lib/blather_notify"
+
+CONFIG = Dhall.load(<<-DHALL).sync
+	let Quota = < unlimited | limited: { included: Natural, price: Natural } >
+	let Currency = < CAD | USD >
+	in
+	(#{ARGV[0]}) : {
+		healthchecks_url: Text,
+		notify_using: { jid: Text, password: Text, target: Text -> Text },
+		plans: List {
+			name: Text,
+			currency: Currency,
+			monthly_price: Natural,
+			minutes: Quota,
+			messages: Quota
+		}
+	}
+DHALL
 
 Net::HTTP.post_form(URI("#{CONFIG[:healthchecks_url]}/start"), {})
 
+REDIS = Redis.new
 db = PG.connect(dbname: "jmp")
 db.type_map_for_results = PG::BasicTypeMapForResults.new(db)
 db.type_map_for_queries = PG::BasicTypeMapForQueries.new(db)
 
-not_renewed = 0
-renewed = 0
-revenue = BigDecimal.new(0)
+BlatherNotify.start(
+	CONFIG[:notify_using][:jid],
+	CONFIG[:notify_using][:password]
+)
 
 RENEW_UNTIL = Date.today >> 1
 
+class Stats
+	def initialize(**kwargs)
+		@stats = kwargs
+	end
+
+	def add(stat, value)
+		@stats[stat] += value
+	end
+
+	def to_h
+		@stats.transform_values { |v| v.is_a?(BigDecimal) ? v.to_s("F") : v }
+	end
+end
+
+stats = Stats.new(
+	not_renewed: 0,
+	renewed: 0,
+	revenue: BigDecimal.new(0)
+)
+
 class Plan
 	def self.from_name(plan_name)
 		plan = CONFIG[:plans].find { |p| p[:name].to_s == plan_name }
@@ -59,6 +106,56 @@ class Plan
 	end
 end
 
+class ExpiredCustomer
+	def self.for(row)
+		plan = Plan.from_name(row["plan_name"])
+		if row["balance"] < plan.price
+			WithLowBalance.new(row, plan)
+		else
+			new(row, plan)
+		end
+	end
+
+	def initialize(row, plan)
+		@row = row
+		@plan = plan
+	end
+
+	def try_renew(db, stats)
+		@plan.renew(
+			db,
+			@row["customer_id"],
+			@row["expires_at"]
+		)
+
+		stats.add(:renewed, 1)
+		stats.add(:revenue, plan.price)
+	end
+
+	class WithLowBalance < ExpiredCustomer
+		def try_renew(_, stats)
+			jid = REDIS.get("jmp_customer_jid-#{@row['customer_id']}")
+			tel = REDIS.lindex("catapult_cred-#{jid}", 3)
+			BlatherNotify.say(
+				CONFIG[:notify_using][:target].call(tel.to_s),
+				format_renewal_notification(tel)
+			)
+
+			stats.add(:not_renewed, 1)
+		end
+
+	protected
+
+		def format_renewal_notification(tel)
+			<<~NOTIFY
+				Failed to renew account for #{tel},
+				balance of #{@row['balance']} is too low.
+				To keep your number, please buy more credit soon.
+			NOTIFY
+		end
+	end
+end
+
 db.transaction do
 	db.exec(
 		<<-SQL
@@ -66,28 +163,9 @@ db.transaction do
 		FROM customer_plans INNER JOIN balances USING (customer_id)
 		WHERE expires_at <= NOW()
 		SQL
-	).each do |expired_customer|
-		plan = Plan.from_name(expired_customer["plan_name"])
-
-		if expired_customer["balance"] < plan.price
-			not_renewed += 1
-			next
-		end
-
-		plan.renew(
-			db,
-			expired_customer["customer_id"],
-			expired_customer["expires_at"]
-		)
-
-		renewed += 1
-		revenue += plan.price
+	).each do |row|
+		ExpiredCustomer.for(row).try_renew(db, stats)
 	end
 end
 
-Net::HTTP.post_form(
-	URI(CONFIG[:healthchecks_url].to_s),
-	renewed: renewed,
-	not_renewed: not_renewed,
-	revenue: revenue.to_s("F")
-)
+Net::HTTP.post_form(URI(CONFIG[:healthchecks_url].to_s), **stats.to_h)

lib/blather_notify.rb 🔗

@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require "blather/client/dsl"
+require "timeout"
+
+module BlatherNotify
+	extend Blather::DSL
+
+	@ready = Queue.new
+
+	when_ready { @ready << :ready }
+
+	def self.start(jid, password)
+		# workqueue_count MUST be 0 or else Blather uses threads!
+		setup(jid, password, nil, nil, nil, nil, workqueue_count: 0)
+
+		EM.error_handler { |e| warn e.message }
+		Thread.new do
+			EM.run do
+				client.run
+			end
+		end
+
+		at_exit { shutdown }
+
+		Timeout.timeout(5) { @ready.pop }
+	end
+end