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