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