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:
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
+9
@@ -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;
|
||||
+3
@@ -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 });
|
||||
|
||||
+35
-18
@@ -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'}}
|
||||
|
||||
+1
-1
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user