config.ru

  1# frozen_string_literal: true
  2
  3require "braintree"
  4require "date"
  5require "delegate"
  6require "dhall"
  7require "pg"
  8require "redis"
  9require "roda"
 10require "uri"
 11
 12if ENV["RACK_ENV"] == "development"
 13	require "pry-rescue"
 14	use PryRescue::Rack
 15end
 16
 17require_relative "lib/electrum"
 18
 19require "sentry-ruby"
 20Sentry.init do |config|
 21	config.traces_sample_rate = 1
 22end
 23use Sentry::Rack::CaptureExceptions
 24
 25REDIS = Redis.new
 26PLANS = Dhall.load("env:PLANS").sync
 27BRAINTREE_CONFIG = Dhall.load("env:BRAINTREE_CONFIG").sync
 28ELECTRUM = Electrum.new(
 29	**Dhall::Coder.load("env:ELECTRUM_CONFIG", transform_keys: :to_sym)
 30)
 31
 32DB = PG.connect(dbname: "jmp")
 33DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
 34DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
 35
 36class Plan
 37	def self.for(plan_name)
 38		new(PLANS.find { |p| p[:name].to_s == plan_name })
 39	end
 40
 41	def initialize(plan)
 42		@plan = plan
 43	end
 44
 45	def price(months=1)
 46		(BigDecimal.new(@plan[:monthly_price].to_i) * months) / 10000
 47	end
 48
 49	def currency
 50		@plan[:currency].to_s.to_sym
 51	end
 52
 53	def merchant_account
 54		BRAINTREE_CONFIG[:merchant_accounts][currency]
 55	end
 56
 57	def self.active?(customer_id)
 58		DB.exec_params(<<~SQL, [customer_id]).first&.[]("count").to_i > 0
 59			SELECT count(1) AS count FROM customer_plans
 60			WHERE customer_id=$1 AND expires_at > NOW()
 61		SQL
 62	end
 63
 64	def activate(customer_id, months)
 65		DB.exec_params(
 66			"INSERT INTO plan_log VALUES ($1, $2, tsrange($3, $4))",
 67			[customer_id, @plan[:name], Time.now, Date.today >> months]
 68		)
 69		true
 70	end
 71end
 72
 73class CreditCardGateway
 74	def initialize(jid, customer_id=nil)
 75		@jid = jid
 76		@customer_id = customer_id
 77
 78		@gateway = Braintree::Gateway.new(
 79			environment: BRAINTREE_CONFIG[:environment].to_s,
 80			merchant_id: BRAINTREE_CONFIG[:merchant_id].to_s,
 81			public_key: BRAINTREE_CONFIG[:public_key].to_s,
 82			private_key: BRAINTREE_CONFIG[:private_key].to_s
 83		)
 84	end
 85
 86	def check_customer_id(cid)
 87		return cid unless ENV["RACK_ENV"] == "production"
 88
 89		raise "customer_id does not match" unless @customer_id == cid
 90
 91		cid
 92	end
 93
 94	def customer_id
 95		customer_id = REDIS.get(redis_key_jid)
 96		return customer_id if check_customer_id(customer_id)
 97
 98		result = @gateway.customer.create
 99		raise "Braintree customer create failed" unless result.success?
