Date: Sun, 5 Jul 2026 17:57:21 -0400
Subject: [PATCH 39/86] Fix receipt pagination styles and charge links
---
frontend/css/receipts_card.css | 9 ++++++
.../organizations/includes/receipts_card.html | 28 +++++++++++++------
2 files changed, 28 insertions(+), 9 deletions(-)
diff --git a/frontend/css/receipts_card.css b/frontend/css/receipts_card.css
index f056a9012..2241300ae 100644
--- a/frontend/css/receipts_card.css
+++ b/frontend/css/receipts_card.css
@@ -47,3 +47,12 @@
display: flex;
justify-content: flex-end;
}
+
+.receipts-card nav {
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+ gap: 0.5rem;
+ margin-top: 1rem;
+}
diff --git a/squarelet/templates/organizations/includes/receipts_card.html b/squarelet/templates/organizations/includes/receipts_card.html
index ef3cc2fb3..1df1942bf 100644
--- a/squarelet/templates/organizations/includes/receipts_card.html
+++ b/squarelet/templates/organizations/includes/receipts_card.html
@@ -24,12 +24,12 @@
| ${{ payment.amount_dollars|floatformat:2 }} |
- {% url "organizations:charge" charge.pk as view_url %}
+ {% url "organizations:charge" payment.pk as view_url %}
{% icon "eye" %}
- {% url "organizations:charge-pdf" charge.pk as pdf_url %}
+ {% url "organizations:charge-pdf" payment.pk as pdf_url %}
{% icon "download" %}
@@ -42,19 +42,29 @@
|
- |
From 7161723062fcc1059459646960bbf438941c23e3 Mon Sep 17 00:00:00 2001
From: Daniel Nass
Date: Sun, 5 Jul 2026 21:43:28 -0400
Subject: [PATCH 40/86] Add receipts card empty message
---
.../templates/organizations/includes/receipts_card.html | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/squarelet/templates/organizations/includes/receipts_card.html b/squarelet/templates/organizations/includes/receipts_card.html
index 1df1942bf..9befad416 100644
--- a/squarelet/templates/organizations/includes/receipts_card.html
+++ b/squarelet/templates/organizations/includes/receipts_card.html
@@ -36,6 +36,12 @@
+ {% empty %}
+
+ |
+ {% trans "No payments to show." %}
+ |
+
{% endfor %}
{% if page_obj %}
From ee84ccc1d9901e17cebbc5d988f9fa5117fb5b4a Mon Sep 17 00:00:00 2001
From: Daniel Nass
Date: Sun, 5 Jul 2026 21:43:48 -0400
Subject: [PATCH 41/86] Filter charges to just the current org
---
squarelet/organizations/views/subscription.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py
index d2038a12f..18c89513d 100644
--- a/squarelet/organizations/views/subscription.py
+++ b/squarelet/organizations/views/subscription.py
@@ -317,10 +317,12 @@ def get_context_data(self, **kwargs):
class PaymentsList(ListView):
permission_required = "organizations.can_view_charge"
- queryset = Charge.objects.all()
template_name = "organizations/organization_payments.html"
paginate_by = 20
+ def get_queryset(self):
+ return Charge.objects.filter(organization__slug=self.kwargs["slug"])
+
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["subject"] = "org"
From 39da26325381e55b2f96b3f7283ff3151626308b Mon Sep 17 00:00:00 2001
From: Daniel Nass
Date: Sun, 5 Jul 2026 21:47:11 -0400
Subject: [PATCH 42/86] Show placeholder card for no subscriptions
---
.../organizations/organization_managesubscriptions.html | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/squarelet/templates/organizations/organization_managesubscriptions.html b/squarelet/templates/organizations/organization_managesubscriptions.html
index a69c34043..69ca11829 100644
--- a/squarelet/templates/organizations/organization_managesubscriptions.html
+++ b/squarelet/templates/organizations/organization_managesubscriptions.html
@@ -102,6 +102,10 @@ {{ subscription.plan.name }}
{% endwith %}
+ {% empty %}
+
+ {% trans "You have no active subscriptions." %}
+
{% endfor %}
From 9ab011abac7e569645991569d94f1361b977931f Mon Sep 17 00:00:00 2001
From: Daniel Nass
Date: Sun, 5 Jul 2026 21:52:02 -0400
Subject: [PATCH 43/86] Pass through payment URL for upgrade button
---
squarelet/templates/organizations/includes/plan_card.html | 4 ++--
squarelet/templates/organizations/organization_detail.html | 1 +
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/squarelet/templates/organizations/includes/plan_card.html b/squarelet/templates/organizations/includes/plan_card.html
index a7652c57c..8d0b7aed3 100644
--- a/squarelet/templates/organizations/includes/plan_card.html
+++ b/squarelet/templates/organizations/includes/plan_card.html
@@ -77,8 +77,8 @@ {% trans "Your plans" %}
{% trans "No active subscription." %}
{% endif %}
- {% if subscriptions_url and can_edit_subscription %}
- {% trans "Upgrade" %}
+ {% if payment_url and can_edit_subscription %}
+ {% trans "Upgrade" %}
{% endif %}
{% if upgrade_plan %}
diff --git a/squarelet/templates/organizations/organization_detail.html b/squarelet/templates/organizations/organization_detail.html
index 0bb3b8ddc..de9cb0ffd 100644
--- a/squarelet/templates/organizations/organization_detail.html
+++ b/squarelet/templates/organizations/organization_detail.html
@@ -270,6 +270,7 @@ {% trans "Affiliations" %}
{% if can_view_subscription %}
{% url 'organizations:subscriptions' organization.slug as subscriptions_url %}
+ {% url 'organizations:payment' organization.slug as payment_url %}
{% url 'organizations:update-card' organization.slug as card_url %}
From 7fb59f8d813e45308d4d7e53a123706e8edfccd8 Mon Sep 17 00:00:00 2001
From: Daniel Nass
Date: Mon, 6 Jul 2026 08:37:55 -0400
Subject: [PATCH 44/86] Format and fix lint errors
---
squarelet/organizations/forms.py | 15 ++--------
squarelet/organizations/urls.py | 10 +++++--
squarelet/organizations/views/__init__.py | 10 +++----
squarelet/organizations/views/detail.py | 2 --
squarelet/organizations/views/subscription.py | 29 ++++++++++---------
5 files changed, 31 insertions(+), 35 deletions(-)
diff --git a/squarelet/organizations/forms.py b/squarelet/organizations/forms.py
index bbe0fc418..646a7fca4 100644
--- a/squarelet/organizations/forms.py
+++ b/squarelet/organizations/forms.py
@@ -194,6 +194,7 @@ def clean(self):
return data
+
class CardForm(StripeForm):
"""Update the credit card on file for an organization."""
@@ -210,9 +211,6 @@ def __init__(self, *args, **kwargs):
)
self.helper.form_tag = False
- def clean(self):
- return super().clean()
-
class UpdateSubscriptionFrequencyForm(forms.ModelForm):
"""Update the frequency of a subscription."""
@@ -226,9 +224,6 @@ def __init__(self, *args, **kwargs):
self.helper = FormHelper()
self.helper.form_tag = False
- def clean(self):
- return super().clean()
-
class UpdateReceiptEmailForm(forms.ModelForm):
"""Update the receipt email for an organization."""
@@ -255,7 +250,7 @@ def __init__(self, *args, **kwargs):
def clean_receipt_emails(self):
"""Make sure each entry is a valid email"""
- emails = re.split(r',\s*', self.cleaned_data["receipt_emails"])
+ emails = re.split(r",\s*", self.cleaned_data["receipt_emails"])
emails = [e.strip() for e in emails if e.strip()]
bad_emails = []
for email in emails:
@@ -268,9 +263,6 @@ def clean_receipt_emails(self):
raise forms.ValidationError(f"Invalid email: {bad_emails_str}")
return emails
- def clean(self):
- return super().clean()
-
class CancelSubscriptionForm(forms.Form):
"""Cancel a subscription."""
@@ -280,9 +272,6 @@ def __init__(self, *args, **kwargs):
self.helper = FormHelper()
self.helper.form_tag = False
- def clean(self):
- return super().clean()
-
class UpdateForm(forms.ModelForm):
"""Update misc information for an organization"""
diff --git a/squarelet/organizations/urls.py b/squarelet/organizations/urls.py
index c114629ea..c101f40ab 100644
--- a/squarelet/organizations/urls.py
+++ b/squarelet/organizations/urls.py
@@ -16,12 +16,18 @@
"~charge-pdf//", view=views.PDFChargeDetail.as_view(), name="charge-pdf"
),
path(
- "/subscriptions/", view=views.ManageSubscriptions.as_view(), name="subscriptions"
+ "/subscriptions/",
+ view=views.ManageSubscriptions.as_view(),
+ name="subscriptions",
),
path(
"/payment/", view=views.UpdateSubscription.as_view(), name="payment"
),
- path("~cancel//", view=views.CancelSubscription.as_view(), name="cancel-subscription"),
+ path(
+ "~cancel//",
+ view=views.CancelSubscription.as_view(),
+ name="cancel-subscription",
+ ),
path(
"~update-frequency//",
view=views.UpdateSubscriptionFrequency.as_view(),
diff --git a/squarelet/organizations/views/__init__.py b/squarelet/organizations/views/__init__.py
index 7c91b2896..36d153326 100644
--- a/squarelet/organizations/views/__init__.py
+++ b/squarelet/organizations/views/__init__.py
@@ -13,15 +13,15 @@
)
from .profile import RequestProfileChange, ReviewProfileChange, Update
from .subscription import (
+ CancelSubscription,
ChargeDetail,
- PDFChargeDetail,
ManageSubscriptions,
- UpdateSubscription,
+ PaymentsList,
+ PDFChargeDetail,
UpdateCard,
- UpdateSubscriptionFrequency,
UpdateReceiptEmail,
- PaymentsList,
- CancelSubscription,
+ UpdateSubscription,
+ UpdateSubscriptionFrequency,
stripe_webhook,
)
diff --git a/squarelet/organizations/views/detail.py b/squarelet/organizations/views/detail.py
index 03f927469..7453c26a0 100644
--- a/squarelet/organizations/views/detail.py
+++ b/squarelet/organizations/views/detail.py
@@ -7,14 +7,12 @@
from django.db.models.functions import Lower, StrIndex
from django.http import JsonResponse
from django.shortcuts import redirect
-from django.utils import timezone
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
# Standard Library
import logging
-from datetime import datetime
# Squarelet
from squarelet.core.mixins import AdminLinkMixin
diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py
index 18c89513d..121d22dbc 100644
--- a/squarelet/organizations/views/subscription.py
+++ b/squarelet/organizations/views/subscription.py
@@ -9,13 +9,13 @@
JsonResponse,
)
from django.shortcuts import redirect
+from django.urls import reverse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.decorators.csrf import csrf_exempt
-from django.views.generic import DetailView, ListView, UpdateView, DeleteView
-from django.urls import reverse
+from django.views.generic import DeleteView, DetailView, ListView, UpdateView
# Standard Library
import json
@@ -34,11 +34,11 @@
new_action,
)
from squarelet.organizations.forms import (
- CardForm,
- PaymentForm,
- UpdateSubscriptionFrequencyForm,
- UpdateReceiptEmailForm,
CancelSubscriptionForm,
+ CardForm,
+ PaymentForm,
+ UpdateReceiptEmailForm,
+ UpdateSubscriptionFrequencyForm,
)
from squarelet.organizations.mixins import OrganizationPermissionMixin
from squarelet.organizations.models import Charge, Organization
@@ -62,6 +62,7 @@
logger = logging.getLogger(__name__)
+
class ManageSubscriptions(OrganizationPermissionMixin, DetailView):
permission_required = "organizations.can_edit_subscription"
queryset = Organization.objects.filter(individual=False)
@@ -211,6 +212,7 @@ def get_initial(self):
),
}
+
class UpdateCard(OrganizationPermissionMixin, UpdateView):
"""Update the credit card on file for an organization."""
@@ -255,7 +257,8 @@ def get_context_data(self, **kwargs):
card = customer.card
context["card"] = card
return context
-
+
+
class UpdateSubscriptionFrequency(OrganizationPermissionMixin, UpdateView):
permission_required = "organizations.can_edit_subscription"
queryset = Plan.objects.all()
@@ -284,10 +287,10 @@ def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["subject"] = "org"
return context
-
+
def form_valid(self, form):
self.object.set_receipt_emails(form.cleaned_data["receipt_emails"])
- return redirect('organizations:subscriptions', slug=self.object.slug)
+ return redirect("organizations:subscriptions", slug=self.object.slug)
def get_initial(self):
return {
@@ -296,7 +299,7 @@ def get_initial(self):
),
}
-
+
class CancelSubscription(OrganizationPermissionMixin, DeleteView):
permission_required = "organizations.can_edit_subscription"
queryset = Plan.objects.all()
@@ -309,8 +312,8 @@ def get_context_data(self, **kwargs):
subscription = self.object.subscriptions.first()
if subscription:
- context["organization"] = subscription.organization
- context["next_date"] = get_subscription_next_date(subscription)
+ context["organization"] = subscription.organization
+ context["next_date"] = get_subscription_next_date(subscription)
return context
@@ -328,7 +331,7 @@ def get_context_data(self, **kwargs):
context["subject"] = "org"
context["organization"] = Organization.objects.get(slug=self.kwargs["slug"])
return context
-
+
@method_decorator(xframe_options_sameorigin, name="dispatch")
class ChargeDetail(UserPassesTestMixin, DetailView):
From 04a6d5f70798124b153826e79b6e6588fb9412bc Mon Sep 17 00:00:00 2001
From: Daniel Nass
Date: Mon, 6 Jul 2026 17:14:03 -0400
Subject: [PATCH 45/86] Include org slug in subscription management views
---
squarelet/organizations/urls.py | 4 ++--
.../organizations/organization_managesubscriptions.html | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/squarelet/organizations/urls.py b/squarelet/organizations/urls.py
index c101f40ab..782de7249 100644
--- a/squarelet/organizations/urls.py
+++ b/squarelet/organizations/urls.py
@@ -24,12 +24,12 @@
"/payment/", view=views.UpdateSubscription.as_view(), name="payment"
),
path(
- "~cancel//",
+ "/cancel//",
view=views.CancelSubscription.as_view(),
name="cancel-subscription",
),
path(
- "~update-frequency//",
+ "/update-frequency//",
view=views.UpdateSubscriptionFrequency.as_view(),
name="update-frequency",
),
diff --git a/squarelet/templates/organizations/organization_managesubscriptions.html b/squarelet/templates/organizations/organization_managesubscriptions.html
index 69ca11829..059a93a6b 100644
--- a/squarelet/templates/organizations/organization_managesubscriptions.html
+++ b/squarelet/templates/organizations/organization_managesubscriptions.html
@@ -65,7 +65,7 @@ {{ subscription.plan.name }}
{{ annual|yesno:_("Annual,Monthly") }}
- {% url 'organizations:update-frequency' subscription.plan.id as update_url %}
+ {% url 'organizations:update-frequency' organization.slug subscription.id as update_url %}
{% blocktrans with annual|yesno:_("monthly,annual") as frequency %}
Change to {{ frequency }}
@@ -94,7 +94,7 @@ {{ subscription.plan.name }}
- {% url 'organizations:cancel-subscription' subscription.plan.id as cancel_url %}
+ {% url 'organizations:cancel-subscription' organization.slug subscription.id as cancel_url %}
{% icon "x-circle" %}
{% trans "Cancel plan" %}
From f3c782ac5127d3bf4ef96c691d5f30bfcf440dd1 Mon Sep 17 00:00:00 2001
From: Daniel Nass
Date: Mon, 6 Jul 2026 17:15:47 -0400
Subject: [PATCH 46/86] Query by subscription, not plan
---
squarelet/organizations/views/subscription.py | 10 +++-------
.../organization_updatesubscriptionfrequency.html | 6 +++---
2 files changed, 6 insertions(+), 10 deletions(-)
diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py
index 121d22dbc..f380063d9 100644
--- a/squarelet/organizations/views/subscription.py
+++ b/squarelet/organizations/views/subscription.py
@@ -41,8 +41,7 @@
UpdateSubscriptionFrequencyForm,
)
from squarelet.organizations.mixins import OrganizationPermissionMixin
-from squarelet.organizations.models import Charge, Organization
-from squarelet.organizations.models.payment import Plan
+from squarelet.organizations.models import Charge, Organization, Subscription
from squarelet.organizations.payments.base import PaymentActionRequired
from squarelet.organizations.payments.exceptions import SubscriptionError
from squarelet.organizations.payments.factory import get_payment_provider
@@ -261,7 +260,7 @@ def get_context_data(self, **kwargs):
class UpdateSubscriptionFrequency(OrganizationPermissionMixin, UpdateView):
permission_required = "organizations.can_edit_subscription"
- queryset = Plan.objects.all()
+ queryset = Subscription.objects.all()
form_class = UpdateSubscriptionFrequencyForm
template_name = "organizations/organization_updatesubscriptionfrequency.html"
@@ -269,10 +268,7 @@ def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["subject"] = "org"
- subscription = self.object.subscriptions.first()
- if subscription:
- context["organization"] = subscription.organization
- context["next_date"] = get_subscription_next_date(subscription)
+ context["next_date"] = get_subscription_next_date(self.object)
return context
diff --git a/squarelet/templates/organizations/organization_updatesubscriptionfrequency.html b/squarelet/templates/organizations/organization_updatesubscriptionfrequency.html
index f745a5a0b..eba22ab72 100644
--- a/squarelet/templates/organizations/organization_updatesubscriptionfrequency.html
+++ b/squarelet/templates/organizations/organization_updatesubscriptionfrequency.html
@@ -2,7 +2,7 @@
{% load django_vite i18n crispy_forms_tags humanize %}
{% load icon %}
-{% block title %}{{ plan.name }} | Change Billing Frequency{% endblock %}
+{% block title %}{{ subscription.plan.name }} | Change Billing Frequency{% endblock %}
{% block layout_id %}update_frequency{% endblock %}
@@ -10,14 +10,14 @@
{% endblock content %}
{% block header %}
- {{ organization.name }}
+ {{ subscription.organization.name }}
{% icon "chevron-right" %}
{% trans "Change billing frequency" %}
{% endblock header %}
{% block main %}
{% url 'organizations:subscriptions' organization.slug as subscriptions_url %}
- {% with currentfreq=plan.annual|yesno:_("annual,monthly") newfreq=plan.annual|yesno:_("monthly,annual") %}
+ {% with plan=subscription.plan currentfreq=plan.annual|yesno:_("annual,monthly") newfreq=plan.annual|yesno:_("monthly,annual") %}
{% for subscription in subscriptions %}
- {% with annual=subscription.plan.annual %}
+ {% with annual=subscription.plan.annual cancelled=subscription.cancelled %}
{{ subscription.plan.name }}
-
+ {% else %}
+
+ {% endif %}
@@ -89,16 +95,18 @@ {{ subscription.plan.name }}
{% icon "calendar" %}
- {{ subscription.plan.cancelled|yesno:_("Ends on,Renews on") }}
+ {{ cancelled|yesno:_("Ends on,Renews on") }}
{{ subscription.next_date }}
- {% url 'organizations:cancel-subscription' organization.slug subscription.id as cancel_url %}
-
- {% icon "x-circle" %}
- {% trans "Cancel plan" %}
-
+ {% if not cancelled %}
+ {% url 'organizations:cancel-subscription' organization.slug subscription.id as cancel_url %}
+
+ {% icon "x-circle" %}
+ {% trans "Cancel plan" %}
+
+ {% endif %}
{% endwith %}
From 9942d23cf37a807935ed7dfe3bf02e32b34efe78 Mon Sep 17 00:00:00 2001
From: Daniel Nass
Date: Mon, 6 Jul 2026 17:21:31 -0400
Subject: [PATCH 49/86] Use organization.remove_subscription to cancel
---
squarelet/organizations/views/subscription.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py
index 89eac6131..9bbc980e1 100644
--- a/squarelet/organizations/views/subscription.py
+++ b/squarelet/organizations/views/subscription.py
@@ -319,9 +319,10 @@ def get_context_data(self, **kwargs):
return context
def form_valid(self, form):
+ organization = self.object
subscription = self.object.subscriptions.filter(id=self.kwargs["pk"]).first()
if subscription:
- subscription.cancel()
+ organization.remove_subscription(subscription)
messages.success(self.request, _("Subscription cancelled."))
return redirect("organizations:subscriptions", slug=self.object.slug)
From ec5958281af42a58f598635c0a2b63fdada35a31 Mon Sep 17 00:00:00 2001
From: Daniel Nass
Date: Tue, 7 Jul 2026 09:20:53 -0400
Subject: [PATCH 50/86] Use subscription ID for subscription frequency link
---
.../organizations/organization_managesubscriptions.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/squarelet/templates/organizations/organization_managesubscriptions.html b/squarelet/templates/organizations/organization_managesubscriptions.html
index 71b6e3d3a..0f5de8e0b 100644
--- a/squarelet/templates/organizations/organization_managesubscriptions.html
+++ b/squarelet/templates/organizations/organization_managesubscriptions.html
@@ -70,7 +70,7 @@ {{ subscription.plan.name }}
{{ annual|yesno:_("Annual,Monthly") }}
- {% url 'organizations:update-frequency' organization.slug subscription.plan.id as update_url %}
+ {% url 'organizations:update-frequency' organization.slug subscription.id as update_url %}
{% blocktrans with annual|yesno:_("monthly,annual") as frequency %}
Change to {{ frequency }}
From 3e6c9d5813403820707cca89667c43b31ad1129b Mon Sep 17 00:00:00 2001
From: Daniel Nass
Date: Tue, 7 Jul 2026 09:21:22 -0400
Subject: [PATCH 51/86] Add permissions check for PaymentsList view
---
squarelet/organizations/views/subscription.py | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py
index 9bbc980e1..290b90c11 100644
--- a/squarelet/organizations/views/subscription.py
+++ b/squarelet/organizations/views/subscription.py
@@ -1,7 +1,7 @@
# Django
from django.conf import settings
from django.contrib import messages
-from django.contrib.auth.mixins import UserPassesTestMixin
+from django.contrib.auth.mixins import UserPassesTestMixin, PermissionRequiredMixin
from django.http.response import (
HttpResponse,
HttpResponseBadRequest,
@@ -327,11 +327,15 @@ def form_valid(self, form):
return redirect("organizations:subscriptions", slug=self.object.slug)
-class PaymentsList(ListView):
- permission_required = "organizations.can_view_charge"
+class PaymentsList(PermissionRequiredMixin, ListView):
template_name = "organizations/organization_payments.html"
paginate_by = 20
+ def has_permission(self):
+ user = self.request.user
+ organization = Organization.objects.get(slug=self.kwargs["slug"])
+ return user.has_perm("organizations.can_view_charge", organization)
+
def get_queryset(self):
return Charge.objects.filter(organization__slug=self.kwargs["slug"])
From 53dfacffaf796eea3a9561116cc07570f4f31c84 Mon Sep 17 00:00:00 2001
From: Daniel Nass
Date: Tue, 7 Jul 2026 11:07:09 -0400
Subject: [PATCH 52/86] Pass all subscriptions to user detail view
---
squarelet/users/views.py | 31 ++++++-------------------------
1 file changed, 6 insertions(+), 25 deletions(-)
diff --git a/squarelet/users/views.py b/squarelet/users/views.py
index 7044044e0..5d96f172b 100644
--- a/squarelet/users/views.py
+++ b/squarelet/users/views.py
@@ -162,38 +162,19 @@ def get_context_data(self, **kwargs):
context["unused_code_count"] = len(self.get_recovery_codes())
# Get the current plan and subscription, if any
individual_org = user.individual_organization
- current_plan = None
upgrade_plan = Plan.objects.filter(slug="professional").first()
- subscription = None
+ subscriptions = None
if hasattr(individual_org, "subscriptions"):
- subscription = individual_org.subscriptions.first()
- if subscription and hasattr(subscription, "plan"):
- current_plan = subscription.plan
- upgrade_plan = None
- context["current_plan"] = current_plan
+ subscriptions = individual_org.subscriptions.all()
+ context["subscriptions"] = subscriptions
context["upgrade_plan"] = upgrade_plan
- # Get card, next charge date, and cancelled status for active subscription
- if current_plan and subscription:
+ # Get card for active subscription
+ if subscriptions:
customer = individual_org.customer()
context["current_plan_card"] = bool(customer.stripe_payment_method_id)
context["current_plan_card_brand"] = customer.payment_brand
context["current_plan_card_last4"] = customer.payment_last4
- # Stripe subscription may have next charge date
- stripe_sub = subscription.stripe_subscription
- if stripe_sub:
- # Try to get next charge date from Stripe subscription
- time_stamp = (
- get_payment_provider()
- .get_subscription_service()
- .get_current_period_end(stripe_sub)
- )
- if time_stamp:
- tz_datetime = datetime.fromtimestamp(
- time_stamp, tz=timezone.get_current_timezone()
- )
- context["current_plan_next_charge_date"] = tz_datetime.date()
- # Check if the plan is cancelled
- context["current_plan_cancelled"] = subscription.cancelled
+
# Cards for each non-individual org the user belongs to that has its own
# paid plan. Plans the org inherits from parents/groups are intentionally
# excluded here -- those are an org-level concept and confuse users when
From 86826099f14fb2b2f7a20b57675ffe9201898255 Mon Sep 17 00:00:00 2001
From: Daniel Nass
Date: Tue, 7 Jul 2026 11:45:32 -0400
Subject: [PATCH 53/86] Update plan card layout and copy
---
frontend/css/plan_card.css | 4 ++--
squarelet/templates/organizations/includes/plan_card.html | 8 ++++----
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/frontend/css/plan_card.css b/frontend/css/plan_card.css
index 0864889c7..34440f342 100644
--- a/frontend/css/plan_card.css
+++ b/frontend/css/plan_card.css
@@ -56,7 +56,7 @@ a.plan-name {
}
.plan-detail {
- flex: 0 1 auto;
+ flex: 1 0 auto;
padding: 0;
display: flex;
align-items: center;
@@ -69,7 +69,6 @@ a.plan-name {
font-size: var(--font-md, 1rem);
color: var(--gray-5, #233944);
margin: 0;
- flex: 1 0 0;
white-space: nowrap;
}
@@ -78,6 +77,7 @@ a.plan-name {
align-items: center;
gap: 0.25rem;
margin: 0;
+ flex: 1 0 0;
}
.plan-card .caption .icon {
diff --git a/squarelet/templates/organizations/includes/plan_card.html b/squarelet/templates/organizations/includes/plan_card.html
index 41cc40d0a..097cfa6f0 100644
--- a/squarelet/templates/organizations/includes/plan_card.html
+++ b/squarelet/templates/organizations/includes/plan_card.html
@@ -28,16 +28,16 @@ {% trans "Payment card" %}
{{ card.brand }} {% trans "ending in" %} {{ card.last4 }}
+
+ {% trans "Add new card" %}
+
-
- {% trans "Add new card" %}
-
{% endif %}
{% trans "Your plans" %}
- {% trans "Modify or cancel your organization's subscriptions" %}
+ {% trans "Modify or cancel your" %} {% if subject == "org" %}{% trans "organization's " %}{% endif %}{% trans "subscriptions" %}
From f2af46c30d25f072cf77cc44734c86f91f30d076 Mon Sep 17 00:00:00 2001
From: Daniel Nass
Date: Tue, 7 Jul 2026 13:18:42 -0400
Subject: [PATCH 54/86] Query organization in UpdateSubscriptionFrequency view
---
squarelet/organizations/views/subscription.py | 14 +++++++++++---
1 file changed, 11 insertions(+), 3 deletions(-)
diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py
index 290b90c11..50ba8134f 100644
--- a/squarelet/organizations/views/subscription.py
+++ b/squarelet/organizations/views/subscription.py
@@ -260,15 +260,23 @@ def get_context_data(self, **kwargs):
class UpdateSubscriptionFrequency(OrganizationPermissionMixin, UpdateView):
permission_required = "organizations.can_edit_subscription"
- queryset = Subscription.objects.all()
form_class = UpdateSubscriptionFrequencyForm
template_name = "organizations/organization_updatesubscriptionfrequency.html"
+ def get_queryset(self):
+ return Organization.objects.filter(slug=self.kwargs["slug"])
+
+ def get_object(self, queryset=None):
+ if queryset is None:
+ queryset = self.get_queryset()
+ return queryset.get()
+
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
- context["subject"] = "org"
- context["next_date"] = get_subscription_next_date(self.object)
+ subscription = self.object.subscriptions.filter(id=self.kwargs["pk"]).first()
+ context["subscription"] = subscription
+ context["next_date"] = get_subscription_next_date(subscription)
return context
From f464b800bbae5ea3ea40af97c25b6f5bfaa1f7e3 Mon Sep 17 00:00:00 2001
From: Daniel Nass
Date: Tue, 7 Jul 2026 14:11:56 -0400
Subject: [PATCH 55/86] Move subscription forms, views and templates to
separate files
---
squarelet/organizations/forms.py | 82 -------
squarelet/organizations/views/subscription.py | 192 +++--------------
squarelet/subscriptions/__init__.py | 0
squarelet/subscriptions/forms.py | 98 +++++++++
squarelet/subscriptions/views.py | 204 ++++++++++++++++++
.../cancel_subscription.html} | 0
.../includes/receipts_card.html | 0
.../manage_subscriptions.html} | 0
.../payments.html} | 2 +-
.../update_card.html} | 0
.../update_receipt_email.html} | 0
.../update_subscription_frequency.html} | 0
12 files changed, 332 insertions(+), 246 deletions(-)
create mode 100644 squarelet/subscriptions/__init__.py
create mode 100644 squarelet/subscriptions/forms.py
create mode 100644 squarelet/subscriptions/views.py
rename squarelet/templates/{organizations/organization_cancelsubscription.html => subscriptions/cancel_subscription.html} (100%)
rename squarelet/templates/{organizations => subscriptions}/includes/receipts_card.html (100%)
rename squarelet/templates/{organizations/organization_managesubscriptions.html => subscriptions/manage_subscriptions.html} (100%)
rename squarelet/templates/{organizations/organization_payments.html => subscriptions/payments.html} (93%)
rename squarelet/templates/{organizations/organization_updatecard.html => subscriptions/update_card.html} (100%)
rename squarelet/templates/{organizations/organization_updatereceiptemail.html => subscriptions/update_receipt_email.html} (100%)
rename squarelet/templates/{organizations/organization_updatesubscriptionfrequency.html => subscriptions/update_subscription_frequency.html} (100%)
diff --git a/squarelet/organizations/forms.py b/squarelet/organizations/forms.py
index 0b8d5b1b8..896b25932 100644
--- a/squarelet/organizations/forms.py
+++ b/squarelet/organizations/forms.py
@@ -195,88 +195,6 @@ def clean(self):
return data
-class CardForm(StripeForm):
- """Update the credit card on file for an organization."""
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- # This form is only for replacing the current card, so these fields aren't used
- self.fields.pop("use_card_on_file", None)
- self.fields.pop("remove_card_on_file", None)
-
- self.helper = FormHelper()
- self.helper.layout = Layout(
- Field("stripe_pk"),
- Field("stripe_token"),
- )
- self.helper.form_tag = False
-
-
-class UpdateSubscriptionFrequencyForm(forms.ModelForm):
- """Update the frequency of a subscription."""
-
- class Meta:
- model = Plan
- fields = []
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self.helper = FormHelper()
- self.helper.form_tag = False
-
-
-class UpdateReceiptEmailForm(forms.ModelForm):
- """Update the receipt email for an organization."""
-
- receipt_emails = forms.CharField(
- label=_("Receipt emails"),
- widget=forms.TextInput(),
- required=True,
- help_text=_("Enter one or more email addresses, separated by commas"),
- )
-
- class Meta:
- model = Organization
- fields = ["receipt_emails"]
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self.helper = FormHelper()
- self.helper.template_pack = "forms"
- self.helper.layout = Layout(
- CrispyField("receipt_emails"),
- )
- self.helper.form_tag = False
-
- def clean_receipt_emails(self):
- """Make sure each entry is a valid email"""
- emails = re.split(r",\s*", self.cleaned_data["receipt_emails"])
- emails = [e.strip() for e in emails if e.strip()]
- bad_emails = []
- for email in emails:
- try:
- validate_email(email.strip())
- except forms.ValidationError:
- bad_emails.append(email)
- if bad_emails:
- bad_emails_str = ", ".join(bad_emails)
- raise forms.ValidationError(f"Invalid email: {bad_emails_str}")
- return emails
-
-
-class CancelSubscriptionForm(forms.ModelForm):
- """Cancel a subscription."""
-
- class Meta:
- model = Organization
- fields = []
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self.helper = FormHelper()
- self.helper.form_tag = False
-
-
class UpdateForm(forms.ModelForm):
"""Update misc information for an organization"""
diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py
index 50ba8134f..cd1aa15cc 100644
--- a/squarelet/organizations/views/subscription.py
+++ b/squarelet/organizations/views/subscription.py
@@ -1,7 +1,7 @@
# Django
from django.conf import settings
from django.contrib import messages
-from django.contrib.auth.mixins import UserPassesTestMixin, PermissionRequiredMixin
+from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin
from django.http.response import (
HttpResponse,
HttpResponseBadRequest,
@@ -9,13 +9,12 @@
JsonResponse,
)
from django.shortcuts import redirect
-from django.urls import reverse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.decorators.csrf import csrf_exempt
-from django.views.generic import DetailView, ListView, UpdateView
+from django.views.generic import DetailView, UpdateView
# Standard Library
import json
@@ -33,15 +32,9 @@
get_stripe_dashboard_url,
new_action,
)
-from squarelet.organizations.forms import (
- CancelSubscriptionForm,
- CardForm,
- PaymentForm,
- UpdateReceiptEmailForm,
- UpdateSubscriptionFrequencyForm,
-)
+from squarelet.organizations.forms import PaymentForm
from squarelet.organizations.mixins import OrganizationPermissionMixin
-from squarelet.organizations.models import Charge, Organization, Subscription
+from squarelet.organizations.models import Charge, Organization
from squarelet.organizations.payments.base import PaymentActionRequired
from squarelet.organizations.payments.exceptions import SubscriptionError
from squarelet.organizations.payments.factory import get_payment_provider
@@ -58,50 +51,18 @@
handle_subscription_deleted,
handle_subscription_updated,
)
+from squarelet.subscriptions.views import (
+ BaseCancelSubscription,
+ BaseManageSubscriptions,
+ BasePaymentsList,
+ BaseUpdateCard,
+ BaseUpdateReceiptEmail,
+ BaseUpdateSubscriptionFrequency,
+)
logger = logging.getLogger(__name__)
-class ManageSubscriptions(OrganizationPermissionMixin, DetailView):
- permission_required = "organizations.can_edit_subscription"
- queryset = Organization.objects.filter(individual=False)
- template_name = "organizations/organization_managesubscriptions.html"
-
- def get_context_data(self, **kwargs):
- context = super().get_context_data(**kwargs)
-
- # Get subscriptions and add renewal/cancellation date and cost data
- subscriptions = self.object.subscriptions.all()
- for subscription in subscriptions:
- subscription.next_date = get_subscription_next_date(subscription)
- subscription.cost = subscription.plan.base_price
- context["subscriptions"] = subscriptions
-
- # Get card on file
- customer = self.object.customer()
- if customer.card is None:
- card = None
- elif customer.card.object == "payment_method":
- card = customer.card.card
- else:
- card = customer.card
- context["card"] = card
-
- # Get all receipt emails
- context["receipt_emails"] = self.object.receipt_emails.all()
-
- # Get failed receipt emails
- context["failed_receipt_emails"] = self.object.receipt_emails.filter(
- failed=True
- )
-
- # Get five most recent payments
- payments = self.object.charges.order_by("-created_at").all()[:5]
- context["payments"] = payments
-
- return context
-
-
class UpdateSubscription(OrganizationPermissionMixin, UpdateView):
permission_required = "organizations.can_edit_subscription"
queryset = Organization.objects.filter(individual=False)
@@ -212,145 +173,50 @@ def get_initial(self):
}
-class UpdateCard(OrganizationPermissionMixin, UpdateView):
- """Update the credit card on file for an organization."""
+class OrgSubscriptionView(OrganizationPermissionMixin):
+ """Base class for org subscription views."""
- permission_required = "organizations.can_edit_subscription"
queryset = Organization.objects.filter(individual=False)
- form_class = CardForm
- template_name = "organizations/organization_updatecard.html"
-
- def _is_ajax(self):
- return self.request.headers.get("X-Requested-With") == "XMLHttpRequest"
-
- def form_valid(self, form):
- organization = self.object
- user = self.request.user
- redirect_url = reverse("organizations:subscriptions", args=[organization.slug])
- token = form.cleaned_data["stripe_token"]
- try:
- organization.save_card(token, user)
- except stripe.StripeError as exc:
- user_message = format_stripe_error(exc)
- if self._is_ajax():
- return JsonResponse({"error": user_message}, status=400)
- messages.error(self.request, f"Payment error: {user_message}")
- return redirect(organization)
- else:
- success_msg = _("Credit card updated")
- if self._is_ajax():
- return JsonResponse(
- {"redirect": redirect_url, "message": str(success_msg)}
- )
- messages.success(self.request, success_msg)
- return redirect(organization)
-
- def get_context_data(self, **kwargs):
- context = super().get_context_data(**kwargs)
- customer = self.object.customer()
- if customer.card is None:
- card = None
- elif customer.card.object == "payment_method":
- card = customer.card.card
- else:
- card = customer.card
- context["card"] = card
- return context
-
-
-class UpdateSubscriptionFrequency(OrganizationPermissionMixin, UpdateView):
+ subject = "organizations"
permission_required = "organizations.can_edit_subscription"
- form_class = UpdateSubscriptionFrequencyForm
- template_name = "organizations/organization_updatesubscriptionfrequency.html"
-
- def get_queryset(self):
- return Organization.objects.filter(slug=self.kwargs["slug"])
-
- def get_object(self, queryset=None):
- if queryset is None:
- queryset = self.get_queryset()
- return queryset.get()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
-
- subscription = self.object.subscriptions.filter(id=self.kwargs["pk"]).first()
- context["subscription"] = subscription
- context["next_date"] = get_subscription_next_date(subscription)
-
+ context["subject"] = self.subject
return context
-class UpdateReceiptEmail(OrganizationPermissionMixin, UpdateView):
- permission_required = "organizations.can_edit_subscription"
- queryset = Organization.objects.filter(individual=False)
- form_class = UpdateReceiptEmailForm
- template_name = "organizations/organization_updatereceiptemail.html"
+class ManageSubscriptions(OrgSubscriptionView, BaseManageSubscriptions):
+ pass
- def get_context_data(self, **kwargs):
- context = super().get_context_data(**kwargs)
- context["subject"] = "org"
- return context
- def form_valid(self, form):
- self.object.set_receipt_emails(form.cleaned_data["receipt_emails"])
- return redirect("organizations:subscriptions", slug=self.object.slug)
+class UpdateCard(OrgSubscriptionView, BaseUpdateCard):
+ pass
- def get_initial(self):
- return {
- "receipt_emails": ", ".join(
- r.email for r in self.object.receipt_emails.all()
- ),
- }
+class UpdateSubscriptionFrequency(OrgSubscriptionView, BaseUpdateSubscriptionFrequency):
+ pass
-class CancelSubscription(OrganizationPermissionMixin, UpdateView):
- permission_required = "organizations.can_edit_subscription"
- form_class = CancelSubscriptionForm
- template_name = "organizations/organization_cancelsubscription.html"
-
- def get_queryset(self):
- return Organization.objects.filter(slug=self.kwargs["slug"])
- def get_object(self, queryset=None):
- if queryset is None:
- queryset = self.get_queryset()
- return queryset.get()
+class UpdateReceiptEmail(OrgSubscriptionView, BaseUpdateReceiptEmail):
+ pass
- def get_context_data(self, **kwargs):
- context = super().get_context_data(**kwargs)
- context["subject"] = "org"
- subscription = self.object.subscriptions.filter(id=self.kwargs["pk"]).first()
- if subscription:
- context["subscription"] = subscription
- context["next_date"] = get_subscription_next_date(subscription)
- return context
- def form_valid(self, form):
- organization = self.object
- subscription = self.object.subscriptions.filter(id=self.kwargs["pk"]).first()
- if subscription:
- organization.remove_subscription(subscription)
- messages.success(self.request, _("Subscription cancelled."))
- return redirect("organizations:subscriptions", slug=self.object.slug)
+class CancelSubscription(OrgSubscriptionView, BaseCancelSubscription):
+ pass
-class PaymentsList(PermissionRequiredMixin, ListView):
- template_name = "organizations/organization_payments.html"
- paginate_by = 20
+class PaymentsList(PermissionRequiredMixin, BasePaymentsList):
+ subject = "organizations"
def has_permission(self):
user = self.request.user
organization = Organization.objects.get(slug=self.kwargs["slug"])
return user.has_perm("organizations.can_view_charge", organization)
- def get_queryset(self):
- return Charge.objects.filter(organization__slug=self.kwargs["slug"])
-
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
- context["subject"] = "org"
- context["organization"] = Organization.objects.get(slug=self.kwargs["slug"])
+ context["subject"] = self.subject
return context
diff --git a/squarelet/subscriptions/__init__.py b/squarelet/subscriptions/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/squarelet/subscriptions/forms.py b/squarelet/subscriptions/forms.py
new file mode 100644
index 000000000..c9f9db0f5
--- /dev/null
+++ b/squarelet/subscriptions/forms.py
@@ -0,0 +1,98 @@
+# Django
+from django import forms
+from django.core.validators import validate_email
+from django.utils.translation import gettext_lazy as _
+
+# Standard Library
+import re
+
+# Third Party
+from crispy_forms.helper import FormHelper
+from crispy_forms.layout import Field as CrispyField, Fieldset, Layout
+
+# Squarelet
+from squarelet.core.forms import StripeForm
+from squarelet.core.layout import Field # Used by PaymentForm
+from squarelet.organizations.models import Organization, Plan
+
+
+class CardForm(StripeForm):
+ """Update the credit card on file for an organization."""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ # This form is only for replacing the current card, so these fields aren't used
+ self.fields.pop("use_card_on_file", None)
+ self.fields.pop("remove_card_on_file", None)
+
+ self.helper = FormHelper()
+ self.helper.layout = Layout(
+ Field("stripe_pk"),
+ Field("stripe_token"),
+ )
+ self.helper.form_tag = False
+
+
+class UpdateSubscriptionFrequencyForm(forms.ModelForm):
+ """Update the frequency of a subscription."""
+
+ class Meta:
+ model = Plan
+ fields = []
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.helper = FormHelper()
+ self.helper.form_tag = False
+
+
+class UpdateReceiptEmailForm(forms.ModelForm):
+ """Update the receipt email for an organization."""
+
+ receipt_emails = forms.CharField(
+ label=_("Receipt emails"),
+ widget=forms.TextInput(),
+ required=True,
+ help_text=_("Enter one or more email addresses, separated by commas"),
+ )
+
+ class Meta:
+ model = Organization
+ fields = ["receipt_emails"]
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.helper = FormHelper()
+ self.helper.template_pack = "forms"
+ self.helper.layout = Layout(
+ CrispyField("receipt_emails"),
+ )
+ self.helper.form_tag = False
+
+ def clean_receipt_emails(self):
+ """Make sure each entry is a valid email"""
+ emails = re.split(r",\s*", self.cleaned_data["receipt_emails"])
+ emails = [e.strip() for e in emails if e.strip()]
+ bad_emails = []
+ for email in emails:
+ try:
+ validate_email(email.strip())
+ except forms.ValidationError:
+ bad_emails.append(email)
+ if bad_emails:
+ bad_emails_str = ", ".join(bad_emails)
+ raise forms.ValidationError(f"Invalid email: {bad_emails_str}")
+ return emails
+
+
+class CancelSubscriptionForm(forms.ModelForm):
+ """Cancel a subscription."""
+
+ class Meta:
+ model = Organization
+ fields = []
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.helper = FormHelper()
+ self.helper.form_tag = False
diff --git a/squarelet/subscriptions/views.py b/squarelet/subscriptions/views.py
new file mode 100644
index 000000000..5df26632e
--- /dev/null
+++ b/squarelet/subscriptions/views.py
@@ -0,0 +1,204 @@
+# Django
+from django.contrib import messages
+from django.http.response import JsonResponse
+from django.shortcuts import redirect
+from django.urls import reverse
+from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
+from django.views.generic import DetailView, ListView, UpdateView
+
+# Standard Library
+from datetime import datetime
+
+# Third Party
+import stripe
+
+# Squarelet
+from squarelet.core.utils import format_stripe_error
+from squarelet.organizations.mixins import OrganizationPermissionMixin
+from squarelet.organizations.models import Charge, Organization
+from squarelet.organizations.payments.factory import get_payment_provider
+from squarelet.subscriptions.forms import (
+ CancelSubscriptionForm,
+ CardForm,
+ UpdateReceiptEmailForm,
+ UpdateSubscriptionFrequencyForm,
+)
+
+
+class BaseManageSubscriptions(DetailView):
+ template_name = "subscriptions/manage_subscriptions.html"
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+
+ # Get subscriptions and add renewal/cancellation date and cost data
+ subscriptions = self.object.subscriptions.all()
+ for subscription in subscriptions:
+ subscription.next_date = get_subscription_next_date(subscription)
+ subscription.cost = subscription.plan.base_price
+ context["subscriptions"] = subscriptions
+
+ # Get card on file
+ customer = self.object.customer()
+ if customer.card is None:
+ card = None
+ elif customer.card.object == "payment_method":
+ card = customer.card.card
+ else:
+ card = customer.card
+ context["card"] = card
+
+ # Get all receipt emails
+ context["receipt_emails"] = self.object.receipt_emails.all()
+
+ # Get failed receipt emails
+ context["failed_receipt_emails"] = self.object.receipt_emails.filter(
+ failed=True
+ )
+
+ # Get five most recent payments
+ payments = self.object.charges.order_by("-created_at").all()[:5]
+ context["payments"] = payments
+
+ return context
+
+
+class BaseUpdateSubscriptionFrequency(UpdateView):
+ form_class = UpdateSubscriptionFrequencyForm
+ template_name = "subscriptions/update_subscription_frequency.html"
+
+ def get_queryset(self):
+ return Organization.objects.filter(slug=self.kwargs["slug"])
+
+ def get_object(self, queryset=None):
+ if queryset is None:
+ queryset = self.get_queryset()
+ return queryset.get()
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+
+ subscription = self.object.subscriptions.filter(id=self.kwargs["pk"]).first()
+ context["subscription"] = subscription
+ context["next_date"] = get_subscription_next_date(subscription)
+
+ return context
+
+
+class BaseUpdateCard(UpdateView):
+ """Update the credit card on file for an organization."""
+
+ form_class = CardForm
+ template_name = "subscriptions/update_card.html"
+
+ def _is_ajax(self):
+ return self.request.headers.get("X-Requested-With") == "XMLHttpRequest"
+
+ def form_valid(self, form):
+ organization = self.object
+ user = self.request.user
+ redirect_url = reverse("organizations:subscriptions", args=[organization.slug])
+ token = form.cleaned_data["stripe_token"]
+ try:
+ organization.save_card(token, user)
+ except stripe.StripeError as exc:
+ user_message = format_stripe_error(exc)
+ if self._is_ajax():
+ return JsonResponse({"error": user_message}, status=400)
+ messages.error(self.request, f"Payment error: {user_message}")
+ return redirect(organization)
+ else:
+ success_msg = _("Credit card updated")
+ if self._is_ajax():
+ return JsonResponse(
+ {"redirect": redirect_url, "message": str(success_msg)}
+ )
+ messages.success(self.request, success_msg)
+ return redirect(organization)
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ customer = self.object.customer()
+ if customer.card is None:
+ card = None
+ elif customer.card.object == "payment_method":
+ card = customer.card.card
+ else:
+ card = customer.card
+ context["card"] = card
+ return context
+
+
+class BaseUpdateReceiptEmail(OrganizationPermissionMixin, UpdateView):
+ form_class = UpdateReceiptEmailForm
+ template_name = "subscriptions/update_receipt_email.html"
+
+ def form_valid(self, form):
+ self.object.set_receipt_emails(form.cleaned_data["receipt_emails"])
+ return redirect("organizations:subscriptions", slug=self.object.slug)
+
+ def get_initial(self):
+ return {
+ "receipt_emails": ", ".join(
+ r.email for r in self.object.receipt_emails.all()
+ ),
+ }
+
+
+class BaseCancelSubscription(UpdateView):
+ form_class = CancelSubscriptionForm
+ template_name = "subscriptions/cancel_subscription.html"
+
+ def get_queryset(self):
+ return Organization.objects.filter(slug=self.kwargs["slug"])
+
+ def get_object(self, queryset=None):
+ if queryset is None:
+ queryset = self.get_queryset()
+ return queryset.get()
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ subscription = self.object.subscriptions.filter(id=self.kwargs["pk"]).first()
+ if subscription:
+ context["subscription"] = subscription
+ context["next_date"] = get_subscription_next_date(subscription)
+ return context
+
+ def form_valid(self, form):
+ organization = self.object
+ subscription = self.object.subscriptions.filter(id=self.kwargs["pk"]).first()
+ if subscription:
+ organization.remove_subscription(subscription)
+ messages.success(self.request, _("Subscription cancelled."))
+ return redirect("organizations:subscriptions", slug=self.object.slug)
+
+
+class BasePaymentsList(ListView):
+ template_name = "subscriptions/payments.html"
+ paginate_by = 20
+
+ def get_queryset(self):
+ return Charge.objects.filter(organization__slug=self.kwargs["slug"])
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["organization"] = Organization.objects.get(slug=self.kwargs["slug"])
+ return context
+
+
+def get_subscription_next_date(subscription):
+ stripe_sub = subscription.stripe_subscription
+ if stripe_sub:
+ time_stamp = (
+ get_payment_provider()
+ .get_subscription_service()
+ .get_current_period_end(stripe_sub)
+ )
+ if time_stamp:
+ tz_datetime = datetime.fromtimestamp(
+ time_stamp, tz=timezone.get_current_timezone()
+ )
+ return tz_datetime.date()
+ return None
diff --git a/squarelet/templates/organizations/organization_cancelsubscription.html b/squarelet/templates/subscriptions/cancel_subscription.html
similarity index 100%
rename from squarelet/templates/organizations/organization_cancelsubscription.html
rename to squarelet/templates/subscriptions/cancel_subscription.html
diff --git a/squarelet/templates/organizations/includes/receipts_card.html b/squarelet/templates/subscriptions/includes/receipts_card.html
similarity index 100%
rename from squarelet/templates/organizations/includes/receipts_card.html
rename to squarelet/templates/subscriptions/includes/receipts_card.html
diff --git a/squarelet/templates/organizations/organization_managesubscriptions.html b/squarelet/templates/subscriptions/manage_subscriptions.html
similarity index 100%
rename from squarelet/templates/organizations/organization_managesubscriptions.html
rename to squarelet/templates/subscriptions/manage_subscriptions.html
diff --git a/squarelet/templates/organizations/organization_payments.html b/squarelet/templates/subscriptions/payments.html
similarity index 93%
rename from squarelet/templates/organizations/organization_payments.html
rename to squarelet/templates/subscriptions/payments.html
index e52abfaaf..235405abf 100644
--- a/squarelet/templates/organizations/organization_payments.html
+++ b/squarelet/templates/subscriptions/payments.html
@@ -29,6 +29,6 @@ {% trans "Payment history" %}
{% block main %}
- {% include "organizations/includes/receipts_card.html" with payments=object_list page_obj=page_obj %}
+ {% include "subscriptions/includes/receipts_card.html" with payments=object_list page_obj=page_obj %}
{% endblock main %}
diff --git a/squarelet/templates/organizations/organization_updatecard.html b/squarelet/templates/subscriptions/update_card.html
similarity index 100%
rename from squarelet/templates/organizations/organization_updatecard.html
rename to squarelet/templates/subscriptions/update_card.html
diff --git a/squarelet/templates/organizations/organization_updatereceiptemail.html b/squarelet/templates/subscriptions/update_receipt_email.html
similarity index 100%
rename from squarelet/templates/organizations/organization_updatereceiptemail.html
rename to squarelet/templates/subscriptions/update_receipt_email.html
diff --git a/squarelet/templates/organizations/organization_updatesubscriptionfrequency.html b/squarelet/templates/subscriptions/update_subscription_frequency.html
similarity index 100%
rename from squarelet/templates/organizations/organization_updatesubscriptionfrequency.html
rename to squarelet/templates/subscriptions/update_subscription_frequency.html
From a0256a1006cc6ae4f2e6822eeca7b2668e97daff Mon Sep 17 00:00:00 2001
From: Daniel Nass
Date: Tue, 7 Jul 2026 14:13:13 -0400
Subject: [PATCH 56/86] Add URLs and views for user subscription pages
---
squarelet/users/urls.py | 22 +++++++++++++++++++
squarelet/users/views.py | 46 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 68 insertions(+)
diff --git a/squarelet/users/urls.py b/squarelet/users/urls.py
index e153696f3..af9268735 100644
--- a/squarelet/users/urls.py
+++ b/squarelet/users/urls.py
@@ -50,4 +50,26 @@
view=views.UserRequestsView.as_view(),
name="requests",
),
+ path(
+ "/subscriptions/",
+ view=views.ManageSubscriptions.as_view(),
+ name="subscriptions",
+ ),
+ path(
+ "/cancel//",
+ view=views.CancelSubscription.as_view(),
+ name="cancel-subscription",
+ ),
+ path(
+ "/update-frequency//",
+ view=views.UpdateSubscriptionFrequency.as_view(),
+ name="update-frequency",
+ ),
+ path(
+ "/receipts/",
+ view=views.UpdateReceiptEmail.as_view(),
+ name="update-receipt-email",
+ ),
+ path("/card/", view=views.UpdateCard.as_view(), name="update-card"),
+ path("/payments/", view=views.PaymentsList.as_view(), name="payments"),
]
diff --git a/squarelet/users/views.py b/squarelet/users/views.py
index 5d96f172b..ebbed9a9d 100644
--- a/squarelet/users/views.py
+++ b/squarelet/users/views.py
@@ -55,6 +55,14 @@
from squarelet.organizations.payments.factory import get_payment_provider
from squarelet.organizations.views import UpdateSubscription
from squarelet.services.models import Service
+from squarelet.subscriptions.views import (
+ BaseCancelSubscription,
+ BaseManageSubscriptions,
+ BasePaymentsList,
+ BaseUpdateCard,
+ BaseUpdateReceiptEmail,
+ BaseUpdateSubscriptionFrequency,
+)
from squarelet.users.forms import (
SignupForm,
UserAutologinPreferenceForm,
@@ -665,3 +673,41 @@ class UserRequestsView(BaseUserInvitationRequestView):
context_object_name = "requests"
is_request_view = True
redirect_url_name = "users:requests"
+
+
+class IndividualSubscriptionView:
+ """Base class for individual subscription views."""
+
+ queryset = Organization.objects.filter(individual=True)
+ subject = "users"
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["subject"] = self.subject
+ return context
+
+
+class ManageSubscriptions(IndividualSubscriptionView, BaseManageSubscriptions):
+ pass
+
+
+class CancelSubscription(IndividualSubscriptionView, BaseCancelSubscription):
+ pass
+
+
+class UpdateSubscriptionFrequency(
+ IndividualSubscriptionView, BaseUpdateSubscriptionFrequency
+):
+ pass
+
+
+class UpdateCard(IndividualSubscriptionView, BaseUpdateCard):
+ pass
+
+
+class UpdateReceiptEmail(IndividualSubscriptionView, BaseUpdateReceiptEmail):
+ pass
+
+
+class PaymentsList(IndividualSubscriptionView, BasePaymentsList):
+ pass
\ No newline at end of file
From 5170e05358b16a1004080bf4bb2b5eea34583da0 Mon Sep 17 00:00:00 2001
From: Daniel Nass
Date: Tue, 7 Jul 2026 14:20:50 -0400
Subject: [PATCH 57/86] Fix import
---
squarelet/templates/subscriptions/manage_subscriptions.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/squarelet/templates/subscriptions/manage_subscriptions.html b/squarelet/templates/subscriptions/manage_subscriptions.html
index 0f5de8e0b..697c3b02f 100644
--- a/squarelet/templates/subscriptions/manage_subscriptions.html
+++ b/squarelet/templates/subscriptions/manage_subscriptions.html
@@ -182,6 +182,6 @@ {% trans "Recent payments" %}
- {% include "organizations/includes/receipts_card.html" with payments=payments %}
+ {% include "subscriptions/includes/receipts_card.html" with payments=payments %}
{% endblock main %}
\ No newline at end of file
From b989d0f7d075f1980788a26d12a9b3d1f2b06ad6 Mon Sep 17 00:00:00 2001
From: Daniel Nass
Date: Tue, 7 Jul 2026 14:26:29 -0400
Subject: [PATCH 58/86] Build URLs in plan card
---
frontend/css/plan_card.css | 2 +-
.../organizations/includes/plan_card.html | 18 ++++++++----------
.../organizations/organization_detail.html | 5 +----
3 files changed, 10 insertions(+), 15 deletions(-)
diff --git a/frontend/css/plan_card.css b/frontend/css/plan_card.css
index 34440f342..facd4297b 100644
--- a/frontend/css/plan_card.css
+++ b/frontend/css/plan_card.css
@@ -56,7 +56,7 @@ a.plan-name {
}
.plan-detail {
- flex: 1 0 auto;
+ flex: 1 0 100%;
padding: 0;
display: flex;
align-items: center;
diff --git a/squarelet/templates/organizations/includes/plan_card.html b/squarelet/templates/organizations/includes/plan_card.html
index 097cfa6f0..00a84cfa3 100644
--- a/squarelet/templates/organizations/includes/plan_card.html
+++ b/squarelet/templates/organizations/includes/plan_card.html
@@ -9,10 +9,8 @@
inherited_plans. — list of (source_org, plan) tuples; may be empty.
upgrade_plan — Plan to advertise when there is no own plan (optional).
next_charge_date, card, cancelled — subscription details (optional, org only).
- subscriptions_url — URL for the upgrade / billing-settings CTA (optional).
- card_url — URL for the card management CTA (optional).
can_edit_subscription — bool gating the CTA. Default True.
- subject — "user" | "org". Controls copy. Default "org".
+ subject — "users" | "organizations". Controls copy and links
{% endcomment %}
{% if subscriptions or upgrade_plan or not inherited_plans %}
@@ -28,7 +26,7 @@ {% trans "Payment card" %}
{{ card.brand }} {% trans "ending in" %} {{ card.last4 }}
-
+
{% trans "Add new card" %}
@@ -37,7 +35,7 @@ {% trans "Payment card" %}
{% trans "Your plans" %}
- {% trans "Modify or cancel your" %} {% if subject == "org" %}{% trans "organization's " %}{% endif %}{% trans "subscriptions" %}
+ {% trans "Modify or cancel your" %} {% if subject == "organizations" %}{% trans "organization's " %}{% endif %}{% trans "subscriptions" %}
@@ -57,7 +55,7 @@ {% trans "Your plans" %}
{% trans "Cancelled" %}
{% else %}
-
+
{% icon "gear" %}
{% trans "Manage" %}
@@ -69,7 +67,7 @@ {% trans "Your plans" %}
{% else %}
- {% if subject == "user" %}
+ {% if subject == "users" %}
{% trans "You are using MuckRock’s" %}
{% trans "Free" %}
{% trans "plan." %}
@@ -77,8 +75,8 @@ {% trans "Your plans" %}
{% trans "No active subscription." %}
{% endif %}
- {% if payment_url and can_edit_subscription %}
- {% trans "Upgrade" %}
+ {% if can_edit_subscription %}
+ {% trans "Upgrade" %}
{% endif %}
{% if upgrade_plan %}
@@ -107,7 +105,7 @@ {% trans "Your plans" %}
{% for source_org, source_plan in inherited_plans %}
- {% if subject == "user" %}
+ {% if subject == "users" %}
{% trans "You inherit" %}
{% else %}
{% trans "Inherits" %}
diff --git a/squarelet/templates/organizations/organization_detail.html b/squarelet/templates/organizations/organization_detail.html
index de9cb0ffd..fc2041426 100644
--- a/squarelet/templates/organizations/organization_detail.html
+++ b/squarelet/templates/organizations/organization_detail.html
@@ -269,15 +269,12 @@ {% trans "Affiliations" %}
{% endif %}
{% if can_view_subscription %}
- {% url 'organizations:subscriptions' organization.slug as subscriptions_url %}
- {% url 'organizations:payment' organization.slug as payment_url %}
- {% url 'organizations:update-card' organization.slug as card_url %}
- {% url 'users:payment' username=user.username as user_payment_url %}
- {% include "organizations/includes/plan_card.html" with org=user.individual_organization plan=current_plan inherited_plans=premium_org_plans upgrade_plan=upgrade_plan next_charge_date=current_plan_next_charge_date card=current_plan_card card_brand=current_plan_card_brand card_last4=current_plan_card_last4 cancelled=current_plan_cancelled payment_url=user_payment_url can_edit_subscription=True subject="user" %}
+ {% include "organizations/includes/plan_card.html" with org=user.individual_organization subject_slug=user.username subscriptions=subscriptions inherited_plans=premium_org_plans upgrade_plan=upgrade_plan card=current_plan_card card_brand=current_plan_card_brand card_last4=current_plan_card_last4 can_edit_subscription=True subject="users" %}
diff --git a/squarelet/users/tests/test_views.py b/squarelet/users/tests/test_views.py
index 9a5056747..bd3b9cd03 100644
--- a/squarelet/users/tests/test_views.py
+++ b/squarelet/users/tests/test_views.py
@@ -409,6 +409,83 @@ def test_get_admin(self, rf, organization_factory, user_factory, charge_factory)
assert response.status_code == 200
+@pytest.mark.django_db()
+class TestIndividualSubscriptionViews:
+ """The user billing routes are keyed on username; the individual
+ organization is resolved from it rather than exposed in the URL."""
+
+ def _bind(self, view_class, rf, **kwargs):
+ view = view_class()
+ view.request = rf.get("/")
+ view.kwargs = kwargs
+ return view
+
+ def test_resolves_individual_org_by_username(self, rf, user_factory):
+ # Usernames may contain characters (e.g. ".") that are not valid in
+ # an org slug, so resolution must go through the username.
+ user = user_factory(username="lasser.allan")
+ view = self._bind(views.ManageSubscriptions, rf, username=user.username)
+ assert view.get_object() == user.individual_organization
+
+ def test_unknown_username_is_404(self, rf):
+ view = self._bind(views.ManageSubscriptions, rf, username="nobody.here")
+ with pytest.raises(Http404):
+ view.get_object()
+
+ def test_payments_scoped_to_individual_org(
+ self, rf, user_factory, organization_factory, charge_factory
+ ):
+ user = user_factory(username="dotted.name")
+ charge = charge_factory(
+ organization=user.individual_organization, charge_id="ch_individual"
+ )
+ other_charge = charge_factory(
+ organization=organization_factory(), charge_id="ch_other"
+ )
+ view = self._bind(views.PaymentsList, rf, username=user.username)
+ charges = list(view.get_queryset())
+ assert charge in charges
+ assert other_charge not in charges
+
+ def test_subject_urls_use_username(self, rf, user_factory):
+ user = user_factory(username="dotted.name")
+ view = self._bind(views.ManageSubscriptions, rf, username=user.username)
+ view.object = view.get_object()
+ assert view.reverse_subject("subscriptions") == (
+ f"/users/{user.username}/subscriptions/"
+ )
+
+
+@pytest.mark.django_db()
+class TestIndividualSubscriptionAccess(ViewTestMixin):
+ """User billing pages are limited to the account owner and staff."""
+
+ view = views.PaymentsList
+ url = "/users/{username}/payments/"
+
+ def test_owner_allowed(self, rf, user_factory):
+ user = user_factory(username="dotted.name")
+ response = self.call_view(rf, user, username=user.username)
+ assert response.status_code == 200
+
+ def test_staff_allowed(self, rf, user_factory):
+ user = user_factory(username="dotted.name")
+ staff = user_factory(is_staff=True)
+ response = self.call_view(rf, staff, username=user.username)
+ assert response.status_code == 200
+
+ def test_other_user_denied(self, rf, user_factory):
+ user = user_factory(username="dotted.name")
+ other = user_factory()
+ with pytest.raises(Http404):
+ self.call_view(rf, other, username=user.username)
+
+ def test_anonymous_redirected_to_login(self, rf, user_factory):
+ user = user_factory(username="dotted.name")
+ response = self.call_view(rf, username=user.username)
+ assert response.status_code == 302
+
+
@pytest.mark.django_db()
class TestUserOnboardingView(ViewTestMixin):
"""Test the User Onboarding view"""
diff --git a/squarelet/users/urls.py b/squarelet/users/urls.py
index f16c73c25..e7dd3bf71 100644
--- a/squarelet/users/urls.py
+++ b/squarelet/users/urls.py
@@ -51,25 +51,27 @@
name="requests",
),
path(
- "/subscriptions/",
+ "/subscriptions/",
view=views.ManageSubscriptions.as_view(),
name="subscriptions",
),
path(
- "/cancel//",
+ "/cancel//",
view=views.CancelSubscription.as_view(),
name="cancel-subscription",
),
path(
- "/update-frequency//",
+ "/update-frequency//",
view=views.UpdateSubscriptionFrequency.as_view(),
name="update-frequency",
),
path(
- "/receipt-email/",
+ "/receipt-email/",
view=views.UpdateReceiptEmail.as_view(),
name="update-receipt-email",
),
- path("/card/", view=views.UpdateCard.as_view(), name="update-card"),
- path("/payments/", view=views.PaymentsList.as_view(), name="payments"),
+ path("/card/", view=views.UpdateCard.as_view(), name="update-card"),
+ path(
+ "/payments/", view=views.PaymentsList.as_view(), name="payments"
+ ),
]
diff --git a/squarelet/users/views.py b/squarelet/users/views.py
index cb71ebf62..02a037faa 100644
--- a/squarelet/users/views.py
+++ b/squarelet/users/views.py
@@ -12,7 +12,7 @@
HttpResponseRedirect,
JsonResponse,
)
-from django.shortcuts import redirect
+from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import (
@@ -50,7 +50,6 @@
from squarelet.organizations.forms import InvitationAcceptForm
from squarelet.organizations.models import Invitation, ReceiptEmail
from squarelet.organizations.models.payment import Plan
-from squarelet.organizations.payments.factory import get_payment_provider
from squarelet.organizations.views import UpdateSubscription
from squarelet.services.models import Service
from squarelet.subscriptions.views import (
@@ -669,16 +668,21 @@ class UserRequestsView(BaseUserInvitationRequestView):
redirect_url_name = "users:requests"
-class IndividualSubscriptionView:
- """Base class for individual subscription views."""
+class IndividualSubscriptionView(LoginRequiredMixin, StaffAccessMixin):
+ """Base class for individual subscription views.
+
+ Reached by username; the individual organization is resolved from it
+ and never exposed in the URL. Access is limited to the account owner
+ and staff (via ``StaffAccessMixin``).
+ """
- queryset = Organization.objects.filter(individual=True)
subject = "users"
+ subject_url_kwarg = "username"
+ individual = True
- def get_context_data(self, **kwargs):
- context = super().get_context_data(**kwargs)
- context["subject"] = self.subject
- return context
+ def get_organization(self):
+ user = get_object_or_404(User, username=self.kwargs["username"])
+ return user.individual_organization
class ManageSubscriptions(IndividualSubscriptionView, BaseManageSubscriptions):
From acbef2c7ccece1dccb2fd5e1776cf3e72549ec22 Mon Sep 17 00:00:00 2001
From: Allan Lasser
Date: Fri, 10 Jul 2026 17:32:49 -0400
Subject: [PATCH 71/86] Fix card mocking in Stripe tests
---
squarelet/organizations/tests/test_permissions.py | 5 +++++
squarelet/organizations/tests/test_views.py | 15 +++++++++++++++
squarelet/users/tests/test_views.py | 5 +++++
3 files changed, 25 insertions(+)
diff --git a/squarelet/organizations/tests/test_permissions.py b/squarelet/organizations/tests/test_permissions.py
index f6a94f4cb..4235e030b 100644
--- a/squarelet/organizations/tests/test_permissions.py
+++ b/squarelet/organizations/tests/test_permissions.py
@@ -245,6 +245,11 @@ class TestDetailPermissionContext(ViewTestMixin):
view = views.Detail
url = "/organizations/{slug}/"
+ @pytest.fixture(autouse=True)
+ def _mock_card(self, mocker):
+ """Avoid hitting Stripe when the view fetches the customer's card."""
+ mocker.patch("squarelet.organizations.models.Customer.card", None)
+
def test_detail_admin_sees_manage_members_link(
self, rf, organization_factory, user_factory
):
diff --git a/squarelet/organizations/tests/test_views.py b/squarelet/organizations/tests/test_views.py
index 20cf34aeb..fde957030 100644
--- a/squarelet/organizations/tests/test_views.py
+++ b/squarelet/organizations/tests/test_views.py
@@ -51,6 +51,11 @@ def _setup_plan(self):
slug="organization", defaults={"name": "Organization"}
)
+ @pytest.fixture(autouse=True)
+ def _mock_card(self, mocker):
+ """Avoid hitting Stripe when the view fetches the customer's card."""
+ mocker.patch("squarelet.organizations.models.Customer.card", None)
+
def test_get_anonymous(self, rf, organization_factory, user_factory):
user = user_factory()
admin = user_factory()
@@ -540,6 +545,11 @@ def _setup_plan(self):
slug="organization", defaults={"name": "Organization"}
)
+ @pytest.fixture(autouse=True)
+ def _mock_card(self, mocker):
+ """Avoid hitting Stripe when the view fetches the customer's card."""
+ mocker.patch("squarelet.organizations.models.Customer.card", None)
+
def test_staff_can_sync_wix_for_org_with_direct_wix_plan(
self, rf, organization_factory, plan_factory, user_factory, mocker
):
@@ -740,6 +750,11 @@ def _setup_plan(self):
slug="organization", defaults={"name": "Organization"}
)
+ @pytest.fixture(autouse=True)
+ def _mock_card(self, mocker):
+ """Avoid hitting Stripe when the view fetches the customer's card."""
+ mocker.patch("squarelet.organizations.models.Customer.card", None)
+
def test_join_button_shown_for_non_member(
self, rf, organization_factory, user_factory
):
diff --git a/squarelet/users/tests/test_views.py b/squarelet/users/tests/test_views.py
index bd3b9cd03..cf47588fc 100644
--- a/squarelet/users/tests/test_views.py
+++ b/squarelet/users/tests/test_views.py
@@ -128,6 +128,11 @@ class TestUserDetailView(ViewTestMixin):
view = views.UserDetailView
url = "/users/{username}/"
+ @pytest.fixture(autouse=True)
+ def _mock_card(self, mocker):
+ """Avoid hitting Stripe when the view fetches the customer's card."""
+ mocker.patch("squarelet.organizations.models.Customer.card", None)
+
def test_get(self, rf, user_factory, professional_plan_factory):
user = user_factory()
professional_plan_factory()
From 9da5b27cb5ddd4b9fb9a1da6b8384a26e050ef83 Mon Sep 17 00:00:00 2001
From: Daniel Nass
Date: Mon, 20 Jul 2026 13:41:17 -0400
Subject: [PATCH 72/86] Updated method for getting card payment brand
---
squarelet/organizations/views/detail.py | 1 -
squarelet/subscriptions/views.py | 24 +++++++------------
.../organizations/includes/plan_card.html | 2 +-
.../templates/subscriptions/update_card.html | 2 +-
squarelet/users/views.py | 2 +-
5 files changed, 11 insertions(+), 20 deletions(-)
diff --git a/squarelet/organizations/views/detail.py b/squarelet/organizations/views/detail.py
index f3c6b0975..97a2d1a4b 100644
--- a/squarelet/organizations/views/detail.py
+++ b/squarelet/organizations/views/detail.py
@@ -20,7 +20,6 @@
from squarelet.organizations.forms import InvitationAcceptForm
from squarelet.organizations.models import Invitation, Membership, Organization, Plan
from squarelet.organizations.models.invitation import OrganizationInvitation
-from squarelet.organizations.payments.factory import get_payment_provider
from squarelet.organizations.tasks import sync_wix
# How much to paginate organizations list by
diff --git a/squarelet/subscriptions/views.py b/squarelet/subscriptions/views.py
index 19fc49703..b309d572b 100644
--- a/squarelet/subscriptions/views.py
+++ b/squarelet/subscriptions/views.py
@@ -16,6 +16,7 @@
# Squarelet
from squarelet.core.utils import format_stripe_error
from squarelet.organizations.models import Charge, Organization
+from squarelet.organizations.models.payment import _payment_brand
from squarelet.organizations.payments.factory import get_payment_provider
from squarelet.subscriptions.forms import (
CancelSubscriptionForm,
@@ -82,14 +83,9 @@ def get_context_data(self, **kwargs):
context["subscriptions"] = subscriptions
# Get card on file
- customer = self.object.customer()
- if customer.card is None:
- card = None
- elif customer.card.object == "payment_method":
- card = customer.card.card
- else:
- card = customer.card
- context["card"] = card
+ details = self.object.customer().payment_details
+ context["card"] = details
+ context["card_brand"] = _payment_brand(details) if details else ""
# Get all receipt emails
context["receipt_emails"] = self.object.receipt_emails.all()
@@ -153,14 +149,10 @@ def form_valid(self, form):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
- customer = self.object.customer()
- if customer.card is None:
- card = None
- elif customer.card.object == "payment_method":
- card = customer.card.card
- else:
- card = customer.card
- context["card"] = card
+ details = self.object.customer().payment_details
+ context["card"] = details
+ context["card_brand"] = _payment_brand(details) if details else ""
+
return context
diff --git a/squarelet/templates/organizations/includes/plan_card.html b/squarelet/templates/organizations/includes/plan_card.html
index fd8192e18..9b9de4296 100644
--- a/squarelet/templates/organizations/includes/plan_card.html
+++ b/squarelet/templates/organizations/includes/plan_card.html
@@ -23,7 +23,7 @@ {% trans "Payment card" %}
{% icon "credit-card" %}
- {{ card.brand }} {% trans "ending in" %} {{ card.last4 }}
+ {{ card_brand }} {% trans "ending in" %} {{ card.last4 }}
diff --git a/squarelet/templates/subscriptions/update_card.html b/squarelet/templates/subscriptions/update_card.html
index 28755285b..17153fac5 100644
--- a/squarelet/templates/subscriptions/update_card.html
+++ b/squarelet/templates/subscriptions/update_card.html
@@ -22,7 +22,7 @@ {% trans "Change payment card" %}
{% icon "credit-card" %}
- {% blocktrans with card.brand as brand and card.last4 as last4 %}
+ {% blocktrans with card_brand as brand and card.last4 as last4 %}
Current card: {{ brand }} ending in {{ last4 }}
{% endblocktrans %}
diff --git a/squarelet/users/views.py b/squarelet/users/views.py
index 02a037faa..ffd6394cf 100644
--- a/squarelet/users/views.py
+++ b/squarelet/users/views.py
@@ -49,7 +49,7 @@
from squarelet.core.utils import new_action
from squarelet.organizations.forms import InvitationAcceptForm
from squarelet.organizations.models import Invitation, ReceiptEmail
-from squarelet.organizations.models.payment import Plan
+from squarelet.organizations.models.payment import Plan, _payment_brand
from squarelet.organizations.views import UpdateSubscription
from squarelet.services.models import Service
from squarelet.subscriptions.views import (
From 20e3bdc4677ac53155f1bb828e1e569543bd0376 Mon Sep 17 00:00:00 2001
From: Daniel Nass
Date: Wed, 22 Jul 2026 10:22:07 -0400
Subject: [PATCH 73/86] Template fix
---
.../organizations/organization_payment.html | 84 -------------------
1 file changed, 84 deletions(-)
diff --git a/squarelet/templates/organizations/organization_payment.html b/squarelet/templates/organizations/organization_payment.html
index 426cb5c9e..bcd85acd0 100644
--- a/squarelet/templates/organizations/organization_payment.html
+++ b/squarelet/templates/organizations/organization_payment.html
@@ -32,90 +32,6 @@ {{ organization.reference_name }}
|