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 default_payment_method=(nonce)
121		@gateway.payment_method.create(
122			customer_id: customer_id,
123			payment_method_nonce: nonce,
124			options: {
125				make_default: true
126			}
127		)
128	end
129
130	def decline_guard(ip)
131		customer_declines, ip_declines = REDIS.mget(
132			"jmp_pay_decline-#{@customer_id}",
133			"jmp_pay_decline-#{ip}"
134		)
135		customer_declines.to_i < 2 && ip_declines.to_i < 4
136	end
137
138	def sale(ip:, **kwargs)
139		return false unless decline_guard(ip)
140		result = @gateway.transaction.sale(**kwargs)
141		return true if result.success?
142
143		REDIS.incr("jmp_pay_decline-#{@customer_id}")
144		REDIS.expire("jmp_pay_decline-#{@customer_id}", 60 * 60 * 24)
145		REDIS.incr("jmp_pay_decline-#{ip}")
146		REDIS.expire("jmp_pay_decline-#{ip}", 60 * 60 * 24)
147		false
148	end
149
150	def buy_plan(plan_name, months, nonce, ip)
151		plan = Plan.for(plan_name)
152		sale(
153			ip: ip,
154			amount: plan.price(months),
155			payment_method_nonce: nonce,
156			merchant_account_id: plan.merchant_account,
157			options: {submit_for_settlement: true}
158		) && plan.activate(@customer_id, months)
159	end
160
161protected
162
163	def redis_key_jid
164		"jmp_customer_id-#{@jid}"
165	end
166
167	def redis_key_customer_id
168		"jmp_customer_jid-#{@customer_id}"
169	end
170end
171
172class UnknownTransactions
173	def self.from(customer_id, address, tx_hashes)
174		self.for(
175			customer_id,
176			fetch_rows_for(address, tx_hashes).map { |row| row["transaction_id"] }
177		)
178	end
179
180	def self.fetch_rows_for(address, tx_hashes)
181		values = tx_hashes.map do |tx_hash|
182			"('#{DB.escape_string(tx_hash)}/#{DB.escape_string(address)}')"
183		end
184		return [] if values.empty?
185		DB.exec_params(<<-SQL)
186			SELECT transaction_id FROM
187				(VALUES #{values.join(',')}) AS t(transaction_id)
188				LEFT JOIN transactions USING (transaction_id)
189			WHERE transactions.transaction_id IS NULL
190		SQL
191	end
192
193	def self.for(customer_id, transaction_ids)
194		transaction_ids.empty? ? None.new : new(customer_id, transaction_ids)
195	end
196
197	def initialize(customer_id, transaction_ids)
198		@customer_id = customer_id
199		@transaction_ids = transaction_ids
200	end
201
202	def enqueue!
203		REDIS.hset(
204			"pending_btc_transactions",
205			*@transaction_ids.flat_map { |txid| [txid, @customer_id] }
206		)
207	end
208
209	class None
210		def enqueue!; end
211	end
212end
213
214class JmpPay < Roda
215	SENTRY_DSN = ENV["SENTRY_DSN"] && URI(ENV["SENTRY_DSN"])
216	plugin :render, engine: "slim"
217	plugin :common_logger, $stdout
218
219	def redis_key_btc_addresses
220		"jmp_customer_btc_addresses-#{request.params['customer_id']}"
221	end
222
223	def verify_address_customer_id(r)
224		return if REDIS.sismember(redis_key_btc_addresses, request.params["address"])
225
226		warn "Address and customer_id do not match"
227		r.halt([
228			403,
229			{"Content-Type" => "text/plain"},
230			"Address and customer_id do not match"
231		])
232	end
233
234	route do |r|
235		r.on "electrum_notify" do
236			verify_address_customer_id(r)
237
238			UnknownTransactions.from(
239				request.params["customer_id"],
240				request.params["address"],
241				ELECTRUM
242					.getaddresshistory(request.params["address"])
243					.map { |item| item["tx_hash"] }
244			).enqueue!
245
246			"OK"
247		end
248
249		r.on :jid do |jid|
250			Sentry.set_user(id: request.params["customer_id"], jid: jid)
251
252			gateway = CreditCardGateway.new(
253				jid,
254				request.params["customer_id"]
255			)
256
257			r.on "activate" do
258				Sentry.configure_scope do |scope|
259					scope.set_transaction_name("activate")
260					scope.set_context(
261						"activate",
262						plan_name: request.params["plan_name"]
263					)
264				end
265
266				render = lambda do |l={}|
267					view(
268						"activate",
269						locals: {
270							token: gateway.client_token,
271							customer_id: gateway.customer_id,
272							error: false
273						}.merge(l)
274					)
275				end
276
277				r.get do
278					if Plan.active?(gateway.customer_id)
279						r.redirect request.params["return_to"], 303
280					else
281						render.call
282					end
283				end
284
285				r.post do
286					result = DB.transaction do
287						Plan.active?(gateway.customer_id) || gateway.buy_plan(
288							request.params["plan_name"],
289							5,
290							request.params["braintree_nonce"],
291							request.ip
292						)
293					end
294					if request.params["auto_top_up_amount"].to_i >= 15
295						REDIS.set(
296							"jmp_customer_auto_top_up_amount-#{gateway.customer_id}",
297							request.params["auto_top_up_amount"].to_i
298						)
299					end
300					if result
301						r.redirect request.params["return_to"], 303
302					else
303						render.call(error: true)
304					end
305				end
306			end
307
308			r.on "credit_cards" do
309				r.get do
310					view(
311						"credit_cards",
312						locals: {
313							token: gateway.client_token,
314							customer_id: gateway.customer_id
315						}
316					)
317				end
318
319				r.post do
320					gateway.default_payment_method = request.params["braintree_nonce"]
321					"OK"
322				end
323			end
324		end
325	end
326end
327
328run JmpPay.freeze.app