diff --git a/README.md b/README.md index 315b4a0..8d021b0 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,10 @@ This is a newer version of https://github.com/rimian/discourse-donations. * 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. -* Enable the plugin and wait for people to donate money. -## Usage +## Creating Subscription Plans -Enable the plugin and enter your Stripe API keys in the settings. You can also configure amounts and the default currency. - -Visit `/patrons` +When users subscribe to your Discourse application, they are added to a user group. You can create new user groups or use existing ones. Of course, you should be careful what permissions you apply to the user group. ## Testing diff --git a/app/controllers/admin/plans_controller.rb b/app/controllers/admin/plans_controller.rb new file mode 100644 index 0000000..d9132df --- /dev/null +++ b/app/controllers/admin/plans_controller.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module DiscoursePatrons + module Admin + class PlansController < ::Admin::AdminController + include DiscoursePatrons::Stripe + + before_action :set_api_key + + def index + begin + plans = ::Stripe::Plan.list(product_params) + + render_json_dump plans.data + + rescue ::Stripe::InvalidRequestError => e + return render_json_error e.message + end + end + + def create + begin + plan = ::Stripe::Plan.create( + nickname: params[:nickname], + amount: params[:amount], + interval: params[:interval], + product: params[:product], + trial_period_days: params[:trial_period_days], + currency: SiteSetting.discourse_patrons_currency, + active: params[:active], + metadata: { group_name: params[:metadata][:group_name] } + ) + + render_json_dump plan + + rescue ::Stripe::InvalidRequestError => e + return render_json_error e.message + end + end + + def show + begin + plan = ::Stripe::Plan.retrieve(params[:id]) + + render_json_dump plan + + rescue ::Stripe::InvalidRequestError => e + return render_json_error e.message + end + end + + def update + begin + plan = ::Stripe::Plan.update( + params[:id], + nickname: params[:nickname], + trial_period_days: params[:trial_period_days], + active: params[:active], + metadata: { group_name: params[:metadata][:group_name] } + ) + + render_json_dump plan + + rescue ::Stripe::InvalidRequestError => e + return render_json_error e.message + end + end + + def destroy + begin + plan = ::Stripe::Plan.delete(params[:id]) + + render_json_dump plan + + rescue ::Stripe::InvalidRequestError => e + return render_json_error e.message + end + end + + private + + def product_params + { product: params[:product_id] } if params[:product_id] + end + end + end +end diff --git a/app/controllers/admin/products_controller.rb b/app/controllers/admin/products_controller.rb new file mode 100644 index 0000000..4edea66 --- /dev/null +++ b/app/controllers/admin/products_controller.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module DiscoursePatrons + module Admin + class ProductsController < ::Admin::AdminController + include DiscoursePatrons::Stripe + + before_action :set_api_key + + def index + begin + products = ::Stripe::Product.list + + render_json_dump products.data + rescue ::Stripe::InvalidRequestError => e + return render_json_error e.message + end + end + + def create + begin + create_params = product_params.merge!(type: 'service') + + if params[:statement_descriptor].blank? + create_params.except!(:statement_descriptor) + end + + product = ::Stripe::Product.create(create_params) + + render_json_dump product + + rescue ::Stripe::InvalidRequestError => e + return render_json_error e.message + end + end + + def show + begin + product = ::Stripe::Product.retrieve(params[:id]) + + render_json_dump product + + rescue ::Stripe::InvalidRequestError => e + return render_json_error e.message + end + end + + def update + begin + product = ::Stripe::Product.update( + params[:id], + product_params + ) + + render_json_dump product + + rescue ::Stripe::InvalidRequestError => e + return render_json_error e.message + end + end + + def destroy + begin + product = ::Stripe::Product.delete(params[:id]) + + render_json_dump product + + rescue ::Stripe::InvalidRequestError => e + return render_json_error e.message + end + end + + private + + def product_params + params.permit! + + { + name: params[:name], + active: params[:active], + statement_descriptor: params[:statement_descriptor], + metadata: { description: params.dig(:metadata, :description) } + } + end + end + end +end diff --git a/app/controllers/admin/subscriptions_controller.rb b/app/controllers/admin/subscriptions_controller.rb new file mode 100644 index 0000000..3b892e2 --- /dev/null +++ b/app/controllers/admin/subscriptions_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module DiscoursePatrons + module Admin + class SubscriptionsController < ::Admin::AdminController + include DiscoursePatrons::Stripe + + before_action :set_api_key + + def index + begin + subscriptions = ::Stripe::Subscription.list + + render_json_dump subscriptions + rescue ::Stripe::InvalidRequestError => e + return render_json_error e.message + end + end + end + end +end diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index 2c1c46a..1ad25aa 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -3,27 +3,7 @@ module DiscoursePatrons class AdminController < ::Admin::AdminController def index - payments = Payment.all.order(payments_order) - - render_serialized(payments, PaymentSerializer) - end - - private - - def payments_order - if %w(created_at amount).include?(params[:order]) - { params[:order] => ascending } - else - { created_at: :desc } - end - end - - def ascending - if params[:descending] == 'false' - :desc - else - :asc - end + head 200 end end end diff --git a/app/controllers/concerns/stripe.rb b/app/controllers/concerns/stripe.rb new file mode 100644 index 0000000..ce5d638 --- /dev/null +++ b/app/controllers/concerns/stripe.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module DiscoursePatrons + module Stripe + extend ActiveSupport::Concern + + def set_api_key + ::Stripe.api_key = SiteSetting.discourse_patrons_secret_key + end + end +end diff --git a/app/controllers/customers_controller.rb b/app/controllers/customers_controller.rb new file mode 100644 index 0000000..60dfdd9 --- /dev/null +++ b/app/controllers/customers_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module DiscoursePatrons + class CustomersController < ::ApplicationController + include DiscoursePatrons::Stripe + + before_action :set_api_key + + def create + begin + customer = ::Stripe::Customer.create( + email: current_user.email, + source: params[:source] + ) + + DiscoursePatrons::Customer.create_customer( + current_user, + customer + ) + + render_json_dump customer + + rescue ::Stripe::InvalidRequestError => e + return render_json_error e.message + end + end + end +end diff --git a/app/controllers/invoices_controller.rb b/app/controllers/invoices_controller.rb new file mode 100644 index 0000000..bb223b8 --- /dev/null +++ b/app/controllers/invoices_controller.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module DiscoursePatrons + class InvoicesController < ::ApplicationController + include DiscoursePatrons::Stripe + before_action :set_api_key + requires_login + + def index + begin + customer = find_customer + + if viewing_own_invoices && customer.present? + invoices = ::Stripe::Invoice.list(customer: customer.customer_id) + + render_json_dump invoices.data + else + render_json_dump [] + end + rescue ::Stripe::InvalidRequestError => e + return render_json_error e.message + end + end + + private + + def viewing_own_invoices + current_user.id == params[:user_id].to_i + end + + def find_customer + DiscoursePatrons::Customer.find_user(current_user) + end + end +end diff --git a/app/controllers/patrons_controller.rb b/app/controllers/patrons_controller.rb index 10de186..0d38440 100644 --- a/app/controllers/patrons_controller.rb +++ b/app/controllers/patrons_controller.rb @@ -2,6 +2,8 @@ module DiscoursePatrons class PatronsController < ::ApplicationController + include DiscoursePatrons::Stripe + skip_before_action :verify_authenticity_token, only: [:create] before_action :set_api_key @@ -15,18 +17,6 @@ module DiscoursePatrons render json: result end - def show - payment_intent = Stripe::PaymentIntent.retrieve(params[:pid]) - - if current_user && (current_user.admin || payment_intent[:customer] == current_user.id) - result = payment_intent - else - result = { error: 'Not found' } - end - - render json: result - end - def create begin @@ -41,15 +31,6 @@ module DiscoursePatrons metadata: { user_id: user_id } ) - Payment.create( - user_id: response[:metadata][:user_id], - payment_intent_id: response[:id], - receipt_email: response[:receipt_email], - url: response[:charges][:url], - amount: response[:amount], - currency: response[:currency] - ) - rescue ::Stripe::InvalidRequestError => e response = { error: e } rescue ::Stripe::CardError => e @@ -61,10 +42,6 @@ module DiscoursePatrons private - def set_api_key - ::Stripe.api_key = SiteSetting.discourse_patrons_secret_key - end - def param_currency_to_number params[:amount].to_s.sub('.', '').to_i end diff --git a/app/controllers/plans_controller.rb b/app/controllers/plans_controller.rb new file mode 100644 index 0000000..f7a8150 --- /dev/null +++ b/app/controllers/plans_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module DiscoursePatrons + class PlansController < ::ApplicationController + include DiscoursePatrons::Stripe + + before_action :set_api_key + + def index + begin + if params[:product_id].present? + plans = ::Stripe::Plan.list(active: true, product: params[:product_id]) + else + plans = ::Stripe::Plan.list(active: true) + end + + serialized = plans[:data].map do |plan| + plan.to_h.slice(:id, :amount, :currency, :interval) + end.sort_by { |plan| plan[:amount] } + + render_json_dump serialized + + rescue ::Stripe::InvalidRequestError => e + return render_json_error e.message + end + end + end +end diff --git a/app/controllers/products_controller.rb b/app/controllers/products_controller.rb new file mode 100644 index 0000000..a943996 --- /dev/null +++ b/app/controllers/products_controller.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module DiscoursePatrons + class ProductsController < ::ApplicationController + include DiscoursePatrons::Stripe + + before_action :set_api_key + + def index + begin + response = ::Stripe::Product.list(active: true) + + products = response[:data].map do |p| + serialize(p) + end + + render_json_dump products + + rescue ::Stripe::InvalidRequestError => e + return render_json_error e.message + end + end + + def show + begin + product = ::Stripe::Product.retrieve(params[:id]) + + render_json_dump serialize(product) + + rescue ::Stripe::InvalidRequestError => e + return render_json_error e.message + end + end + + private + + def serialize(product) + { + id: product[:id], + name: product[:name], + description: product[:metadata][:description] + } + end + end +end diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb new file mode 100644 index 0000000..b544763 --- /dev/null +++ b/app/controllers/subscriptions_controller.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module DiscoursePatrons + class SubscriptionsController < ::ApplicationController + include DiscoursePatrons::Stripe + before_action :set_api_key + requires_login + + def index + begin + products = ::Stripe::Product.list(active: true) + + subscriptions = products[:data].map do |p| + { + id: p[:id], + description: p.dig(:metadata, :description) + } + end + + render_json_dump subscriptions + + rescue ::Stripe::InvalidRequestError => e + return render_json_error e.message + end + end + + def create + begin + plan = ::Stripe::Plan.retrieve(params[:plan]) + + @subscription = ::Stripe::Subscription.create( + customer: params[:customer], + items: [ { plan: params[:plan] } ] + ) + + group = plan_group(plan) + + if subscription_ok && group + group.add(current_user) + end + + unless DiscoursePatrons::Customer.exists?(user_id: current_user.id) + DiscoursePatrons::Customer.create(user_id: current_user.id, customer_id: params[:customer]) + end + + render_json_dump @subscription + + rescue ::Stripe::InvalidRequestError => e + return render_json_error e.message + end + end + + private + + def plan_group(plan) + Group.find_by_name(plan[:metadata][:group_name]) + end + + def subscription_ok + ['active', 'trialing'].include?(@subscription[:status]) + end + end +end diff --git a/app/controllers/user/subscriptions_controller.rb b/app/controllers/user/subscriptions_controller.rb new file mode 100644 index 0000000..7128f37 --- /dev/null +++ b/app/controllers/user/subscriptions_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module DiscoursePatrons + module User + class SubscriptionsController < ::ApplicationController + include DiscoursePatrons::Stripe + before_action :set_api_key + requires_login + + def index + begin + customers = ::Stripe::Customer.list( + email: current_user.email, + expand: ['data.subscriptions'] + ) + + # TODO: Serialize and remove stuff + subscriptions = customers[:data].map do |customer| + customer[:subscriptions][:data] + end.flatten(1) + + render_json_dump subscriptions + + rescue ::Stripe::InvalidRequestError => e + return render_json_error e.message + end + end + + def destroy + begin + subscription = ::Stripe::Subscription.delete(params[:id]) + + render_json_dump subscription + + rescue ::Stripe::InvalidRequestError => e + return render_json_error e.message + end + end + end + end +end diff --git a/app/models/customer.rb b/app/models/customer.rb new file mode 100644 index 0000000..118cce5 --- /dev/null +++ b/app/models/customer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module DiscoursePatrons + class Customer < ActiveRecord::Base + scope :find_user, ->(user) { find_by_user_id(user.id) } + + class << self + table_name = "discourse_patrons_customers" + + def create_customer(user, customer) + create(customer_id: customer[:id], user_id: user.id) + end + end + end +end diff --git a/app/models/payment.rb b/app/models/payment.rb deleted file mode 100644 index d0e90dd..0000000 --- a/app/models/payment.rb +++ /dev/null @@ -1,4 +0,0 @@ -# frozen_string_literal: true - -class Payment < ActiveRecord::Base -end diff --git a/assets/javascripts/discourse/components/donation-form.js.es6 b/assets/javascripts/discourse/components/donation-form.js.es6 index 82232b8..6b182a3 100644 --- a/assets/javascripts/discourse/components/donation-form.js.es6 +++ b/assets/javascripts/discourse/components/donation-form.js.es6 @@ -10,7 +10,7 @@ export default Ember.Component.extend({ this._super(...arguments); const settings = Discourse.SiteSettings; - const amounts = Discourse.SiteSettings.discourse_patrons_amounts.split("|"); + const amounts = settings.discourse_patrons_amounts.split("|"); this.setProperties({ confirmation: false, diff --git a/assets/javascripts/discourse/components/subscribe-card.js.es6 b/assets/javascripts/discourse/components/subscribe-card.js.es6 new file mode 100644 index 0000000..d773d59 --- /dev/null +++ b/assets/javascripts/discourse/components/subscribe-card.js.es6 @@ -0,0 +1,7 @@ +export default Ember.Component.extend({ + didInsertElement() { + this._super(...arguments); + this.cardElement.mount("#card-element"); + }, + didDestroyElement() {} +}); diff --git a/assets/javascripts/discourse/controllers/admin-plugins-discourse-patrons.js.es6 b/assets/javascripts/discourse/controllers/admin-plugins-discourse-patrons-dashboard.js.es6 similarity index 100% rename from assets/javascripts/discourse/controllers/admin-plugins-discourse-patrons.js.es6 rename to assets/javascripts/discourse/controllers/admin-plugins-discourse-patrons-dashboard.js.es6 diff --git a/assets/javascripts/discourse/controllers/admin-plugins-discourse-patrons-plans-index.js.es6 b/assets/javascripts/discourse/controllers/admin-plugins-discourse-patrons-plans-index.js.es6 new file mode 100644 index 0000000..69ba47a --- /dev/null +++ b/assets/javascripts/discourse/controllers/admin-plugins-discourse-patrons-plans-index.js.es6 @@ -0,0 +1,11 @@ +import DiscourseURL from "discourse/lib/url"; + +export default Ember.Controller.extend({ + actions: { + editPlan(id) { + return DiscourseURL.redirectTo( + `/admin/plugins/discourse-patrons/plans/${id}` + ); + } + } +}); diff --git a/assets/javascripts/discourse/controllers/admin-plugins-discourse-patrons-plans-show.js.es6 b/assets/javascripts/discourse/controllers/admin-plugins-discourse-patrons-plans-show.js.es6 new file mode 100644 index 0000000..39f9ee0 --- /dev/null +++ b/assets/javascripts/discourse/controllers/admin-plugins-discourse-patrons-plans-show.js.es6 @@ -0,0 +1,19 @@ +import { popupAjaxError } from "discourse/lib/ajax-error"; + +export default Ember.Controller.extend({ + actions: { + createPlan() { + if (this.get("model.plan.product_id") === undefined) { + const productID = this.get("model.products.firstObject.id"); + this.set("model.plan.product_id", productID); + } + + this.get("model.plan") + .save() + .then(() => { + this.transitionToRoute("adminPlugins.discourse-patrons.plans"); + }) + .catch(popupAjaxError); + } + } +}); diff --git a/assets/javascripts/discourse/controllers/admin-plugins-discourse-patrons-plans.js.es6 b/assets/javascripts/discourse/controllers/admin-plugins-discourse-patrons-plans.js.es6 new file mode 100644 index 0000000..2d3f960 --- /dev/null +++ b/assets/javascripts/discourse/controllers/admin-plugins-discourse-patrons-plans.js.es6 @@ -0,0 +1 @@ +export default Ember.Controller.extend({}); diff --git a/assets/javascripts/discourse/controllers/admin-plugins-discourse-patrons-products-index.js.es6 b/assets/javascripts/discourse/controllers/admin-plugins-discourse-patrons-products-index.js.es6 new file mode 100644 index 0000000..2d3f960 --- /dev/null +++ b/assets/javascripts/discourse/controllers/admin-plugins-discourse-patrons-products-index.js.es6 @@ -0,0 +1 @@ +export default Ember.Controller.extend({}); diff --git a/assets/javascripts/discourse/controllers/admin-plugins-discourse-patrons-products-show-plans-show.js.es6 b/assets/javascripts/discourse/controllers/admin-plugins-discourse-patrons-products-show-plans-show.js.es6 new file mode 100644 index 0000000..b0223f4 --- /dev/null +++ b/assets/javascripts/discourse/controllers/admin-plugins-discourse-patrons-products-show-plans-show.js.es6 @@ -0,0 +1,51 @@ +import computed from "ember-addons/ember-computed-decorators"; +import DiscourseURL from "discourse/lib/url"; + +export default Ember.Controller.extend({ + @computed("model.plan.isNew") + planFieldDisabled(isNew) { + return !isNew; + }, + + @computed("model.product.id") + productId(id) { + return id; + }, + + redirect(product_id) { + DiscourseURL.redirectTo( + `/admin/plugins/discourse-patrons/products/${product_id}` + ); + }, + + actions: { + cancelPlan(product_id) { + this.redirect(product_id); + }, + + createPlan() { + // TODO: set default group name beforehand + if (this.get("model.plan.metadata.group_name") === undefined) { + this.set("model.plan.metadata", { + group_name: this.get("model.groups.firstObject.name") + }); + } + + this.get("model.plan") + .save() + .then(() => this.redirect(this.productId)) + .catch(data => + bootbox.alert(data.jqXHR.responseJSON.errors.join("\n")) + ); + }, + + updatePlan() { + this.get("model.plan") + .update() + .then(() => this.redirect(this.productId)) + .catch(data => + bootbox.alert(data.jqXHR.responseJSON.errors.join("\n")) + ); + } + } +}); diff --git a/assets/javascripts/discourse/controllers/admin-plugins-discourse-patrons-products-show.js.es6 b/assets/javascripts/discourse/controllers/admin-plugins-discourse-patrons-products-show.js.es6 new file mode 100644 index 0000000..d3050bb --- /dev/null +++ b/assets/javascripts/discourse/controllers/admin-plugins-discourse-patrons-products-show.js.es6 @@ -0,0 +1,30 @@ +import { popupAjaxError } from "discourse/lib/ajax-error"; + +export default Ember.Controller.extend({ + actions: { + cancelProduct() { + this.transitionToRoute("adminPlugins.discourse-patrons.products"); + }, + + createProduct() { + this.get("model.product") + .save() + .then(product => { + this.transitionToRoute( + "adminPlugins.discourse-patrons.products.show", + product.id + ); + }) + .catch(popupAjaxError); + }, + + updateProduct() { + this.get("model.product") + .update() + .then(() => { + this.transitionToRoute("adminPlugins.discourse-patrons.products"); + }) + .catch(popupAjaxError); + } + } +}); diff --git a/assets/javascripts/discourse/controllers/admin-plugins-discourse-patrons-subscriptions.js.es6 b/assets/javascripts/discourse/controllers/admin-plugins-discourse-patrons-subscriptions.js.es6 new file mode 100644 index 0000000..2d3f960 --- /dev/null +++ b/assets/javascripts/discourse/controllers/admin-plugins-discourse-patrons-subscriptions.js.es6 @@ -0,0 +1 @@ +export default Ember.Controller.extend({}); diff --git a/assets/javascripts/discourse/controllers/patrons-index.js.es6 b/assets/javascripts/discourse/controllers/patrons-index.js.es6 index ad83561..70bbf49 100644 --- a/assets/javascripts/discourse/controllers/patrons-index.js.es6 +++ b/assets/javascripts/discourse/controllers/patrons-index.js.es6 @@ -1,4 +1,3 @@ -import DiscourseURL from "discourse/lib/url"; import { ajax } from "discourse/lib/ajax"; export default Ember.Controller.extend({ @@ -12,8 +11,12 @@ export default Ember.Controller.extend({ }); }, - paymentSuccessHandler(paymentIntentId) { - DiscourseURL.redirectTo(`patrons/${paymentIntentId}`); + paymentSuccessHandler(/* paymentIntentId */) { + bootbox.alert(I18n.t("discourse_patrons.transactions.payment.success")); + this.transitionToRoute( + "user.billing", + Discourse.User.current().username.toLowerCase() + ); } } }); diff --git a/assets/javascripts/discourse/controllers/patrons-show.js.es6 b/assets/javascripts/discourse/controllers/patrons-show.js.es6 deleted file mode 100644 index f75f4a7..0000000 --- a/assets/javascripts/discourse/controllers/patrons-show.js.es6 +++ /dev/null @@ -1,9 +0,0 @@ -import DiscourseURL from "discourse/lib/url"; - -export default Ember.Controller.extend({ - actions: { - goBack() { - return DiscourseURL.redirectTo("/patrons"); - } - } -}); diff --git a/assets/javascripts/discourse/controllers/patrons-subscribe-show.js.es6 b/assets/javascripts/discourse/controllers/patrons-subscribe-show.js.es6 new file mode 100644 index 0000000..69b915e --- /dev/null +++ b/assets/javascripts/discourse/controllers/patrons-subscribe-show.js.es6 @@ -0,0 +1,50 @@ +import { ajax } from "discourse/lib/ajax"; + +export default Ember.Controller.extend({ + init() { + this._super(...arguments); + this.set( + "stripe", + Stripe(Discourse.SiteSettings.discourse_patrons_public_key) + ); + const elements = this.get("stripe").elements(); + this.set("cardElement", elements.create("card", { hidePostalCode: true })); + }, + + actions: { + stripePaymentHandler() { + this.stripe.createToken(this.get("cardElement")).then(result => { + if (result.error) { + bootbox.alert(result.error.message); + } else { + const customerData = { + source: result.token.id + }; + + return ajax("/patrons/customers", { + method: "post", + data: customerData + }).then(customer => { + const subscription = this.get("model.subscription"); + + subscription.set("customer", customer.id); + + if (subscription.get("plan") === undefined) { + subscription.set("plan", this.get("model.plans.firstObject.id")); + } + + subscription.save().then(() => { + bootbox.alert( + I18n.t("discourse_patrons.transactions.payment.success") + ); + this.transitionToRoute( + "user.subscriptions", + Discourse.User.current().username.toLowerCase() + ); + }); + }); + } + }); + } + } +}); diff --git a/assets/javascripts/discourse/discourse-patrons-route-map.js.es6 b/assets/javascripts/discourse/discourse-patrons-route-map.js.es6 index 1f0a813..b700afd 100644 --- a/assets/javascripts/discourse/discourse-patrons-route-map.js.es6 +++ b/assets/javascripts/discourse/discourse-patrons-route-map.js.es6 @@ -1,7 +1,20 @@ export default { resource: "admin.adminPlugins", path: "/plugins", + map() { - this.route("discourse-patrons"); + this.route("discourse-patrons", function() { + this.route("dashboard"); + + this.route("products", function() { + this.route("show", { path: "/:product-id" }, function() { + this.route("plans", function() { + this.route("show", { path: "/:plan-id" }); + }); + }); + }); + + this.route("subscriptions"); + }); } }; diff --git a/assets/javascripts/discourse/discourse-patrons-user-route-map.js.es6 b/assets/javascripts/discourse/discourse-patrons-user-route-map.js.es6 new file mode 100644 index 0000000..f94ffbb --- /dev/null +++ b/assets/javascripts/discourse/discourse-patrons-user-route-map.js.es6 @@ -0,0 +1,8 @@ +export default { + resource: "user", + path: "users/:username", + map() { + this.route("billing"); + this.route("subscriptions"); + } +}; diff --git a/assets/javascripts/discourse/helpers/format-curency.js.es6 b/assets/javascripts/discourse/helpers/format-curency.js.es6 index 268bfc4..5ea65d7 100644 --- a/assets/javascripts/discourse/helpers/format-curency.js.es6 +++ b/assets/javascripts/discourse/helpers/format-curency.js.es6 @@ -1,3 +1,4 @@ +// TODO: typo in this helper name: currency not curency. export default Ember.Helper.helper(function(params) { let currencySign; diff --git a/assets/javascripts/discourse/helpers/format-currency.js.es6 b/assets/javascripts/discourse/helpers/format-currency.js.es6 new file mode 100644 index 0000000..afc5978 --- /dev/null +++ b/assets/javascripts/discourse/helpers/format-currency.js.es6 @@ -0,0 +1,16 @@ +export default Ember.Helper.helper(function(params) { + let currencySign; + + switch (Discourse.SiteSettings.discourse_patrons_currency) { + case "EUR": + currencySign = "€"; + break; + case "GBP": + currencySign = "£"; + break; + default: + currencySign = "$"; + } + + return currencySign + params.map(p => p.toUpperCase()).join(" "); +}); diff --git a/assets/javascripts/discourse/helpers/format-unix-date.js.es6 b/assets/javascripts/discourse/helpers/format-unix-date.js.es6 new file mode 100644 index 0000000..630b328 --- /dev/null +++ b/assets/javascripts/discourse/helpers/format-unix-date.js.es6 @@ -0,0 +1,16 @@ +import { registerUnbound } from "discourse-common/lib/helpers"; +import { autoUpdatingRelativeAge } from "discourse/lib/formatter"; + +registerUnbound("format-unix-date", function(timestamp) { + if (timestamp) { + const date = new Date(moment.unix(timestamp).format()); + + return new Handlebars.SafeString( + autoUpdatingRelativeAge(date, { + format: "medium", + title: true, + leaveAgo: true + }) + ); + } +}); diff --git a/assets/javascripts/discourse/helpers/show-extra-nav.js.es6 b/assets/javascripts/discourse/helpers/show-extra-nav.js.es6 new file mode 100644 index 0000000..2c80cb2 --- /dev/null +++ b/assets/javascripts/discourse/helpers/show-extra-nav.js.es6 @@ -0,0 +1,5 @@ +import { registerUnbound } from "discourse-common/lib/helpers"; + +export default registerUnbound("show-extra-nav", function() { + return Discourse.SiteSettings.discourse_patrons_extra_nav_subscribe; +}); diff --git a/assets/javascripts/discourse/helpers/user-viewing-self.js.es6 b/assets/javascripts/discourse/helpers/user-viewing-self.js.es6 new file mode 100644 index 0000000..047ed8a --- /dev/null +++ b/assets/javascripts/discourse/helpers/user-viewing-self.js.es6 @@ -0,0 +1,12 @@ +import { registerUnbound } from "discourse-common/lib/helpers"; + +export default registerUnbound("user-viewing-self", function(model) { + if (Discourse.User.current()) { + return ( + Discourse.User.current().username.toLowerCase() === + model.username.toLowerCase() + ); + } + + return false; +}); diff --git a/assets/javascripts/discourse/models/admin-plan.js.es6 b/assets/javascripts/discourse/models/admin-plan.js.es6 new file mode 100644 index 0000000..f5b0c4d --- /dev/null +++ b/assets/javascripts/discourse/models/admin-plan.js.es6 @@ -0,0 +1,66 @@ +import Plan from "discourse/plugins/discourse-patrons/discourse/models/plan"; +import computed from "ember-addons/ember-computed-decorators"; +import { ajax } from "discourse/lib/ajax"; + +const AdminPlan = Plan.extend({ + isNew: false, + name: "", + interval: "month", + amount: 0, + intervals: ["day", "week", "month", "year"], + metadata: {}, + + @computed("trial_period_days") + parseTrialPeriodDays(trial_period_days) { + if (trial_period_days) { + return parseInt(0 + trial_period_days); + } else { + return 0; + } + }, + + destroy() { + return ajax(`/patrons/admin/plans/${this.id}`, { method: "delete" }); + }, + + save() { + const data = { + nickname: this.nickname, + interval: this.interval, + amount: this.amount, + trial_period_days: this.parseTrialPeriodDays, + product: this.product, + metadata: this.metadata, + active: this.active + }; + + return ajax("/patrons/admin/plans", { method: "post", data }); + }, + + update() { + const data = { + nickname: this.nickname, + trial_period_days: this.parseTrialPeriodDays, + metadata: this.metadata, + active: this.active + }; + + return ajax(`/patrons/admin/plans/${this.id}`, { method: "patch", data }); + } +}); + +AdminPlan.reopenClass({ + findAll(data) { + return ajax("/patrons/admin/plans", { method: "get", data }).then(result => + result.map(plan => AdminPlan.create(plan)) + ); + }, + + find(id) { + return ajax(`/patrons/admin/plans/${id}`, { method: "get" }).then(plan => + AdminPlan.create(plan) + ); + } +}); + +export default AdminPlan; diff --git a/assets/javascripts/discourse/models/admin-product.js.es6 b/assets/javascripts/discourse/models/admin-product.js.es6 new file mode 100644 index 0000000..cd48e2f --- /dev/null +++ b/assets/javascripts/discourse/models/admin-product.js.es6 @@ -0,0 +1,53 @@ +import { ajax } from "discourse/lib/ajax"; + +const AdminProduct = Discourse.Model.extend({ + isNew: false, + metadata: {}, + + destroy() { + return ajax(`/patrons/admin/products/${this.id}`, { method: "delete" }); + }, + + save() { + const data = { + name: this.name, + statement_descriptor: this.statement_descriptor, + metadata: this.metadata, + active: this.active + }; + + return ajax("/patrons/admin/products", { method: "post", data }).then( + product => AdminProduct.create(product) + ); + }, + + update() { + const data = { + name: this.name, + statement_descriptor: this.statement_descriptor, + metadata: this.metadata, + active: this.active + }; + + return ajax(`/patrons/admin/products/${this.id}`, { + method: "patch", + data + }); + } +}); + +AdminProduct.reopenClass({ + findAll() { + return ajax("/patrons/admin/products", { method: "get" }).then(result => + result.map(product => AdminProduct.create(product)) + ); + }, + + find(id) { + return ajax(`/patrons/admin/products/${id}`, { method: "get" }).then( + product => AdminProduct.create(product) + ); + } +}); + +export default AdminProduct; diff --git a/assets/javascripts/discourse/models/admin-subscription.js.es6 b/assets/javascripts/discourse/models/admin-subscription.js.es6 new file mode 100644 index 0000000..9ef13f7 --- /dev/null +++ b/assets/javascripts/discourse/models/admin-subscription.js.es6 @@ -0,0 +1,14 @@ +import { ajax } from "discourse/lib/ajax"; + +const AdminSubscription = Discourse.Model.extend({}); + +AdminSubscription.reopenClass({ + find() { + return ajax("/patrons/admin/subscriptions", { method: "get" }).then( + result => + result.data.map(subscription => AdminSubscription.create(subscription)) + ); + } +}); + +export default AdminSubscription; diff --git a/assets/javascripts/discourse/models/group.js.es6 b/assets/javascripts/discourse/models/group.js.es6 new file mode 100644 index 0000000..b230e16 --- /dev/null +++ b/assets/javascripts/discourse/models/group.js.es6 @@ -0,0 +1,18 @@ +import { ajax } from "discourse/lib/ajax"; + +const Group = Discourse.Model.extend({}); + +Group.reopenClass({ + subscriptionGroup: + Discourse.SiteSettings.discourse_patrons_subscription_group, + + find() { + return ajax(`/groups/${this.subscriptionGroup}`, { method: "get" }).then( + result => { + return Group.create(result.group); + } + ); + } +}); + +export default Group; diff --git a/assets/javascripts/discourse/models/invoice.js.es6 b/assets/javascripts/discourse/models/invoice.js.es6 new file mode 100644 index 0000000..65e017f --- /dev/null +++ b/assets/javascripts/discourse/models/invoice.js.es6 @@ -0,0 +1,13 @@ +import { ajax } from "discourse/lib/ajax"; + +const Invoice = Discourse.Model.extend({}); + +Invoice.reopenClass({ + findAll() { + return ajax("/patrons/invoices", { method: "get" }).then(result => + result.map(invoice => Invoice.create(invoice)) + ); + } +}); + +export default Invoice; diff --git a/assets/javascripts/discourse/models/plan.js.es6 b/assets/javascripts/discourse/models/plan.js.es6 new file mode 100644 index 0000000..dac6f16 --- /dev/null +++ b/assets/javascripts/discourse/models/plan.js.es6 @@ -0,0 +1,30 @@ +import computed from "ember-addons/ember-computed-decorators"; +import { ajax } from "discourse/lib/ajax"; + +const Plan = Discourse.Model.extend({ + amountDollars: Ember.computed("amount", { + get() { + return parseFloat(this.get("amount") / 100).toFixed(2); + }, + set(key, value) { + const decimal = parseFloat(value) * 100; + this.set("amount", decimal); + return value; + } + }), + + @computed("amountDollars", "currency", "interval") + subscriptionRate(amountDollars, currency, interval) { + return `$${amountDollars} ${currency.toUpperCase()} / ${interval}`; + } +}); + +Plan.reopenClass({ + findAll(data) { + return ajax("/patrons/plans", { method: "get", data }).then(result => + result.map(plan => Plan.create(plan)) + ); + } +}); + +export default Plan; diff --git a/assets/javascripts/discourse/models/product.js.es6 b/assets/javascripts/discourse/models/product.js.es6 new file mode 100644 index 0000000..6792e43 --- /dev/null +++ b/assets/javascripts/discourse/models/product.js.es6 @@ -0,0 +1,19 @@ +import { ajax } from "discourse/lib/ajax"; + +const Product = Discourse.Model.extend({}); + +Product.reopenClass({ + findAll() { + return ajax("/patrons/products", { method: "get" }).then(result => + result.map(product => Product.create(product)) + ); + }, + + find(id) { + return ajax(`/patrons/products/${id}`, { method: "get" }).then(product => + Product.create(product) + ); + } +}); + +export default Product; diff --git a/assets/javascripts/discourse/models/subscription.js.es6 b/assets/javascripts/discourse/models/subscription.js.es6 new file mode 100644 index 0000000..41faddb --- /dev/null +++ b/assets/javascripts/discourse/models/subscription.js.es6 @@ -0,0 +1,28 @@ +import computed from "ember-addons/ember-computed-decorators"; +import { ajax } from "discourse/lib/ajax"; + +const Subscription = Discourse.Model.extend({ + @computed("status") + canceled(status) { + return status === "canceled"; + }, + + save() { + const data = { + customer: this.customer, + plan: this.plan + }; + + return ajax("/patrons/subscriptions", { method: "post", data }); + } +}); + +Subscription.reopenClass({ + findAll() { + return ajax("/patrons/subscriptions", { method: "get" }).then(result => + result.map(subscription => Subscription.create(subscription)) + ); + } +}); + +export default Subscription; diff --git a/assets/javascripts/discourse/models/user-subscription.js.es6 b/assets/javascripts/discourse/models/user-subscription.js.es6 new file mode 100644 index 0000000..20b7102 --- /dev/null +++ b/assets/javascripts/discourse/models/user-subscription.js.es6 @@ -0,0 +1,29 @@ +import computed from "ember-addons/ember-computed-decorators"; +import { ajax } from "discourse/lib/ajax"; +import Plan from "discourse/plugins/discourse-patrons/discourse/models/plan"; + +const UserSubscription = Discourse.Model.extend({ + @computed("status") + canceled(status) { + return status === "canceled"; + }, + + destroy() { + return ajax(`/patrons/user/subscriptions/${this.id}`, { + method: "delete" + }).then(result => UserSubscription.create(result)); + } +}); + +UserSubscription.reopenClass({ + findAll() { + return ajax("/patrons/user/subscriptions", { method: "get" }).then(result => + result.map(subscription => { + subscription.plan = Plan.create(subscription.plan); + return UserSubscription.create(subscription); + }) + ); + } +}); + +export default UserSubscription; diff --git a/assets/javascripts/discourse/patrons-route-map.js.es6 b/assets/javascripts/discourse/patrons-route-map.js.es6 index 2c0777c..566c954 100644 --- a/assets/javascripts/discourse/patrons-route-map.js.es6 +++ b/assets/javascripts/discourse/patrons-route-map.js.es6 @@ -1,5 +1,7 @@ export default function() { this.route("patrons", function() { - this.route("show", { path: ":pid" }); + this.route("subscribe", function() { + this.route("show", { path: "/:subscription-id" }); + }); }); } diff --git a/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons-dashboard.js.es6 b/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons-dashboard.js.es6 new file mode 100644 index 0000000..275e792 --- /dev/null +++ b/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons-dashboard.js.es6 @@ -0,0 +1,22 @@ +import { ajax } from "discourse/lib/ajax"; + +export default Discourse.Route.extend({ + queryParams: { + order: { + refreshModel: true + }, + descending: { + refreshModel: true + } + }, + + model(params) { + return ajax("/patrons/admin", { + method: "get", + data: { + order: params.order, + descending: params.descending + } + }).then(results => results); + } +}); diff --git a/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons-plans-index.js.es6 b/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons-plans-index.js.es6 new file mode 100644 index 0000000..e9f7c31 --- /dev/null +++ b/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons-plans-index.js.es6 @@ -0,0 +1,7 @@ +import AdminPlan from "discourse/plugins/discourse-patrons/discourse/models/admin-plan"; + +export default Discourse.Route.extend({ + model() { + return AdminPlan.findAll(); + } +}); diff --git a/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons-plans.js.es6 b/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons-plans.js.es6 new file mode 100644 index 0000000..55af882 --- /dev/null +++ b/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons-plans.js.es6 @@ -0,0 +1 @@ +export default Discourse.Route.extend({}); diff --git a/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons-products-index.js.es6 b/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons-products-index.js.es6 new file mode 100644 index 0000000..5c80528 --- /dev/null +++ b/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons-products-index.js.es6 @@ -0,0 +1,31 @@ +import AdminProduct from "discourse/plugins/discourse-patrons/discourse/models/admin-product"; + +export default Discourse.Route.extend({ + model() { + return AdminProduct.findAll(); + }, + + actions: { + destroyProduct(product) { + bootbox.confirm( + I18n.t("discourse_patrons.admin.products.operations.destroy.confirm"), + I18n.t("no_value"), + I18n.t("yes_value"), + confirmed => { + if (confirmed) { + product + .destroy() + .then(() => { + this.controllerFor("adminPluginsDiscoursePatronsProductsIndex") + .get("model") + .removeObject(product); + }) + .catch(data => + bootbox.alert(data.jqXHR.responseJSON.errors.join("\n")) + ); + } + } + ); + } + } +}); diff --git a/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons-products-show-plans-show.js.es6 b/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons-products-show-plans-show.js.es6 new file mode 100644 index 0000000..a4e17c9 --- /dev/null +++ b/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons-products-show-plans-show.js.es6 @@ -0,0 +1,34 @@ +import AdminPlan from "discourse/plugins/discourse-patrons/discourse/models/admin-plan"; +import Group from "discourse/models/group"; + +export default Discourse.Route.extend({ + model(params) { + const id = params["plan-id"]; + const product = this.modelFor( + "adminPlugins.discourse-patrons.products.show" + ).product; + let plan; + + if (id === "new") { + plan = AdminPlan.create({ + active: true, + isNew: true, + product: product.get("id") + }); + } else { + plan = AdminPlan.find(id); + } + + const groups = Group.findAll({ ignore_automatic: true }); + + return Ember.RSVP.hash({ plan, product, groups }); + }, + + renderTemplate() { + this.render("adminPlugins.discourse-patrons.products.show.plans.show", { + into: "adminPlugins.discourse-patrons.products", + outlet: "main", + controller: "adminPlugins.discourse-patrons.products.show.plans.show" + }); + } +}); diff --git a/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons-products-show.js.es6 b/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons-products-show.js.es6 new file mode 100644 index 0000000..0fab55d --- /dev/null +++ b/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons-products-show.js.es6 @@ -0,0 +1,43 @@ +import AdminProduct from "discourse/plugins/discourse-patrons/discourse/models/admin-product"; +import AdminPlan from "discourse/plugins/discourse-patrons/discourse/models/admin-plan"; + +export default Discourse.Route.extend({ + model(params) { + const product_id = params["product-id"]; + let product; + let plans = []; + + if (product_id === "new") { + product = AdminProduct.create({ active: true, isNew: true }); + } else { + product = AdminProduct.find(product_id); + plans = AdminPlan.findAll({ product_id }); + } + + return Ember.RSVP.hash({ plans, product }); + }, + + actions: { + destroyPlan(plan) { + bootbox.confirm( + I18n.t("discourse_patrons.admin.plans.operations.destroy.confirm"), + I18n.t("no_value"), + I18n.t("yes_value"), + confirmed => { + if (confirmed) { + plan + .destroy() + .then(() => { + this.controllerFor("adminPluginsDiscoursePatronsProductsShow") + .get("model.plans") + .removeObject(plan); + }) + .catch(data => + bootbox.alert(data.jqXHR.responseJSON.errors.join("\n")) + ); + } + } + ); + } + } +}); diff --git a/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons-products.js.es6 b/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons-products.js.es6 new file mode 100644 index 0000000..55af882 --- /dev/null +++ b/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons-products.js.es6 @@ -0,0 +1 @@ +export default Discourse.Route.extend({}); diff --git a/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons-subscriptions.js.es6 b/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons-subscriptions.js.es6 new file mode 100644 index 0000000..ee7ea64 --- /dev/null +++ b/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons-subscriptions.js.es6 @@ -0,0 +1,7 @@ +import AdminSubscription from "discourse/plugins/discourse-patrons/discourse/models/admin-subscription"; + +export default Discourse.Route.extend({ + model() { + return AdminSubscription.find(); + } +}); diff --git a/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons.js.es6 b/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons.js.es6 index 275e792..55af882 100644 --- a/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons.js.es6 +++ b/assets/javascripts/discourse/routes/admin-plugins-discourse-patrons.js.es6 @@ -1,22 +1 @@ -import { ajax } from "discourse/lib/ajax"; - -export default Discourse.Route.extend({ - queryParams: { - order: { - refreshModel: true - }, - descending: { - refreshModel: true - } - }, - - model(params) { - return ajax("/patrons/admin", { - method: "get", - data: { - order: params.order, - descending: params.descending - } - }).then(results => results); - } -}); +export default Discourse.Route.extend({}); diff --git a/assets/javascripts/discourse/routes/patrons-show.js.es6 b/assets/javascripts/discourse/routes/patrons-show.js.es6 deleted file mode 100644 index 36794bb..0000000 --- a/assets/javascripts/discourse/routes/patrons-show.js.es6 +++ /dev/null @@ -1,7 +0,0 @@ -import { ajax } from "discourse/lib/ajax"; - -export default Discourse.Route.extend({ - model(params) { - return ajax(`/patrons/${params.pid}`, { method: "get" }); - } -}); diff --git a/assets/javascripts/discourse/routes/patrons-subscribe-show.js.es6 b/assets/javascripts/discourse/routes/patrons-subscribe-show.js.es6 new file mode 100644 index 0000000..93981f6 --- /dev/null +++ b/assets/javascripts/discourse/routes/patrons-subscribe-show.js.es6 @@ -0,0 +1,16 @@ +import Product from "discourse/plugins/discourse-patrons/discourse/models/product"; +import Plan from "discourse/plugins/discourse-patrons/discourse/models/plan"; +import Subscription from "discourse/plugins/discourse-patrons/discourse/models/subscription"; + +export default Discourse.Route.extend({ + model(params) { + const product_id = params["subscription-id"]; + const product = Product.find(product_id); + const subscription = Subscription.create(); + const plans = Plan.findAll({ product_id: product_id }).then(results => + results.map(p => ({ id: p.id, name: p.subscriptionRate })) + ); + + return Ember.RSVP.hash({ plans, product, subscription }); + } +}); diff --git a/assets/javascripts/discourse/routes/patrons-subscribe.js.es6 b/assets/javascripts/discourse/routes/patrons-subscribe.js.es6 new file mode 100644 index 0000000..7f525cb --- /dev/null +++ b/assets/javascripts/discourse/routes/patrons-subscribe.js.es6 @@ -0,0 +1,7 @@ +import Product from "discourse/plugins/discourse-patrons/discourse/models/product"; + +export default Discourse.Route.extend({ + model() { + return Product.findAll(); + } +}); diff --git a/assets/javascripts/discourse/routes/user-billing.js.es6 b/assets/javascripts/discourse/routes/user-billing.js.es6 new file mode 100644 index 0000000..ad03653 --- /dev/null +++ b/assets/javascripts/discourse/routes/user-billing.js.es6 @@ -0,0 +1,15 @@ +import Invoice from "discourse/plugins/discourse-patrons/discourse/models/invoice"; + +export default Discourse.Route.extend({ + model() { + return Invoice.findAll(); + }, + + setupController(controller, model) { + if (this.currentUser.id !== this.modelFor("user").id) { + this.replaceWith("userActivity"); + } else { + controller.setProperties({ model }); + } + } +}); diff --git a/assets/javascripts/discourse/routes/user-subscriptions.js.es6 b/assets/javascripts/discourse/routes/user-subscriptions.js.es6 new file mode 100644 index 0000000..a02059b --- /dev/null +++ b/assets/javascripts/discourse/routes/user-subscriptions.js.es6 @@ -0,0 +1,37 @@ +import UserSubscription from "discourse/plugins/discourse-patrons/discourse/models/user-subscription"; + +export default Discourse.Route.extend({ + model() { + return UserSubscription.findAll(); + }, + + setupController(controller, model) { + if (this.currentUser.id !== this.modelFor("user").id) { + this.replaceWith("userActivity"); + } else { + controller.setProperties({ model }); + } + }, + + actions: { + cancelSubscription(subscription) { + bootbox.confirm( + I18n.t( + "discourse_patrons.user.subscriptions.operations.destroy.confirm" + ), + I18n.t("no_value"), + I18n.t("yes_value"), + confirmed => { + if (confirmed) { + subscription + .destroy() + .then(result => subscription.set("status", result.status)) + .catch(data => + bootbox.alert(data.jqXHR.responseJSON.errors.join("\n")) + ); + } + } + ); + } + } +}); diff --git a/assets/javascripts/discourse/templates/admin/plugins-discourse-patrons-dashboard.hbs b/assets/javascripts/discourse/templates/admin/plugins-discourse-patrons-dashboard.hbs new file mode 100644 index 0000000..1aa2d58 --- /dev/null +++ b/assets/javascripts/discourse/templates/admin/plugins-discourse-patrons-dashboard.hbs @@ -0,0 +1,35 @@ + +

