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