Merge branch 'low-balance-auto-top-up'

Stephen Paul Weber created

* low-balance-auto-top-up:
  Some people have exactly 5 who don't need to be told
  On low balance, top-up or notify
  ExpiringLock helper

Change summary

config.dhall.sample      |  3 
lib/customer.rb          |  2 
lib/customer_plan.rb     |  4 +
lib/expiring_lock.rb     | 18 +++++++
lib/low_balance.rb       | 77 ++++++++++++++++++++++++++++++++++
sgx_jmp.rb               | 48 ++++++++++++++-------
test/test_low_balance.rb | 95 ++++++++++++++++++++++++++++++++++++++++++
7 files changed, 229 insertions(+), 18 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/expiring_lock.rb 🔗

@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class ExpiringLock
+	def initialize(name, expiry: 60 * 60 * 24)
+		@name = name
+		@expiry = expiry
+	end
+
+	def with(els=nil)
+		REDIS.exists(@name).then do |exists|
+			next els&.call if exists == 1
+
+			EMPromise.resolve(yield).then do |rval|
+				REDIS.setex(@name, @expiry, "").then { rval }
+			end
+		end
+	end
+end

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 🔗

@@ -67,7 +67,9 @@ require_relative "lib/command_list"
 require_relative "lib/customer"
 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"
@@ -149,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
@@ -159,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]
@@ -249,23 +267,21 @@ message do |m|
 			id: customer.customer_id, jid: m.from.stripped.to_s
 		)
 		EMPromise.all([
-			customer, (customer.incr_message_usage if billable_message(m)),
-			REDIS.exists("jmp_usage_notify-#{customer.customer_id}"),
+			(customer.incr_message_usage if billable_message(m)),
 			customer.stanza_from(m)
-		])
-	}.then { |(customer, _, already, _)|
-		next if already == 1
-
-		customer.message_usage((today..(today - 30))).then do |usage|
-			next unless usage > 500
-
-			BLATHER.join(CONFIG[:notify_admin], "sgx-jmp")
-			BLATHER.say(
-				CONFIG[:notify_admin],
-				"#{customer.customer_id} has used #{usage} messages since #{today - 30}",
-				:groupchat
-			)
-			REDIS.set("jmp_usage_notify-#{customer.customer_id}", ex: 60 * 60 * 24)
+		]).then { customer }
+	}.then { |customer|
+		ExpiringLock.new("jmp_usage_notify-#{customer.customer_id}").with do
+			customer.message_usage((today..(today - 30))).then do |usage|
+				next unless usage > 500
+
+				BLATHER.join(CONFIG[:notify_admin], "sgx-jmp")
+				BLATHER.say(
+					CONFIG[:notify_admin],
+					"#{customer.customer_id} has used #{usage} messages since #{today - 30}",
+					:groupchat
+				)
+			end
 		end
 	}.catch { |e| panic(e, sentry_hub) }
 end

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