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