diff --git a/.rubocop.yml b/.rubocop.yml index 8ed9f0e0aeb98b105abeca61f2809fa05c80f91a..21f5dd4412f5149845116253e69afc8380041222 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -10,6 +10,10 @@ Metrics/MethodLength: Exclude: - test/* +Metrics/AbcSize: + Exclude: + - test/* + Style/Tab: Enabled: false diff --git a/lib/customer.rb b/lib/customer.rb index 5f1844abfb2edca4984ac74c0c2e5d313bfc5b9b..a2dc6e050c9c34af3b7aeb27cfb842cd47ac7b9c 100644 --- a/lib/customer.rb +++ b/lib/customer.rb @@ -13,7 +13,7 @@ class Customer def self.for_customer_id(customer_id) result = DB.query_defer(<<~SQL, [customer_id]) - SELECT COALESCE(balance,0) AS balance, plan_name + SELECT COALESCE(balance,0) AS balance, plan_name, expires_at FROM customer_plans LEFT JOIN balances USING (customer_id) WHERE customer_id=$1 LIMIT 1 SQL @@ -22,14 +22,37 @@ class Customer end end - attr_reader :balance + attr_reader :customer_id, :balance - def initialize(customer_id, plan_name: nil, balance: BigDecimal.new(0)) + def initialize( + customer_id, + plan_name: nil, + expires_at: Time.now, + balance: BigDecimal.new(0) + ) @plan = plan_name && Plan.for(plan_name) + @expires_at = expires_at @customer_id = customer_id @balance = balance end + def with_plan(plan_name) + self.class.new( + @customer_id, + balance: @balance, + expires_at: @expires_at, + plan_name: plan_name + ) + end + + def plan_name + @plan.name + end + + def currency + @plan.currency + end + def merchant_account @plan.merchant_account end @@ -41,4 +64,16 @@ class Customer .find(@customer_id) .then(PaymentMethods.method(:for_braintree_customer)) end + + def active? + @plan && @expires_at > Time.now + end + + def registered? + ibr = IBR.new(:get, CONFIG[:sgx]) + ibr.from = "customer_#{@customer_id}@#{CONFIG[:component][:jid]}" + IQ_MANAGER.write(ibr).catch { nil }.then do |result| + result&.respond_to?(:registered?) && result&.registered? + end + end end diff --git a/lib/plan.rb b/lib/plan.rb index 10d9701f6f52a965978d767364a0ae844c05515a..97bb46b27645ef005d8d63c923bc3482ca5daa6f 100644 --- a/lib/plan.rb +++ b/lib/plan.rb @@ -12,6 +12,10 @@ class Plan @plan = plan end + def name + @plan[:name] + end + def currency @plan[:currency] end diff --git a/lib/registration.rb b/lib/registration.rb new file mode 100644 index 0000000000000000000000000000000000000000..7a60b14dca71593aad7cacee9fd178993183c9cf --- /dev/null +++ b/lib/registration.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +class Registration + def self.for(iq, customer, web_register_manager) + raise "TODO" if customer&.active? + + EMPromise.resolve(customer&.registered?).then do |registered| + if registered + Registered.new(iq, result.phone) + else + web_register_manager.choose_tel(iq).then do |(riq, tel)| + Activation.for(riq, customer, tel) + end + end + end + end + + class Registered + def initialize(iq, tel) + @reply = iq.reply + @reply.status = :completed + @tel = tel + end + + def write + @reply.note_type = :error + @reply.note_text = <<~NOTE + You are already registered with JMP number #{@tel} + NOTE + BLATHER << @reply + nil + end + end + + class Activation + def self.for(iq, customer, tel) + return EMPromise.resolve(new(iq, customer, tel)) if customer + + # Create customer_id + raise "TODO" + end + + def initialize(iq, customer, tel) + @reply = iq.reply + reply.allowed_actions = [:next] + + @customer = customer + @tel = tel + end + + attr_reader :reply, :customer, :tel + + FORM_FIELDS = [ + { + var: "activation_method", + type: "list-single", + label: "Activate using", + required: true, + options: [ + { + value: "bitcoin", + label: "Bitcoin" + }, + { + value: "credit_card", + label: "Credit Card" + }, + { + value: "code", + label: "Referral or Activation Code" + } + ] + }, + { + var: "plan_name", + type: "list-single", + label: "What currency should your account balance be in?", + required: true, + options: [ + { + value: "cad_beta_unlimited-v20210223", + label: "Canadian Dollars" + }, + { + value: "usd_beta_unlimited-v20210223", + label: "United States Dollars" + } + ] + } + ].freeze + + def write + form = reply.form + form.type = :form + form.title = "Activate JMP" + form.instructions = "Going to activate #{tel} (TODO RATE CTR)" + form.fields = FORM_FIELDS + + COMMAND_MANAGER.write(reply).then { |iq| + Payment.for(iq, customer, tel) + }.then(&:write) + end + end + + module Payment + def self.for(iq, customer, tel) + case iq.form.field("activation_method")&.value&.to_s + when "bitcoin" + Bitcoin.new(iq, customer, tel) + when "credit_card" + raise "TODO" + when "code" + raise "TODO" + else + raise "Invalid activation method" + end + end + + class Bitcoin + def initialize(iq, customer, tel) + @reply = iq.reply + reply.note_type = :info + reply.status = :completed + + plan_name = iq.form.field("plan_name").value.to_s + @customer = customer.with_plan(plan_name) + @customer_id = customer.customer_id + @tel = tel + @addr = ELECTRUM.createnewaddress + end + + attr_reader :reply, :customer_id, :tel + + def save + EMPromise.all([ + REDIS.mset( + "pending_tel_for-#{customer_id}", tel, + "pending_plan_for-#{customer_id}", @customer.plan_name + ), + @addr.then do |addr| + REDIS.sadd("jmp_customer_btc_addresses-#{customer_id}", addr) + end + ]) + end + + def note_text(amount, addr) + <<~NOTE + Activate your account by sending at least #{'%.6f' % amount} BTC to + #{addr} + + You will receive a notification when your payment is complete. + NOTE + end + + def write + EMPromise.all([ + @addr, + save, + BTC_SELL_PRICES.public_send(@customer.currency.to_s.downcase) + ]).then do |(addr, _, rate)| + min = CONFIG[:activation_amount] / rate + reply.note_text = note_text(min, addr) + BLATHER << reply + nil + end + end + end + end +end diff --git a/lib/web_register_manager.rb b/lib/web_register_manager.rb index ae3ec0aa640951ab957b11f6787ede7246eefa7d..d9f8728c8bdd9d114c68b30f42376c9520a38c01 100644 --- a/lib/web_register_manager.rb +++ b/lib/web_register_manager.rb @@ -14,7 +14,7 @@ class WebRegisterManager end def choose_tel(iq) - self[iq.from.stripped].choose_tel(iq) + self[iq&.from&.stripped].choose_tel(iq) end class HaveTel diff --git a/sgx_jmp.rb b/sgx_jmp.rb index 06e61997228a481ea28db884a92f68a14c17117a..5ff6ff9b9b9c599f037058572b5c5a88eb186230 100644 --- a/sgx_jmp.rb +++ b/sgx_jmp.rb @@ -8,17 +8,24 @@ require "dhall" require "em-hiredis" require "em_promise" +require_relative "lib/btc_sell_prices" require_relative "lib/buy_account_credit_form" require_relative "lib/customer" +require_relative "lib/electrum" require_relative "lib/em" +require_relative "lib/existing_registration" require_relative "lib/payment_methods" +require_relative "lib/registration" require_relative "lib/transaction" +require_relative "lib/web_register_manager" CONFIG = Dhall::Coder .new(safe: Dhall::Coder::JSON_LIKE + [Symbol]) .load(ARGV[0], transform_keys: ->(k) { k&.to_sym }) +ELECTRUM = Electrum.new(**CONFIG[:electrum]) + # Braintree is not async, so wrap in EM.defer for now class AsyncBraintree def initialize(environment:, merchant_id:, public_key:, private_key:, **) @@ -60,13 +67,16 @@ Blather::DSL.append_features(self.class) def panic(e) warn "Error raised during event loop: #{e.message}" + warn e.backtrace if e.respond_to?(:backtrace) exit 1 end EM.error_handler(&method(:panic)) when_ready do + BLATHER = self REDIS = EM::Hiredis.connect + BTC_SELL_PRICES = BTCSellPrices.new(REDIS, CONFIG[:oxr_app_id]) DB = PG::EM::Client.new(dbname: "jmp") DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB) DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB) @@ -126,6 +136,7 @@ end IQ_MANAGER = SessionManager.new(self, :id) COMMAND_MANAGER = SessionManager.new(self, :sessionid, timeout: 60 * 60) +web_register_manager = WebRegisterManager.new disco_items node: "http://jabber.org/protocol/commands" do |iq| reply = iq.reply @@ -136,11 +147,28 @@ disco_items node: "http://jabber.org/protocol/commands" do |iq| iq.to, "buy-credit", "Buy account credit" + ), + Blather::Stanza::DiscoItems::Item.new( + iq.to, + "jabber:iq:register", + "Register" ) ] self << reply end +command :execute?, node: "jabber:iq:register", sessionid: nil do |iq| + Customer.for_jid(iq.from.stripped).catch { + nil + }.then { |customer| + Registration.for( + iq, + customer, + web_register_manager + ).then(&:write) + }.catch(&method(:panic)) +end + def reply_with_note(iq, text, type: :info) reply = iq.reply reply.status = :completed diff --git a/test/test_helper.rb b/test/test_helper.rb index 972fa4976f3d9a54209ec4eb8f3176b6bed55268..c2da5f7f8438f629f0843bb67630cb2235a6b62d 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -37,6 +37,7 @@ CONFIG = { component: { jid: "component" }, + activation_amount: 1, plans: [ { name: "test_usd", @@ -58,6 +59,16 @@ BLATHER = Class.new { def <<(*); end }.new.freeze +class Matching + def initialize(&block) + @block = block + end + + def ===(other) + @block.call(other) + end +end + module Minitest class Test def self.property(m, &block) diff --git a/test/test_registration.rb b/test/test_registration.rb new file mode 100644 index 0000000000000000000000000000000000000000..41b73a93eebca11d403fba4acf54661830cc8f01 --- /dev/null +++ b/test/test_registration.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require "test_helper" +require "registration" + +class RegistrationTest < Minitest::Test + Customer::IQ_MANAGER = Minitest::Mock.new + + def test_for_activated + skip "Registration#for activated not implemented yet" + iq = Blather::Stanza::Iq::Command.new + Registration.for(iq, Customer.new("test"), Minitest::Mock.new).sync + end + em :test_for_activated + + def test_for_not_activated_with_customer_id + Customer::IQ_MANAGER.expect( + :write, + EMPromise.resolve(nil), + [Blather::Stanza::Iq] + ) + web_manager = WebRegisterManager.new + web_manager["test@example.com"] = "+15555550000" + iq = Blather::Stanza::Iq::Command.new + iq.from = "test@example.com" + result = Registration.for( + iq, + Customer.new("test"), + web_manager + ).sync + assert_kind_of Registration::Activation, result + end + em :test_for_not_activated_with_customer_id + + def test_for_not_activated_without_customer_id + skip "customer_id creation not implemented yet" + iq = Blather::Stanza::Iq::Command.new + Registration.for(iq, nil, Minitest::Mock.new).sync + end + em :test_for_not_activated_without_customer_id + + class ActivationTest < Minitest::Test + Registration::Activation::COMMAND_MANAGER = Minitest::Mock.new + def setup + iq = Blather::Stanza::Iq::Command.new + @activation = Registration::Activation.new(iq, "test", "+15555550000") + end + + def test_write + result = Minitest::Mock.new + result.expect(:then, result) + result.expect(:then, EMPromise.resolve(:test_result)) + Registration::Activation::COMMAND_MANAGER.expect( + :write, + result, + [Blather::Stanza::Iq::Command] + ) + assert_equal :test_result, @activation.write.sync + end + em :test_write + end + + class PaymentTest < Minitest::Test + Registration::Payment::Bitcoin::ELECTRUM = Minitest::Mock.new + + def test_for_bitcoin + Registration::Payment::Bitcoin::ELECTRUM.expect(:createnewaddress, "addr") + iq = Blather::Stanza::Iq::Command.new + iq.form.fields = [ + { var: "activation_method", value: "bitcoin" }, + { var: "plan_name", value: "test_usd" } + ] + result = Registration::Payment.for( + iq, + Customer.new("test"), + "+15555550000" + ) + assert_kind_of Registration::Payment::Bitcoin, result + end + + def test_for_credit_card + skip "CreditCard not implemented yet" + iq = Blather::Stanza::Iq::Command.new + iq.form.fields = [ + { var: "activation_method", value: "credit_card" }, + { var: "plan_name", value: "test_usd" } + ] + result = Registration::Payment.for(iq, "test", "+15555550000") + assert_kind_of Registration::Payment::CreditCard, result + end + + def test_for_code + skip "Code not implemented yet" + iq = Blather::Stanza::Iq::Command.new + iq.form.fields = [ + { var: "activation_method", value: "code" }, + { var: "plan_name", value: "test_usd" } + ] + result = Registration::Payment.for(iq, "test", "+15555550000") + assert_kind_of Registration::Payment::Code, result + end + + class BitcoinTest < Minitest::Test + Registration::Payment::Bitcoin::ELECTRUM = Minitest::Mock.new + Registration::Payment::Bitcoin::REDIS = Minitest::Mock.new + Registration::Payment::Bitcoin::BTC_SELL_PRICES = Minitest::Mock.new + Registration::Payment::Bitcoin::BLATHER = Minitest::Mock.new + + def setup + Registration::Payment::Bitcoin::ELECTRUM.expect( + :createnewaddress, + EMPromise.resolve("testaddr") + ) + iq = Blather::Stanza::Iq::Command.new + iq.form.fields = [ + { var: "plan_name", value: "test_usd" } + ] + @bitcoin = Registration::Payment::Bitcoin.new( + iq, + Customer.new("test"), + "+15555550000" + ) + end + + def test_write + reply_text = <<~NOTE + Activate your account by sending at least 1.000000 BTC to + testaddr + + You will receive a notification when your payment is complete. + NOTE + Registration::Payment::Bitcoin::BLATHER.expect( + :<<, + nil, + [Matching.new do |reply| + assert_equal :completed, reply.status + assert_equal :info, reply.note_type + assert_equal reply_text, reply.note.content + true + end] + ) + Registration::Payment::Bitcoin::BTC_SELL_PRICES.expect( + :usd, + EMPromise.resolve(BigDecimal.new(1)) + ) + @bitcoin.stub(:save, EMPromise.resolve(nil)) do + @bitcoin.write.sync + end + Registration::Payment::Bitcoin::BLATHER.verify + end + em :test_write + end + end +end