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