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