FEATURE: Cancel payments at end of subscription vs immediately

Previously, when a user canceled a subscription, the access would revoke
immediately on Discourse vs. at the end of the billing period. This
commit changes the behavior to remove membership at the end of the
billing period using Stripe's `cancel_at_period_end` attribute on the
Subscription object.

This commit now requires the setup of webhooks for subscription
processing to occur correctly.
This commit is contained in:
Justin DiRose 2020-08-19 14:37:47 -05:00
parent 91824dcdae
commit a868e6b838
No known key found for this signature in database
GPG Key ID: 4B811FB264021800
7 changed files with 38 additions and 187 deletions

View File

@ -3,6 +3,10 @@
module DiscourseSubscriptions module DiscourseSubscriptions
class HooksController < ::ApplicationController class HooksController < ::ApplicationController
include DiscourseSubscriptions::Group include DiscourseSubscriptions::Group
include DiscourseSubscriptions::Stripe
layout false
skip_before_action :check_xhr
skip_before_action :redirect_to_login_if_required
skip_before_action :verify_authenticity_token, only: [:create] skip_before_action :verify_authenticity_token, only: [:create]
def create def create
@ -15,7 +19,7 @@ module DiscourseSubscriptions
rescue JSON::ParserError => e rescue JSON::ParserError => e
render_json_error e.message render_json_error e.message
return return
rescue Stripe::SignatureVerificationError => e rescue ::Stripe::SignatureVerificationError => e
render_json_error e.message render_json_error e.message
return return
end end
@ -34,19 +38,7 @@ module DiscourseSubscriptions
end end
when 'customer.subscription.deleted' when 'customer.subscription.deleted'
delete_subscription(event)
customer = Customer.find_by(
customer_id: event[:data][:object][:customer],
product_id: event[:data][:object][:plan][:product]
)
if customer
customer.delete
user = ::User.find(customer.user_id)
group = plan_group(event[:data][:object][:plan])
group.remove(user) if group
end
end end
head 200 head 200
@ -59,11 +51,32 @@ module DiscourseSubscriptions
end end
def subscription_complete?(event) def subscription_complete?(event)
event.dig(:data, :object, :status) == 'complete' event && event[:data] && event[:data][:object] && event[:data][:object][:status] && event[:data][:object][:status] == 'complete'
end end
def previously_incomplete?(event) def previously_incomplete?(event)
event.dig(:data, :previous_attributes, :status) == 'incomplete' event && event[:data] && event[:data][:previous_attributes] && event[:data][:previous_attributes][:status] && event[:data][:previous_attributes][:status] == 'incomplete'
end
def delete_subscription(event)
customer = Customer.find_by(
customer_id: event[:data][:object][:customer],
product_id: event[:data][:object][:plan][:product]
)
if customer
sub_model = Subscription.find_by(
customer_id: customer.id,
external_id: [:id]
)
sub_model.delete if sub_model
user = ::User.find(customer.user_id)
customer.delete
group = plan_group(event[:data][:object][:plan])
group.remove(user) if group
end
end end
end end
end end

View File

@ -48,30 +48,13 @@ module DiscourseSubscriptions
end end
def destroy def destroy
# we cancel but don't remove until the end of the period
# full removal is done via webhooks
begin begin
subscription = ::Stripe::Subscription.retrieve(params[:id]) subscription = ::Stripe::Subscription.update(params[:id], { cancel_at_period_end: true, } )
customer = Customer.find_by( if subscription
user_id: current_user.id, render_json_dump subscription
customer_id: subscription[:customer],
product_id: subscription[:plan][:product]
)
if customer.present?
sub_model = Subscription.find_by(
customer_id: customer.id,
external_id: params[:id]
)
deleted = ::Stripe::Subscription.delete(params[:id])
customer.delete
sub_model.delete if sub_model
group = plan_group(subscription[:plan])
group.remove(current_user) if group
render_json_dump deleted
else else
render_json_error I18n.t('discourse_subscriptions.customer_not_found') render_json_error I18n.t('discourse_subscriptions.customer_not_found')
end end

View File

@ -29,6 +29,7 @@ export default Route.extend({
) )
.finally(() => { .finally(() => {
subscription.set("loading", false); subscription.set("loading", false);
this.refresh();
}); });
} }
} }

View File

@ -19,7 +19,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_at label="discourse_subscriptions.user.subscriptions.cancelled" action=(route-action "cancelSubscription" subscription) icon="times"}}
{{/if}} {{/if}}
</td> </td>
</tr> </tr>

View File

@ -51,6 +51,7 @@ en:
id: Subscription ID id: Subscription ID
status: Status status: Status
created_at: Created created_at: Created
cancelled: cancelled
operations: operations:
destroy: destroy:
confirm: Are you sure you want to cancel this subscription? confirm: Are you sure you want to cancel this subscription?

