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 activate(customer_id, months)
 57		DB.exec_params(
 58			"INSERT INTO plan_log VALUES ($1, $2, $3, $4)",
 59			[customer_id, @plan[:name], Time.now, Date.today >> months]
 60		)
 61	end
 62end
 63
 64class CreditCardGateway
 65	def initialize(jid, customer_id=nil)
 66		@jid = jid
 67		@customer_id = customer_id
 68
 69		@gateway = Braintree::Gateway.new(
 70			environment: BRAINTREE_CONFIG[:environment].to_s,
 71			merchant_id: BRAINTREE_CONFIG[:merchant_id].to_s,
 72			public_key: BRAINTREE_CONFIG[:public_key].to_s,
 73			private_key: BRAINTREE_CONFIG[:private_key].to_s
 74		)
 75	end
 76
 77	def check_customer_id(cid)
 78		return cid unless ENV["RACK_ENV"] == "production"
 79
 80		raise "customer_id does not match" unless @customer_id == cid
 81
 82		cid
 83	end
 84
 85	def customer_id
 86		customer_id = REDIS.get(redis_key_jid)
 87		return customer_id if check_customer_id(customer_id)
 88
 89		result = @gateway.customer.create
 90		raise "Braintree customer create failed" unless result.success?
 91		@customer_id = result.customer.id
 92		save_customer_id!
 93	end
 94
 95	def save_customer_id!
 96		unless REDIS.set(redis_key_jid, @customer_id) == "OK"
 97			raise "Saving new jid,customer to redis failed"
 98		end
 99
100		unless REDIS.set(redis_key_customer_id, @jid) == "OK"
101			raise "Saving new customer,jid to redis failed"
102		end
103
104		@customer_id
105	end
106
107	def client_token
108		@gateway.client_token.generate(customer_id: customer_id)
109	end
110
111	def default_payment_method=(nonce)
112		@gateway.payment_method.create(
113			customer_id: customer_id,
114			payment_method_nonce: nonce,
115			options: {
116				make_default: true
117			}
118		)
119	end
120
121	def buy_plan(plan_name, months, nonce)
122		plan = Plan.for(plan_name)
123		result = @gateway.transaction.sale(
124			amount: plan.price(months),
125			payment_method_nonce: nonce,
126			merchant_account_id: plan.merchant_account,
127			options: {submit_for_settlement: true}
128		)
129		return false unless result.success?
130		plan.activate(@customer_id, months)
131		true
132	end
133
134protected
135
136	def redis_key_jid
137		"jmp_customer_id-#{@jid}"
138	end
139
140	def redis_key_customer_id
141		"jmp_customer_jid-#{@customer_id}"
142	end
143end
144
145class UnknownTransactions
146	def self.from(customer_id, address, tx_hashes)
147		values = tx_hashes.map do |tx_hash|
148			"('#{DB.escape_string(tx_hash)}/#{DB.escape_string(address)}')"
149		end
150		rows = DB.exec_params(<<-SQL)
151			SELECT transaction_id FROM
152				(VALUES #{values.join(',')}) AS t(transaction_id)
153				LEFT JOIN transactions USING (transaction_id)
154			WHERE transactions.transaction_id IS NULL
155		SQL
156		new(customer_id, rows.map { |row| row["transaction_id"] })
157	end
158
159	def initialize(customer_id, transaction_ids)
160		@customer_id = customer_id
161		@transaction_ids = transaction_ids
162	end
163
164	def enqueue!
165		REDIS.hset(
166			"pending_btc_transactions",
167			*@transaction_ids.flat_map { |txid| [txid, @customer_id] }
168		)
169	end
170end
171
172class JmpPay < Roda
173	plugin :render, engine: "slim"
174	plugin :common_logger, $stdout
175
176	def redis_key_btc_addresses
177		"jmp_customer_btc_addresses-#{request.params['customer_id']}"
178	end
179
180	def verify_address_customer_id(r)
181		return if REDIS.sismember(redis_key_btc_addresses, request.params["address"])
182
183		warn "Address and customer_id do not match"
184		r.halt(
185			403,
186			{"Content-Type" => "text/plain"},
187			"Address and customer_id do not match"
188		)
189	end
190
191	route do |r|
192		r.on "electrum_notify" do
193			verify_address_customer_id(r)
194
195			UnknownTransactions.from(
196				request.params["customer_id"],
197				request.params["address"],
198				ELECTRUM
199					.getaddresshistory(request.params["address"])
200					.map { |item| item["tx_hash"] }
201			).enqueue!
202
203			"OK"
204		end
205
206		r.on :jid do |jid|
207			Sentry.set_user(id: request.params["customer_id"], jid: jid)
208
209			gateway = CreditCardGateway.new(
210				jid,
211				request.params["customer_id"]
212			)
213
214			r.on "activate" do
215				Sentry.configure_scope do |scope|
216					scope.set_transaction_name("activate")
217					scope.set_context(
218						"activate",
219						plan_name: request.params["plan_name"]
220					)
221				end
222
223				render = lambda do |l={}|
224					view(
225						"activate",
226						locals: {
227							token: gateway.client_token,
228							customer_id: gateway.customer_id,
229							error: false
230						}.merge(l)
231					)
232				end
233
234				r.get do
235					render.call
236				end
237
238				r.post do
239					result = gateway.buy_plan(
240						request.params["plan_name"],
241						5,
242						request.params["braintree_nonce"]
243					)
244					if result
245						r.redirect request.params["return_to"], 303
246					else
247						render.call(error: true)
248					end
249				end
250			end
251
252			r.on "credit_cards" do
253				r.get do
254					view(
255						"credit_cards",
256						locals: {
257							token: gateway.client_token,
258							customer_id: gateway.customer_id
259						}
260					)
261				end
262
263				r.post do
264					gateway.default_payment_method = request.params["braintree_nonce"]
265					"OK"
266				end
267			end
268		end
269	end
270end
271
272run JmpPay.freeze.app