diff --git a/README.md b/README.md index 326b7d4..6f36b20 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,16 @@ See [Screenshots](#screenshots) below. * Be sure your site is enforcing https. * Follow the install instructions here: https://meta.discourse.org/t/install-a-plugin/19157 -* Add your Stripe public and private keys in settings and set the currency to your local value. + +##Settings: + +You'll need to get some info from your Stripe account to complete the steps below: https://dashboard.stripe.com/ + +* Add your Stripe public and private keys +* Set the currency to your local value. +* Add your Stripe webhook secret. + +See webhook info below. ## What are Subscriptions? @@ -34,6 +43,18 @@ Firstly, you'll need an account with the [Stripe](https://stripe.com) payment ga When you get a moment, take a look at Stripe's documentation. But for now, you can set up an account in test mode and see how it all works without making any real transactions. Then, if you're happy with how everything works, you can start taking real transactions. See below for test credit card numbers. +### Enable Webhooks in your Stripe account + +You'll need to tell Stripe where your end points are. You can enter this in your Stripe dashboard. +Also: Add the webhook secret in settings (above). + +The address for webhooks is: `[your server address]/s/hooks` + +Discourse Subscriptions responds to the following events: + +* `customer.subscription.deleted` +* `customer.subscription.updated` + ### Set up your User Groups in Discourse When a user successfully subscribes to your Discourse application, after their credit card transaction has been processed, they are added to a User Group. By assigning users to a User Group, you can manage what your users have access to on your website. User groups are a core functionality of Discourse and this plugin does nothing with them except and and remove users from the group you associated with your Plan. @@ -52,17 +73,14 @@ In the admin, add a new Product. Once you have a product saved, you can add plan If you take a look at your [Stripe Dashboard](https://dashboard.stripe.com), you'll see all those products and plans are listed. Discourse Subscriptions does not create them locally. They are created in Stripe. -## Enable Webhooks - -You'll need to tell Stripe where your end points are. You can enter this in your Stripe dashboard. - -The address for webhooks is: `/s/hooks` - ## Testing Test with these credit card numbers: -* 4111 1111 1111 1111 +* 4111 1111 1111 1111 (no authentication required) +* 4000 0027 6000 3184 (authentication required) + +For more test card numbers: https://stripe.com/docs/testing Visit `/s` and enter a few test transactions. diff --git a/app/controllers/hooks_controller.rb b/app/controllers/hooks_controller.rb index eeeb32b..1c3e2c1 100644 --- a/app/controllers/hooks_controller.rb +++ b/app/controllers/hooks_controller.rb @@ -2,10 +2,69 @@ module DiscourseSubscriptions class HooksController < ::ApplicationController + include DiscourseSubscriptions::Group skip_before_action :verify_authenticity_token, only: [:create] def create + begin + payload = request.body.read + sig_header = request.env['HTTP_STRIPE_SIGNATURE'] + webhook_secret = SiteSetting.discourse_subscriptions_webhook_secret + + event = ::Stripe::Webhook.construct_event(payload, sig_header, webhook_secret) + + rescue JSON::ParserError => e + render_json_error e.message + return + rescue Stripe::SignatureVerificationError => e + render_json_error e.message + return + end + + case event[:type] + when 'customer.subscription.updated' + customer = Customer.find_by( + customer_id: event[:data][:object][:customer], + product_id: event[:data][:object][:plan][:product] + ) + + if customer && subscription_completion?(event) + user = ::User.find(customer.user_id) + group = plan_group(event[:data][:object][:plan]) + group.add(user) if group + end + + when 'customer.subscription.deleted' + + customer = Customer.find_by( + customer_id: event[:data][:object][:customer], + product_id: event[:data][:object][:plan][:product] + ) + + if customer + customer.delete + + user = ::User.find(customer.user_id) + group = plan_group(event[:data][:object][:plan]) + group.remove(user) if group + end + end + head 200 end + + private + + def subscription_completion?(event) + subscription_complete?(event) && previously_incomplete?(event) + end + + def subscription_complete?(event) + event.dig(:data, :object, :status) == 'complete' + end + + def previously_incomplete?(event) + event.dig(:data, :previous_attributes, :status) == 'incomplete' + end end end diff --git a/assets/javascripts/discourse/controllers/s-show.js.es6 b/assets/javascripts/discourse/controllers/s-show.js.es6 index d0ea851..d481024 100644 --- a/assets/javascripts/discourse/controllers/s-show.js.es6 +++ b/assets/javascripts/discourse/controllers/s-show.js.es6 @@ -49,7 +49,7 @@ export default Ember.Controller.extend({ }); }, - createSubsciption(plan) { + createSubscription(plan) { return this.stripe.createToken(this.get("cardElement")).then(result => { if (result.error) { return result; @@ -85,7 +85,7 @@ export default Ember.Controller.extend({ let transaction; if (this.planTypeIsSelected) { - transaction = this.createSubsciption(plan); + transaction = this.createSubscription(plan); } else { transaction = this.createPayment(plan); } @@ -95,7 +95,11 @@ export default Ember.Controller.extend({ if (result.error) { bootbox.alert(result.error.message || result.error); } else { - this.alert(`${type}.success`); + if (result.status === "incomplete") { + this.alert(`${type}.incomplete`); + } else { + this.alert(`${type}.success`); + } const success_route = this.planTypeIsSelected ? "user.billing.subscriptions" diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index c23a3a2..020b917 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -4,6 +4,7 @@ en: discourse_subscriptions_extra_nav_subscribe: Show the subscribe button in the primary navigation discourse_subscriptions_public_key: Stripe Publishable Key discourse_subscriptions_secret_key: Stripe Secret Key + discourse_subscriptions_webhook_secret: Stripe Webhook Secret discourse_subscriptions_currency: Default Currency Code. This can be overridden when creating a subscription plan. discourse_subscriptions_allow_payments: Allow single payments errors: @@ -31,6 +32,7 @@ en: payment_button: Subscribe success: Thank you! Your subscription has been created. + incomplete: The payment is incomplete. Your subscription will be updated when payment is complete. validate: payment_options: required: Please select a subscription plan. @@ -42,6 +44,7 @@ en: payment_button: Pay Once success: Thank you! + incomplete: Payment is incomplete. validate: payment_options: required: Please select a payment option. diff --git a/config/settings.yml b/config/settings.yml index 0ed6c2e..fd3975c 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -10,6 +10,9 @@ plugins: discourse_subscriptions_secret_key: default: '' client: false + discourse_subscriptions_webhook_secret: + default: '' + client: false discourse_subscriptions_allow_payments: default: false client: true diff --git a/plugin.rb b/plugin.rb index 67d8cf9..843726b 100644 --- a/plugin.rb +++ b/plugin.rb @@ -8,7 +8,7 @@ enabled_site_setting :discourse_subscriptions_enabled -gem 'stripe', '5.13.0' +gem 'stripe', '5.14.0' register_asset "stylesheets/common/main.scss" register_asset "stylesheets/common/layout.scss" diff --git a/spec/requests/hooks_controller_spec.rb b/spec/requests/hooks_controller_spec.rb index f7687d8..6398464 100644 --- a/spec/requests/hooks_controller_spec.rb +++ b/spec/requests/hooks_controller_spec.rb @@ -4,9 +4,121 @@ require 'rails_helper' module DiscourseSubscriptions RSpec.describe HooksController do - it "responds ok" do - post "/s/hooks.json" + before do + SiteSetting.discourse_subscriptions_webhook_secret = 'zascharoo' + end + + it "contructs a webhook event" do + payload = 'we-want-a-shrubbery' + headers = { HTTP_STRIPE_SIGNATURE: 'stripe-webhook-signature' } + + ::Stripe::Webhook + .expects(:construct_event) + .with('we-want-a-shrubbery', 'stripe-webhook-signature', 'zascharoo') + .returns(type: 'something') + + post "/s/hooks.json", params: payload, headers: headers + expect(response.status).to eq 200 end + + describe "event types" do + let(:user) { Fabricate(:user) } + let(:customer) { Fabricate(:customer, customer_id: 'c_575768', product_id: 'p_8654', user_id: user.id) } + let(:group) { Fabricate(:group, name: 'subscribers-group') } + + let(:event_data) do + { + object: { + customer: customer.customer_id, + plan: { product: customer.product_id, metadata: { group_name: group.name } } + } + } + end + + describe "customer.subscription.updated" do + before do + event = { + type: 'customer.subscription.updated', + data: event_data + } + + ::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 'does not add the user to the group' do + event_data[:object][:status] = 'incomplete' + event_data[:previous_attributes] = { status: 'incomplete' } + + expect { + post "/s/hooks.json" + }.not_to change { user.groups.count } + + expect(response.status).to eq 200 + end + + it 'does not add the user to the group' do + event_data[:object][:status] = 'incomplete' + event_data[:previous_attributes] = { status: 'something-else' } + + expect { + post "/s/hooks.json" + }.not_to change { user.groups.count } + + expect(response.status).to eq 200 + end + + it 'adds the user to the group when completing the transaction' do + event_data[:object][:status] = 'complete' + event_data[:previous_attributes] = { status: 'incomplete' } + + expect { + post "/s/hooks.json" + }.to change { user.groups.count }.by(1) + + expect(response.status).to eq 200 + end + end + end + + describe "customer.subscription.deleted" do + before do + event = { + type: 'customer.subscription.deleted', + data: event_data + } + + ::Stripe::Webhook + .stubs(:construct_event) + .returns(event) + + group.add(user) + end + + it "deletes the customer" do + expect { + post "/s/hooks.json" + }.to change { DiscourseSubscriptions::Customer.count }.by(-1) + + expect(response.status).to eq 200 + end + + it "removes the user from the group" do + expect { + post "/s/hooks.json" + }.to change { user.groups.count }.by(-1) + + expect(response.status).to eq 200 + end + end + end end end diff --git a/test/javascripts/acceptance/payments-test.js.es6 b/test/javascripts/acceptance/payments-test.js.es6 index 4978a69..998f69f 100644 --- a/test/javascripts/acceptance/payments-test.js.es6 +++ b/test/javascripts/acceptance/payments-test.js.es6 @@ -1,18 +1,17 @@ import { acceptance } from "helpers/qunit-helpers"; import { stubStripe } from "discourse/plugins/discourse-subscriptions/helpers/stripe"; -acceptance("Discourse Patrons", { - settings: { - discourse_patrons_amounts: "1.00|2.00" - }, - +acceptance("Discourse Subscriptions", { beforeEach() { stubStripe(); - } + }, + + loggedIn: true }); -QUnit.skip("viewing the one-off payment page", async assert => { +QUnit.test("viewing product page", async assert => { await visit("/s"); - assert.ok($(".donations-page-payment").length, "has payment form class"); + assert.ok($("#product-list").length, "has product page"); + assert.ok($(".product:first-child a").length, "has a link"); }); diff --git a/test/javascripts/acceptance/plugin-outlets-test.js.es6 b/test/javascripts/acceptance/plugin-outlets-test.js.es6 index 931c8aa..f7c769f 100644 --- a/test/javascripts/acceptance/plugin-outlets-test.js.es6 +++ b/test/javascripts/acceptance/plugin-outlets-test.js.es6 @@ -1,6 +1,6 @@ import { acceptance } from "helpers/qunit-helpers"; -acceptance("Discourse Patrons", { +acceptance("Discourse Subscriptions", { settings: { discourse_subscriptions_extra_nav_subscribe: true } diff --git a/test/javascripts/acceptance/subscribe-test.js.es6 b/test/javascripts/acceptance/subscribe-test.js.es6 index 439059f..815bb38 100644 --- a/test/javascripts/acceptance/subscribe-test.js.es6 +++ b/test/javascripts/acceptance/subscribe-test.js.es6 @@ -1,14 +1,23 @@ import { acceptance } from "helpers/qunit-helpers"; +import { stubStripe } from "discourse/plugins/discourse-subscriptions/helpers/stripe"; -acceptance("Discourse Patrons", { - settings: { - discourse_patrons_subscription_group: "plan-id" +acceptance("Discourse Subscriptions", { + beforeEach() { + stubStripe(); }, + loggedIn: true }); -QUnit.skip("subscribing", async assert => { - await visit("/patrons/subscribe"); +QUnit.test("subscribing", async assert => { + await visit("/s"); - assert.ok($("h3").length, "has a heading"); + await click(".product:first-child a"); + + assert.ok( + $(".discourse-subscriptions-section-columns").length, + "has the sections for billing" + ); + + assert.ok($(".subscribe-buttons button").length, "has buttons for subscribe"); }); diff --git a/test/javascripts/helpers/product-pretender.js.es6 b/test/javascripts/helpers/product-pretender.js.es6 index aaa85d6..14ccb40 100644 --- a/test/javascripts/helpers/product-pretender.js.es6 +++ b/test/javascripts/helpers/product-pretender.js.es6 @@ -1,7 +1,7 @@ export default function(helpers) { const { response } = helpers; - this.get("/patrons/products", () => { + this.get("/s/products", () => { const products = [ { id: "prod_23o8I7tU4g56", @@ -19,4 +19,23 @@ export default function(helpers) { return response(products); }); + + this.get("/s/products/:id", () => { + const product = {}; + + return response(product); + }); + + this.get("/s/plans", () => { + const plans = [ + { + id: "plan_GHGHSHS8654G", + amount: 200, + currency: "usd", + interval: "month" + } + ]; + + return response(plans); + }); }