On low balance, top-up or notify

Stephen Paul Weber created

On start up, check for users with low balance and NOTIFY about them.  LISTEN for
such notifications and process by either sending a low-balance warning message
or else attempting an auto-top-up as configured.

Using NOTIFY/LISTEN because then we can NOTIFY after any INSERT that leaves the
balance too low (using a trigger).  Doing the sync on start-up in case we missed
a NOTIFY during any downtime.  Using the Redis lock to prevent spamming a
low-balance user in case of many restarts or if they have many small
transactions happen in one day.

Change summary

config.dhall.sample      |  3 
lib/customer.rb          |  2 
lib/customer_plan.rb     |  4 +
lib/low_balance.rb       | 77 ++++++++++++++++++++++++++++++++++
sgx_jmp.rb               | 17 +++++++
test/test_low_balance.rb | 95 ++++++++++++++++++++++++++++++++++++++++++
6 files changed, 196 insertions(+), 2 deletions(-)

Detailed changes

config.dhall.sample 🔗

@@ -70,5 +70,6 @@
 		"https://pay.jmp.chat/electrum_notify?address=${address}&customer_id=${customer_id}",
 	adr = "",
 	interac = "",
-	payable = ""
+	payable = "",
+	notify_from = "+15551234567@example.net"
 }

lib/customer.rb 🔗

@@ -16,7 +16,7 @@ class Customer
 
 	attr_reader :customer_id, :balance
 	def_delegators :@plan, :active?, :activate_plan_starting_now, :bill_plan,
-	               :currency, :merchant_account, :plan_name
+	               :currency, :merchant_account, :plan_name, :auto_top_up_amount
 	def_delegators :@sgx, :register!, :registered?, :fwd_timeout=
 	def_delegators :@usage, :usage_report, :message_usage, :incr_message_usage
 

lib/customer_plan.rb 🔗

@@ -19,6 +19,10 @@ class CustomerPlan
 		plan_name && @expires_at > Time.now
 	end
 
+	def auto_top_up_amount
+		REDIS.get("jmp_customer_auto_top_up_amount-#{@customer_id}").then(&:to_i)
+	end
+
 	def bill_plan
 		EM.promise_fiber do
 			DB.transaction do

lib/low_balance.rb 🔗

@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require_relative "expiring_lock"
+require_relative "transaction"
+
+class LowBalance
+	def self.for(customer)
+		ExpiringLock.new(
+			"jmp_low_balance_notify-#{customer.customer_id}"
+		).with(-> { Locked.new }) do
+			customer.auto_top_up_amount.then do |auto_top_up_amount|
+				for_auto_top_up_amount(customer, auto_top_up_amount)
+			end
+		end
+	end
+
+	def self.for_auto_top_up_amount(customer, auto_top_up_amount)
+		if auto_top_up_amount.positive?
+			AutoTopUp.new(customer, auto_top_up_amount)
+		else
+			customer.btc_addresses.then do |btc_addresses|
+				new(customer, btc_addresses)
+			end
+		end
+	end
+
+	def initialize(customer, btc_addresses)
+		@customer = customer
+		@btc_addresses = btc_addresses
+	end
+
+	def notify!
+		m = Blather::Stanza::Message.new
+		m.from = CONFIG[:notify_from]
+		m.body =
+			"Your balance of $#{'%.4f' % @customer.balance} is low." \
+			"#{btc_addresses_for_notification}"
+		@customer.stanza_to(m)
+	end
+
+	def btc_addresses_for_notification
+		return if @btc_addresses.empty?
+		"\nYou can buy credit by sending any amount of Bitcoin to one of " \
+		"these addresses:\n#{@btc_addresses.join("\n")}"
+	end
+
+	class AutoTopUp
+		def initialize(customer, auto_top_up_amount)
+			@customer = customer
+			@auto_top_up_amount = auto_top_up_amount
+			@message = Blather::Stanza::Message.new
+			@message.from = CONFIG[:notify_from]
+		end
+
+		def sale
+			Transaction.sale(@customer, amount: @auto_top_up_amount).then do |tx|
+				tx.insert.then { tx }
+			end
+		end
+
+		def notify!
+			sale.then { |tx|
+				@message.body =
+					"Automatic top-up has charged your default " \
+					"payment method and added #{tx} to your balance."
+			}.catch { |e|
+				@message.body =
+					"Automatic top-up transaction for " \
+					"$#{@auto_top_up_amount} failed: #{e.message}"
+			}.then { @customer.stanza_to(@message) }
+		end
+	end
+
+	class Locked
+		def notify!; end
+	end
+end

