diff --git a/config.dhall.sample b/config.dhall.sample index 006b65d96b97514f45a049d85d817cb220579b4b..421a1ae957378a475b462727ec0dead98c5b1196 100644 --- a/config.dhall.sample +++ b/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" } diff --git a/lib/customer.rb b/lib/customer.rb index 92642b067b1177749dd9f57a81aacb8f281c885a..2fc587be87d8db0429eb2e655bde17af62f16506 100644 --- a/lib/customer.rb +++ b/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 diff --git a/lib/customer_plan.rb b/lib/customer_plan.rb index af099fda69dd5f30a64b6d8678a77d54e078c39a..057706cf65964513a649773e936476af5b820039 100644 --- a/lib/customer_plan.rb +++ b/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 diff --git a/lib/low_balance.rb b/lib/low_balance.rb new file mode 100644 index 0000000000000000000000000000000000000000..4f9f73eed617482e2b3c7e7c05099987af6c4251 --- /dev/null +++ b/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 diff --git a/sgx_jmp.rb b/sgx_jmp.rb index 76809404f1f3af6c34479974cc36bd251f98175a..d222cbafebaf5049e6d1bda51d460d5d5bbb77a6 100644 --- a/sgx_jmp.rb +++ b/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] diff --git a/test/test_low_balance.rb b/test/test_low_balance.rb new file mode 100644 index 0000000000000000000000000000000000000000..92291edabacbd0a63421f6267709090ad8a4fdad --- /dev/null +++ b/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