diff --git a/app/controllers/discourse_subscriptions/admin/coupons_controller.rb b/app/controllers/discourse_subscriptions/admin/coupons_controller.rb new file mode 100644 index 0000000..072a5be --- /dev/null +++ b/app/controllers/discourse_subscriptions/admin/coupons_controller.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module DiscourseSubscriptions + module Admin + class CouponsController < ::Admin::AdminController + include DiscourseSubscriptions::Stripe + include DiscourseSubscriptions::Group + before_action :set_api_key + + def index + begin + promo_codes = ::Stripe::PromotionCode.list({ limit: 100 })[:data] + promo_codes = promo_codes.select { |code| code[:coupon][:valid] == true } + render_json_dump promo_codes + rescue ::Stripe::InvalidRequestError => e + render_json_error e.message + end + end + + def create + params.require([:promo, :discount_type, :discount, :active]) + begin + coupon_params = { + duration: 'forever', + } + + case params[:discount_type] + when 'amount' + coupon_params[:amount_off] = params[:discount].to_i * 100 + coupon_params[:currency] = SiteSetting.discourse_subscriptions_currency + when 'percent' + coupon_params[:percent_off] = params[:discount] + end + + coupon = ::Stripe::Coupon.create(coupon_params) + + promo_code = ::Stripe::PromotionCode.create({ coupon: coupon[:id], code: params[:promo] }) if coupon.present? + + render_json_dump promo_code + rescue ::Stripe::InvalidRequestError => e + render_json_error e.message + end + end + + def update + params.require([:id, :active]) + begin + promo_code = ::Stripe::PromotionCode.update( + params[:id], + { + active: params[:active] + } + ) + + render_json_dump promo_code + rescue ::Stripe::InvalidRequestError => e + render_json_error e.message + end + end + + def destroy + params.require(:coupon_id) + begin + coupon = ::Stripe::Coupon.delete(params[:coupon_id]) + render_json_dump coupon + rescue ::Stripe::InvalidRequestError => e + render_json_error e.message + end + end + end + end +end diff --git a/assets/javascripts/discourse/components/create-coupon-form.js.es6 b/assets/javascripts/discourse/components/create-coupon-form.js.es6 new file mode 100644 index 0000000..338a4e8 --- /dev/null +++ b/assets/javascripts/discourse/components/create-coupon-form.js.es6 @@ -0,0 +1,32 @@ +import Component from "@ember/component"; +import discourseComputed from "discourse-common/utils/decorators"; + +export default Component.extend({ + @discourseComputed + discountTypes() { + return [ + { id: "amount", name: "Amount" }, + { id: "percent", name: "Percent" }, + ]; + }, + discountType: "amount", + discount: null, + promoCode: null, + active: false, + + actions: { + createNewCoupon() { + const createParams = { + promo: this.promoCode, + discount_type: this.discountType, + discount: this.discount, + active: this.active, + }; + + this.create(createParams); + }, + cancelCreate() { + this.cancel(); + }, + }, +}); diff --git a/assets/javascripts/discourse/controllers/admin-plugins-discourse-subscriptions-coupons.js.es6 b/assets/javascripts/discourse/controllers/admin-plugins-discourse-subscriptions-coupons.js.es6 new file mode 100644 index 0000000..14ea1d7 --- /dev/null +++ b/assets/javascripts/discourse/controllers/admin-plugins-discourse-subscriptions-coupons.js.es6 @@ -0,0 +1,42 @@ +import Controller from "@ember/controller"; +import AdminCoupon from "discourse/plugins/discourse-subscriptions/discourse/models/admin-coupon"; +import { popupAjaxError } from "discourse/lib/ajax-error"; + +export default Controller.extend({ + creating: null, + + actions: { + openCreateForm() { + this.set("creating", true); + }, + closeCreateForm() { + this.set("creating", false); + }, + createNewCoupon(params) { + AdminCoupon.save(params) + .then(() => { + this.send("closeCreateForm"); + this.send("reloadModel"); + }) + .catch(popupAjaxError); + }, + deleteCoupon(coupon) { + AdminCoupon.destroy(coupon) + .then(() => { + this.send("reloadModel"); + }) + .catch(popupAjaxError); + }, + toggleActive(coupon) { + const couponData = { + id: coupon.id, + active: !coupon.active, + }; + AdminCoupon.update(couponData) + .then(() => { + this.send("reloadModel"); + }) + .catch(popupAjaxError); + }, + }, +}); diff --git a/assets/javascripts/discourse/discourse-subscriptions-route-map.js.es6 b/assets/javascripts/discourse/discourse-subscriptions-route-map.js.es6 index 0ea72b8..be24a39 100644 --- a/assets/javascripts/discourse/discourse-subscriptions-route-map.js.es6 +++ b/assets/javascripts/discourse/discourse-subscriptions-route-map.js.es6 @@ -14,6 +14,8 @@ export default { }); }); + this.route("coupons"); + this.route("subscriptions"); }); }, diff --git a/assets/javascripts/discourse/models/admin-coupon.js.es6 b/assets/javascripts/discourse/models/admin-coupon.js.es6 new file mode 100644 index 0000000..e3b12d5 --- /dev/null +++ b/assets/javascripts/discourse/models/admin-coupon.js.es6 @@ -0,0 +1,64 @@ +import { ajax } from "discourse/lib/ajax"; +import EmberObject from "@ember/object"; +import discourseComputed from "discourse-common/utils/decorators"; + +const AdminCoupon = EmberObject.extend({ + @discourseComputed("coupon.amount_off", "coupon.percent_off") + discount(amount_off, percent_off) { + if (amount_off) { + return `${parseFloat(amount_off * 0.01).toFixed(2)}`; + } else if (percent_off) { + return `${percent_off}%`; + } + }, +}); + +AdminCoupon.reopenClass({ + list() { + return ajax("/s/admin/coupons", { + method: "get", + }).then((result) => { + if (result === null) { + return { unconfigured: true }; + } + return result.map((coupon) => AdminCoupon.create(coupon)); + }); + }, + save(params) { + const data = { + promo: params.promo, + discount_type: params.discount_type, + discount: params.discount, + active: params.active, + }; + + return ajax("/s/admin/coupons", { + method: "post", + data, + }).then((coupon) => AdminCoupon.create(coupon)); + }, + + update(params) { + const data = { + id: params.id, + active: params.active, + }; + + return ajax("/s/admin/coupons", { + method: "put", + data, + }).then((coupon) => AdminCoupon.create(coupon)); + }, + + destroy(params) { + const data = { + coupon_id: params.coupon.id, + }; + return ajax("/s/admin/coupons", { + method: "delete", + data, + }); + }, +}); + +export default AdminCoupon; diff --git a/assets/javascripts/discourse/routes/admin-plugins-discourse-subscriptions-coupons.js.es6 b/assets/javascripts/discourse/routes/admin-plugins-discourse-subscriptions-coupons.js.es6 new file mode 100644 index 0000000..85db462 --- /dev/null +++ b/assets/javascripts/discourse/routes/admin-plugins-discourse-subscriptions-coupons.js.es6 @@ -0,0 +1,14 @@ +import Route from "@ember/routing/route"; +import AdminCoupon from "discourse/plugins/discourse-subscriptions/discourse/models/admin-coupon"; + +export default Route.extend({ + model() { + return AdminCoupon.list(); + }, + + actions: { + reloadModel() { + this.refresh(); + }, + }, +}); diff --git a/assets/javascripts/discourse/templates/admin/plugins-discourse-subscriptions-coupons.hbs b/assets/javascripts/discourse/templates/admin/plugins-discourse-subscriptions-coupons.hbs new file mode 100644 index 0000000..6083eb6 --- /dev/null +++ b/assets/javascripts/discourse/templates/admin/plugins-discourse-subscriptions-coupons.hbs @@ -0,0 +1,44 @@ +{{#if model.unconfigured}} +

{{i18n 'discourse_subscriptions.admin.unconfigured'}}

+

Discourse Subscriptions on Meta

+{{else}} + {{#if model}} + + + + + + + + + {{#each model as |coupon|}} + + + + + + + + {{/each}} +
{{i18n 'discourse_subscriptions.admin.coupons.code'}}{{i18n 'discourse_subscriptions.admin.coupons.discount'}}{{i18n 'discourse_subscriptions.admin.coupons.times_redeemed'}}{{i18n 'discourse_subscriptions.admin.coupons.active'}}{{i18n 'discourse_subscriptions.admin.coupons.actions'}}
{{coupon.code}}{{coupon.discount}}{{coupon.times_redeemed}}{{input type="checkbox" checked=coupon.active click=(action "toggleActive" coupon)}} + {{d-button + action=(action "deleteCoupon") + actionParam=coupon + icon="trash-alt" + class="btn-danger btn btn-icon btn-no-text"}} +
+ {{/if}} + + {{#unless creating}} + {{d-button + action=(action "openCreateForm") + label="discourse_subscriptions.admin.coupons.create" + title="discourse_subscriptions.admin.coupons.create" + icon="plus" + class="btn btn-icon btn-primary create-coupon"}} + {{/unless}} + + {{#if creating}} + {{create-coupon-form cancel=(action 'closeCreateForm') create=(action 'createNewCoupon')}} + {{/if}} +{{/if}} diff --git a/assets/javascripts/discourse/templates/admin/plugins-discourse-subscriptions.hbs b/assets/javascripts/discourse/templates/admin/plugins-discourse-subscriptions.hbs index 9d392d7..a32c2b6 100644 --- a/assets/javascripts/discourse/templates/admin/plugins-discourse-subscriptions.hbs +++ b/assets/javascripts/discourse/templates/admin/plugins-discourse-subscriptions.hbs @@ -4,6 +4,7 @@ diff --git a/assets/javascripts/discourse/templates/components/create-coupon-form.hbs b/assets/javascripts/discourse/templates/components/create-coupon-form.hbs new file mode 100644 index 0000000..72aa197 --- /dev/null +++ b/assets/javascripts/discourse/templates/components/create-coupon-form.hbs @@ -0,0 +1,36 @@ +
+
+

+ + {{input type="text" name="promo_code" value=promoCode}} +

+

+ + {{combo-box + content=discountTypes + value=discountType + onChange=(action (mut discountType)) + }} + {{input class="discount-amount" type="text" name="amount" value=discount}} +

+

+ + {{input type="checkbox" name="active" checked=active}} +

+
+ + {{d-button + action=(action "createNewCoupon") + label="discourse_subscriptions.admin.coupons.create" + title="discourse_subscriptions.admin.coupons.create" + icon="plus" + class="btn-primary btn btn-icon"}} + {{d-button + action=(action "cancelCreate") + label="cancel" + title="cancel" + icon="times" + class="btn btn-icon"}} +
diff --git a/assets/stylesheets/common/main.scss b/assets/stylesheets/common/main.scss index 60cf202..9746656 100644 --- a/assets/stylesheets/common/main.scss +++ b/assets/stylesheets/common/main.scss @@ -22,6 +22,10 @@ textarea[readonly] { margin-right: 5px; } } + + .btn-primary.create-coupon { + margin-top: 1em; + } } .plan-amount { diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 2a7cfcb..f2eea12 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -140,6 +140,15 @@ en: active: Active created_at: Created recurring: Recurring Plan? + coupons: + title: Coupons + code: Promo Code + discount: Discount + times_redeemed: Times Redeemed + active: Active? + actions: Actions + create: Create Coupon + promo_code: Promo Code (case-insensitive) subscriptions: title: Subscriptions subscription: diff --git a/config/routes.rb b/config/routes.rb index 7120370..02115fc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -11,6 +11,8 @@ DiscourseSubscriptions::Engine.routes.draw do resources :plans resources :subscriptions, only: [:index, :destroy] resources :products + resources :coupons, only: [:index, :create] + resource :coupons, only: [:destroy, :update] end namespace :user do diff --git a/plugin.rb b/plugin.rb index 8e5f965..bcd4807 100644 --- a/plugin.rb +++ b/plugin.rb @@ -35,6 +35,7 @@ Discourse::Application.routes.append do get '/admin/plugins/discourse-subscriptions/subscriptions' => 'admin/plugins#index', constraints: AdminConstraint.new get '/admin/plugins/discourse-subscriptions/plans' => 'admin/plugins#index', constraints: AdminConstraint.new get '/admin/plugins/discourse-subscriptions/plans/:plan_id' => 'admin/plugins#index', constraints: AdminConstraint.new + get '/admin/plugins/discourse-subscriptions/coupons' => 'admin/plugins#index', constraints: AdminConstraint.new get 'u/:username/billing' => 'users#show', constraints: { username: USERNAME_ROUTE_FORMAT } get 'u/:username/billing/:id' => 'users#show', constraints: { username: USERNAME_ROUTE_FORMAT } end diff --git a/spec/requests/admin/coupons_controller_spec.rb b/spec/requests/admin/coupons_controller_spec.rb new file mode 100644 index 0000000..f0fdcef --- /dev/null +++ b/spec/requests/admin/coupons_controller_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'rails_helper' + +module DiscourseSubscriptions + RSpec.describe Admin::CouponsController do + it 'is a subclass of AdminController' do + expect(DiscourseSubscriptions::Admin::CouponsController < ::Admin::AdminController).to eq(true) + end + + context 'when unauthenticated' do + it "does nothing" do + ::Stripe::PromotionCode.expects(:list).never + get "/s/admin/coupons.json" + expect(response.status).to eq(403) + end + end + + context 'when authenticated' do + let(:admin) { Fabricate(:admin) } + + before { sign_in(admin) } + + describe "#index" do + it "returns a list of promo codes" do + ::Stripe::PromotionCode.expects(:list).with(limit: 100).returns({ + data: [{ + id: 'promo_123', + coupon: { + valid: true + } + }] + }) + + get "/s/admin/coupons.json" + expect(response.status).to eq(200) + expect(response.parsed_body[0]['id']).to eq('promo_123') + end + + it "only returns valid promo codes" do + ::Stripe::PromotionCode.expects(:list).with(limit: 100).returns({ + data: [{ + id: 'promo_123', + coupon: { + valid: false + } + }] + }) + + get "/s/admin/coupons.json" + expect(response.status).to eq(200) + expect(response.parsed_body).to be_blank + end + end + + describe "#create" do + it "creates a coupon with an amount off" do + ::Stripe::Coupon.expects(:create).returns(id: 'coup_123') + ::Stripe::PromotionCode.expects(:create).returns({ + code: 'p123', + coupon: { + amount_off: 2000 + } + }) + + post "/s/admin/coupons.json", params: { + promo: 'p123', + discount_type: 'amount', + discount: '2000', + active: true, + } + expect(response.status).to eq(200) + expect(response.parsed_body['code']).to eq('p123') + expect(response.parsed_body['coupon']['amount_off']).to eq(2000) + end + + it "creates a coupon with a percent off" do + ::Stripe::Coupon.expects(:create).returns(id: 'coup_123') + ::Stripe::PromotionCode.expects(:create).returns({ + code: 'p123', + coupon: { + percent_off: 20 + } + }) + + post "/s/admin/coupons.json", params: { + promo: 'p123', + discount_type: 'percent', + discount: '20', + active: true, + } + expect(response.status).to eq(200) + expect(response.parsed_body['code']).to eq('p123') + expect(response.parsed_body['coupon']['percent_off']).to eq(20) + end + end + end + end +end