config.ru

  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