Write initial buy credit command

Stephen Paul Weber created

Change summary

.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(-)

Detailed changes

.gitmodules 🔗

@@ -0,0 +1,3 @@
+[submodule "schemas"]
+	path = schemas
+	url = https://git.singpolyma.net/jmp-schemas

.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

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

config.dhall.sample 🔗

@@ -12,5 +12,11 @@
 		nick = "userid",
 		username = "token",
 		password = "secret"
+	},
+	braintree = {
+		environment = "sandbox",
+		merchant_id = "",
+		public_key = "",
+		private_key = ""
 	}
 }

em_promise.rb 🔗

@@ -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

schemas 🔗

@@ -0,0 +1 @@
+Subproject commit b0729aba768a943ed9f695d1468f1c62f2076727

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