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