~singpolyma/sgx-jmp

1b9bf2959540f16f493665453383978f6497c79a — Stephen Paul Weber 1 year, 4 months ago eaeee71
On low balance, top-up or notify

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.
M config.dhall.sample => config.dhall.sample +2 -1
@@ 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"
}

M lib/customer.rb => lib/customer.rb +1 -1
@@ 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


M lib/customer_plan.rb => lib/customer_plan.rb +4 -0
@@ 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

A lib/low_balance.rb => lib/low_balance.rb +77 -0
@@ 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

M sgx_jmp.rb => sgx_jmp.rb +17 -0
@@ 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]

A test/test_low_balance.rb => test/test_low_balance.rb +95 -0
@@ 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