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