config.ru

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