100		@customer_id = result.customer.id
101		save_customer_id!
102	end
103
104	def save_customer_id!
105		unless REDIS.set(redis_key_jid, @customer_id) == "OK"
106			raise "Saving new jid,customer to redis failed"
107		end
108
109		unless REDIS.set(redis_key_customer_id, @jid) == "OK"
110			raise "Saving new customer,jid to redis failed"
111		end
112
113		@customer_id
114	end
115
116	def client_token
117		@gateway.client_token.generate(customer_id: customer_id)
118	end
119
120	def payment_methods?
121		!@gateway.customer.find(customer_id).payment_methods.empty?
122	end
123
124	def default_payment_method=(nonce)
125		@gateway.payment_method.create(
126			customer_id: customer_id,
127			payment_method_nonce: nonce,
128			options: {
129				make_default: true
130			}
131		)
132	end
133
134	def decline_guard(ip)
135		customer_declines, ip_declines = REDIS.mget(
136			"jmp_pay_decline-#{@customer_id}",
137			"jmp_pay_decline-#{ip}"
138		)
139		customer_declines.to_i < 2 && ip_declines.to_i < 4
140	end
141
142	def sale(ip:, **kwargs)
143		return false unless decline_guard(ip)
144		result = @gateway.transaction.sale(**kwargs)
145		return true if result.success?
146
147		REDIS.incr("jmp_pay_decline-#{@customer_id}")
148		REDIS.expire("jmp_pay_decline-#{@customer_id}", 60 * 60 * 24)
149		REDIS.incr("jmp_pay_decline-#{ip}")
150		REDIS.expire("jmp_pay_decline-#{ip}", 60 * 60 * 24)
151		false
152	end
153
154	def buy_plan(plan_name, months, nonce, ip)
155		plan = Plan.for(plan_name)
156		sale(
157			ip: ip,
158			amount: plan.price(months),
159			payment_method_nonce: nonce,
160			merchant_account_id: plan.merchant_account,
161			options: {submit_for_settlement: true}
162		) && plan.activate(@customer_id, months)
163	end
164
165protected
166
167	def redis_key_jid
168		"jmp_customer_id-#{@jid}"
169	end
170
171	def redis_key_customer_id
172		"jmp_customer_jid-#{@customer_id}"
173	end
174end
175
176class UnknownTransactions
177	def self.from(customer_id, address, tx_hashes)
178		self.for(
179			customer_id,
180			fetch_rows_for(address, tx_hashes).map { |row| row["transaction_id"] }
181		)
182	end
183
184	def self.fetch_rows_for(address, tx_hashes)
185		values = tx_hashes.map do |tx_hash|
186			"('#{DB.escape_string(tx_hash)}/#{DB.escape_string(address)}')"
187		end
188		return [] if values.empty?
189		DB.exec_params(<<-SQL)
190			SELECT transaction_id FROM
191				(VALUES #{values.join(',')}) AS t(transaction_id)
192				LEFT JOIN transactions USING (transaction_id)
193			WHERE transactions.transaction_id IS NULL
194		SQL
195	end
196
197	def self.for(customer_id, transaction_ids)
198		transaction_ids.empty? ? None.new : new(customer_id, transaction_ids)
199	end
200
201	def initialize(customer_id, transaction_ids)
202		@customer_id = customer_id
203		@transaction_ids = transaction_ids
204	end
205
206	def enqueue!
207		REDIS.hset(
208			"pending_btc_transactions",
209			*@transaction_ids.flat_map { |txid| [txid, @customer_id] }
210		)
211	end
212
213	class None
214		def enqueue!; end
215	end
216end
217
218# This class must contain all of the routes because of how the DSL works
219# rubocop:disable Metrics/ClassLength
220class JmpPay < Roda
221	SENTRY_DSN = ENV["SENTRY_DSN"] && URI(ENV["SENTRY_DSN"])
222	plugin :render, engine: "slim"
223	plugin :common_logger, $stdout
224
225	def redis_key_btc_addresses
226		"jmp_customer_btc_addresses-#{request.params['customer_id']}"
227	end
228
229	def verify_address_customer_id(r)
230		return if REDIS.sismember(redis_key_btc_addresses, request.params["address"])
231
232		warn "Address and customer_id do not match"
233		r.halt([
234			403,
235			{"Content-Type" => "text/plain"},
236			"Address and customer_id do not match"
237		])
238	end
239
240	route do |r|
241		r.on "electrum_notify" do
242			verify_address_customer_id(r)
243
244			UnknownTransactions.from(
245				request.params["customer_id"],
246				request.params["address"],
247				ELECTRUM
248					.getaddresshistory(request.params["address"])
249					.map { |item| item["tx_hash"] }
250			).enqueue!
251
252			"OK"
253		end
254
255		r.on :jid do |jid|
256			Sentry.set_user(id: request.params["customer_id"], jid: jid)
257
258			gateway = CreditCardGateway.new(
259				jid,
260				request.params["customer_id"]
261			)
262
263			r.on "activate" do
264				Sentry.configure_scope do |scope|
265					scope.set_transaction_name("activate")
266					scope.set_context(
267						"activate",
268						plan_name: request.params["plan_name"]
269					)
270				end
271
272				render = lambda do |l={}|
273					view(
274						"activate",
275						locals: {
276							token: gateway.client_token,
277							customer_id: gateway.customer_id,
278							error: false
279						}.merge(l)
280					)
281				end
282
283				r.get do
284					if Plan.active?(gateway.customer_id)
285						r.redirect request.params["return_to"], 303
286					else
287						render.call
288					end
289				end
290
291				r.post do
292					result = DB.transaction do
293						Plan.active?(gateway.customer_id) || gateway.buy_plan(
294							request.params["plan_name"],
295							5,
296							request.params["braintree_nonce"],
297							request.ip
298						)
299					end
300					if request.params["auto_top_up_amount"].to_i >= 15
301						REDIS.set(
302							"jmp_customer_auto_top_up_amount-#{gateway.customer_id}",
303							request.params["auto_top_up_amount"].to_i
304						)
305					end
306					if result
307						r.redirect request.params["return_to"], 303
308					else
309						render.call(error: true)
310					end
311				end
312			end
313
314			r.on "credit_cards" do
315				r.get do
316					view(
317						"credit_cards",
318						locals: {
319							token: gateway.client_token,
320							customer_id: gateway.customer_id,
321							auto_top_up: REDIS.get(
322								"jmp_customer_auto_top_up_amount-#{gateway.customer_id}"
323							) || (gateway.payment_methods? ? "" : "15")
324						}
325					)
326				end
327
328				r.post do
329					gateway.default_payment_method = request.params["braintree_nonce"]
330					if request.params["auto_top_up_amount"].to_i >= 15
331						REDIS.set(
332							"jmp_customer_auto_top_up_amount-#{gateway.customer_id}",
333							request.params["auto_top_up_amount"].to_i
334						)
335					elsif request.params["auto_top_up_amount"].to_i == 0
336						REDIS.del(
337							"jmp_customer_auto_top_up_amount-#{gateway.customer_id}"
338						)
339					end
340					"OK"
341				end
342			end
343		end
344	end
345end
346# rubocop:enable Metrics/ClassLength
347
348run JmpPay.freeze.app