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)
 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