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				descriptor: { name: "JMPchat" },
108				options: {
109					store_in_vault_on_success: true, submit_for_settlement: true
110				}, **kwargs
111			)
112		end
113	end
114
115	def customer
116		@gateway.customer
117	end
118
119	def payment_method
120		@gateway.payment_method
121	end
122end
123
124class CardVault
125	def self.for(gateway, nonce, amount=nil)
126		if amount&.positive?
127			CardDeposit.new(gateway, nonce, amount)
128		else
129			new(gateway, nonce)
130		end
131	end
132
133	def initialize(gateway, nonce)
134		@gateway = gateway
135		@nonce = nonce
136	end
137
138	def call(auto_top_up_amount)
139		result = vault!
140		ThreeDSecureRepo.new.put_from_result(result)
141		AutoTopUpRepo.new.put(
142			@gateway.customer_id,
143			auto_top_up_amount
144		)
145		result
146	end
147
148	def vault!
149		@gateway.default_method(@nonce)
150	end
151
152	class CardDeposit < self
153		def initialize(gateway, nonce, amount)
154			super(gateway, nonce)
155			@amount = amount
156
157			return unless @amount < 15 || @amount > 35
158
159			raise CreditCardGateway::ErrorResult, "amount too low or too high"
160		end
161
162		def call(*)
163			result = super
164			Transaction.new(
165				@gateway.customer_id,
166				result.transaction.id,
167				@amount,
168				"Credit card payment"
169			).save
170			result
171		end
172
173		def vault!
174			@gateway.sale(@nonce, @amount)
175		end
176	end
177end
178
179class JmpPay < Roda
180	SENTRY_DSN = ENV.fetch("SENTRY_DSN", nil)&.then { |v| URI(v) }
181	plugin :render, engine: "slim"
182	plugin :cookies, path: "/"
183	plugin :common_logger, $stdout
184	plugin :content_for
185	plugin(
186		:assets,
187		css: { tom_select: "tom_select.scss", loader: "loader.scss" },
188		js: {
189			section_list: "section_list.js",
190			tom_select: "tom_select.js",
191			htmx: "htmx.js"
192		},
193		add_suffix: true
194	)
195
196	extend Forwardable
197	def_delegators :request, :params
198
199	def electrum
200		case params["currency"]
201		when "bch"
202			ELECTRUM_BCH
203		else
204			ELECTRUM
205		end
206	end
207
208	def nil_empty(s)
209		s.to_s == "" ? nil : s
210	end
211
212	def stallion_shipment
213		{
214			to_address: {
215				country_code: params["country-name"],
216				postal_code: params["postal-code"],
217				address1: nil_empty(params["street-address"]),
218				email: nil_empty(params["email"])
219			}.compact,
220			weight_unit: "g",
221			weight:
222				(params["esim_adapter_quantity"].to_i * 10) +
223					(params["pcsc_quantity"].to_i * 30),
224			size_unit: "in",
225			length: 18,
226			width: 8.5,
227			height: params["pcsc_quantity"].to_i.positive? ? 1 : 0.1,
228			package_type: "Large Envelope Or Flat",
229			items: [
230				{
231					title: "Memory Card",
232					description: "Memory Card",
233					sku: "003",
234					quantity: params["esim_adapter_quantity"].to_i,
235					value: params["currency"] == "CAD" ? 54.99 : 39.99,
236					currency: params["currency"],
237					country_of_origin: "CN"
238				},
239				{
240					title: "Card Reader",
241					description: "Card Reader",
242					sku: "002",
243					quantity: params["pcsc_quantity"].to_i,
244					value: params["currency"] == "CAD" ? 13.50 : 10,
245					currency: params["currency"],
246					country_of_origin: "CN"
247				}
248			].select { |item| item[:quantity].positive? },
249			insured: true
250		}
251	end
252
253	def get_rates
254		uri = URI("https://ship.stallionexpress.ca/api/v4/rates")
255		Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
256			req = Net::HTTP::Post.new(uri)
257			req["Content-Type"] = "application/json"
258			req["Authorization"] = "Bearer #{ENV.fetch('STALLION_TOKEN')}"
259			body = stallion_shipment
260			req.body = body.to_json
261			JSON.parse(http.request(req).read_body)
262		end
263	end
264
265	def retail_rate(rates)
266		return unless rates&.first&.dig("total")
267
268		total = BigDecimal(rates.first["total"], 2)
269		total *= 0.75 if params["currency"] == "USD"
270		params["country-name"] == "US" ? total.round : total.ceil
271	end
272
273	route do |r|
274		r.on "electrum_notify" do
275			REDIS.lpush(
276				"exciting_#{electrum.currency}_addrs",
277				"#{params['address']}/#{params["customer_id"]}"
278			)
279
280			"OK"
281		end
282
283		r.assets
284
285		atfd = r.cookies["atfd"] || SecureRandom.uuid
286		one_year = 60 * 60 * 24 * 365
287		response.set_cookie(
288			"atfd",
289			value: atfd, expires: Time.now + one_year
290		)
291		params.delete("atfd") if params["atfd"].to_s == ""
292		antifrauds = [atfd, r.ip, params["atfd"]].compact.uniq
293
294		r.on "esim-adapter" do
295			r.get "braintree" do
296				gateway = CreditCardGateway.new(params["currency"], antifrauds)
297				if gateway.antifraud
298					next view(
299						:message,
300						locals: { message: "Please contact support" }
301					)
302				end
303				render :braintree, locals: { token: gateway.client_token }
304			end
305
306			r.get "total" do
307				next "" unless params["postal-code"].to_s != ""
308
309				resp = get_rates
310				next resp["errors"].to_a.join("<br />") unless resp["success"]
311
312				render :total, locals: resp.merge("body" => stallion_shipment)
313			end
314
315			r.post do
316				gateway = CreditCardGateway.new(params["currency"], antifrauds)
317				if gateway.antifraud
318					next view(
319						:message,
320						locals: { message: "Please contact support" }
321					)
322				end
323
324				cost = stallion_shipment[:items].map { |item| item[:quantity] * item[:value] }.sum
325				rate = retail_rate(get_rates["rates"])
326
327				unless BigDecimal(params["amount"], 2) >= (cost + rate)
328					raise "Invalid amount"
329				end
330
331				sale_result = gateway.sale(params["braintree_nonce"], params["amount"])
332
333				postal_lookup = JSON.parse(Net::HTTP.get(URI(
334					"https://app.zipcodebase.com/api/v1/search?" \
335					"apikey=#{ENV.fetch('ZIPCODEBASE_TOKEN')}&" \
336					"codes=#{params['postal-code']}&" \
337					"country=#{params['country-name']}"
338				)))
339
340				uri = URI("https://ship.stallionexpress.ca/api/v4/orders")
341				Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
342					req = Net::HTTP::Post.new(uri)
343					req["Content-Type"] = "application/json"
344					req["Authorization"] = "Bearer #{ENV.fetch('STALLION_TOKEN')}"
345					body = stallion_shipment
346					body.merge!(body.delete(:to_address))
347					body[:store_id] = ENV.fetch("STALLION_STORE_ID")
348					body[:value] = BigDecimal(params["amount"], 2)
349					body[:currency] = params["currency"]
350					body[:order_at] = Time.now.strftime("%Y-%m-%d %H:%m:%S")
351					body[:order_id] = sale_result.transaction.id
352					body[:name] = "#{params['given-name']} #{params['family-name']}"
353					body[:city] = postal_lookup["results"].values.first.first["city"]
354					body[:province_code] = postal_lookup["results"].values.first.first["state_code"]
355					req.body = body.to_json
356					resp = JSON.parse(http.request(req).read_body)
357					view :receipt, locals: resp.merge("rate" => rate)
358				end
359			rescue CreditCardGateway::ErrorResult
360				response.status = 400
361				$!.message
362			end
363
364			r.get do
365				view :esim_adapter, locals: { antifraud: atfd, ip: request.ip }
366			end
367		end
368
369		r.on :jid do |jid|
370			Sentry.set_user(id: params["customer_id"], jid: jid)
371
372			customer_id = params["customer_id"]
373			gateway = CreditCardCustomerGateway.new(jid, customer_id, antifrauds)
374			topup = AutoTopUpRepo.new
375
376			r.on "credit_cards" do
377				r.get do
378					if gateway.antifraud
379						return view(
380							:message,
381							locals: { message: "Please contact support" }
382						)
383					end
384
385					view(
386						"credit_cards",
387						locals: {
388							jid: jid,
389							token: gateway.client_token,
390							customer_id: gateway.customer_id,
391							antifraud: atfd,
392							auto_top_up: topup.find(gateway.customer_id) ||
393							             (gateway.payment_methods? ? "" : "15")
394						}
395					)
396				end
397
398				r.post do
399					CardVault
400						.for(
401							gateway, params["braintree_nonce"],
402							params["amount"].to_d
403						).call(params["auto_top_up_amount"].to_i)
404					"OK"
405				rescue ThreeDSecureRepo::Failed
406					gateway.remove_method($!.message)
407					response.status = 400
408					"hostedFieldsFieldsInvalidError"
409				rescue CreditCardGateway::ErrorResult
410					response.status = 400
411					$!.message
412				end
413			end
414		end
415	end
416end
417
418run JmpPay.freeze.app