Merge branch '3ds-tx'

Stephen Paul Weber created

* 3ds-tx:
  Allow fully 3DS'd transaction from the web
  Factor out with_antifraud helper
  Factor out a transaction object
  Factor out a Customer object

Change summary

bin/process_pending_btc_transactions |  21 ---
config.ru                            | 136 +++++++++++++++++++----------
lib/customer.rb                      |  32 +++++++
lib/three_d_secure_repo.rb           |  11 +
lib/transaction.rb                   |  40 ++++++++
views/credit_cards.slim              |   8 +
6 files changed, 179 insertions(+), 69 deletions(-)

Detailed changes

bin/process_pending_btc_transactions 🔗

@@ -25,6 +25,7 @@ require "redis"
 
 require_relative "../lib/blather_notify"
 require_relative "../lib/electrum"
+require_relative "../lib/transaction"
 
 CONFIG =
 	Dhall::Coder
@@ -98,19 +99,6 @@ class Plan
 		@plan[:currency]
 	end
 
-	def bonus_for(fiat_amount)
-		return BigDecimal(0) if fiat_amount <= 15
-
-		fiat_amount * case fiat_amount
-		when (15..29.99)
-			0.01
-		when (30..139.99)
-			0.03
-		else
-			0.05
-		end
-	end
-
 	def price
 		BigDecimal(@plan[:monthly_price].to_i) * 0.0001
 	end
@@ -184,11 +172,10 @@ class Customer
 	end
 
 	def add_btc_credit(txid, btc_amount, fiat_amount)
-		return unless add_transaction(txid, fiat_amount, "Bitcoin payment")
+		tx = Transaction.new(txid, fiat_amount, "Bitcoin payment")
+		return unless tx.save
 
-		if (bonus = plan.bonus_for(fiat_amount)).positive?
-			add_transaction("bonus_for_#{txid}", bonus, "Bitcoin payment bonus")
-		end
+		tx.bonus&.save
 		notify_btc_credit(txid, btc_amount, fiat_amount, bonus)
 	end
 

config.ru 🔗

@@ -1,6 +1,7 @@
 # frozen_string_literal: true
 
 require "braintree"
+require "bigdecimal/util"
 require "date"
 require "delegate"
 require "dhall"
@@ -16,8 +17,10 @@ if ENV["RACK_ENV"] == "development"
 end
 
 require_relative "lib/auto_top_up_repo"
+require_relative "lib/customer"
 require_relative "lib/three_d_secure_repo"
 require_relative "lib/electrum"
+require_relative "lib/transaction"
 
 require "sentry-ruby"
 Sentry.init do |config|
@@ -70,25 +73,14 @@ class CreditCardGateway
 	end
 
 	def customer_id
-		customer_id = REDIS.get(redis_key_jid)
+		customer_id = Customer.new(nil, @jid).customer_id
 		return customer_id if check_customer_id(customer_id)
 
 		result = @gateway.customer.create
 		raise "Braintree customer create failed" unless result.success?
 
 		@customer_id = result.customer.id
-		save_customer_id!
-	end
-
-	def save_customer_id!
-		unless REDIS.set(redis_key_jid, @customer_id) == "OK"
-			raise "Saving new jid,customer to redis failed"
-		end
-
-		unless REDIS.set(redis_key_customer_id, @jid) == "OK"
-			raise "Saving new customer,jid to redis failed"
-		end
-
+		Customer.new(@customer_id, @jid).save!
 		@customer_id
 	end
 
@@ -127,46 +119,45 @@ class CreditCardGateway
 			)
 	end
 
-	def incr_antifraud!
+	def with_antifraud
+		result = antifraud || yield
+		return result if result.success?
+
 		@antifraud.each do |k|
 			REDIS.incr("jmp_antifraud-#{k}")
 			REDIS.expire("jmp_antifraud-#{k}", 60 * 60 * 24)
 		end
+
+		raise ErrorResult.for(result)
 	end
 
-	def payment_method_create_options
-		options = { verify_card: true, make_default: true }
-		if merchant_account
-			options[:verification_merchant_account_id] = merchant_account.to_s
+	def sale(nonce, amount)
+		with_antifraud do
+			@gateway.transaction.sale(
+				customer_id: customer_id, payment_method_nonce: nonce,
+				amount: amount, merchant_account_id: merchant_account.to_s,
+				options: {
+					store_in_vault_on_success: true, submit_for_settlement: true
+				}
+			)
 		end
-		options
 	end
 
 	def default_method(nonce)
-		result = antifraud || @gateway.payment_method.create(
-			customer_id: customer_id, payment_method_nonce: nonce,
-			options: payment_method_create_options
-		)
-
-		return result if result.success?
-
-		incr_antifraud!
-		raise ErrorResult.for(result)
+		with_antifraud do
+			@gateway.payment_method.create(
+				customer_id: customer_id, payment_method_nonce: nonce,
+				options: {
+					verify_card: true, make_default: true,
+					verification_merchant_account_id: merchant_account.to_s
+				}
+			)
+		end
 	end
 
 	def remove_method(token)
 		@gateway.payment_method.delete(token)
 	end
-
-protected
-
-	def redis_key_jid
-		"jmp_customer_id-#{@jid}"
-	end
-
-	def redis_key_customer_id
-		"jmp_customer_jid-#{@customer_id}"
-	end
 end
 
 class UnknownTransactions
@@ -214,6 +205,61 @@ class UnknownTransactions
 	end
 end
 
