FEATURE: Allow creation of coupons in admin panel (#43)

Adds full support to create coupon/promo codes in the Admin > Plugins > Subscriptions section of the plugin. The Create Coupon button opens a form on the same page, and the active checkboxes toggle the active status of the coupon code.
This commit is contained in:
Justin DiRose 2021-01-13 11:47:22 -06:00 committed by GitHub
parent a6de862ba9
commit 400313cded
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 422 additions and 0 deletions

View File

@ -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

View File

@ -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();
},
},
});

View File

@ -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);
},
},
});

View File

@ -14,6 +14,8 @@ export default {
});
});
this.route("coupons");
this.route("subscriptions");
});
},

View File

@ -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;

View File

@ -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();
},
},
});

View File

@ -0,0 +1,44 @@
{{#if model.unconfigured}}
<p>{{i18n 'discourse_subscriptions.admin.unconfigured'}}</p>
<p><a href="https://meta.discourse.org/t/discourse-subscriptions/140818/">Discourse Subscriptions on Meta</a></p>
{{else}}
{{#if model}}
<table class="table discourse-patrons-table">
<thead>
<th>{{i18n 'discourse_subscriptions.admin.coupons.code'}}</th>
<th>{{i18n 'discourse_subscriptions.admin.coupons.discount'}}</th>
<th>{{i18n 'discourse_subscriptions.admin.coupons.times_redeemed'}}</th>
<th>{{i18n 'discourse_subscriptions.admin.coupons.active'}}</th>
<th>{{i18n 'discourse_subscriptions.admin.coupons.actions'}}</th>
</thead>
{{#each model as |coupon|}}
<tr>
<td>{{coupon.code}}</td>
<td>{{coupon.discount}}</td>
<td>{{coupon.times_redeemed}}</td>
<td>{{input type="checkbox" checked=coupon.active click=(action "toggleActive" coupon)}}</td>
<td>
{{d-button
action=(action "deleteCoupon")
actionParam=coupon
icon="trash-alt"
class="btn-danger btn btn-icon btn-no-text"}}
</td>
</tr>
{{/each}}
</table>
{{/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}}

View File

@ -4,6 +4,7 @@
<ul class="nav nav-pills">
{{!-- {{nav-item route='adminPlugins.discourse-subscriptions.dashboard' label='discourse_subscriptions.admin.dashboard.title'}} --}}
{{nav-item route='adminPlugins.discourse-subscriptions.products' label='discourse_subscriptions.admin.products.title'}}
{{nav-item route='adminPlugins.discourse-subscriptions.coupons' label='discourse_subscriptions.admin.coupons.title'}}
{{nav-item route='adminPlugins.discourse-subscriptions.subscriptions' label='discourse_subscriptions.admin.subscriptions.title'}}
</ul>

View File

@ -0,0 +1,36 @@
<div class='create-coupon-form'>
<form class="form-horizontal">
<p>
<label for="promo_code">{{i18n 'discourse_subscriptions.admin.coupons.promo_code'}}</label>
{{input type="text" name="promo_code" value=promoCode}}
</p>
<p>
<label for="amount">{{i18n 'discourse_subscriptions.admin.coupons.discount'}}</label>
{{combo-box
content=discountTypes
value=discountType
onChange=(action (mut discountType))
}}
{{input class="discount-amount" type="text" name="amount" value=discount}}
</p>
<p>
<label for="active">
{{i18n 'discourse_subscriptions.admin.coupons.active'}}
</label>
{{input type="checkbox" name="active" checked=active}}
</p>
</form>
{{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"}}
</div>

View File

@ -22,6 +22,10 @@ textarea[readonly] {
margin-right: 5px;
}
}
.btn-primary.create-coupon {
margin-top: 1em;
}
}
.plan-amount {

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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