Allow activating an account via credit card on web

Stephen Paul Weber created

This is designed to work with current jmp-register flows pending new-register
existing.  Link a user to https://pay.jmp.chat/<jid>/activate?return_to=... and
they can choose to buy 5 months of service in either USD or CAD on a supported
credit card.  The card will be vaulted onto their newly-minted customer_id and
the amount immediately billed. No account balance will be set or used, but
rather a plan_log row created starting now and expiring in 5 months.

Change summary

.rubocop.yml        |  1 
config.ru           | 84 +++++++++++++++++++++++++++++++++++++++++--
views/activate.slim | 89 +++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 169 insertions(+), 5 deletions(-)

Detailed changes

.rubocop.yml 🔗

@@ -7,6 +7,7 @@ Metrics/LineLength:
 Metrics/BlockLength:
   ExcludedMethods:
     - route
+    - "on"
 
 Layout/Tab:
   Enabled: false

config.ru 🔗

@@ -1,6 +1,7 @@
 # frozen_string_literal: true
 
 require "braintree"
+require "date"
 require "delegate"
 require "dhall"
 require "pg"
@@ -15,6 +16,7 @@ end
 require_relative "lib/electrum"
 
 REDIS = Redis.new
+PLANS = Dhall.load("env:PLANS").sync
 BRAINTREE_CONFIG = Dhall.load("env:BRAINTREE_CONFIG").sync
 ELECTRUM = Electrum.new(
 	**Dhall::Coder.load("env:ELECTRUM_CONFIG", transform_keys: :to_sym)
@@ -24,6 +26,35 @@ DB = PG.connect(dbname: "jmp")
 DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
 DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
 
+class Plan
+	def self.for(plan_name)
+		new(PLANS.find { |p| p[:name].to_s == plan_name })
+	end
+
+	def initialize(plan)
+		@plan = plan
+	end
+
+	def price(months=1)
+		(BigDecimal.new(@plan[:monthly_price].to_i) * months) / 10000
+	end
+
+	def currency
+		@plan[:currency].to_s.to_sym
+	end
+
+	def merchant_account
+		BRAINTREE_CONFIG[:merchant_accounts][currency]
+	end
+
+	def activate(customer_id, months)
+		DB.exec_params(
+			"INSERT INTO plan_log VALUES ($1, $2, $3, $4)",
+			[customer_id, @plan[:name], Time.now, Date.today >> months]
+		)
+	end
+end
+
 class CreditCardGateway
 	def initialize(jid, customer_id=nil)
 		@jid = jid
@@ -81,6 +112,19 @@ class CreditCardGateway
 		)
 	end
 
+	def buy_plan(plan_name, months, nonce)
+		plan = Plan.for(plan_name)
+		result = @gateway.transaction.sale(
+			amount: plan.price(months),
+			payment_method_nonce: nonce,
+			merchant_account_id: plan.merchant_account,
+			options: {submit_for_settlement: true}
+		)
+		return false unless result.success?
+		plan.activate(@customer_id, months)
+		true
+	end
+
 protected
 
 	def redis_key_jid
@@ -154,12 +198,42 @@ class JmpPay < Roda
 		end
 
 		r.on :jid do |jid|
-			r.on "credit_cards" do
-				gateway = CreditCardGateway.new(
-					jid,
-					request.params["customer_id"]
-				)
+			gateway = CreditCardGateway.new(
+				jid,
+				request.params["customer_id"]
+			)
 
+			r.on "activate" do
+				render = lambda do |l={}|
+					view(
+						"activate",
+						locals: {
+							token: gateway.client_token,
+							customer_id: gateway.customer_id,
+							error: false
+						}.merge(l)
+					)
+				end
+
+				r.get do
+					render.call
+				end
+
+				r.post do
+					result = gateway.buy_plan(
+						request.params["plan_name"],
+						5,
+						request.params["braintree_nonce"]
+					)
+					if result
+						r.redirect request.params["return_to"], 303
+					else
+						render.call(error: true)
+					end
+				end
+			end
+
+			r.on "credit_cards" do
 				r.get do
 					view(
 						"credit_cards",

views/activate.slim 🔗

@@ -0,0 +1,89 @@
+scss:
+	html, body {
+		font-family: sans-serif;
+		text-align: center;
+	}
+
+	form {
+		margin: auto;
+		max-width: 40em;
+
+		fieldset {
+			max-width: 20em;
+			margin: 2em auto;
+			label {
+				display: block;
+			}
+		}
+
+		button {
+			display: block;
+			width: 10em;
+			margin: auto;
+		}
+
+	}
+
+	.error {
+		color: red;
+		max-width: 40em;
+		margin: 1em auto;
+	}
+
+h1 Activate New Account
+
+- if error
+	p.error
+		' 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.
+	p.error
+		' If you were trying a prepaid card, you may wish to use
+		a href="https://privacy.com/" Privacy.com
+		|  instead, as they do support international transactions.
+
+form method="post" action=""
+	#braintree
+		| Unfortunately, our credit card processor requires JavaScript.
+
+	fieldset
+		legend Pay for 5 months of service
+		label
+			' $14.95 USD
+			input type="radio" name="plan_name" value="usd_beta_unlimited-v20210223" required="required"
+		label
+			' $17.95 CAD
+			input type="radio" name="plan_name" value="cad_beta_unlimited-v20210223" required="required"
+
+	input type="hidden" name="customer_id" value=customer_id
+	input type="hidden" name="braintree_nonce"
+
+script src="https://js.braintreegateway.com/web/dropin/1.26.0/js/dropin.min.js"
+javascript:
+	document.querySelector("#braintree").innerHTML = "";
+
+	var button = document.createElement("button");
+	button.innerHTML = "Pay Now";
+	document.querySelector("form").appendChild(button);
+	braintree.dropin.create({
+		authorization: #{{token.to_json}},
+		container: "#braintree",
+		vaultManager: false
+	}, function (createErr, instance) {
+		if(createErr) console.log(createErr);
+
+		document.querySelector("form").addEventListener("submit", function(e) {
+			e.preventDefault();
+
+			instance.requestPaymentMethod(function(err, payload) {
+				if(err) {
+					console.log(err);
+					instance._mainView.showSheetError();
+				} else {
+					e.target.braintree_nonce.value = payload.nonce;
+					e.target.submit();
+				}
+			});
+		});
+	});