Merge pull request #2 from rimian/feature/subscriptions

Feature/subscriptions
This commit is contained in:
Rimian Perkins 2019-11-08 09:23:00 +11:00 committed by GitHub
commit d26a60a3e8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
109 changed files with 2698 additions and 335 deletions

View File

@ -11,13 +11,10 @@ This is a newer version of https://github.com/rimian/discourse-donations.
* Be sure your site is enforcing https. * Be sure your site is enforcing https.
* Follow the install instructions here: https://meta.discourse.org/t/install-a-plugin/19157 * 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. * 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. 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.
Visit `/patrons`
## Testing ## Testing

View File

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

View File

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

View File

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

View File

@ -3,27 +3,7 @@
module DiscoursePatrons module DiscoursePatrons
class AdminController < ::Admin::AdminController class AdminController < ::Admin::AdminController
def index def index
payments = Payment.all.order(payments_order) head 200
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
end end
end end
end end

View File

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

View File

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

View File

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

View File

@ -2,6 +2,8 @@
module DiscoursePatrons module DiscoursePatrons
class PatronsController < ::ApplicationController class PatronsController < ::ApplicationController
include DiscoursePatrons::Stripe
skip_before_action :verify_authenticity_token, only: [:create] skip_before_action :verify_authenticity_token, only: [:create]
before_action :set_api_key before_action :set_api_key
@ -15,18 +17,6 @@ module DiscoursePatrons
render json: result render json: result
end 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 def create
begin begin
@ -41,15 +31,6 @@ module DiscoursePatrons
metadata: { user_id: user_id } 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 rescue ::Stripe::InvalidRequestError => e
response = { error: e } response = { error: e }
rescue ::Stripe::CardError => e rescue ::Stripe::CardError => e
@ -61,10 +42,6 @@ module DiscoursePatrons
private private
def set_api_key
::Stripe.api_key = SiteSetting.discourse_patrons_secret_key
end
def param_currency_to_number def param_currency_to_number
params[:amount].to_s.sub('.', '').to_i params[:amount].to_s.sub('.', '').to_i
end end

View File

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

View File

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

View File

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

View File

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

15
app/models/customer.rb Normal file
View File

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

View File

@ -1,4 +0,0 @@
# frozen_string_literal: true
class Payment < ActiveRecord::Base
end

View File

@ -10,7 +10,7 @@ export default Ember.Component.extend({
this._super(...arguments); this._super(...arguments);
const settings = Discourse.SiteSettings; const settings = Discourse.SiteSettings;
const amounts = Discourse.SiteSettings.discourse_patrons_amounts.split("|"); const amounts = settings.discourse_patrons_amounts.split("|");
this.setProperties({ this.setProperties({
confirmation: false, confirmation: false,

View File

@ -0,0 +1,7 @@
export default Ember.Component.extend({
didInsertElement() {
this._super(...arguments);
this.cardElement.mount("#card-element");
},
didDestroyElement() {}
});

View File

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

View File

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

View File

@ -0,0 +1 @@
export default Ember.Controller.extend({});

View File

@ -0,0 +1 @@
export default Ember.Controller.extend({});

View File

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

View File

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

View File

@ -0,0 +1 @@
export default Ember.Controller.extend({});

View File

@ -1,4 +1,3 @@
import DiscourseURL from "discourse/lib/url";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
export default Ember.Controller.extend({ export default Ember.Controller.extend({
@ -12,8 +11,12 @@ export default Ember.Controller.extend({
}); });
}, },
paymentSuccessHandler(paymentIntentId) { paymentSuccessHandler(/* paymentIntentId */) {
DiscourseURL.redirectTo(`patrons/${paymentIntentId}`); bootbox.alert(I18n.t("discourse_patrons.transactions.payment.success"));
this.transitionToRoute(
"user.billing",
Discourse.User.current().username.toLowerCase()
);
} }
} }
}); });

View File

@ -1,9 +0,0 @@
import DiscourseURL from "discourse/lib/url";
export default Ember.Controller.extend({
actions: {
goBack() {
return DiscourseURL.redirectTo("/patrons");
}
}
});

View File

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

