FIX: Allow user to update card details for recurring subscriptions (#123)
* add new route for card update * create backend route * update label * basic functionality working * ran rubocop * added rspec tests for functionality * make payment_method param compulsory * fixed js linting * improve client side error handling * improve server side error handling * improved update card page UI * improve button UI for user subscriptions page * give feedback to user about save status * remove heading from last column * fix padding on edit/delete buttons for update table Co-authored-by: Blake Erickson <o.blakeerickson@gmail.com>
This commit is contained in:
parent
d4d92d9653
commit
945af4f140
|
@ -63,6 +63,31 @@ module DiscourseSubscriptions
|
||||||
render_json_error e.message
|
render_json_error e.message
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
params.require(:payment_method)
|
||||||
|
|
||||||
|
subscription = Subscription.where(external_id: params[:id]).first
|
||||||
|
begin
|
||||||
|
attach_method_to_customer(subscription.customer_id, params[:payment_method])
|
||||||
|
subscription = ::Stripe::Subscription.update(params[:id], { default_payment_method: params[:payment_method] })
|
||||||
|
render json: success_json
|
||||||
|
rescue ::Stripe::InvalidRequestError
|
||||||
|
render_json_error I18n.t("discourse_subscriptions.card.invalid")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def attach_method_to_customer(customer_id, method)
|
||||||
|
customer = Customer.find(customer_id)
|
||||||
|
::Stripe::PaymentMethod.attach(
|
||||||
|
method,
|
||||||
|
{
|
||||||
|
customer: customer.customer_id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,56 @@
|
||||||
|
import Controller from "@ember/controller";
|
||||||
|
import { action } from "@ember/object";
|
||||||
|
import { ajax } from "discourse/lib/ajax";
|
||||||
|
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||||
|
import I18n from "I18n";
|
||||||
|
import bootbox from "bootbox";
|
||||||
|
|
||||||
|
export default Controller.extend({
|
||||||
|
loading: false,
|
||||||
|
saved: false,
|
||||||
|
init() {
|
||||||
|
this._super(...arguments);
|
||||||
|
this.set(
|
||||||
|
"stripe",
|
||||||
|
Stripe(this.siteSettings.discourse_subscriptions_public_key)
|
||||||
|
);
|
||||||
|
const elements = this.get("stripe").elements();
|
||||||
|
this.set("cardElement", elements.create("card", { hidePostalCode: true }));
|
||||||
|
},
|
||||||
|
|
||||||
|
@action
|
||||||
|
async updatePaymentMethod() {
|
||||||
|
this.set("loading", true);
|
||||||
|
this.set("saved", false);
|
||||||
|
|
||||||
|
const paymentMethodObject = await this.stripe.createPaymentMethod({
|
||||||
|
type: "card",
|
||||||
|
card: this.cardElement,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (paymentMethodObject.error) {
|
||||||
|
bootbox.alert(
|
||||||
|
paymentMethodObject.error?.message || I18n.t("generic_error")
|
||||||
|
);
|
||||||
|
this.set("loading", false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentMethod = paymentMethodObject.paymentMethod.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ajax(`/s/user/subscriptions/${this.model}`, {
|
||||||
|
method: "PUT",
|
||||||
|
data: {
|
||||||
|
payment_method: paymentMethod,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.set("saved", true);
|
||||||
|
} catch (err) {
|
||||||
|
popupAjaxError(err);
|
||||||
|
} finally {
|
||||||
|
this.set("loading", false);
|
||||||
|
this.cardElement?.clear();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
|
@ -4,7 +4,9 @@ export default {
|
||||||
map() {
|
map() {
|
||||||
this.route("billing", function () {
|
this.route("billing", function () {
|
||||||
this.route("payments");
|
this.route("payments");
|
||||||
this.route("subscriptions");
|
this.route("subscriptions", function () {
|
||||||
|
this.route("card", { path: "/card/:stripe-subscription-id" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -4,6 +4,6 @@ export default Route.extend({
|
||||||
templateName: "user/billing/index",
|
templateName: "user/billing/index",
|
||||||
|
|
||||||
redirect() {
|
redirect() {
|
||||||
this.transitionTo("user.billing.subscriptions");
|
this.transitionTo("user.billing.subscriptions.index");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
import Route from "@ember/routing/route";
|
||||||
|
|
||||||
|
export default Route.extend({
|
||||||
|
model(params) {
|
||||||
|
return params["stripe-subscription-id"];
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1,42 @@
|
||||||
|
import Route from "@ember/routing/route";
|
||||||
|
import UserSubscription from "discourse/plugins/discourse-subscriptions/discourse/models/user-subscription";
|
||||||
|
import I18n from "I18n";
|
||||||
|
import { action } from "@ember/object";
|
||||||
|
import bootbox from "bootbox";
|
||||||
|
|
||||||
|
export default Route.extend({
|
||||||
|
model() {
|
||||||
|
return UserSubscription.findAll();
|
||||||
|
},
|
||||||
|
|
||||||
|
@action
|
||||||
|
updateCard(subscriptionId) {
|
||||||
|
this.transitionTo("user.billing.subscriptions.card", subscriptionId);
|
||||||
|
},
|
||||||
|
@action
|
||||||
|
cancelSubscription(subscription) {
|
||||||
|
bootbox.confirm(
|
||||||
|
I18n.t(
|
||||||
|
"discourse_subscriptions.user.subscriptions.operations.destroy.confirm"
|
||||||
|
),
|
||||||
|
I18n.t("no_value"),
|
||||||
|
I18n.t("yes_value"),
|
||||||
|
(confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
subscription.set("loading", true);
|
||||||
|
|
||||||
|
subscription
|
||||||
|
.destroy()
|
||||||
|
.then((result) => subscription.set("status", result.status))
|
||||||
|
.catch((data) =>
|
||||||
|
bootbox.alert(data.jqXHR.responseJSON.errors.join("\n"))
|
||||||
|
)
|
||||||
|
.finally(() => {
|
||||||
|
subscription.set("loading", false);
|
||||||
|
this.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
|
@ -1,40 +1,3 @@
|
||||||
import Route from "@ember/routing/route";
|
import Route from "@ember/routing/route";
|
||||||
import UserSubscription from "discourse/plugins/discourse-subscriptions/discourse/models/user-subscription";
|
|
||||||
import I18n from "I18n";
|
|
||||||
import { action } from "@ember/object";
|
|
||||||
import bootbox from "bootbox";
|
|
||||||
|
|
||||||
export default Route.extend({
|
export default Route.extend();
|
||||||
templateName: "user/billing/subscriptions",
|
|
||||||
|
|
||||||
model() {
|
|
||||||
return UserSubscription.findAll();
|
|
||||||
},
|
|
||||||
|
|
||||||
@action
|
|
||||||
cancelSubscription(subscription) {
|
|
||||||
bootbox.confirm(
|
|
||||||
I18n.t(
|
|
||||||
"discourse_subscriptions.user.subscriptions.operations.destroy.confirm"
|
|
||||||
),
|
|
||||||
I18n.t("no_value"),
|
|
||||||
I18n.t("yes_value"),
|
|
||||||
(confirmed) => {
|
|
||||||
if (confirmed) {
|
|
||||||
subscription.set("loading", true);
|
|
||||||
|
|
||||||
subscription
|
|
||||||
.destroy()
|
|
||||||
.then((result) => subscription.set("status", result.status))
|
|
||||||
.catch((data) =>
|
|
||||||
bootbox.alert(data.jqXHR.responseJSON.errors.join("\n"))
|
|
||||||
)
|
|
||||||
.finally(() => {
|
|
||||||
subscription.set("loading", false);
|
|
||||||
this.refresh();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
<h3>{{i18n "discourse_subscriptions.user.subscriptions.update_card.heading" sub_id=model}}</h3>
|
||||||
|
|
||||||
|
<div class="form-vertical">
|
||||||
|
<div class="control-group">
|
||||||
|
{{subscribe-card
|
||||||
|
cardElement=cardElement
|
||||||
|
class="input-xxlarge"
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{save-controls
|
||||||
|
action=(action "updatePaymentMethod")
|
||||||
|
saved=saved
|
||||||
|
saveDisabled=loading
|
||||||
|
savingText="discourse_subscriptions.user.subscriptions.update_card.single"
|
||||||
|
}}
|
||||||
|
</div>
|
|
@ -31,10 +31,15 @@
|
||||||
}}
|
}}
|
||||||
{{else}}
|
{{else}}
|
||||||
{{d-button
|
{{d-button
|
||||||
|
action= (route-action "updateCard" subscription.id)
|
||||||
|
icon="far-edit"
|
||||||
|
class="btn no-text btn-icon"
|
||||||
|
}}
|
||||||
|
{{d-button
|
||||||
|
class="btn-danger btn no-text btn-icon"
|
||||||
|
icon="trash-alt"
|
||||||
disabled=subscription.canceled_at
|
disabled=subscription.canceled_at
|
||||||
label="discourse_subscriptions.user.subscriptions.cancel"
|
|
||||||
action=(route-action "cancelSubscription" subscription)
|
action=(route-action "cancelSubscription" subscription)
|
||||||
icon="times"
|
|
||||||
}}
|
}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
|
@ -48,12 +48,12 @@ table.discourse-subscriptions-user-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
th,
|
th,
|
||||||
td {
|
td {
|
||||||
padding: 10px;
|
padding-top: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
padding-left: 8px;
|
||||||
}
|
}
|
||||||
th:first-child,
|
th:first-child,
|
||||||
td:first-child,
|
td:first-child {
|
||||||
th:last-child,
|
|
||||||
td:last-child {
|
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -91,6 +91,9 @@ en:
|
||||||
status: Status
|
status: Status
|
||||||
discounted: Discounted
|
discounted: Discounted
|
||||||
renews: Renews
|
renews: Renews
|
||||||
|
update_card:
|
||||||
|
heading: "Update Card for subscription: %{sub_id}"
|
||||||
|
single: "Update"
|
||||||
created_at: Created
|
created_at: Created
|
||||||
cancel: cancel
|
cancel: cancel
|
||||||
cancelled: cancelled
|
cancelled: cancelled
|
||||||
|
|
|
@ -3,3 +3,4 @@ en:
|
||||||
customer_not_found: Customer not found
|
customer_not_found: Customer not found
|
||||||
card:
|
card:
|
||||||
declined: Card Declined
|
declined: Card Declined
|
||||||
|
invalid: Card Invalid
|
||||||
|
|
|
@ -18,7 +18,7 @@ DiscourseSubscriptions::Engine.routes.draw do
|
||||||
|
|
||||||
namespace :user do
|
namespace :user do
|
||||||
resources :payments, only: [:index]
|
resources :payments, only: [:index]
|
||||||
resources :subscriptions, only: [:index, :destroy]
|
resources :subscriptions, only: [:index, :update, :destroy]
|
||||||
end
|
end
|
||||||
|
|
||||||
get '/' => 'subscribe#index'
|
get '/' => 'subscribe#index'
|
||||||
|
|
|
@ -40,6 +40,7 @@ Discourse::Application.routes.append do
|
||||||
get '/admin/plugins/discourse-subscriptions/coupons' => 'admin/plugins#index', constraints: AdminConstraint.new
|
get '/admin/plugins/discourse-subscriptions/coupons' => '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 }
|
||||||
|
get 'u/:username/billing/subscriptions/card/:subscription_id' => 'users#show', constraints: { username: USERNAME_ROUTE_FORMAT }
|
||||||
end
|
end
|
||||||
|
|
||||||
load File.expand_path('lib/discourse_subscriptions/engine.rb', __dir__)
|
load File.expand_path('lib/discourse_subscriptions/engine.rb', __dir__)
|
||||||
|
|
|
@ -18,6 +18,12 @@ module DiscourseSubscriptions
|
||||||
::Stripe::Subscription.expects(:delete).never
|
::Stripe::Subscription.expects(:delete).never
|
||||||
patch "/s/user/subscriptions/sub_12345.json"
|
patch "/s/user/subscriptions/sub_12345.json"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "doesn't update payment method for subscription" do
|
||||||
|
::Stripe::Subscription.expects(:update).never
|
||||||
|
::Stripe::PaymentMethod.expects(:attach).never
|
||||||
|
put "/s/user/subscriptions/sub_12345.json", params: { payment_method: "pm_abc123abc" }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context "authenticated" do
|
context "authenticated" do
|
||||||
|
@ -82,6 +88,14 @@ module DiscourseSubscriptions
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "update" do
|
||||||
|
it "updates the payment method for subscription" do
|
||||||
|
::Stripe::Subscription.expects(:update).once
|
||||||
|
::Stripe::PaymentMethod.expects(:attach).once
|
||||||
|
put "/s/user/subscriptions/sub_1234.json", params: { payment_method: "pm_abc123abc" }
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in New Issue