~singpolyma/sgx-jmp

579c4fe3e7fd06584296ff3180b068ba543e26c3 — Stephen Paul Weber 3 years ago d0f1728
Write initial buy credit command
7 files changed, 262 insertions(+), 55 deletions(-)

A .gitmodules
M .rubocop.yml
M Gemfile
M config.dhall.sample
D em_promise.rb
A schemas
M sgx_jmp.rb
A .gitmodules => .gitmodules +3 -0
@@ 0,0 1,3 @@
[submodule "schemas"]
	path = schemas
	url = https://git.singpolyma.net/jmp-schemas

M .rubocop.yml => .rubocop.yml +12 -0
@@ 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

M Gemfile => Gemfile +9 -1
@@ 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

M config.dhall.sample => config.dhall.sample +6 -0
@@ 12,5 12,11 @@
		nick = "userid",
		username = "token",
		password = "secret"
	},
	braintree = {
		environment = "sandbox",
		merchant_id = "",
		public_key = "",
		private_key = ""
	}
}

D em_promise.rb => em_promise.rb +0 -52
@@ 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

A schemas => schemas +1 -0
@@ 0,0 1,1 @@
Subproject commit b0729aba768a943ed9f695d1468f1c62f2076727

M sgx_jmp.rb => sgx_jmp.rb +231 -2
@@ 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