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 %}
+
{% 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