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:
Justin DiRose
2020-07-22 11:06:34 -05:00
committed by GitHub
parent fcc90e4fcc
commit 587661fafb
31 changed files with 272 additions and 533 deletions
@@ -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}}