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