FEATURE: Add new stripe based pricing table (#202)

This commit adds an optional new stripe based pricing table.
If the user is logged in, the email field will be prepopulated with the users email.

The pricing table can be configured in the stripe dashboard.

Once the discourse_subscriptions_pricing_table setting is filled with the pricing table embed code from the stripe dashboard,
the pricing table will be displayed on /s/subscriptions

For more details see https://stripe.com/docs/payments/checkout/pricing-table
---------

Co-authored-by: spirobel <spirobel@protonmail.com>
This commit is contained in:
Blake Erickson 2024-04-29 12:47:58 -06:00 committed by GitHub
parent dcde03d7c4
commit 45754baa00
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 311 additions and 1 deletions

View File

@ -9,6 +9,7 @@ module DiscourseSubscriptions
layout false layout false
before_action :set_api_key
skip_before_action :check_xhr skip_before_action :check_xhr
skip_before_action :redirect_to_login_if_required skip_before_action :redirect_to_login_if_required
skip_before_action :verify_authenticity_token, only: [:create] skip_before_action :verify_authenticity_token, only: [:create]
@ -27,6 +28,40 @@ module DiscourseSubscriptions
end end
case event[:type] case event[:type]
when "checkout.session.completed"
checkout_session = event[:data][:object]
email = checkout_session[:customer_email]
return head 200 if checkout_session[:status] != "complete"
return render_json_error "customer not found" if checkout_session[:customer].nil?
customer_id = checkout_session[:customer]
user = ::User.find_by_username_or_email(email)
discourse_customer = Customer.find_by(user_id: user.id)
if discourse_customer.nil?
discourse_customer = Customer.create(user_id: user.id, customer_id: customer_id)
end
Subscription.create(
customer_id: discourse_customer.id,
external_id: checkout_session[:subscription],
)
line_items =
::Stripe::Checkout::Session.list_line_items(checkout_session[:id], { limit: 1 })
item = line_items[:data].first
group = plan_group(item[:price])
group.add(user) unless group.nil?
discourse_customer.product_id = item[:price][:product]
discourse_customer.save!
::Stripe::Subscription.update(
checkout_session[:subscription],
{ metadata: { user_id: user.id, username: user.username } },
)
when "customer.subscription.created" when "customer.subscription.created"
when "customer.subscription.updated" when "customer.subscription.updated"
customer = customer =

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
module DiscourseSubscriptions
class PricingtableController < ::ApplicationController
requires_plugin DiscourseSubscriptions::PLUGIN_NAME
def index
head 200
end
end
end

View File

@ -0,0 +1,43 @@
import Controller from "@ember/controller";
import { computed } from "@ember/object";
import { htmlSafe } from "@ember/template";
import I18n from "I18n";
export default Controller.extend({
init() {
this._super(...arguments);
if (this.currentUser) {
this.currentUser
.checkEmail()
.then(() => this.set("email", this.currentUser.email));
}
},
pricingTable: computed("email", function () {
try {
const pricingTableId =
this.siteSettings.discourse_subscriptions_pricing_table_id;
const publishableKey =
this.siteSettings.discourse_subscriptions_public_key;
const pricingTableEnabled =
this.siteSettings.discourse_subscriptions_pricing_table_enabled;
if (!pricingTableEnabled || !pricingTableId || !publishableKey) {
throw new Error("Pricing table not configured");
}
if (this.currentUser) {
return htmlSafe(`<stripe-pricing-table
pricing-table-id="${pricingTableId}"
publishable-key="${publishableKey}"
customer-email="${this.email}"></stripe-pricing-table>`);
} else {
return htmlSafe(`<stripe-pricing-table
pricing-table-id="${pricingTableId}"
publishable-key="${publishableKey}"
></stripe-pricing-table>`);
}
} catch (error) {
return I18n.t("discourse_subscriptions.subscribe.no_products");
}
}),
});

View File

@ -8,11 +8,14 @@ export default {
const siteSettings = container.lookup("service:site-settings"); const siteSettings = container.lookup("service:site-settings");
const isNavLinkEnabled = const isNavLinkEnabled =
siteSettings.discourse_subscriptions_extra_nav_subscribe; siteSettings.discourse_subscriptions_extra_nav_subscribe;
const isPricingTableEnabled =
siteSettings.discourse_subscriptions_pricing_table_enabled;
const subscribeHref = isPricingTableEnabled ? "/s/subscriptions" : "/s";
if (isNavLinkEnabled) { if (isNavLinkEnabled) {
api.addNavigationBarItem({ api.addNavigationBarItem({
name: "subscribe", name: "subscribe",
displayName: I18n.t("discourse_subscriptions.navigation.subscribe"), displayName: I18n.t("discourse_subscriptions.navigation.subscribe"),
href: "/s", href: subscribeHref,
}); });
} }

View File

@ -1,4 +1,5 @@
export default function () { export default function () {
this.route("subscriptions", { path: "/s/subscriptions" });
this.route("subscribe", { path: "/s" }, function () { this.route("subscribe", { path: "/s" }, function () {
this.route("show", { path: "/:subscription-id" }); this.route("show", { path: "/:subscription-id" });
}); });

View File

@ -0,0 +1,3 @@
<div class="container">
{{pricingTable}}
</div>

View File

@ -7,6 +7,8 @@ en:
site_settings: site_settings:
discourse_subscriptions_enabled: Enable the Discourse Subscriptions plugin. discourse_subscriptions_enabled: Enable the Discourse Subscriptions plugin.
discourse_subscriptions_extra_nav_subscribe: Show the subscribe button in the primary navigation discourse_subscriptions_extra_nav_subscribe: Show the subscribe button in the primary navigation
discourse_subscriptions_pricing_table_id: Add the pricing-table-id from the Pricing Table embed code here.
discourse_subscriptions_pricing_table_enabled: Subscribe button will show the embedded pricing table and Stripe Checkout will be used.
discourse_subscriptions_public_key: Stripe Publishable Key discourse_subscriptions_public_key: Stripe Publishable Key
discourse_subscriptions_secret_key: Stripe Secret Key discourse_subscriptions_secret_key: Stripe Secret Key
discourse_subscriptions_webhook_secret: Stripe Webhook Secret discourse_subscriptions_webhook_secret: Stripe Webhook Secret

View File

@ -4,6 +4,12 @@ discourse_subscriptions:
discourse_subscriptions_extra_nav_subscribe: discourse_subscriptions_extra_nav_subscribe:
default: false default: false
client: true client: true
discourse_subscriptions_pricing_table_id:
default: ''
client: true
discourse_subscriptions_pricing_table_enabled:
default: false
client: true
discourse_subscriptions_public_key: discourse_subscriptions_public_key:
default: '' default: ''
client: true client: true

View File

@ -22,6 +22,10 @@ register_html_builder("server:before-head-close") do |controller|
"<script src='https://js.stripe.com/v3/' nonce='#{controller.helpers.csp_nonce_placeholder}'></script>" "<script src='https://js.stripe.com/v3/' nonce='#{controller.helpers.csp_nonce_placeholder}'></script>"
end end
register_html_builder("server:before-head-close") do |controller|
"<script async src='https://js.stripe.com/v3/pricing-table.js' nonce='#{controller.helpers.csp_nonce_placeholder}'></script>"
end
extend_content_security_policy(script_src: %w[https://js.stripe.com/v3/ https://hooks.stripe.com]) extend_content_security_policy(script_src: %w[https://js.stripe.com/v3/ https://hooks.stripe.com])
add_admin_route "discourse_subscriptions.admin_navigation", "discourse-subscriptions.products" add_admin_route "discourse_subscriptions.admin_navigation", "discourse-subscriptions.products"

View File

@ -43,6 +43,128 @@ RSpec.describe DiscourseSubscriptions::HooksController do
} }
end end
let(:checkout_session_completed_data) do
{
object: {
id: "cs_test_a1ENei5A9TGOaEketyV5qweiQR5CyJWHT5j8T3HheQY3uah3RxzKttVUKZ",
object: "checkout.session",
customer: customer.customer_id,
customer_email: user.email,
invoice: "in_1P9b7iEYXaQnncSh81AQtuHD",
metadata: {
},
mode: "subscription",
payment_status: "paid",
status: "complete",
submit_type: nil,
subscription: "sub_1P9b7iEYXaQnncSh3H3G9d2Y",
success_url: "http://localhost:4200/my/billing/subscriptions",
url: nil,
},
}
end
let(:checkout_session_completed_bad_data) do
{
object: {
id: "cs_test_a1ENei5A9TGOaEketyV5qweiQR5CyJWHT5j8T3HheQY3uah3RxzKttVUKZ",
object: "checkout.session",
customer: nil,
customer_email: user.email,
invoice: "in_1P9b7iEYXaQnncSh81AQtuHD",
metadata: {
},
mode: "subscription",
payment_status: "paid",
status: "complete",
submit_type: nil,
subscription: "sub_1P9b7iEYXaQnncSh3H3G9d2Y",
success_url: "http://localhost:4200/my/billing/subscriptions",
url: nil,
},
}
end
let(:list_line_items_data) do
{
data: [
{
id: "li_1P9YlFEYXaQnncShFBl7r0na",
object: "item",
description: "Exclusive Access",
price: {
id: "price_1OrmlvEYXaQnncShNahrpKvA",
metadata: {
group_name: group.name,
trial_period_days: "0",
},
nickname: "EA1",
product: "prod_PhB6IpGhEX14Hi",
},
},
],
}
end
describe "checkout.session.completed" do
before do
event = { type: "checkout.session.completed", data: checkout_session_completed_data }
::Stripe::Checkout::Session
.stubs(:list_line_items)
.with(checkout_session_completed_data[:object][:id], { limit: 1 })
.returns(list_line_items_data)
::Stripe::Subscription
.stubs(:update)
.with(
checkout_session_completed_data[:object][:subscription],
{ metadata: { user_id: user.id, username: user.username } },
)
.returns(
{
id: checkout_session_completed_data[:object][:subscription],
object: "subscription",
metadata: {
user_id: user.id.to_s,
username: user.username,
},
},
)
::Stripe::Webhook.stubs(:construct_event).returns(event)
end
it "is successfull" do
post "/s/hooks.json"
expect(response.status).to eq 200
end
describe "completing the subscription" do
it "adds the user to the group when completing the transaction" do
expect { post "/s/hooks.json" }.to change { user.groups.count }.by(1)
expect(response.status).to eq 200
end
end
end
describe "checkout.session.completed with bad data" do
before do
event = { type: "checkout.session.completed", data: checkout_session_completed_bad_data }
::Stripe::Checkout::Session
.stubs(:list_line_items)
.with(checkout_session_completed_data[:object][:id], { limit: 1 })
.returns(list_line_items_data)
::Stripe::Webhook.stubs(:construct_event).returns(event)
end
it "is returns 422" do
post "/s/hooks.json"
expect(response.status).to eq 422
end
end
describe "customer.subscription.updated" do describe "customer.subscription.updated" do
before do before do
event = { type: "customer.subscription.updated", data: event_data } event = { type: "customer.subscription.updated", data: event_data }

View File

@ -0,0 +1,80 @@
# frozen_string_literal: true
RSpec.describe "Pricing Table", type: :system, js: true do
fab!(:admin)
fab!(:product) { Fabricate(:product, external_id: "prod_OiK") }
let(:dialog) { PageObjects::Components::Dialog.new }
let(:product_subscriptions_page) { PageObjects::Pages::AdminSubscriptionProduct.new }
before do
sign_in(admin)
SiteSetting.discourse_subscriptions_enabled = true
SiteSetting.discourse_subscriptions_extra_nav_subscribe = true
SiteSetting.discourse_subscriptions_secret_key = "sk_test_51xuu"
SiteSetting.discourse_subscriptions_public_key = "pk_test_51xuu"
SiteSetting.discourse_subscriptions_pricing_table_enabled = true
# this needs to be stubbed or it will try to make a request to stripe
one_product = {
id: "prod_OiK",
active: true,
name: "Tomtom",
metadata: {
description: "Photos of tomtom",
repurchaseable: true,
},
}
::Stripe::Product.stubs(:list).returns({ data: [one_product] })
::Stripe::Product.stubs(:delete).returns({ id: "prod_OiK" })
::Stripe::Product.stubs(:retrieve).returns(one_product)
::Stripe::Price.stubs(:list).returns({ data: [] })
end
it "Links to the pricing table page" do
visit("/")
link = find("li.nav-item_subscribe a")
uri = URI.parse(link[:href])
expect(uri.path).to eq("/s/subscriptions")
end
it "Links to the old page when disabled" do
SiteSetting.discourse_subscriptions_pricing_table_enabled = false
visit("/")
link = find("li.nav-item_subscribe a")
uri = URI.parse(link[:href])
expect(uri.path).to eq("/s")
end
it "Old subscribe page still works when disabled" do
SiteSetting.discourse_subscriptions_pricing_table_enabled = false
visit("/")
find("li.nav-item_subscribe a").click
expect(page).to have_selector("div.title-wrapper h1", text: "Subscribe")
end
it "Shows a message when not setup yet" do
visit("/")
find("li.nav-item_subscribe a").click
expect(page).to have_selector(
"div.container",
text: "There are currently no products available.",
)
end
# Commenting out for now, not sure how to stub network reqeusts made in the browser to stripe
# it "Shows a pricing table when setup" do
# SiteSetting.discourse_subscriptions_pricing_table = '{"insert-pricing-table-embed-code"}'
# visit("/")
# find("li.nav-item_subscribe a").click
# expect(page).to have_selector('stripe-pricing-table')
# end
end