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