sgx_jmp.rb

  1# frozen_string_literal: true
  2
  3require "pg/em"
  4require "bigdecimal"
  5require "blather/client"
  6require "braintree"
  7require "dhall"
  8require "em-hiredis"
  9require "em_promise"
 10
 11require_relative "lib/buy_account_credit_form"
 12require_relative "lib/customer"
 13require_relative "lib/em"
 14require_relative "lib/payment_methods"
 15require_relative "lib/transaction"
 16
 17CONFIG =
 18	Dhall::Coder
 19	.new(safe: Dhall::Coder::JSON_LIKE + [Symbol])
 20	.load(ARGV[0], transform_keys: ->(k) { k&.to_sym })
 21
 22# Braintree is not async, so wrap in EM.defer for now
 23class AsyncBraintree
 24	def initialize(environment:, merchant_id:, public_key:, private_key:, **)
 25		@gateway = Braintree::Gateway.new(
 26			environment: environment,
 27			merchant_id: merchant_id,
 28			public_key: public_key,
 29			private_key: private_key
 30		)
 31	end
 32
 33	def respond_to_missing?(m, *)
 34		@gateway.respond_to?(m)
 35	end
 36
 37	def method_missing(m, *args)
 38		return super unless respond_to_missing?(m, *args)
 39
 40		EM.promise_defer(klass: PromiseChain) do
 41			@gateway.public_send(m, *args)
 42		end
 43	end
 44
 45	class PromiseChain < EMPromise
 46		def respond_to_missing?(*)
 47			false # We don't actually know what we respond to...
 48		end
 49
 50		def method_missing(m, *args)
 51			return super if respond_to_missing?(m, *args)
 52			self.then { |o| o.public_send(m, *args) }
 53		end
 54	end
 55end
 56
 57BRAINTREE = AsyncBraintree.new(**CONFIG[:braintree])
 58
 59Blather::DSL.append_features(self.class)
 60
 61def panic(e)
 62	warn "Error raised during event loop: #{e.message}"
 63	exit 1
 64end
 65
 66EM.error_handler(&method(:panic))
 67
 68when_ready do
 69	REDIS = EM::Hiredis.connect
 70	DB = PG::EM::Client.new(dbname: "jmp")
 71	DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
 72	DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
 73
 74	EM.add_periodic_timer(3600) do
 75		ping = Blather::Stanza::Iq::Ping.new(:get, CONFIG[:server][:host])
 76		ping.from = CONFIG[:component][:jid]
 77		self << ping
 78	end
 79end
 80
 81# workqueue_count MUST be 0 or else Blather uses threads!
 82setup(
 83	CONFIG[:component][:jid],
 84	CONFIG[:component][:secret],
 85	CONFIG[:server][:host],
 86	CONFIG[:server][:port],
 87	nil,
 88	nil,
 89	workqueue_count: 0
 90)
 91
 92message :error? do |m|
 93	puts "MESSAGE ERROR: #{m.inspect}"
 94end
 95
 96class SessionManager
 97	def initialize(blather, id_msg, timeout: 5)
 98		@blather = blather
 99		@sessions = {}
100		@id_msg = id_msg
101		@timeout = timeout
102	end
103
104	def promise_for(stanza)
105		id = "#{stanza.to.stripped}/#{stanza.public_send(@id_msg)}"
106		@sessions.fetch(id) do
107			@sessions[id] = EMPromise.new
108			EM.add_timer(@timeout) do
109				@sessions.delete(id)&.reject(:timeout)
110			end
111			@sessions[id]
112		end
113	end
114
115	def write(stanza)
116		promise = promise_for(stanza)
117		@blather << stanza
118		promise
119	end
120
121	def fulfill(stanza)
122		id = "#{stanza.from.stripped}/#{stanza.public_send(@id_msg)}"
123		@sessions.delete(id)&.fulfill(stanza)
124	end
125end
126
127IQ_MANAGER = SessionManager.new(self, :id)
128COMMAND_MANAGER = SessionManager.new(self, :sessionid, timeout: 60 * 60)
129
130disco_items node: "http://jabber.org/protocol/commands" do |iq|
131	reply = iq.reply
132	reply.items = [
133		# TODO: don't show this item if no braintree methods available
134		# TODO: don't show this item if no plan for this customer
135		Blather::Stanza::DiscoItems::Item.new(
136			iq.to,
137			"buy-credit",
138			"Buy account credit"
139		)
140	]
141	self << reply
142end
143
144def reply_with_note(iq, text, type: :info)
145	reply = iq.reply
146	reply.status = :completed
147	reply.note_type = type
148	reply.note_text = text
149
150	self << reply
151end
152
153command :execute?, node: "buy-credit", sessionid: nil do |iq|
154	reply = iq.reply
155	reply.allowed_actions = [:complete]
156
157	Customer.for_jid(iq.from.stripped).then { |customer|
158		BuyAccountCreditForm.new(customer).add_to_form(reply.form).then { customer }
159	}.then { |customer|
160		EMPromise.all([
161			customer.payment_methods,
162			customer.merchant_account,
163			COMMAND_MANAGER.write(reply)
164		])
165	}.then { |(payment_methods, merchant_account, iq2)|
166		iq = iq2 # This allows the catch to use it also
167		payment_method = payment_methods.fetch(
168			iq.form.field("payment_method")&.value.to_i
169		)
170		amount = iq.form.field("amount").value.to_s
171		Transaction.sale(merchant_account, payment_method, amount)
172	}.then { |transaction|
173		transaction.insert.then { transaction.amount }
174	}.then { |amount|
175		reply_with_note(iq, "$#{'%.2f' % amount} added to your account balance.")
176	}.catch { |e|
177		text = "Failed to buy credit, system said: #{e.message}"
178		reply_with_note(iq, text, type: :error)
179	}.catch(&method(:panic))
180end
181
182command sessionid: /./ do |iq|
183	COMMAND_MANAGER.fulfill(iq)
184end
185
186iq :result? do |iq|
187	IQ_MANAGER.fulfill(iq)
188end
189
190iq :error? do |iq|
191	IQ_MANAGER.fulfill(iq)
192end