+class CardVault
+	def self.for(gateway, nonce, amount=nil)
+		if amount&.positive?
+			CardDeposit.new(gateway, nonce, amount)
+		else
+			new(gateway, nonce)
+		end
+	end
+
+	def initialize(gateway, nonce)
+		@gateway = gateway
+		@nonce = nonce
+	end
+
+	def call(auto_top_up_amount)
+		result = vault!
+		ThreeDSecureRepo.new.put_from_result(result)
+		AutoTopUpRepo.new.put(
+			@gateway.customer_id,
+			auto_top_up_amount
+		)
+		result
+	end
+
+	def vault!
+		@gateway.default_method(@nonce)
+	end
+
+	class CardDeposit < self
+		def initialize(gateway, nonce, amount)
+			super(gateway, nonce)
+			@amount = amount
+
+			return unless @amount < 15 || @amount > 35
+
+			raise CreditCardGateway::ErrorResult, "amount too low or too high"
+		end
+
+		def call(*)
+			result = super
+			Transaction.new(
+				@gateway.customer_id,
+				result.transaction.id,
+				@amount,
+				"Credit card payment"
+			).save
+			result
+		end
+
+		def vault!
+			@gateway.sale(@nonce, @amount)
+		end
+	end
+end
+
 class JmpPay < Roda
 	SENTRY_DSN = ENV["SENTRY_DSN"] && URI(ENV["SENTRY_DSN"])
 	plugin :render, engine: "slim"
@@ -290,15 +336,11 @@ class JmpPay < Roda
 				end
 
 				r.post do
-					result = gateway.default_method(params["braintree_nonce"])
-					ThreeDSecureRepo.new.put_from_payment_method(
-						gateway.customer_id,
-						result.payment_method
-					)
-					topup.put(
-						gateway.customer_id,
-						params["auto_top_up_amount"].to_i
-					)
+					CardVault
+						.for(
+							gateway, params["braintree_nonce"],
+							params["amount"].to_d
+						).call(params["auto_top_up_amount"].to_i)
 					"OK"
 				rescue ThreeDSecureRepo::Failed
 					gateway.remove_method($!.message)

lib/customer.rb 🔗

@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+class Customer
+	def initialize(customer_id, jid)
+		@customer_id = customer_id
+		@jid = jid
+	end
+
+	def customer_id
+		@customer_id = REDIS.get(redis_key_jid)
+	end
+
+	def save!
+		unless REDIS.set(redis_key_jid, @customer_id) == "OK"
+			raise "Saving new jid,customer to redis failed"
+		end
+
+		return if REDIS.set(redis_key_customer_id, @jid) == "OK"
+
+		raise "Saving new customer,jid to redis failed"
+	end
+
+protected
+
+	def redis_key_jid
+		"jmp_customer_id-#{@jid}"
+	end
+
+	def redis_key_customer_id
+		"jmp_customer_jid-#{@customer_id}"
+	end
+end

lib/three_d_secure_repo.rb 🔗

@@ -3,10 +3,15 @@
 class ThreeDSecureRepo
 	class Failed < StandardError; end
 
-	def put_from_payment_method(_customer_id, method)
-		return unless method.verification # Already vaulted
+	def put_from_result(result)
+		three_d = if result.payment_method
+			return unless result.payment_method.verification # Already vaulted
+
+			result.payment_method.verification.three_d_secure_info
+		else
+			result.transaction.three_d_secure_info
+		end
 
-		three_d = method.verification.three_d_secure_info
 		if !three_d ||
 		   (three_d.liability_shift_possible && !three_d.liability_shifted)
 			raise Failed, method.token

lib/transaction.rb 🔗

@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+class Transaction
+	def initialize(customer_id, id, amount, note)
+		@customer_id = customer_id
+		@id = id
+		@amount = amount
+		@note = note
+	end
+
+	def bonus
+		return unless bonus_amount.positive?
+
+		new(@customer_id, "bonus_for_#{@id}", bonus_amount, "#{@note} bonus")
+	end
+
+	def bonus_amount
+		return BigDecimal(0) if @amount <= 15
+
+		@amount * case @amount
+		when (15..29.99)
+			0.01
+		when (30..139.99)
+			0.03
+		else
+			0.05
+		end
+	end
+
+	def save
+		args = [@customer_id, @id, @amount, @note]
+		DB.exec_params(<<-SQL, args).cmd_tuples.positive?
+			INSERT INTO transactions
+				(customer_id, transaction_id, settled_after, amount, note)
+			VALUES
+				($1, $2, LOCALTIMESTAMP, $3, $4)
+			ON CONFLICT (transaction_id) DO NOTHING
+		SQL
+	end
+end

views/credit_cards.slim 🔗

@@ -24,11 +24,15 @@ form method="post" action=""
 	#braintree
 		| Unfortunately, our credit card processor requires JavaScript.
 
+	label#amount style="#{'display:none;' unless params['amount']}"
+		div Amount of initial deposit (minimum $15)
+		input type="number" name="amount" min="15" value="#{params.fetch('amount', '')}"
+
 	fieldset
 		legend Auto top-up when account balance is low?
 		label
 			| When balance drops below $5, add $
-			input type="number" name="auto_top_up_amount" min="15" value=auto_top_up
+			input type="number" name="auto_top_up_amount" min="15" max="35" value=auto_top_up
 			small Leave blank for no auto top-up.
 
 	input type="hidden" name="customer_id" value=customer_id
@@ -93,7 +97,7 @@ javascript:
 
 			instance.requestPaymentMethod({
 				threeDSecure: {
-					amount: "0.0",
+					amount: document.querySelector("input[name=amount]").value || "0.0",
 					requireChallenge: true
 				}
 			}, function(err, payload) {