config.ru

  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							token: gateway.client_token,
347							customer_id: gateway.customer_id,
348							antifraud: atfd,
349							auto_top_up: topup.find(gateway.customer_id) ||
350							             (gateway.payment_methods? ? "" : "15")
351						}
352					)
353				end
354
355				r.post do
356					CardVault
357						.for(
358							gateway, params["braintree_nonce"],
359							params["amount"].to_d
360						).call(params["auto_top_up_amount"].to_i)
361					"OK"
362				rescue ThreeDSecureRepo::Failed
363					gateway.remove_method($!.message)
364					response.status = 400
365					"hostedFieldsFieldsInvalidError"
366				rescue CreditCardGateway::ErrorResult
367					response.status = 400
368					$!.message
369				end
370			end
371		end
372	end
373end
374
375run JmpPay.freeze.app