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
109 changed files with 2698 additions and 335 deletions
+2 -5
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.
* Follow the install instructions here: https://meta.discourse.org/t/install-a-plugin/19157
* Add your Stripe public and private keys in settings and set the currency to your local value.
* Enable the plugin and wait for people to donate money.
## Usage
## Creating Subscription Plans
Enable the plugin and enter your Stripe API keys in the settings. You can also configure amounts and the default currency.
Visit `/patrons`
When users subscribe to your Discourse application, they are added to a user group. You can create new user groups or use existing ones. Of course, you should be careful what permissions you apply to the user group.
## Testing
+87
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
@@ -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
@@ -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
+1 -21
View File
@@ -3,27 +3,7 @@
module DiscoursePatrons
class AdminController < ::Admin::AdminController
def index
payments = Payment.all.order(payments_order)
render_serialized(payments, PaymentSerializer)
end
private
def payments_order
if %w(created_at amount).include?(params[:order])
{ params[:order] => ascending }
else
{ created_at: :desc }
end
end
def ascending
if params[:descending] == 'false'
:desc
else
:asc
end
head 200
end
end
end
+11
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
+28
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
+35
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
+2 -25
View File
@@ -2,6 +2,8 @@
module DiscoursePatrons
class PatronsController < ::ApplicationController
include DiscoursePatrons::Stripe
skip_before_action :verify_authenticity_token, only: [:create]
before_action :set_api_key
@@ -15,18 +17,6 @@ module DiscoursePatrons
render json: result
end
def show
payment_intent = Stripe::PaymentIntent.retrieve(params[:pid])
if current_user && (current_user.admin || payment_intent[:customer] == current_user.id)
result = payment_intent
else
result = { error: 'Not found' }
end
render json: result
end
def create
begin
@@ -41,15 +31,6 @@ module DiscoursePatrons
metadata: { user_id: user_id }
)
Payment.create(
user_id: response[:metadata][:user_id],
payment_intent_id: response[:id],
receipt_email: response[:receipt_email],
url: response[:charges][:url],
amount: response[:amount],
currency: response[:currency]
)
rescue ::Stripe::InvalidRequestError => e
response = { error: e }
rescue ::Stripe::CardError => e
@@ -61,10 +42,6 @@ module DiscoursePatrons
private
def set_api_key
::Stripe.api_key = SiteSetting.discourse_patrons_secret_key
end
def param_currency_to_number
params[:amount].to_s.sub('.', '').to_i
end
+28
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
+45
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
@@ -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
@@ -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
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
-4
View File
@@ -1,4 +0,0 @@
# frozen_string_literal: true
class Payment < ActiveRecord::Base
end
@@ -10,7 +10,7 @@ export default Ember.Component.extend({
this._super(...arguments);
const settings = Discourse.SiteSettings;
const amounts = Discourse.SiteSettings.discourse_patrons_amounts.split("|");
const amounts = settings.discourse_patrons_amounts.split("|");
this.setProperties({
confirmation: false,
@@ -0,0 +1,7 @@
export default Ember.Component.extend({
didInsertElement() {
this._super(...arguments);
this.cardElement.mount("#card-element");
},
didDestroyElement() {}
});
@@ -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}`
);
}
}
});
@@ -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);
}
}
});
@@ -0,0 +1 @@
export default Ember.Controller.extend({});
@@ -0,0 +1 @@
export default Ember.Controller.extend({});
@@ -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"))
);
}
}
});
@@ -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);
}
}
});
@@ -0,0 +1 @@
export default Ember.Controller.extend({});
@@ -1,4 +1,3 @@
import DiscourseURL from "discourse/lib/url";
import { ajax } from "discourse/lib/ajax";
export default Ember.Controller.extend({
@@ -12,8 +11,12 @@ export default Ember.Controller.extend({
});
},
paymentSuccessHandler(paymentIntentId) {
DiscourseURL.redirectTo(`patrons/${paymentIntentId}`);
paymentSuccessHandler(/* paymentIntentId */) {
bootbox.alert(I18n.t("discourse_patrons.transactions.payment.success"));
this.transitionToRoute(
"user.billing",
Discourse.User.current().username.toLowerCase()
);
}
}
});
@@ -1,9 +0,0 @@
import DiscourseURL from "discourse/lib/url";
export default Ember.Controller.extend({
actions: {
goBack() {
return DiscourseURL.redirectTo("/patrons");
}
}
});
@@ -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()
);
});
});
}
});
}
}
});
@@ -1,7 +1,20 @@
export default {
resource: "admin.adminPlugins",
path: "/plugins",
map() {
this.route("discourse-patrons");
this.route("discourse-patrons", function() {
this.route("dashboard");
this.route("products", function() {
this.route("show", { path: "/:product-id" }, function() {
this.route("plans", function() {
this.route("show", { path: "/:plan-id" });
});
});
});
this.route("subscriptions");
});
}
};
@@ -0,0 +1,8 @@
export default {
resource: "user",
path: "users/:username",
map() {
this.route("billing");
this.route("subscriptions");
}
};

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