1# frozen_string_literal: true
2
3require "braintree"
4require "date"
5require "delegate"
6require "dhall"
7require "pg"
8require "redis"
9require "roda"
10require "uri"
11
12if ENV["RACK_ENV"] == "development"
13 require "pry-rescue"
14 use PryRescue::Rack
15end
16
17require_relative "lib/electrum"
18
19require "sentry-ruby"
20Sentry.init do |config|
21 config.traces_sample_rate = 1
22end
23use Sentry::Rack::CaptureExceptions
24
25REDIS = Redis.new
26PLANS = Dhall.load("env:PLANS").sync
27BRAINTREE_CONFIG = Dhall.load("env:BRAINTREE_CONFIG").sync
28ELECTRUM = Electrum.new(
29 **Dhall::Coder.load("env:ELECTRUM_CONFIG", transform_keys: :to_sym)
30)
31
32DB = PG.connect(dbname: "jmp")
33DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
34DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
35
36class Plan
37 def self.for(plan_name)
38 new(PLANS.find { |p| p[:name].to_s == plan_name })
39 end
40
41 def initialize(plan)
42 @plan = plan
43 end
44
45 def price(months=1)
46 (BigDecimal.new(@plan[:monthly_price].to_i) * months) / 10000
47 end
48
49 def currency
50 @plan[:currency].to_s.to_sym
51 end
52
53 def merchant_account
54 BRAINTREE_CONFIG[:merchant_accounts][currency]
55 end
56
57 def self.active?(customer_id)
58 DB.exec_params(<<~SQL, [customer_id]).first&.[]("count").to_i > 0
59 SELECT count(1) AS count FROM customer_plans
60 WHERE customer_id=$1 AND expires_at > NOW()
61 SQL
62 end
63
64 def activate(customer_id, months)
65 DB.exec_params(
66 "INSERT INTO plan_log VALUES ($1, $2, tsrange($3, $4))",
67 [customer_id, @plan[:name], Time.now, Date.today >> months]
68 )
69 true
70 end
71end
72
73class CreditCardGateway
74 def initialize(jid, customer_id=nil)
75 @jid = jid
76 @customer_id = customer_id
77
78 @gateway = Braintree::Gateway.new(
79 environment: BRAINTREE_CONFIG[:environment].to_s,
80 merchant_id: BRAINTREE_CONFIG[:merchant_id].to_s,
81 public_key: BRAINTREE_CONFIG[:public_key].to_s,
82 private_key: BRAINTREE_CONFIG[:private_key].to_s
83 )
84 end
85
86 def check_customer_id(cid)
87 return cid unless ENV["RACK_ENV"] == "production"
88
89 raise "customer_id does not match" unless @customer_id == cid
90
91 cid
92 end
93
94 def customer_id
95 customer_id = REDIS.get(redis_key_jid)
96 return customer_id if check_customer_id(customer_id)
97
98 result = @gateway.customer.create
99 raise "Braintree customer create failed" unless result.success?
100 @customer_id = result.customer.id
101 save_customer_id!
102 end
103
104 def save_customer_id!
105 unless REDIS.set(redis_key_jid, @customer_id) == "OK"
106 raise "Saving new jid,customer to redis failed"
107 end
108
109 unless REDIS.set(redis_key_customer_id, @jid) == "OK"
110 raise "Saving new customer,jid to redis failed"
111 end
112
113 @customer_id
114 end
115
116 def client_token
117 @gateway.client_token.generate(customer_id: customer_id)
118 end
119
120 def payment_methods?
121 !@gateway.customer.find(customer_id).payment_methods.empty?
122 end
123
124 def default_payment_method=(nonce)
125 @gateway.payment_method.create(
126 customer_id: customer_id,
127 payment_method_nonce: nonce,
128 options: {
129 make_default: true
130 }
131 )
132 end
133
134 def decline_guard(ip)
135 customer_declines, ip_declines = REDIS.mget(
136 "jmp_pay_decline-#{@customer_id}",
137 "jmp_pay_decline-#{ip}"
138 )
139 customer_declines.to_i < 2 && ip_declines.to_i < 4
140 end
141
142 def sale(ip:, **kwargs)
143 return false unless decline_guard(ip)
144 result = @gateway.transaction.sale(**kwargs)
145 return true if result.success?
146
147 REDIS.incr("jmp_pay_decline-#{@customer_id}")
148 REDIS.expire("jmp_pay_decline-#{@customer_id}", 60 * 60 * 24)
149 REDIS.incr("jmp_pay_decline-#{ip}")
150 REDIS.expire("jmp_pay_decline-#{ip}", 60 * 60 * 24)
151 false
152 end
153
154 def buy_plan(plan_name, months, nonce, ip)
155 plan = Plan.for(plan_name)
156 sale(
157 ip: ip,
158 amount: plan.price(months),
159 payment_method_nonce: nonce,
160 merchant_account_id: plan.merchant_account,
161 options: {submit_for_settlement: true}
162 ) && plan.activate(@customer_id, months)
163 end
164
165protected
166
167 def redis_key_jid
168 "jmp_customer_id-#{@jid}"
169 end
170
171 def redis_key_customer_id
172 "jmp_customer_jid-#{@customer_id}"
173 end
174end
175
176class UnknownTransactions
177 def self.from(customer_id, address, tx_hashes)
178 self.for(
179 customer_id,
180 fetch_rows_for(address, tx_hashes).map { |row| row["transaction_id"] }
181 )
182 end
183
184 def self.fetch_rows_for(address, tx_hashes)
185 values = tx_hashes.map do |tx_hash|
186 "('#{DB.escape_string(tx_hash)}/#{DB.escape_string(address)}')"
187 end
188 return [] if values.empty?
189 DB.exec_params(<<-SQL)
190 SELECT transaction_id FROM
191 (VALUES #{values.join(',')}) AS t(transaction_id)
192 LEFT JOIN transactions USING (transaction_id)
193 WHERE transactions.transaction_id IS NULL
194 SQL
195 end
196
197 def self.for(customer_id, transaction_ids)
198 transaction_ids.empty? ? None.new : new(customer_id, transaction_ids)
199 end
200
201 def initialize(customer_id, transaction_ids)
202 @customer_id = customer_id
203 @transaction_ids = transaction_ids
204 end
205
206 def enqueue!
207 REDIS.hset(
208 "pending_btc_transactions",
209 *@transaction_ids.flat_map { |txid| [txid, @customer_id] }
210 )
211 end
212
213 class None
214 def enqueue!; end
215 end
216end
217
218# This class must contain all of the routes because of how the DSL works
219# rubocop:disable Metrics/ClassLength
220class JmpPay < Roda
221 SENTRY_DSN = ENV["SENTRY_DSN"] && URI(ENV["SENTRY_DSN"])
222 plugin :render, engine: "slim"
223 plugin :common_logger, $stdout
224
225 def redis_key_btc_addresses
226 "jmp_customer_btc_addresses-#{request.params['customer_id']}"
227 end
228
229 def verify_address_customer_id(r)
230 return if REDIS.sismember(redis_key_btc_addresses, request.params["address"])
231
232 warn "Address and customer_id do not match"
233 r.halt([
234 403,
235 {"Content-Type" => "text/plain"},
236 "Address and customer_id do not match"
237 ])
238 end
239
240 route do |r|
241 r.on "electrum_notify" do
242 verify_address_customer_id(r)
243
244 UnknownTransactions.from(
245 request.params["customer_id"],
246 request.params["address"],
247 ELECTRUM
248 .getaddresshistory(request.params["address"])
249 .map { |item| item["tx_hash"] }
250 ).enqueue!
251
252 "OK"
253 end
254
255 r.on :jid do |jid|
256 Sentry.set_user(id: request.params["customer_id"], jid: jid)
257
258 gateway = CreditCardGateway.new(
259 jid,
260 request.params["customer_id"]
261 )
262
263 r.on "activate" do
264 Sentry.configure_scope do |scope|
265 scope.set_transaction_name("activate")
266 scope.set_context(
267 "activate",
268 plan_name: request.params["plan_name"]
269 )
270 end
271
272 render = lambda do |l={}|
273 view(
274 "activate",
275 locals: {
276 token: gateway.client_token,
277 customer_id: gateway.customer_id,
278 error: false
279 }.merge(l)
280 )
281 end
282
283 r.get do
284 if Plan.active?(gateway.customer_id)
285 r.redirect request.params["return_to"], 303
286 else
287 render.call
288 end
289 end
290
291 r.post do
292 result = DB.transaction do
293 Plan.active?(gateway.customer_id) || gateway.buy_plan(
294 request.params["plan_name"],
295 5,
296 request.params["braintree_nonce"],
297 request.ip
298 )
299 end
300 if request.params["auto_top_up_amount"].to_i >= 15
301 REDIS.set(
302 "jmp_customer_auto_top_up_amount-#{gateway.customer_id}",
303 request.params["auto_top_up_amount"].to_i
304 )
305 end
306 if result
307 r.redirect request.params["return_to"], 303
308 else
309 render.call(error: true)
310 end
311 end
312 end
313
314 r.on "credit_cards" do
315 r.get do
316 view(
317 "credit_cards",
318 locals: {
319 token: gateway.client_token,
320 customer_id: gateway.customer_id,
321 auto_top_up: REDIS.get(
322 "jmp_customer_auto_top_up_amount-#{gateway.customer_id}"
323 ) || (gateway.payment_methods? ? "" : "15")
324 }
325 )
326 end
327
328 r.post do
329 gateway.default_payment_method = request.params["braintree_nonce"]
330 if request.params["auto_top_up_amount"].to_i >= 15
331 REDIS.set(
332 "jmp_customer_auto_top_up_amount-#{gateway.customer_id}",
333 request.params["auto_top_up_amount"].to_i
334 )
335 elsif request.params["auto_top_up_amount"].to_i == 0
336 REDIS.del(
337 "jmp_customer_auto_top_up_amount-#{gateway.customer_id}"
338 )
339 end
340 "OK"
341 end
342 end
343 end
344 end
345end
346# rubocop:enable Metrics/ClassLength
347
348run JmpPay.freeze.app