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/auto_top_up_repo"
 19require_relative "lib/three_d_secure_repo"
 20require_relative "lib/electrum"
 21
 22require "sentry-ruby"
 23Sentry.init do |config|
 24	config.traces_sample_rate = 1
 25end
 26use Sentry::Rack::CaptureExceptions
 27
 28REDIS = Redis.new
 29PLANS = Dhall.load("env:PLANS").sync
 30BRAINTREE_CONFIG = Dhall.load("env:BRAINTREE_CONFIG").sync
 31ELECTRUM = Electrum.new(
 32	**Dhall::Coder.load("env:ELECTRUM_CONFIG", transform_keys: :to_sym)
 33)
 34
 35DB = PG.connect(dbname: "jmp")
 36DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
 37DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
 38
 39class CreditCardGateway
 40	class ErrorResult < StandardError
 41		def self.for(result)
 42			if result.verification&.status == "gateway_rejected" &&
 43			   result.verification&.gateway_rejection_reason == "cvv"
 44				new("fieldInvalidForCvv")
 45			else
 46				new(result.message)
 47			end
 48		end
 49	end
 50
 51	def initialize(jid, customer_id=nil)
 52		@jid = jid
 53		@customer_id = customer_id
 54
 55		@gateway = Braintree::Gateway.new(
 56			environment: BRAINTREE_CONFIG[:environment].to_s,
 57			merchant_id: BRAINTREE_CONFIG[:merchant_id].to_s,
 58			public_key: BRAINTREE_CONFIG[:public_key].to_s,
 59			private_key: BRAINTREE_CONFIG[:private_key].to_s
 60		)
 61	end
 62
 63	def check_customer_id(cid)
 64		return cid unless ENV["RACK_ENV"] == "production"
 65
 66		raise "customer_id does not match" unless @customer_id == cid
 67
 68		cid
 69	end
 70
 71	def customer_id
 72		customer_id = REDIS.get(redis_key_jid)
 73		return customer_id if check_customer_id(customer_id)
 74
 75		result = @gateway.customer.create
 76		raise "Braintree customer create failed" unless result.success?
 77
 78		@customer_id = result.customer.id
 79		save_customer_id!
 80	end
 81
 82	def save_customer_id!
 83		unless REDIS.set(redis_key_jid, @customer_id) == "OK"
 84			raise "Saving new jid,customer to redis failed"
 85		end
 86
 87		unless REDIS.set(redis_key_customer_id, @jid) == "OK"
 88			raise "Saving new customer,jid to redis failed"
 89		end
 90
 91		@customer_id
 92	end
 93
 94	def customer_plan
 95		name = DB.exec_params(<<~SQL, [customer_id]).first&.[]("plan_name")
 96			SELECT plan_name FROM customer_plans WHERE customer_id=$1 LIMIT 1
 97		SQL
 98		PLANS.find { |plan| plan[:name].to_s == name }
 99	end
100
101	def merchant_account
102		plan = customer_plan
103		return unless plan
104
105		BRAINTREE_CONFIG[:merchant_accounts][plan[:currency]]
106	end
107
108	def client_token
109		kwargs = {}
110		kwargs[:merchant_account_id] = merchant_account.to_s if merchant_account
111		@gateway.client_token.generate(customer_id: customer_id, **kwargs)
112	end
113
114	def payment_methods?
115		!@gateway.customer.find(customer_id).payment_methods.empty?
116	end
117
118	def default_method(nonce)
119		result = @gateway.payment_method.create(
120			customer_id: customer_id,
121			payment_method_nonce: nonce,
122			options: {
123				verify_card: true,
124				make_default: true
125			}
126		)
127		raise ErrorResult.for(result) unless result.success?
128
129		result
130	end
131
132	def remove_method(token)
133		@gateway.payment_method.delete(token)
134	end
135
136protected
137
138	def redis_key_jid
139		"jmp_customer_id-#{@jid}"
140	end
141
142	def redis_key_customer_id
143		"jmp_customer_jid-#{@customer_id}"
144	end
145end
146
147class UnknownTransactions
148	def self.from(customer_id, address, tx_hashes)
149		self.for(
150			customer_id,
151			fetch_rows_for(address, tx_hashes).map { |row|
152				row["transaction_id"]
153			}
154		)
155	end
156
157	def self.fetch_rows_for(address, tx_hashes)
158		values = tx_hashes.map { |tx_hash|
159			"('#{DB.escape_string(tx_hash)}/#{DB.escape_string(address)}')"
160		}
161		return [] if values.empty?
162
163		DB.exec_params(<<-SQL)
164			SELECT transaction_id FROM
165				(VALUES #{values.join(',')}) AS t(transaction_id)
166				LEFT JOIN transactions USING (transaction_id)
167			WHERE transactions.transaction_id IS NULL
168		SQL
169	end
170
171	def self.for(customer_id, transaction_ids)
172		transaction_ids.empty? ? None.new : new(customer_id, transaction_ids)
173	end
174
175	def initialize(customer_id, transaction_ids)
176		@customer_id = customer_id
177		@transaction_ids = transaction_ids
178	end
179
180	def enqueue!
181		REDIS.hset(
182			"pending_btc_transactions",
183			*@transaction_ids.flat_map { |txid| [txid, @customer_id] }
184		)
185	end
186
187	class None
188		def enqueue!; end
189	end
190end
191
192class JmpPay < Roda
193	SENTRY_DSN = ENV["SENTRY_DSN"] && URI(ENV["SENTRY_DSN"])
194	plugin :render, engine: "slim"
195	plugin :common_logger, $stdout
196
197	extend Forwardable
198	def_delegators :request, :params
199
200	def redis_key_btc_addresses
201		"jmp_customer_btc_addresses-#{params['customer_id']}"
202	end
203
204	def verify_address_customer_id(r)
205		return if REDIS.sismember(redis_key_btc_addresses, params["address"])
206
207		warn "Address and customer_id do not match"
208		r.halt([
209			403,
210			{ "Content-Type" => "text/plain" },
211			"Address and customer_id do not match"
212		])
213	end
214
215	route do |r|
216		r.on "electrum_notify" do
217			verify_address_customer_id(r)
218
219			UnknownTransactions.from(
220				params["customer_id"],
221				params["address"],
222				ELECTRUM
223					.getaddresshistory(params["address"])
224					.map { |item| item["tx_hash"] }
225			).enqueue!
226
227			"OK"
228		end
229
230		r.on :jid do |jid|
231			Sentry.set_user(id: params["customer_id"], jid: jid)
232
233			gateway = CreditCardGateway.new(jid, params["customer_id"])
234			topup = AutoTopUpRepo.new
235
236			r.on "credit_cards" do
237				r.get do
238					view(
239						"credit_cards",
240						locals: {
241							token: gateway.client_token,
242							customer_id: gateway.customer_id,
243							auto_top_up: topup.find(gateway.customer_id) ||
244							             (gateway.payment_methods? ? "" : "15")
245						}
246					)
247				end
248
249				r.post do
250					result = gateway.default_method(params["braintree_nonce"])
251					ThreeDSecureRepo.new.put_from_payment_method(
252						gateway.customer_id,
253						result.payment_method
254					)
255					topup.put(
256						gateway.customer_id,
257						params["auto_top_up_amount"].to_i
258					)
259					"OK"
260				rescue ThreeDSecureRepo::Failed
261					gateway.remove_method($!.message)
262					response.status = 400
263					"hostedFieldsFieldsInvalidError"
264				rescue CreditCardGateway::ErrorResult
265					response.status = 400
266					$!.message
267				end
268			end
269		end
270	end
271end
272
273run JmpPay.freeze.app