config.ru

  1# frozen_string_literal: true
  2
  3require "braintree"
  4require "bigdecimal/util"
  5require "countries"
  6require "date"
  7require "delegate"
  8require "dhall"
  9require "forwardable"
 10require "pg"
 11require "redis"
 12require "roda"
 13require "uri"
 14require "json"
 15
 16if ENV["RACK_ENV"] == "development"
 17	require "pry-rescue"
 18	use PryRescue::Rack
 19end
 20
 21require_relative "lib/auto_top_up_repo"
 22require_relative "lib/customer"
 23require_relative "lib/three_d_secure_repo"
 24require_relative "lib/electrum"
 25require_relative "lib/transaction"
 26require_relative "lib/credit_card_customer_gateway"
 27
 28require "sentry-ruby"
 29Sentry.init do |config|
 30	config.traces_sample_rate = 0.01
 31end
 32use Sentry::Rack::CaptureExceptions
 33
 34REDIS = Redis.new
 35PLANS = Dhall.load("env:PLANS").sync
 36BRAINTREE_CONFIG = Dhall.load("env:BRAINTREE_CONFIG").sync
 37ELECTRUM = Electrum.new(
 38	**Dhall::Coder.load("env:ELECTRUM_CONFIG", transform_keys: :to_sym)
 39)
 40ELECTRUM_BCH = Electrum.new(
 41	**Dhall::Coder.load("env:ELECTRON_CASH_CONFIG", transform_keys: :to_sym)
 42)
 43
 44DB = PG.connect(dbname: "jmp")
 45DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
 46DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
 47
 48class CreditCardGateway
 49	class ErrorResult < StandardError
 50		def self.for(result)
 51			if result.verification&.status == "gateway_rejected" &&
 52			   result.verification&.gateway_rejection_reason == "cvv"
 53				new("fieldInvalidForCvv")
 54			else
 55				new(result.message)
 56			end
 57		end
 58	end
 59
 60	def initialize(currency, antifraud)
 61		@currency = currency
 62		@antifraud = antifraud
 63
 64		@gateway = Braintree::Gateway.new(
 65			environment: BRAINTREE_CONFIG[:environment].to_s,
 66			merchant_id: BRAINTREE_CONFIG[:merchant_id].to_s,
 67			public_key: BRAINTREE_CONFIG[:public_key].to_s,
 68			private_key: BRAINTREE_CONFIG[:private_key].to_s
 69		)
 70	end
 71
 72	def merchant_account
 73		BRAINTREE_CONFIG[:merchant_accounts][@currency]
 74	end
 75
 76	def client_token(**kwargs)
 77		kwargs[:merchant_account_id] = merchant_account.to_s if merchant_account
 78		@gateway.client_token.generate(**kwargs)
 79	end
 80
 81	def antifraud
 82		REDIS.mget(@antifraud.map { |k| "jmp_antifraud-#{k}" }).find do |anti|
 83			anti.to_i > 2
 84		end &&
 85			Braintree::ErrorResult.new(
 86				@gateway, errors: {}, message: "Please contact support"
 87			)
 88	end
 89
 90	def with_antifraud
 91		result = antifraud || yield
 92		return result if result.success?
 93
 94		@antifraud.each do |k|
 95			REDIS.incr("jmp_antifraud-#{k}")
 96			REDIS.expire("jmp_antifraud-#{k}", 60 * 60 * 24)
 97		end
 98
 99		raise ErrorResult.for(result)