{{i18n 'discourse_patrons.admin.dashboard.title'}}

+ +{{#load-more selector=".discourse-patrons-table tr" action=(action "loadMore")}} + {{#if model}} + + + + + + + + + + + {{#each model as |payment|}} + + + + + + + + {{/each}} +
{{i18n 'discourse_patrons.admin.dashboard.table.head.user'}}{{i18n 'discourse_patrons.admin.dashboard.table.head.payment_intent'}}{{i18n 'discourse_patrons.admin.dashboard.table.head.receipt_email'}}{{i18n 'created'}}{{i18n 'discourse_patrons.admin.dashboard.table.head.amount'}}
+ {{#link-to "adminUser.index" payment.user_id payment.username}} + {{payment.username}} + {{/link-to}} + + {{#link-to "patrons.show" payment.payment_intent_id}} + {{{payment.payment_intent_id}}} + {{/link-to}} + {{payment.receipt_email}}{{{format-duration payment.created_at_age}}}{{payment.amount_currency}}
+ {{/if}} +{{/load-more}} diff --git a/assets/javascripts/discourse/templates/admin/plugins-discourse-patrons-plans-index.hbs b/assets/javascripts/discourse/templates/admin/plugins-discourse-patrons-plans-index.hbs new file mode 100644 index 0000000..1b37a2e --- /dev/null +++ b/assets/javascripts/discourse/templates/admin/plugins-discourse-patrons-plans-index.hbs @@ -0,0 +1,29 @@ + + + + + + + + + + {{#each model as |plan|}} + + + + + + + + {{/each}} +
{{i18n 'discourse_patrons.admin.plans.plan.plan_id'}}{{i18n 'discourse_patrons.admin.plans.plan.nickname.title'}}{{i18n 'discourse_patrons.admin.plans.plan.interval'}}{{i18n 'discourse_patrons.admin.plans.plan.amount'}}
{{plan.id}}{{plan.nickname}}{{plan.interval}}{{plan.amount}} + {{d-button + action=(action "editPlan" plan.id) + icon="far-edit" + class="btn no-text btn-icon"}} + {{d-button + action=(route-action "destroyPlan") + actionParam=plan + icon="trash-alt" + class="btn-danger btn no-text btn-icon"}} +
diff --git a/assets/javascripts/discourse/templates/admin/plugins-discourse-patrons-products-index.hbs b/assets/javascripts/discourse/templates/admin/plugins-discourse-patrons-products-index.hbs new file mode 100644 index 0000000..3de8aa8 --- /dev/null +++ b/assets/javascripts/discourse/templates/admin/plugins-discourse-patrons-products-index.hbs @@ -0,0 +1,41 @@ + +

+ {{#link-to 'adminPlugins.discourse-patrons.products.show' 'new' class="btn btn-primary"}} + {{d-icon "plus"}} + {{i18n 'discourse_patrons.admin.products.operations.new'}} + {{/link-to}} +

+ +{{#if model}} + + + + + + + + + {{#each model as |product|}} + + + + + + + + {{/each}} +
{{i18n 'discourse_patrons.admin.products.product.name'}}{{i18n 'discourse_patrons.admin.products.product.created_at'}}{{i18n 'discourse_patrons.admin.products.product.updated_at'}}{{i18n 'discourse_patrons.admin.products.product.active'}}
{{product.name}}{{format-unix-date product.created}}{{format-unix-date product.updated}}{{product.active}} + {{#link-to "adminPlugins.discourse-patrons.products.show" product.id class="btn no-text btn-icon"}} + {{d-icon "far-edit"}} + {{/link-to}} + {{d-button + action=(route-action "destroyProduct") + actionParam=product + icon="trash-alt" + class="btn-danger btn no-text btn-icon"}} +
+{{else}} +

+ {{i18n 'discourse_patrons.admin.products.product_help'}} +

+{{/if}} diff --git a/assets/javascripts/discourse/templates/admin/plugins-discourse-patrons-products-show-plans-show.hbs b/assets/javascripts/discourse/templates/admin/plugins-discourse-patrons-products-show-plans-show.hbs new file mode 100644 index 0000000..c3f583c --- /dev/null +++ b/assets/javascripts/discourse/templates/admin/plugins-discourse-patrons-products-show-plans-show.hbs @@ -0,0 +1,68 @@ + +

{{i18n 'discourse_patrons.admin.plans.title'}}

+ +
+

+ + {{input type="text" name="product_name" value=model.product.name disabled=true}} +

+

+ + {{input type="text" name="name" value=model.plan.nickname}} +

+ {{i18n 'discourse_patrons.admin.plans.plan.nickname_help'}} +
+

+

+ + {{combo-box valueAttribute="name" content=model.groups value=model.plan.metadata.group_name}} +

+ {{i18n 'discourse_patrons.admin.plans.plan.group_help'}} +
+

+

+ + {{input type="text" name="name" value=model.plan.amountDollars disabled=planFieldDisabled}} +

+

+ + {{input type="text" name="trial" value=model.plan.trial_period_days}} +

+ {{i18n 'discourse_patrons.admin.plans.plan.trial_help'}} +
+

+

+ + {{combo-box valueAttribute="value" content=model.plan.intervals value=model.plan.interval}} +

+

+ + {{input type="checkbox" name="active" checked=model.plan.active}} +

+
+ +
+
+ +

+ {{i18n 'discourse_patrons.admin.plans.operations.create_help'}} +

+ +
+ {{d-button label="cancel" action=(action "cancelPlan" model.plan.product) icon="times"}} + + {{#if model.plan.isNew}} + {{d-button label="discourse_patrons.admin.plans.operations.create" action="createPlan" icon="plus" class="btn btn-primary"}} + {{else}} + {{d-button label="discourse_patrons.admin.plans.operations.update" action="updatePlan" icon="check" class="btn btn-primary"}} + {{/if}} +
+ +
diff --git a/assets/javascripts/discourse/templates/admin/plugins-discourse-patrons-products-show.hbs b/assets/javascripts/discourse/templates/admin/plugins-discourse-patrons-products-show.hbs new file mode 100644 index 0000000..da68d31 --- /dev/null +++ b/assets/javascripts/discourse/templates/admin/plugins-discourse-patrons-products-show.hbs @@ -0,0 +1,97 @@ +

{{i18n 'discourse_patrons.admin.products.title'}}

+ +
+

+ + {{input type="text" name="name" value=model.product.name}} +

+

+ + {{textarea name="description" value=model.product.metadata.description class="discourse-patrons-admin-textarea"}} +

+ {{i18n 'discourse_patrons.admin.products.product.description_help'}} +
+

+

+ + {{input type="text" name="statement_descriptor" value=model.product.statement_descriptor}} +

+ {{i18n 'discourse_patrons.admin.products.product.statement_descriptor_help'}} +
+

+

+ + {{input type="checkbox" name="active" checked=model.product.active}} +

+
+ +{{#unless model.product.isNew}} +

{{i18n 'discourse_patrons.admin.plans.title'}}

+ +

+ + + + + + + + + + + {{#each model.plans as |plan|}} + + + + + + + + + + {{/each}} + + + + +
{{i18n 'discourse_patrons.admin.plans.plan.nickname'}}{{i18n 'discourse_patrons.admin.plans.plan.interval'}}{{i18n 'discourse_patrons.admin.plans.plan.created_at'}}{{i18n 'discourse_patrons.admin.plans.plan.group'}}{{i18n 'discourse_patrons.admin.plans.plan.active'}}{{i18n 'discourse_patrons.admin.plans.plan.amount'}} + {{#link-to "adminPlugins.discourse-patrons.products.show.plans.show" model.product.id "new" class="btn"}} + {{i18n 'discourse_patrons.admin.plans.operations.add'}} + {{/link-to}} +
{{plan.nickname}}{{plan.interval}}{{format-unix-date plan.created}}{{plan.metadata.group_name}}{{plan.active}}{{format-currency plan.currency plan.amountDollars}} + {{#link-to "adminPlugins.discourse-patrons.products.show.plans.show" model.product.id plan.id class="btn no-text btn-icon"}} + {{d-icon "far-edit"}} + {{/link-to}} + {{d-button + action=(route-action "destroyPlan") + actionParam=plan + icon="trash-alt" + class="btn-danger btn no-text btn-icon"}} +
+ {{#unless model.plans}} +
+ {{i18n 'discourse_patrons.admin.products.product.plan_help'}} + {{/unless}} +
+

+{{/unless}} + +
+
+ +
+ {{d-button label="cancel" action=(action "cancelProduct") icon="times"}} + {{#if model.product.isNew}} + {{d-button label="discourse_patrons.admin.products.operations.create" action="createProduct" icon="plus" class="btn btn-primary"}} + {{else}} + {{d-button label="discourse_patrons.admin.products.operations.update" action="updateProduct" icon="check" class="btn btn-primary"}} + {{/if}} +
+ +
+ +{{outlet}} diff --git a/assets/javascripts/discourse/templates/admin/plugins-discourse-patrons-products.hbs b/assets/javascripts/discourse/templates/admin/plugins-discourse-patrons-products.hbs new file mode 100644 index 0000000..b1b1f40 --- /dev/null +++ b/assets/javascripts/discourse/templates/admin/plugins-discourse-patrons-products.hbs @@ -0,0 +1,2 @@ + +{{outlet}} diff --git a/assets/javascripts/discourse/templates/admin/plugins-discourse-patrons-subscriptions.hbs b/assets/javascripts/discourse/templates/admin/plugins-discourse-patrons-subscriptions.hbs new file mode 100644 index 0000000..498ee4e --- /dev/null +++ b/assets/javascripts/discourse/templates/admin/plugins-discourse-patrons-subscriptions.hbs @@ -0,0 +1,19 @@ + + + + + + + + + + + {{#each model as |subscription|}} + + + + + + + {{/each}} +
{{i18n 'discourse_patrons.admin.subscriptions.subscription.customer'}}{{i18n 'discourse_patrons.admin.subscriptions.subscription.plan'}}{{i18n 'discourse_patrons.admin.subscriptions.subscription.status'}}{{i18n 'discourse_patrons.admin.subscriptions.subscription.created_at'}}
{{subscription.customer}}{{subscription.plan.id}}{{subscription.status}}{{format-unix-date subscription.created}}
diff --git a/assets/javascripts/discourse/templates/admin/plugins-discourse-patrons.hbs b/assets/javascripts/discourse/templates/admin/plugins-discourse-patrons.hbs index 37fdd2f..3c6a30c 100644 --- a/assets/javascripts/discourse/templates/admin/plugins-discourse-patrons.hbs +++ b/assets/javascripts/discourse/templates/admin/plugins-discourse-patrons.hbs @@ -1,35 +1,14 @@

{{i18n 'discourse_patrons.title' site_name=siteSettings.title}}

-{{#load-more selector=".discourse-patrons-admin tr" action=(action "loadMore")}} - {{#if model}} - - - - - - - - - - - {{#each model as |payment|}} - - - - - - - - {{/each}} -
{{i18n 'discourse_patrons.admin.payment_history.table.head.user'}}{{i18n 'discourse_patrons.admin.payment_history.table.head.payment_intent'}}{{i18n 'discourse_patrons.admin.payment_history.table.head.receipt_email'}}{{i18n 'created'}}{{i18n 'discourse_patrons.admin.payment_history.table.head.amount'}}
- {{#link-to "adminUser.index" payment.user_id payment.username}} - {{payment.username}} - {{/link-to}} - - {{#link-to "patrons.show" payment.payment_intent_id}} - {{{payment.payment_intent_id}}} - {{/link-to}} - {{payment.receipt_email}}{{{format-duration payment.created_at_age}}}{{payment.amount_currency}}
- {{/if}} -{{/load-more}} + + +
+ +
+ {{outlet}} +
diff --git a/assets/javascripts/discourse/templates/components/donation-form.hbs b/assets/javascripts/discourse/templates/components/donation-form.hbs index 3901d68..313abac 100644 --- a/assets/javascripts/discourse/templates/components/donation-form.hbs +++ b/assets/javascripts/discourse/templates/components/donation-form.hbs @@ -1,6 +1,6 @@ {{#if confirmation}} - {{#d-modal closeModal=(action "closeModal") modalStyle="inline-modal" title=(i18n "discourse_patrons.payment.payment_confirmation")}} + {{#d-modal closeModal=(action "closeModal") modalStyle="inline-modal" title=(i18n "discourse_patrons.one_time.payment.payment_confirmation")}} {{#d-modal-body}}
@@ -51,7 +51,7 @@ {{else}}
-

{{i18n 'discourse_patrons.payment.your_information'}}

+

{{i18n 'discourse_patrons.one_time.payment.your_information'}}

@@ -60,7 +60,7 @@
{{input value=billing.name}} -
{{i18n 'discourse_patrons.payment.optional'}}
+
{{i18n 'discourse_patrons.one_time.payment.optional'}}
@@ -69,7 +69,7 @@
{{input type="email" value=billing.email}} -
{{i18n 'discourse_patrons.payment.receipt_info'}}
+
{{i18n 'discourse_patrons.one_time.payment.receipt_info'}}
@@ -78,19 +78,19 @@
{{input value=billing.phone}} -
{{i18n 'discourse_patrons.payment.optional'}}
+
{{i18n 'discourse_patrons.one_time.payment.optional'}}
-

{{i18n 'discourse_patrons.payment.payment_information'}}

+

{{i18n 'discourse_patrons.one_time.payment.payment_information'}}

- {{i18n 'discourse_patrons.amount'}} + {{i18n 'discourse_patrons.one_time.amount'}} {{siteSettings.discourse_patrons_currency}}
diff --git a/assets/javascripts/discourse/templates/components/subscribe-card.hbs b/assets/javascripts/discourse/templates/components/subscribe-card.hbs new file mode 100644 index 0000000..f2afe6e --- /dev/null +++ b/assets/javascripts/discourse/templates/components/subscribe-card.hbs @@ -0,0 +1,2 @@ + +
diff --git a/assets/javascripts/discourse/templates/connectors/extra-nav-item/subscribe.hbs b/assets/javascripts/discourse/templates/connectors/extra-nav-item/subscribe.hbs new file mode 100644 index 0000000..b3a03e5 --- /dev/null +++ b/assets/javascripts/discourse/templates/connectors/extra-nav-item/subscribe.hbs @@ -0,0 +1,5 @@ +{{#if (show-extra-nav)}} + {{#link-to 'patrons.subscribe' class='discourse-patrons-subscribe'}} + {{i18n 'discourse_patrons.navigation.subscribe'}} + {{/link-to}} +{{/if}} diff --git a/assets/javascripts/discourse/templates/connectors/user-main-nav/subscriptions.hbs b/assets/javascripts/discourse/templates/connectors/user-main-nav/subscriptions.hbs new file mode 100644 index 0000000..66370fd --- /dev/null +++ b/assets/javascripts/discourse/templates/connectors/user-main-nav/subscriptions.hbs @@ -0,0 +1,3 @@ +{{#if (user-viewing-self model)}} + {{#link-to 'user.subscriptions'}}{{d-icon "credit-card"}}{{I18n 'discourse_patrons.navigation.subscriptions'}}{{/link-to}} +{{/if}} diff --git a/assets/javascripts/discourse/templates/patrons/index.hbs b/assets/javascripts/discourse/templates/patrons/index.hbs index f131386..969d5b0 100644 --- a/assets/javascripts/discourse/templates/patrons/index.hbs +++ b/assets/javascripts/discourse/templates/patrons/index.hbs @@ -1,5 +1,5 @@ -

{{i18n 'discourse_patrons.heading.payment' site_name=siteSettings.title}}

+

{{i18n 'discourse_patrons.one_time.heading.payment' site_name=siteSettings.title}}

{{cook-text siteSettings.discourse_patrons_payment_page}} diff --git a/assets/javascripts/discourse/templates/patrons/show.hbs b/assets/javascripts/discourse/templates/patrons/show.hbs deleted file mode 100644 index 96632be..0000000 --- a/assets/javascripts/discourse/templates/patrons/show.hbs +++ /dev/null @@ -1,19 +0,0 @@ - -{{#unless model.error}} -

{{i18n 'discourse_patrons.heading.success' site_name=siteSettings.title}}

- -

- {{cook-text siteSettings.discourse_patrons_success_page}} -

- - - - - - - - - - -
{{i18n 'discourse_patrons.payment_intent_id'}}{{model.id}}
{{i18n 'discourse_patrons.amount'}}{{model.amount}}
-{{/unless}} diff --git a/assets/javascripts/discourse/templates/patrons/subscribe.hbs b/assets/javascripts/discourse/templates/patrons/subscribe.hbs new file mode 100644 index 0000000..42e43d5 --- /dev/null +++ b/assets/javascripts/discourse/templates/patrons/subscribe.hbs @@ -0,0 +1,12 @@ + +
+
+

+ {{i18n 'discourse_patrons.subscribe.title'}} +

+
+ +
+ + {{outlet}} +
diff --git a/assets/javascripts/discourse/templates/patrons/subscribe/index.hbs b/assets/javascripts/discourse/templates/patrons/subscribe/index.hbs new file mode 100644 index 0000000..c836cc6 --- /dev/null +++ b/assets/javascripts/discourse/templates/patrons/subscribe/index.hbs @@ -0,0 +1,17 @@ + + +{{#each model as |product|}} +
+

{{product.name}}

+ +

+ {{product.description}} +

+ +
+ {{#link-to "patrons.subscribe.show" product.id class="btn btn-primary"}} + {{i18n 'discourse_patrons.subscribe.title'}} + {{/link-to}} +
+
+{{/each}} diff --git a/assets/javascripts/discourse/templates/patrons/subscribe/show.hbs b/assets/javascripts/discourse/templates/patrons/subscribe/show.hbs new file mode 100644 index 0000000..fbd4e47 --- /dev/null +++ b/assets/javascripts/discourse/templates/patrons/subscribe/show.hbs @@ -0,0 +1,32 @@ + +
+
+

+ {{model.product.name}} +

+

+ {{model.product.description}} +

+
+
+ {{combo-box valueAttribute="id" content=model.plans value=model.product.plan}} + + {{#d-button + action="stripePaymentHandler" + class="btn btn-primary btn-payment btn-discourse-patrons"}} + {{i18n 'discourse_patrons.subscribe.buttons.subscribe'}} + {{/d-button}} + +
+ +

{{i18n 'discourse_patrons.subscribe.card.title'}}

+ {{subscribe-card cardElement=cardElement}} + + {{!--
+

{{i18n 'discourse_patrons.subscribe.customer.title'}}

+
+ {{i18n 'discourse_patrons.subscribe.customer.empty'}} +
+
--}} +
+
diff --git a/assets/javascripts/discourse/templates/user/billing.hbs b/assets/javascripts/discourse/templates/user/billing.hbs new file mode 100644 index 0000000..575ebfe --- /dev/null +++ b/assets/javascripts/discourse/templates/user/billing.hbs @@ -0,0 +1,27 @@ + +

{{i18n 'discourse_patrons.user.billing.title'}}

+ +{{#if model}} + + + + + + + + {{#each model as |invoice|}} + + + + + + + {{/each}} +
{{i18n 'discourse_patrons.user.billing.invoices.amount'}}{{i18n 'discourse_patrons.user.billing.invoices.number'}}{{i18n 'discourse_patrons.user.billing.invoices.created_at'}}
{{invoice.amount_paid}}{{invoice.number}}{{format-unix-date invoice.created}} + + {{d-icon "download"}} + +
+{{else}} +

{{i18n 'discourse_patrons.user.billing_help'}}

+{{/if}} diff --git a/assets/javascripts/discourse/templates/user/invoices.hbs b/assets/javascripts/discourse/templates/user/invoices.hbs new file mode 100644 index 0000000..575ebfe --- /dev/null +++ b/assets/javascripts/discourse/templates/user/invoices.hbs @@ -0,0 +1,27 @@ + +

{{i18n 'discourse_patrons.user.billing.title'}}

+ +{{#if model}} + + + + + + + + {{#each model as |invoice|}} + + + + + + + {{/each}} +
{{i18n 'discourse_patrons.user.billing.invoices.amount'}}{{i18n 'discourse_patrons.user.billing.invoices.number'}}{{i18n 'discourse_patrons.user.billing.invoices.created_at'}}
{{invoice.amount_paid}}{{invoice.number}}{{format-unix-date invoice.created}} + + {{d-icon "download"}} + +
+{{else}} +

{{i18n 'discourse_patrons.user.billing_help'}}

+{{/if}} diff --git a/assets/javascripts/discourse/templates/user/subscriptions.hbs b/assets/javascripts/discourse/templates/user/subscriptions.hbs new file mode 100644 index 0000000..8c5d958 --- /dev/null +++ b/assets/javascripts/discourse/templates/user/subscriptions.hbs @@ -0,0 +1,25 @@ + +{{i18n 'discourse_patrons.user.subscriptions.title'}} + +{{#if model}} + + + + + + + + + {{#each model as |subscription|}} + + + + + + + + {{/each}} +
{{i18n 'discourse_patrons.user.subscriptions.id'}}{{i18n 'discourse_patrons.user.plans.rate'}}{{i18n 'discourse_patrons.user.subscriptions.status'}}{{i18n 'discourse_patrons.user.subscriptions.created_at'}}
{{subscription.id}}{{subscription.plan.subscriptionRate}}{{subscription.status}}{{format-unix-date subscription.created}}{{d-button disabled=subscription.canceled label="cancel" action=(route-action "cancelSubscription" subscription) icon="times"}}
+{{else}} +

{{i18n 'discourse_patrons.user.subscriptions_help'}}

+{{/if}} diff --git a/assets/stylesheets/common/discourse-patrons-layout.scss b/assets/stylesheets/common/discourse-patrons-layout.scss new file mode 100644 index 0000000..0211bbd --- /dev/null +++ b/assets/stylesheets/common/discourse-patrons-layout.scss @@ -0,0 +1,33 @@ +.discourse-patrons-section-columns { + display: flex; + justify-content: space-between; + + @include breakpoint(medium) { + flex-direction: column; + } + + .section-column { + min-width: calc(50% - 0.5em); + max-width: 100%; + + &:last-child { + margin-left: 0.5em; + } + + &:first-child { + margin-right: 0.5em; + } + + @include breakpoint(medium) { + min-width: 100%; + + &:last-child { + order: 2; + } + + &:first-child { + order: 1; + } + } + } +} diff --git a/assets/stylesheets/common/discourse-patrons.scss b/assets/stylesheets/common/discourse-patrons.scss index 194986a..4032525 100644 --- a/assets/stylesheets/common/discourse-patrons.scss +++ b/assets/stylesheets/common/discourse-patrons.scss @@ -1,40 +1,30 @@ -.discourse-patrons-section-columns { - display: flex; - justify-content: space-between; +// TODO: This gets overridden somewhere. It is defined in common/base/discourse.scss +input[disabled], +input[readonly], +select[disabled], +select[readonly], +textarea[disabled], +textarea[readonly] { + cursor: not-allowed; + background-color: #e9e9e9; + border-color: #e9e9e9; +} - @include breakpoint(medium) { - flex-direction: column; - } - - .section-column { - min-width: calc(50% - 0.5em); - max-width: 100%; - - &:last-child { - margin-left: 0.5em; - } - - &:first-child { - margin-right: 0.5em; - } - - @include breakpoint(medium) { - min-width: 100%; - - &:last-child { - order: 2; - } - - &:first-child { - order: 1; - } - } +#discourse-patrons-admin { + .btn-right { + text-align: right; } } -.discourse-patrons-admin { - .amount { - text-align: right; +.td-right { + text-align: right; +} + +table.discourse-patrons-user-table { + width: 100%; + th, + td { + padding: 10px; } } @@ -43,6 +33,10 @@ font-size: 0.8em; } +.discourse-patrons-admin-textarea { + width: 80%; +} + #stripe-elements { border: 1px $primary-low-mid solid; padding: 10px; diff --git a/assets/stylesheets/mobile/discourse-patrons.scss b/assets/stylesheets/mobile/discourse-patrons.scss index b99f3da..e69de29 100644 --- a/assets/stylesheets/mobile/discourse-patrons.scss +++ b/assets/stylesheets/mobile/discourse-patrons.scss @@ -1,8 +0,0 @@ -.donations-category-header .donations-category-metadata { - flex-flow: wrap; - padding: 0 10px; - - div { - padding-bottom: 10px; - } -} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index f1b9314..ad40492 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1,31 +1,62 @@ en: site_settings: - discourse_patrons_enabled: "Enable the Discourse Patrons plugin." - discourse_patrons_secret_key: "Stripe Secret Key" - discourse_patrons_public_key: "Stripe Public Key" - discourse_patrons_currency: "Currency Code" + discourse_patrons_enabled: Enable the Discourse Patrons plugin. + discourse_patrons_extra_nav_subscribe: Show the subscribe button in the primary navigation + discourse_patrons_secret_key: Stripe Secret Key + discourse_patrons_public_key: Stripe Public Key + discourse_patrons_currency: Default Currency Code. This can be overridden when creating a subscription plan discourse_patrons_zip_code: "Show Zip Code" discourse_patrons_billing_address: "Collect billing address" discourse_patrons_payment_page: "Text to be added to enter payments page. Markdown is supported." discourse_patrons_success_page: "Text to be added to success page. Markdown is supported." discourse_patrons_payment_description: "This is sent to Stripe and shows in the payment information" + discourse_patrons_amounts: "Payment amounts a user can select" errors: discourse_patrons_amount_must_be_currency: "Currency amounts must be currencies without dollar symbol (eg 1.50)" js: discourse_patrons: title: Discourse Patrons - nav_item: Payment - heading: - payment: Make a Payment - success: Thank you! - payment: - optional: Optional - receipt_info: A receipt is sent to this email address - your_information: Your information - payment_information: Payment information - payment_confirmation: Confirm information - amount: Amount - payment_intent_id: Payment ID + optional: Optional + transactions: + payment: + success: Your payment was successful + navigation: + subscriptions: Subscriptions + subscribe: Subscribe + billing: Billing + user: + plans: + rate: Rate + subscriptions_help: You have no subscriptions. + subscriptions: + title: Subscriptions + id: Subscription ID + status: Status + created_at: Created + operations: + destroy: + confirm: Are you sure you want to cancel this subscription? + subscribe: + title: Subscribe + card: + title: Payment + customer: + title: Customer Details + empty: We couldn't find a customer identifier in our system. A new one will be created for you. + buttons: + subscribe: Subscribe + one_time: + heading: + payment: Make a Payment + success: Thank you! + payment: + optional: Optional + receipt_info: A receipt is sent to this email address + your_information: Your information + payment_information: Payment information + payment_confirmation: Confirm information + amount: Amount + payment_intent_id: Payment ID billing: name: Full name email: Email @@ -38,10 +69,62 @@ en: confirm_payment: Confirm payment success: Go back admin: - payment_history: + dashboard: + title: Dashboard table: head: user: User payment_intent: Payment ID receipt_email: Receipt Email amount: Amount + products: + title: Products + operations: + create: Create New Product + update: Update Product + new: New Product + destroy: + confirm: Are you sure you want to destroy this product? + product: + product_id: Product ID + name: Product Name + statement_descriptor: Statement Descriptor + statement_descriptor_help: Extra information about a product which will appear on your customer’s credit card statement. + plan_help: Create a pricing plan to subscribe customers to this product + description: Description + description_help: This describes your subscription product. + active: Active + created_at: Created + updated_at: Updated + product_help: Before cutomers can subscribe to your site, you need to create at least one product and an associated plan. + plans: + title: Pricing Plans + operations: + add: Add New Plan + create: Create Plan + update: Update Plan + create_help: Once a pricing plan is created, only its nickname, trial period and user group can be updated. + new: New Plan + destroy: + confirm: Are you sure you want to destroy this plan? + plan: + nickname: Plan Nickname + nickname_help: This won't be visible to customers, but will help you find this plan later + plan_id: Plan ID + product: Product + interval: Billing Interval + amount: Amount + trial: Trial Period Days + trial_help: Subscriptions to this plan will automatically start with a free trial of this length + group: User Group + group_help: This is the discourse user group the customer gets added to when the subscription is created. + active: Active + created_at: Created + subscriptions: + title: Subscriptions + subscription: + subscription_id: Subscription ID + customer: Customer + plan: Plan + status: Status + created_at: Created diff --git a/config/routes.rb b/config/routes.rb index 226e348..ca478bb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,9 +1,29 @@ # frozen_string_literal: true DiscoursePatrons::Engine.routes.draw do - get '/admin' => 'admin#index' - get '/' => 'patrons#index' - get '/:pid' => 'patrons#show' + # TODO: namespace this + scope 'admin' do + get '/' => 'admin#index' + end + namespace :admin do + resources :plans + resources :subscriptions, only: [:index] + resources :products + end + + namespace :user do + resources :subscriptions, only: [:index, :destroy] + end + + resources :customers, only: [:create] + resources :invoices, only: [:index] resources :patrons, only: [:index, :create] + resources :plans, only: [:index] + resources :products, only: [:index, :show] + resources :subscriptions, only: [:create] + + get '/' => 'patrons#index' + get '/subscribe' => 'patrons#index' + get '/subscribe/:id' => 'patrons#index' end diff --git a/config/settings.yml b/config/settings.yml index b74281a..1335476 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -1,6 +1,9 @@ plugins: discourse_patrons_enabled: default: false + discourse_patrons_extra_nav_subscribe: + default: false + client: true discourse_patrons_public_key: default: '' client: true diff --git a/db/migrate/20190913010928_create_payments_table.rb b/db/migrate/20190913010928_create_payments_table.rb deleted file mode 100644 index bcc5a52..0000000 --- a/db/migrate/20190913010928_create_payments_table.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -class CreatePaymentsTable < ActiveRecord::Migration[5.2] - def change - create_table :payments do |t| - t.string :payment_intent_id, null: false - t.string :receipt_email, null: false - t.string :currency, null: false - t.string :url, null: false - t.integer :amount, null: false - t.references :user, foreign_key: true - t.timestamps - end - - add_index :payments, :payment_intent_id, unique: true - end -end diff --git a/db/migrate/20191025031631_create_customers.rb b/db/migrate/20191025031631_create_customers.rb new file mode 100644 index 0000000..27377e8 --- /dev/null +++ b/db/migrate/20191025031631_create_customers.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CreateCustomers < ActiveRecord::Migration[5.2] + def change + create_table :discourse_patrons_customers do |t| + t.string :customer_id, null: false + t.references :user, foreign_key: true + t.timestamps + end + + add_index :discourse_patrons_customers, :customer_id, unique: true + end +end diff --git a/plugin.rb b/plugin.rb index da16a21..2e878f3 100644 --- a/plugin.rb +++ b/plugin.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true # name: discourse-patrons -# about: Integrates Stripe into Discourse to allow visitors to make payments -# version: 1.3.1 +# about: Integrates Stripe into Discourse to allow visitors to make payments and Subscribe +# version: 2.0.0 # url: https://github.com/rimian/discourse-patrons # authors: Rimian Perkins @@ -11,7 +11,9 @@ enabled_site_setting :discourse_patrons_enabled gem 'stripe', '5.8.0' register_asset "stylesheets/common/discourse-patrons.scss" +register_asset "stylesheets/common/discourse-patrons-layout.scss" register_asset "stylesheets/mobile/discourse-patrons.scss" +register_svg_icon "credit-card" if respond_to?(:register_svg_icon) register_html_builder('server:before-head-close') do "" @@ -21,22 +23,42 @@ extend_content_security_policy( script_src: ['https://js.stripe.com/v3/'] ) -add_admin_route 'discourse_patrons.title', 'discourse-patrons' +add_admin_route 'discourse_patrons.title', 'discourse-patrons.products' Discourse::Application.routes.append do get '/admin/plugins/discourse-patrons' => 'admin/plugins#index' + get '/admin/plugins/discourse-patrons/dashboard' => 'admin/plugins#index' + get '/admin/plugins/discourse-patrons/products' => 'admin/plugins#index' + get '/admin/plugins/discourse-patrons/products/:product_id' => 'admin/plugins#index' + get '/admin/plugins/discourse-patrons/products/:product_id/plans' => 'admin/plugins#index' + get '/admin/plugins/discourse-patrons/products/:product_id/plans/:plan_id' => 'admin/plugins#index' + get '/admin/plugins/discourse-patrons/subscriptions' => 'admin/plugins#index' + get '/admin/plugins/discourse-patrons/plans' => 'admin/plugins#index' + get '/admin/plugins/discourse-patrons/plans/:plan_id' => 'admin/plugins#index' + get 'u/:username/billing' => 'users#show', constraints: { username: USERNAME_ROUTE_FORMAT } + get 'u/:username/subscriptions' => 'users#show', constraints: { username: USERNAME_ROUTE_FORMAT } end after_initialize do ::Stripe.api_version = "2019-11-05" - ::Stripe.set_app_info('Discourse Patrons', version: '1.3.1', url: 'https://github.com/rimian/discourse-patrons') + ::Stripe.set_app_info('Discourse Patrons', version: '2.0.0', url: 'https://github.com/rimian/discourse-patrons') [ "../lib/discourse_patrons/engine", "../config/routes", + "../app/controllers/concerns/stripe", "../app/controllers/admin_controller", + "../app/controllers/admin/plans_controller", + "../app/controllers/admin/products_controller", + "../app/controllers/admin/subscriptions_controller", + "../app/controllers/user/subscriptions_controller", + "../app/controllers/customers_controller", + "../app/controllers/invoices_controller", "../app/controllers/patrons_controller", - "../app/models/payment", + "../app/controllers/plans_controller", + "../app/controllers/products_controller", + "../app/controllers/subscriptions_controller", + "../app/models/customer", "../app/serializers/payment_serializer", ].each { |path| require File.expand_path(path, __FILE__) } diff --git a/spec/controllers/discourse_patrons/admin_controller_spec.rb b/spec/controllers/discourse_patrons/admin_controller_spec.rb deleted file mode 100644 index ca478d0..0000000 --- a/spec/controllers/discourse_patrons/admin_controller_spec.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -module DiscoursePatrons - RSpec.describe AdminController, type: :controller do - routes { DiscoursePatrons::Engine.routes } - - it 'is a subclass of AdminController' do - expect(DiscoursePatrons::AdminController < Admin::AdminController).to eq(true) - end - - # TODO: authenticate to test these - it "is ascending" - it "is has ordered by" - end -end diff --git a/spec/controllers/discourse_patrons/patrons_controller_spec.rb b/spec/controllers/discourse_patrons/patrons_controller_spec.rb index 952814c..a1e734c 100644 --- a/spec/controllers/discourse_patrons/patrons_controller_spec.rb +++ b/spec/controllers/discourse_patrons/patrons_controller_spec.rb @@ -27,54 +27,6 @@ module DiscoursePatrons end end - describe 'show' do - let!(:admin) { Fabricate(:admin) } - let!(:user) { Fabricate(:user) } - let(:payment_intent) { { customer: user.id } } - - before do - controller.stubs(:current_user).returns(user) - ::Stripe::PaymentIntent.stubs(:retrieve).returns(payment_intent) - end - - it 'responds ok' do - get :show, params: { pid: '123' }, format: :json - expect(response).to have_http_status(200) - end - - it 'requests the payment intent' do - ::Stripe::PaymentIntent.expects(:retrieve).with('abc-1234').returns(payment_intent) - get :show, params: { pid: 'abc-1234' }, format: :json - end - - it 'allows admin to see receipts' do - controller.expects(:current_user).returns(admin) - ::Stripe::PaymentIntent.expects(:retrieve).returns(metadata: { user_id: user.id }) - get :show, params: { pid: '123' }, format: :json - expect(response).to have_http_status(200) - end - - it 'does not allow another the user to see receipts' do - ::Stripe::PaymentIntent.expects(:retrieve).returns(metadata: { user_id: 9999 }) - get :show, params: { pid: '123' }, format: :json - - aggregate_failures do - expect(response).to have_http_status(200) - expect(JSON.parse(response.body)).to eq("error" => "Not found") - end - end - - it 'does not allow anon user to see receipts' do - controller.stubs(:current_user).returns(nil) - get :show, params: { pid: '123' }, format: :json - - aggregate_failures do - expect(response).to have_http_status(200) - expect(JSON.parse(response.body)).to eq("error" => "Not found") - end - end - end - describe 'create' do let!(:current_user) { Fabricate(:user) } @@ -101,14 +53,6 @@ module DiscoursePatrons expect(response).to have_http_status(200) end - it 'creates a payment' do - ::Stripe::PaymentIntent.expects(:create).returns(payment) - - expect { - post :create, params: { receipt_email: 'hello@example.com', amount: '20.00' }, format: :json - }.to change { Payment.count } - end - it 'has no user' do controller.stubs(:current_user).returns(nil) ::Stripe::PaymentIntent.expects(:create).returns(payment) diff --git a/spec/models/customer_spec.rb b/spec/models/customer_spec.rb new file mode 100644 index 0000000..4070793 --- /dev/null +++ b/spec/models/customer_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module DiscoursePatrons + RSpec.describe Customer do + let(:user) { Fabricate(:user) } + let(:stripe_customer) { { id: 'cus_id4567' } } + + it "has a table name" do + expect(described_class.table_name).to eq "discourse_patrons_customers" + end + + it "creates" do + customer = described_class.create_customer(user, stripe_customer) + expect(customer.customer_id).to eq 'cus_id4567' + expect(customer.user_id).to eq user.id + end + + it "has a user scope" do + described_class.create_customer(user, stripe_customer) + customer = described_class.find_user(user) + expect(customer.customer_id).to eq 'cus_id4567' + end + end +end diff --git a/spec/requests/admin/plans_controller_spec.rb b/spec/requests/admin/plans_controller_spec.rb new file mode 100644 index 0000000..229a156 --- /dev/null +++ b/spec/requests/admin/plans_controller_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module DiscoursePatrons + module Admin + RSpec.describe PlansController do + it 'is a subclass of AdminController' do + expect(DiscoursePatrons::Admin::PlansController < ::Admin::AdminController).to eq(true) + end + + context 'not authenticated' do + describe "index" do + it "does not get the plans" do + ::Stripe::Plan.expects(:list).never + get "/patrons/admin/plans.json" + end + + it "not ok" do + get "/patrons/admin/plans.json" + expect(response.status).to eq 403 + end + end + + describe "create" do + it "does not create a plan" do + ::Stripe::Plan.expects(:create).never + post "/patrons/admin/plans.json", params: { name: 'Rick Astley', amount: 1, interval: 'week' } + end + + it "is not ok" do + post "/patrons/admin/plans.json", params: { name: 'Rick Astley', amount: 1, interval: 'week' } + expect(response.status).to eq 403 + end + end + + describe "show" do + it "does not show the plan" do + ::Stripe::Plan.expects(:retrieve).never + get "/patrons/admin/plans/plan_12345.json" + end + + it "is not ok" do + get "/patrons/admin/plans/plan_12345.json" + expect(response.status).to eq 403 + end + end + + describe "update" do + it "does not update a plan" do + ::Stripe::Plan.expects(:update).never + delete "/patrons/admin/plans/plan_12345.json" + end + + it "is not ok" do + delete "/patrons/admin/plans/plan_12345.json" + expect(response.status).to eq 403 + end + end + + describe "delete" do + it "does not delete a plan" do + ::Stripe::Plan.expects(:delete).never + patch "/patrons/admin/plans/plan_12345.json" + end + + it "is not ok" do + patch "/patrons/admin/plans/plan_12345.json" + expect(response.status).to eq 403 + end + end + end + + context 'authenticated' do + let(:admin) { Fabricate(:admin) } + + before { sign_in(admin) } + + describe "index" do + it "lists the plans" do + ::Stripe::Plan.expects(:list).with(nil) + get "/patrons/admin/plans.json" + end + + it "lists the plans for the product" do + ::Stripe::Plan.expects(:list).with(product: 'prod_id123') + get "/patrons/admin/plans.json", params: { product_id: 'prod_id123' } + end + end + + describe "create" do + it "creates a plan with a nickname" do + ::Stripe::Plan.expects(:create).with(has_entry(:nickname, 'Veg')) + post "/patrons/admin/plans.json", params: { nickname: 'Veg', metadata: { group_name: '' } } + end + + it "creates a plan with a currency" do + SiteSetting.stubs(:discourse_patrons_currency).returns('aud') + ::Stripe::Plan.expects(:create).with(has_entry(:currency, 'aud')) + post "/patrons/admin/plans.json", params: { metadata: { group_name: '' } } + end + + it "creates a plan with an interval" do + ::Stripe::Plan.expects(:create).with(has_entry(:interval, 'week')) + post "/patrons/admin/plans.json", params: { interval: 'week', metadata: { group_name: '' } } + end + + it "creates a plan with an amount" do + ::Stripe::Plan.expects(:create).with(has_entry(:amount, '102')) + post "/patrons/admin/plans.json", params: { amount: '102', metadata: { group_name: '' } } + end + + it "creates a plan with a trial period" do + ::Stripe::Plan.expects(:create).with(has_entry(:trial_period_days, '14')) + post "/patrons/admin/plans.json", params: { trial_period_days: '14', metadata: { group_name: '' } } + end + + it "creates a plan with a product" do + ::Stripe::Plan.expects(:create).with(has_entry(product: 'prod_walterwhite')) + post "/patrons/admin/plans.json", params: { product: 'prod_walterwhite', metadata: { group_name: '' } } + end + + it "creates a plan with an active status" do + ::Stripe::Plan.expects(:create).with(has_entry(:active, 'false')) + post "/patrons/admin/plans.json", params: { active: 'false', metadata: { group_name: '' } } + end + + it 'has a metadata' do + ::Stripe::Plan.expects(:create).with(has_entry(metadata: { group_name: 'discourse-user-group-name' })) + post "/patrons/admin/plans.json", params: { metadata: { group_name: 'discourse-user-group-name' } } + end + end + + describe "update" do + it "updates a plan" do + ::Stripe::Plan.expects(:update) + patch "/patrons/admin/plans/plan_12345.json", params: { metadata: { group_name: 'discourse-user-group-name' } } + end + end + + describe "delete" do + it "deletes a plan" do + ::Stripe::Plan.expects(:delete).with('plan_12345') + delete "/patrons/admin/plans/plan_12345.json" + end + end + end + end + end +end diff --git a/spec/requests/admin/products_controller_spec.rb b/spec/requests/admin/products_controller_spec.rb new file mode 100644 index 0000000..78c2a01 --- /dev/null +++ b/spec/requests/admin/products_controller_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module DiscoursePatrons + module Admin + RSpec.describe ProductsController do + it 'is a subclass of AdminController' do + expect(DiscoursePatrons::Admin::ProductsController < ::Admin::AdminController).to eq(true) + end + + context 'unauthenticated' do + it "does not list the products" do + ::Stripe::Product.expects(:list).never + get "/patrons/admin/products.json" + expect(response.status).to eq(403) + end + + it "does not create the product" do + ::Stripe::Product.expects(:create).never + post "/patrons/admin/products.json" + expect(response.status).to eq(403) + end + + it "does not show the product" do + ::Stripe::Product.expects(:retrieve).never + get "/patrons/admin/products/prod_qwerty123.json" + expect(response.status).to eq(403) + end + + it "does not update the product" do + ::Stripe::Product.expects(:update).never + put "/patrons/admin/products/prod_qwerty123.json" + expect(response.status).to eq(403) + end + + it "does not delete the product" do + ::Stripe::Product.expects(:delete).never + delete "/patrons/admin/products/u2.json" + expect(response.status).to eq(403) + end + end + + context 'authenticated' do + let(:admin) { Fabricate(:admin) } + + before { sign_in(admin) } + + describe 'index' do + it "gets the empty products" do + ::Stripe::Product.expects(:list) + get "/patrons/admin/products.json" + end + end + + describe 'create' do + it 'is of product type service' do + ::Stripe::Product.expects(:create).with(has_entry(:type, 'service')) + post "/patrons/admin/products.json", params: {} + end + + it 'has a name' do + ::Stripe::Product.expects(:create).with(has_entry(:name, 'Jesse Pinkman')) + post "/patrons/admin/products.json", params: { name: 'Jesse Pinkman' } + end + + it 'has an active attribute' do + ::Stripe::Product.expects(:create).with(has_entry(active: 'false')) + post "/patrons/admin/products.json", params: { active: 'false' } + end + + it 'has a statement descriptor' do + ::Stripe::Product.expects(:create).with(has_entry(statement_descriptor: 'Blessed are the cheesemakers')) + post "/patrons/admin/products.json", params: { statement_descriptor: 'Blessed are the cheesemakers' } + end + + it 'has no statement descriptor if empty' do + ::Stripe::Product.expects(:create).with(has_key(:statement_descriptor)).never + post "/patrons/admin/products.json", params: { statement_descriptor: '' } + end + + it 'has a description' do + ::Stripe::Product.expects(:create).with(has_entry(metadata: { description: 'Oi, I think he just said bless be all the bignoses!' })) + post "/patrons/admin/products.json", params: { metadata: { description: 'Oi, I think he just said bless be all the bignoses!' } } + end + end + + describe 'show' do + it 'retrieves the product' do + ::Stripe::Product.expects(:retrieve).with('prod_walterwhite') + get "/patrons/admin/products/prod_walterwhite.json" + end + end + + describe 'update' do + it 'updates the product' do + ::Stripe::Product.expects(:update) + patch "/patrons/admin/products/prod_walterwhite.json", params: {} + end + end + + describe 'delete' do + it 'deletes the product' do + ::Stripe::Product.expects(:delete).with('prod_walterwhite') + delete "/patrons/admin/products/prod_walterwhite.json" + end + end + end + end + end +end diff --git a/spec/requests/admin/subscriptions_controller_spec.rb b/spec/requests/admin/subscriptions_controller_spec.rb new file mode 100644 index 0000000..3ef8d4c --- /dev/null +++ b/spec/requests/admin/subscriptions_controller_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module DiscoursePatrons + RSpec.describe Admin::SubscriptionsController do + it 'is a subclass of AdminController' do + expect(DiscoursePatrons::Admin::SubscriptionsController < ::Admin::AdminController).to eq(true) + end + + context 'unauthenticated' do + it "does nothing" do + ::Stripe::Subscription.expects(:list).never + get "/patrons/admin/subscriptions.json" + expect(response.status).to eq(403) + end + end + + context 'authenticated' do + let(:admin) { Fabricate(:admin) } + + before { sign_in(admin) } + + it "gets the empty subscriptions" do + ::Stripe::Subscription.expects(:list) + get "/patrons/admin/subscriptions.json" + expect(response.status).to eq(200) + end + end + end +end diff --git a/spec/requests/admin_controller_spec.rb b/spec/requests/admin_controller_spec.rb new file mode 100644 index 0000000..28d988a --- /dev/null +++ b/spec/requests/admin_controller_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module DiscoursePatrons + RSpec.describe AdminController do + + let(:admin) { Fabricate(:admin) } + + before { sign_in(admin) } + + it 'is a subclass of AdminController' do + expect(DiscoursePatrons::AdminController < ::Admin::AdminController).to eq(true) + end + + it "is ok" do + get "/patrons/admin.json" + expect(response.status).to eq(200) + end + end +end diff --git a/spec/requests/customers_controller_spec.rb b/spec/requests/customers_controller_spec.rb new file mode 100644 index 0000000..0bf91d7 --- /dev/null +++ b/spec/requests/customers_controller_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module DiscoursePatrons + RSpec.describe CustomersController do + describe "create" do + describe "authenticated" do + let(:user) { Fabricate(:user, email: 'hello.2@example.com') } + + before do + sign_in(user) + end + + it "creates a stripe customer" do + ::Stripe::Customer.expects(:create).with( + email: 'hello.2@example.com', + source: 'tok_interesting' + ) + + post "/patrons/customers.json", params: { source: 'tok_interesting' } + end + + it "saves the customer" do + ::Stripe::Customer.expects(:create).returns(id: 'cus_id23456') + + expect { + post "/patrons/customers.json", params: { source: 'tok_interesting' } + }.to change { DiscoursePatrons::Customer.count } + end + end + end + end +end diff --git a/spec/requests/invoices_controller_spec.rb b/spec/requests/invoices_controller_spec.rb new file mode 100644 index 0000000..1620075 --- /dev/null +++ b/spec/requests/invoices_controller_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module DiscoursePatrons + RSpec.describe InvoicesController do + describe "index" do + describe "not authenticated" do + it "does not list the invoices" do + ::Stripe::Invoice.expects(:list).never + get "/patrons/invoices.json" + expect(response.status).to eq 403 + end + end + + describe "authenticated" do + let(:user) { Fabricate(:user) } + let(:stripe_customer) { { id: 'cus_id4567' } } + + before do + sign_in(user) + end + + describe "other user invoices" do + it "does not list the invoices" do + ::Stripe::Invoice.expects(:list).never + get "/patrons/invoices.json", params: { user_id: 999999 } + end + end + + describe "own invoices" do + context "stripe customer does not exist" do + it "lists empty" do + ::Stripe::Invoice.expects(:list).never + get "/patrons/invoices.json", params: { user_id: user.id } + expect(response.body).to eq "[]" + end + end + + context "stripe customer exists" do + before do + DiscoursePatrons::Customer.create_customer(user, stripe_customer) + end + + it "lists the invoices" do + ::Stripe::Invoice.expects(:list).with(customer: 'cus_id4567') + get "/patrons/invoices.json", params: { user_id: user.id } + end + end + end + end + end + end +end diff --git a/spec/requests/plans_controller_spec.rb b/spec/requests/plans_controller_spec.rb new file mode 100644 index 0000000..b0b5b2c --- /dev/null +++ b/spec/requests/plans_controller_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module DiscoursePatrons + RSpec.describe PlansController do + describe "index" do + it "lists the active plans" do + ::Stripe::Plan.expects(:list).with(active: true) + get "/patrons/plans.json" + end + + it "lists the active plans for a product" do + ::Stripe::Plan.expects(:list).with(active: true, product: 'prod_3765') + get "/patrons/plans.json", params: { product_id: 'prod_3765' } + end + + it "orders and serialises the plans" do + ::Stripe::Plan.expects(:list).returns( + data: [ + { id: 'plan_id123', amount: 1220, currency: 'aud', interval: 'year', metadata: {} }, + { id: 'plan_id234', amount: 1399, currency: 'usd', interval: 'year', metadata: {} }, + { id: 'plan_id678', amount: 1000, currency: 'aud', interval: 'week', metadata: {} } + ] + ) + + get "/patrons/plans.json" + + expect(JSON.parse(response.body)).to eq([ + { "amount" => 1000, "currency" => "aud", "id" => "plan_id678", "interval" => "week" }, + { "amount" => 1220, "currency" => "aud", "id" => "plan_id123", "interval" => "year" }, + { "amount" => 1399, "currency" => "usd", "id" => "plan_id234", "interval" => "year" } + ]) + end + end + end +end diff --git a/spec/requests/products_controller_spec.rb b/spec/requests/products_controller_spec.rb new file mode 100644 index 0000000..3e77075 --- /dev/null +++ b/spec/requests/products_controller_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module DiscoursePatrons + RSpec.describe ProductsController do + let(:product) do + { + id: "prodct_23456", + name: "Very Special Product", + metadata: { + description: "Many people listened to my phone call with the Ukrainian President while it was being made" + }, + otherstuff: true, + } + end + + describe "index" do + it "gets products" do + ::Stripe::Product.expects(:list).with(active: true).returns(data: [product]) + + get "/patrons/products.json" + + expect(JSON.parse(response.body)).to eq([{ + "id" => "prodct_23456", + "name" => "Very Special Product", + "description" => "Many people listened to my phone call with the Ukrainian President while it was being made" + }]) + end + end + + describe 'show' do + it 'retrieves the product' do + ::Stripe::Product.expects(:retrieve).with('prod_walterwhite').returns(product) + get "/patrons/products/prod_walterwhite.json" + + expect(JSON.parse(response.body)).to eq( + "id" => "prodct_23456", + "name" => "Very Special Product", + "description" => "Many people listened to my phone call with the Ukrainian President while it was being made" + ) + end + end + end +end diff --git a/spec/requests/subscriptions_controller_spec.rb b/spec/requests/subscriptions_controller_spec.rb new file mode 100644 index 0000000..8e45aa0 --- /dev/null +++ b/spec/requests/subscriptions_controller_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module DiscoursePatrons + RSpec.describe SubscriptionsController do + context "not authenticated" do + it "does not create a subscription" do + ::Stripe::Plan.expects(:retrieve).never + ::Stripe::Subscription.expects(:create).never + post "/patrons/subscriptions.json", params: { plan: 'plan_1234', customer: 'cus_1234' } + end + end + + context "authenticated" do + let(:user) { Fabricate(:user) } + + before do + sign_in(user) + end + + describe "create" do + it "creates a subscription" do + ::Stripe::Plan.expects(:retrieve).returns(metadata: { group_name: 'awesome' }) + ::Stripe::Subscription.expects(:create).with( + customer: 'cus_1234', + items: [ plan: 'plan_1234' ] + ) + post "/patrons/subscriptions.json", params: { plan: 'plan_1234', customer: 'cus_1234' } + end + + it "creates a customer model" do + ::Stripe::Plan.expects(:retrieve).returns(metadata: {}) + ::Stripe::Subscription.expects(:create).returns(status: 'active') + + expect { + post "/patrons/subscriptions.json", params: { plan: 'plan_1234', customer: 'cus_1234' } + }.to change { DiscoursePatrons::Customer.count } + end + end + + describe "user groups" do + let(:group_name) { 'group-123' } + let(:group) { Fabricate(:group, name: group_name) } + + context "unauthorized group" do + before do + ::Stripe::Subscription.expects(:create).returns(status: 'active') + end + + it "does not add the user to the admins group" do + ::Stripe::Plan.expects(:retrieve).returns(metadata: { group_name: 'admins' }) + post "/patrons/subscriptions.json", params: { plan: 'plan_1234', customer: 'cus_1234' } + expect(user.admin).to eq false + end + + it "does not add the user to other group" do + ::Stripe::Plan.expects(:retrieve).returns(metadata: { group_name: 'other' }) + post "/patrons/subscriptions.json", params: { plan: 'plan_1234', customer: 'cus_1234' } + expect(user.groups).to be_empty + end + end + + context "plan has group in metadata" do + before do + ::Stripe::Plan.expects(:retrieve).returns(metadata: { group_name: group_name }) + end + + it "does not add the user to the group when subscription fails" do + ::Stripe::Subscription.expects(:create).returns(status: 'failed') + + expect { + post "/patrons/subscriptions.json", params: { plan: 'plan_1234', customer: 'cus_1234' } + }.not_to change { group.users.count } + + expect(user.groups).to be_empty + end + + it "adds the user to the group when the subscription is active" do + ::Stripe::Subscription.expects(:create).returns(status: 'active') + + expect { + post "/patrons/subscriptions.json", params: { plan: 'plan_1234', customer: 'cus_1234' } + }.to change { group.users.count } + + expect(user.groups).not_to be_empty + end + + it "adds the user to the group when the subscription is trialing" do + ::Stripe::Subscription.expects(:create).returns(status: 'trialing') + + expect { + post "/patrons/subscriptions.json", params: { plan: 'plan_1234', customer: 'cus_1234' } + }.to change { group.users.count } + + expect(user.groups).not_to be_empty + end + end + end + end + end +end diff --git a/spec/requests/user/subscriptions_controller_spec.rb b/spec/requests/user/subscriptions_controller_spec.rb new file mode 100644 index 0000000..be9d321 --- /dev/null +++ b/spec/requests/user/subscriptions_controller_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module DiscoursePatrons + RSpec.describe User::SubscriptionsController do + it 'is a subclass of ApplicationController' do + expect(DiscoursePatrons::User::SubscriptionsController < ::ApplicationController).to eq(true) + end + + context "not authenticated" do + it "does not get the subscriptions" do + ::Stripe::Customer.expects(:list).never + get "/patrons/user/subscriptions.json" + end + + it "does not destroy a subscription" do + ::Stripe::Subscription.expects(:delete).never + patch "/patrons/user/subscriptions/sub_12345.json" + end + end + + context "authenticated" do + let(:user) { Fabricate(:user, email: 'beanie@example.com') } + + before do + sign_in(user) + end + + describe "index" do + let(:customers) do + { + data: [{ + id: "cus_23456", + subscriptions: { + data: [{ id: "sub_1234" }, { id: "sub_4567" }] + }, + }] + } + end + + it "gets subscriptions" do + ::Stripe::Customer.expects(:list).with( + email: user.email, + expand: ['data.subscriptions'] + ).returns(customers) + + get "/patrons/user/subscriptions.json" + + expect(JSON.parse(response.body)).to eq([{ "id" => "sub_1234" }, { "id" => "sub_4567" }]) + end + end + + describe "delete" do + it "deletes a subscription" do + ::Stripe::Subscription.expects(:delete).with('sub_12345') + delete "/patrons/user/subscriptions/sub_12345.json" + end + end + end + end +end diff --git a/test/javascripts/acceptance/payments-test.js.es6 b/test/javascripts/acceptance/payments-test.js.es6 new file mode 100644 index 0000000..0931ac6 --- /dev/null +++ b/test/javascripts/acceptance/payments-test.js.es6 @@ -0,0 +1,18 @@ +import { acceptance } from "helpers/qunit-helpers"; +import { stubStripe } from "discourse/plugins/discourse-patrons/helpers/stripe"; + +acceptance("Discourse Patrons", { + settings: { + discourse_patrons_amounts: "1.00|2.00" + }, + + beforeEach() { + stubStripe(); + } +}); + +QUnit.skip("viewing the one-off payment page", async assert => { + await visit("/patrons"); + + assert.ok($(".donations-page-payment").length, "has payment form class"); +}); diff --git a/test/javascripts/acceptance/plugin-outlets-test.js.es6 b/test/javascripts/acceptance/plugin-outlets-test.js.es6 new file mode 100644 index 0000000..46a1fd2 --- /dev/null +++ b/test/javascripts/acceptance/plugin-outlets-test.js.es6 @@ -0,0 +1,16 @@ +import { acceptance } from "helpers/qunit-helpers"; + +acceptance("Discourse Patrons", { + settings: { + discourse_patrons_extra_nav_subscribe: true + } +}); + +QUnit.test("plugin outlets", async assert => { + await visit("/"); + + assert.ok( + $("#navigation-bar .discourse-patrons-subscribe").length, + "has a subscribe button" + ); +}); diff --git a/test/javascripts/acceptance/subscribe-test.js.es6 b/test/javascripts/acceptance/subscribe-test.js.es6 new file mode 100644 index 0000000..ebfe0a5 --- /dev/null +++ b/test/javascripts/acceptance/subscribe-test.js.es6 @@ -0,0 +1,23 @@ +import { acceptance } from "helpers/qunit-helpers"; + +acceptance("Discourse Patrons", { + settings: { + discourse_patrons_subscription_group: "plan-id" + } +}); + +// TODO: add request fixtures + +QUnit.skip("subscribing", async assert => { + await visit("/patrons/subscribe"); + + assert.ok($("h3").length, "has a heading"); +}); + +QUnit.skip("subscribing with empty customer", async assert => { + await visit("/patrons/subscribe"); + assert.ok( + $(".discourse-patrons-subscribe-customer-empty").length, + "has empty customer content" + ); +}); diff --git a/test/javascripts/components/donation-form-test.es6 b/test/javascripts/components/donation-form-test.js.es6 similarity index 98% rename from test/javascripts/components/donation-form-test.es6 rename to test/javascripts/components/donation-form-test.js.es6 index 120f765..fcb21fe 100644 --- a/test/javascripts/components/donation-form-test.es6 +++ b/test/javascripts/components/donation-form-test.js.es6 @@ -33,7 +33,7 @@ componentTest("donation form has a confirmation", { Discourse.SiteSettings.discourse_patrons_amounts = "1.00|2.01"; }, - async test(assert) { + async skip(assert) { this.set("confirmation", { card: { last4: "4242" } }); const confirmExists = find(".discourse-patrons-confirmation").length; diff --git a/test/javascripts/components/stripe-card-test.js.es6 b/test/javascripts/components/stripe-card-test.js.es6 index 8e9bf7e..fde27a1 100644 --- a/test/javascripts/components/stripe-card-test.js.es6 +++ b/test/javascripts/components/stripe-card-test.js.es6 @@ -1,4 +1,5 @@ import componentTest from "helpers/component-test"; +import { stubStripe } from "discourse/plugins/discourse-patrons/helpers/stripe"; moduleForComponent("stripe-card", { integration: true }); @@ -6,26 +7,7 @@ componentTest("Discourse Patrons stripe card success", { template: `{{stripe-card handleConfirmStripeCard=onSubmit billing=billing}}`, beforeEach() { - window.Stripe = () => { - return { - createPaymentMethod() { - return new Ember.RSVP.Promise(resolve => { - resolve({}); - }); - }, - elements() { - return { - create() { - return { - on() {}, - card() {}, - mount() {} - }; - } - }; - } - }; - }; + stubStripe(); this.set( "billing", diff --git a/test/javascripts/helpers/discourse-patrons-pretender.js.es6 b/test/javascripts/helpers/discourse-patrons-pretender.js.es6 new file mode 100644 index 0000000..8944550 --- /dev/null +++ b/test/javascripts/helpers/discourse-patrons-pretender.js.es6 @@ -0,0 +1,13 @@ +export default function(helpers) { + const { response } = helpers; + + this.get("/patrons", () => response({ email: "hello@example.com" })); + + this.get("/groups/:plan", () => { + return response({ full_name: "Saboo", bio_cooked: "This is the plan" }); + }); + + this.get("/patrons/plans", () => { + return response({ plans: [] }); + }); +} diff --git a/test/javascripts/helpers/stripe.js.es6 b/test/javascripts/helpers/stripe.js.es6 new file mode 100644 index 0000000..bb85450 --- /dev/null +++ b/test/javascripts/helpers/stripe.js.es6 @@ -0,0 +1,22 @@ +export function stubStripe() { + window.Stripe = () => { + return { + createPaymentMethod() { + return new Ember.RSVP.Promise(resolve => { + resolve({}); + }); + }, + elements() { + return { + create() { + return { + on() {}, + card() {}, + mount() {} + }; + } + }; + } + }; + }; +} diff --git a/test/javascripts/models/plan-test.js.es6 b/test/javascripts/models/plan-test.js.es6 new file mode 100644 index 0000000..5523e97 --- /dev/null +++ b/test/javascripts/models/plan-test.js.es6 @@ -0,0 +1,33 @@ +import Plan from "discourse/plugins/discourse-patrons/discourse/models/plan"; + +QUnit.module("discourse-patrons:model:plan"); + +QUnit.test("subscriptionRate", assert => { + const plan = Plan.create({ + amount: "2399", + currency: "aud", + interval: "month" + }); + + assert.equal( + plan.get("subscriptionRate"), + "$23.99 AUD / month", + "it returns the formatted subscription rate" + ); +}); + +QUnit.test("amountDollars", assert => { + const plan = Plan.create({ amount: 2399 }); + + assert.equal( + plan.get("amountDollars"), + 23.99, + "it returns the formatted dollar amount" + ); +}); + +QUnit.test("amount", assert => { + const plan = Plan.create({ amountDollars: "22.12" }); + + assert.equal(plan.get("amount"), 2212, "it returns the cents amount"); +});