1# frozen_string_literal: true
2
3require "braintree"
4require "bigdecimal/util"
5require "date"
6require "delegate"
7require "dhall"
8require "forwardable"
9require "pg"
10require "redis"
11require "roda"
12require "uri"
13
14if ENV["RACK_ENV"] == "development"
15 require "pry-rescue"
16 use PryRescue::Rack
17end
18
19require_relative "lib/auto_top_up_repo"
20require_relative "lib/customer"
21require_relative "lib/three_d_secure_repo"
22require_relative "lib/electrum"
23require_relative "lib/transaction"
24
25require "sentry-ruby"
26Sentry.init do |config|
27 config.traces_sample_rate = 0.01
28end
29use Sentry::Rack::CaptureExceptions
30
31REDIS = Redis.new
32PLANS = Dhall.load("env:PLANS").sync
33BRAINTREE_CONFIG = Dhall.load("env:BRAINTREE_CONFIG").sync
34ELECTRUM = Electrum.new(
35 **Dhall::Coder.load("env:ELECTRUM_CONFIG", transform_keys: :to_sym)
36)
37ELECTRUM_BCH = Electrum.new(
38 **Dhall::Coder.load("env:ELECTRON_CASH_CONFIG", transform_keys: :to_sym)
39)
40
41DB = PG.connect(dbname: "jmp")
42DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
43DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
44
45class CreditCardGateway
46 class ErrorResult < StandardError
47 def self.for(result)
48 if result.verification&.status == "gateway_rejected" &&
49 result.verification&.gateway_rejection_reason == "cvv"
50 new("fieldInvalidForCvv")
51 else
52 new(result.message)
53 end
54 end
55 end
56
57 def initialize(jid, customer_id, antifraud)
58 @jid = jid
59 @customer_id = customer_id
60 @antifraud = antifraud
61
62 @gateway = Braintree::Gateway.new(
63 environment: BRAINTREE_CONFIG[:environment].to_s,
64 merchant_id: BRAINTREE_CONFIG[:merchant_id].to_s,
65 public_key: BRAINTREE_CONFIG[:public_key].to_s,
66 private_key: BRAINTREE_CONFIG[:private_key].to_s
67 )
68 end
69
70 def check_customer_id(cid)
71 return cid unless ENV["RACK_ENV"] == "production"
72
73 raise "customer_id does not match" unless @customer_id == cid
74
75 cid
76 end
77
78 def customer_id
79 customer_id = Customer.new(nil, @jid).customer_id
80 return customer_id if check_customer_id(customer_id)
81
82 result = @gateway.customer.create
83 raise "Braintree customer create failed" unless result.success?
84
85 @customer_id = result.customer.id
86 Customer.new(@customer_id, @jid).save!
87 @customer_id
88 end
89
90 def customer_plan
91 name = DB.exec_params(<<~SQL, [customer_id]).first&.[]("plan_name")
92 SELECT plan_name FROM customer_plans WHERE customer_id=$1 LIMIT 1
93 SQL
94 PLANS.find { |plan| plan[:name].to_s == name }
95 end
96
97 def merchant_account
98 plan = customer_plan
99 return unless plan
100
101 BRAINTREE_CONFIG[:merchant_accounts][plan[:currency]]
102 end
103
104 def client_token
105 kwargs = {}
106 kwargs[:merchant_account_id] = merchant_account.to_s if merchant_account
107 @gateway.client_token.generate(customer_id: customer_id, **kwargs)
108 end
109
110 def payment_methods?
111 !@gateway.customer.find(customer_id).payment_methods.empty?
112 end
113
114 def antifraud
115 return if REDIS.exists?("jmp_antifraud_bypass-#{customer_id}")
116
117 REDIS.mget(@antifraud.map { |k| "jmp_antifraud-#{k}" }).find do |anti|
118 anti.to_i > 2
119 end &&
120 Braintree::ErrorResult.new(
121 @gateway, errors: {}, message: "Please contact support"
122 )
123 end
124
125 def with_antifraud
126 result = antifraud || yield
127 return result if result.success?
128
129 @antifraud.each do |k|
130 REDIS.incr("jmp_antifraud-#{k}")
131 REDIS.expire("jmp_antifraud-#{k}", 60 * 60 * 24)
132 end
133
134 raise ErrorResult.for(result)
135 end
136
137 def sale(nonce, amount)
138 with_antifraud do
139 @gateway.transaction.sale(
140 customer_id: customer_id, payment_method_nonce: nonce,
141 amount: amount, merchant_account_id: merchant_account.to_s,
142 options: {
143 store_in_vault_on_success: true, submit_for_settlement: true
144 }
145 )
146 end
147 end
148
149 def default_method(nonce)
150 with_antifraud do
151 @gateway.payment_method.create(
152 customer_id: customer_id, payment_method_nonce: nonce,
153 options: {
154 verify_card: true, make_default: true,
155 verification_merchant_account_id: merchant_account.to_s
156 }
157 )
158 end
159 end
160
161 def remove_method(token)
162 @gateway.payment_method.delete(token)
163 end
164end
165
166class UnknownTransactions
167 def self.from(currency, customer_id, address, tx_hashes)
168 self.for(
169 currency,
170 customer_id,
171 fetch_rows_for(address, tx_hashes).map { |row|
172 row["transaction_id"]
173 }
174 )
175 end
176
177 def self.fetch_rows_for(address, tx_hashes)
178 values = tx_hashes.map { |tx_hash|
179 "('#{DB.escape_string(tx_hash)}/#{DB.escape_string(address)}')"
180 }
181 return [] if values.empty?
182
183 DB.exec_params(<<-SQL)
184 SELECT transaction_id FROM
185 (VALUES #{values.join(',')}) AS t(transaction_id)
186 LEFT JOIN transactions USING (transaction_id)
187 WHERE transactions.transaction_id IS NULL
188 SQL
189 end
190
191 def self.for(currency, customer_id, transaction_ids)
192 return None.new if transaction_ids.empty?
193
194 new(currency, customer_id, transaction_ids)
195 end
196
197 def initialize(currency, customer_id, transaction_ids)
198 @currency = currency
199 @customer_id = customer_id
200 @transaction_ids = transaction_ids
201 end
202
203 def enqueue!
204 REDIS.hset(
205 "pending_#{@currency}_transactions",
206 *@transaction_ids.flat_map { |txid| [txid, @customer_id] }
207 )
208 end
209
210 class None
211 def enqueue!; end
212 end
213end
214
215class CardVault
216 def self.for(gateway, nonce, amount=nil)
217 if amount&.positive?
218 CardDeposit.new(gateway, nonce, amount)
219 else
220 new(gateway, nonce)
221 end
222 end
223
224 def initialize(gateway, nonce)
225 @gateway = gateway
226 @nonce = nonce
227 end
228
229 def call(auto_top_up_amount)
230 result = vault!
231 ThreeDSecureRepo.new.put_from_result(result)
232 AutoTopUpRepo.new.put(
233 @gateway.customer_id,
234 auto_top_up_amount
235 )
236 result
237 end
238
239 def vault!
240 @gateway.default_method(@nonce)
241 end
242
243 class CardDeposit < self
244 def initialize(gateway, nonce, amount)
245 super(gateway, nonce)
246 @amount = amount
247
248 return unless @amount < 15 || @amount > 35
249
250 raise CreditCardGateway::ErrorResult, "amount too low or too high"
251 end
252
253 def call(*)
254 result = super
255 Transaction.new(
256 @gateway.customer_id,
257 result.transaction.id,
258 @amount,
259 "Credit card payment"
260 ).save
261 result
262 end
263
264 def vault!
265 @gateway.sale(@nonce, @amount)
266 end
267 end
268end
269
270class JmpPay < Roda
271 SENTRY_DSN = ENV["SENTRY_DSN"] && URI(ENV["SENTRY_DSN"])
272 plugin :render, engine: "slim"
273 plugin :cookies, path: "/"
274 plugin :common_logger, $stdout
275
276 extend Forwardable
277 def_delegators :request, :params
278
279 def electrum
280 case params["currency"]
281 when "bch"
282 ELECTRUM_BCH
283 else
284 ELECTRUM
285 end
286 end
287
288 def redis_key_btc_addresses
289 "jmp_customer_#{electrum.currency}_addresses-#{params['customer_id']}"
290 end
291
292 def verify_address_customer_id(r)
293 return if REDIS.sismember(redis_key_btc_addresses, params["address"])
294
295 warn "Address and customer_id do not match"
296 r.halt([
297 403,
298 { "Content-Type" => "text/plain" },
299 "Address and customer_id do not match"
300 ])
301 end
302
303 route do |r|
304 r.on "electrum_notify" do
305 verify_address_customer_id(r)
306
307 UnknownTransactions.from(
308 electrum.currency,
309 params["customer_id"],
310 params["address"],
311 electrum
312 .getaddresshistory(params["address"])
313 .map { |item| item["tx_hash"] }
314 ).enqueue!
315
316 "OK"
317 end
318
319 r.on :jid do |jid|
320 Sentry.set_user(id: params["customer_id"], jid: jid)
321
322 atfd = r.cookies["atfd"] || SecureRandom.uuid
323 one_year = 60 * 60 * 24 * 365
324 response.set_cookie(
325 "atfd",
326 value: atfd, expires: Time.now + one_year
327 )
328 params.delete("atfd") if params["atfd"].to_s == ""
329 antifrauds = [atfd, r.ip, params["atfd"]].compact.uniq
330 customer_id = params["customer_id"]
331 gateway = CreditCardGateway.new(jid, customer_id, antifrauds)
332 topup = AutoTopUpRepo.new
333
334 r.on "credit_cards" do
335 r.get do
336 if gateway.antifraud
337 return view(
338 :message,
339 locals: { message: "Please contact support" }
340 )
341 end
342
343 view(
344 "credit_cards",
345 locals: {
346 jid: jid,
347 token: gateway.client_token,
348 customer_id: gateway.customer_id,
349 antifraud: atfd,
350 auto_top_up: topup.find(gateway.customer_id) ||
351 (gateway.payment_methods? ? "" : "15")
352 }
353 )
354 end
355
356 r.post do
357 CardVault
358 .for(
359 gateway, params["braintree_nonce"],
360 params["amount"].to_d
361 ).call(params["auto_top_up_amount"].to_i)
362 "OK"
363 rescue ThreeDSecureRepo::Failed
364 gateway.remove_method($!.message)
365 response.status = 400
366 "hostedFieldsFieldsInvalidError"
367 rescue CreditCardGateway::ErrorResult
368 response.status = 400
369 $!.message
370 end
371 end
372 end
373 end
374end
375
376run JmpPay.freeze.app