buttons for selecting price and one time payment

This commit is contained in:
Rimian Perkins 2019-12-13 10:41:14 +11:00
parent e27b55ea6f
commit 57fb508514
13 changed files with 264 additions and 90 deletions

View File

@ -0,0 +1,31 @@
# 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
payment = ::Stripe::PaymentIntent.create(
payment_method_types: ['card'],
payment_method: params[:payment_method],
amount: params[:amount],
currency: params[:currency],
confirm: true
)
render_json_dump payment
rescue ::Stripe::InvalidRequestError => e
render_json_error e.message
rescue ::Stripe::CardError => e
render_json_error 'Card Declined'
end
end
end
end

View File

@ -1,5 +1,18 @@
import { equal } from "@ember/object/computed";
export default Ember.Component.extend({ export default Ember.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.plans.map(p => p.set("selected", false));
plan.set("selected", true); plan.set("selected", true);

View File

@ -1,4 +1,4 @@
import computed from "ember-addons/ember-computed-decorators"; import computed from "discourse-common/utils/decorators";
import User from "discourse/models/user"; import User from "discourse/models/user";
export default Ember.Component.extend({ export default Ember.Component.extend({

View File

@ -1,7 +1,22 @@
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 computed from "discourse-common/utils/decorators";
import { i18n } from "discourse/lib/computed";
export default Ember.Controller.extend({ export default Ember.Controller.extend({
planTypeIsSelected: true,
@computed("planTypeIsSelected")
type(planTypeIsSelected) {
return planTypeIsSelected ? "plans" : "payment";
},
@computed("type")
buttonText(type) {
return I18n.t(`discourse_subscriptions.${this.get("type")}.payment_button`);
},
init() { init() {
this._super(...arguments); this._super(...arguments);
this.set( this.set(
@ -13,54 +28,83 @@ export default Ember.Controller.extend({
this.set("cardElement", elements.create("card", { hidePostalCode: true })); this.set("cardElement", elements.create("card", { hidePostalCode: true }));
}, },
alert(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();
});
},
createSubsciption(plan) {
return this.stripe.createToken(this.get("cardElement")).then(result => {
if (result.error) {
return result;
} else {
const customer = Customer.create({ source: result.token.id });
return customer.save().then(c => {
const subscription = Subscription.create({
customer: c.id,
plan: plan.get("id")
});
return subscription.save();
});
}
});
},
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("selected")
.get("firstObject"); .get("firstObject");
if (!plan) { if (!plan) {
bootbox.alert( this.alert(`${type}.validate.payment_options.required`);
I18n.t(
"discourse_subscriptions.transactions.payment.validate.plan.required"
)
);
this.set("loading", false); this.set("loading", false);
return; return;
} }
this.stripe.createToken(this.get("cardElement")).then(result => { let transaction;
if (result.error) {
bootbox.alert(result.error.message); if (this.planTypeIsSelected) {
transaction = this.createSubsciption(plan);
} else {
transaction = this.createPayment(plan);
}
transaction
.then(result => {
if (result.error) {
bootbox.alert(result.error.message || result.error);
} else {
this.alert(`${type}.success`);
this.transitionToRoute(
"user.subscriptions",
Discourse.User.current().username.toLowerCase()
);
}
})
.catch(result => {
bootbox.alert(result.errorThrown);
})
.finally(() => {
this.set("loading", false); this.set("loading", false);
} else { });
const customer = Customer.create({ source: result.token.id });
customer.save().then(c => {
const subscription = Subscription.create({
customer: c.id,
plan: plan.get("id")
});
subscription
.save()
.then(() => {
bootbox.alert(
I18n.t("discourse_subscriptions.transactions.payment.success")
);
this.transitionToRoute(
"user.subscriptions",
Discourse.User.current().username.toLowerCase()
);
})
.finally(() => {
this.set("loading", false);
});
});
}
});
} }
} }
}); });

View File

@ -0,0 +1,15 @@
import { ajax } from "discourse/lib/ajax";
const Payment = Discourse.Model.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

@ -1,27 +1,35 @@
{{#ds-button <div class="subscribe-buttons">
id="discourse-subscriptions-payment-type-plan" {{#ds-button
selected=true id="discourse-subscriptions-payment-type-plan"
class="btn-discourse-subscriptions-payment-type" selected=planButtonSelected
}} action="selectPlans"
{{i18n "discourse_subscriptions.plans.purchase"}} class="btn-discourse-subscriptions-payment-type"
{{/ds-button}} }}
{{i18n "discourse_subscriptions.plans.purchase"}}
{{/ds-button}}
{{#ds-button {{#ds-button
id="discourse-subscriptions-payment-type-payment" id="discourse-subscriptions-payment-type-payment"
selected=false selected=paymentButtonSelected
class="btn-discourse-subscriptions-payment-type" action="selectPayments"
}} class="btn-discourse-subscriptions-payment-type"
{{i18n "discourse_subscriptions.payment.purchase"}} }}
{{/ds-button}} {{i18n "discourse_subscriptions.payment.purchase"}}
{{/ds-button}}
</div>
<hr> <hr>
<p> <p>
{{i18n "discourse_subscriptions.plans.select"}} {{#if planTypeIsSelected}}
{{i18n "discourse_subscriptions.plans.select"}}
{{else}}
{{i18n "discourse_subscriptions.payment.select"}}
{{/if}}
</p> </p>
<div id="subscribe-buttons"> <div class="subscribe-buttons">
{{#each plans as |plan|}} {{#each plans as |plan|}}
{{#ds-button {{#ds-button
action="clickPlan" action="clickPlan"
@ -30,7 +38,11 @@
class="btn-discourse-subscriptions-subscribe" class="btn-discourse-subscriptions-subscribe"
}} }}
<div class="interval"> <div class="interval">
{{i18n (concat "discourse_subscriptions.plans.interval.adverb." plan.interval)}} {{#if planTypeIsSelected}}
{{i18n (concat "discourse_subscriptions.plans.interval.adverb." plan.interval)}}
{{else}}
{{i18n "discourse_subscriptions.payment.interval"}}
{{/if}}
</div> </div>
<span class="amount"> <span class="amount">
{{format-currency plan.currency plan.amountDollars}} {{format-currency plan.currency plan.amountDollars}}

View File

@ -14,7 +14,10 @@
{{i18n 'discourse_subscriptions.subscribe.card.title'}} {{i18n 'discourse_subscriptions.subscribe.card.title'}}
</h2> </h2>
{{payment-options plans=model.plans}} {{payment-options
plans=model.plans
planTypeIsSelected=planTypeIsSelected
}}
<hr> <hr>
@ -27,7 +30,7 @@
disabled=loading disabled=loading
action="stripePaymentHandler" action="stripePaymentHandler"
class="btn btn-primary btn-payment"}} class="btn btn-primary btn-payment"}}
{{i18n 'discourse_subscriptions.subscribe.buttons.subscribe'}} {{buttonText}}
{{/d-button}} {{/d-button}}
{{/if}} {{/if}}

View File

@ -1,4 +1,4 @@
#subscribe-buttons { .subscribe-buttons {
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;

View File

@ -18,26 +18,35 @@ en:
title: Discourse Subscriptions title: Discourse Subscriptions
admin_navigation: Subscriptions admin_navigation: Subscriptions
optional: Optional optional: Optional
transactions:
payment:
success: Your payment was successful
validate:
plan:
required: Please select a subscription plan.
navigation: navigation:
subscriptions: Subscriptions subscriptions: Subscriptions
subscribe: Subscribe subscribe: Subscribe
billing: Billing billing: Billing
plans: plans:
select: Select subscription plan
purchase: Purchase a subscription purchase: Purchase a subscription
select: Select subscription plan
interval: interval:
adverb: adverb:
week: Weekly week: Weekly
month: Monthly month: Monthly
year: Yearly year: Yearly
payment_button:
Subscribe
success: Thank you! Your subscription has been created.
validate:
payment_options:
required: Please select a subscription plan.
payment: payment:
purchase: Make just one payment purchase: Make just one payment
select: Select a payment option
interval:
One payment
payment_button:
Pay Once
success: Thank you!
validate:
payment_options:
required: Please select a payment option.
one_time: one_time:
heading: heading:
payment: Make a Payment payment: Make a Payment

View File

@ -18,6 +18,7 @@ DiscourseSubscriptions::Engine.routes.draw do
resources :customers, only: [:create] resources :customers, only: [:create]
resources :invoices, only: [:index] resources :invoices, only: [:index]
resources :payments, only: [:create]
resources :patrons, only: [:index, :create] resources :patrons, only: [:index, :create]
resources :plans, only: [:index] resources :plans, only: [:index]
resources :products, only: [:index, :show] resources :products, only: [:index, :show]

View File

@ -62,6 +62,7 @@ after_initialize do
"../app/controllers/invoices_controller", "../app/controllers/invoices_controller",
"../app/controllers/patrons_controller", "../app/controllers/patrons_controller",
"../app/controllers/plans_controller", "../app/controllers/plans_controller",
"../app/controllers/payments_controller",
"../app/controllers/products_controller", "../app/controllers/products_controller",
"../app/controllers/subscriptions_controller", "../app/controllers/subscriptions_controller",
"../app/models/customer", "../app/models/customer",

View File

@ -0,0 +1,40 @@
# 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: { }
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::PaymentIntent.expects(:create).with(
payment_method_types: ['card'],
payment_method: 'pm_123',
amount: '999',
currency: 'gdp',
confirm: true
)
post "/s/payments.json", params: {
payment_method: 'pm_123',
amount: 999,
currency: 'gdp'
}
end
end
end
end
end

View File

@ -18,7 +18,7 @@ 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 plans=plans}}`, template: `{{payment-options plans=plans planTypeIsSelected=planTypeIsSelected}}`,
async test(assert) { async test(assert) {
this.set("plans", [ this.set("plans", [
@ -26,21 +26,8 @@ componentTest("Discourse Subscriptions payment options has content", {
{ currency: "gdp", interval: "month", amountDollars: "9.99" } { currency: "gdp", interval: "month", amountDollars: "9.99" }
]); ]);
assert.equal( this.set("planTypeIsSelected", true);
find(".btn-discourse-subscriptions-payment-type").length,
2,
"The payment type buttons are shown"
);
assert.equal(
find("#discourse-subscriptions-payment-type-plan.btn-primary").length,
1,
"The plan payment type button is selected"
);
assert.equal(
find("#discourse-subscriptions-payment-type-payment.btn-primary").length,
0,
"The single payment type button is not selected"
);
assert.equal( assert.equal(
find(".btn-discourse-subscriptions-payment-type").length, find(".btn-discourse-subscriptions-payment-type").length,
2, 2,
@ -73,22 +60,40 @@ componentTest("Discourse Subscriptions payment options has content", {
} }
}); });
componentTest("Discourse Subscriptions payment options plan is selected", { componentTest("Discourse Subscriptions payment type plan", {
template: `{{payment-options plans=plans}}`, template: `{{payment-options plans=plans planTypeIsSelected=planTypeIsSelected}}`,
beforeEach() {},
async test(assert) { async test(assert) {
this.set("plans", [ this.set("plans", [
EmberObject.create({ { currency: "aud", interval: "year", amountDollars: "44.99" }
currency: "aud",
interval: "year",
amountDollars: "44.99"
})
]); ]);
await click(".btn-discourse-subscriptions-subscribe:first-child"); this.set("planTypeIsSelected", true);
assert.ok(this.get("plans.firstObject.selected"), "it selected the plan"); 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"
);
} }
}); });