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