Merge branch 'new-signup-credit-card-decline'

Stephen Paul Weber created

* new-signup-credit-card-decline:
  Block repeated declines for 24 hours
  Handle credit card decline

Change summary

lib/registration.rb       | 49 ++++++++++++++++++++++++++++++++++---
lib/transaction.rb        | 42 +++++++++++++++++++++++++------
sgx_jmp.rb                |  6 ++--
test/test_helper.rb       |  8 ++++-
test/test_registration.rb | 54 ++++++++++++++++++++++++++++++++++++----
test/test_transaction.rb  | 37 ++++++++++++++++++++++------
6 files changed, 164 insertions(+), 32 deletions(-)

Detailed changes

lib/registration.rb 🔗

@@ -219,15 +219,56 @@ class Registration
 
 				def write
 					Transaction.sale(
-						@customer.merchant_account,
-						@payment_method,
-						CONFIG[:activation_amount]
-					).then(&:insert).then {
+						@customer,
+						CONFIG[:activation_amount],
+						@payment_method
+					).then(
+						method(:sold),
+						->(_) { declined }
+					)
+				end
+
+			protected
+
+				def sold(tx)
+					tx.insert.then {
 						@customer.bill_plan
 					}.then do
 						Finish.new(@iq, @customer, @tel).write
 					end
 				end
+
+				DECLINE_MESSAGE =
+					"Your bank declined the transaction. " \
+					"Often this happens when a person's credit card " \
+					"is a US card that does not support international " \
+					"transactions, as JMP is not based in the USA, though " \
+					"we do support transactions in USD.\n\n" \
+					"If you were trying a prepaid card, you may wish to use "\
+					"Privacy.com instead, as they do support international " \
+					"transactions.\n\n " \
+					"You may add another card and then choose next"
+
+				def decline_oob(reply)
+					oob = OOB.find_or_create(reply.command)
+					oob.url = CONFIG[:credit_card_url].call(
+						reply.to.stripped.to_s,
+						@customer.customer_id
+					)
+					oob.desc = DECLINE_MESSAGE
+					oob
+				end
+
+				def declined
+					reply = @iq.reply
+					reply_oob = decline_oob(reply)
+					reply.allowed_actions = [:next]
+					reply.note_type = :error
+					reply.note_text = "#{reply_oob.desc}: #{reply_oob.url}"
+					COMMAND_MANAGER.write(reply).then do |riq|
+						CreditCard.for(riq, @customer, @tel)
+					end
+				end
 			end
 		end
 	end

lib/transaction.rb 🔗

@@ -1,18 +1,42 @@
 # frozen_string_literal: true
 
 class Transaction
-	def self.sale(merchant_account, payment_method, amount)
-		BRAINTREE.transaction.sale(
-			amount: amount,
-			payment_method_token: payment_method.token,
-			merchant_account_id: merchant_account,
-			options: { submit_for_settlement: true }
-		).then do |response|
-			raise response.message unless response.success?
-			new(response.transaction)
+	def self.sale(customer, amount, payment_method=nil)
+		REDIS.get("jmp_pay_decline-#{customer.customer_id}").then do |declines|
+			raise "too many declines" if declines.to_i >= 2
+
+			BRAINTREE.transaction.sale(
+				amount: amount,
+				**sale_args_for(customer, payment_method)
+			).then do |response|
+				decline_guard(customer, response)
+				new(response.transaction)
+			end
 		end
 	end
 
+	def self.decline_guard(customer, response)
+		return if response.success?
+
+		REDIS.incr("jmp_pay_decline-#{customer.customer_id}").then do
+			REDIS.expire("jmp_pay_decline-#{customer.customer_id}", 60 * 60 * 24)
+		end
+		raise response.message
+	end
+
+	def self.sale_args_for(customer, payment_method=nil)
+		{
+			merchant_account_id: customer.merchant_account,
+			options: { submit_for_settlement: true }
+		}.merge(
+			if payment_method
+				{ payment_method_token: payment_method.token }
+			else
+				{ customer_id: customer.id }
+			end
+		)
+	end
+
 	attr_reader :amount
 
 	def initialize(braintree_transaction)

sgx_jmp.rb 🔗

@@ -205,17 +205,17 @@ command :execute?, node: "buy-credit", sessionid: nil do |iq|
 		BuyAccountCreditForm.new(customer).add_to_form(reply.form).then { customer }
 	}.then { |customer|
 		EMPromise.all([
+			customer,
 			customer.payment_methods,
-			customer.merchant_account,
 			COMMAND_MANAGER.write(reply)
 		])
-	}.then { |(payment_methods, merchant_account, iq2)|
+	}.then { |(customer, payment_methods, iq2)|
 		iq = iq2 # This allows the catch to use it also
 		payment_method = payment_methods.fetch(
 			iq.form.field("payment_method")&.value.to_i
 		)
 		amount = iq.form.field("amount").value.to_s
-		Transaction.sale(merchant_account, payment_method, amount)
+		Transaction.sale(customer, amount, payment_method)
 	}.then { |transaction|
 		transaction.insert.then { transaction.amount }
 	}.then { |amount|

test/test_helper.rb 🔗

@@ -81,8 +81,12 @@ class Matching
 end
 
 class PromiseMock < Minitest::Mock
-	def then
-		yield self
+	def then(succ=nil, _=nil)
+		if succ
+			succ.call(self)
+		else
+			yield self
+		end
 	end
 end
 

test/test_registration.rb 🔗

@@ -205,6 +205,8 @@ class RegistrationTest < Minitest::Test
 				Minitest::Mock.new
 			Registration::Payment::CreditCard::Activate::Transaction =
 				Minitest::Mock.new
+			Registration::Payment::CreditCard::Activate::COMMAND_MANAGER =
+				Minitest::Mock.new
 
 			def test_write
 				transaction = PromiseMock.new
@@ -212,19 +214,19 @@ class RegistrationTest < Minitest::Test
 					:insert,
 					EMPromise.resolve(nil)
 				)
