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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 272 additions and 533 deletions

View File

@ -12,7 +12,6 @@ module DiscourseSubscriptions
plans = ::Stripe::Price.list(product_params) plans = ::Stripe::Price.list(product_params)
render_json_dump plans.data render_json_dump plans.data
rescue ::Stripe::InvalidRequestError => e rescue ::Stripe::InvalidRequestError => e
render_json_error e.message render_json_error e.message
end end
@ -20,12 +19,9 @@ module DiscourseSubscriptions
def create def create
begin begin
plan = ::Stripe::Price.create( price_object = {
nickname: params[:nickname], nickname: params[:nickname],
unit_amount: params[:amount], unit_amount: params[:amount],
recurring: {
interval: params[:interval],
},
product: params[:product], product: params[:product],
currency: params[:currency], currency: params[:currency],
active: params[:active], active: params[:active],
@ -33,10 +29,17 @@ module DiscourseSubscriptions
group_name: params[:metadata][:group_name], group_name: params[:metadata][:group_name],
trial_period_days: params[:trial_period_days] 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 render_json_dump plan
rescue ::Stripe::InvalidRequestError => e rescue ::Stripe::InvalidRequestError => e
render_json_error e.message render_json_error e.message
end end
@ -74,7 +77,6 @@ module DiscourseSubscriptions
) )
render_json_dump plan render_json_dump plan
rescue ::Stripe::InvalidRequestError => e rescue ::Stripe::InvalidRequestError => e
render_json_error e.message render_json_error e.message
end end

View File

@ -12,7 +12,6 @@ module DiscourseSubscriptions
webhook_secret = SiteSetting.discourse_subscriptions_webhook_secret webhook_secret = SiteSetting.discourse_subscriptions_webhook_secret
event = ::Stripe::Webhook.construct_event(payload, sig_header, webhook_secret) event = ::Stripe::Webhook.construct_event(payload, sig_header, webhook_secret)
rescue JSON::ParserError => e rescue JSON::ParserError => e
render_json_error e.message render_json_error e.message
return return

View File

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

View File

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

View File

@ -15,11 +15,10 @@ module DiscourseSubscriptions
end end
serialized = plans[:data].map do |plan| 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] } end.sort_by { |plan| plan[:amount] }
render_json_dump serialized render_json_dump serialized
rescue ::Stripe::InvalidRequestError => e rescue ::Stripe::InvalidRequestError => e
render_json_error e.message render_json_error e.message
end end

View File

