Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions frontend/icons/arrow-loop.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 27 additions & 0 deletions squarelet/organizations/models/payment.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Django
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models, transaction
from django.db.models import Q
from django.urls import reverse
Expand Down Expand Up @@ -467,6 +468,32 @@ def cancel(self):
# Slack notification for cancellation
self.send_slack_notification("cancelled")

def uncancel(self):
"""Re-enable renewal for a subscription that was pending cancellation.

Clears the cancelled flag and cancel_at date locally, and removes
cancel_at_period_end on the Stripe subscription so it auto-renews.
"""
customer = self.organization.customer()
if not customer.stripe_payment_method_id:
raise ValidationError(
_(
"No payment method on file. "
"Please add a payment method before re-subscribing."
)
)
if self.stripe_subscription:
updated = (
get_payment_provider()
.get_subscription_service()
.uncancel(self.stripe_subscription)
)
if updated:
self.cache_stripe_subscription_fields(updated)
self.cancelled = False
self.cancel_at = None
self.save()

def modify(self, plan):
"""Modify an existing plan
Note - this should never be used to switch from a MR to a PP plan or vice versa
Expand Down
9 changes: 9 additions & 0 deletions squarelet/organizations/payments/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,15 @@ def cancel_at_period_end(self, stripe_subscription):
``cancel_at`` timestamp when cancellation is scheduled.
"""

@abstractmethod
def uncancel(self, stripe_subscription):
"""Re-enable a subscription that was set to cancel at period end.

Clears both ``cancel_at_period_end`` and any ``cancel_at`` timestamp,
so the subscription will auto-renew as normal.
Returns the updated Stripe subscription object.
"""

@abstractmethod
def delete(self, stripe_subscription):
"""Immediately cancel and delete a subscription."""
Expand Down
7 changes: 7 additions & 0 deletions squarelet/organizations/payments/providers/stripe_modern.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,13 @@ def cancel_at_period_end(self, stripe_subscription):
cancel_at_period_end=True,
)

def uncancel(self, stripe_subscription):
return stripe.Subscription.modify(
stripe_subscription.id,
cancel_at_period_end=False,
cancel_at="",
)
Comment on lines +188 to +192

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This throws an error: Received both cancel_at_period_end and cancel_at parameters. Please pass in only one. Removing either one seems to fix it, but I’m not sure if it makes a difference on Stripe's end.


def delete(self, stripe_subscription):
stripe_subscription.delete()

Expand Down
5 changes: 5 additions & 0 deletions squarelet/organizations/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@
view=RedirectView.as_view(pattern_name="organizations:subscriptions"),
name="payment",
),
path(
"<slug:slug>/resubscribe/<int:pk>/",
view=views.Resubscribe.as_view(),
name="resubscribe",
),
path("<slug:slug>/update/", view=views.Update.as_view(), name="update"),
path(
"<slug:slug>/request-profile-change/",
Expand Down
2 changes: 2 additions & 0 deletions squarelet/organizations/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
PaymentsList,
PDFChargeDetail,
RemoveCard,
Resubscribe,
UpdateCard,
UpdateReceiptEmail,
UpdateSubscription,
Expand All @@ -33,6 +34,7 @@
# Subscription views
"ManageSubscriptions",
"UpdateSubscription",
"Resubscribe",
"UpdateCard",
"RemoveCard",
"UpdateReceiptEmail",
Expand Down
5 changes: 5 additions & 0 deletions squarelet/organizations/views/subscription.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
BaseManageSubscriptions,
BasePaymentsList,
BaseRemoveCard,
BaseResubscribe,
BaseUpdateCard,
BaseUpdateReceiptEmail,
)
Expand Down Expand Up @@ -201,6 +202,10 @@ class CancelSubscription(OrgSubscriptionView, BaseCancelSubscription):
pass


class Resubscribe(OrgSubscriptionView, BaseResubscribe):
pass


class PaymentsList(PermissionRequiredMixin, BasePaymentsList):
subject = "organizations"
individual = False
Expand Down
34 changes: 34 additions & 0 deletions squarelet/payments/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.views import redirect_to_login
from django.core.exceptions import ValidationError
from django.db import transaction
from django.http import Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect
Expand Down Expand Up @@ -748,6 +749,39 @@ def get_context_data(self, **kwargs):
return context


class BaseResubscribe(SubscriptionObjectMixin, View):
"""Resubscribe to a cancelled subscription."""

def _is_ajax(self):
return self.request.headers.get("X-Requested-With") == "XMLHttpRequest"

def _error(self, message, redirect_url="subscriptions"):
"""Return the error as JSON for AJAX callers, else flash and redirect."""
if self._is_ajax():
return JsonResponse({"error": str(message)}, status=400)
messages.error(self.request, message)
return redirect(self.reverse_subject(redirect_url))

def post(self, request, *args, **kwargs):
organization = self.get_object()
redirect_url = self.reverse_subject("subscriptions")
subscription = organization.subscriptions.filter(id=self.kwargs["pk"]).first()
print(subscription)
try:
subscription.uncancel()
except ValidationError as exc:
return self._error(exc.message, "update-card")
except stripe.StripeError as exc:
return self._error(f"Stripe error: {format_stripe_error(exc)}")

self.log_staff_action("uncancelled subscription")
success_msg = _(f"Resubscribed to {subscription.plan.name}")
if self._is_ajax():
return JsonResponse({"redirect": redirect_url, "message": str(success_msg)})
messages.success(request, success_msg)
return redirect(redirect_url)


def get_subscription_next_date(subscription):
stripe_sub = subscription.stripe_subscription
if stripe_sub:
Expand Down
9 changes: 9 additions & 0 deletions squarelet/templates/subscriptions/manage_subscriptions.html
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ <h3 class="{% if subject == "organizations" %}organization{% else %}professional
{% icon "x-circle" %}
{% trans "Cancel plan" %}
</a>
{% else %}
{% url subject|add:':resubscribe' subject_slug subscription.id as resubscribe_url %}
<form method="post" action="{{ resubscribe_url }}">
{% csrf_token %}
<button type="submit" class="btn ghost primary small">
{% icon "arrow-loop" %}
{% trans "Resubscribe" %}
</button>
</form>
{% endif %}
</div>
</main>
Expand Down
5 changes: 5 additions & 0 deletions squarelet/users/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@
view=views.CancelSubscription.as_view(),
name="cancel-subscription",
),
path(
"<str:username>/resubscribe/<int:pk>/",
view=views.Resubscribe.as_view(),
name="resubscribe",
),
path(
"<str:username>/receipt-email/",
view=views.UpdateReceiptEmail.as_view(),
Expand Down
5 changes: 5 additions & 0 deletions squarelet/users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
BaseManageSubscriptions,
BasePaymentsList,
BaseRemoveCard,
BaseResubscribe,
BaseUpdateCard,
BaseUpdateReceiptEmail,
)
Expand Down Expand Up @@ -693,6 +694,10 @@ class CancelSubscription(IndividualSubscriptionView, BaseCancelSubscription):
pass


class Resubscribe(IndividualSubscriptionView, BaseResubscribe):
pass


class UpdateCard(IndividualSubscriptionView, BaseUpdateCard):
pass

Expand Down
Loading