diff --git a/frontend/icons/arrow-loop.svg b/frontend/icons/arrow-loop.svg new file mode 100644 index 000000000..42a29cbed --- /dev/null +++ b/frontend/icons/arrow-loop.svg @@ -0,0 +1,3 @@ + + + diff --git a/squarelet/organizations/models/payment.py b/squarelet/organizations/models/payment.py index a721cf85d..2faff807d 100644 --- a/squarelet/organizations/models/payment.py +++ b/squarelet/organizations/models/payment.py @@ -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 @@ -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 diff --git a/squarelet/organizations/payments/base.py b/squarelet/organizations/payments/base.py index 7db8b824a..bbceb704e 100644 --- a/squarelet/organizations/payments/base.py +++ b/squarelet/organizations/payments/base.py @@ -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.""" diff --git a/squarelet/organizations/payments/providers/stripe_modern.py b/squarelet/organizations/payments/providers/stripe_modern.py index 4e2149a2f..f38418708 100644 --- a/squarelet/organizations/payments/providers/stripe_modern.py +++ b/squarelet/organizations/payments/providers/stripe_modern.py @@ -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="", + ) + def delete(self, stripe_subscription): stripe_subscription.delete() diff --git a/squarelet/organizations/urls.py b/squarelet/organizations/urls.py index 1030d9a2c..b341367b3 100644 --- a/squarelet/organizations/urls.py +++ b/squarelet/organizations/urls.py @@ -43,6 +43,11 @@ view=RedirectView.as_view(pattern_name="organizations:subscriptions"), name="payment", ), + path( + "/resubscribe//", + view=views.Resubscribe.as_view(), + name="resubscribe", + ), path("/update/", view=views.Update.as_view(), name="update"), path( "/request-profile-change/", diff --git a/squarelet/organizations/views/__init__.py b/squarelet/organizations/views/__init__.py index b01833129..4965ab0b1 100644 --- a/squarelet/organizations/views/__init__.py +++ b/squarelet/organizations/views/__init__.py @@ -19,6 +19,7 @@ PaymentsList, PDFChargeDetail, RemoveCard, + Resubscribe, UpdateCard, UpdateReceiptEmail, UpdateSubscription, @@ -33,6 +34,7 @@ # Subscription views "ManageSubscriptions", "UpdateSubscription", + "Resubscribe", "UpdateCard", "RemoveCard", "UpdateReceiptEmail", diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py index 9ec9529c6..d53e3b1d6 100644 --- a/squarelet/organizations/views/subscription.py +++ b/squarelet/organizations/views/subscription.py @@ -56,6 +56,7 @@ BaseManageSubscriptions, BasePaymentsList, BaseRemoveCard, + BaseResubscribe, BaseUpdateCard, BaseUpdateReceiptEmail, ) @@ -201,6 +202,10 @@ class CancelSubscription(OrgSubscriptionView, BaseCancelSubscription): pass +class Resubscribe(OrgSubscriptionView, BaseResubscribe): + pass + + class PaymentsList(PermissionRequiredMixin, BasePaymentsList): subject = "organizations" individual = False diff --git a/squarelet/payments/views.py b/squarelet/payments/views.py index a66fd1816..9a8ba5999 100644 --- a/squarelet/payments/views.py +++ b/squarelet/payments/views.py @@ -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 @@ -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: diff --git a/squarelet/templates/subscriptions/manage_subscriptions.html b/squarelet/templates/subscriptions/manage_subscriptions.html index 3740656e3..87a7fa478 100644 --- a/squarelet/templates/subscriptions/manage_subscriptions.html +++ b/squarelet/templates/subscriptions/manage_subscriptions.html @@ -111,6 +111,15 @@

+ {% else %} + {% url subject|add:':resubscribe' subject_slug subscription.id as resubscribe_url %} +
+ {% csrf_token %} + +
{% endif %} diff --git a/squarelet/users/urls.py b/squarelet/users/urls.py index dc987f8b7..7ac02809a 100644 --- a/squarelet/users/urls.py +++ b/squarelet/users/urls.py @@ -60,6 +60,11 @@ view=views.CancelSubscription.as_view(), name="cancel-subscription", ), + path( + "/resubscribe//", + view=views.Resubscribe.as_view(), + name="resubscribe", + ), path( "/receipt-email/", view=views.UpdateReceiptEmail.as_view(), diff --git a/squarelet/users/views.py b/squarelet/users/views.py index 8ff7bdb4c..a647ac2ec 100644 --- a/squarelet/users/views.py +++ b/squarelet/users/views.py @@ -56,6 +56,7 @@ BaseManageSubscriptions, BasePaymentsList, BaseRemoveCard, + BaseResubscribe, BaseUpdateCard, BaseUpdateReceiptEmail, ) @@ -693,6 +694,10 @@ class CancelSubscription(IndividualSubscriptionView, BaseCancelSubscription): pass +class Resubscribe(IndividualSubscriptionView, BaseResubscribe): + pass + + class UpdateCard(IndividualSubscriptionView, BaseUpdateCard): pass