FEATURE: Allow one-time purchases on products (#18)
Building off the foundation of using the Prices API, this PR adds the ability to create a one-time purchase plan for any product, which then can add a user to the specified plan group. Some things to be aware of: One-time purchases cannot have trials. One-time purchases use the Invoice API instead of Subscriptions. Invoices are created then charged immediately. Users should receive emails for these invoices directly from Stripe just like subscriptions.
This commit is contained in:
parent
fcc90e4fcc
commit
587661fafb
|
@ -12,7 +12,6 @@ module DiscourseSubscriptions
|
|||
plans = ::Stripe::Price.list(product_params)
|
||||
|
||||
render_json_dump plans.data
|
||||
|
||||
rescue ::Stripe::InvalidRequestError => e
|
||||
render_json_error e.message
|
||||
end
|
||||
|
@ -20,12 +19,9 @@ module DiscourseSubscriptions
|
|||
|
||||
def create
|
||||
begin
|
||||
plan = ::Stripe::Price.create(
|
||||
price_object = {
|
||||
nickname: params[:nickname],
|
||||
unit_amount: params[:amount],
|
||||
recurring: {
|
||||
interval: params[:interval],
|
||||
},
|
||||
product: params[:product],
|
||||
currency: params[:currency],
|
||||
active: params[:active],
|
||||
|
@ -33,10 +29,17 @@ module DiscourseSubscriptions
|
|||
group_name: params[:metadata][:group_name],
|
||||
trial_period_days: params[:trial_period_days]
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if params[:type] == 'recurring'
|
||||
price_object[:recurring] = {
|
||||
interval: params[:interval]
|
||||
}
|
||||
end
|
||||
|
||||
plan = ::Stripe::Price.create(price_object)
|
||||
|
||||
render_json_dump plan
|
||||
|
||||
rescue ::Stripe::InvalidRequestError => e
|
||||
render_json_error e.message
|
||||
end
|
||||
|
@ -74,7 +77,6 @@ module DiscourseSubscriptions
|
|||
)
|
||||
|
||||
render_json_dump plan
|
||||
|
||||
rescue ::Stripe::InvalidRequestError => e
|
||||
render_json_error e.message
|
||||
end
|
||||
|
|
|
@ -12,7 +12,6 @@ module DiscourseSubscriptions
|
|||
webhook_secret = SiteSetting.discourse_subscriptions_webhook_secret
|
||||
|
||||
event = ::Stripe::Webhook.construct_event(payload, sig_header, webhook_secret)
|
||||
|
||||
rescue JSON::ParserError => e
|
||||
render_json_error e.message
|
||||
return
|
||||
|
|
|
@ -1,35 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseSubscriptions
|
||||
class InvoicesController < ::ApplicationController
|
||||
include DiscourseSubscriptions::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
|
||||
render_json_error e.message
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def viewing_own_invoices
|
||||
current_user.id == params[:user_id].to_i
|
||||
end
|
||||
|
||||
def find_customer
|
||||
Customer.find_user(current_user)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,40 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseSubscriptions
|
||||
class PaymentsController < ::ApplicationController
|
||||
include DiscourseSubscriptions::Stripe
|
||||
|
||||
skip_before_action :verify_authenticity_token, only: [:create]
|
||||
before_action :set_api_key
|
||||
|
||||
requires_login
|
||||
|
||||
def create
|
||||
begin
|
||||
customer = Customer.where(user_id: current_user.id, product_id: nil).first_or_create do |c|
|
||||
new_customer = ::Stripe::Customer.create(
|
||||
email: current_user.email
|
||||
)
|
||||
|
||||
c.customer_id = new_customer[:id]
|
||||
end
|
||||
|
||||
payment = ::Stripe::PaymentIntent.create(
|
||||
payment_method_types: ['card'],
|
||||
payment_method: params[:payment_method],
|
||||
amount: params[:amount],
|
||||
currency: params[:currency],
|
||||
customer: customer[:customer_id],
|
||||
confirm: true
|
||||
)
|
||||
|
||||
render_json_dump payment
|
||||
|
||||
rescue ::Stripe::InvalidRequestError => e
|
||||
render_json_error e.message
|
||||
rescue ::Stripe::CardError => e
|
||||
render_json_error I18n.t('discourse_subscriptions.card.declined')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -15,11 +15,10 @@ module DiscourseSubscriptions
|
|||
end
|
||||
|
||||
serialized = plans[:data].map do |plan|
|
||||
plan.to_h.slice(:id, :unit_amount, :currency, :recurring)
|
||||
plan.to_h.slice(:id, :unit_amount, :currency, :type, :recurring)
|
||||
end.sort_by { |plan| plan[:amount] }
|
||||
|
||||
render_json_dump serialized
|
||||
|
||||
rescue ::Stripe::InvalidRequestError => e
|
||||
render_json_error e.message
|
||||
end
|
||||
|
|
|
@ -19,7 +19,6 @@ module DiscourseSubscriptions
|
|||
end
|
||||
|
||||
render_json_dump subscriptions
|
||||
|
||||
rescue ::Stripe::InvalidRequestError => e
|
||||
render_json_error e.message
|
||||
end
|
||||
|
@ -29,36 +28,48 @@ module DiscourseSubscriptions
|
|||
begin
|
||||
plan = ::Stripe::Price.retrieve(params[:plan])
|
||||
|
||||
if plan[:metadata] && plan[:metadata][:trial_period_days]
|
||||
trial_days = plan[:metadata][:trial_period_days]
|
||||
recurring_plan = plan[:type] == 'recurring'
|
||||
|
||||
if recurring_plan
|
||||
trial_days = plan[:metadata][:trial_period_days] if plan[:metadata] && plan[:metadata][:trial_period_days]
|
||||
|
||||
transaction = ::Stripe::Subscription.create(
|
||||
customer: params[:customer],
|
||||
items: [{ price: params[:plan] }],
|
||||
metadata: metadata_user,
|
||||
trial_period_days: trial_days
|
||||
)
|
||||
else
|
||||
invoice_item = ::Stripe::InvoiceItem.create(
|
||||
customer: params[:customer],
|
||||
price: params[:plan]
|
||||
)
|
||||
invoice = ::Stripe::Invoice.create(
|
||||
customer: params[:customer]
|
||||
)
|
||||
transaction = ::Stripe::Invoice.pay(invoice[:id])
|
||||
end
|
||||
|
||||
@subscription = ::Stripe::Subscription.create(
|
||||
customer: params[:customer],
|
||||
items: [ { price: params[:plan] } ],
|
||||
metadata: metadata_user,
|
||||
trial_period_days: trial_days
|
||||
)
|
||||
if transaction_ok(transaction)
|
||||
group = plan_group(plan)
|
||||
|
||||
group = plan_group(plan)
|
||||
group.add(current_user) if group
|
||||
|
||||
if subscription_ok && group
|
||||
group.add(current_user)
|
||||
customer = Customer.create(
|
||||
user_id: current_user.id,
|
||||
customer_id: params[:customer],
|
||||
product_id: plan[:product]
|
||||
)
|
||||
|
||||
if transaction[:object] == 'subscription'
|
||||
Subscription.create(
|
||||
customer_id: customer.id,
|
||||
external_id: transaction[:id]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
customer = Customer.create(
|
||||
user_id: current_user.id,
|
||||
customer_id: params[:customer],
|
||||
product_id: plan[:product]
|
||||
)
|
||||
|
||||
Subscription.create(
|
||||
customer_id: customer.id,
|
||||
external_id: @subscription[:id]
|
||||
)
|
||||
|
||||
render_json_dump @subscription
|
||||
|
||||
render_json_dump transaction
|
||||
rescue ::Stripe::InvalidRequestError => e
|
||||
render_json_error e.message
|
||||
end
|
||||
|
@ -70,8 +81,8 @@ module DiscourseSubscriptions
|
|||
{ user_id: current_user.id, username: current_user.username_lower }
|
||||
end
|
||||
|
||||
def subscription_ok
|
||||
['active', 'trialing'].include?(@subscription[:status])
|
||||
def transaction_ok(transaction)
|
||||
%w[active trialing paid].include?(transaction[:status])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -21,9 +21,10 @@ module DiscourseSubscriptions
|
|||
all_invoices = ::Stripe::Invoice.list(customer: customer_id)
|
||||
invoices_with_products = all_invoices[:data].select do |invoice|
|
||||
# i cannot dig it so we must get iffy with it
|
||||
if invoice[:lines] && invoice[:lines][:data] && invoice[:lines][:data][0] && invoice[:lines][:data][0][:plan] && invoice[:lines][:data][0][:plan][:product]
|
||||
product_ids.include?(invoice[:lines][:data][0][:plan][:product])
|
||||
end
|
||||
invoice_lines = invoice[:lines][:data][0] if invoice[:lines] && invoice[:lines][:data]
|
||||
invoice_product_id = invoice_lines[:price][:product] if invoice_lines[:price] && invoice_lines[:price][:product]
|
||||
invoice_product_id = invoice_lines[:plan][:product] if invoice_lines[:plan] && invoice_lines[:plan][:product]
|
||||
product_ids.include?(invoice_product_id)
|
||||
end
|
||||
invoice_ids = invoices_with_products.map { |invoice| invoice[:id] }
|
||||
payments = ::Stripe::PaymentIntent.list(customer: customer_id)
|
||||
|
|
|
@ -1,22 +1,9 @@
|
|||
import { equal } from "@ember/object/computed";
|
||||
import Component from "@ember/component";
|
||||
|
||||
export default Component.extend({
|
||||
planButtonSelected: equal("planTypeIsSelected", true),
|
||||
paymentButtonSelected: equal("planTypeIsSelected", false),
|
||||
|
||||
actions: {
|
||||
selectPlans() {
|
||||
this.set("planTypeIsSelected", true);
|
||||
},
|
||||
|
||||
selectPayments() {
|
||||
this.set("planTypeIsSelected", false);
|
||||
},
|
||||
|
||||
clickPlan(plan) {
|
||||
this.plans.map(p => p.set("selected", false));
|
||||
plan.set("selected", true);
|
||||
this.set("selectedPlan", plan.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
import Component from "@ember/component";
|
||||
import discourseComputed from "discourse-common/utils/decorators";
|
||||
|
||||
const RECURRING = "recurring";
|
||||
|
||||
export default Component.extend({
|
||||
@discourseComputed("selectedPlan")
|
||||
selected(planId) {
|
||||
return planId === this.plan.id;
|
||||
},
|
||||
|
||||
@discourseComputed("plan.type")
|
||||
recurringPlan(type) {
|
||||
return type === RECURRING;
|
||||
},
|
||||
|
||||
actions: {
|
||||
planClick() {
|
||||
this.clickPlan(this.plan);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
|
@ -2,6 +2,9 @@ import discourseComputed from "discourse-common/utils/decorators";
|
|||
import DiscourseURL from "discourse/lib/url";
|
||||
import Controller from "@ember/controller";
|
||||
|
||||
const RECURRING = "recurring";
|
||||
const ONE_TIME = "one_time";
|
||||
|
||||
export default Controller.extend({
|
||||
// Also defined in settings.
|
||||
selectedCurrency: Ember.computed.alias("model.plan.currency"),
|
||||
|
@ -47,6 +50,12 @@ export default Controller.extend({
|
|||
},
|
||||
|
||||
actions: {
|
||||
changeRecurring() {
|
||||
const recurring = this.get("model.plan.isRecurring");
|
||||
this.set("model.plan.type", recurring ? ONE_TIME : RECURRING);
|
||||
this.set("model.plan.isRecurring", !recurring);
|
||||
},
|
||||
|
||||
createPlan() {
|
||||
// TODO: set default group name beforehand
|
||||
if (this.get("model.plan.metadata.group_name") === undefined) {
|
||||
|
|
|
@ -1,29 +1,13 @@
|
|||
import Controller from "@ember/controller";
|
||||
import Customer from "discourse/plugins/discourse-subscriptions/discourse/models/customer";
|
||||
import Payment from "discourse/plugins/discourse-subscriptions/discourse/models/payment";
|
||||
import Subscription from "discourse/plugins/discourse-subscriptions/discourse/models/subscription";
|
||||
import discourseComputed from "discourse-common/utils/decorators";
|
||||
import I18n from "I18n";
|
||||
|
||||
export default Controller.extend({
|
||||
planTypeIsSelected: true,
|
||||
|
||||
@discourseComputed("planTypeIsSelected")
|
||||
type(planTypeIsSelected) {
|
||||
return planTypeIsSelected ? "plans" : "payment";
|
||||
},
|
||||
|
||||
@discourseComputed("type")
|
||||
buttonText(type) {
|
||||
return I18n.t(`discourse_subscriptions.${type}.payment_button`);
|
||||
},
|
||||
selectedPlan: null,
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
this.set(
|
||||
"paymentsAllowed",
|
||||
Discourse.SiteSettings.discourse_subscriptions_allow_payments
|
||||
);
|
||||
this.set(
|
||||
"stripe",
|
||||
Stripe(Discourse.SiteSettings.discourse_subscriptions_public_key)
|
||||
|
@ -37,20 +21,6 @@ export default Controller.extend({
|
|||
bootbox.alert(I18n.t(`discourse_subscriptions.${path}`));
|
||||
},
|
||||
|
||||
createPayment(plan) {
|
||||
return this.stripe
|
||||
.createPaymentMethod("card", this.get("cardElement"))
|
||||
.then(result => {
|
||||
const payment = Payment.create({
|
||||
payment_method: result.paymentMethod.id,
|
||||
amount: plan.get("amount"),
|
||||
currency: plan.get("currency")
|
||||
});
|
||||
|
||||
return payment.save();
|
||||
});
|
||||
},
|
||||
|
||||
createSubscription(plan) {
|
||||
return this.stripe.createToken(this.get("cardElement")).then(result => {
|
||||
if (result.error) {
|
||||
|
@ -73,24 +43,17 @@ export default Controller.extend({
|
|||
actions: {
|
||||
stripePaymentHandler() {
|
||||
this.set("loading", true);
|
||||
const type = this.get("type");
|
||||
const plan = this.get("model.plans")
|
||||
.filterBy("selected")
|
||||
.filterBy("id", this.selectedPlan)
|
||||
.get("firstObject");
|
||||
|
||||
if (!plan) {
|
||||
this.alert(`${type}.validate.payment_options.required`);
|
||||
this.alert("plans.validate.payment_options.required");
|
||||
this.set("loading", false);
|
||||
return;
|
||||
}
|
||||
|
||||
let transaction;
|
||||
|
||||
if (this.planTypeIsSelected) {
|
||||
transaction = this.createSubscription(plan);
|
||||
} else {
|
||||
transaction = this.createPayment(plan);
|
||||
}
|
||||
let transaction = this.createSubscription(plan);
|
||||
|
||||
transaction
|
||||
.then(result => {
|
||||
|
@ -98,17 +61,15 @@ export default Controller.extend({
|
|||
bootbox.alert(result.error.message || result.error);
|
||||
} else {
|
||||
if (result.status === "incomplete") {
|
||||
this.alert(`${type}.incomplete`);
|
||||
this.alert("plans.incomplete");
|
||||
} else {
|
||||
this.alert(`${type}.success`);
|
||||
this.alert("plans.success");
|
||||
}
|
||||
|
||||
const success_route = this.planTypeIsSelected
|
||||
? "user.billing.subscriptions"
|
||||
: "user.billing.payments";
|
||||
|
||||
this.transitionToRoute(
|
||||
success_route,
|
||||
plan.type === "recurring"
|
||||
? "user.billing.subscriptions"
|
||||
: "user.billing.payments",
|
||||
Discourse.User.current().username.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
|
|
@ -26,6 +26,7 @@ const AdminPlan = Plan.extend({
|
|||
amount: this.unit_amount,
|
||||
currency: this.currency,
|
||||
trial_period_days: this.parseTrialPeriodDays,
|
||||
type: this.type,
|
||||
product: this.product,
|
||||
metadata: this.metadata,
|
||||
active: this.active
|
||||
|
|
|
@ -1,14 +0,0 @@
|
|||
import { ajax } from "discourse/lib/ajax";
|
||||
import EmberObject from "@ember/object";
|
||||
|
||||
const Invoice = EmberObject.extend({});
|
||||
|
||||
Invoice.reopenClass({
|
||||
findAll() {
|
||||
return ajax("/s/invoices", { method: "get" }).then(result =>
|
||||
result.map(invoice => Invoice.create(invoice))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default Invoice;
|
|
@ -1,16 +0,0 @@
|
|||
import EmberObject from "@ember/object";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
|
||||
const Payment = EmberObject.extend({
|
||||
save() {
|
||||
const data = {
|
||||
payment_method: this.payment_method,
|
||||
amount: this.amount,
|
||||
currency: this.currency
|
||||
};
|
||||
|
||||
return ajax("/s/payments", { method: "post", data });
|
||||
}
|
||||
});
|
||||
|
||||
export default Payment;
|
|
@ -16,11 +16,14 @@ export default Route.extend({
|
|||
active: true,
|
||||
isNew: true,
|
||||
interval: "month",
|
||||
type: "recurring",
|
||||
isRecurring: true,
|
||||
currency: Discourse.SiteSettings.discourse_subscriptions_currency,
|
||||
product: product.get("id")
|
||||
});
|
||||
} else {
|
||||
plan = AdminPlan.find(id);
|
||||
plan.isRecurring = plan.type === "recurring";
|
||||
}
|
||||
|
||||
const groups = Group.findAll({ ignore_automatic: true });
|
||||
|
|
|
@ -40,30 +40,47 @@
|
|||
{{input class="plan-amount" type="text" name="name" value=model.plan.amountDollars disabled=planFieldDisabled}}
|
||||
</p>
|
||||
<p>
|
||||
<label for="trial">
|
||||
{{i18n 'discourse_subscriptions.admin.plans.plan.trial'}}
|
||||
({{i18n 'discourse_subscriptions.optional'}})
|
||||
</label>
|
||||
{{input type="text" name="trial" value=model.plan.trial_period_days}}
|
||||
<div class="control-instructions">
|
||||
{{i18n 'discourse_subscriptions.admin.plans.plan.trial_help'}}
|
||||
</div>
|
||||
</p>
|
||||
<p>
|
||||
<label for="interval">
|
||||
{{i18n 'discourse_subscriptions.admin.plans.plan.interval'}}
|
||||
<label for="recurring">
|
||||
{{i18n 'discourse_subscriptions.admin.plans.plan.recurring'}}
|
||||
</label>
|
||||
{{#if planFieldDisabled}}
|
||||
{{input disabled=true value=selectedInterval}}
|
||||
{{input disabled=true value=model.plan.isRecurring}}
|
||||
{{else}}
|
||||
{{combo-box
|
||||
valueProperty="name"
|
||||
content=availableIntervals
|
||||
value=selectedInterval
|
||||
onChange=(action (mut selectedInterval))
|
||||
{{input
|
||||
type="checkbox"
|
||||
name="recurring"
|
||||
checked=model.plan.isRecurring
|
||||
change=(action 'changeRecurring')
|
||||
}}
|
||||
{{/if}}
|
||||
</p>
|
||||
{{#if model.plan.isRecurring}}
|
||||
<p>
|
||||
<label for="interval">
|
||||
{{i18n 'discourse_subscriptions.admin.plans.plan.interval'}}
|
||||
</label>
|
||||
{{#if planFieldDisabled}}
|
||||
{{input disabled=true value=selectedInterval}}
|
||||
{{else}}
|
||||
{{combo-box
|
||||
valueProperty="name"
|
||||
content=availableIntervals
|
||||
value=selectedInterval
|
||||
onChange=(action (mut selectedInterval))
|
||||
}}
|
||||
{{/if}}
|
||||
</p>
|
||||
<p>
|
||||
<label for="trial">
|
||||
{{i18n 'discourse_subscriptions.admin.plans.plan.trial'}}
|
||||
({{i18n 'discourse_subscriptions.optional'}})
|
||||
</label>
|
||||
{{input type="text" name="trial" value=model.plan.trial_period_days}}
|
||||
<div class="control-instructions">
|
||||
{{i18n 'discourse_subscriptions.admin.plans.plan.trial_help'}}
|
||||
</div>
|
||||
</p>
|
||||
{{/if}}
|
||||
<p>
|
||||
<label for="active">
|
||||
{{i18n 'discourse_subscriptions.admin.plans.plan.active'}}
|
||||
|
|
|
@ -53,7 +53,7 @@
|
|||
{{#each model.plans as |plan|}}
|
||||
<tr>
|
||||
<td>{{plan.nickname}}</td>
|
||||
<td>{{plan.interval}}</td>
|
||||
<td>{{plan.recurring.interval}}</td>
|
||||
<td>{{format-unix-date plan.created}}</td>
|
||||
<td>{{plan.metadata.group_name}}</td>
|
||||
<td>{{plan.active}}</td>
|
||||
|
|
|
@ -1,54 +1,9 @@
|
|||
|
||||
{{#if paymentsAllowed}}
|
||||
<div class="subscribe-buttons">
|
||||
{{#ds-button
|
||||
id="discourse-subscriptions-payment-type-plan"
|
||||
selected=planButtonSelected
|
||||
action="selectPlans"
|
||||
class="btn-discourse-subscriptions-payment-type"
|
||||
}}
|
||||
{{i18n "discourse_subscriptions.plans.purchase"}}
|
||||
{{/ds-button}}
|
||||
|
||||
{{#ds-button
|
||||
id="discourse-subscriptions-payment-type-payment"
|
||||
selected=paymentButtonSelected
|
||||
action="selectPayments"
|
||||
class="btn-discourse-subscriptions-payment-type"
|
||||
}}
|
||||
{{i18n "discourse_subscriptions.payment.purchase"}}
|
||||
{{/ds-button}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<hr>
|
||||
|
||||
<p>
|
||||
{{#if planTypeIsSelected}}
|
||||
{{i18n "discourse_subscriptions.plans.select"}}
|
||||
{{else}}
|
||||
{{i18n "discourse_subscriptions.payment.select"}}
|
||||
{{/if}}
|
||||
{{i18n "discourse_subscriptions.plans.select"}}
|
||||
</p>
|
||||
|
||||
<div class="subscribe-buttons">
|
||||
{{#each plans as |plan|}}
|
||||
{{#ds-button
|
||||
action="clickPlan"
|
||||
actionParam=plan
|
||||
selected=plan.selected
|
||||
class="btn-discourse-subscriptions-subscribe"
|
||||
}}
|
||||
<div class="interval">
|
||||
{{#if planTypeIsSelected}}
|
||||
{{i18n (concat "discourse_subscriptions.plans.interval.adverb." plan.recurring.interval)}}
|
||||
{{else}}
|
||||
{{i18n "discourse_subscriptions.payment.interval"}}
|
||||
{{/if}}
|
||||
</div>
|
||||
<span class="amount">
|
||||
{{format-currency plan.currency plan.amountDollars}}
|
||||
</span>
|
||||
{{/ds-button}}
|
||||
{{payment-plan plan=plan selectedPlan=selectedPlan clickPlan=(action "clickPlan")}}
|
||||
{{/each}}
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
{{#ds-button
|
||||
action="planClick"
|
||||
selected=selected
|
||||
class="btn-discourse-subscriptions-subscribe"
|
||||
}}
|
||||
<div class="interval">
|
||||
{{#if recurringPlan}}
|
||||
{{i18n (concat "discourse_subscriptions.plans.interval.adverb." plan.recurring.interval)}}
|
||||
{{else}}
|
||||
{{i18n "discourse_subscriptions.one_time_payment"}}
|
||||
{{/if}}
|
||||
</div>
|
||||
<span class="amount">
|
||||
{{format-currency plan.currency plan.amountDollars}}
|
||||
</span>
|
||||
{{/ds-button}}
|
|
@ -21,8 +21,7 @@
|
|||
|
||||
{{payment-options
|
||||
plans=model.plans
|
||||
paymentsAllowed=paymentsAllowed
|
||||
planTypeIsSelected=planTypeIsSelected
|
||||
selectedPlan=selectedPlan
|
||||
}}
|
||||
|
||||
<hr>
|
||||
|
@ -32,12 +31,12 @@
|
|||
{{#if loading}}
|
||||
{{loading-spinner}}
|
||||
{{else}}
|
||||
{{#d-button
|
||||
{{d-button
|
||||
disabled=loading
|
||||
action="stripePaymentHandler"
|
||||
class="btn btn-primary btn-payment"}}
|
||||
{{buttonText}}
|
||||
{{/d-button}}
|
||||
class="btn btn-primary btn-payment"
|
||||
label="discourse_subscriptions.plans.payment_button"
|
||||
}}
|
||||
{{/if}}
|
||||
|
||||
{{/unless}}
|
||||
|
|
|
@ -6,7 +6,6 @@ en:
|
|||
discourse_subscriptions_secret_key: Stripe Secret Key
|
||||
discourse_subscriptions_webhook_secret: Stripe Webhook Secret
|
||||
discourse_subscriptions_currency: Default Currency Code. This can be overridden when creating a subscription plan.
|
||||
discourse_subscriptions_allow_payments: Allow single payments
|
||||
errors:
|
||||
discourse_patrons_amount_must_be_currency: "Currency amounts must be currencies without dollar symbol (eg 1.50)"
|
||||
js:
|
||||
|
@ -21,6 +20,7 @@ en:
|
|||
subscribe: Subscribe
|
||||
user_activity:
|
||||
payments: Payments
|
||||
one_time_payment: One-Time Payment
|
||||
plans:
|
||||
purchase: Purchase a subscription
|
||||
select: Select subscription plan
|
||||
|
@ -36,18 +36,6 @@ en:
|
|||
validate:
|
||||
payment_options:
|
||||
required: Please select a subscription plan.
|
||||
payment:
|
||||
purchase: Make just one payment
|
||||
select: Select a payment option
|
||||
interval:
|
||||
One payment
|
||||
payment_button:
|
||||
Pay Once
|
||||
success: Thank you!
|
||||
incomplete: Payment is incomplete.
|
||||
validate:
|
||||
payment_options:
|
||||
required: Please select a payment option.
|
||||
user:
|
||||
payments:
|
||||
id: Payment ID
|
||||
|
@ -140,6 +128,7 @@ en:
|
|||
group_help: This is the discourse user group the customer gets added to when the subscription is created.
|
||||
active: Active
|
||||
created_at: Created
|
||||
recurring: Recurring Plan?
|
||||
subscriptions:
|
||||
title: Subscriptions
|
||||
subscription:
|
||||
|
|
|
@ -20,8 +20,6 @@ DiscourseSubscriptions::Engine.routes.draw do
|
|||
|
||||
resources :customers, only: [:create]
|
||||
resources :hooks, only: [:create]
|
||||
resources :invoices, only: [:index]
|
||||
resources :payments, only: [:create]
|
||||
resources :plans, only: [:index], constraints: SubscriptionsUserConstraint.new
|
||||
resources :products, only: [:index, :show]
|
||||
resources :subscriptions, only: [:create]
|
||||
|
|
|
@ -13,9 +13,6 @@ plugins:
|
|||
discourse_subscriptions_webhook_secret:
|
||||
default: ''
|
||||
client: false
|
||||
discourse_subscriptions_allow_payments:
|
||||
default: false
|
||||
client: true
|
||||
discourse_subscriptions_currency:
|
||||
client: true
|
||||
default: "USD"
|
||||
|
|
16
plugin.rb
16
plugin.rb
|
@ -27,14 +27,14 @@ extend_content_security_policy(
|
|||
add_admin_route 'discourse_subscriptions.admin_navigation', 'discourse-subscriptions.products'
|
||||
|
||||
Discourse::Application.routes.append do
|
||||
get '/admin/plugins/discourse-subscriptions' => 'admin/plugins#index'
|
||||
get '/admin/plugins/discourse-subscriptions/products' => 'admin/plugins#index'
|
||||
get '/admin/plugins/discourse-subscriptions/products/:product_id' => 'admin/plugins#index'
|
||||
get '/admin/plugins/discourse-subscriptions/products/:product_id/plans' => 'admin/plugins#index'
|
||||
get '/admin/plugins/discourse-subscriptions/products/:product_id/plans/:plan_id' => 'admin/plugins#index'
|
||||
get '/admin/plugins/discourse-subscriptions/subscriptions' => 'admin/plugins#index'
|
||||
get '/admin/plugins/discourse-subscriptions/plans' => 'admin/plugins#index'
|
||||
get '/admin/plugins/discourse-subscriptions/plans/:plan_id' => 'admin/plugins#index'
|
||||
get '/admin/plugins/discourse-subscriptions' => 'admin/plugins#index', constraints: AdminConstraint.new
|
||||
get '/admin/plugins/discourse-subscriptions/products' => 'admin/plugins#index', constraints: AdminConstraint.new
|
||||
get '/admin/plugins/discourse-subscriptions/products/:product_id' => 'admin/plugins#index', constraints: AdminConstraint.new
|
||||
get '/admin/plugins/discourse-subscriptions/products/:product_id/plans' => 'admin/plugins#index', constraints: AdminConstraint.new
|
||||
get '/admin/plugins/discourse-subscriptions/products/:product_id/plans/:plan_id' => 'admin/plugins#index', constraints: AdminConstraint.new
|
||||
get '/admin/plugins/discourse-subscriptions/subscriptions' => 'admin/plugins#index', constraints: AdminConstraint.new
|
||||
get '/admin/plugins/discourse-subscriptions/plans' => 'admin/plugins#index', constraints: AdminConstraint.new
|
||||
get '/admin/plugins/discourse-subscriptions/plans/:plan_id' => 'admin/plugins#index', constraints: AdminConstraint.new
|
||||
get 'u/:username/billing' => 'users#show', constraints: { username: USERNAME_ROUTE_FORMAT }
|
||||
get 'u/:username/billing/:id' => 'users#show', constraints: { username: USERNAME_ROUTE_FORMAT }
|
||||
end
|
||||
|
|
|
@ -98,7 +98,12 @@ module DiscourseSubscriptions
|
|||
|
||||
it "creates a plan with an interval" do
|
||||
::Stripe::Price.expects(:create).with(has_entry(recurring: { interval: 'week' }))
|
||||
post "/s/admin/plans.json", params: { interval: 'week', metadata: { group_name: '' } }
|
||||
post "/s/admin/plans.json", params: { type: 'recurring', interval: 'week', metadata: { group_name: '' } }
|
||||
end
|
||||
|
||||
it "creates a plan as a one-time purchase" do
|
||||
::Stripe::Price.expects(:create).with(Not(has_key(:recurring)))
|
||||
post "/s/admin/plans.json", params: { metadata: { group_name: '' } }
|
||||
end
|
||||
|
||||
it "creates a plan with an amount" do
|
||||
|
|
|
@ -1,54 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
module DiscourseSubscriptions
|
||||
RSpec.describe InvoicesController do
|
||||
describe "index" do
|
||||
describe "not authenticated" do
|
||||
it "does not list the invoices" do
|
||||
::Stripe::Invoice.expects(:list).never
|
||||
get "/s/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 "/s/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 "/s/invoices.json", params: { user_id: user.id }
|
||||
expect(response.body).to eq "[]"
|
||||
end
|
||||
end
|
||||
|
||||
context "stripe customer exists" do
|
||||
before do
|
||||
DiscourseSubscriptions::Customer.create_customer(user, stripe_customer)
|
||||
end
|
||||
|
||||
it "lists the invoices" do
|
||||
::Stripe::Invoice.expects(:list).with(customer: 'cus_id4567')
|
||||
get "/s/invoices.json", params: { user_id: user.id }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,50 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
module DiscourseSubscriptions
|
||||
RSpec.describe PaymentsController do
|
||||
context "not authenticated" do
|
||||
it "does not create a payment intent" do
|
||||
::Stripe::PaymentIntent.expects(:create).never
|
||||
|
||||
post "/s/payments.json", params: {
|
||||
payment_method: 'pm_123',
|
||||
amount: 999,
|
||||
currency: 'gdp'
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
context "authenticated" do
|
||||
let(:user) { Fabricate(:user) }
|
||||
|
||||
before do
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
describe "create" do
|
||||
it "creates a payment intent" do
|
||||
::Stripe::Customer.expects(:create).with(
|
||||
email: user.email
|
||||
).returns(id: 'cus_87653')
|
||||
|
||||
::Stripe::PaymentIntent.expects(:create).with(
|
||||
payment_method_types: ['card'],
|
||||
payment_method: 'pm_123',
|
||||
amount: '999',
|
||||
currency: 'gdp',
|
||||
confirm: true,
|
||||
customer: 'cus_87653'
|
||||
)
|
||||
|
||||
post "/s/payments.json", params: {
|
||||
payment_method: 'pm_123',
|
||||
amount: 999,
|
||||
currency: 'gdp'
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -22,6 +22,7 @@ module DiscourseSubscriptions
|
|||
describe "create" do
|
||||
it "creates a subscription" do
|
||||
::Stripe::Price.expects(:retrieve).returns(
|
||||
type: 'recurring',
|
||||
product: 'product_12345',
|
||||
metadata: {
|
||||
group_name: 'awesome',
|
||||
|
@ -41,8 +42,29 @@ module DiscourseSubscriptions
|
|||
}.to change { DiscourseSubscriptions::Customer.count }
|
||||
end
|
||||
|
||||
it "creates a one time payment subscription" do
|
||||
::Stripe::Price.expects(:retrieve).returns(
|
||||
type: 'one_time',
|
||||
product: 'product_12345',
|
||||
metadata: {
|
||||
group_name: 'awesome'
|
||||
}
|
||||
)
|
||||
|
||||
::Stripe::InvoiceItem.expects(:create)
|
||||
|
||||
::Stripe::Invoice.expects(:create).returns(id: 'in_123')
|
||||
|
||||
::Stripe::Invoice.expects(:pay).returns(status: 'paid')
|
||||
|
||||
expect {
|
||||
post '/s/subscriptions.json', params: { plan: 'plan_1234', customer: 'cus_1234' }
|
||||
}.to change { DiscourseSubscriptions::Customer.count }
|
||||
|
||||
end
|
||||
|
||||
it "creates a customer model" do
|
||||
::Stripe::Price.expects(:retrieve).returns(metadata: {})
|
||||
::Stripe::Price.expects(:retrieve).returns(type: 'recurring', metadata: {})
|
||||
::Stripe::Subscription.expects(:create).returns(status: 'active')
|
||||
|
||||
expect {
|
||||
|
@ -61,13 +83,13 @@ module DiscourseSubscriptions
|
|||
end
|
||||
|
||||
it "does not add the user to the admins group" do
|
||||
::Stripe::Price.expects(:retrieve).returns(metadata: { group_name: 'admins' })
|
||||
::Stripe::Price.expects(:retrieve).returns(type: 'recurring', metadata: { group_name: 'admins' })
|
||||
post "/s/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::Price.expects(:retrieve).returns(metadata: { group_name: 'other' })
|
||||
::Stripe::Price.expects(:retrieve).returns(type: 'recurring', metadata: { group_name: 'other' })
|
||||
post "/s/subscriptions.json", params: { plan: 'plan_1234', customer: 'cus_1234' }
|
||||
expect(user.groups).to be_empty
|
||||
end
|
||||
|
@ -75,7 +97,7 @@ module DiscourseSubscriptions
|
|||
|
||||
context "plan has group in metadata" do
|
||||
before do
|
||||
::Stripe::Price.expects(:retrieve).returns(metadata: { group_name: group_name })
|
||||
::Stripe::Price.expects(:retrieve).returns(type: 'recurring', metadata: { group_name: group_name })
|
||||
end
|
||||
|
||||
it "does not add the user to the group when subscription fails" do
|
||||
|
|
|
@ -18,9 +18,9 @@ componentTest("Discourse Subscriptions payment options have no plans", {
|
|||
|
||||
componentTest("Discourse Subscriptions payment options has content", {
|
||||
template: `{{payment-options
|
||||
paymentsAllowed=paymentsAllowed
|
||||
plans=plans
|
||||
planTypeIsSelected=planTypeIsSelected}}`,
|
||||
plans=plans
|
||||
selectedPlan=selectedPlan
|
||||
}}`,
|
||||
|
||||
beforeEach() {
|
||||
this.set("plans", [
|
||||
|
@ -35,110 +35,9 @@ componentTest("Discourse Subscriptions payment options has content", {
|
|||
amountDollars: "9.99"
|
||||
}
|
||||
]);
|
||||
|
||||
this.set("planTypeIsSelected", true);
|
||||
this.set("paymentsAllowed", true);
|
||||
},
|
||||
|
||||
async test(assert) {
|
||||
assert.equal(
|
||||
find(".btn-discourse-subscriptions-payment-type").length,
|
||||
2,
|
||||
"The payment type buttons are shown"
|
||||
);
|
||||
assert.equal(
|
||||
find(".btn-discourse-subscriptions-subscribe").length,
|
||||
2,
|
||||
"The plan buttons are shown"
|
||||
);
|
||||
assert.equal(
|
||||
find("#subscribe-buttons .btn-primary").length,
|
||||
0,
|
||||
"No plan buttons are selected by default"
|
||||
);
|
||||
assert.equal(
|
||||
find(".btn-discourse-subscriptions-subscribe:first-child .interval")
|
||||
.text()
|
||||
.trim(),
|
||||
"Yearly",
|
||||
"The plan interval is shown"
|
||||
);
|
||||
assert.equal(
|
||||
find(".btn-discourse-subscriptions-subscribe:first-child .amount")
|
||||
.text()
|
||||
.trim(),
|
||||
"$AUD 44.99",
|
||||
"The plan amount and currency is shown"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
componentTest("Discourse Subscriptions payments allowed setting", {
|
||||
template: `{{payment-options plans=plans paymentsAllowed=paymentsAllowed}}`,
|
||||
|
||||
async test(assert) {
|
||||
this.set("paymentsAllowed", true);
|
||||
|
||||
assert.ok(
|
||||
find("#discourse-subscriptions-payment-type-plan").length,
|
||||
"The plan type button displayed"
|
||||
);
|
||||
assert.ok(
|
||||
find("#discourse-subscriptions-payment-type-payment").length,
|
||||
"The payment type button displayed"
|
||||
);
|
||||
|
||||
this.set("paymentsAllowed", false);
|
||||
|
||||
assert.notOk(
|
||||
find("#discourse-subscriptions-payment-type-plan").length,
|
||||
"The plan type button hidden"
|
||||
);
|
||||
assert.notOk(
|
||||
find("#discourse-subscriptions-payment-type-payment").length,
|
||||
"The payment type button hidden"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
componentTest("Discourse Subscriptions payment type plan", {
|
||||
template: `{{payment-options
|
||||
paymentsAllowed=paymentsAllowed
|
||||
plans=plans
|
||||
planTypeIsSelected=planTypeIsSelected}}`,
|
||||
|
||||
async test(assert) {
|
||||
this.set("plans", [
|
||||
{ currency: "aud", interval: "year", amountDollars: "44.99" }
|
||||
]);
|
||||
|
||||
this.set("paymentsAllowed", true);
|
||||
this.set("planTypeIsSelected", true);
|
||||
|
||||
assert.equal(
|
||||
find("#discourse-subscriptions-payment-type-plan.btn-primary").length,
|
||||
1,
|
||||
"The plan type button is selected"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
find("#discourse-subscriptions-payment-type-payment.btn-primary").length,
|
||||
0,
|
||||
"The payment type button is not selected"
|
||||
);
|
||||
|
||||
await click("#discourse-subscriptions-payment-type-payment");
|
||||
|
||||
assert.equal(
|
||||
find("#discourse-subscriptions-payment-type-plan.btn-primary").length,
|
||||
0,
|
||||
"The plan type button is selected"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
find("#discourse-subscriptions-payment-type-payment.btn-primary").length,
|
||||
1,
|
||||
"The payment type button is not selected"
|
||||
);
|
||||
assert.equal(this.selectedPlan, null, "No plans are selected by default");
|
||||
}
|
||||
});
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
import componentTest from "helpers/component-test";
|
||||
|
||||
moduleForComponent("payment-plan", { integration: true });
|
||||
|
||||
componentTest("Payment plan subscription button rendered", {
|
||||
template: `{{payment-plan
|
||||
plan=plan
|
||||
selectedPlan=selectedPlan
|
||||
}}`,
|
||||
|
||||
beforeEach() {
|
||||
this.set("plan", {
|
||||
type: "recurring",
|
||||
currency: "aud",
|
||||
recurring: { interval: "year" },
|
||||
amountDollars: "44.99"
|
||||
});
|
||||
},
|
||||
|
||||
async test(assert) {
|
||||
assert.equal(
|
||||
find(".btn-discourse-subscriptions-subscribe").length,
|
||||
1,
|
||||
"The payment button is shown"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
find(".btn-discourse-subscriptions-subscribe:first-child .interval")
|
||||
.text()
|
||||
.trim(),
|
||||
"Yearly",
|
||||
"The plan interval is shown -- Yearly"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
find(".btn-discourse-subscriptions-subscribe:first-child .amount")
|
||||
.text()
|
||||
.trim(),
|
||||
"$AUD 44.99",
|
||||
"The plan amount and currency is shown"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
componentTest("Payment plan one-time-payment button rendered", {
|
||||
template: `{{payment-plan
|
||||
plan=plan
|
||||
selectedPlan=selectedPlan
|
||||
}}`,
|
||||
|
||||
beforeEach() {
|
||||
this.set("plan", {
|
||||
type: "one_time",
|
||||
currency: "USD",
|
||||
amountDollars: "3.99"
|
||||
});
|
||||
},
|
||||
|
||||
async test(assert) {
|
||||
assert.equal(
|
||||
find(".btn-discourse-subscriptions-subscribe:first-child .interval")
|
||||
.text()
|
||||
.trim(),
|
||||
"One-Time Payment",
|
||||
"Shown as one time payment"
|
||||
);
|
||||
}
|
||||
});
|
|
@ -1,13 +0,0 @@
|
|||
export default function(helpers) {
|
||||
const { response } = helpers;
|
||||
|
||||
this.get("/patrons", () => response({ email: "hello@example.com" }));
|
||||
|
||||
this.get("/groups/:plan", () => {
|
||||
return response({ full_name: "Saboo", bio_cooked: "This is the plan" });
|
||||
});
|
||||
|
||||
this.get("/patrons/plans", () => {
|
||||
return response({ plans: [] });
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue