FEATURE: Implement refunds from dashboard (#27)

An implementation of refunds from the Admin dashboard. To refund, go to Plugins > Subscriptions > Subscriptions then click the `Cancel` button. You'll be presented with a modal. If you wish to refund only the most recent payment, check the box. 

This only implements refunds for Subscriptions, not One Time Payments. One Time Payments will still need to be handled manually at this time.
This commit is contained in:
Justin DiRose 2020-10-29 10:31:12 -05:00 committed by GitHub
parent b950926538
commit b92627677f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 87 additions and 27 deletions

View File

@ -26,7 +26,9 @@ module DiscourseSubscriptions
end end
def destroy def destroy
params.require(:id)
begin begin
refund_subscription(params[:id]) if params[:refund]
subscription = ::Stripe::Subscription.delete(params[:id]) subscription = ::Stripe::Subscription.delete(params[:id])
customer = Customer.find_by( customer = Customer.find_by(
@ -49,6 +51,18 @@ module DiscourseSubscriptions
render_json_error e.message render_json_error e.message
end end
end end
private
# this will only refund the most recent subscription payment
def refund_subscription(subscription_id)
subscription = ::Stripe::Subscription.retrieve(subscription_id)
invoice = ::Stripe::Invoice.retrieve(subscription[:latest_invoice]) if subscription[:latest_invoice]
payment_intent = invoice[:payment_intent] if invoice[:payment_intent]
refund = ::Stripe::Refund.create({
payment_intent: payment_intent,
})
end
end end
end end
end end

View File

@ -1,3 +1,12 @@
import Controller from "@ember/controller"; import Controller from "@ember/controller";
import showModal from "discourse/lib/show-modal";
export default Controller.extend({}); export default Controller.extend({
actions: {
showCancelModal(subscription) {
showModal("admin-cancel-subscription", {
model: subscription,
});
},
},
});

View File

@ -19,9 +19,13 @@ const AdminSubscription = EmberObject.extend({
return getURL(`/admin/users/${metadata.user_id}/${metadata.username}`); return getURL(`/admin/users/${metadata.user_id}/${metadata.username}`);
}, },
destroy() { destroy(refund) {
const data = {
refund: refund,
};
return ajax(`/s/admin/subscriptions/${this.id}`, { return ajax(`/s/admin/subscriptions/${this.id}`, {
method: "delete", method: "delete",
data,
}).then((result) => AdminSubscription.create(result)); }).then((result) => AdminSubscription.create(result));
}, },
}); });

View File

@ -1,6 +1,6 @@
import I18n from "I18n";
import Route from "@ember/routing/route"; import Route from "@ember/routing/route";
import AdminSubscription from "discourse/plugins/discourse-subscriptions/discourse/models/admin-subscription"; import AdminSubscription from "discourse/plugins/discourse-subscriptions/discourse/models/admin-subscription";
import I18n from "I18n";
export default Route.extend({ export default Route.extend({
model() { model() {
@ -8,29 +8,24 @@ export default Route.extend({
}, },
actions: { actions: {
cancelSubscription(subscription) { cancelSubscription(model) {
bootbox.confirm( const subscription = model.subscription;
I18n.t( const refund = model.refund;
"discourse_subscriptions.user.subscriptions.operations.destroy.confirm"
),
I18n.t("no_value"),
I18n.t("yes_value"),
(confirmed) => {
if (confirmed) {
subscription.set("loading", true); subscription.set("loading", true);
subscription subscription
.destroy() .destroy(refund)
.then((result) => subscription.set("status", result.status)) .then((result) => {
subscription.set("status", result.status);
this.send("closeModal");
bootbox.alert(I18n.t("discourse_subscriptions.admin.canceled"));
})
.catch((data) => .catch((data) =>
bootbox.alert(data.jqXHR.responseJSON.errors.join("\n")) bootbox.alert(data.jqXHR.responseJSON.errors.join("\n"))
) )
.finally(() => { .finally(() => {
subscription.set("loading", false); subscription.set("loading", false);
this.refresh();
}); });
}
}
);
}, },
}, },
}); });

View File

@ -34,7 +34,7 @@
{{#if subscription.loading}} {{#if subscription.loading}}
{{loading-spinner size="small"}} {{loading-spinner size="small"}}
{{else}} {{else}}
{{d-button disabled=subscription.canceled label="cancel" action=(route-action "cancelSubscription" subscription) icon="times"}} {{d-button disabled=subscription.canceled label="cancel" action=(action "showCancelModal" subscription) icon="times"}}
{{/if}} {{/if}}
</td> </td>
</tr> </tr>

View File

@ -0,0 +1,21 @@
<div>
{{#d-modal-body rawTitle=(i18n "discourse_subscriptions.user.subscriptions.operations.destroy.confirm")}}
{{input type="checkbox" checked=refund}}
{{i18n "discourse_subscriptions.admin.ask_refund"}}
{{/d-modal-body}}
<div class="modal-footer">
{{#if model.loading}}
{{loading-spinner}}
{{else}}
{{d-button
label="yes_value"
action=(route-action "cancelSubscription" (hash subscription=model refund=refund))
icon="times"
class="btn-danger"
}}
{{d-button label="no_value" action=(route-action "closeModal")}}
{{/if}}
</div>
</div>

View File

@ -148,3 +148,5 @@ en:
plan: Plan plan: Plan
status: Status status: Status
created_at: Created created_at: Created
ask_refund: Refund the last payment made by the customer?
canceled: "The subscription is canceled."

View File

@ -99,6 +99,21 @@ module DiscourseSubscriptions
delete "/s/admin/subscriptions/sub_12345.json" delete "/s/admin/subscriptions/sub_12345.json"
}.not_to change { user.groups.count } }.not_to change { user.groups.count }
end end
it "refunds if params[:refund] present" do
::Stripe::Subscription
.expects(:delete)
.with('sub_12345')
.returns(
plan: { product: 'pr_34578' },
customer: 'c_123'
)
::Stripe::Subscription.expects(:retrieve).with('sub_12345').returns(latest_invoice: 'in_123')
::Stripe::Invoice.expects(:retrieve).with('in_123').returns(payment_intent: 'pi_123')
::Stripe::Refund.expects(:create).with(payment_intent: 'pi_123')
delete "/s/admin/subscriptions/sub_12345.json", params: { refund: true }
end
end end
end end
end end