@ -19,7 +19,6 @@ module DiscourseSubscriptions
end end
render_json_dump subscriptions render_json_dump subscriptions
rescue ::Stripe::InvalidRequestError => e rescue ::Stripe::InvalidRequestError => e
render_json_error e.message render_json_error e.message
end end
@ -29,36 +28,48 @@ module DiscourseSubscriptions
begin begin
plan = ::Stripe::Price.retrieve(params[:plan]) plan = ::Stripe::Price.retrieve(params[:plan])
if plan[:metadata] && plan[:metadata][:trial_period_days] recurring_plan = plan[:type] == 'recurring'
trial_days = plan[:metadata][:trial_period_days]
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 end
@subscription = ::Stripe::Subscription.create( if transaction_ok(transaction)
customer: params[:customer], group = plan_group(plan)
items: [ { price: params[:plan] } ],
metadata: metadata_user,
trial_period_days: trial_days
)
group = plan_group(plan) group.add(current_user) if group
if subscription_ok && group customer = Customer.create(
group.add(current_user) 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 end
customer = Customer.create( render_json_dump transaction
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
rescue ::Stripe::InvalidRequestError => e rescue ::Stripe::InvalidRequestError => e
render_json_error e.message render_json_error e.message
end end
@ -70,8 +81,8 @@ module DiscourseSubscriptions
{ user_id: current_user.id, username: current_user.username_lower } { user_id: current_user.id, username: current_user.username_lower }
end end
def subscription_ok def transaction_ok(transaction)
['active', 'trialing'].include?(@subscription[:status]) %w[active trialing paid].include?(transaction[:status])
end end
end end
end end

View File

@ -21,9 +21,10 @@ module DiscourseSubscriptions
all_invoices = ::Stripe::Invoice.list(customer: customer_id) all_invoices = ::Stripe::Invoice.list(customer: customer_id)
invoices_with_products = all_invoices[:data].select do |invoice| invoices_with_products = all_invoices[:data].select do |invoice|
# i cannot dig it so we must get iffy with it # 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] invoice_lines = invoice[:lines][:data][0] if invoice[:lines] && invoice[:lines][:data]
product_ids.include?(invoice[:lines][:data][0][:plan][:product]) invoice_product_id = invoice_lines[:price][:product] if invoice_lines[:price] && invoice_lines[:price][:product]
end invoice_product_id = invoice_lines[:plan][:product] if invoice_lines[:plan] && invoice_lines[:plan][:product]
product_ids.include?(invoice_product_id)
end end
invoice_ids = invoices_with_products.map { |invoice| invoice[:id] } invoice_ids = invoices_with_products.map { |invoice| invoice[:id] }
payments = ::Stripe::PaymentIntent.list(customer: customer_id) payments = ::Stripe::PaymentIntent.list(customer: customer_id)

View File

@ -1,22 +1,9 @@
import { equal } from "@ember/object/computed";
import Component from "@ember/component"; import Component from "@ember/component";
export default Component.extend({ export default Component.extend({
planButtonSelected: equal("planTypeIsSelected", true),
paymentButtonSelected: equal("planTypeIsSelected", false),
actions: { actions: {
selectPlans() {
this.set("planTypeIsSelected", true);
},
selectPayments() {
this.set("planTypeIsSelected", false);
},
clickPlan(plan) { clickPlan(plan) {
this.plans.map(p => p.set("selected", false)); this.set("selectedPlan", plan.id);
plan.set("selected", true);
} }
} }
}); });

View File

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

View File

@ -2,6 +2,9 @@ import discourseComputed from "discourse-common/utils/decorators";
import DiscourseURL from "discourse/lib/url"; import DiscourseURL from "discourse/lib/url";
import Controller from "@ember/controller"; import Controller from "@ember/controller";
const RECURRING = "recurring";
const ONE_TIME = "one_time";
export default Controller.extend({ export default Controller.extend({
// Also defined in settings. // Also defined in settings.
selectedCurrency: Ember.computed.alias("model.plan.currency"), selectedCurrency: Ember.computed.alias("model.plan.currency"),
@ -47,6 +50,12 @@ export default Controller.extend({
}, },
actions: { 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() { createPlan() {
// TODO: set default group name beforehand // TODO: set default group name beforehand
if (this.get("model.plan.metadata.group_name") === undefined) { if (this.get("model.plan.metadata.group_name") === undefined) {

View File

@ -1,29 +1,13 @@
import Controller from "@ember/controller"; import Controller from "@ember/controller";
import Customer from "discourse/plugins/discourse-subscriptions/discourse/models/customer"; 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 Subscription from "discourse/plugins/discourse-subscriptions/discourse/models/subscription";
import discourseComputed from "discourse-common/utils/decorators";
import I18n from "I18n"; import I18n from "I18n";
export default Controller.extend({ export default Controller.extend({
planTypeIsSelected: true, selectedPlan: null,
@discourseComputed("planTypeIsSelected")
type(planTypeIsSelected) {
return planTypeIsSelected ? "plans" : "payment";
},
@discourseComputed("type")
buttonText(type) {
return I18n.t(`discourse_subscriptions.${type}.payment_button`);
},
init() { init() {
this._super(...arguments); this._super(...arguments);
this.set(
"paymentsAllowed",
Discourse.SiteSettings.discourse_subscriptions_allow_payments
);
this.set( this.set(
"stripe", "stripe",
Stripe(Discourse.SiteSettings.discourse_subscriptions_public_key) Stripe(Discourse.SiteSettings.discourse_subscriptions_public_key)
@ -37,20 +21,6 @@ export default Controller.extend({
bootbox.alert(I18n.t(`discourse_subscriptions.${path}`)); 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) { createSubscription(plan) {
return this.stripe.createToken(this.get("cardElement")).then(result => { return this.stripe.createToken(this.get("cardElement")).then(result => {
if (result.error) { if (result.error) {
@ -73,24 +43,17 @@ export default Controller.extend({
actions: { actions: {
stripePaymentHandler() { stripePaymentHandler() {
this.set("loading", true); this.set("loading", true);
const type = this.get("type");
const plan = this.get("model.plans") const plan = this.get("model.plans")
.filterBy("selected") .filterBy("id", this.selectedPlan)
.get("firstObject"); .get("firstObject");
if (!plan) { if (!plan) {
this.alert(`${type}.validate.payment_options.required`); this.alert("plans.validate.payment_options.required");
this.set("loading", false); this.set("loading", false);
return; return;
} }
let transaction; let transaction = this.createSubscription(plan);
if (this.planTypeIsSelected) {
transaction = this.createSubscription(plan);
} else {
transaction = this.createPayment(plan);
}
transaction transaction
.then(result => { .then(result => {
@ -98,17 +61,15 @@ export default Controller.extend({
bootbox.alert(result.error.message || result.error); bootbox.alert(result.error.message || result.error);
} else { } else {
if (result.status === "incomplete") { if (result.status === "incomplete") {
this.alert(`${type}.incomplete`); this.alert("plans.incomplete");
} else { } else {
this.alert(`${type}.success`); this.alert("plans.success");
} }
const success_route = this.planTypeIsSelected
? "user.billing.subscriptions"
: "user.billing.payments";
this.transitionToRoute( this.transitionToRoute(
success_route, plan.type === "recurring"
? "user.billing.subscriptions"
: "user.billing.payments",
Discourse.User.current().username.toLowerCase() Discourse.User.current().username.toLowerCase()
); );
} }

View File

@ -26,6 +26,7 @@ const AdminPlan = Plan.extend({
amount: this.unit_amount, amount: this.unit_amount,
currency: this.currency, currency: this.currency,
trial_period_days: this.parseTrialPeriodDays, trial_period_days: this.parseTrialPeriodDays,
type: this.type,
product: this.product, product: this.product,
metadata: this.metadata, metadata: this.metadata,
active: this.active active: this.active

View File

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

View File

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

View File

@ -16,11 +16,14 @@ export default Route.extend({
active: true, active: true,
isNew: true, isNew: true,
interval: "month", interval: "month",
type: "recurring",
isRecurring: true,
currency: Discourse.SiteSettings.discourse_subscriptions_currency, currency: Discourse.SiteSettings.discourse_subscriptions_currency,
product: product.get("id") product: product.get("id")
}); });
} else { } else {
plan = AdminPlan.find(id); plan = AdminPlan.find(id);
plan.isRecurring = plan.type === "recurring";
} }
const groups = Group.findAll({ ignore_automatic: true }); const groups = Group.findAll({ ignore_automatic: true });

View File

@ -40,30 +40,47 @@
{{input class="plan-amount" type="text" name="name" value=model.plan.amountDollars disabled=planFieldDisabled}} {{input class="plan-amount" type="text" name="name" value=model.plan.amountDollars disabled=planFieldDisabled}}
</p> </p>
<p> <p>
<label for="trial"> <label for="recurring">
{{i18n 'discourse_subscriptions.admin.plans.plan.trial'}} {{i18n 'discourse_subscriptions.admin.plans.plan.recurring'}}
({{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> </label>
{{#if planFieldDisabled}} {{#if planFieldDisabled}}
{{input disabled=true value=selectedInterval}} {{input disabled=true value=model.plan.isRecurring}}
{{else}} {{else}}
{{combo-box {{input
valueProperty="name" type="checkbox"
content=availableIntervals name="recurring"
value=selectedInterval checked=model.plan.isRecurring
onChange=(action (mut selectedInterval)) change=(action 'changeRecurring')
}} }}
{{/if}} {{/if}}
</p> </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> <p>
<label for="active"> <label for="active">
{{i18n 'discourse_subscriptions.admin.plans.plan.active'}} {{i18n 'discourse_subscriptions.admin.plans.plan.active'}}

View File

@ -53,7 +53,7 @@
{{#each model.plans as |plan|}} {{#each model.plans as |plan|}}
<tr> <tr>
<td>{{plan.nickname}}</td> <td>{{plan.nickname}}</td>
<td>{{plan.interval}}</td> <td>{{plan.recurring.interval}}</td>
<td>{{format-unix-date plan.created}}</td> <td>{{format-unix-date plan.created}}</td>
<td>{{plan.metadata.group_name}}</td> <td>{{plan.metadata.group_name}}</td>
<td>{{plan.active}}</td> <td>{{plan.active}}</td>

View File

@ -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> <p>
{{#if planTypeIsSelected}} {{i18n "discourse_subscriptions.plans.select"}}
{{i18n "discourse_subscriptions.plans.select"}}
{{else}}
{{i18n "discourse_subscriptions.payment.select"}}
{{/if}}
</p> </p>
<div class="subscribe-buttons"> <div class="subscribe-buttons">
{{#each plans as |plan|}} {{#each plans as |plan|}}
{{#ds-button {{payment-plan plan=plan selectedPlan=selectedPlan clickPlan=(action "clickPlan")}}
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}}
{{/each}} {{/each}}
</div> </div>

View File

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

View File

@ -21,8 +21,7 @@
{{payment-options {{payment-options
plans=model.plans plans=model.plans
paymentsAllowed=paymentsAllowed selectedPlan=selectedPlan
planTypeIsSelected=planTypeIsSelected
}} }}
<hr> <hr>
@ -32,12 +31,12 @@
{{#if loading}} {{#if loading}}
{{loading-spinner}} {{loading-spinner}}
{{else}} {{else}}
{{#d-button {{d-button
disabled=loading disabled=loading
action="stripePaymentHandler" action="stripePaymentHandler"
class="btn btn-primary btn-payment"}} class="btn btn-primary btn-payment"
{{buttonText}} label="discourse_subscriptions.plans.payment_button"
{{/d-button}} }}
{{/if}} {{/if}}
{{/unless}} {{/unless}}

View File

@ -6,7 +6,6 @@ en:
discourse_subscriptions_secret_key: Stripe Secret Key discourse_subscriptions_secret_key: Stripe Secret Key
discourse_subscriptions_webhook_secret: Stripe Webhook Secret discourse_subscriptions_webhook_secret: Stripe Webhook Secret
discourse_subscriptions_currency: Default Currency Code. This can be overridden when creating a subscription plan. discourse_subscriptions_currency: Default Currency Code. This can be overridden when creating a subscription plan.
discourse_subscriptions_allow_payments: Allow single payments
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:
@ -21,6 +20,7 @@ en:
subscribe: Subscribe subscribe: Subscribe
user_activity: user_activity:
payments: Payments payments: Payments
one_time_payment: One-Time Payment
plans: plans:
purchase: Purchase a subscription purchase: Purchase a subscription
select: Select subscription plan select: Select subscription plan
@ -36,18 +36,6 @@ en:
validate: validate:
payment_options: payment_options:
required: Please select a subscription plan. 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: user:
payments: payments:
id: Payment ID 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. group_help: This is the discourse user group the customer gets added to when the subscription is created.
active: Active active: Active
created_at: Created created_at: Created
recurring: Recurring Plan?
subscriptions: subscriptions:
title: Subscriptions title: Subscriptions
subscription: subscription:

View File

@ -20,8 +20,6 @@ DiscourseSubscriptions::Engine.routes.draw do
resources :customers, only: [:create] resources :customers, only: [:create]
resources :hooks, only: [:create] resources :hooks, only: [:create]
resources :invoices, only: [:index]
resources :payments, only: [:create]
resources :plans, only: [:index], constraints: SubscriptionsUserConstraint.new resources :plans, only: [:index], constraints: SubscriptionsUserConstraint.new
resources :products, only: [:index, :show] resources :products, only: [:index, :show]
resources :subscriptions, only: [:create] resources :subscriptions, only: [:create]

View File

@ -13,9 +13,6 @@ plugins:
discourse_subscriptions_webhook_secret: discourse_subscriptions_webhook_secret:
default: '' default: ''
client: false client: false
discourse_subscriptions_allow_payments:
default: false
client: true
discourse_subscriptions_currency: discourse_subscriptions_currency:
client: true client: true
default: "USD" default: "USD"

View File

@ -27,14 +27,14 @@ extend_content_security_policy(
add_admin_route 'discourse_subscriptions.admin_navigation', 'discourse-subscriptions.products' add_admin_route 'discourse_subscriptions.admin_navigation', 'discourse-subscriptions.products'
Discourse::Application.routes.append do Discourse::Application.routes.append do
get '/admin/plugins/discourse-subscriptions' => 'admin/plugins#index' get '/admin/plugins/discourse-subscriptions' => 'admin/plugins#index', constraints: AdminConstraint.new
get '/admin/plugins/discourse-subscriptions/products' => 'admin/plugins#index' get '/admin/plugins/discourse-subscriptions/products' => 'admin/plugins#index', constraints: AdminConstraint.new
get '/admin/plugins/discourse-subscriptions/products/:product_id' => 'admin/plugins#index' 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' 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' 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' get '/admin/plugins/discourse-subscriptions/subscriptions' => 'admin/plugins#index', constraints: AdminConstraint.new
get '/admin/plugins/discourse-subscriptions/plans' => 'admin/plugins#index' get '/admin/plugins/discourse-subscriptions/plans' => 'admin/plugins#index', constraints: AdminConstraint.new
get '/admin/plugins/discourse-subscriptions/plans/:plan_id' => 'admin/plugins#index' 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' => 'users#show', constraints: { username: USERNAME_ROUTE_FORMAT }
get 'u/:username/billing/:id' => 'users#show', constraints: { username: USERNAME_ROUTE_FORMAT } get 'u/:username/billing/:id' => 'users#show', constraints: { username: USERNAME_ROUTE_FORMAT }
end end

View File

@ -98,7 +98,12 @@ module DiscourseSubscriptions
it "creates a plan with an interval" do it "creates a plan with an interval" do
::Stripe::Price.expects(:create).with(has_entry(recurring: { interval: 'week' })) ::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 end
it "creates a plan with an amount" do it "creates a plan with an amount" do

View File

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

View File

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

View File

@ -22,6 +22,7 @@ module DiscourseSubscriptions
describe "create" do describe "create" do
it "creates a subscription" do it "creates a subscription" do
::Stripe::Price.expects(:retrieve).returns( ::Stripe::Price.expects(:retrieve).returns(
type: 'recurring',
product: 'product_12345', product: 'product_12345',
metadata: { metadata: {
group_name: 'awesome', group_name: 'awesome',
@ -41,8 +42,29 @@ module DiscourseSubscriptions
}.to change { DiscourseSubscriptions::Customer.count } }.to change { DiscourseSubscriptions::Customer.count }
end 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 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') ::Stripe::Subscription.expects(:create).returns(status: 'active')
expect { expect {
@ -61,13 +83,13 @@ module DiscourseSubscriptions
end end
it "does not add the user to the admins group" do 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' } post "/s/subscriptions.json", params: { plan: 'plan_1234', customer: 'cus_1234' }
expect(user.admin).to eq false expect(user.admin).to eq false
end end
it "does not add the user to other group" do 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' } post "/s/subscriptions.json", params: { plan: 'plan_1234', customer: 'cus_1234' }
expect(user.groups).to be_empty expect(user.groups).to be_empty
end end
@ -75,7 +97,7 @@ module DiscourseSubscriptions
context "plan has group in metadata" do context "plan has group in metadata" do
before 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 end
it "does not add the user to the group when subscription fails" do it "does not add the user to the group when subscription fails" do

View File

@ -18,9 +18,9 @@ componentTest("Discourse Subscriptions payment options have no plans", {
componentTest("Discourse Subscriptions payment options has content", { componentTest("Discourse Subscriptions payment options has content", {
template: `{{payment-options template: `{{payment-options
paymentsAllowed=paymentsAllowed plans=plans
plans=plans selectedPlan=selectedPlan
planTypeIsSelected=planTypeIsSelected}}`, }}`,
beforeEach() { beforeEach() {
this.set("plans", [ this.set("plans", [
@ -35,110 +35,9 @@ componentTest("Discourse Subscriptions payment options has content", {
amountDollars: "9.99" amountDollars: "9.99"
} }
]); ]);
this.set("planTypeIsSelected", true);
this.set("paymentsAllowed", true);
}, },
async test(assert) { async test(assert) {
assert.equal( assert.equal(this.selectedPlan, null, "No plans are selected by default");
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"
);
} }
}); });

View File

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

View File

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