diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb new file mode 100644 index 0000000..8d47804 --- /dev/null +++ b/app/controllers/payments_controller.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DiscourseSubscriptions + class PaymentsController < ::ApplicationController + include DiscourseSubscriptions::Stripe + + skip_before_action :verify_authenticity_token, only: [:create] + before_action :set_api_key + + requires_login + + def create + begin + payment = ::Stripe::PaymentIntent.create( + payment_method_types: ['card'], + payment_method: params[:payment_method], + amount: params[:amount], + currency: params[:currency], + confirm: true + ) + + render_json_dump payment + + rescue ::Stripe::InvalidRequestError => e + render_json_error e.message + rescue ::Stripe::CardError => e + render_json_error 'Card Declined' + end + end + end +end diff --git a/assets/javascripts/discourse/components/payment-options.js.es6 b/assets/javascripts/discourse/components/payment-options.js.es6 index f4a96a3..c6c1048 100644 --- a/assets/javascripts/discourse/components/payment-options.js.es6 +++ b/assets/javascripts/discourse/components/payment-options.js.es6 @@ -1,5 +1,18 @@ +import { equal } from "@ember/object/computed"; + export default Ember.Component.extend({ + planButtonSelected: equal("planTypeIsSelected", true), + paymentButtonSelected: equal("planTypeIsSelected", false), + actions: { + selectPlans() { + this.set("planTypeIsSelected", true); + }, + + selectPayments() { + this.set("planTypeIsSelected", false); + }, + clickPlan(plan) { this.plans.map(p => p.set("selected", false)); plan.set("selected", true); diff --git a/assets/javascripts/discourse/components/product-list.js.es6 b/assets/javascripts/discourse/components/product-list.js.es6 index 0f338f1..2d01ba9 100644 --- a/assets/javascripts/discourse/components/product-list.js.es6 +++ b/assets/javascripts/discourse/components/product-list.js.es6 @@ -1,4 +1,4 @@ -import computed from "ember-addons/ember-computed-decorators"; +import computed from "discourse-common/utils/decorators"; import User from "discourse/models/user"; export default Ember.Component.extend({ diff --git a/assets/javascripts/discourse/controllers/s-subscribe-show.js.es6 b/assets/javascripts/discourse/controllers/s-subscribe-show.js.es6 index b69ca0f..0b8f25b 100644 --- a/assets/javascripts/discourse/controllers/s-subscribe-show.js.es6 +++ b/assets/javascripts/discourse/controllers/s-subscribe-show.js.es6 @@ -1,7 +1,22 @@ import Customer from "discourse/plugins/discourse-subscriptions/discourse/models/customer"; +import Payment from "discourse/plugins/discourse-subscriptions/discourse/models/payment"; import Subscription from "discourse/plugins/discourse-subscriptions/discourse/models/subscription"; +import computed from "discourse-common/utils/decorators"; +import { i18n } from "discourse/lib/computed"; export default Ember.Controller.extend({ + planTypeIsSelected: true, + + @computed("planTypeIsSelected") + type(planTypeIsSelected) { + return planTypeIsSelected ? "plans" : "payment"; + }, + + @computed("type") + buttonText(type) { + return I18n.t(`discourse_subscriptions.${this.get("type")}.payment_button`); + }, + init() { this._super(...arguments); this.set( @@ -13,54 +28,83 @@ export default Ember.Controller.extend({ this.set("cardElement", elements.create("card", { hidePostalCode: true })); }, + alert(path) { + bootbox.alert(I18n.t(`discourse_subscriptions.${path}`)); + }, + + createPayment(plan) { + return this.stripe + .createPaymentMethod("card", this.get("cardElement")) + .then(result => { + const payment = Payment.create({ + payment_method: result.paymentMethod.id, + amount: plan.get("amount"), + currency: plan.get("currency") + }); + + return payment.save(); + }); + }, + + createSubsciption(plan) { + return this.stripe.createToken(this.get("cardElement")).then(result => { + if (result.error) { + return result; + } else { + const customer = Customer.create({ source: result.token.id }); + + return customer.save().then(c => { + const subscription = Subscription.create({ + customer: c.id, + plan: plan.get("id") + }); + + return subscription.save(); + }); + } + }); + }, + actions: { stripePaymentHandler() { this.set("loading", true); + const type = this.get("type"); const plan = this.get("model.plans") .filterBy("selected") .get("firstObject"); if (!plan) { - bootbox.alert( - I18n.t( - "discourse_subscriptions.transactions.payment.validate.plan.required" - ) - ); - + this.alert(`${type}.validate.payment_options.required`); this.set("loading", false); return; } - this.stripe.createToken(this.get("cardElement")).then(result => { - if (result.error) { - bootbox.alert(result.error.message); + let transaction; + + if (this.planTypeIsSelected) { + transaction = this.createSubsciption(plan); + } else { + transaction = this.createPayment(plan); + } + + transaction + .then(result => { + if (result.error) { + bootbox.alert(result.error.message || result.error); + } else { + this.alert(`${type}.success`); + this.transitionToRoute( + "user.subscriptions", + Discourse.User.current().username.toLowerCase() + ); + } + }) + .catch(result => { + bootbox.alert(result.errorThrown); + }) + .finally(() => { this.set("loading", false); - } else { - const customer = Customer.create({ source: result.token.id }); - - customer.save().then(c => { - const subscription = Subscription.create({ - customer: c.id, - plan: plan.get("id") - }); - - subscription - .save() - .then(() => { - bootbox.alert( - I18n.t("discourse_subscriptions.transactions.payment.success") - ); - this.transitionToRoute( - "user.subscriptions", - Discourse.User.current().username.toLowerCase() - ); - }) - .finally(() => { - this.set("loading", false); - }); - }); - } - }); + }); } } }); diff --git a/assets/javascripts/discourse/models/payment.js.es6 b/assets/javascripts/discourse/models/payment.js.es6 new file mode 100644 index 0000000..1f7fe19 --- /dev/null +++ b/assets/javascripts/discourse/models/payment.js.es6 @@ -0,0 +1,15 @@ +import { ajax } from "discourse/lib/ajax"; + +const Payment = Discourse.Model.extend({ + save() { + const data = { + payment_method: this.payment_method, + amount: this.amount, + currency: this.currency + }; + + return ajax("/s/payments", { method: "post", data }); + } +}); + +export default Payment; diff --git a/assets/javascripts/discourse/templates/components/payment-options.hbs b/assets/javascripts/discourse/templates/components/payment-options.hbs index 62dbd42..c3ee8c1 100644 --- a/assets/javascripts/discourse/templates/components/payment-options.hbs +++ b/assets/javascripts/discourse/templates/components/payment-options.hbs @@ -1,27 +1,35 @@ -{{#ds-button - id="discourse-subscriptions-payment-type-plan" - selected=true - class="btn-discourse-subscriptions-payment-type" -}} - {{i18n "discourse_subscriptions.plans.purchase"}} -{{/ds-button}} +
+ {{#ds-button + id="discourse-subscriptions-payment-type-plan" + selected=planButtonSelected + action="selectPlans" + class="btn-discourse-subscriptions-payment-type" + }} + {{i18n "discourse_subscriptions.plans.purchase"}} + {{/ds-button}} -{{#ds-button - id="discourse-subscriptions-payment-type-payment" - selected=false - class="btn-discourse-subscriptions-payment-type" -}} - {{i18n "discourse_subscriptions.payment.purchase"}} -{{/ds-button}} + {{#ds-button + id="discourse-subscriptions-payment-type-payment" + selected=paymentButtonSelected + action="selectPayments" + class="btn-discourse-subscriptions-payment-type" + }} + {{i18n "discourse_subscriptions.payment.purchase"}} + {{/ds-button}} +

- {{i18n "discourse_subscriptions.plans.select"}} + {{#if planTypeIsSelected}} + {{i18n "discourse_subscriptions.plans.select"}} + {{else}} + {{i18n "discourse_subscriptions.payment.select"}} + {{/if}}

-
+
{{#each plans as |plan|}} {{#ds-button action="clickPlan" @@ -30,7 +38,11 @@ class="btn-discourse-subscriptions-subscribe" }}
- {{i18n (concat "discourse_subscriptions.plans.interval.adverb." plan.interval)}} + {{#if planTypeIsSelected}} + {{i18n (concat "discourse_subscriptions.plans.interval.adverb." plan.interval)}} + {{else}} + {{i18n "discourse_subscriptions.payment.interval"}} + {{/if}}
{{format-currency plan.currency plan.amountDollars}} diff --git a/assets/javascripts/discourse/templates/s/subscribe/show.hbs b/assets/javascripts/discourse/templates/s/subscribe/show.hbs index cfd0bc3..23faeaa 100644 --- a/assets/javascripts/discourse/templates/s/subscribe/show.hbs +++ b/assets/javascripts/discourse/templates/s/subscribe/show.hbs @@ -14,7 +14,10 @@ {{i18n 'discourse_subscriptions.subscribe.card.title'}} - {{payment-options plans=model.plans}} + {{payment-options + plans=model.plans + planTypeIsSelected=planTypeIsSelected + }}
@@ -27,7 +30,7 @@ disabled=loading action="stripePaymentHandler" class="btn btn-primary btn-payment"}} - {{i18n 'discourse_subscriptions.subscribe.buttons.subscribe'}} + {{buttonText}} {{/d-button}} {{/if}} diff --git a/assets/stylesheets/common/subscribe.scss b/assets/stylesheets/common/subscribe.scss index acc9540..b8c9edd 100644 --- a/assets/stylesheets/common/subscribe.scss +++ b/assets/stylesheets/common/subscribe.scss @@ -1,4 +1,4 @@ -#subscribe-buttons { +.subscribe-buttons { display: flex; justify-content: space-around; diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index a4d3bb3..56799e9 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -18,26 +18,35 @@ en: title: Discourse Subscriptions admin_navigation: Subscriptions optional: Optional - transactions: - payment: - success: Your payment was successful - validate: - plan: - required: Please select a subscription plan. navigation: subscriptions: Subscriptions subscribe: Subscribe billing: Billing plans: - select: Select subscription plan purchase: Purchase a subscription + select: Select subscription plan interval: adverb: week: Weekly month: Monthly year: Yearly + payment_button: + Subscribe + success: Thank you! Your subscription has been created. + validate: + payment_options: + required: Please select a subscription plan. payment: purchase: Make just one payment + select: Select a payment option + interval: + One payment + payment_button: + Pay Once + success: Thank you! + validate: + payment_options: + required: Please select a payment option. one_time: heading: payment: Make a Payment diff --git a/config/routes.rb b/config/routes.rb index 1124db6..b3091d2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,6 +18,7 @@ DiscourseSubscriptions::Engine.routes.draw do resources :customers, only: [:create] resources :invoices, only: [:index] + resources :payments, only: [:create] resources :patrons, only: [:index, :create] resources :plans, only: [:index] resources :products, only: [:index, :show] diff --git a/plugin.rb b/plugin.rb index 9c3c42d..fea1d7c 100644 --- a/plugin.rb +++ b/plugin.rb @@ -62,6 +62,7 @@ after_initialize do "../app/controllers/invoices_controller", "../app/controllers/patrons_controller", "../app/controllers/plans_controller", + "../app/controllers/payments_controller", "../app/controllers/products_controller", "../app/controllers/subscriptions_controller", "../app/models/customer", diff --git a/spec/requests/payments_controller_spec.rb b/spec/requests/payments_controller_spec.rb new file mode 100644 index 0000000..bac66ab --- /dev/null +++ b/spec/requests/payments_controller_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module DiscourseSubscriptions + RSpec.describe PaymentsController do + context "not authenticated" do + it "does not create a payment intent" do + ::Stripe::PaymentIntent.expects(:create).never + post "/s/payments.json", params: { } + end + end + + context "authenticated" do + let(:user) { Fabricate(:user) } + + before do + sign_in(user) + end + + describe "create" do + it "creates a payment intent" do + ::Stripe::PaymentIntent.expects(:create).with( + payment_method_types: ['card'], + payment_method: 'pm_123', + amount: '999', + currency: 'gdp', + confirm: true + ) + + post "/s/payments.json", params: { + payment_method: 'pm_123', + amount: 999, + currency: 'gdp' + } + end + end + end + end +end diff --git a/test/javascripts/components/payment-options-test.js.es6 b/test/javascripts/components/payment-options-test.js.es6 index 49f323b..435d032 100644 --- a/test/javascripts/components/payment-options-test.js.es6 +++ b/test/javascripts/components/payment-options-test.js.es6 @@ -18,7 +18,7 @@ componentTest("Discourse Subscriptions payment options have no plans", { }); componentTest("Discourse Subscriptions payment options has content", { - template: `{{payment-options plans=plans}}`, + template: `{{payment-options plans=plans planTypeIsSelected=planTypeIsSelected}}`, async test(assert) { this.set("plans", [ @@ -26,21 +26,8 @@ componentTest("Discourse Subscriptions payment options has content", { { currency: "gdp", interval: "month", amountDollars: "9.99" } ]); - assert.equal( - find(".btn-discourse-subscriptions-payment-type").length, - 2, - "The payment type buttons are shown" - ); - assert.equal( - find("#discourse-subscriptions-payment-type-plan.btn-primary").length, - 1, - "The plan payment type button is selected" - ); - assert.equal( - find("#discourse-subscriptions-payment-type-payment.btn-primary").length, - 0, - "The single payment type button is not selected" - ); + this.set("planTypeIsSelected", true); + assert.equal( find(".btn-discourse-subscriptions-payment-type").length, 2, @@ -73,22 +60,40 @@ componentTest("Discourse Subscriptions payment options has content", { } }); -componentTest("Discourse Subscriptions payment options plan is selected", { - template: `{{payment-options plans=plans}}`, - - beforeEach() {}, +componentTest("Discourse Subscriptions payment type plan", { + template: `{{payment-options plans=plans planTypeIsSelected=planTypeIsSelected}}`, async test(assert) { this.set("plans", [ - EmberObject.create({ - currency: "aud", - interval: "year", - amountDollars: "44.99" - }) + { currency: "aud", interval: "year", amountDollars: "44.99" } ]); - await click(".btn-discourse-subscriptions-subscribe:first-child"); + this.set("planTypeIsSelected", true); - assert.ok(this.get("plans.firstObject.selected"), "it selected the plan"); + assert.equal( + find("#discourse-subscriptions-payment-type-plan.btn-primary").length, + 1, + "The plan type button is selected" + ); + + assert.equal( + find("#discourse-subscriptions-payment-type-payment.btn-primary").length, + 0, + "The payment type button is not selected" + ); + + await click("#discourse-subscriptions-payment-type-payment"); + + assert.equal( + find("#discourse-subscriptions-payment-type-plan.btn-primary").length, + 0, + "The plan type button is selected" + ); + + assert.equal( + find("#discourse-subscriptions-payment-type-payment.btn-primary").length, + 1, + "The payment type button is not selected" + ); } });