FEATURE: Add support for 3D Secure payments (#19)

Adds an additional checkout flow to support authentication of payment methods.
This commit is contained in:
Justin DiRose 2020-07-24 15:07:18 -05:00 committed by GitHub
parent 587661fafb
commit 9491f558ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 153 additions and 37 deletions

View File

@ -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

View File

@ -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);
});
}

View File

@ -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 });
}
};

View File

@ -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

View File

@ -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'

View File

@ -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) }