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 default_method(nonce)
138		result = antifraud || @gateway.payment_method.create(
139			customer_id: customer_id, payment_method_nonce: nonce,
140			options: { verify_card: true, make_default: true }
141		)
142
143		return result if result.success?
144
145		incr_antifraud!
146		raise ErrorResult.for(result)
147	end
148
149	def remove_method(token)
150		@gateway.payment_method.delete(token)
151	end
152
153protected
154
155	def redis_key_jid
156		"jmp_customer_id-#{@jid}"
157	end
158
159	def redis_key_customer_id
160		"jmp_customer_jid-#{@customer_id}"
161	end
162end
163
164class UnknownTransactions
165	def self.from(customer_id, address, tx_hashes)
166		self.for(
167			customer_id,
168			fetch_rows_for(address, tx_hashes).map { |row|
169				row["transaction_id"]
170			}
171		)
172	end
173
174	def self.fetch_rows_for(address, tx_hashes)
175		values = tx_hashes.map { |tx_hash|
176			"('#{DB.escape_string(tx_hash)}/#{DB.escape_string(address)}')"
177		}
178		return [] if values.empty?
179
180		DB.exec_params(<<-SQL)
181			SELECT transaction_id FROM
182				(VALUES #{values.join(',')}) AS t(transaction_id)
183				LEFT JOIN transactions USING (transaction_id)
184			WHERE transactions.transaction_id IS NULL
185		SQL
186	end
187
188	def self.for(customer_id, transaction_ids)
189		transaction_ids.empty? ? None.new : new(customer_id, transaction_ids)
190	end
191
192	def initialize(customer_id, transaction_ids)
193		@customer_id = customer_id
194		@transaction_ids = transaction_ids
195	end
196
197	def enqueue!
198		REDIS.hset(
199			"pending_btc_transactions",
200			*@transaction_ids.flat_map { |txid| [txid, @customer_id] }
201		)
202	end
203
204	class None
205		def enqueue!; end
206	end
207end
208
209class JmpPay < Roda
210	SENTRY_DSN = ENV["SENTRY_DSN"] && URI(ENV["SENTRY_DSN"])
211	plugin :render, engine: "slim"
212	plugin :cookies, path: "/"
213	plugin :common_logger, $stdout
214
215	extend Forwardable
216	def_delegators :request, :params
217
218	def redis_key_btc_addresses
219		"jmp_customer_btc_addresses-#{params['customer_id']}"
220	end
221
222	def verify_address_customer_id(r)
223		return if REDIS.sismember(redis_key_btc_addresses, params["address"])
224
225		warn "Address and customer_id do not match"
226		r.halt([
227			403,
228			{ "Content-Type" => "text/plain" },
229			"Address and customer_id do not match"
230		])
231	end
232
233	route do |r|
234		r.on "electrum_notify" do
235			verify_address_customer_id(r)
236
237			UnknownTransactions.from(
238				params["customer_id"],
239				params["address"],
240				ELECTRUM
241					.getaddresshistory(params["address"])
242					.map { |item| item["tx_hash"] }
243			).enqueue!
244
245			"OK"
246		end
247
248		r.on :jid do |jid|
249			Sentry.set_user(id: params["customer_id"], jid: jid)
250
251			atfd = r.cookies["atfd"] || SecureRandom.uuid
252			one_year = 60 * 60 * 24 * 365
253			response.set_cookie(
254				"atfd",
255				value: atfd, expires: Time.now + one_year
256			)
257			params.delete("atfd") if params["atfd"].to_s == ""
258			antifrauds = [atfd, r.ip, params["atfd"]].compact.uniq
259			customer_id = params["customer_id"]
260			gateway = CreditCardGateway.new(jid, customer_id, antifrauds)
261			topup = AutoTopUpRepo.new
262
263			r.on "credit_cards" do
264				r.get do
265					if gateway.antifraud
266						return view(
267							:message,
268							locals: { message: "Please contact support" }
269						)
270					end
271
272					view(
273						"credit_cards",
274						locals: {
275							token: gateway.client_token,
276							customer_id: gateway.customer_id,
277							antifraud: atfd,
278							auto_top_up: topup.find(gateway.customer_id) ||
279							             (gateway.payment_methods? ? "" : "15")
280						}
281					)
282				end
283
284				r.post do
285					result = gateway.default_method(params["braintree_nonce"])
286					ThreeDSecureRepo.new.put_from_payment_method(
287						gateway.customer_id,
288						result.payment_method
289					)
290					topup.put(
291						gateway.customer_id,
292						params["auto_top_up_amount"].to_i
293					)
294					"OK"
295				rescue ThreeDSecureRepo::Failed
296					gateway.remove_method($!.message)
297					response.status = 400
298					"hostedFieldsFieldsInvalidError"
299				rescue CreditCardGateway::ErrorResult
300					response.status = 400
301					$!.message
302				end
303			end
304		end
305	end
306end
307
308run JmpPay.freeze.app