From 579c4fe3e7fd06584296ff3180b068ba543e26c3 Mon Sep 17 00:00:00 2001 From: Stephen Paul Weber Date: Tue, 23 Feb 2021 14:39:04 -0500 Subject: [PATCH] Write initial buy credit command --- .gitmodules | 3 + .rubocop.yml | 12 +++ Gemfile | 10 +- config.dhall.sample | 6 ++ em_promise.rb | 52 ---------- schemas | 1 + sgx_jmp.rb | 233 +++++++++++++++++++++++++++++++++++++++++++- 7 files changed, 262 insertions(+), 55 deletions(-) create mode 100644 .gitmodules delete mode 100644 em_promise.rb create mode 160000 schemas diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..7b04718 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "schemas"] + path = schemas + url = https://git.singpolyma.net/jmp-schemas diff --git a/.rubocop.yml b/.rubocop.yml index 41acaa6..67d9c1a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -28,3 +28,15 @@ Style/DoubleNegation: Layout/SpaceAroundEqualsInParameterDefault: EnforcedStyle: no_space + +Layout/AccessModifierIndentation: + EnforcedStyle: outdent + +Style/BlockDelimiters: + EnforcedStyle: braces_for_chaining + +Style/MultilineBlockChain: + Enabled: false + +Layout/IndentArray: + EnforcedStyle: consistent diff --git a/Gemfile b/Gemfile index 70b28ed..91d946a 100644 --- a/Gemfile +++ b/Gemfile @@ -3,6 +3,14 @@ source "https://rubygems.org" gem "blather" +gem "braintree" gem "dhall" +gem "em-hiredis" +gem "em-pg-client", git: "https://github.com/royaltm/ruby-em-pg-client" +gem "em_promise.rb" gem "eventmachine" -gem "promise.rb" +gem "time-hash" + +group(:development) do + gem "pry-remote-em" +end diff --git a/config.dhall.sample b/config.dhall.sample index 4e3afae..b78d77d 100644 --- a/config.dhall.sample +++ b/config.dhall.sample @@ -12,5 +12,11 @@ nick = "userid", username = "token", password = "secret" + }, + braintree = { + environment = "sandbox", + merchant_id = "", + public_key = "", + private_key = "" } } diff --git a/em_promise.rb b/em_promise.rb deleted file mode 100644 index 866f337..0000000 --- a/em_promise.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -require "eventmachine" -require "promise" - -class EMPromise < Promise - def initialize(deferrable=nil) - super() - fulfill(deferrable) if deferrable - end - - def fulfill(value, bind_defer=true) - if bind_defer && value.is_a?(EM::Deferrable) - value.callback { |x| fulfill(x, false) } - value.errback(&method(:reject)) - else - super(value) - end - end - - def defer - EM.next_tick { yield } - end - - def wait - fiber = Fiber.current - resume = proc do |arg| - defer { fiber.resume(arg) } - end - - self.then(resume, resume) - Fiber.yield - end - - def self.reject(e) - new.tap { |promise| promise.reject(e) } - end -end - -module EventMachine - module Deferrable - def promise - EMPromise.new(self) - end - - [:then, :rescue, :catch].each do |method| - define_method(method) do |*args, &block| - promise.public_send(method, *args, &block) - end - end - end -end diff --git a/schemas b/schemas new file mode 160000 index 0000000..b0729ab --- /dev/null +++ b/schemas @@ -0,0 +1 @@ +Subproject commit b0729aba768a943ed9f695d1468f1c62f2076727 diff --git a/sgx_jmp.rb b/sgx_jmp.rb index a672ea2..69163ba 100644 --- a/sgx_jmp.rb +++ b/sgx_jmp.rb @@ -1,12 +1,57 @@ # frozen_string_literal: true +require "pg/em" +require "bigdecimal" require "blather/client" +require "braintree" require "dhall" - -require_relative "em_promise" +require "em-hiredis" +require "em_promise" +require "time-hash" CONFIG = Dhall::Coder.load(ARGV[0]) +# Braintree is not async, so wrap in EM.defer for now +class AsyncBraintree + def initialize(environment:, merchant_id:, public_key:, private_key:) + @gateway = Braintree::Gateway.new( + environment: environment, + merchant_id: merchant_id, + public_key: public_key, + private_key: private_key + ) + end + + def respond_to_missing?(m, *) + @gateway.respond_to?(m) + end + + def method_missing(m, *args) + return super unless respond_to_missing?(m, *args) + + promise = PromiseChain.new + EventMachine.defer( + -> { @gateway.public_send(m, *args) }, + promise.method(:fulfill), + promise.method(:reject) + ) + promise + end + + class PromiseChain < EMPromise + def respond_to_missing?(*) + false # We don't actually know what we respond to... + end + + def method_missing(m, *args) + return super if false # cover everything for now + self.then { |o| o.public_send(m, *args) } + end + end +end + +BRAINTREE = AsyncBraintree.new(**CONFIG["braintree"].transform_keys(&:to_sym)) + def node(name, parent, ns: nil) Niceogiri::XML::Node.new( name, @@ -103,7 +148,19 @@ end Blather::DSL.append_features(self.class) +def panic(e) + warn "Error raised during event loop: #{e.message}" + exit 1 +end + +EM.error_handler(&method(:panic)) + when_ready do + REDIS = EM::Hiredis.connect + 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) + EM.add_periodic_timer(3600) do ping = Blather::Stanza::Iq::Ping.new(:get, CONFIG["server"]["host"]) ping.from = CONFIG["component"]["jid"] @@ -177,3 +234,175 @@ ibr :set? do |iq| fwd.id = "JMPSET%#{iq.id}" self << fwd end + +@command_sessions = TimeHash.new +def command_reply_and_promise(reply) + promise = EMPromise.new + @command_sessions.put(reply.sessionid, promise, 60 * 60) + self << reply + promise +end + +def command_reply_and_done(reply) + @command_sessions.delete(reply.sessionid) + self << reply +end + +class XEP0122Field + attr_reader :field + + def initialize(type, range: nil, **field) + @type = type + @range = range + @field = Blather::Stanza::X::Field.new(**field) + @field.add_child(validate) + end + +protected + + def validate + validate = Nokogiri::XML::Node.new("validate", field.document) + validate["xmlns"] = "http://jabber.org/protocol/xdata-validate" + validate["datatype"] = @type + validate.add_child(validation) + validate + end + + def validation + range_node || begin + validation = Nokogiri::XML::Node.new("basic", field.document) + validation["xmlns"] = "http://jabber.org/protocol/xdata-validate" + end + end + + def range_node + return unless @range + + validation = Nokogiri::XML::Node.new("range", field.document) + validation["xmlns"] = "http://jabber.org/protocol/xdata-validate" + validation["min"] = @range.min.to_s if @range.min + validation["max"] = @range.max.to_s if @range.max + end +end + +disco_items node: "http://jabber.org/protocol/commands" do |iq| + reply = iq.reply + reply.items = [ + # TODO: don't show this item if no braintree methods available + Blather::Stanza::DiscoItems::Item.new( + iq.to, + "buy-credit", + "Buy account credit" + ) + ] + self << reply +end + +command :execute?, node: "buy-credit", sessionid: nil do |iq| + reply = iq.reply + reply.new_sessionid! + reply.node = iq.node + reply.status = :executing + reply.allowed_actions = [:complete] + + REDIS.get("jmp_customer_id-#{iq.from.stripped}").then { |customer_id| + raise "No customer id" unless customer_id + + EMPromise.all([ + DB.query_defer( + "SELECT balance FROM balances WHERE customer_id=$1 LIMIT 1", + [customer_id] + ).then do |rows| + rows.first&.dig("balance") || BigDecimal.new(0) + end, + BRAINTREE.customer.find(customer_id).payment_methods + ]) + }.then { |(balance, payment_methods)| + raise "No payment methods available" if payment_methods.empty? + + default_payment_method = payment_methods.index(&:default?) + + form = reply.form + form.type = :form + form.title = "Buy Account Credit" + form.fields = [ + { + type: "fixed", + value: "Current balance: $#{balance.to_s('F')}" + }, + { + var: "payment_method", + type: "list-single", + label: "Credit card to pay with", + value: default_payment_method.to_s, + required: true, + options: payment_methods.map.with_index do |method, idx| + { + value: idx.to_s, + label: "#{method.card_type} #{method.last_4}" + } + end + }, + XEP0122Field.new( + "xs:decimal", + range: (0..1000), + var: "amount", + label: "Amount of credit to buy", + required: true + ).field + ] + + EMPromise.all([ + payment_methods, + command_reply_and_promise(reply) + ]) + }.then { |(payment_methods, iq2)| + iq = iq2 # This allows the catch to use it also + payment_method = payment_methods.fetch( + iq.form.field("payment_method").value.to_i + ) + BRAINTREE.transaction.sale( + amount: iq.form.field("amount").value.to_s, + payment_method_token: payment_method.token + ) + }.then { |braintree_response| + raise braintree_response.message unless braintree_response.success? + transaction = braintree_response.transaction + + DB.exec_defer( + "INSERT INTO transactions " \ + "(customer_id, transaction_id, created_at, amount) " \ + "VALUES($1, $2, $3, $4)", + [ + transaction.customer_details.id, + transaction.id, + transaction.created_at, + transaction.amount + ] + ).then { transaction.amount } + }.then { |amount| + reply2 = iq.reply + reply2.command[:sessionid] = iq.sessionid + reply2.node = iq.node + reply2.status = :completed + note = reply2.note + note[:type] = :info + note.content = "$#{amount.to_s('F')} added to your account balance." + + command_reply_and_done(reply2) + }.catch { |e| + reply2 = iq.reply + reply2.command[:sessionid] = iq.sessionid + reply2.node = iq.node + reply2.status = :completed + note = reply2.note + note[:type] = :error + note.content = "Failed to buy credit, system said: #{e.message}" + + command_reply_and_done(reply2) + }.catch(&method(:panic)) +end + +command sessionid: /./ do |iq| + @command_sessions[iq.sessionid]&.fulfill(iq) +end -- 2.45.2