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

View File

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

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

View File

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

View File

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

View File

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

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 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) {

View File

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

View File

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

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

View File

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

View File

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

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>
{{#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>

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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