View File

@ -19,13 +19,13 @@ DiscourseSubscriptions::Engine.routes.draw do
end end
resources :customers, only: [:create] resources :customers, only: [:create]
resources :hooks, only: [:create]
resources :plans, only: [:index], constraints: SubscriptionsUserConstraint.new resources :plans, only: [:index], constraints: SubscriptionsUserConstraint.new
resources :products, only: [:index, :show] resources :products, only: [:index, :show]
resources :subscriptions, only: [:create] resources :subscriptions, only: [:create]
post '/subscriptions/finalize' => 'subscriptions#finalize' post '/subscriptions/finalize' => 'subscriptions#finalize'
post '/hooks' => 'hooks#create'
get '/' => 'subscriptions#index', constraints: SubscriptionsUserConstraint.new get '/' => 'subscriptions#index', constraints: SubscriptionsUserConstraint.new
get '/:id' => 'subscriptions#index', constraints: SubscriptionsUserConstraint.new get '/:id' => 'subscriptions#index', constraints: SubscriptionsUserConstraint.new
end end

View File

@ -82,153 +82,6 @@ module DiscourseSubscriptions
) )
end end
end end
describe "delete" do
let(:group) { Fabricate(:group, name: 'subscribers') }
before do
# Users can have more than one customer id
Customer.create(user_id: user.id, customer_id: 'customer_id_1', product_id: 'p_1')
Customer.create(user_id: user.id, customer_id: 'customer_id_1', product_id: 'p_2')
Customer.create(user_id: user.id, customer_id: 'customer_id_2', product_id: 'p_2')
group.add(user)
end
it "does not delete a subscription when the customer is wrong" do
::Stripe::Subscription
.expects(:retrieve)
.with('sub_12345')
.returns(
plan: { product: 'p_1' },
customer: 'wrong_id'
)
::Stripe::Subscription
.expects(:delete)
.never
expect {
delete "/s/user/subscriptions/sub_12345.json"
}.not_to change { DiscourseSubscriptions::Customer.count }
expect(response.status).to eq 422
end
it "does not deletes the subscription when the product is wrong" do
::Stripe::Subscription
.expects(:retrieve)
.with('sub_12345')
.returns(
plan: { product: 'p_wrong' },
customer: 'customer_id_2'
)
::Stripe::Subscription
.expects(:delete)
.never
expect {
delete "/s/user/subscriptions/sub_12345.json"
}.not_to change { DiscourseSubscriptions::Customer.count }
expect(response.status).to eq 422
end
it "removes the user from the group" do
::Stripe::Subscription
.expects(:retrieve)
.with('sub_12345')
.returns(
plan: { product: 'p_1', metadata: { group_name: 'subscribers' } },
customer: 'customer_id_1'
)
::Stripe::Subscription
.expects(:delete)
expect {
delete "/s/user/subscriptions/sub_12345.json"
}.to change { user.groups.count }.by(-1)
end
it "does not remove the user from the group" do
::Stripe::Subscription
.expects(:retrieve)
.with('sub_12345')
.returns(
plan: { product: 'p_1', metadata: { group_name: 'does_not_exist' } },
customer: 'customer_id_1'
)
::Stripe::Subscription
.expects(:delete)
expect {
delete "/s/user/subscriptions/sub_12345.json"
}.not_to change { user.groups.count }
end
it "deletes the first subscription product 1" do
::Stripe::Subscription
.expects(:retrieve)
.with('sub_12345')
.returns(
plan: { product: 'p_1', metadata: {} },
customer: 'customer_id_1'
)
::Stripe::Subscription
.expects(:delete)
.with('sub_12345')
expect {
delete "/s/user/subscriptions/sub_12345.json"
}.to change { DiscourseSubscriptions::Customer.count }.by(-1)
expect(response.status).to eq 200
end
it "deletes the first subscription product 2" do
::Stripe::Subscription
.expects(:retrieve)
.with('sub_12345')
.returns(
plan: { product: 'p_2', metadata: {} },
customer: 'customer_id_1'
)
::Stripe::Subscription
.expects(:delete)
.with('sub_12345')
expect {
delete "/s/user/subscriptions/sub_12345.json"
}.to change { DiscourseSubscriptions::Customer.count }.by(-1)
expect(response.status).to eq 200
end
it "deletes the second subscription" do
::Stripe::Subscription
.expects(:retrieve)
.with('sub_12345')
.returns(
plan: { product: 'p_2', metadata: {} },
customer: 'customer_id_2'
)
::Stripe::Subscription
.expects(:delete)
.with('sub_12345')
expect {
delete "/s/user/subscriptions/sub_12345.json"
}.to change { DiscourseSubscriptions::Customer.count }.by(-1)
expect(response.status).to eq 200
end
end
end end
end end
end end