config.ru

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