Merge branch 'card-form-improvements'

Stephen Paul Weber created

* card-form-improvements:
  Allow bypassing antifraud for a customer
  Block repeated failed attempts to verify cards
  Capture exceptional cases to Sentry
  Show decline error text in more cases

Change summary

config.ru               | 57 ++++++++++++++++++++++++++++++++++--------
views/credit_cards.slim | 34 ++++++++++++++++++++-----
2 files changed, 73 insertions(+), 18 deletions(-)

Detailed changes

config.ru 🔗

@@ -48,9 +48,10 @@ class CreditCardGateway
 		end
 	end
 
-	def initialize(jid, customer_id=nil)
+	def initialize(jid, customer_id, antifraud)
 		@jid = jid
 		@customer_id = customer_id
+		@antifraud = antifraud
 
 		@gateway = Braintree::Gateway.new(
 			environment: BRAINTREE_CONFIG[:environment].to_s,
@@ -115,18 +116,34 @@ class CreditCardGateway
 		!@gateway.customer.find(customer_id).payment_methods.empty?
 	end
 
+	def antifraud
+		return if REDIS.exists?("jmp_antifraud_bypass-#{customer_id}")
+
+		REDIS.mget(@antifraud.map { |k| "jmp_antifraud-#{k}" }).find do |anti|
+			anti.to_i > 2
+		end &&
+			Braintree::ErrorResult.new(
+				@gateway, errors: {}, message: "Please contact support"
+			)
+	end
+
+	def incr_antifraud!
+		@antifraud.each do |k|
+			REDIS.incr("jmp_antifraud-#{k}")
+			REDIS.expire("jmp_antifraud-#{k}", 60 * 60 * 24)
+		end
+	end
+
 	def default_method(nonce)
-		result = @gateway.payment_method.create(
-			customer_id: customer_id,
-			payment_method_nonce: nonce,
-			options: {
-				verify_card: true,
-				make_default: true
-			}
+		result = antifraud || @gateway.payment_method.create(
+			customer_id: customer_id, payment_method_nonce: nonce,
+			options: { verify_card: true, make_default: true }
 		)
-		raise ErrorResult.for(result) unless result.success?
 
-		result
+		return result if result.success?
+
+		incr_antifraud!
+		raise ErrorResult.for(result)
 	end
 
 	def remove_method(token)
@@ -192,6 +209,7 @@ end
 class JmpPay < Roda
 	SENTRY_DSN = ENV["SENTRY_DSN"] && URI(ENV["SENTRY_DSN"])
 	plugin :render, engine: "slim"
+	plugin :cookies, path: "/"
 	plugin :common_logger, $stdout
 
 	extend Forwardable
@@ -230,16 +248,33 @@ class JmpPay < Roda
 		r.on :jid do |jid|
 			Sentry.set_user(id: params["customer_id"], jid: jid)
 
-			gateway = CreditCardGateway.new(jid, params["customer_id"])
+			atfd = r.cookies["atfd"] || SecureRandom.uuid
+			one_year = 60 * 60 * 24 * 365
+			response.set_cookie(
+				"atfd",
+				value: atfd, expires: Time.now + one_year
+			)
+			params.delete("atfd") if params["atfd"].to_s == ""
+			antifrauds = [atfd, r.ip, params["atfd"]].compact.uniq
+			customer_id = params["customer_id"]
+			gateway = CreditCardGateway.new(jid, customer_id, antifrauds)
 			topup = AutoTopUpRepo.new
 
 			r.on "credit_cards" do
 				r.get do
+					if gateway.antifraud
+						return view(
+							:message,
+							locals: { message: "Please contact support" }
+						)
+					end
+
 					view(
 						"credit_cards",
 						locals: {
 							token: gateway.client_token,
 							customer_id: gateway.customer_id,
+							antifraud: atfd,
 							auto_top_up: topup.find(gateway.customer_id) ||
 							             (gateway.payment_methods? ? "" : "15")
 						}

views/credit_cards.slim 🔗

@@ -32,12 +32,22 @@ form method="post" action=""
 			small Leave blank for no auto top-up.
 
 	input type="hidden" name="customer_id" value=customer_id
+	input type="hidden" name="atfd" value=antifraud
 	input type="hidden" name="braintree_nonce"
 
 script src="https://js.braintreegateway.com/web/dropin/1.33.0/js/dropin.js"
 javascript:
 	document.querySelector("#braintree").innerHTML = "";
 
+	if(window.localStorage) {
+		var atfd = localStorage.getItem("atfd");
+		if(!atfd) {
+			atfd = "#{antifraud}";
+			localStorage.setItem("atfd", atfd);
+		}
+		document.querySelector("input[name=atfd]").value = atfd;
+	}
+
 	var button = document.createElement("button");
 	button.innerHTML = "Save";
 	document.querySelector("form").appendChild(button);
@@ -53,7 +63,10 @@ javascript:
 			chooseAnotherWayToPay: "Add a different payment source"
 		}
 	}, function (createErr, instance) {
-		if(createErr) console.log(createErr);
+		if(createErr) {
+			console.log(createErr);
+			Sentry.captureException(createErr);
+		}
 
 		document.querySelector("form").addEventListener("submit", function(e) {
 			e.preventDefault();
@@ -82,17 +95,24 @@ javascript:
 							return Promise.reject(response);
 						}
 					}).catch(function(err) {
-							console.log(err);
-							err.text().then(function(msg) {
-								instance._mainView.hideLoadingIndicator();
-								instance.clearSelectedPaymentMethod();
-								instance._mainView.showSheetError(msg);
-							});
+						if(!(err instanceof Response)) return Promise.reject(err);
+
+						return err.text().then(function(msg) {
+							console.log(msg);
+							instance._mainView.hideLoadingIndicator();
+							instance.clearSelectedPaymentMethod();
+							instance._mainView.showSheetError(msg);
+							var errEl = instance._mainView.sheetErrorText;
+							if(errEl.innerHTML === instance._mainView.strings.genericError) {
+								errEl.innerHTML = "Card Issuer Says: " + msg;
+							}
+						});
 					}).catch(function(err) {
 						console.log(err);
 						instance._mainView.hideLoadingIndicator();
 						instance.clearSelectedPaymentMethod();
 						instance._mainView.showSheetError();
+						Sentry.captureException(err);
 					});
 				}
 			});