100	end
101
102	def sale(nonce, amount, **kwargs)
103		with_antifraud do
104			@gateway.transaction.sale(
105				payment_method_nonce: nonce,
106				amount: amount, merchant_account_id: merchant_account.to_s,
107				options: {
108					store_in_vault_on_success: true, submit_for_settlement: true
109				}, **kwargs
110			)
111		end
112	end
113
114	def customer
115		@gateway.customer
116	end
117
118	def payment_method
119		@gateway.payment_method
120	end
121end
122
123class UnknownTransactions
124	def self.from(currency, customer_id, address, tx_hashes)
125		self.for(
126			currency,
127			customer_id,
128			fetch_rows_for(address, tx_hashes).map { |row|
129				row["transaction_id"]
130			}
131		)
132	end
133
134	def self.fetch_rows_for(address, tx_hashes)
135		values = tx_hashes.map { |tx_hash|
136			"('#{DB.escape_string(tx_hash)}/#{DB.escape_string(address)}')"
137		}
138		return [] if values.empty?
139
140		DB.exec_params(<<-SQL)
141			SELECT transaction_id FROM
142				(VALUES #{values.join(',')}) AS t(transaction_id)
143				LEFT JOIN transactions USING (transaction_id)
144			WHERE transactions.transaction_id IS NULL
145		SQL
146	end
147
148	def self.for(currency, customer_id, transaction_ids)
149		return None.new if transaction_ids.empty?
150
151		new(currency, customer_id, transaction_ids)
152	end
153
154	def initialize(currency, customer_id, transaction_ids)
155		@currency = currency
156		@customer_id = customer_id
157		@transaction_ids = transaction_ids
158	end
159
160	def enqueue!
161		REDIS.hset(
162			"pending_#{@currency}_transactions",
163			*@transaction_ids.flat_map { |txid| [txid, @customer_id] }
164		)
165	end
166
167	class None
168		def enqueue!; end
169	end
170end
171
172class CardVault
173	def self.for(gateway, nonce, amount=nil)
174		if amount&.positive?
175			CardDeposit.new(gateway, nonce, amount)
176		else
177			new(gateway, nonce)
178		end
179	end
180
181	def initialize(gateway, nonce)
182		@gateway = gateway
183		@nonce = nonce
184	end
185
186	def call(auto_top_up_amount)
187		result = vault!
188		ThreeDSecureRepo.new.put_from_result(result)
189		AutoTopUpRepo.new.put(
190			@gateway.customer_id,
191			auto_top_up_amount
192		)
193		result
194	end
195
196	def vault!
197		@gateway.default_method(@nonce)
198	end
199
200	class CardDeposit < self
201		def initialize(gateway, nonce, amount)
202			super(gateway, nonce)
203			@amount = amount
204
205			return unless @amount < 15 || @amount > 35
206
207			raise CreditCardGateway::ErrorResult, "amount too low or too high"
208		end
209
210		def call(*)
211			result = super
212			Transaction.new(
213				@gateway.customer_id,
214				result.transaction.id,
215				@amount,
216				"Credit card payment"
217			).save
218			result
219		end
220
221		def vault!
222			@gateway.sale(@nonce, @amount)
223		end
224	end
225end
226
227class JmpPay < Roda
228	SENTRY_DSN = ENV.fetch("SENTRY_DSN", nil)&.then { |v| URI(v) }
229	plugin :render, engine: "slim"
230	plugin :cookies, path: "/"
231	plugin :common_logger, $stdout
232	plugin :content_for
233	plugin(
234		:assets,
235		css: { tom_select: "tom_select.scss", loader: "loader.scss" },
236		js: {
237			section_list: "section_list.js",
238			tom_select: "tom_select.js",
239			htmx: "htmx.js"
240		},
241		add_suffix: true
242	)
243
244	extend Forwardable
245	def_delegators :request, :params
246
247	def electrum
248		case params["currency"]
249		when "bch"
250			ELECTRUM_BCH
251		else
252			ELECTRUM
253		end
254	end
255
256	def redis_key_btc_addresses
257		"jmp_customer_#{electrum.currency}_addresses-#{params['customer_id']}"
258	end
259
260	def verify_address_customer_id(r)
261		return if REDIS.sismember(redis_key_btc_addresses, params["address"])
262
263		warn "Address and customer_id do not match"
264		r.halt([
265			403,
266			{ "Content-Type" => "text/plain" },
267			"Address and customer_id do not match"
268		])
269	end
270
271	def nil_empty(s)
272		s.to_s == "" ? nil : s
273	end
274
275	def stallion_shipment
276		{
277			to_address: {
278				country_code: params["country-name"],
279				postal_code: params["postal-code"],
280				address1: nil_empty(params["street-address"]),
281				email: nil_empty(params["email"])
282			}.compact,
283			weight_unit: "g",
284			weight:
285				(params["esim_adapter_quantity"].to_i * 10) +
286					(params["pcsc_quantity"].to_i * 30),
287			size_unit: "in",
288			length: 18,
289			width: 8.5,
290			height: params["pcsc_quantity"].to_i.positive? ? 1 : 0.1,
291			package_type: "Large Envelope Or Flat",
292			items: [
293				{
294					title: "Memory Card",
295					description: "Memory Card",
296					sku: "003",
297					quantity: params["esim_adapter_quantity"].to_i,
298					value: params["currency"] == "CAD" ? 54.99 : 39.99,
299					currency: params["currency"],
300					country_of_origin: "CN"
301				},
302				{
303					title: "Card Reader",
304					description: "Card Reader",
305					sku: "002",
306					quantity: params["pcsc_quantity"].to_i,
307					value: params["currency"] == "CAD" ? 13.50 : 10,
308					currency: params["currency"],
309					country_of_origin: "CN"
310				}
311			].select { |item| item[:quantity].positive? },
312			insured: true
313		}
314	end
315
316	def get_rates
317		uri = URI("https://ship.stallionexpress.ca/api/v4/rates")
318		Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
319			req = Net::HTTP::Post.new(uri)
320			req["Content-Type"] = "application/json"
321			req["Authorization"] = "Bearer #{ENV.fetch('STALLION_TOKEN')}"
322			body = stallion_shipment
323			req.body = body.to_json
324			JSON.parse(http.request(req).read_body)
325		end
326	end
327
328	def retail_rate(rates)
329		return unless rates&.first&.dig("total")
330
331		total = BigDecimal(rates.first["total"], 2)
332		total *= 0.75 if params["currency"] == "USD"
333		params["country-name"] == "US" ? total.round : total.ceil
334	end
335
336	route do |r|
337		r.on "electrum_notify" do
338			verify_address_customer_id(r)
339
340			UnknownTransactions.from(
341				electrum.currency,
342				params["customer_id"],
343				params["address"],
344				electrum
345					.getaddresshistory(params["address"])
346					.map { |item| item["tx_hash"] }
347			).enqueue!
348
349			"OK"
350		end
351
352		r.assets
353
354		atfd = r.cookies["atfd"] || SecureRandom.uuid
355		one_year = 60 * 60 * 24 * 365
356		response.set_cookie(
357			"atfd",
358			value: atfd, expires: Time.now + one_year
359		)
360		params.delete("atfd") if params["atfd"].to_s == ""
361		antifrauds = [atfd, r.ip, params["atfd"]].compact.uniq
362
363		r.on "esim-adapter" do
364			r.get "braintree" do
365				gateway = CreditCardGateway.new(params["currency"], antifrauds)
366				if gateway.antifraud
367					next view(
368						:message,
369						locals: { message: "Please contact support" }
370					)
371				end
372				render :braintree, locals: { token: gateway.client_token }
373			end
374
375			r.get "total" do
376				next "" unless params["postal-code"].to_s != ""
377
378				resp = get_rates
379				next resp["errors"].to_a.join("<br />") unless resp["success"]
380
381				render :total, locals: resp.merge("body" => stallion_shipment)
382			end
383
384			r.post do
385				gateway = CreditCardGateway.new(params["currency"], antifrauds)
386				if gateway.antifraud
387					next view(
388						:message,
389						locals: { message: "Please contact support" }
390					)
391				end
392
393				cost = stallion_shipment[:items].map { |item| item[:quantity] * item[:value] }.sum
394				rate = retail_rate(get_rates["rates"])
395
396				unless BigDecimal(params["amount"], 2) >= (cost + rate)
397					raise "Invalid amount"
398				end
399
400				sale_result = gateway.sale(params["braintree_nonce"], params["amount"])
401
402				postal_lookup = JSON.parse(Net::HTTP.get(URI(
403					"https://app.zipcodebase.com/api/v1/search?" \
404					"apikey=#{ENV.fetch('ZIPCODEBASE_TOKEN')}&" \
405					"codes=#{params['postal-code']}&" \
406					"country=#{params['country-name']}"
407				)))
408
409				uri = URI("https://ship.stallionexpress.ca/api/v4/orders")
410				Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
411					req = Net::HTTP::Post.new(uri)
412					req["Content-Type"] = "application/json"
413					req["Authorization"] = "Bearer #{ENV.fetch('STALLION_TOKEN')}"
414					body = stallion_shipment
415					body.merge!(body.delete(:to_address))
416					body[:store_id] = ENV.fetch("STALLION_STORE_ID")
417					body[:value] = BigDecimal(params["amount"], 2)
418					body[:currency] = params["currency"]
419					body[:order_at] = Time.now.strftime("%Y-%m-%d %H:%m:%S")
420					body[:order_id] = sale_result.transaction.id
421					body[:name] = "#{params['given-name']} #{params['family-name']}"
422					body[:city] = postal_lookup["results"].values.first.first["city"]
423					body[:province_code] = postal_lookup["results"].values.first.first["state_code"]
424					req.body = body.to_json
425					resp = JSON.parse(http.request(req).read_body)
426					view :receipt, locals: resp.merge("rate" => rate)
427				end
428			rescue CreditCardGateway::ErrorResult
429				response.status = 400
430				$!.message
431			end
432
433			r.get do
434				view :esim_adapter, locals: { antifraud: atfd, ip: request.ip }
435			end
436		end
437
438		r.on :jid do |jid|
439			Sentry.set_user(id: params["customer_id"], jid: jid)
440
441			customer_id = params["customer_id"]
442			gateway = CreditCardCustomerGateway.new(jid, customer_id, antifrauds)
443			topup = AutoTopUpRepo.new
444
445			r.on "credit_cards" do
446				r.get do
447					if gateway.antifraud
448						return view(
449							:message,
450							locals: { message: "Please contact support" }
451						)
452					end
453
454					view(
455						"credit_cards",
456						locals: {
457							jid: jid,
458							token: gateway.client_token,
459							customer_id: gateway.customer_id,
460							antifraud: atfd,
461							auto_top_up: topup.find(gateway.customer_id) ||
462							             (gateway.payment_methods? ? "" : "15")
463						}
464					)
465				end
466
467				r.post do
468					CardVault
469						.for(
470							gateway, params["braintree_nonce"],
471							params["amount"].to_d
472						).call(params["auto_top_up_amount"].to_i)
473					"OK"
474				rescue ThreeDSecureRepo::Failed
475					gateway.remove_method($!.message)
476					response.status = 400
477					"hostedFieldsFieldsInvalidError"
478				rescue CreditCardGateway::ErrorResult
479					response.status = 400
480					$!.message
481				end
482			end
483		end
484	end
485end
486
487run JmpPay.freeze.app