From 9491f558eadb1fcfd2ed71f58bbc2046912cd8fc Mon Sep 17 00:00:00 2001 From: Justin DiRose Date: Fri, 24 Jul 2020 15:07:18 -0500 Subject: [PATCH] FEATURE: Add support for 3D Secure payments (#19) Adds an additional checkout flow to support authentication of payment methods. --- .../subscriptions_controller.rb | 74 ++++++++++++++----- .../discourse/controllers/s-show.js.es6 | 62 ++++++++++++---- .../discourse/models/transaction.js.es6 | 12 +++ config/routes.rb | 2 + plugin.rb | 2 +- .../requests/subscriptions_controller_spec.rb | 38 +++++++++- 6 files changed, 153 insertions(+), 37 deletions(-) create mode 100644 assets/javascripts/discourse/models/transaction.js.es6 diff --git a/app/controllers/discourse_subscriptions/subscriptions_controller.rb b/app/controllers/discourse_subscriptions/subscriptions_controller.rb index 0ad7650..ec94c84 100644 --- a/app/controllers/discourse_subscriptions/subscriptions_controller.rb +++ b/app/controllers/discourse_subscriptions/subscriptions_controller.rb @@ -39,6 +39,8 @@ module DiscourseSubscriptions metadata: metadata_user, trial_period_days: trial_days ) + + payment_intent = retrieve_payment_intent(transaction[:latest_invoice]) if transaction[:status] == 'incomplete' else invoice_item = ::Stripe::InvoiceItem.create( customer: params[:customer], @@ -47,27 +49,14 @@ module DiscourseSubscriptions invoice = ::Stripe::Invoice.create( customer: params[:customer] ) - transaction = ::Stripe::Invoice.pay(invoice[:id]) + transaction = ::Stripe::Invoice.finalize_invoice(invoice[:id]) + payment_intent = retrieve_payment_intent(transaction[:id]) if transaction[:status] == 'open' + transaction = ::Stripe::Invoice.pay(invoice[:id]) if payment_intent[:status] == 'successful' end - if transaction_ok(transaction) - group = plan_group(plan) + finalize_transaction(transaction, plan) if transaction_ok(transaction) - group.add(current_user) if group - - customer = Customer.create( - user_id: current_user.id, - customer_id: params[:customer], - product_id: plan[:product] - ) - - if transaction[:object] == 'subscription' - Subscription.create( - customer_id: customer.id, - external_id: transaction[:id] - ) - end - end + transaction = transaction.to_h.merge(transaction, payment_intent: payment_intent) render_json_dump transaction rescue ::Stripe::InvalidRequestError => e @@ -75,6 +64,55 @@ module DiscourseSubscriptions end end + def finalize + begin + price = ::Stripe::Price.retrieve(params[:plan]) + transaction = retrieve_transaction(params[:transaction]) + finalize_transaction(transaction, price) if transaction_ok(transaction) + + render_json_dump params[:transaction] + rescue ::Stripe::InvalidRequestError => e + render_json_error e.message + end + end + + def retrieve_transaction(transaction) + begin + case transaction + when /^sub_/ + ::Stripe::Subscription.retrieve(transaction) + when /^in_/ + ::Stripe::Invoice.retrieve(transaction) + end + rescue ::Stripe::InvalidRequestError => e + e.message + end + end + + def retrieve_payment_intent(invoice_id) + invoice = ::Stripe::Invoice.retrieve(invoice_id) + ::Stripe::PaymentIntent.retrieve(invoice[:payment_intent]) + end + + def finalize_transaction(transaction, plan) + group = plan_group(plan) + + group.add(current_user) if group + + customer = Customer.create( + user_id: current_user.id, + customer_id: transaction[:customer], + product_id: plan[:product] + ) + + if transaction[:object] == 'subscription' + Subscription.create( + customer_id: customer.id, + external_id: transaction[:id] + ) + end + end + private def metadata_user diff --git a/assets/javascripts/discourse/controllers/s-show.js.es6 b/assets/javascripts/discourse/controllers/s-show.js.es6 index a623572..7abbf0e 100644 --- a/assets/javascripts/discourse/controllers/s-show.js.es6 +++ b/assets/javascripts/discourse/controllers/s-show.js.es6 @@ -1,6 +1,7 @@ import Controller from "@ember/controller"; import Customer from "discourse/plugins/discourse-subscriptions/discourse/models/customer"; import Subscription from "discourse/plugins/discourse-subscriptions/discourse/models/subscription"; +import Transaction from "discourse/plugins/discourse-subscriptions/discourse/models/transaction"; import I18n from "I18n"; export default Controller.extend({ @@ -40,6 +41,35 @@ export default Controller.extend({ }); }, + handleAuthentication(plan, transaction) { + return this.stripe + .confirmCardPayment(transaction.payment_intent.client_secret) + .then(result => { + if ( + result.paymentIntent && + result.paymentIntent.status === "succeeded" + ) { + return result; + } else { + this.set("loading", false); + bootbox.alert(result.error.message || result.error); + return result; + } + }); + }, + + _advanceSuccessfulTransaction(plan) { + this.alert("plans.success"); + this.set("loading", false); + + this.transitionToRoute( + plan.type === "recurring" + ? "user.billing.subscriptions" + : "user.billing.payments", + Discourse.User.current().username.toLowerCase() + ); + }, + actions: { stripePaymentHandler() { this.set("loading", true); @@ -59,25 +89,29 @@ export default Controller.extend({ .then(result => { if (result.error) { bootbox.alert(result.error.message || result.error); - } else { - if (result.status === "incomplete") { - this.alert("plans.incomplete"); - } else { - this.alert("plans.success"); - } - - this.transitionToRoute( - plan.type === "recurring" - ? "user.billing.subscriptions" - : "user.billing.payments", - Discourse.User.current().username.toLowerCase() + } else if ( + result.status === "incomplete" || + result.status === "open" + ) { + const transactionId = result.id; + const planId = this.selectedPlan; + this.handleAuthentication(plan, result).then( + authenticationResult => { + if (authenticationResult && !authenticationResult.error) { + return Transaction.finalize(transactionId, planId).then( + () => { + this._advanceSuccessfulTransaction(plan); + } + ); + } + } ); + } else { + this._advanceSuccessfulTransaction(plan); } }) .catch(result => { bootbox.alert(result.errorThrown); - }) - .finally(() => { this.set("loading", false); }); } diff --git a/assets/javascripts/discourse/models/transaction.js.es6 b/assets/javascripts/discourse/models/transaction.js.es6 new file mode 100644 index 0000000..2f7910b --- /dev/null +++ b/assets/javascripts/discourse/models/transaction.js.es6 @@ -0,0 +1,12 @@ +import { ajax } from "discourse/lib/ajax"; + +export default { + finalize(transaction, plan) { + const data = { + transaction: transaction, + plan: plan + }; + + return ajax("/s/subscriptions/finalize", { method: "post", data }); + } +}; diff --git a/config/routes.rb b/config/routes.rb index 56a372a..493afce 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -24,6 +24,8 @@ DiscourseSubscriptions::Engine.routes.draw do resources :products, only: [:index, :show] resources :subscriptions, only: [:create] + post '/subscriptions/finalize' => 'subscriptions#finalize' + get '/' => 'subscriptions#index', constraints: SubscriptionsUserConstraint.new get '/:id' => 'subscriptions#index', constraints: SubscriptionsUserConstraint.new end diff --git a/plugin.rb b/plugin.rb index 06b2656..f970d4f 100644 --- a/plugin.rb +++ b/plugin.rb @@ -21,7 +21,7 @@ register_html_builder('server:before-head-close') do end extend_content_security_policy( - script_src: ['https://js.stripe.com/v3/'] + script_src: ['https://js.stripe.com/v3/', 'https://hooks.stripe.com'] ) add_admin_route 'discourse_subscriptions.admin_navigation', 'discourse-subscriptions.products' diff --git a/spec/requests/subscriptions_controller_spec.rb b/spec/requests/subscriptions_controller_spec.rb index 4660e24..6e8f980 100644 --- a/spec/requests/subscriptions_controller_spec.rb +++ b/spec/requests/subscriptions_controller_spec.rb @@ -35,7 +35,7 @@ module DiscourseSubscriptions items: [ price: 'plan_1234' ], metadata: { user_id: user.id, username: user.username_lower }, trial_period_days: 0 - ).returns(status: 'active') + ).returns(status: 'active', customer: 'cus_1234') expect { post "/s/subscriptions.json", params: { plan: 'plan_1234', customer: 'cus_1234' } @@ -53,9 +53,15 @@ module DiscourseSubscriptions ::Stripe::InvoiceItem.expects(:create) - ::Stripe::Invoice.expects(:create).returns(id: 'in_123') + ::Stripe::Invoice.expects(:create).returns(status: 'open', id: 'in_123') - ::Stripe::Invoice.expects(:pay).returns(status: 'paid') + ::Stripe::Invoice.expects(:finalize_invoice).returns(id: 'in_123', status: 'open', payment_intent: 'pi_123') + + ::Stripe::Invoice.expects(:retrieve).returns(id: 'in_123', status: 'open', payment_intent: 'pi_123') + + ::Stripe::PaymentIntent.expects(:retrieve).returns(status: 'successful') + + ::Stripe::Invoice.expects(:pay).returns(status: 'paid', customer: 'cus_1234') expect { post '/s/subscriptions.json', params: { plan: 'plan_1234', customer: 'cus_1234' } @@ -65,7 +71,7 @@ module DiscourseSubscriptions it "creates a customer model" do ::Stripe::Price.expects(:retrieve).returns(type: 'recurring', metadata: {}) - ::Stripe::Subscription.expects(:create).returns(status: 'active') + ::Stripe::Subscription.expects(:create).returns(status: 'active', customer: 'cus_1234') expect { post "/s/subscriptions.json", params: { plan: 'plan_1234', customer: 'cus_1234' } @@ -73,6 +79,30 @@ module DiscourseSubscriptions end end + describe "strong customer authenticated transaction" do + context "with subscription" do + it "finalizes the subscription" do + ::Stripe::Price.expects(:retrieve).returns(id: "plan_1234", product: "prod_1234", metadata: {}) + ::Stripe::Subscription.expects(:retrieve).returns(id: "sub_123", customer: 'cus_1234', status: "active") + + expect { + post "/s/subscriptions/finalize.json", params: { plan: 'plan_1234', transaction: 'sub_1234' } + }.to change { DiscourseSubscriptions::Customer.count } + end + end + + context "with one-time payment" do + it "finalizes the one-time payment" do + ::Stripe::Price.expects(:retrieve).returns(id: "plan_1234", product: "prod_1234", metadata: {}) + ::Stripe::Invoice.expects(:retrieve).returns(id: "in_123", customer: 'cus_1234', status: "paid") + + expect { + post "/s/subscriptions/finalize.json", params: { plan: 'plan_1234', transaction: 'in_1234' } + }.to change { DiscourseSubscriptions::Customer.count } + end + end + end + describe "user groups" do let(:group_name) { 'group-123' } let(:group) { Fabricate(:group, name: group_name) }