From 45754baa0070f1fe600f4fd71d2d46e61d359b13 Mon Sep 17 00:00:00 2001 From: Blake Erickson Date: Mon, 29 Apr 2024 12:47:58 -0600 Subject: [PATCH] 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 --- .../hooks_controller.rb | 35 +++++ .../pricingtable_controller.rb | 11 ++ .../discourse/controllers/subscriptions.js | 43 ++++++ .../initializers/setup-subscriptions.js | 5 +- .../discourse/subscriptions-route-map.js | 1 + .../discourse/templates/subscriptions.hbs | 3 + config/locales/client.en.yml | 2 + config/settings.yml | 6 + plugin.rb | 4 + spec/requests/hooks_controller_spec.rb | 122 ++++++++++++++++++ spec/system/pricing_table_spec.rb | 80 ++++++++++++ 11 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 app/controllers/discourse_subscriptions/pricingtable_controller.rb create mode 100644 assets/javascripts/discourse/controllers/subscriptions.js create mode 100644 assets/javascripts/discourse/templates/subscriptions.hbs create mode 100644 spec/system/pricing_table_spec.rb diff --git a/app/controllers/discourse_subscriptions/hooks_controller.rb b/app/controllers/discourse_subscriptions/hooks_controller.rb index e779c6d..cf235e8 100644 --- a/app/controllers/discourse_subscriptions/hooks_controller.rb +++ b/app/controllers/discourse_subscriptions/hooks_controller.rb @@ -9,6 +9,7 @@ module DiscourseSubscriptions layout false + before_action :set_api_key skip_before_action :check_xhr skip_before_action :redirect_to_login_if_required skip_before_action :verify_authenticity_token, only: [:create] @@ -27,6 +28,40 @@ module DiscourseSubscriptions end 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.updated" customer = diff --git a/app/controllers/discourse_subscriptions/pricingtable_controller.rb b/app/controllers/discourse_subscriptions/pricingtable_controller.rb new file mode 100644 index 0000000..eca32e4 --- /dev/null +++ b/app/controllers/discourse_subscriptions/pricingtable_controller.rb @@ -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 diff --git a/assets/javascripts/discourse/controllers/subscriptions.js b/assets/javascripts/discourse/controllers/subscriptions.js new file mode 100644 index 0000000..41b62b9 --- /dev/null +++ b/assets/javascripts/discourse/controllers/subscriptions.js @@ -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(``); + } else { + return htmlSafe(``); + } + } catch (error) { + return I18n.t("discourse_subscriptions.subscribe.no_products"); + } + }), +}); diff --git a/assets/javascripts/discourse/initializers/setup-subscriptions.js b/assets/javascripts/discourse/initializers/setup-subscriptions.js index 42339a9..1c3c190 100644 --- a/assets/javascripts/discourse/initializers/setup-subscriptions.js +++ b/assets/javascripts/discourse/initializers/setup-subscriptions.js @@ -8,11 +8,14 @@ export default { const siteSettings = container.lookup("service:site-settings"); const isNavLinkEnabled = siteSettings.discourse_subscriptions_extra_nav_subscribe; + const isPricingTableEnabled = + siteSettings.discourse_subscriptions_pricing_table_enabled; + const subscribeHref = isPricingTableEnabled ? "/s/subscriptions" : "/s"; if (isNavLinkEnabled) { api.addNavigationBarItem({ name: "subscribe", displayName: I18n.t("discourse_subscriptions.navigation.subscribe"), - href: "/s", + href: subscribeHref, }); } diff --git a/assets/javascripts/discourse/subscriptions-route-map.js b/assets/javascripts/discourse/subscriptions-route-map.js index 2d68b94..cea2fb2 100644 --- a/assets/javascripts/discourse/subscriptions-route-map.js +++ b/assets/javascripts/discourse/subscriptions-route-map.js @@ -1,4 +1,5 @@ export default function () { + this.route("subscriptions", { path: "/s/subscriptions" }); this.route("subscribe", { path: "/s" }, function () { this.route("show", { path: "/:subscription-id" }); }); diff --git a/assets/javascripts/discourse/templates/subscriptions.hbs b/assets/javascripts/discourse/templates/subscriptions.hbs new file mode 100644 index 0000000..4705670 --- /dev/null +++ b/assets/javascripts/discourse/templates/subscriptions.hbs @@ -0,0 +1,3 @@ +
+ {{pricingTable}} +
\ No newline at end of file diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 6d1f10f..c069e93 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -7,6 +7,8 @@ en: site_settings: discourse_subscriptions_enabled: Enable the Discourse Subscriptions plugin. 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_secret_key: Stripe Secret Key discourse_subscriptions_webhook_secret: Stripe Webhook Secret diff --git a/config/settings.yml b/config/settings.yml index 3dbd594..8e77799 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -4,6 +4,12 @@ discourse_subscriptions: discourse_subscriptions_extra_nav_subscribe: default: false client: true + discourse_subscriptions_pricing_table_id: + default: '' + client: true + discourse_subscriptions_pricing_table_enabled: + default: false + client: true discourse_subscriptions_public_key: default: '' client: true diff --git a/plugin.rb b/plugin.rb index 0b02690..ec5e583 100644 --- a/plugin.rb +++ b/plugin.rb @@ -22,6 +22,10 @@ register_html_builder("server:before-head-close") do |controller| "" end +register_html_builder("server:before-head-close") do |controller| + "" +end + 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" diff --git a/spec/requests/hooks_controller_spec.rb b/spec/requests/hooks_controller_spec.rb index 60a863d..8cc94b7 100644 --- a/spec/requests/hooks_controller_spec.rb +++ b/spec/requests/hooks_controller_spec.rb @@ -43,6 +43,128 @@ RSpec.describe DiscourseSubscriptions::HooksController do } 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 before do event = { type: "customer.subscription.updated", data: event_data } diff --git a/spec/system/pricing_table_spec.rb b/spec/system/pricing_table_spec.rb new file mode 100644 index 0000000..1e2b34a --- /dev/null +++ b/spec/system/pricing_table_spec.rb @@ -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