~singpolyma/jmp-pay

5109bd75a5952168663a3447c15911437ce42d3c — Stephen Paul Weber 6 months ago 199fd55 + e1cf684
Merge branch '3ds-tx'

* 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
M bin/process_pending_btc_transactions => bin/process_pending_btc_transactions +4 -17
@@ 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


M config.ru => config.ru +89 -47
@@ 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)

A lib/customer.rb => lib/customer.rb +32 -0
@@ 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

M lib/three_d_secure_repo.rb => lib/three_d_secure_repo.rb +8 -3
@@ 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

A lib/transaction.rb => lib/transaction.rb +40 -0
@@ 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

M views/credit_cards.slim => views/credit_cards.slim +6 -2
@@ 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) {