View File

@ -1,7 +1,20 @@
export default { export default {
resource: "admin.adminPlugins", resource: "admin.adminPlugins",
path: "/plugins", path: "/plugins",
map() { 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");
});
} }
}; };

View File

@ -0,0 +1,8 @@
export default {
resource: "user",
path: "users/:username",
map() {
this.route("billing");
this.route("subscriptions");
}
};

View File

@ -1,3 +1,4 @@
// TODO: typo in this helper name: currency not curency.
export default Ember.Helper.helper(function(params) { export default Ember.Helper.helper(function(params) {
let currencySign; let currencySign;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,7 @@
export default function() { export default function() {
this.route("patrons", function() { this.route("patrons", function() {
this.route("show", { path: ":pid" }); this.route("subscribe", function() {
this.route("show", { path: "/:subscription-id" });
});
}); });
} }

View File

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

View File

@ -0,0 +1,7 @@
import AdminPlan from "discourse/plugins/discourse-patrons/discourse/models/admin-plan";
export default Discourse.Route.extend({
model() {
return AdminPlan.findAll();
}
});

View File

@ -0,0 +1 @@
export default Discourse.Route.extend({});

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
export default Discourse.Route.extend({});

View File

@ -0,0 +1,7 @@
import AdminSubscription from "discourse/plugins/discourse-patrons/discourse/models/admin-subscription";
export default Discourse.Route.extend({
model() {
return AdminSubscription.find();
}
});

View File

@ -1,22 +1 @@
import { ajax } from "discourse/lib/ajax"; export default Discourse.Route.extend({});
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);
}
});

View File

@ -1,7 +0,0 @@
import { ajax } from "discourse/lib/ajax";
export default Discourse.Route.extend({
model(params) {
return ajax(`/patrons/${params.pid}`, { method: "get" });
}
});

View File

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

View File

@ -0,0 +1,7 @@
import Product from "discourse/plugins/discourse-patrons/discourse/models/product";
export default Discourse.Route.extend({
model() {
return Product.findAll();
}
});

View File

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

View File

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

View File

