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