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