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:
Faizaan Gagan 2022-07-06 07:23:27 +05:30 committed by GitHub
parent d4d92d9653
commit 945af4f140
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 183 additions and 47 deletions

View File

@ -63,6 +63,31 @@ module DiscourseSubscriptions
render_json_error e.message
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

View File

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

View File

@ -4,7 +4,9 @@ export default {
map() {
this.route("billing", function () {
this.route("payments");
this.route("subscriptions");
this.route("subscriptions", function () {
this.route("card", { path: "/card/:stripe-subscription-id" });
});
});
},
};

View File

@ -4,6 +4,6 @@ export default Route.extend({
templateName: "user/billing/index",
redirect() {
this.transitionTo("user.billing.subscriptions");
this.transitionTo("user.billing.subscriptions.index");
},
});

View File

@ -0,0 +1,7 @@
import Route from "@ember/routing/route";
export default Route.extend({
model(params) {
return params["stripe-subscription-id"];
},
});

View File

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

View File

@ -1,40 +1,3 @@
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({
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();
});
}
}
);
},
});
export default Route.extend();

View File

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

View File

@ -31,10 +31,15 @@
}}
{{else}}
{{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
label="discourse_subscriptions.user.subscriptions.cancel"
action=(route-action "cancelSubscription" subscription)
icon="times"
}}
{{/if}}
{{/if}}

View File

@ -48,12 +48,12 @@ table.discourse-subscriptions-user-table {
width: 100%;
th,
td {
padding: 10px;
padding-top: 8px;
padding-bottom: 8px;
padding-left: 8px;
}
th:first-child,
td:first-child,
th:last-child,
td:last-child {
td:first-child {
padding-left: 0;
}
}

View File

@ -91,6 +91,9 @@ en:
status: Status
discounted: Discounted
renews: Renews
update_card:
heading: "Update Card for subscription: %{sub_id}"
single: "Update"
created_at: Created
cancel: cancel
cancelled: cancelled

View File

@ -3,3 +3,4 @@ en:
customer_not_found: Customer not found
card:
declined: Card Declined
invalid: Card Invalid

View File

@ -18,7 +18,7 @@ DiscourseSubscriptions::Engine.routes.draw do
namespace :user do
resources :payments, only: [:index]
resources :subscriptions, only: [:index, :destroy]
resources :subscriptions, only: [:index, :update, :destroy]
end
get '/' => 'subscribe#index'

View File

@ -40,6 +40,7 @@ Discourse::Application.routes.append do
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/:id' => 'users#show', constraints: { username: USERNAME_ROUTE_FORMAT }
get 'u/:username/billing/subscriptions/card/:subscription_id' => 'users#show', constraints: { username: USERNAME_ROUTE_FORMAT }
end
load File.expand_path('lib/discourse_subscriptions/engine.rb', __dir__)

View File

@ -18,6 +18,12 @@ module DiscourseSubscriptions
::Stripe::Subscription.expects(:delete).never
patch "/s/user/subscriptions/sub_12345.json"
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
context "authenticated" do
@ -82,6 +88,14 @@ module DiscourseSubscriptions
)
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