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