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