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