~singpolyma/sgx-jmp

4e091b60793f85187eaf57acd08ea9e95053db52 — Stephen Paul Weber 1 year, 3 months ago 53d0894 + a9c201d
Merge branch 'low-balance-auto-top-up'

* 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
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/expiring_lock.rb => lib/expiring_lock.rb +18 -0
@@ 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

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 +32 -16
@@ 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

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