sgx_jmp.rb 🔗

@@ -69,6 +69,7 @@ require_relative "lib/customer_repo"
 require_relative "lib/electrum"
 require_relative "lib/expiring_lock"
 require_relative "lib/em"
+require_relative "lib/low_balance"
 require_relative "lib/payment_methods"
 require_relative "lib/registration"
 require_relative "lib/transaction"
@@ -150,6 +151,14 @@ end
 
 EM.error_handler(&method(:panic))
 
+def poll_for_notify(db)
+	db.wait_for_notify_defer.then { |notify|
+		Customer.for_customer_id(notify[:extra])
+	}.then(&LowBalance.method(:for)).then(&:notify!).then {
+		poll_for_notify(db)
+	}.catch(&method(:panic))
+end
+
 when_ready do
 	LOG.info "Ready"
 	BLATHER = self
@@ -160,6 +169,14 @@ when_ready do
 		conn.type_map_for_queries = PG::BasicTypeMapForQueries.new(conn)
 	end
 
+	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
+		poll_for_notify(conn)
+	end
+
 	EM.add_periodic_timer(3600) do
 		ping = Blather::Stanza::Iq::Ping.new(:get, CONFIG[:server][:host])
 		ping.from = CONFIG[:component][:jid]

test/test_low_balance.rb 🔗

@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "low_balance"
+
+ExpiringLock::REDIS = Minitest::Mock.new
+CustomerPlan::REDIS = Minitest::Mock.new
+Customer::REDIS = Minitest::Mock.new
+
+class LowBalanceTest < Minitest::Test
+	def test_for_locked
+		ExpiringLock::REDIS.expect(
+			:exists,
+			EMPromise.resolve(1),
+			["jmp_low_balance_notify-test"]
+		)
+		assert_kind_of LowBalance::Locked, LowBalance.for(Customer.new("test")).sync
+	end
+	em :test_for_locked
+
+	def test_for_no_auto_top_up
+		ExpiringLock::REDIS.expect(
+			:exists,
+			EMPromise.resolve(0),
+			["jmp_low_balance_notify-test"]
+		)
+		CustomerPlan::REDIS.expect(
+			:get,
+			EMPromise.resolve(nil),
+			["jmp_customer_auto_top_up_amount-test"]
+		)
+		Customer::REDIS.expect(
+			:smembers,
+			EMPromise.resolve([]),
+			["jmp_customer_btc_addresses-test"]
+		)
+		ExpiringLock::REDIS.expect(
+			:setex,
+			EMPromise.resolve(nil),
+			["jmp_low_balance_notify-test", 60 * 60 * 24, ""]
+		)
+		assert_kind_of(
+			LowBalance,
+			LowBalance.for(Customer.new("test")).sync
+		)
+		assert_mock ExpiringLock::REDIS
+	end
+	em :test_for_no_auto_top_up
+
+	def test_for_auto_top_up
+		ExpiringLock::REDIS.expect(
+			:exists,
+			EMPromise.resolve(0),
+			["jmp_low_balance_notify-test"]
+		)
+		CustomerPlan::REDIS.expect(
+			:get,
+			EMPromise.resolve("15"),
+			["jmp_customer_auto_top_up_amount-test"]
+		)
+		ExpiringLock::REDIS.expect(
+			:setex,
+			EMPromise.resolve(nil),
+			["jmp_low_balance_notify-test", 60 * 60 * 24, ""]
+		)
+		assert_kind_of(
+			LowBalance::AutoTopUp,
+			LowBalance.for(Customer.new("test")).sync
+		)
+		assert_mock ExpiringLock::REDIS
+	end
+	em :test_for_auto_top_up
+
+	class AutoTopUpTest < Minitest::Test
+		LowBalance::AutoTopUp::Transaction = Minitest::Mock.new
+
+		def setup
+			@customer = Customer.new("test")
+			@auto_top_up = LowBalance::AutoTopUp.new(@customer, 100)
+		end
+
+		def test_notify!
+			tx = PromiseMock.new
+			tx.expect(:insert, EMPromise.resolve(nil))
+			LowBalance::AutoTopUp::Transaction.expect(
+				:sale,
+				tx,
+				[@customer, amount: 100]
+			)
+			@auto_top_up.notify!
+			assert_mock tx
+		end
+		em :test_notify!
+	end
+end