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