+				customer = Minitest::Mock.new(
+					Customer.new("test", plan_name: "test_usd")
+				)
 				Registration::Payment::CreditCard::Activate::Transaction.expect(
 					:sale,
 					transaction,
 					[
-						"merchant_usd",
-						:test_default_method,
-						CONFIG[:activation_amount]
+						customer,
+						CONFIG[:activation_amount],
+						:test_default_method
 					]
 				)
 				iq = Blather::Stanza::Iq::Command.new
-				customer = Minitest::Mock.new(
-					Customer.new("test", plan_name: "test_usd")
-				)
 				customer.expect(:bill_plan, nil)
 				Registration::Payment::CreditCard::Activate::Finish.expect(
 					:new,
@@ -240,8 +242,48 @@ class RegistrationTest < Minitest::Test
 				Registration::Payment::CreditCard::Activate::Transaction.verify
 				transaction.verify
 				customer.verify
+				Registration::Payment::CreditCard::Activate::Finish.verify
 			end
 			em :test_write
+
+			def test_write_declines
+				customer = Minitest::Mock.new(
+					Customer.new("test", plan_name: "test_usd")
+				)
+				Registration::Payment::CreditCard::Activate::Transaction.expect(
+					:sale,
+					EMPromise.reject("declined"),
+					[
+						customer,
+						CONFIG[:activation_amount],
+						:test_default_method
+					]
+				)
+				iq = Blather::Stanza::Iq::Command.new
+				iq.from = "test@example.com"
+				result = Minitest::Mock.new
+				result.expect(:then, nil)
+				Registration::Payment::CreditCard::Activate::COMMAND_MANAGER.expect(
+					:write,
+					result,
+					[Matching.new do |reply|
+						assert_equal :error, reply.note_type
+						assert_equal(
+							Registration::Payment::CreditCard::Activate::DECLINE_MESSAGE +
+							": http://creditcard.example.com",
+							reply.note.content
+						)
+					end]
+				)
+				Registration::Payment::CreditCard::Activate.new(
+					iq,
+					customer,
+					:test_default_method,
+					"+15555550000"
+				).write.sync
+				Registration::Payment::CreditCard::Activate::Transaction.verify
+			end
+			em :test_write_declines
 		end
 	end
 

test/test_transaction.rb 🔗

@@ -5,6 +5,7 @@ require "transaction"
 
 Transaction::DB = Minitest::Mock.new
 Transaction::BRAINTREE = Minitest::Mock.new
+Transaction::REDIS = Minitest::Mock.new
 
 class TransactionTest < Minitest::Test
 	FAKE_BRAINTREE_TRANSACTION =
@@ -16,26 +17,46 @@ class TransactionTest < Minitest::Test
 		)
 
 	def test_sale_fails
+		Transaction::REDIS.expect(
+			:get,
+			EMPromise.resolve("1"),
+			["jmp_pay_decline-test"]
+		)
+		Transaction::REDIS.expect(
+			:incr,
+			EMPromise.resolve(nil),
+			["jmp_pay_decline-test"]
+		)
+		Transaction::REDIS.expect(
+			:expire,
+			EMPromise.resolve(nil),
+			["jmp_pay_decline-test", 60 * 60 * 24]
+		)
 		braintree_transaction = Minitest::Mock.new
 		Transaction::BRAINTREE.expect(:transaction, braintree_transaction)
 		braintree_transaction.expect(
 			:sale,
 			EMPromise.resolve(
-				OpenStruct.new(success?: false)
+				OpenStruct.new(success?: false, message: "declined")
 			),
 			[Hash]
 		)
-		assert_raises do
+		assert_raises("declined") do
 			Transaction.sale(
-				"merchant_usd",
-				OpenStruct.new(token: "token"),
-				123
+				Customer.new("test", plan_name: "test_usd"),
+				123,
+				OpenStruct.new(token: "token")
 			).sync
 		end
 	end
 	em :test_sale_fails
 
 	def test_sale
+		Transaction::REDIS.expect(
+			:get,
+			EMPromise.resolve("1"),
+			["jmp_pay_decline-test"]
+		)
 		braintree_transaction = Minitest::Mock.new
 		Transaction::BRAINTREE.expect(:transaction, braintree_transaction)
 		braintree_transaction.expect(
@@ -54,9 +75,9 @@ class TransactionTest < Minitest::Test
 			}]
 		)
 		result = Transaction.sale(
-			"merchant_usd",
-			OpenStruct.new(token: "token"),
-			123
+			Customer.new("test", plan_name: "test_usd"),
+			123,
+			OpenStruct.new(token: "token")
 		).sync
 		assert_kind_of Transaction, result
 	end