@ -0,0 +1,35 @@
<h3>{{i18n 'discourse_patrons.admin.dashboard.title'}}</h3>
{{#load-more selector=".discourse-patrons-table tr" action=(action "loadMore")}}
{{#if model}}
<table class="table discourse-patrons-table">
<thead>
<tr>
<th>{{i18n 'discourse_patrons.admin.dashboard.table.head.user'}}</th>
<th>{{i18n 'discourse_patrons.admin.dashboard.table.head.payment_intent'}}</th>
<th>{{i18n 'discourse_patrons.admin.dashboard.table.head.receipt_email'}}</th>
<th onclick={{action "orderPayments" "created_at"}} class="sortable">{{i18n 'created'}}</th>
<th class="amount" onclick={{action "orderPayments" "amount"}} class="sortable amount">{{i18n 'discourse_patrons.admin.dashboard.table.head.amount'}}</th>
</tr>
</thead>
{{#each model as |payment|}}
<tr>
<td>
{{#link-to "adminUser.index" payment.user_id payment.username}}
{{payment.username}}
{{/link-to}}
</td>
<td>
{{#link-to "patrons.show" payment.payment_intent_id}}
{{{payment.payment_intent_id}}}
{{/link-to}}
</td>
<td>{{payment.receipt_email}}</td>
<td>{{{format-duration payment.created_at_age}}}</td>
<td class="amount">{{payment.amount_currency}}</td>
</tr>
{{/each}}
</table>
{{/if}}
{{/load-more}}

View File

@ -0,0 +1,29 @@
<table class="table discourse-patrons-table">
<thead>
<th>{{i18n 'discourse_patrons.admin.plans.plan.plan_id'}}</th>
<th>{{i18n 'discourse_patrons.admin.plans.plan.nickname.title'}}</th>
<th>{{i18n 'discourse_patrons.admin.plans.plan.interval'}}</th>
<th>{{i18n 'discourse_patrons.admin.plans.plan.amount'}}</th>
<th></th>
</thead>
{{#each model as |plan|}}
<tr>
<td>{{plan.id}}</td>
<td>{{plan.nickname}}</td>
<td>{{plan.interval}}</td>
<td>{{plan.amount}}</td>
<td class="td-right">
{{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"}}
</td>
</tr>
{{/each}}
</table>

View File

@ -0,0 +1,41 @@
<p class="btn-right">
{{#link-to 'adminPlugins.discourse-patrons.products.show' 'new' class="btn btn-primary"}}
{{d-icon "plus"}}
<span>{{i18n 'discourse_patrons.admin.products.operations.new'}}</span>
{{/link-to}}
</p>
{{#if model}}
<table class="table discourse-patrons-table">
<thead>
<th>{{i18n 'discourse_patrons.admin.products.product.name'}}</th>
<th>{{i18n 'discourse_patrons.admin.products.product.created_at'}}</th>
<th>{{i18n 'discourse_patrons.admin.products.product.updated_at'}}</th>
<th class="td-right">{{i18n 'discourse_patrons.admin.products.product.active'}}</th>
<th></th>
</thead>
{{#each model as |product|}}
<tr>
<td>{{product.name}}</td>
<td>{{format-unix-date product.created}}</td>
<td>{{format-unix-date product.updated}}</td>
<td class="td-right">{{product.active}}</td>
<td class="td-right">
{{#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"}}
</td>
</tr>
{{/each}}
</table>
{{else}}
<p>
{{i18n 'discourse_patrons.admin.products.product_help'}}
</p>
{{/if}}

View File

@ -0,0 +1,68 @@
<h4>{{i18n 'discourse_patrons.admin.plans.title'}}</h4>
<form class="form-horizontal">
<p>
<label for="product">{{i18n 'discourse_patrons.admin.products.product.name'}}</label>
{{input type="text" name="product_name" value=model.product.name disabled=true}}
</p>
<p>
<label for="name">{{i18n 'discourse_patrons.admin.plans.plan.nickname'}}</label>
{{input type="text" name="name" value=model.plan.nickname}}
<div class="control-instructions">
{{i18n 'discourse_patrons.admin.plans.plan.nickname_help'}}
</div>
</p>
<p>
<label for="interval">{{i18n 'discourse_patrons.admin.plans.plan.group'}}</label>
{{combo-box valueAttribute="name" content=model.groups value=model.plan.metadata.group_name}}
<div class="control-instructions">
{{i18n 'discourse_patrons.admin.plans.plan.group_help'}}
</div>
</p>
<p>
<label for="amount">{{i18n 'discourse_patrons.admin.plans.plan.amount'}}</label>
{{input type="text" name="name" value=model.plan.amountDollars disabled=planFieldDisabled}}
</p>
<p>
<label for="trial">
{{i18n 'discourse_patrons.admin.plans.plan.trial'}}
({{i18n 'discourse_patrons.optional'}})
</label>
{{input type="text" name="trial" value=model.plan.trial_period_days}}
<div class="control-instructions">
{{i18n 'discourse_patrons.admin.plans.plan.trial_help'}}
</div>
</p>
<p>
<label for="interval">
{{i18n 'discourse_patrons.admin.plans.plan.interval'}}
</label>
{{combo-box valueAttribute="value" content=model.plan.intervals value=model.plan.interval}}
</p>
<p>
<label for="active">
{{i18n 'discourse_patrons.admin.plans.plan.active'}}
</label>
{{input type="checkbox" name="active" checked=model.plan.active}}
</p>
</form>
<section>
<hr>
<p class="control-instructions">
{{i18n 'discourse_patrons.admin.plans.operations.create_help'}}
</p>
<div class="pull-right">
{{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}}
</div>
</section>

View File

@ -0,0 +1,97 @@
<h4>{{i18n 'discourse_patrons.admin.products.title'}}</h4>
<form class="form-horizontal">
<p>
<label for="name">{{i18n 'discourse_patrons.admin.products.product.name'}}</label>
{{input type="text" name="name" value=model.product.name}}
</p>
<p>
<label for="description">
{{i18n 'discourse_patrons.admin.products.product.description'}}
</label>
{{textarea name="description" value=model.product.metadata.description class="discourse-patrons-admin-textarea"}}
<div class="control-instructions">
{{i18n 'discourse_patrons.admin.products.product.description_help'}}
</div>
</p>
<p>
<label for="statement_descriptor">
{{i18n 'discourse_patrons.admin.products.product.statement_descriptor'}}
</label>
{{input type="text" name="statement_descriptor" value=model.product.statement_descriptor}}
<div class="control-instructions">
{{i18n 'discourse_patrons.admin.products.product.statement_descriptor_help'}}
</div>
</p>
<p>
<label for="active">{{i18n 'discourse_patrons.admin.products.product.active'}}</label>
{{input type="checkbox" name="active" checked=model.product.active}}
</p>
</form>
{{#unless model.product.isNew}}
<h4>{{i18n 'discourse_patrons.admin.plans.title'}}</h4>
<p>
<table class="table discourse-patrons-table">
<thead>
<th>{{i18n 'discourse_patrons.admin.plans.plan.nickname'}}</th>
<th>{{i18n 'discourse_patrons.admin.plans.plan.interval'}}</th>
<th>{{i18n 'discourse_patrons.admin.plans.plan.created_at'}}</th>
<th>{{i18n 'discourse_patrons.admin.plans.plan.group'}}</th>
<th>{{i18n 'discourse_patrons.admin.plans.plan.active'}}</th>
<th class="td-right">{{i18n 'discourse_patrons.admin.plans.plan.amount'}}</th>
<th class="td-right">
{{#link-to "adminPlugins.discourse-patrons.products.show.plans.show" model.product.id "new" class="btn"}}
{{i18n 'discourse_patrons.admin.plans.operations.add'}}
{{/link-to}}
</th>
</thead>
{{#each model.plans as |plan|}}
<tr>
<td>{{plan.nickname}}</td>
<td>{{plan.interval}}</td>
<td>{{format-unix-date plan.created}}</td>
<td>{{plan.metadata.group_name}}</td>
<td>{{plan.active}}</td>
<td class="td-right">{{format-currency plan.currency plan.amountDollars}}</td>
<td class="td-right">
{{#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"}}
</td>
</tr>
{{/each}}
<tr>
<td colspan="7">
{{#unless model.plans}}
<hr>
{{i18n 'discourse_patrons.admin.products.product.plan_help'}}
{{/unless}}
</td>
</tr>
</table>
</p>
{{/unless}}
<section>
<hr>
<div class="pull-right">
{{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}}
</div>
</section>
{{outlet}}

View File

@ -0,0 +1,19 @@
<table class="table discourse-patrons-table">
<thead>
<tr>
<th>{{i18n 'discourse_patrons.admin.subscriptions.subscription.customer'}}</th>
<th>{{i18n 'discourse_patrons.admin.subscriptions.subscription.plan'}}</th>
<th>{{i18n 'discourse_patrons.admin.subscriptions.subscription.status'}}</th>
<th class="td-right">{{i18n 'discourse_patrons.admin.subscriptions.subscription.created_at'}}</th>
</tr>
</thead>
{{#each model as |subscription|}}
<tr>
<td>{{subscription.customer}}</td>
<td>{{subscription.plan.id}}</td>
<td>{{subscription.status}}</td>
<td class="td-right">{{format-unix-date subscription.created}}</td>
</tr>
{{/each}}
</table>

View File

@ -1,35 +1,14 @@
<h2>{{i18n 'discourse_patrons.title' site_name=siteSettings.title}}</h2> <h2>{{i18n 'discourse_patrons.title' site_name=siteSettings.title}}</h2>
{{#load-more selector=".discourse-patrons-admin tr" action=(action "loadMore")}} <ul class="nav nav-pills">
{{#if model}} {{!-- {{nav-item route='adminPlugins.discourse-patrons.dashboard' label='discourse_patrons.admin.dashboard.title'}} --}}
<table class="table discourse-patrons-admin"> {{nav-item route='adminPlugins.discourse-patrons.products' label='discourse_patrons.admin.products.title'}}
<thead> {{nav-item route='adminPlugins.discourse-patrons.subscriptions' label='discourse_patrons.admin.subscriptions.title'}}
<tr> </ul>
<th>{{i18n 'discourse_patrons.admin.payment_history.table.head.user'}}</th>
<th>{{i18n 'discourse_patrons.admin.payment_history.table.head.payment_intent'}}</th> <hr>
<th>{{i18n 'discourse_patrons.admin.payment_history.table.head.receipt_email'}}</th>
<th onclick={{action "orderPayments" "created_at"}} class="sortable">{{i18n 'created'}}</th> <div id="discourse-patrons-admin">
<th class="amount" onclick={{action "orderPayments" "amount"}} class="sortable amount">{{i18n 'discourse_patrons.admin.payment_history.table.head.amount'}}</th> {{outlet}}
</tr> </div>
</thead>
{{#each model as |payment|}}
<tr>
<td>
{{#link-to "adminUser.index" payment.user_id payment.username}}
{{payment.username}}
{{/link-to}}
</td>
<td>
{{#link-to "patrons.show" payment.payment_intent_id}}
{{{payment.payment_intent_id}}}
{{/link-to}}
</td>
<td>{{payment.receipt_email}}</td>
<td>{{{format-duration payment.created_at_age}}}</td>
<td class="amount">{{payment.amount_currency}}</td>
</tr>
{{/each}}
</table>
{{/if}}
{{/load-more}}

View File

@ -1,6 +1,6 @@
{{#if confirmation}} {{#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}} {{#d-modal-body}}
<div class="discourse-patrons-section-columns"> <div class="discourse-patrons-section-columns">
<div class="section-column discourse-patrons-confirmation-billing"> <div class="section-column discourse-patrons-confirmation-billing">
@ -51,7 +51,7 @@
{{else}} {{else}}
<div class="discourse-patrons-section-columns discourse-patrons-payment-details"> <div class="discourse-patrons-section-columns discourse-patrons-payment-details">
<div class="section-column"> <div class="section-column">
<h3>{{i18n 'discourse_patrons.payment.your_information'}}</h3> <h3>{{i18n 'discourse_patrons.one_time.payment.your_information'}}</h3>
<div class="user-controls discourse-patrons-fields discourse-patrons-billing"> <div class="user-controls discourse-patrons-fields discourse-patrons-billing">
<div class="display-row"> <div class="display-row">
@ -60,7 +60,7 @@
</div> </div>
<div class="value"> <div class="value">
{{input value=billing.name}} {{input value=billing.name}}
<div class="desc">{{i18n 'discourse_patrons.payment.optional'}}</div> <div class="desc">{{i18n 'discourse_patrons.one_time.payment.optional'}}</div>
</div> </div>
</div> </div>
<div class="display-row"> <div class="display-row">
@ -69,7 +69,7 @@
</div> </div>
<div class="value"> <div class="value">
{{input type="email" value=billing.email}} {{input type="email" value=billing.email}}
<div class="desc">{{i18n 'discourse_patrons.payment.receipt_info'}}</div> <div class="desc">{{i18n 'discourse_patrons.one_time.payment.receipt_info'}}</div>
</div> </div>
</div> </div>
<div class="display-row"> <div class="display-row">
@ -78,19 +78,19 @@
</div> </div>
<div class="value"> <div class="value">
{{input value=billing.phone}} {{input value=billing.phone}}
<div class="desc">{{i18n 'discourse_patrons.payment.optional'}}</div> <div class="desc">{{i18n 'discourse_patrons.one_time.payment.optional'}}</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="section-column"> <div class="section-column">
<h3>{{i18n 'discourse_patrons.payment.payment_information'}}</h3> <h3>{{i18n 'discourse_patrons.one_time.payment.payment_information'}}</h3>
<div class="user-controls discourse-patrons-fields"> <div class="user-controls discourse-patrons-fields">
<div class="display-row"> <div class="display-row">
<div class="field"> <div class="field">
{{i18n 'discourse_patrons.amount'}} {{i18n 'discourse_patrons.one_time.amount'}}
{{siteSettings.discourse_patrons_currency}} {{siteSettings.discourse_patrons_currency}}
</div> </div>
<div class="value"> <div class="value">

View File

@ -0,0 +1,2 @@
<div id="card-element"></div>

View File

@ -0,0 +1,5 @@
{{#if (show-extra-nav)}}
{{#link-to 'patrons.subscribe' class='discourse-patrons-subscribe'}}
{{i18n 'discourse_patrons.navigation.subscribe'}}
{{/link-to}}
{{/if}}

View File

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

View File

@ -1,5 +1,5 @@
<h3>{{i18n 'discourse_patrons.heading.payment' site_name=siteSettings.title}}</h3> <h3>{{i18n 'discourse_patrons.one_time.heading.payment' site_name=siteSettings.title}}</h3>
<p> <p>
{{cook-text siteSettings.discourse_patrons_payment_page}} {{cook-text siteSettings.discourse_patrons_payment_page}}

View File

@ -1,19 +0,0 @@
{{#unless model.error}}
<h3>{{i18n 'discourse_patrons.heading.success' site_name=siteSettings.title}}</h3>
<p>
{{cook-text siteSettings.discourse_patrons_success_page}}
</p>
<table>
<tr>
<td>{{i18n 'discourse_patrons.payment_intent_id'}}</td>
<td>{{model.id}}</td>
</tr>
<tr>
<td>{{i18n 'discourse_patrons.amount'}}</td>
<td>{{model.amount}}</td>
</tr>
</table>
{{/unless}}

View File

@ -0,0 +1,12 @@
<div class="container">
<div class="title-wrapper">
<h1>
{{i18n 'discourse_patrons.subscribe.title'}}
</h1>
</div>
<hr>
{{outlet}}
</div>

View File

@ -0,0 +1,17 @@
{{#each model as |product|}}
<div>
<h2>{{product.name}}</h2>
<p>
{{product.description}}
</p>
<div class="pull-right">
{{#link-to "patrons.subscribe.show" product.id class="btn btn-primary"}}
{{i18n 'discourse_patrons.subscribe.title'}}
{{/link-to}}
</div>
</div>
{{/each}}

View File

@ -0,0 +1,32 @@
<div class="discourse-patrons-section-columns">
<div class="section-column discourse-patrons-confirmation-billing">
<h2>
{{model.product.name}}
</h2>
<p>
{{model.product.description}}
</p>
</div>
<div class="section-column">
{{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}}
<hr>
<h4>{{i18n 'discourse_patrons.subscribe.card.title'}}</h4>
{{subscribe-card cardElement=cardElement}}
{{!-- <div id="discourse-patrons-subscribe-customer">
<h4>{{i18n 'discourse_patrons.subscribe.customer.title'}}</h4>
<div class="discourse-patrons-subscribe-customer-empty">
{{i18n 'discourse_patrons.subscribe.customer.empty'}}
</div>
</div> --}}
</div>
</div>

View File

@ -0,0 +1,27 @@
<h3>{{i18n 'discourse_patrons.user.billing.title'}}</h3>
{{#if model}}
<table class="topic-list">
<thead>
<th>{{i18n 'discourse_patrons.user.billing.invoices.amount'}}</th>
<th>{{i18n 'discourse_patrons.user.billing.invoices.number'}}</th>
<th>{{i18n 'discourse_patrons.user.billing.invoices.created_at'}}</th>
<th></th>
</thead>
{{#each model as |invoice|}}
<tr>
<td>{{invoice.amount_paid}}</td>
<td>{{invoice.number}}</td>
<td>{{format-unix-date invoice.created}}</td>
<td class="td-right">
<a href="{{invoice.invoice_pdf}}" class="btn btn-icon">
{{d-icon "download"}}
</a>
</td>
</tr>
{{/each}}
</table>
{{else}}
<p>{{i18n 'discourse_patrons.user.billing_help'}}</p>
{{/if}}

View File

@ -0,0 +1,27 @@
<h3>{{i18n 'discourse_patrons.user.billing.title'}}</h3>
{{#if model}}
<table class="topic-list">
<thead>
<th>{{i18n 'discourse_patrons.user.billing.invoices.amount'}}</th>
<th>{{i18n 'discourse_patrons.user.billing.invoices.number'}}</th>
<th>{{i18n 'discourse_patrons.user.billing.invoices.created_at'}}</th>
<th></th>
</thead>
{{#each model as |invoice|}}
<tr>
<td>{{invoice.amount_paid}}</td>
<td>{{invoice.number}}</td>
<td>{{format-unix-date invoice.created}}</td>
<td class="td-right">
<a href="{{invoice.invoice_pdf}}" class="btn btn-icon">
{{d-icon "download"}}
</a>
</td>
</tr>
{{/each}}
</table>
{{else}}
<p>{{i18n 'discourse_patrons.user.billing_help'}}</p>
{{/if}}

View File

@ -0,0 +1,25 @@
{{i18n 'discourse_patrons.user.subscriptions.title'}}
{{#if model}}
<table class="table discourse-patrons-user-table">
<thead>
<th>{{i18n 'discourse_patrons.user.subscriptions.id'}}</th>
<th>{{i18n 'discourse_patrons.user.plans.rate'}}</th>
<th>{{i18n 'discourse_patrons.user.subscriptions.status'}}</th>
<th>{{i18n 'discourse_patrons.user.subscriptions.created_at'}}</th>
<th></th>
</thead>
{{#each model as |subscription|}}
<tr>
<td>{{subscription.id}}</td>
<td>{{subscription.plan.subscriptionRate}}</td>
<td>{{subscription.status}}</td>
<td>{{format-unix-date subscription.created}}</td>
<td class="td-right">{{d-button disabled=subscription.canceled label="cancel" action=(route-action "cancelSubscription" subscription) icon="times"}}</td>
</tr>
{{/each}}
</table>
{{else}}
<p>{{i18n 'discourse_patrons.user.subscriptions_help'}}</p>
{{/if}}

View File

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

View File

@ -1,40 +1,30 @@
.discourse-patrons-section-columns { // TODO: This gets overridden somewhere. It is defined in common/base/discourse.scss
display: flex; input[disabled],
justify-content: space-between; input[readonly],
select[disabled],
select[readonly],
textarea[disabled],
textarea[readonly] {
cursor: not-allowed;
background-color: #e9e9e9;
border-color: #e9e9e9;
}
@include breakpoint(medium) { #discourse-patrons-admin {
flex-direction: column; .btn-right {
} text-align: right;
.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 { .td-right {
.amount { text-align: right;
text-align: right; }
table.discourse-patrons-user-table {
width: 100%;
th,
td {
padding: 10px;
} }
} }
@ -43,6 +33,10 @@
font-size: 0.8em; font-size: 0.8em;
} }
.discourse-patrons-admin-textarea {
width: 80%;
}
#stripe-elements { #stripe-elements {
border: 1px $primary-low-mid solid; border: 1px $primary-low-mid solid;
padding: 10px; padding: 10px;

View File

@ -1,8 +0,0 @@
.donations-category-header .donations-category-metadata {
flex-flow: wrap;
padding: 0 10px;
div {
padding-bottom: 10px;
}
}

View File

@ -1,31 +1,62 @@
en: en:
site_settings: site_settings:
discourse_patrons_enabled: "Enable the Discourse Patrons plugin." discourse_patrons_enabled: Enable the Discourse Patrons plugin.
discourse_patrons_secret_key: "Stripe Secret Key" discourse_patrons_extra_nav_subscribe: Show the subscribe button in the primary navigation
discourse_patrons_public_key: "Stripe Public Key" discourse_patrons_secret_key: Stripe Secret Key
discourse_patrons_currency: "Currency Code" 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_zip_code: "Show Zip Code"
discourse_patrons_billing_address: "Collect billing address" discourse_patrons_billing_address: "Collect billing address"
discourse_patrons_payment_page: "Text to be added to enter payments page. Markdown is supported." 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_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_payment_description: "This is sent to Stripe and shows in the payment information"
discourse_patrons_amounts: "Payment amounts a user can select"
errors: errors:
discourse_patrons_amount_must_be_currency: "Currency amounts must be currencies without dollar symbol (eg 1.50)" discourse_patrons_amount_must_be_currency: "Currency amounts must be currencies without dollar symbol (eg 1.50)"
js: js:
discourse_patrons: discourse_patrons:
title: Discourse Patrons title: Discourse Patrons
nav_item: Payment optional: Optional
heading: transactions:
payment: Make a Payment payment:
success: Thank you! success: Your payment was successful
payment: navigation:
optional: Optional subscriptions: Subscriptions
receipt_info: A receipt is sent to this email address subscribe: Subscribe
your_information: Your information billing: Billing
payment_information: Payment information user:
payment_confirmation: Confirm information plans:
amount: Amount rate: Rate
payment_intent_id: Payment ID 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: billing:
name: Full name name: Full name
email: Email email: Email
@ -38,10 +69,62 @@ en:
confirm_payment: Confirm payment confirm_payment: Confirm payment
success: Go back success: Go back
admin: admin:
payment_history: dashboard:
title: Dashboard
table: table:
head: head:
user: User user: User
payment_intent: Payment ID payment_intent: Payment ID
receipt_email: Receipt Email receipt_email: Receipt Email
amount: Amount 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 customers 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

View File

@ -1,9 +1,29 @@
# frozen_string_literal: true # frozen_string_literal: true
DiscoursePatrons::Engine.routes.draw do DiscoursePatrons::Engine.routes.draw do
get '/admin' => 'admin#index' # TODO: namespace this
get '/' => 'patrons#index' scope 'admin' do
get '/:pid' => 'patrons#show' 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 :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 end

View File

@ -1,6 +1,9 @@
plugins: plugins:
discourse_patrons_enabled: discourse_patrons_enabled:
default: false default: false
discourse_patrons_extra_nav_subscribe:
default: false
client: true
discourse_patrons_public_key: discourse_patrons_public_key:
default: '' default: ''
client: true client: true

View File

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

View File

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

View File

@ -1,8 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
# name: discourse-patrons # name: discourse-patrons
# about: Integrates Stripe into Discourse to allow visitors to make payments # about: Integrates Stripe into Discourse to allow visitors to make payments and Subscribe
# version: 1.3.1 # version: 2.0.0
# url: https://github.com/rimian/discourse-patrons # url: https://github.com/rimian/discourse-patrons
# authors: Rimian Perkins # authors: Rimian Perkins
@ -11,7 +11,9 @@ enabled_site_setting :discourse_patrons_enabled
gem 'stripe', '5.8.0' gem 'stripe', '5.8.0'
register_asset "stylesheets/common/discourse-patrons.scss" register_asset "stylesheets/common/discourse-patrons.scss"
register_asset "stylesheets/common/discourse-patrons-layout.scss"
register_asset "stylesheets/mobile/discourse-patrons.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 register_html_builder('server:before-head-close') do
"<script src='https://js.stripe.com/v3/'></script>" "<script src='https://js.stripe.com/v3/'></script>"
@ -21,22 +23,42 @@ extend_content_security_policy(
script_src: ['https://js.stripe.com/v3/'] 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 Discourse::Application.routes.append do
get '/admin/plugins/discourse-patrons' => 'admin/plugins#index' 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 end
after_initialize do after_initialize do
::Stripe.api_version = "2019-11-05" ::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", "../lib/discourse_patrons/engine",
"../config/routes", "../config/routes",
"../app/controllers/concerns/stripe",
"../app/controllers/admin_controller", "../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/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", "../app/serializers/payment_serializer",
].each { |path| require File.expand_path(path, __FILE__) } ].each { |path| require File.expand_path(path, __FILE__) }

View File

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

View File

@ -27,54 +27,6 @@ module DiscoursePatrons
end end
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 describe 'create' do
let!(:current_user) { Fabricate(:user) } let!(:current_user) { Fabricate(:user) }
@ -101,14 +53,6 @@ module DiscoursePatrons
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
end 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 it 'has no user' do
controller.stubs(:current_user).returns(nil) controller.stubs(:current_user).returns(nil)
::Stripe::PaymentIntent.expects(:create).returns(payment) ::Stripe::PaymentIntent.expects(:create).returns(payment)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More