From 9c2e7ba9bc219d580ba0e7dec6b3aeeee758153e Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Fri, 26 Jun 2026 15:39:55 -0400 Subject: [PATCH 01/86] Wrap current_plan in a list for now --- squarelet/organizations/views/detail.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/squarelet/organizations/views/detail.py b/squarelet/organizations/views/detail.py index 0bf6bb9a7..18666022d 100644 --- a/squarelet/organizations/views/detail.py +++ b/squarelet/organizations/views/detail.py @@ -61,7 +61,8 @@ def get_context_data(self, **kwargs): if hasattr(org, "subscriptions"): subscription = org.subscriptions.first() if subscription and hasattr(subscription, "plan"): - current_plan = subscription.plan + # Temporarily wrap this in a list to support the new plan card template, which expects a list of plans + current_plan = [subscription.plan] upgrade_plan = None context["current_plan"] = current_plan context["upgrade_plan"] = upgrade_plan From 8ae47f848c0ad53a3001af612e7a6170033022c0 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Fri, 26 Jun 2026 15:40:33 -0400 Subject: [PATCH 02/86] Update heading buttons on org plan card --- .../organizations/organization_detail.html | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/squarelet/templates/organizations/organization_detail.html b/squarelet/templates/organizations/organization_detail.html index 40a9569ab..4743ea216 100644 --- a/squarelet/templates/organizations/organization_detail.html +++ b/squarelet/templates/organizations/organization_detail.html @@ -68,7 +68,7 @@

{% trans "Staff control panel" %}

  • {% icon "credit-card" %} - {% trans "Plan" %} + {% trans "Plans" %}
  • {% endif %} @@ -269,19 +269,20 @@

    {% trans "Affiliations" %}

    {% endif %} {% if can_view_subscription %} + {% url 'organizations:payment' organization.slug as payment_url %}
    -

    {% trans "Plan" %}

    +

    {% trans "Plans" %}

    {% if can_edit_subscription %} From 7f9cf2040b3589c476245a459b64baebbc971b38 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Fri, 26 Jun 2026 15:41:53 -0400 Subject: [PATCH 03/86] Redesign plan card --- frontend/css/plan_card.css | 54 ++++++++++- .../organizations/includes/plan_card.html | 93 ++++++++++--------- 2 files changed, 96 insertions(+), 51 deletions(-) diff --git a/frontend/css/plan_card.css b/frontend/css/plan_card.css index 96f27d82f..0864889c7 100644 --- a/frontend/css/plan_card.css +++ b/frontend/css/plan_card.css @@ -19,7 +19,7 @@ flex-wrap: wrap; align-items: center; justify-content: space-between; - gap: 0.5rem 1rem; + gap: 1rem 1rem; font-size: var(--font-md, 1rem); } @@ -60,16 +60,60 @@ a.plan-name { padding: 0; display: flex; align-items: center; - gap: 0.5rem; + gap: 1rem; margin: 0.25rem 0; } -.plan-detail .icon { +.plan-card h3 { + font-weight: 600; + font-size: var(--font-md, 1rem); + color: var(--gray-5, #233944); + margin: 0; + flex: 1 0 0; + white-space: nowrap; +} + +.plan-card .caption { + display: flex; + align-items: center; + gap: 0.25rem; + margin: 0; +} + +.plan-card .caption .icon { display: inline-flex; align-items: center; color: var(--gray-4, #5c717c); - width: var(--font-sm, 1rem); - height: var(--font-sm, 1rem); + width: var(--font-md, 1em); + height: var(--font-md, 1em); +} + +.plan-list { + margin: 0; + padding: 0; + list-style-type: none; + display: flex; + gap: 0.5rem 1.5rem; + flex: 1 0 100%; +} + +.plan-item { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + flex: 1 0 100%; +} + +.plan-info { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.75rem; +} + +.plan-name { + color: var(--blue-4, #1367d0); } /* Plan benefits checklist */ diff --git a/squarelet/templates/organizations/includes/plan_card.html b/squarelet/templates/organizations/includes/plan_card.html index b08267eae..a64f81932 100644 --- a/squarelet/templates/organizations/includes/plan_card.html +++ b/squarelet/templates/organizations/includes/plan_card.html @@ -5,7 +5,7 @@ Args: org — subject Organization (used for payment / receipts URLs). - plan — own Plan or None. + plans — list of own Plans or empty list. 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). @@ -14,56 +14,57 @@ subject — "user" | "org". Controls copy. Default "org". {% endcomment %} -{% if plan or upgrade_plan or not inherited_plans %} -
    - {% if plan %} +{% if plans or upgrade_plan or not inherited_plans %} +
    + {% if plans %}
    -

    - {% if subject == "user" %} - {% trans "You are subscribed to the" %} - {% else %} - {% trans "Subscribed to the" %} - {% endif %} - {{ plan.name }} - {% trans "plan." %} -

    - {% if payment_url and can_edit_subscription %} - - {% if subject == "user" %}{% trans "Change plan" %}{% else %}{% trans "Billing settings" %}{% endif %} - + {% if card %} +
    +

    {% trans "Payment card" %}

    + + {% icon "credit-card" %} + + {{ card.brand }} {% trans "ending in" %} {{ card.last4 }} + + +
    + + {% trans "Add new card" %} + {% endif %} -
    - {% if plan.benefits %} -
      - {% for benefit in plan.benefits %} -
    • - {% icon "checkmark" %} - {{ benefit }} + +
      +

      {% trans "Your plans" %}

      + + {% trans "Modify or cancel your organization's subscriptions" %} + +
      + +
        + {% for plan in plans %} +
      • +
        + {{ plan.name }} + + {% icon "calendar" %} + {{ plan.annual|yesno:_("Annual,Monthly") }} + +
        + + {%if plan.cancelled %} + + {% trans "Cancelled" %} + + {% else %} + + {% icon "gear" %} + {% trans "Manage" %} + + {% endif %}
      • {% endfor %}
      - {% endif %} - {% if next_charge_date or card %} -
        - {% if next_charge_date %} -
      • - {% icon "calendar" %} - - {% if cancelled %}{% trans "Subscription ends on" %}{% else %}{% trans "Subscription renews on" %}{% endif %} - - -
      • - {% endif %} - {% if card %} -
      • - {% icon "credit-card" %} - {{ card_brand }} {% trans "ending in" %} {{ card_last4 }} -
      • - {% endif %} -
      - {% endif %} + {% else %}

      From be6ef2da8ea094f98a5288db60d10e9917b1c48d Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Fri, 26 Jun 2026 16:02:55 -0400 Subject: [PATCH 04/86] Set up new subscription management page structure --- frontend/views/organization_payment.ts | 2 + .../organizations/organization_payment.html | 91 +++++++++++++------ 2 files changed, 66 insertions(+), 27 deletions(-) diff --git a/frontend/views/organization_payment.ts b/frontend/views/organization_payment.ts index 2318f7609..e4480af23 100644 --- a/frontend/views/organization_payment.ts +++ b/frontend/views/organization_payment.ts @@ -1,3 +1,5 @@ +import "@/css/sidebar_layout.css"; + import { d, exists, on } from "../util"; // import { StripeAPIError } from 'stripe/lib/Error'; // const Stripe = require('stripe') as StripeAPIError; diff --git a/squarelet/templates/organizations/organization_payment.html b/squarelet/templates/organizations/organization_payment.html index bcd85acd0..43cf3fb8a 100644 --- a/squarelet/templates/organizations/organization_payment.html +++ b/squarelet/templates/organizations/organization_payment.html @@ -1,11 +1,22 @@ -{% extends "base.html" %} -{% load i18n django_vite crispy_forms_tags planinfo %} +{% extends "layouts/sidebar_layout.html" %} +{% load avatar humanize django_vite i18n crispy_forms_tags planinfo %} +{% load icon %} -{% block title %}{{ organization.reference_name }}{% endblock %} +{% block title %}{{ organization.name }} | Manage Subscriptions{% endblock %} {% block vite %} {{ block.super }} {% vite_asset "frontend/views/organization_payment.ts" %} +{% endblock vite %} + +{% block layout_id %}manage_billing{% endblock %} + +{% block header %} +

      +

      {{ organization.name }}

      + {% icon "chevron-right" %} +

      {% trans "Subscriptions" %}

      +
      {% endblock %} {% block content %} @@ -32,31 +43,57 @@

      {{ organization.reference_name }}

      {% crispy form form.helper %} - {% if form.stripe_token %} -
      - Cost: -
      -
      -
      -

      {% blocktrans %}Billing information{% endblocktrans %}

      -
      -
      -
      - - -
      - {% endif %} +
    • + + {% icon "gear" %} + {% trans "Payment settings" %} + +
    • + +
    • + + {% icon "log" %} + {% trans "Recent payments" %} + +
    • +
    +{% endblock sidebar %} -
    -
    - - - {% trans 'Cancel' %} - +{% block main %} +
    +
    +
    +

    {% trans "Your plans" %}

    +
    +
    + +
    +
    + +
    +
    +
    +

    {% trans "Payment settings" %}

    +
    +
    + +
    +
    - -
    -{% planinfo organization=organization %} -{% endblock content %} \ No newline at end of file +
    +
    +
    +

    {% trans "Recent payments" %}

    +
    + + {% icon "log" %} + {% trans "View all" %} + +
    + +
    +
    +
    +{% endblock main %} \ No newline at end of file From 9b26665ec6e3bbd1d12a54c225b27681805240db Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Mon, 29 Jun 2026 13:42:18 -0400 Subject: [PATCH 05/86] Pass all plans to plan card --- squarelet/organizations/views/detail.py | 33 ++++--------------- .../organizations/includes/plan_card.html | 16 ++++----- .../organizations/organization_detail.html | 2 +- 3 files changed, 16 insertions(+), 35 deletions(-) diff --git a/squarelet/organizations/views/detail.py b/squarelet/organizations/views/detail.py index 18666022d..03f927469 100644 --- a/squarelet/organizations/views/detail.py +++ b/squarelet/organizations/views/detail.py @@ -54,23 +54,18 @@ def get_context_data(self, **kwargs): context["users"] = admins context["admins"] = admins - # Get the current plan and subscription, if any - current_plan = None + # Get subscriptions, if any upgrade_plan = Plan.objects.get(slug="organization") - subscription = None + subscriptions = None if hasattr(org, "subscriptions"): - subscription = org.subscriptions.first() - if subscription and hasattr(subscription, "plan"): - # Temporarily wrap this in a list to support the new plan card template, which expects a list of plans - current_plan = [subscription.plan] - upgrade_plan = None - context["current_plan"] = current_plan + subscriptions = org.subscriptions.all() + context["subscriptions"] = subscriptions context["upgrade_plan"] = upgrade_plan context["member_count"] = len(users) context["admin_count"] = len(admins) - if current_plan and subscription: - context.update(self._get_subscription_context(org, subscription)) + if subscriptions: + context.update(self._get_subscription_context(org)) # Any member (not just admins) may request verification, but only if # they have confirmed an email address on their account. @@ -118,29 +113,15 @@ def _get_membership_context(self, user, org): ctx["pending_invitations"] = org.invitations.get_pending_invitations() return ctx - def _get_subscription_context(self, org, subscription): + def _get_subscription_context(self, org): """Return context dict for card, next charge date, and cancellation status.""" customer = org.customer() ctx = { "current_plan_card": bool(customer.stripe_payment_method_id), "current_plan_card_brand": customer.payment_brand, "current_plan_card_last4": customer.payment_last4, - "current_plan_cancelled": subscription.cancelled, } - 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() - ) - ctx["current_plan_next_charge_date"] = tz_datetime.date() - return ctx def _get_groups_context(self, user, org): diff --git a/squarelet/templates/organizations/includes/plan_card.html b/squarelet/templates/organizations/includes/plan_card.html index a64f81932..d158c5881 100644 --- a/squarelet/templates/organizations/includes/plan_card.html +++ b/squarelet/templates/organizations/includes/plan_card.html @@ -14,9 +14,9 @@ subject — "user" | "org". Controls copy. Default "org". {% endcomment %} -{% if plans or upgrade_plan or not inherited_plans %} -
    - {% if plans %} +{% if subscriptions or upgrade_plan or not inherited_plans %} +
    + {% if subscriptions %}
    {% if card %}
    @@ -41,22 +41,22 @@

    {% trans "Your plans" %}

      - {% for plan in plans %} + {% for subscription in subscriptions %}
    • - {{ plan.name }} + {{ subscription.plan.name }} {% icon "calendar" %} - {{ plan.annual|yesno:_("Annual,Monthly") }} + {{ subscription.plan.annual|yesno:_("Annual,Monthly") }}
      - {%if plan.cancelled %} + {%if subscription.plan.cancelled %} {% trans "Cancelled" %} {% else %} - + {% icon "gear" %} {% trans "Manage" %} diff --git a/squarelet/templates/organizations/organization_detail.html b/squarelet/templates/organizations/organization_detail.html index 4743ea216..55cbc7e55 100644 --- a/squarelet/templates/organizations/organization_detail.html +++ b/squarelet/templates/organizations/organization_detail.html @@ -289,7 +289,7 @@

      {% trans "Plans" %}

      {% endif %}
    {% url 'organizations:payment' organization.slug as payment_url %} - {% include "organizations/includes/plan_card.html" with org=organization plan=current_plan inherited_plans=inherited_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=payment_url can_edit_subscription=can_edit_subscription subject="org" %} + {% include "organizations/includes/plan_card.html" with org=organization subscriptions=subscriptions inherited_plans=inherited_plans upgrade_plan=upgrade_plan card=current_plan_card card_brand=current_plan_card_brand payment_url=payment_url card_last4=current_plan_card_last4 can_edit_subscription=can_edit_subscription subject="org" %}
    {% endif %} From edc225f4874eb743e549069b9a7d3a7f2bbe39f3 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Mon, 29 Jun 2026 13:52:34 -0400 Subject: [PATCH 06/86] Rename UpdateSubscription to UpdateSubscriptions --- squarelet/organizations/urls.py | 2 +- squarelet/organizations/views/__init__.py | 4 ++-- squarelet/organizations/views/subscription.py | 2 +- squarelet/users/views.py | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/squarelet/organizations/urls.py b/squarelet/organizations/urls.py index 45fe8a75c..d14a4cb68 100644 --- a/squarelet/organizations/urls.py +++ b/squarelet/organizations/urls.py @@ -16,7 +16,7 @@ "~charge-pdf//", view=views.PDFChargeDetail.as_view(), name="charge-pdf" ), path( - "/payment/", view=views.UpdateSubscription.as_view(), name="payment" + "/payment/", view=views.UpdateSubscriptions.as_view(), name="payment" ), path("/update/", view=views.Update.as_view(), name="update"), path( diff --git a/squarelet/organizations/views/__init__.py b/squarelet/organizations/views/__init__.py index a53be83f4..1ad85d1c5 100644 --- a/squarelet/organizations/views/__init__.py +++ b/squarelet/organizations/views/__init__.py @@ -15,7 +15,7 @@ from .subscription import ( ChargeDetail, PDFChargeDetail, - UpdateSubscription, + UpdateSubscriptions, stripe_webhook, ) @@ -25,7 +25,7 @@ "List", "autocomplete", # Subscription views - "UpdateSubscription", + "UpdateSubscriptions", "ChargeDetail", "PDFChargeDetail", "stripe_webhook", diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py index 143c6e051..e46a0bf9a 100644 --- a/squarelet/organizations/views/subscription.py +++ b/squarelet/organizations/views/subscription.py @@ -52,7 +52,7 @@ logger = logging.getLogger(__name__) -class UpdateSubscription(OrganizationPermissionMixin, UpdateView): +class UpdateSubscriptions(OrganizationPermissionMixin, UpdateView): permission_required = "organizations.can_edit_subscription" queryset = Organization.objects.filter(individual=False) form_class = PaymentForm diff --git a/squarelet/users/views.py b/squarelet/users/views.py index 7044044e0..f3a3b0702 100644 --- a/squarelet/users/views.py +++ b/squarelet/users/views.py @@ -53,7 +53,7 @@ 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.organizations.views import UpdateSubscriptions from squarelet.services.models import Service from squarelet.users.forms import ( SignupForm, @@ -586,8 +586,8 @@ def get_context_data(self, **kwargs): return context -class UserPaymentView(LoginRequiredMixin, StaffAccessMixin, UpdateSubscription): - """UpdateSubscription with staff access control""" +class UserPaymentView(LoginRequiredMixin, StaffAccessMixin, UpdateSubscriptions): + """UpdateSubscriptions with staff access control""" def get_object(self, queryset=None): username = self.kwargs.get("username") From 12e87f32577835efcfa2eef6b427a81078fbd745 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Mon, 29 Jun 2026 15:59:08 -0400 Subject: [PATCH 07/86] Include subscription data, dates and costs in payment page context --- squarelet/organizations/views/subscription.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py index e46a0bf9a..b8665fb00 100644 --- a/squarelet/organizations/views/subscription.py +++ b/squarelet/organizations/views/subscription.py @@ -9,6 +9,7 @@ JsonResponse, ) from django.shortcuts import redirect +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 @@ -19,6 +20,7 @@ import json import logging import sys +from datetime import datetime # Third Party import stripe @@ -35,6 +37,7 @@ 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 from squarelet.organizations.tasks import ( handle_charge_succeeded, handle_customer_updated, @@ -144,6 +147,12 @@ def form_valid(self, form): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) + subscriptions = self.object.subscriptions.all() + for subscription in subscriptions: + subscription.next_date = self.get_subscription_next_date(subscription) + subscription.cost = self.get_subscription_cost(subscription) + context["subscriptions"] = subscriptions + context["failed_receipt_emails"] = self.object.receipt_emails.filter( failed=True ) @@ -155,6 +164,33 @@ def get_context_data(self, **kwargs): self.object.subscriptions.filter(plan=plan).first() if plan else None ) return context + + def get_subscription_next_date(self, 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 + + # Same calculation that's done in new PlansView().updateTotalCost() + def get_subscription_cost(self, subscription): + stripe_sub = subscription.stripe_subscription + if stripe_sub: + base_price = subscription.plan.base_price + min_users = subscription.plan.minimum_users + price_per_user = subscription.plan.price_per_user + max_users = self.object.max_users + cost = base_price + (max_users - min_users) * price_per_user + return cost + return None def get_initial(self): plan = self.object.plans.first() From 4df9a6ace026e5dee88e8eb82c4e2b7d9a7c457e Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Mon, 29 Jun 2026 16:20:41 -0400 Subject: [PATCH 08/86] Add tag icon --- frontend/icons/tag.svg | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 frontend/icons/tag.svg diff --git a/frontend/icons/tag.svg b/frontend/icons/tag.svg new file mode 100644 index 000000000..c7ff5f067 --- /dev/null +++ b/frontend/icons/tag.svg @@ -0,0 +1,3 @@ + + + From 3c88e70de0bfbdbcd01930588c512559936a0d62 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Mon, 29 Jun 2026 16:38:01 -0400 Subject: [PATCH 09/86] Add subscription management cards to payment page --- frontend/css/organization_payment.css | 51 +++++++++++++++++++ frontend/views/organization_payment.ts | 2 + .../organizations/organization_payment.html | 41 ++++++++++++++- 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 frontend/css/organization_payment.css diff --git a/frontend/css/organization_payment.css b/frontend/css/organization_payment.css new file mode 100644 index 000000000..3657f2cf1 --- /dev/null +++ b/frontend/css/organization_payment.css @@ -0,0 +1,51 @@ +.subscription-card { + width: 100%; + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 0.25rem 1rem; + padding: 1rem; +} + +.subscription-card h3 { + color: var(--blue-4); + font-family: var(--font-sans, "Source Sans Pro"); + font-size: var(--font-md, 1rem); + font-style: normal; + font-weight: 600; + line-height: var(--font-xl, 1.5rem); + margin: 0 0 auto; +} + +.subscription-frequency { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.25rem; +} + +.subscription-details { + margin: 0; + padding: 0; + display: flex; + gap: 1rem; + font-size: var(--font-sm); + opacity: 0.7; +} + +.subscription-detail { + flex: 0 1 auto; + padding: 0; + display: flex; + align-items: center; + gap: 0.25rem; + color: var(--gray-5); +} + +.subscription-detail .icon { + display: inline-flex; + align-items: center; + width: var(--font-md, 0.75em); + height: var(--font-md, 0.75em); +} diff --git a/frontend/views/organization_payment.ts b/frontend/views/organization_payment.ts index e4480af23..560648365 100644 --- a/frontend/views/organization_payment.ts +++ b/frontend/views/organization_payment.ts @@ -1,4 +1,6 @@ import "@/css/sidebar_layout.css"; +import "@/css/organization_payment.css"; + import { d, exists, on } from "../util"; // import { StripeAPIError } from 'stripe/lib/Error'; diff --git a/squarelet/templates/organizations/organization_payment.html b/squarelet/templates/organizations/organization_payment.html index 43cf3fb8a..bf3d73591 100644 --- a/squarelet/templates/organizations/organization_payment.html +++ b/squarelet/templates/organizations/organization_payment.html @@ -67,8 +67,45 @@

    {% trans "Your plans" %}

    -
    -
    + {% for subscription in subscriptions %} + {% with annual=subscription.plan.annual %} +
    +

    {{ subscription.plan.name }}

    +
    + + {{ annual|yesno:_("Annual,Monthly") }} + + + {{ annual|yesno:_("Change to monthly,Change to annual") }} + +
    +
    + + + {% icon "tag" %} + + + ${{ subscription.cost|floatformat:2|intcomma }} + {{ annual|yesno:_("per year,per month") }} + + + + + {% icon "calendar" %} + + + {{ subscription.plan.cancelled|yesno:_("Ends on,Renews on") }} + {{ subscription.next_date }} + + +
    + + {% icon "x-circle" %} + {% trans "Cancel plan" %} + +
    + {% endwith %} + {% endfor %}
    From 42552aeafa8c3875116af617b7431330cb2d6ea1 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Tue, 30 Jun 2026 10:46:02 -0400 Subject: [PATCH 10/86] Just use base price for plan cost --- squarelet/organizations/views/subscription.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py index b8665fb00..2cea56678 100644 --- a/squarelet/organizations/views/subscription.py +++ b/squarelet/organizations/views/subscription.py @@ -184,12 +184,13 @@ def get_subscription_next_date(self, subscription): def get_subscription_cost(self, subscription): stripe_sub = subscription.stripe_subscription if stripe_sub: - base_price = subscription.plan.base_price - min_users = subscription.plan.minimum_users - price_per_user = subscription.plan.price_per_user - max_users = self.object.max_users - cost = base_price + (max_users - min_users) * price_per_user - return cost + # base_price = subscription.plan.base_price + # min_users = subscription.plan.minimum_users + # price_per_user = subscription.plan.price_per_user + # max_users = self.object.max_users + # cost = base_price + (max_users - min_users) * price_per_user + # return cost + return subscription.plan.base_price return None def get_initial(self): From efdf312e12e23927005183ff4ef9f5f1f485c0cf Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Tue, 30 Jun 2026 13:28:22 -0400 Subject: [PATCH 11/86] Pass payment card and receipt emails to subscriptions page --- squarelet/organizations/views/subscription.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py index 2cea56678..1dc6066c5 100644 --- a/squarelet/organizations/views/subscription.py +++ b/squarelet/organizations/views/subscription.py @@ -147,12 +147,28 @@ def form_valid(self, form): 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 = self.get_subscription_next_date(subscription) subscription.cost = self.get_subscription_cost(subscription) 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 ) From 9dbae645b0be9a42c5a727bcecf30dc2e21c30dd Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Tue, 30 Jun 2026 13:29:32 -0400 Subject: [PATCH 12/86] Reorganize CSS --- frontend/css/organization_payment.css | 26 +++++++++++-------- .../organizations/organization_payment.html | 8 ++++-- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/frontend/css/organization_payment.css b/frontend/css/organization_payment.css index 3657f2cf1..f87ef73ca 100644 --- a/frontend/css/organization_payment.css +++ b/frontend/css/organization_payment.css @@ -1,15 +1,12 @@ .subscription-card { width: 100%; display: flex; - flex-wrap: wrap; - align-items: flex-end; - justify-content: space-between; - gap: 0.25rem 1rem; + flex-direction: column; + gap: 0.5rem 1rem; padding: 1rem; } .subscription-card h3 { - color: var(--blue-4); font-family: var(--font-sans, "Source Sans Pro"); font-size: var(--font-md, 1rem); font-style: normal; @@ -18,6 +15,10 @@ margin: 0 0 auto; } +.subscription-card .plan-name { + color: var(--blue-4); +} + .subscription-frequency { display: flex; flex-direction: row; @@ -25,18 +26,21 @@ gap: 0.25rem; } -.subscription-details { - margin: 0; - padding: 0; +.subscription-row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.subscription-group { display: flex; gap: 1rem; - font-size: var(--font-sm); - opacity: 0.7; } .subscription-detail { flex: 0 1 auto; - padding: 0; + font-size: var(--font-sm); + opacity: 0.7; display: flex; align-items: center; gap: 0.25rem; diff --git a/squarelet/templates/organizations/organization_payment.html b/squarelet/templates/organizations/organization_payment.html index bf3d73591..810a61261 100644 --- a/squarelet/templates/organizations/organization_payment.html +++ b/squarelet/templates/organizations/organization_payment.html @@ -70,7 +70,8 @@

    {% trans "Your plans" %}

    {% for subscription in subscriptions %} {% with annual=subscription.plan.annual %}
    -

    {{ subscription.plan.name }}

    +
    +

    {{ subscription.plan.name }}

    {{ annual|yesno:_("Annual,Monthly") }} @@ -79,7 +80,9 @@

    {{ subscription.plan.name }}

    {{ annual|yesno:_("Change to monthly,Change to annual") }}
    -
    +
    +
    +
    {% icon "tag" %} @@ -103,6 +106,7 @@

    {{ subscription.plan.name }}

    {% icon "x-circle" %} {% trans "Cancel plan" %} +
    {% endwith %} {% endfor %} From 334955125216f0a1810ca683be8beb081eef0213 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Tue, 30 Jun 2026 13:29:56 -0400 Subject: [PATCH 13/86] Show payment card and receipt emails on subscription page --- frontend/css/organization_payment.css | 4 + .../organizations/organization_payment.html | 101 ++++++++++++------ 2 files changed, 74 insertions(+), 31 deletions(-) diff --git a/frontend/css/organization_payment.css b/frontend/css/organization_payment.css index f87ef73ca..f951de80a 100644 --- a/frontend/css/organization_payment.css +++ b/frontend/css/organization_payment.css @@ -53,3 +53,7 @@ width: var(--font-md, 0.75em); height: var(--font-md, 0.75em); } + +.subscription-detail .emails :not(:last-child):after { + content: ","; +} diff --git a/squarelet/templates/organizations/organization_payment.html b/squarelet/templates/organizations/organization_payment.html index 810a61261..737edcbf1 100644 --- a/squarelet/templates/organizations/organization_payment.html +++ b/squarelet/templates/organizations/organization_payment.html @@ -72,40 +72,40 @@

    {% trans "Your plans" %}

    {{ subscription.plan.name }}

    -
    - - {{ annual|yesno:_("Annual,Monthly") }} - - - {{ annual|yesno:_("Change to monthly,Change to annual") }} - -
    +
    + + {{ annual|yesno:_("Annual,Monthly") }} + + + {{ annual|yesno:_("Change to monthly,Change to annual") }} + +
    - - - {% icon "tag" %} + + + {% icon "tag" %} + + + ${{ subscription.cost|floatformat:2|intcomma }} + {{ annual|yesno:_("per year,per month") }} + - - ${{ subscription.cost|floatformat:2|intcomma }} - {{ annual|yesno:_("per year,per month") }} + + + {% icon "calendar" %} + + + {{ subscription.plan.cancelled|yesno:_("Ends on,Renews on") }} + {{ subscription.next_date }} + - - - - {% icon "calendar" %} - - - {{ subscription.plan.cancelled|yesno:_("Ends on,Renews on") }} - {{ subscription.next_date }} - - -
    - - {% icon "x-circle" %} - {% trans "Cancel plan" %} - +
    + + {% icon "x-circle" %} + {% trans "Cancel plan" %} +
    {% endwith %} @@ -119,8 +119,47 @@

    {% trans "Payment settings" %}

    -
    -
    +
    + {% if card %} +
    +
    +

    {% trans "Payment card" %}

    + + + {% icon "credit-card" %} + + + {{ card.brand }} {% trans "ending in" %} {{ card.last4 }} + + +
    + + + {% trans "Add new card" %} + +
    + {% endif %} + +
    +
    +

    {% trans "Receipt emails" %}

    + + + {% icon "mail" %} + + + {% for email in receipt_emails %} + {{ email.email }} + {% endfor %} + + +
    + + + {% trans "Change email" %} + +
    +
    From 8274f61f2e08a8e77577dc75733e979ecf7ab1a9 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Tue, 30 Jun 2026 14:07:43 -0400 Subject: [PATCH 14/86] Plan card fixes --- squarelet/templates/organizations/includes/plan_card.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/squarelet/templates/organizations/includes/plan_card.html b/squarelet/templates/organizations/includes/plan_card.html index d158c5881..4e8da80a8 100644 --- a/squarelet/templates/organizations/includes/plan_card.html +++ b/squarelet/templates/organizations/includes/plan_card.html @@ -28,7 +28,7 @@

    {% trans "Payment card" %}

    - + {% trans "Add new card" %} {% endif %} @@ -51,12 +51,12 @@

    {% trans "Your plans" %}

    - {%if subscription.plan.cancelled %} + {% if subscription.plan.cancelled %} {% trans "Cancelled" %} {% else %} - + {% icon "gear" %} {% trans "Manage" %} From 9e50d99339ccf7d9f26e68713c1edce7966183c7 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Tue, 30 Jun 2026 16:00:28 -0400 Subject: [PATCH 15/86] Pass recent payment data to subscription page --- squarelet/organizations/views/subscription.py | 12 +++++------- .../organizations/organization_payment.html | 6 +++--- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py index 1dc6066c5..5a66c4a4b 100644 --- a/squarelet/organizations/views/subscription.py +++ b/squarelet/organizations/views/subscription.py @@ -172,13 +172,11 @@ def get_context_data(self, **kwargs): context["failed_receipt_emails"] = self.object.receipt_emails.filter( failed=True ) - # Provide a single subscription for the template to check cancelled status. - # In the multi-subscription world this will need to be revisited, but for - # now the template only needs to know about the primary (first) subscription. - plan = self.object.plans.first() - context["current_subscription"] = ( - self.object.subscriptions.filter(plan=plan).first() if plan else None - ) + + # Get five most recent payments + payments = self.object.charges.order_by("-created_at").all()[:5] + context["payments"] = payments + return context def get_subscription_next_date(self, subscription): diff --git a/squarelet/templates/organizations/organization_payment.html b/squarelet/templates/organizations/organization_payment.html index 737edcbf1..cb0614c44 100644 --- a/squarelet/templates/organizations/organization_payment.html +++ b/squarelet/templates/organizations/organization_payment.html @@ -167,13 +167,13 @@

    {% trans "Receipt emails" %}

    {% trans "Recent payments" %}

    - + {% icon "log" %} {% trans "View all" %} -
    -
    +
    +
    {% endblock main %} \ No newline at end of file From fe5aac6d9bc0bfd5df4c18bfe9f198ff3d310e3c Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Tue, 30 Jun 2026 16:29:36 -0400 Subject: [PATCH 16/86] Use blocktrans --- .../templates/organizations/organization_payment.html | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/squarelet/templates/organizations/organization_payment.html b/squarelet/templates/organizations/organization_payment.html index cb0614c44..34127e699 100644 --- a/squarelet/templates/organizations/organization_payment.html +++ b/squarelet/templates/organizations/organization_payment.html @@ -77,7 +77,9 @@

    {{ subscription.plan.name }}

    {{ annual|yesno:_("Annual,Monthly") }} - {{ annual|yesno:_("Change to monthly,Change to annual") }} + {% blocktrans with annual|yesno:_("monthly,annual") as frequency %} + Change to {{ frequency }} + {% endblocktrans %} @@ -129,7 +131,9 @@

    {% trans "Payment card" %}

    {% icon "credit-card" %} - {{ card.brand }} {% trans "ending in" %} {{ card.last4 }} + {% blocktrans with card.brand as brand and card.last4 as last4 %} + {{ brand }} ending in {{ last4 }} + {% endblocktrans %} From 272c68ec001c5a78ecc881d0f664c5c0f34e1a70 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Thu, 2 Jul 2026 08:20:24 -0400 Subject: [PATCH 17/86] Revert UpdateSubscription view and payment template --- frontend/css/organization_payment.css | 59 --------- frontend/views/organization_payment.ts | 4 - squarelet/organizations/urls.py | 2 +- squarelet/organizations/views/__init__.py | 4 +- squarelet/organizations/views/subscription.py | 58 +-------- .../organizations/organization_payment.html | 119 ++++++------------ squarelet/users/views.py | 6 +- 7 files changed, 48 insertions(+), 204 deletions(-) delete mode 100644 frontend/css/organization_payment.css diff --git a/frontend/css/organization_payment.css b/frontend/css/organization_payment.css deleted file mode 100644 index f951de80a..000000000 --- a/frontend/css/organization_payment.css +++ /dev/null @@ -1,59 +0,0 @@ -.subscription-card { - width: 100%; - display: flex; - flex-direction: column; - gap: 0.5rem 1rem; - padding: 1rem; -} - -.subscription-card h3 { - font-family: var(--font-sans, "Source Sans Pro"); - font-size: var(--font-md, 1rem); - font-style: normal; - font-weight: 600; - line-height: var(--font-xl, 1.5rem); - margin: 0 0 auto; -} - -.subscription-card .plan-name { - color: var(--blue-4); -} - -.subscription-frequency { - display: flex; - flex-direction: row; - align-items: center; - gap: 0.25rem; -} - -.subscription-row { - display: flex; - justify-content: space-between; - align-items: center; -} - -.subscription-group { - display: flex; - gap: 1rem; -} - -.subscription-detail { - flex: 0 1 auto; - font-size: var(--font-sm); - opacity: 0.7; - display: flex; - align-items: center; - gap: 0.25rem; - color: var(--gray-5); -} - -.subscription-detail .icon { - display: inline-flex; - align-items: center; - width: var(--font-md, 0.75em); - height: var(--font-md, 0.75em); -} - -.subscription-detail .emails :not(:last-child):after { - content: ","; -} diff --git a/frontend/views/organization_payment.ts b/frontend/views/organization_payment.ts index 560648365..2318f7609 100644 --- a/frontend/views/organization_payment.ts +++ b/frontend/views/organization_payment.ts @@ -1,7 +1,3 @@ -import "@/css/sidebar_layout.css"; -import "@/css/organization_payment.css"; - - import { d, exists, on } from "../util"; // import { StripeAPIError } from 'stripe/lib/Error'; // const Stripe = require('stripe') as StripeAPIError; diff --git a/squarelet/organizations/urls.py b/squarelet/organizations/urls.py index d14a4cb68..45fe8a75c 100644 --- a/squarelet/organizations/urls.py +++ b/squarelet/organizations/urls.py @@ -16,7 +16,7 @@ "~charge-pdf//", view=views.PDFChargeDetail.as_view(), name="charge-pdf" ), path( - "/payment/", view=views.UpdateSubscriptions.as_view(), name="payment" + "/payment/", view=views.UpdateSubscription.as_view(), name="payment" ), path("/update/", view=views.Update.as_view(), name="update"), path( diff --git a/squarelet/organizations/views/__init__.py b/squarelet/organizations/views/__init__.py index 1ad85d1c5..a53be83f4 100644 --- a/squarelet/organizations/views/__init__.py +++ b/squarelet/organizations/views/__init__.py @@ -15,7 +15,7 @@ from .subscription import ( ChargeDetail, PDFChargeDetail, - UpdateSubscriptions, + UpdateSubscription, stripe_webhook, ) @@ -25,7 +25,7 @@ "List", "autocomplete", # Subscription views - "UpdateSubscriptions", + "UpdateSubscription", "ChargeDetail", "PDFChargeDetail", "stripe_webhook", diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py index 5a66c4a4b..a7f65a8e5 100644 --- a/squarelet/organizations/views/subscription.py +++ b/squarelet/organizations/views/subscription.py @@ -54,8 +54,7 @@ logger = logging.getLogger(__name__) - -class UpdateSubscriptions(OrganizationPermissionMixin, UpdateView): +class UpdateSubscription(OrganizationPermissionMixin, UpdateView): permission_required = "organizations.can_edit_subscription" queryset = Organization.objects.filter(individual=False) form_class = PaymentForm @@ -147,65 +146,10 @@ def form_valid(self, form): 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 = self.get_subscription_next_date(subscription) - subscription.cost = self.get_subscription_cost(subscription) - 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 - - def get_subscription_next_date(self, 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 - - # Same calculation that's done in new PlansView().updateTotalCost() - def get_subscription_cost(self, subscription): - stripe_sub = subscription.stripe_subscription - if stripe_sub: - # base_price = subscription.plan.base_price - # min_users = subscription.plan.minimum_users - # price_per_user = subscription.plan.price_per_user - # max_users = self.object.max_users - # cost = base_price + (max_users - min_users) * price_per_user - # return cost - return subscription.plan.base_price - return None def get_initial(self): plan = self.object.plans.first() diff --git a/squarelet/templates/organizations/organization_payment.html b/squarelet/templates/organizations/organization_payment.html index 34127e699..426cb5c9e 100644 --- a/squarelet/templates/organizations/organization_payment.html +++ b/squarelet/templates/organizations/organization_payment.html @@ -1,22 +1,11 @@ -{% extends "layouts/sidebar_layout.html" %} -{% load avatar humanize django_vite i18n crispy_forms_tags planinfo %} -{% load icon %} +{% extends "base.html" %} +{% load i18n django_vite crispy_forms_tags planinfo %} -{% block title %}{{ organization.name }} | Manage Subscriptions{% endblock %} +{% block title %}{{ organization.reference_name }}{% endblock %} {% block vite %} {{ block.super }} {% vite_asset "frontend/views/organization_payment.ts" %} -{% endblock vite %} - -{% block layout_id %}manage_billing{% endblock %} - -{% block header %} -
    -

    {{ organization.name }}

    - {% icon "chevron-right" %} -

    {% trans "Subscriptions" %}

    -
    {% endblock %} {% block content %} @@ -112,72 +101,46 @@

    {{ subscription.plan.name }}

    {% endwith %} {% endfor %} - - -
    -
    -
    -

    {% trans "Payment settings" %}

    -
    -
    - -
    - {% if card %} -
    -
    -

    {% trans "Payment card" %}

    - - - {% icon "credit-card" %} - - - {% blocktrans with card.brand as brand and card.last4 as last4 %} - {{ brand }} ending in {{ last4 }} - {% endblocktrans %} - - -
    - - - {% trans "Add new card" %} - -
    - {% endif %} + +{% endif %} +
    + {% trans "Current plan" %}: {{ organization.plan|default:"Free" }} + {% if organization.subscription.cancelled %} +
    + {% blocktrans with update_on=organization.subscription.update_on|date:"m/d/Y" %} + Subscription ends on {{ update_on }} + {% endblocktrans %} +
    + {% endif %} +
    +
    + {% crispy form form.helper %} -
    -
    -

    {% trans "Receipt emails" %}

    - - - {% icon "mail" %} - - - {% for email in receipt_emails %} - {{ email.email }} - {% endfor %} - - + {% if form.stripe_token %} +
    + Cost: +
    +
    +
    +

    {% blocktrans %}Billing information{% endblocktrans %}

    +
    +
    + + +
    + {% endif %} - - {% trans "Change email" %} +
    -
    - -
    -
    -
    -

    {% trans "Recent payments" %}

    -
    - - {% icon "log" %} - {% trans "View all" %} - -
    + + -
    -
    -
    -{% endblock main %} \ No newline at end of file + + +{% planinfo organization=organization %} +{% endblock content %} \ No newline at end of file diff --git a/squarelet/users/views.py b/squarelet/users/views.py index f3a3b0702..7044044e0 100644 --- a/squarelet/users/views.py +++ b/squarelet/users/views.py @@ -53,7 +53,7 @@ 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 UpdateSubscriptions +from squarelet.organizations.views import UpdateSubscription from squarelet.services.models import Service from squarelet.users.forms import ( SignupForm, @@ -586,8 +586,8 @@ def get_context_data(self, **kwargs): return context -class UserPaymentView(LoginRequiredMixin, StaffAccessMixin, UpdateSubscriptions): - """UpdateSubscriptions with staff access control""" +class UserPaymentView(LoginRequiredMixin, StaffAccessMixin, UpdateSubscription): + """UpdateSubscription with staff access control""" def get_object(self, queryset=None): username = self.kwargs.get("username") From 006c0d68e8ba3539bbd5e1e1de2084703eca0836 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Thu, 2 Jul 2026 08:22:08 -0400 Subject: [PATCH 18/86] Set up new ManageSubscriptions view --- .../css/organization_managesubscriptions.css | 59 ++++++ .../views/organization_managesubscriptions.ts | 2 + squarelet/organizations/urls.py | 3 + squarelet/organizations/views/__init__.py | 2 + squarelet/organizations/views/subscription.py | 63 ++++++- .../organizations/organization_detail.html | 4 +- .../organization_managesubscriptions.html | 172 ++++++++++++++++++ 7 files changed, 302 insertions(+), 3 deletions(-) create mode 100644 frontend/css/organization_managesubscriptions.css create mode 100644 frontend/views/organization_managesubscriptions.ts create mode 100644 squarelet/templates/organizations/organization_managesubscriptions.html diff --git a/frontend/css/organization_managesubscriptions.css b/frontend/css/organization_managesubscriptions.css new file mode 100644 index 000000000..f951de80a --- /dev/null +++ b/frontend/css/organization_managesubscriptions.css @@ -0,0 +1,59 @@ +.subscription-card { + width: 100%; + display: flex; + flex-direction: column; + gap: 0.5rem 1rem; + padding: 1rem; +} + +.subscription-card h3 { + font-family: var(--font-sans, "Source Sans Pro"); + font-size: var(--font-md, 1rem); + font-style: normal; + font-weight: 600; + line-height: var(--font-xl, 1.5rem); + margin: 0 0 auto; +} + +.subscription-card .plan-name { + color: var(--blue-4); +} + +.subscription-frequency { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.25rem; +} + +.subscription-row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.subscription-group { + display: flex; + gap: 1rem; +} + +.subscription-detail { + flex: 0 1 auto; + font-size: var(--font-sm); + opacity: 0.7; + display: flex; + align-items: center; + gap: 0.25rem; + color: var(--gray-5); +} + +.subscription-detail .icon { + display: inline-flex; + align-items: center; + width: var(--font-md, 0.75em); + height: var(--font-md, 0.75em); +} + +.subscription-detail .emails :not(:last-child):after { + content: ","; +} diff --git a/frontend/views/organization_managesubscriptions.ts b/frontend/views/organization_managesubscriptions.ts new file mode 100644 index 000000000..13536e73b --- /dev/null +++ b/frontend/views/organization_managesubscriptions.ts @@ -0,0 +1,2 @@ +import "@/css/sidebar_layout.css"; +import "@/css/organization_managesubscriptions.css"; diff --git a/squarelet/organizations/urls.py b/squarelet/organizations/urls.py index 45fe8a75c..597e82a2c 100644 --- a/squarelet/organizations/urls.py +++ b/squarelet/organizations/urls.py @@ -15,6 +15,9 @@ path( "~charge-pdf//", view=views.PDFChargeDetail.as_view(), name="charge-pdf" ), + path( + "/subscriptions/", view=views.ManageSubscriptions.as_view(), name="subscriptions" + ), path( "/payment/", view=views.UpdateSubscription.as_view(), name="payment" ), diff --git a/squarelet/organizations/views/__init__.py b/squarelet/organizations/views/__init__.py index a53be83f4..429257b20 100644 --- a/squarelet/organizations/views/__init__.py +++ b/squarelet/organizations/views/__init__.py @@ -15,6 +15,7 @@ from .subscription import ( ChargeDetail, PDFChargeDetail, + ManageSubscriptions, UpdateSubscription, stripe_webhook, ) @@ -25,6 +26,7 @@ "List", "autocomplete", # Subscription views + "ManageSubscriptions", "UpdateSubscription", "ChargeDetail", "PDFChargeDetail", diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py index a7f65a8e5..d14b21701 100644 --- a/squarelet/organizations/views/subscription.py +++ b/squarelet/organizations/views/subscription.py @@ -32,7 +32,7 @@ get_stripe_dashboard_url, new_action, ) -from squarelet.organizations.forms import PaymentForm +from squarelet.organizations.forms import CardForm, PaymentForm from squarelet.organizations.mixins import OrganizationPermissionMixin from squarelet.organizations.models import Charge, Organization from squarelet.organizations.payments.base import PaymentActionRequired @@ -54,6 +54,67 @@ 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 = self.get_subscription_next_date(subscription) + subscription.cost = self.get_subscription_cost(subscription) + 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 + + def get_subscription_next_date(self, 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 + + def get_subscription_cost(self, subscription): + stripe_sub = subscription.stripe_subscription + if stripe_sub: + return subscription.plan.base_price + return None + + class UpdateSubscription(OrganizationPermissionMixin, UpdateView): permission_required = "organizations.can_edit_subscription" queryset = Organization.objects.filter(individual=False) diff --git a/squarelet/templates/organizations/organization_detail.html b/squarelet/templates/organizations/organization_detail.html index 55cbc7e55..6ff46b364 100644 --- a/squarelet/templates/organizations/organization_detail.html +++ b/squarelet/templates/organizations/organization_detail.html @@ -269,13 +269,13 @@

    {% trans "Affiliations" %}

    {% endif %} {% if can_view_subscription %} - {% url 'organizations:payment' organization.slug as payment_url %} + {% url 'organizations:subscriptions' organization.slug as subscriptions_url %}

    {% trans "Plans" %}

    {% if can_edit_subscription %}
    - + {% icon "gear" %} {% trans "Manage plans" %} diff --git a/squarelet/templates/organizations/organization_managesubscriptions.html b/squarelet/templates/organizations/organization_managesubscriptions.html new file mode 100644 index 000000000..358ea9bdf --- /dev/null +++ b/squarelet/templates/organizations/organization_managesubscriptions.html @@ -0,0 +1,172 @@ +{% extends "layouts/sidebar_layout.html" %} +{% load avatar humanize django_vite i18n crispy_forms_tags planinfo %} +{% load icon %} + +{% block title %}{{ organization.name }} | Manage Subscriptions{% endblock %} + +{% block vite %} +{{ block.super }} +{% vite_asset "frontend/views/organization_managesubscriptions.ts" %} +{% endblock vite %} + +{% block layout_id %}manage_billing{% endblock %} + +{% block header %} +
    +

    {{ organization.name }}

    + {% icon "chevron-right" %} +

    {% trans "Subscriptions" %}

    +
    +{% endblock %} + +{% block sidebar %} + + {% icon "chevron-left" %} + {% trans "Back to team overview" %} + + +{% endblock sidebar %} + +{% block main %} +
    +
    +
    +

    {% trans "Your plans" %}

    +
    +
    + + {% for subscription in subscriptions %} + {% with annual=subscription.plan.annual %} +
    +
    +

    {{ subscription.plan.name }}

    + +
    +
    +
    + + + {% icon "tag" %} + + + ${{ subscription.cost|floatformat:2|intcomma }} + {{ annual|yesno:_("per year,per month") }} + + + + + {% icon "calendar" %} + + + {{ subscription.plan.cancelled|yesno:_("Ends on,Renews on") }} + {{ subscription.next_date }} + + +
    + + {% icon "x-circle" %} + {% trans "Cancel plan" %} + +
    +
    + {% endwith %} + {% endfor %} +
    + +
    +
    +
    +

    {% trans "Payment settings" %}

    +
    +
    + +
    + {% if card %} +
    +
    +

    {% trans "Payment card" %}

    + + + {% icon "credit-card" %} + + + {% blocktrans with card.brand as brand and card.last4 as last4 %} + {{ brand }} ending in {{ last4 }} + {% endblocktrans %} + + +
    + + + {% trans "Add new card" %} + +
    + {% endif %} + +
    +
    +

    {% trans "Receipt emails" %}

    + + + {% icon "mail" %} + + + {% for email in receipt_emails %} + {{ email.email }} + {% endfor %} + + +
    + + + {% trans "Change email" %} + +
    +
    +
    + +
    +
    +
    +

    {% trans "Recent payments" %}

    +
    + + {% icon "log" %} + {% trans "View all" %} + +
    + +
    +
    +
    +{% endblock main %} \ No newline at end of file From dceea83f11152bd4a6df1f68a5f4f25c37afb1b1 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Thu, 2 Jul 2026 14:54:33 -0400 Subject: [PATCH 19/86] Add update card form helper and view class --- squarelet/organizations/forms.py | 19 ++++++++ squarelet/organizations/urls.py | 1 + squarelet/organizations/views/__init__.py | 2 + squarelet/organizations/views/subscription.py | 44 +++++++++++++++++++ 4 files changed, 66 insertions(+) diff --git a/squarelet/organizations/forms.py b/squarelet/organizations/forms.py index 6242d5e40..4037cfe2f 100644 --- a/squarelet/organizations/forms.py +++ b/squarelet/organizations/forms.py @@ -191,6 +191,25 @@ 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 + + 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 597e82a2c..3304e5a74 100644 --- a/squarelet/organizations/urls.py +++ b/squarelet/organizations/urls.py @@ -21,6 +21,7 @@ path( "/payment/", view=views.UpdateSubscription.as_view(), name="payment" ), + path("/card/", view=views.UpdateCard.as_view(), name="update-card"), 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 429257b20..430168239 100644 --- a/squarelet/organizations/views/__init__.py +++ b/squarelet/organizations/views/__init__.py @@ -17,6 +17,7 @@ PDFChargeDetail, ManageSubscriptions, UpdateSubscription, + UpdateCard, stripe_webhook, ) @@ -28,6 +29,7 @@ # Subscription views "ManageSubscriptions", "UpdateSubscription", + "UpdateCard", "ChargeDetail", "PDFChargeDetail", "stripe_webhook", diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py index d14b21701..09ca091a1 100644 --- a/squarelet/organizations/views/subscription.py +++ b/squarelet/organizations/views/subscription.py @@ -224,6 +224,50 @@ def get_initial(self): ), } +class UpdateCard(OrganizationPermissionMixin, UpdateView): + """Update the credit card on file for an organization.""" + + 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 = organization.get_absolute_url() + 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 @method_decorator(xframe_options_sameorigin, name="dispatch") class ChargeDetail(UserPassesTestMixin, DetailView): From ace882de7d387c209144e7ee3d514163783377d0 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Thu, 2 Jul 2026 14:56:58 -0400 Subject: [PATCH 20/86] Redirect to subscriptions page --- 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 09ca091a1..bfd4f5e4f 100644 --- a/squarelet/organizations/views/subscription.py +++ b/squarelet/organizations/views/subscription.py @@ -15,6 +15,7 @@ from django.views.decorators.clickjacking import xframe_options_sameorigin from django.views.decorators.csrf import csrf_exempt from django.views.generic import DetailView, UpdateView +from django.urls import reverse # Standard Library import json @@ -238,7 +239,7 @@ def _is_ajax(self): def form_valid(self, form): organization = self.object user = self.request.user - redirect_url = organization.get_absolute_url() + redirect_url = reverse("organizations:subscriptions", args=[organization.slug]) token = form.cleaned_data["stripe_token"] try: organization.save_card(token, user) From aba9589c8dffd8c8137bed308d366b7456a50160 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Thu, 2 Jul 2026 14:57:19 -0400 Subject: [PATCH 21/86] Add update card template and Stripe form JS --- frontend/css/organization_updatecard.css | 53 +++++++++ frontend/views/organization_updatecard.ts | 103 ++++++++++++++++++ .../organization_updatecard.html | 66 +++++++++++ 3 files changed, 222 insertions(+) create mode 100644 frontend/css/organization_updatecard.css create mode 100644 frontend/views/organization_updatecard.ts create mode 100644 squarelet/templates/organizations/organization_updatecard.html diff --git a/frontend/css/organization_updatecard.css b/frontend/css/organization_updatecard.css new file mode 100644 index 000000000..fa05431ff --- /dev/null +++ b/frontend/css/organization_updatecard.css @@ -0,0 +1,53 @@ +.update-card { + max-width: 32rem; +} + +.update-card main { + box-sizing: border-box; + width: 100%; + border-radius: 0.5rem; + border: 1px solid var(--gray-2, #d8dee2); + background: var(--white, #fff); + box-shadow: var(--shadow-1); + display: flex; + flex-direction: column; + gap: 0.5rem 1rem; + padding: 1rem; +} + +.update-card h2 { + margin: 0.5rem 0 2rem; +} + +.card-row { + flex: 0 1 auto; + font-size: var(--font-sm); + opacity: 0.7; + display: flex; + align-items: center; + gap: 0.25rem; + color: var(--gray-5); +} + +.card-row .icon { + display: inline-flex; + align-items: center; + width: var(--font-md, 0.75em); + height: var(--font-md, 0.75em); +} + +.update-card p { + margin: 0; +} + +.update-card form { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.update-card .buttons { + display: flex; + gap: 1rem; + align-items: center; +} diff --git a/frontend/views/organization_updatecard.ts b/frontend/views/organization_updatecard.ts new file mode 100644 index 000000000..e207adf06 --- /dev/null +++ b/frontend/views/organization_updatecard.ts @@ -0,0 +1,103 @@ +import "@/css/sidebar_layout.css"; +import "@/css/organization_updatecard.css"; + +import { d, exists, on } from "../util"; + +const Stripe = window["Stripe"] as any; + +const STRIPE_STYLE = { + base: { + color: "#3F3F3F", + fontSize: "18px", + fontFamily: "system-ui, sans-serif", + fontSmoothing: "antialiased", + "::placeholder": { + color: "#899194", + }, + }, + invalid: { + color: "#e5424d", + ":focus": { + color: "#303238", + }, + }, +}; + +export class UpdateCardView { + readonly stripePkInput = d("id_stripe_pk") as HTMLInputElement; + readonly tokenInput = d("id_stripe_token") as HTMLInputElement; + readonly errorElem = d("card-errors"); + readonly form = d("stripe-form") as HTMLFormElement; + + constructor() { + this.initUpdateCardForm(); + } + + initUpdateCardForm(): void { + const stripePk = this.stripePkInput.value; + if (!stripePk || !Stripe) return; + + const stripe = Stripe(stripePk); + const elements = stripe.elements(); + const card = elements.create("card", { style: STRIPE_STYLE }); + card.mount("#card-element"); + + card.addEventListener( + "change", + (event: { error?: { message: string } }) => { + this.errorElem.textContent = event.error?.message ?? ""; + }, + ); + + // We don't want the browser to fill this in with old values + this.tokenInput.value = ""; + + this.form.addEventListener("submit", (event) => { + event.preventDefault(); + + // Create token for new card, then AJAX submit + stripe.createToken(card).then((result) => { + if (result.error) { + this.errorElem.textContent = result.error.message; + } else { + ( + document.getElementById("id_stripe_token") as HTMLInputElement + ).value = result.token.id; + this.submitViaAjax(); + } + }); + }); + } + + submitViaAjax() { + fetch(this.form.action || window.location.href, { + method: "POST", + headers: { "X-Requested-With": "XMLHttpRequest" }, + body: new FormData(this.form), + }) + .then((response) => + response.json().then((data) => ({ status: response.status, data })), + ) + .then(({ status, data }) => { + if (status >= 200 && status < 300) { + window.location.href = data.redirect; + } else { + if (this.errorElem) { + this.errorElem.textContent = + data.error || "An error occurred. Please try again."; + } + } + }) + .catch(() => { + if (this.errorElem) { + this.errorElem.textContent = + "A network error occurred. Please try again."; + } + }); + } +} + +/* Load the UpdateCardView */ +if (exists("id_stripe_pk")) { + new UpdateCardView(); +} diff --git a/squarelet/templates/organizations/organization_updatecard.html b/squarelet/templates/organizations/organization_updatecard.html new file mode 100644 index 000000000..8233f676e --- /dev/null +++ b/squarelet/templates/organizations/organization_updatecard.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} +{% load django_vite i18n crispy_forms_tags %} +{% load icon %} + +{% block title %}{{ organization.name }} | Update Card{% endblock %} + +{% block vite %} +{{ block.super }} +{% vite_asset "frontend/views/organization_updatecard.ts" %} +{% endblock vite %} + +{% block layout_id %}update_card{% endblock %} + +{% block content %} +
    + {% url 'organizations:subscriptions' organization.slug as subscriptions_url %} + + {% icon "chevron-left" %} + {% trans "Back to your subscriptions" %} + + +
    +

    {% trans "Change payment card" %}

    +
    + +
    + {% if card %} +
    + {% icon "credit-card" %} + + {% blocktrans with card.brand as brand and card.last4 as last4 %} + Current card: {{ brand }} ending in {{ last4 }} + {% endblocktrans %} + +
    + {% endif %} + +
    + {% crispy form form.helper %} + +
    +

    {% trans "Enter your new credit card details below." %}

    +
    +
    +
    + +
    + +
    + + + + {% trans "Cancel" %} + +
    +
    +
    +
    +{% endblock content %} \ No newline at end of file From 7b8a390ddcdf73238c9932b77a720588348e5142 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Thu, 2 Jul 2026 14:57:51 -0400 Subject: [PATCH 22/86] Update links on plan card and org detail page --- .../organizations/includes/plan_card.html | 21 ++++++++++--------- .../organizations/organization_detail.html | 1 + 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/squarelet/templates/organizations/includes/plan_card.html b/squarelet/templates/organizations/includes/plan_card.html index 4e8da80a8..a7652c57c 100644 --- a/squarelet/templates/organizations/includes/plan_card.html +++ b/squarelet/templates/organizations/includes/plan_card.html @@ -4,14 +4,15 @@ Plan card partial. Args: - org — subject Organization (used for payment / receipts URLs). - plans — list of own Plans or empty list. - inherited_plans — list of (source_org, plan) tuples; may be empty. - upgrade_plan — Plan to advertise when there is no own plan (optional). + org — subject Organization (used for payment / receipts URLs). + plans — list of own Plans or empty list. + 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). - payment_url — URL for the upgrade / billing-settings CTA (optional). + 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 — "user" | "org". Controls copy. Default "org". {% endcomment %} {% if subscriptions or upgrade_plan or not inherited_plans %} @@ -28,7 +29,7 @@

    {% trans "Payment card" %}

    - + {% trans "Add new card" %} {% endif %} @@ -56,7 +57,7 @@

    {% trans "Your plans" %}

    {% trans "Cancelled" %} {% else %} - + {% icon "gear" %} {% trans "Manage" %} @@ -76,8 +77,8 @@

    {% trans "Your plans" %}

    {% trans "No active subscription." %} {% endif %}

    - {% if payment_url and can_edit_subscription %} - {% trans "Upgrade" %} + {% if subscriptions_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 6ff46b364..b4be92631 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:update-card' organization.slug as card_url %}

    {% trans "Plans" %}

    From 3726c45cdd0d58d018fbda6ff2c3ad091df2636e Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Thu, 2 Jul 2026 15:15:15 -0400 Subject: [PATCH 23/86] Add org name to update card template --- frontend/css/organization_updatecard.css | 9 ++++++++- .../templates/organizations/organization_updatecard.html | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/css/organization_updatecard.css b/frontend/css/organization_updatecard.css index fa05431ff..0d0e2d3c4 100644 --- a/frontend/css/organization_updatecard.css +++ b/frontend/css/organization_updatecard.css @@ -15,8 +15,15 @@ padding: 1rem; } +.update-card h1 { + font-size: var(--font-xl, 24px); + font-weight: 400; + margin: 0.5rem 0 0; +} + .update-card h2 { - margin: 0.5rem 0 2rem; + font-size: var(--font-xl, 24px); + margin: 0.25rem 0 2rem; } .card-row { diff --git a/squarelet/templates/organizations/organization_updatecard.html b/squarelet/templates/organizations/organization_updatecard.html index 8233f676e..6be295072 100644 --- a/squarelet/templates/organizations/organization_updatecard.html +++ b/squarelet/templates/organizations/organization_updatecard.html @@ -20,6 +20,7 @@
    +

    {{ organization.name }}

    {% trans "Change payment card" %}

    From 0bea1741f01feccc532611551bc2c5c619574fd6 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Fri, 3 Jul 2026 09:49:23 -0400 Subject: [PATCH 24/86] Create modal layout template --- frontend/css/modal_layout.css | 64 +++++++++++++++++++ frontend/views/modal_layout.ts | 1 + squarelet/templates/layouts/modal_layout.html | 18 ++++++ 3 files changed, 83 insertions(+) create mode 100644 frontend/css/modal_layout.css create mode 100644 frontend/views/modal_layout.ts create mode 100644 squarelet/templates/layouts/modal_layout.html diff --git a/frontend/css/modal_layout.css b/frontend/css/modal_layout.css new file mode 100644 index 000000000..2725eec80 --- /dev/null +++ b/frontend/css/modal_layout.css @@ -0,0 +1,64 @@ +.modal-layout header { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + margin: 0.5rem 0 2rem; +} + +.modal-layout main { + box-sizing: border-box; + width: 100%; + border-radius: 0.5rem; + border: 1px solid var(--gray-2, #d8dee2); + background: var(--white, #fff); + box-shadow: var(--shadow-1); + display: flex; + flex-direction: column; + gap: 0.5rem 1rem; + padding: 1rem; +} + +.modal-layout h1 { + font-size: var(--font-xl, 24px); + font-weight: 400; + margin: 0; +} + +.modal-layout h2 { + font-size: var(--font-xl, 24px); + margin: 0; +} + +.modal-layout .info { + flex: 0 1 auto; + font-size: var(--font-sm); + opacity: 0.7; + display: flex; + align-items: center; + gap: 0.25rem; + color: var(--gray-5); +} + +.modal-layout .info .icon { + display: inline-flex; + align-items: center; + width: var(--font-md, 0.75em); + height: var(--font-md, 0.75em); +} + +.modal-layout p { + margin: 0; +} + +.modal-layout form { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.modal-layout .buttons { + display: flex; + gap: 1rem; + align-items: center; +} diff --git a/frontend/views/modal_layout.ts b/frontend/views/modal_layout.ts new file mode 100644 index 000000000..e10328153 --- /dev/null +++ b/frontend/views/modal_layout.ts @@ -0,0 +1 @@ +import "@/css/modal_layout.css"; diff --git a/squarelet/templates/layouts/modal_layout.html b/squarelet/templates/layouts/modal_layout.html new file mode 100644 index 000000000..3e5823601 --- /dev/null +++ b/squarelet/templates/layouts/modal_layout.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% load django_vite %} + +{% block vite %} +{{ block.super }} +{% vite_asset "frontend/views/modal_layout.ts" %} +{% endblock vite %} + +{% block container %} + +{% endblock container %} From b0e967a91ec48bdc25e16c26dd65233dbb6c0d78 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Fri, 3 Jul 2026 09:49:58 -0400 Subject: [PATCH 25/86] Use modal layout for update card template --- frontend/css/organization_updatecard.css | 60 ----------- frontend/views/organization_updatecard.ts | 3 - .../organization_updatecard.html | 100 +++++++++--------- 3 files changed, 48 insertions(+), 115 deletions(-) delete mode 100644 frontend/css/organization_updatecard.css diff --git a/frontend/css/organization_updatecard.css b/frontend/css/organization_updatecard.css deleted file mode 100644 index 0d0e2d3c4..000000000 --- a/frontend/css/organization_updatecard.css +++ /dev/null @@ -1,60 +0,0 @@ -.update-card { - max-width: 32rem; -} - -.update-card main { - box-sizing: border-box; - width: 100%; - border-radius: 0.5rem; - border: 1px solid var(--gray-2, #d8dee2); - background: var(--white, #fff); - box-shadow: var(--shadow-1); - display: flex; - flex-direction: column; - gap: 0.5rem 1rem; - padding: 1rem; -} - -.update-card h1 { - font-size: var(--font-xl, 24px); - font-weight: 400; - margin: 0.5rem 0 0; -} - -.update-card h2 { - font-size: var(--font-xl, 24px); - margin: 0.25rem 0 2rem; -} - -.card-row { - flex: 0 1 auto; - font-size: var(--font-sm); - opacity: 0.7; - display: flex; - align-items: center; - gap: 0.25rem; - color: var(--gray-5); -} - -.card-row .icon { - display: inline-flex; - align-items: center; - width: var(--font-md, 0.75em); - height: var(--font-md, 0.75em); -} - -.update-card p { - margin: 0; -} - -.update-card form { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.update-card .buttons { - display: flex; - gap: 1rem; - align-items: center; -} diff --git a/frontend/views/organization_updatecard.ts b/frontend/views/organization_updatecard.ts index e207adf06..871c50aab 100644 --- a/frontend/views/organization_updatecard.ts +++ b/frontend/views/organization_updatecard.ts @@ -1,6 +1,3 @@ -import "@/css/sidebar_layout.css"; -import "@/css/organization_updatecard.css"; - import { d, exists, on } from "../util"; const Stripe = window["Stripe"] as any; diff --git a/squarelet/templates/organizations/organization_updatecard.html b/squarelet/templates/organizations/organization_updatecard.html index 6be295072..02746f22f 100644 --- a/squarelet/templates/organizations/organization_updatecard.html +++ b/squarelet/templates/organizations/organization_updatecard.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "layouts/modal_layout.html" %} {% load django_vite i18n crispy_forms_tags %} {% load icon %} @@ -11,57 +11,53 @@ {% block layout_id %}update_card{% endblock %} -{% block content %} -
    - {% url 'organizations:subscriptions' organization.slug as subscriptions_url %} - - {% icon "chevron-left" %} - {% trans "Back to your subscriptions" %} - +{% block header %} + {% url 'organizations:subscriptions' organization.slug as subscriptions_url %} + + {% icon "chevron-left" %} + {% trans "Back to your subscriptions" %} + +

    {{ organization.name }}

    +

    {% trans "Change payment card" %}

    +{% endblock header %} -
    -

    {{ organization.name }}

    -

    {% trans "Change payment card" %}

    -
    +{% block main %} + {% url 'organizations:subscriptions' organization.slug as subscriptions_url %} + {% if card %} +
    + {% icon "credit-card" %} + + {% blocktrans with card.brand as brand and card.last4 as last4 %} + Current card: {{ brand }} ending in {{ last4 }} + {% endblocktrans %} + +
    + {% endif %} -
    - {% if card %} -
    - {% icon "credit-card" %} - - {% blocktrans with card.brand as brand and card.last4 as last4 %} - Current card: {{ brand }} ending in {{ last4 }} - {% endblocktrans %} - -
    - {% endif %} +
    + {% crispy form form.helper %} - - {% crispy form form.helper %} - -
    -

    {% trans "Enter your new credit card details below." %}

    -
    -
    -
    - -
    - -
    - - - - {% trans "Cancel" %} - -
    -
    -
    -
    -{% endblock content %} \ No newline at end of file +
    +

    {% trans "Enter your new credit card details below." %}

    +
    +
    +
    + +
    + +
    + + + + {% trans "Cancel" %} + +
    + +{% endblock main %} From bac4e7aa0a969ac785d3b6dd57518a54109c52d8 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Fri, 3 Jul 2026 10:05:17 -0400 Subject: [PATCH 26/86] More modal layout styles --- frontend/css/modal_layout.css | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/css/modal_layout.css b/frontend/css/modal_layout.css index 2725eec80..7bb67fb28 100644 --- a/frontend/css/modal_layout.css +++ b/frontend/css/modal_layout.css @@ -19,15 +19,21 @@ padding: 1rem; } +.modal-layout :is(h1, h2, h3) { + margin: 0; +} + .modal-layout h1 { font-size: var(--font-xl, 24px); font-weight: 400; - margin: 0; } .modal-layout h2 { font-size: var(--font-xl, 24px); - margin: 0; +} + +.modal-layout h3 { + font-size: var(--font-md, 16px); } .modal-layout .info { @@ -61,4 +67,5 @@ display: flex; gap: 1rem; align-items: center; + margin-top: 0.5rem; } From 7cc60680e79078db5f402c8b93c4a114c6d56865 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Fri, 3 Jul 2026 10:53:35 -0400 Subject: [PATCH 27/86] Make get_subscription_next_date a standalone function --- squarelet/organizations/views/subscription.py | 41 ++++++++----------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py index bfd4f5e4f..fd7ad55e9 100644 --- a/squarelet/organizations/views/subscription.py +++ b/squarelet/organizations/views/subscription.py @@ -66,8 +66,8 @@ def get_context_data(self, **kwargs): # Get subscriptions and add renewal/cancellation date and cost data subscriptions = self.object.subscriptions.all() for subscription in subscriptions: - subscription.next_date = self.get_subscription_next_date(subscription) - subscription.cost = self.get_subscription_cost(subscription) + subscription.next_date = get_subscription_next_date(subscription) + subscription.cost = subscription.plan.base_price context["subscriptions"] = subscriptions # Get card on file @@ -93,27 +93,6 @@ def get_context_data(self, **kwargs): context["payments"] = payments return context - - def get_subscription_next_date(self, 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 - - def get_subscription_cost(self, subscription): - stripe_sub = subscription.stripe_subscription - if stripe_sub: - return subscription.plan.base_price - return None class UpdateSubscription(OrganizationPermissionMixin, UpdateView): @@ -394,3 +373,19 @@ def stripe_webhook(request): if handler: handler.delay(event_obj) return HttpResponse() + + +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 From 1e9eaa5b09130b1c80303d2abd73fb02aac2d665 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Fri, 3 Jul 2026 11:11:15 -0400 Subject: [PATCH 28/86] More modal layout helper classes --- frontend/css/modal_layout.css | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frontend/css/modal_layout.css b/frontend/css/modal_layout.css index 7bb67fb28..18e454620 100644 --- a/frontend/css/modal_layout.css +++ b/frontend/css/modal_layout.css @@ -53,6 +53,19 @@ height: var(--font-md, 0.75em); } +.modal-layout .help { + font-weight: var(--font-regular, 400); + font-size: var(--font-sm, 0.875em); +} + +.modal-layout .plan.professional { + color: var(--blue-4); +} + +.modal-layout .plan.organization { + color: var(--purple-4); +} + .modal-layout p { margin: 0; } From 2d1a61a21eb433d3d4fa61b1226965cb2c47f0a7 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Fri, 3 Jul 2026 11:12:02 -0400 Subject: [PATCH 29/86] Add cancel subscription template and stub view --- squarelet/organizations/forms.py | 11 +++++ squarelet/organizations/urls.py | 1 + squarelet/organizations/views/__init__.py | 2 + squarelet/organizations/views/subscription.py | 23 ++++++++- .../organization_cancelsubscription.html | 49 +++++++++++++++++++ .../organization_managesubscriptions.html | 3 +- 6 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 squarelet/templates/organizations/organization_cancelsubscription.html diff --git a/squarelet/organizations/forms.py b/squarelet/organizations/forms.py index 4037cfe2f..3bf738978 100644 --- a/squarelet/organizations/forms.py +++ b/squarelet/organizations/forms.py @@ -210,6 +210,17 @@ def __init__(self, *args, **kwargs): def clean(self): return super().clean() +class CancelSubscriptionForm(forms.Form): + """Cancel a subscription.""" + + def __init__(self, *args, **kwargs): + super().__init__(*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 3304e5a74..94322ff90 100644 --- a/squarelet/organizations/urls.py +++ b/squarelet/organizations/urls.py @@ -21,6 +21,7 @@ path( "/payment/", view=views.UpdateSubscription.as_view(), name="payment" ), + path("~cancel//", view=views.CancelSubscription.as_view(), name="cancel-subscription"), path("/card/", view=views.UpdateCard.as_view(), name="update-card"), path("/update/", view=views.Update.as_view(), name="update"), path( diff --git a/squarelet/organizations/views/__init__.py b/squarelet/organizations/views/__init__.py index 430168239..3f57466b8 100644 --- a/squarelet/organizations/views/__init__.py +++ b/squarelet/organizations/views/__init__.py @@ -18,6 +18,7 @@ ManageSubscriptions, UpdateSubscription, UpdateCard, + CancelSubscription, stripe_webhook, ) @@ -30,6 +31,7 @@ "ManageSubscriptions", "UpdateSubscription", "UpdateCard", + "CancelSubscription", "ChargeDetail", "PDFChargeDetail", "stripe_webhook", diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py index fd7ad55e9..ffbd6e1f7 100644 --- a/squarelet/organizations/views/subscription.py +++ b/squarelet/organizations/views/subscription.py @@ -14,7 +14,7 @@ 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, UpdateView +from django.views.generic import DetailView, UpdateView, DeleteView from django.urls import reverse # Standard Library @@ -33,9 +33,10 @@ get_stripe_dashboard_url, new_action, ) -from squarelet.organizations.forms import CardForm, PaymentForm +from squarelet.organizations.forms import CardForm, PaymentForm, CancelSubscriptionForm from squarelet.organizations.mixins import OrganizationPermissionMixin from squarelet.organizations.models import Charge, Organization +from squarelet.organizations.models.payment import Plan from squarelet.organizations.payments.base import PaymentActionRequired from squarelet.organizations.payments.exceptions import SubscriptionError from squarelet.organizations.payments.factory import get_payment_provider @@ -248,6 +249,24 @@ def get_context_data(self, **kwargs): card = customer.card context["card"] = card return context + +class CancelSubscription(OrganizationPermissionMixin, DeleteView): + permission_required = "organizations.can_edit_subscription" + queryset = Plan.objects.all() + form_class = CancelSubscriptionForm + template_name = "organizations/organization_cancelsubscription.html" + + 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) + + return context + @method_decorator(xframe_options_sameorigin, name="dispatch") class ChargeDetail(UserPassesTestMixin, DetailView): diff --git a/squarelet/templates/organizations/organization_cancelsubscription.html b/squarelet/templates/organizations/organization_cancelsubscription.html new file mode 100644 index 000000000..00f859baa --- /dev/null +++ b/squarelet/templates/organizations/organization_cancelsubscription.html @@ -0,0 +1,49 @@ +{% extends "layouts/modal_layout.html" %} +{% load django_vite i18n crispy_forms_tags %} +{% load icon %} + +{% block title %}{{ plan.name }} | Cancel Subscription{% endblock %} + +{% block layout_id %}cancel_subscription{% endblock %} + +{% block content %} +{% endblock content %} + +{% block header %} + {% url 'organizations:subscriptions' organization.slug as subscriptions_url %} + + {% icon "chevron-left" %} + {% trans "Back to your subscriptions" %} + + +

    {{ organization.name }}

    +

    {% trans "Cancel subscription" %}

    +{% endblock header %} + +{% block main %} + {% url 'organizations:subscriptions' organization.slug as subscriptions_url %} +
    + {% crispy form form.helper %} +
    +

    + {{ plan.name }} +

    +

    {% trans "Are you sure you want to cancel this plan?" %}

    +
    +

    + {% blocktrans with date=next_date %} + Your plan is paid through {{ date }}. + {% endblocktrans %} +

    +
    + + + + {% trans "Keep plan" %} + +
    +
    +{% endblock main %} diff --git a/squarelet/templates/organizations/organization_managesubscriptions.html b/squarelet/templates/organizations/organization_managesubscriptions.html index 358ea9bdf..ff7113a9a 100644 --- a/squarelet/templates/organizations/organization_managesubscriptions.html +++ b/squarelet/templates/organizations/organization_managesubscriptions.html @@ -93,7 +93,8 @@

    {{ subscription.plan.name }}

    - + {% url 'organizations:cancel-subscription' subscription.plan.id as cancel_url %} + {% icon "x-circle" %} {% trans "Cancel plan" %} From b9a6942cdc68f2d95ae33433d05ddd594de5e6b7 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Fri, 3 Jul 2026 11:31:38 -0400 Subject: [PATCH 30/86] Modal layout footer block --- frontend/css/modal_layout.css | 12 ++++++++++++ squarelet/templates/layouts/modal_layout.html | 2 ++ 2 files changed, 14 insertions(+) diff --git a/frontend/css/modal_layout.css b/frontend/css/modal_layout.css index 18e454620..c4cd03199 100644 --- a/frontend/css/modal_layout.css +++ b/frontend/css/modal_layout.css @@ -82,3 +82,15 @@ align-items: center; margin-top: 0.5rem; } + +.modal-layout .footer { + margin-top: 2rem; + font-weight: 600; + font-family: var(--font-sans, "Source Sans Pro"); + font-size: var(--font-md, 16px); +} + +.modal-layout .footer a { + color: var(--gray-5, #233944); + text-decoration-color: var(--gray-5, #233944); +} diff --git a/squarelet/templates/layouts/modal_layout.html b/squarelet/templates/layouts/modal_layout.html index 3e5823601..3c577f988 100644 --- a/squarelet/templates/layouts/modal_layout.html +++ b/squarelet/templates/layouts/modal_layout.html @@ -14,5 +14,7 @@
    {% block main %}{% endblock %}
    + + {% block footer %}{% endblock %} {% endblock container %} From 9799563041e21d4724ad8805b192f429cfd916b8 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Fri, 3 Jul 2026 11:31:44 -0400 Subject: [PATCH 31/86] Add customer service CTA --- .../organizations/organization_cancelsubscription.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/squarelet/templates/organizations/organization_cancelsubscription.html b/squarelet/templates/organizations/organization_cancelsubscription.html index 00f859baa..a0919194c 100644 --- a/squarelet/templates/organizations/organization_cancelsubscription.html +++ b/squarelet/templates/organizations/organization_cancelsubscription.html @@ -47,3 +47,11 @@

    {% endblock main %} + +{% block footer %} + +{% endblock footer %} From 1481d137b0d4d033e1cd0040c38674649649fc49 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Fri, 3 Jul 2026 13:27:43 -0400 Subject: [PATCH 32/86] Add subscription frequency template and stub view --- squarelet/organizations/forms.py | 17 ++++++ squarelet/organizations/urls.py | 5 ++ squarelet/organizations/views/__init__.py | 2 + squarelet/organizations/views/subscription.py | 27 ++++++++- .../organization_managesubscriptions.html | 3 +- ...anization_updatesubscriptionfrequency.html | 59 +++++++++++++++++++ 6 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 squarelet/templates/organizations/organization_updatesubscriptionfrequency.html diff --git a/squarelet/organizations/forms.py b/squarelet/organizations/forms.py index 3bf738978..e707cb23c 100644 --- a/squarelet/organizations/forms.py +++ b/squarelet/organizations/forms.py @@ -210,6 +210,23 @@ def __init__(self, *args, **kwargs): def clean(self): return super().clean() + +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 + + def clean(self): + return super().clean() + + class CancelSubscriptionForm(forms.Form): """Cancel a subscription.""" diff --git a/squarelet/organizations/urls.py b/squarelet/organizations/urls.py index 94322ff90..a38c111c8 100644 --- a/squarelet/organizations/urls.py +++ b/squarelet/organizations/urls.py @@ -22,6 +22,11 @@ "/payment/", view=views.UpdateSubscription.as_view(), name="payment" ), path("~cancel//", view=views.CancelSubscription.as_view(), name="cancel-subscription"), + path( + "~update-frequency//", + view=views.UpdateSubscriptionFrequency.as_view(), + name="update-frequency", + ), path("/card/", view=views.UpdateCard.as_view(), name="update-card"), path("/update/", view=views.Update.as_view(), name="update"), path( diff --git a/squarelet/organizations/views/__init__.py b/squarelet/organizations/views/__init__.py index 3f57466b8..5f07b76ef 100644 --- a/squarelet/organizations/views/__init__.py +++ b/squarelet/organizations/views/__init__.py @@ -18,6 +18,7 @@ ManageSubscriptions, UpdateSubscription, UpdateCard, + UpdateSubscriptionFrequency, CancelSubscription, stripe_webhook, ) @@ -31,6 +32,7 @@ "ManageSubscriptions", "UpdateSubscription", "UpdateCard", + "UpdateSubscriptionFrequency", "CancelSubscription", "ChargeDetail", "PDFChargeDetail", diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py index ffbd6e1f7..fa59bd989 100644 --- a/squarelet/organizations/views/subscription.py +++ b/squarelet/organizations/views/subscription.py @@ -33,7 +33,12 @@ get_stripe_dashboard_url, new_action, ) -from squarelet.organizations.forms import CardForm, PaymentForm, CancelSubscriptionForm +from squarelet.organizations.forms import ( + CardForm, + PaymentForm, + UpdateSubscriptionFrequencyForm, + CancelSubscriptionForm, +) from squarelet.organizations.mixins import OrganizationPermissionMixin from squarelet.organizations.models import Charge, Organization from squarelet.organizations.models.payment import Plan @@ -250,6 +255,24 @@ def get_context_data(self, **kwargs): context["card"] = card return context +class UpdateSubscriptionFrequency(OrganizationPermissionMixin, UpdateView): + permission_required = "organizations.can_edit_subscription" + queryset = Plan.objects.all() + form_class = UpdateSubscriptionFrequencyForm + template_name = "organizations/organization_updatesubscriptionfrequency.html" + + 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) + + return context + + class CancelSubscription(OrganizationPermissionMixin, DeleteView): permission_required = "organizations.can_edit_subscription" queryset = Plan.objects.all() @@ -264,7 +287,7 @@ def get_context_data(self, **kwargs): if subscription: context["organization"] = subscription.organization context["next_date"] = get_subscription_next_date(subscription) - + return context diff --git a/squarelet/templates/organizations/organization_managesubscriptions.html b/squarelet/templates/organizations/organization_managesubscriptions.html index ff7113a9a..f16301c3f 100644 --- a/squarelet/templates/organizations/organization_managesubscriptions.html +++ b/squarelet/templates/organizations/organization_managesubscriptions.html @@ -65,7 +65,8 @@

    {{ subscription.plan.name }}

    {{ annual|yesno:_("Annual,Monthly") }} - + {% url 'organizations:update-frequency' subscription.plan.id as update_url %} + {% blocktrans with annual|yesno:_("monthly,annual") as frequency %} Change to {{ frequency }} {% endblocktrans %} diff --git a/squarelet/templates/organizations/organization_updatesubscriptionfrequency.html b/squarelet/templates/organizations/organization_updatesubscriptionfrequency.html new file mode 100644 index 000000000..2c00c9f2b --- /dev/null +++ b/squarelet/templates/organizations/organization_updatesubscriptionfrequency.html @@ -0,0 +1,59 @@ +{% extends "layouts/modal_layout.html" %} +{% load django_vite i18n crispy_forms_tags humanize %} +{% load icon %} + +{% block title %}{{ plan.name }} | Change Billing Frequency{% endblock %} + +{% block layout_id %}update_frequency{% endblock %} + +{% block content %} +{% endblock content %} + +{% block header %} + {% url 'organizations:subscriptions' organization.slug as subscriptions_url %} + + {% icon "chevron-left" %} + {% trans "Back to your subscriptions" %} + + +

    {{ organization.name }}

    +

    {% 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") %} +
    + {% crispy form form.helper %} +
    +

    + {{ plan.name }} +

    +

    + {% blocktrans with frequency=newfreq %} + Are you sure you want to change to {{ frequency }} billing? + {% endblocktrans %} +

    +
    +

    + {% blocktrans with cost=plan.base_price|floatformat:2|intcomma date=next_date %} + You will be charged ${{ cost }} on {{ date }}. + {% endblocktrans %} +

    +
    + + + + {% blocktrans with frequency=currentfreq %} + Keep {{ frequency }} + {% endblocktrans %} + +
    +
    + {% endwith %} +{% endblock main %} From 2cddfa6d7bdd5a7e4509fb7b81aa79e9d9c3b0e4 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Fri, 3 Jul 2026 17:32:35 -0400 Subject: [PATCH 33/86] Add receipt email form --- squarelet/organizations/forms.py | 45 +++++++++++++++++ squarelet/organizations/urls.py | 5 ++ squarelet/organizations/views/__init__.py | 2 + squarelet/organizations/views/subscription.py | 24 +++++++++ .../organization_managesubscriptions.html | 3 +- .../organization_updatereceiptemail.html | 50 +++++++++++++++++++ 6 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 squarelet/templates/organizations/organization_updatereceiptemail.html diff --git a/squarelet/organizations/forms.py b/squarelet/organizations/forms.py index e707cb23c..bbe0fc418 100644 --- a/squarelet/organizations/forms.py +++ b/squarelet/organizations/forms.py @@ -6,6 +6,9 @@ from django.urls import reverse 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 @@ -225,6 +228,48 @@ def __init__(self, *args, **kwargs): def clean(self): return super().clean() + + +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 + + def clean(self): + return super().clean() class CancelSubscriptionForm(forms.Form): diff --git a/squarelet/organizations/urls.py b/squarelet/organizations/urls.py index a38c111c8..6bf8a3d16 100644 --- a/squarelet/organizations/urls.py +++ b/squarelet/organizations/urls.py @@ -28,6 +28,11 @@ name="update-frequency", ), path("/card/", view=views.UpdateCard.as_view(), name="update-card"), + path( + "/receipts/", + view=views.UpdateReceiptEmail.as_view(), + name="update-receipt-email", + ), 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 5f07b76ef..0d36a1d3b 100644 --- a/squarelet/organizations/views/__init__.py +++ b/squarelet/organizations/views/__init__.py @@ -19,6 +19,7 @@ UpdateSubscription, UpdateCard, UpdateSubscriptionFrequency, + UpdateReceiptEmail, CancelSubscription, stripe_webhook, ) @@ -33,6 +34,7 @@ "UpdateSubscription", "UpdateCard", "UpdateSubscriptionFrequency", + "UpdateReceiptEmail", "CancelSubscription", "ChargeDetail", "PDFChargeDetail", diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py index fa59bd989..029cd592c 100644 --- a/squarelet/organizations/views/subscription.py +++ b/squarelet/organizations/views/subscription.py @@ -37,6 +37,7 @@ CardForm, PaymentForm, UpdateSubscriptionFrequencyForm, + UpdateReceiptEmailForm, CancelSubscriptionForm, ) from squarelet.organizations.mixins import OrganizationPermissionMixin @@ -272,6 +273,29 @@ def get_context_data(self, **kwargs): 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" + + 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) + + def get_initial(self): + return { + "receipt_emails": ", ".join( + r.email for r in self.object.receipt_emails.all() + ), + } + class CancelSubscription(OrganizationPermissionMixin, DeleteView): permission_required = "organizations.can_edit_subscription" diff --git a/squarelet/templates/organizations/organization_managesubscriptions.html b/squarelet/templates/organizations/organization_managesubscriptions.html index f16301c3f..b8c3d551e 100644 --- a/squarelet/templates/organizations/organization_managesubscriptions.html +++ b/squarelet/templates/organizations/organization_managesubscriptions.html @@ -150,7 +150,8 @@

    {% trans "Receipt emails" %}

    - + {% url 'organizations:update-receipt-email' organization.slug as receipt_email_url %} + {% trans "Change email" %} diff --git a/squarelet/templates/organizations/organization_updatereceiptemail.html b/squarelet/templates/organizations/organization_updatereceiptemail.html new file mode 100644 index 000000000..ee0cf53d1 --- /dev/null +++ b/squarelet/templates/organizations/organization_updatereceiptemail.html @@ -0,0 +1,50 @@ +{% extends "layouts/modal_layout.html" %} +{% load django_vite i18n crispy_forms_tags %} +{% load icon %} + +{% block title %}{{ organization.name }} | Update Receipt Emails{% endblock %} + +{% block vite %} +{{ block.super }} +{% endblock vite %} + +{% block layout_id %}update_receipt_emails{% endblock %} + +{% block header %} + {% url 'organizations:subscriptions' organization.slug as subscriptions_url %} + + {% icon "chevron-left" %} + {% trans "Back to your subscriptions" %} + +

    {{ organization.name }}

    +

    {% trans "Change receipt emails" %}

    +{% endblock header %} + +{% block main %} + {% url 'organizations:subscriptions' organization.slug as subscriptions_url %} + {% if card %} +
    + {% icon "credit-card" %} + + {% blocktrans with card.brand as brand and card.last4 as last4 %} + Current card: {{ brand }} ending in {{ last4 }} + {% endblocktrans %} + +
    + {% endif %} + +
    + {% crispy form form.helper %} + +
    + + + + {% trans "Cancel" %} + +
    +
    +{% endblock main %} From c3734a66c142fc90c7d4093e71ff400408de271e Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Sun, 5 Jul 2026 17:19:46 -0400 Subject: [PATCH 34/86] Add receipts card template --- frontend/css/receipts_card.css | 49 ++++++++++++++ frontend/views/receipts_card.ts | 1 + .../organizations/includes/receipts_card.html | 65 +++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 frontend/css/receipts_card.css create mode 100644 frontend/views/receipts_card.ts create mode 100644 squarelet/templates/organizations/includes/receipts_card.html diff --git a/frontend/css/receipts_card.css b/frontend/css/receipts_card.css new file mode 100644 index 000000000..f056a9012 --- /dev/null +++ b/frontend/css/receipts_card.css @@ -0,0 +1,49 @@ +.receipts-card { + width: 100%; + display: flex; + flex-direction: column; + gap: 0.5rem 1rem; + padding: 1rem; +} + +.receipts-card table { + font-size: var(--font-sm); + border-spacing: 0; +} + +/* Visually hidden table header for accessibility */ +.receipts-card thead { + width: 1cm; + height: 1cm; + overflow: hidden; + position: absolute; + clip: rect(0 0 0 0); + clip-path: inset(50%); +} + +.receipts-card td { + padding: 0; +} + +.receipts-card tr:not(:last-child) td { + padding-bottom: 1rem; +} + +.receipts-card time { + font-weight: 600; + white-space: nowrap; +} + +.receipts-card .description { + padding-inline: 0.5rem; +} + +.receipts-card .amount { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.receipts-card .actions { + display: flex; + justify-content: flex-end; +} diff --git a/frontend/views/receipts_card.ts b/frontend/views/receipts_card.ts new file mode 100644 index 000000000..627a87194 --- /dev/null +++ b/frontend/views/receipts_card.ts @@ -0,0 +1 @@ +import "@/css/receipts_card.css"; diff --git a/squarelet/templates/organizations/includes/receipts_card.html b/squarelet/templates/organizations/includes/receipts_card.html new file mode 100644 index 000000000..ef3cc2fb3 --- /dev/null +++ b/squarelet/templates/organizations/includes/receipts_card.html @@ -0,0 +1,65 @@ +{% load i18n icon django_vite %} + +{% block vite %} +{% vite_asset "frontend/views/receipts_card.ts" %} +{% endblock vite %} + +
    + + + + + + + + + + + {% for payment in payments %} + + + + + + + {% endfor %} + + {% if page_obj %} + + + + + + {% endif %} +
    {% trans "Date" %}{% trans "Description" %}{% trans "Amount" %}{% trans "Actions" %}
    + + {{ payment.description }}${{ payment.amount_dollars|floatformat:2 }} +
    + {% url "organizations:charge" charge.pk as view_url %} + + {% icon "eye" %} + + + {% url "organizations:charge-pdf" charge.pk as pdf_url %} + + {% icon "download" %} + +
    +
    + +
    +
    From bb5a66cbf9a50c8577f17a17b981662d43b02ccd Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Sun, 5 Jul 2026 17:20:51 -0400 Subject: [PATCH 35/86] Add payments list view --- frontend/views/organization_payments.ts | 1 + squarelet/organizations/urls.py | 1 + squarelet/organizations/views/__init__.py | 2 ++ squarelet/organizations/views/subscription.py | 23 +++++++++++-- .../organizations/organization_payments.html | 34 +++++++++++++++++++ 5 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 frontend/views/organization_payments.ts create mode 100644 squarelet/templates/organizations/organization_payments.html diff --git a/frontend/views/organization_payments.ts b/frontend/views/organization_payments.ts new file mode 100644 index 000000000..97f47e957 --- /dev/null +++ b/frontend/views/organization_payments.ts @@ -0,0 +1 @@ +import "@/css/sidebar_layout.css"; diff --git a/squarelet/organizations/urls.py b/squarelet/organizations/urls.py index 6bf8a3d16..c114629ea 100644 --- a/squarelet/organizations/urls.py +++ b/squarelet/organizations/urls.py @@ -33,6 +33,7 @@ view=views.UpdateReceiptEmail.as_view(), name="update-receipt-email", ), + path("/payments/", view=views.PaymentsList.as_view(), name="payments"), 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 0d36a1d3b..7c91b2896 100644 --- a/squarelet/organizations/views/__init__.py +++ b/squarelet/organizations/views/__init__.py @@ -20,6 +20,7 @@ UpdateCard, UpdateSubscriptionFrequency, UpdateReceiptEmail, + PaymentsList, CancelSubscription, stripe_webhook, ) @@ -36,6 +37,7 @@ "UpdateSubscriptionFrequency", "UpdateReceiptEmail", "CancelSubscription", + "PaymentsList", "ChargeDetail", "PDFChargeDetail", "stripe_webhook", diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py index 029cd592c..1581f6537 100644 --- a/squarelet/organizations/views/subscription.py +++ b/squarelet/organizations/views/subscription.py @@ -14,7 +14,7 @@ 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, UpdateView, DeleteView +from django.views.generic import DetailView, ListView, UpdateView, DeleteView from django.urls import reverse # Standard Library @@ -96,7 +96,13 @@ def get_context_data(self, **kwargs): ) # Get five most recent payments - payments = self.object.charges.order_by("-created_at").all()[:5] + # payments = self.object.charges.order_by("-created_at").all()[:5] + payments = [{ + 'pk': 1, + 'created_at': datetime(2024, 6, 1), + 'description': 'Sunlight Research Center – Premium', + 'amount_dollars': 1380.00, + }] context["payments"] = payments return context @@ -315,6 +321,19 @@ def get_context_data(self, **kwargs): return context +class PaymentsList(ListView): + permission_required = "organizations.can_view_charge" + queryset = Charge.objects.all() + template_name = "organizations/organization_payments.html" + paginate_by = 20 + + def get_context_data(self, **kwargs): + context = super().get_context_data(**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): queryset = Charge.objects.all() diff --git a/squarelet/templates/organizations/organization_payments.html b/squarelet/templates/organizations/organization_payments.html new file mode 100644 index 000000000..e52abfaaf --- /dev/null +++ b/squarelet/templates/organizations/organization_payments.html @@ -0,0 +1,34 @@ +{% extends "layouts/sidebar_layout.html" %} +{% load avatar humanize django_vite i18n crispy_forms_tags planinfo %} +{% load icon %} + +{% block title %}{{ organization.name }} | Payments{% endblock %} + +{% block vite %} +{{ block.super }} +{% vite_asset "frontend/views/organization_payments.ts" %} +{% endblock vite %} + + +{% block layout_id %}payments{% endblock %} + +{% block header %} +
    +

    {{ organization.name }}

    + {% icon "chevron-right" %} +

    {% trans "Payment history" %}

    +
    +{% endblock %} + +{% block sidebar %} + + {% icon "chevron-left" %} + {% trans "Back to subscriptions" %} + +{% endblock sidebar %} + +{% block main %} +
    + {% include "organizations/includes/receipts_card.html" with payments=object_list page_obj=page_obj %} +
    +{% endblock main %} From 2de5a2fb4183806c6b8c4ab7cf99eb1b536e087e Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Sun, 5 Jul 2026 17:22:04 -0400 Subject: [PATCH 36/86] Add receipts card and update links to payments page --- squarelet/templates/organizations/organization_detail.html | 2 +- .../organizations/organization_managesubscriptions.html | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/squarelet/templates/organizations/organization_detail.html b/squarelet/templates/organizations/organization_detail.html index b4be92631..0bb3b8ddc 100644 --- a/squarelet/templates/organizations/organization_detail.html +++ b/squarelet/templates/organizations/organization_detail.html @@ -281,7 +281,7 @@

    {% trans "Plans" %}

    {% trans "Manage plans" %} {% if can_view_charge %} - + {% icon "log" %} {% trans "View payments" %} diff --git a/squarelet/templates/organizations/organization_managesubscriptions.html b/squarelet/templates/organizations/organization_managesubscriptions.html index b8c3d551e..a69c34043 100644 --- a/squarelet/templates/organizations/organization_managesubscriptions.html +++ b/squarelet/templates/organizations/organization_managesubscriptions.html @@ -163,13 +163,13 @@

    {% trans "Receipt emails" %}

    {% trans "Recent payments" %}

    - + {% url 'organizations:payments' organization.slug as payments_url %} + {% icon "log" %} {% trans "View all" %}

    -
    -
    + {% include "organizations/includes/receipts_card.html" with payments=payments %}
    {% endblock main %} \ No newline at end of file From af4bbabad83f38c20ef0dd49f53e56bb18e09335 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Sun, 5 Jul 2026 17:28:22 -0400 Subject: [PATCH 37/86] Update modal layout headers --- frontend/css/modal_layout.css | 12 ++++++++++-- .../organization_cancelsubscription.html | 7 +------ .../organizations/organization_updatecard.html | 6 +----- .../organization_updatereceiptemail.html | 6 +----- .../organization_updatesubscriptionfrequency.html | 7 +------ 5 files changed, 14 insertions(+), 24 deletions(-) diff --git a/frontend/css/modal_layout.css b/frontend/css/modal_layout.css index c4cd03199..8a7f15ccd 100644 --- a/frontend/css/modal_layout.css +++ b/frontend/css/modal_layout.css @@ -1,11 +1,19 @@ .modal-layout header { display: flex; - flex-direction: column; + flex-direction: row; + flex-wrap: wrap; align-items: flex-start; - gap: 0.25rem; + gap: 0.25rem 1rem; margin: 0.5rem 0 2rem; } +.modal-layout header .arrow { + fill: var(--gray-4, #5c717c); + width: var(--font-md, 1rem); + height: var(--font-md, 1rem); + align-self: center; +} + .modal-layout main { box-sizing: border-box; width: 100%; diff --git a/squarelet/templates/organizations/organization_cancelsubscription.html b/squarelet/templates/organizations/organization_cancelsubscription.html index a0919194c..d2b96a491 100644 --- a/squarelet/templates/organizations/organization_cancelsubscription.html +++ b/squarelet/templates/organizations/organization_cancelsubscription.html @@ -10,13 +10,8 @@ {% endblock content %} {% block header %} - {% url 'organizations:subscriptions' organization.slug as subscriptions_url %} - - {% icon "chevron-left" %} - {% trans "Back to your subscriptions" %} - -

    {{ organization.name }}

    + {% icon "chevron-right" %}

    {% trans "Cancel subscription" %}

    {% endblock header %} diff --git a/squarelet/templates/organizations/organization_updatecard.html b/squarelet/templates/organizations/organization_updatecard.html index 02746f22f..4d27f7fdc 100644 --- a/squarelet/templates/organizations/organization_updatecard.html +++ b/squarelet/templates/organizations/organization_updatecard.html @@ -12,12 +12,8 @@ {% block layout_id %}update_card{% endblock %} {% block header %} - {% url 'organizations:subscriptions' organization.slug as subscriptions_url %} - - {% icon "chevron-left" %} - {% trans "Back to your subscriptions" %} -

    {{ organization.name }}

    + {% icon "chevron-right" %}

    {% trans "Change payment card" %}

    {% endblock header %} diff --git a/squarelet/templates/organizations/organization_updatereceiptemail.html b/squarelet/templates/organizations/organization_updatereceiptemail.html index ee0cf53d1..9035dabe1 100644 --- a/squarelet/templates/organizations/organization_updatereceiptemail.html +++ b/squarelet/templates/organizations/organization_updatereceiptemail.html @@ -11,12 +11,8 @@ {% block layout_id %}update_receipt_emails{% endblock %} {% block header %} - {% url 'organizations:subscriptions' organization.slug as subscriptions_url %} - - {% icon "chevron-left" %} - {% trans "Back to your subscriptions" %} -

    {{ organization.name }}

    + {% icon "chevron-right" %}

    {% trans "Change receipt emails" %}

    {% endblock header %} diff --git a/squarelet/templates/organizations/organization_updatesubscriptionfrequency.html b/squarelet/templates/organizations/organization_updatesubscriptionfrequency.html index 2c00c9f2b..f745a5a0b 100644 --- a/squarelet/templates/organizations/organization_updatesubscriptionfrequency.html +++ b/squarelet/templates/organizations/organization_updatesubscriptionfrequency.html @@ -10,13 +10,8 @@ {% endblock content %} {% block header %} - {% url 'organizations:subscriptions' organization.slug as subscriptions_url %} - - {% icon "chevron-left" %} - {% trans "Back to your subscriptions" %} - -

    {{ organization.name }}

    + {% icon "chevron-right" %}

    {% trans "Change billing frequency" %}

    {% endblock header %} From a756184a5abd4a8ee34036e71197326bd5f0d536 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Sun, 5 Jul 2026 17:42:58 -0400 Subject: [PATCH 38/86] Remove dummy data --- squarelet/organizations/views/subscription.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py index 1581f6537..d2038a12f 100644 --- a/squarelet/organizations/views/subscription.py +++ b/squarelet/organizations/views/subscription.py @@ -96,13 +96,7 @@ def get_context_data(self, **kwargs): ) # Get five most recent payments - # payments = self.object.charges.order_by("-created_at").all()[:5] - payments = [{ - 'pk': 1, - 'created_at': datetime(2024, 6, 1), - 'description': 'Sunlight Research Center – Premium', - 'amount_dollars': 1380.00, - }] + payments = self.object.charges.order_by("-created_at").all()[:5] context["payments"] = payments return context From da98ceed52e25c62d8e1041a57810f9a849b4169 Mon Sep 17 00:00:00 2001 From: Daniel Nass 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 @@ -
    + {% 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") %}
    {% crispy form form.helper %}
    From d9722d864341badc97e978a7bbea854a3a428b52 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Mon, 6 Jul 2026 17:17:00 -0400 Subject: [PATCH 47/86] Rework cancel subscription view --- squarelet/organizations/forms.py | 6 ++++- squarelet/organizations/views/subscription.py | 26 ++++++++++++++----- .../organization_cancelsubscription.html | 4 +-- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/squarelet/organizations/forms.py b/squarelet/organizations/forms.py index 646a7fca4..0b8d5b1b8 100644 --- a/squarelet/organizations/forms.py +++ b/squarelet/organizations/forms.py @@ -264,9 +264,13 @@ def clean_receipt_emails(self): return emails -class CancelSubscriptionForm(forms.Form): +class CancelSubscriptionForm(forms.ModelForm): """Cancel a subscription.""" + class Meta: + model = Organization + fields = [] + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.helper = FormHelper() diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py index f380063d9..89eac6131 100644 --- a/squarelet/organizations/views/subscription.py +++ b/squarelet/organizations/views/subscription.py @@ -15,7 +15,7 @@ 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 DeleteView, DetailView, ListView, UpdateView +from django.views.generic import DetailView, ListView, UpdateView # Standard Library import json @@ -296,23 +296,35 @@ def get_initial(self): } -class CancelSubscription(OrganizationPermissionMixin, DeleteView): +class CancelSubscription(OrganizationPermissionMixin, UpdateView): permission_required = "organizations.can_edit_subscription" - queryset = Plan.objects.all() 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() + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["subject"] = "org" - - subscription = self.object.subscriptions.first() + subscription = self.object.subscriptions.filter(id=self.kwargs["pk"]).first() if subscription: - context["organization"] = subscription.organization + context["subscription"] = subscription context["next_date"] = get_subscription_next_date(subscription) - return context + def form_valid(self, form): + subscription = self.object.subscriptions.filter(id=self.kwargs["pk"]).first() + if subscription: + subscription.cancel() + messages.success(self.request, _("Subscription cancelled.")) + return redirect("organizations:subscriptions", slug=self.object.slug) + class PaymentsList(ListView): permission_required = "organizations.can_view_charge" diff --git a/squarelet/templates/organizations/organization_cancelsubscription.html b/squarelet/templates/organizations/organization_cancelsubscription.html index d2b96a491..dbe6f793e 100644 --- a/squarelet/templates/organizations/organization_cancelsubscription.html +++ b/squarelet/templates/organizations/organization_cancelsubscription.html @@ -2,7 +2,7 @@ {% load django_vite i18n crispy_forms_tags %} {% load icon %} -{% block title %}{{ plan.name }} | Cancel Subscription{% endblock %} +{% block title %}{{ subscription.plan.name }} | Cancel Subscription{% endblock %} {% block layout_id %}cancel_subscription{% endblock %} @@ -21,7 +21,7 @@

    {% trans "Cancel subscription" %}

    {% crispy form form.helper %}

    - {{ plan.name }} + {{ subscription.plan.name }}

    {% trans "Are you sure you want to cancel this plan?" %}

    From 0c2e564626ef7cc59fa1d8d838e11f5b697b7b17 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Mon, 6 Jul 2026 17:19:10 -0400 Subject: [PATCH 48/86] Show badge for cancelled subscriptions --- .../organizations/includes/plan_card.html | 2 +- .../organization_managesubscriptions.html | 42 +++++++++++-------- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/squarelet/templates/organizations/includes/plan_card.html b/squarelet/templates/organizations/includes/plan_card.html index 8d0b7aed3..41cc40d0a 100644 --- a/squarelet/templates/organizations/includes/plan_card.html +++ b/squarelet/templates/organizations/includes/plan_card.html @@ -52,7 +52,7 @@

    {% trans "Your plans" %}

    - {% if subscription.plan.cancelled %} + {% if subscription.cancelled %} {% trans "Cancelled" %} diff --git a/squarelet/templates/organizations/organization_managesubscriptions.html b/squarelet/templates/organizations/organization_managesubscriptions.html index 059a93a6b..71b6e3d3a 100644 --- a/squarelet/templates/organizations/organization_managesubscriptions.html +++ b/squarelet/templates/organizations/organization_managesubscriptions.html @@ -57,21 +57,27 @@

    {% trans "Your plans" %}

    {% for subscription in subscriptions %} - {% with annual=subscription.plan.annual %} + {% with annual=subscription.plan.annual cancelled=subscription.cancelled %}
    @@ -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 %}

    {% trans "Plans" %}

    {% if can_edit_subscription %}
    - + {% icon "gear" %} {% trans "Manage plans" %} From ef9f2a207a5f6ed280d32593692d19bf4e3d5a4e Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Tue, 7 Jul 2026 14:27:04 -0400 Subject: [PATCH 59/86] Update user page subscription links and headings --- squarelet/templates/users/user_detail.html | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/squarelet/templates/users/user_detail.html b/squarelet/templates/users/user_detail.html index 5c7254625..d8121138b 100644 --- a/squarelet/templates/users/user_detail.html +++ b/squarelet/templates/users/user_detail.html @@ -32,7 +32,7 @@

    {% trans "User Account" %}

  • {% icon "credit-card" %} - {% trans "Plan" %} + {% trans "Plans" %}
  • @@ -132,14 +132,15 @@

    {% firstof user.name user.username %}

    -

    {% trans "Plan" %}

    +

    {% trans "Plans" %}

    From 251d5f67bb61f249c208852bc92e83da2e446547 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Tue, 7 Jul 2026 14:35:57 -0400 Subject: [PATCH 60/86] Use subject namespace in subscription template URLs --- .../templates/subscriptions/cancel_subscription.html | 2 +- .../templates/subscriptions/manage_subscriptions.html | 10 +++++----- squarelet/templates/subscriptions/payments.html | 2 +- squarelet/templates/subscriptions/update_card.html | 3 +-- .../templates/subscriptions/update_receipt_email.html | 3 +-- .../subscriptions/update_subscription_frequency.html | 3 +-- 6 files changed, 10 insertions(+), 13 deletions(-) diff --git a/squarelet/templates/subscriptions/cancel_subscription.html b/squarelet/templates/subscriptions/cancel_subscription.html index dbe6f793e..a4744505d 100644 --- a/squarelet/templates/subscriptions/cancel_subscription.html +++ b/squarelet/templates/subscriptions/cancel_subscription.html @@ -16,7 +16,7 @@

    {% trans "Cancel subscription" %}

    {% endblock header %} {% block main %} - {% url 'organizations:subscriptions' organization.slug as subscriptions_url %} + {% url subject|add:':subscriptions' organization.slug as subscriptions_url %} {% crispy form form.helper %}
    diff --git a/squarelet/templates/subscriptions/manage_subscriptions.html b/squarelet/templates/subscriptions/manage_subscriptions.html index 697c3b02f..804ca77d7 100644 --- a/squarelet/templates/subscriptions/manage_subscriptions.html +++ b/squarelet/templates/subscriptions/manage_subscriptions.html @@ -70,7 +70,7 @@

    {{ subscription.plan.name }}

    {{ annual|yesno:_("Annual,Monthly") }} - {% url 'organizations:update-frequency' organization.slug subscription.id as update_url %} + {% url subject|add:':update-frequency' organization.slug subscription.id as update_url %} {% blocktrans with annual|yesno:_("monthly,annual") as frequency %} Change to {{ frequency }} @@ -101,7 +101,7 @@

    {{ subscription.plan.name }}

    {% if not cancelled %} - {% url 'organizations:cancel-subscription' organization.slug subscription.id as cancel_url %} + {% url subject|add:':cancel-subscription' organization.slug subscription.id as cancel_url %} {% icon "x-circle" %} {% trans "Cancel plan" %} @@ -141,7 +141,7 @@

    {% trans "Payment card" %}

  • - + {% trans "Add new card" %} @@ -162,7 +162,7 @@

    {% trans "Receipt emails" %}

    - {% url 'organizations:update-receipt-email' organization.slug as receipt_email_url %} + {% url subject|add:':update-receipt-email' organization.slug as receipt_email_url %} {% trans "Change email" %} @@ -175,7 +175,7 @@

    {% trans "Receipt emails" %}

    {% trans "Recent payments" %}

    - {% url 'organizations:payments' organization.slug as payments_url %} + {% url subject|add:':payments' organization.slug as payments_url %} {% icon "log" %} {% trans "View all" %} diff --git a/squarelet/templates/subscriptions/payments.html b/squarelet/templates/subscriptions/payments.html index 235405abf..fcccabbb5 100644 --- a/squarelet/templates/subscriptions/payments.html +++ b/squarelet/templates/subscriptions/payments.html @@ -21,7 +21,7 @@

    {% trans "Payment history" %}

    {% endblock %} {% block sidebar %} -
    + {% icon "chevron-left" %} {% trans "Back to subscriptions" %} diff --git a/squarelet/templates/subscriptions/update_card.html b/squarelet/templates/subscriptions/update_card.html index 4d27f7fdc..421d67339 100644 --- a/squarelet/templates/subscriptions/update_card.html +++ b/squarelet/templates/subscriptions/update_card.html @@ -18,7 +18,6 @@

    {% trans "Change payment card" %}

    {% endblock header %} {% block main %} - {% url 'organizations:subscriptions' organization.slug as subscriptions_url %} {% if card %}
    {% icon "credit-card" %} @@ -51,7 +50,7 @@

    {% trans "Change payment card" %}

    {% trans "Update card" %} - + {% trans "Cancel" %}
    diff --git a/squarelet/templates/subscriptions/update_receipt_email.html b/squarelet/templates/subscriptions/update_receipt_email.html index 9035dabe1..297834744 100644 --- a/squarelet/templates/subscriptions/update_receipt_email.html +++ b/squarelet/templates/subscriptions/update_receipt_email.html @@ -17,7 +17,6 @@

    {% trans "Change receipt emails" %}

    {% endblock header %} {% block main %} - {% url 'organizations:subscriptions' organization.slug as subscriptions_url %} {% if card %}
    {% icon "credit-card" %} @@ -38,7 +37,7 @@

    {% trans "Change receipt emails" %}

    {% trans "Update email" %} - + {% trans "Cancel" %}
    diff --git a/squarelet/templates/subscriptions/update_subscription_frequency.html b/squarelet/templates/subscriptions/update_subscription_frequency.html index eba22ab72..b2596819c 100644 --- a/squarelet/templates/subscriptions/update_subscription_frequency.html +++ b/squarelet/templates/subscriptions/update_subscription_frequency.html @@ -16,7 +16,6 @@

    {% trans "Change billing frequency" %}

    {% endblock header %} {% block main %} - {% url 'organizations:subscriptions' organization.slug as subscriptions_url %} {% with plan=subscription.plan currentfreq=plan.annual|yesno:_("annual,monthly") newfreq=plan.annual|yesno:_("monthly,annual") %} {% crispy form form.helper %} @@ -43,7 +42,7 @@

    - + {% blocktrans with frequency=currentfreq %} Keep {{ frequency }} {% endblocktrans %} From bf0445291d96d3dae36c91d15dec236646799acc Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Tue, 7 Jul 2026 15:00:20 -0400 Subject: [PATCH 61/86] Fixes --- squarelet/organizations/urls.py | 2 +- squarelet/subscriptions/views.py | 2 +- squarelet/templates/users/user_detail.html | 2 +- squarelet/users/urls.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/squarelet/organizations/urls.py b/squarelet/organizations/urls.py index 782de7249..101c8a2df 100644 --- a/squarelet/organizations/urls.py +++ b/squarelet/organizations/urls.py @@ -35,7 +35,7 @@ ), path("/card/", view=views.UpdateCard.as_view(), name="update-card"), path( - "/receipts/", + "/receipt-email/", view=views.UpdateReceiptEmail.as_view(), name="update-receipt-email", ), diff --git a/squarelet/subscriptions/views.py b/squarelet/subscriptions/views.py index 5df26632e..8d58b75d7 100644 --- a/squarelet/subscriptions/views.py +++ b/squarelet/subscriptions/views.py @@ -130,7 +130,7 @@ def get_context_data(self, **kwargs): return context -class BaseUpdateReceiptEmail(OrganizationPermissionMixin, UpdateView): +class BaseUpdateReceiptEmail(UpdateView): form_class = UpdateReceiptEmailForm template_name = "subscriptions/update_receipt_email.html" diff --git a/squarelet/templates/users/user_detail.html b/squarelet/templates/users/user_detail.html index d8121138b..8d9c83c8c 100644 --- a/squarelet/templates/users/user_detail.html +++ b/squarelet/templates/users/user_detail.html @@ -138,7 +138,7 @@

    {% trans "Plans" %}

    {% icon "gear" %} {% trans "Manage plans" %}
    - + {% icon "log" %} {% trans "View payments" %} diff --git a/squarelet/users/urls.py b/squarelet/users/urls.py index af9268735..f16c73c25 100644 --- a/squarelet/users/urls.py +++ b/squarelet/users/urls.py @@ -66,7 +66,7 @@ name="update-frequency", ), path( - "/receipts/", + "/receipt-email/", view=views.UpdateReceiptEmail.as_view(), name="update-receipt-email", ), From c0d193bd4185daae78edc138ed18108b8556f338 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Tue, 7 Jul 2026 15:03:21 -0400 Subject: [PATCH 62/86] Namespace redirect urls --- squarelet/subscriptions/views.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/squarelet/subscriptions/views.py b/squarelet/subscriptions/views.py index 8d58b75d7..b9e8329b0 100644 --- a/squarelet/subscriptions/views.py +++ b/squarelet/subscriptions/views.py @@ -98,7 +98,7 @@ def _is_ajax(self): def form_valid(self, form): organization = self.object user = self.request.user - redirect_url = reverse("organizations:subscriptions", args=[organization.slug]) + redirect_url = reverse(f"{self.subject}:subscriptions", args=[organization.slug]) token = form.cleaned_data["stripe_token"] try: organization.save_card(token, user) @@ -107,7 +107,7 @@ def form_valid(self, form): if self._is_ajax(): return JsonResponse({"error": user_message}, status=400) messages.error(self.request, f"Payment error: {user_message}") - return redirect(organization) + return redirect(redirect_url) else: success_msg = _("Credit card updated") if self._is_ajax(): @@ -115,7 +115,7 @@ def form_valid(self, form): {"redirect": redirect_url, "message": str(success_msg)} ) messages.success(self.request, success_msg) - return redirect(organization) + return redirect(redirect_url) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -136,7 +136,7 @@ class BaseUpdateReceiptEmail(UpdateView): 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(f"{self.subject}:subscriptions", slug=self.object.slug) def get_initial(self): return { @@ -172,7 +172,7 @@ def form_valid(self, form): if subscription: organization.remove_subscription(subscription) messages.success(self.request, _("Subscription cancelled.")) - return redirect("organizations:subscriptions", slug=self.object.slug) + return redirect(f"{self.subject}:subscriptions", slug=self.object.slug) class BasePaymentsList(ListView): From c7028c3c168715f2fb6a2cd2d7b64431388dcffb Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Tue, 7 Jul 2026 15:03:38 -0400 Subject: [PATCH 63/86] Vary template copy by subject --- squarelet/templates/subscriptions/manage_subscriptions.html | 6 +++++- squarelet/templates/subscriptions/payments.html | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/squarelet/templates/subscriptions/manage_subscriptions.html b/squarelet/templates/subscriptions/manage_subscriptions.html index 804ca77d7..3bc03761b 100644 --- a/squarelet/templates/subscriptions/manage_subscriptions.html +++ b/squarelet/templates/subscriptions/manage_subscriptions.html @@ -22,7 +22,11 @@

    {% trans "Subscriptions" %}

    {% block sidebar %} {% icon "chevron-left" %} - {% trans "Back to team overview" %} + {% if subject == "organizations" %} + {% trans "Back to team overview" %} + {% else %} + {% trans "Back to your account" %} + {% endif %}
    • diff --git a/squarelet/templates/subscriptions/payments.html b/squarelet/templates/subscriptions/payments.html index fcccabbb5..da8ea4533 100644 --- a/squarelet/templates/subscriptions/payments.html +++ b/squarelet/templates/subscriptions/payments.html @@ -23,7 +23,11 @@

      {% trans "Payment history" %}

      {% block sidebar %} {% icon "chevron-left" %} - {% trans "Back to subscriptions" %} + {% if subject == "users" %} + {% trans "Back to your subscriptions" %} + {% else %} + {% trans "Back to subscriptions" %} + {% endif %} {% endblock sidebar %} From cf3aad4d882992056e3e7938b330bf59b6d7700a Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Tue, 7 Jul 2026 15:16:28 -0400 Subject: [PATCH 64/86] Rename TS and CSS files to match templates --- ...ization_managesubscriptions.css => manage_subscriptions.css} | 0 frontend/views/manage_subscriptions.ts | 2 ++ frontend/views/organization_managesubscriptions.ts | 2 -- frontend/views/{organization_payments.ts => payments.ts} | 0 frontend/views/{organization_updatecard.ts => update_card.ts} | 0 squarelet/templates/subscriptions/manage_subscriptions.html | 2 +- squarelet/templates/subscriptions/payments.html | 2 +- squarelet/templates/subscriptions/update_card.html | 2 +- 8 files changed, 5 insertions(+), 5 deletions(-) rename frontend/css/{organization_managesubscriptions.css => manage_subscriptions.css} (100%) create mode 100644 frontend/views/manage_subscriptions.ts delete mode 100644 frontend/views/organization_managesubscriptions.ts rename frontend/views/{organization_payments.ts => payments.ts} (100%) rename frontend/views/{organization_updatecard.ts => update_card.ts} (100%) diff --git a/frontend/css/organization_managesubscriptions.css b/frontend/css/manage_subscriptions.css similarity index 100% rename from frontend/css/organization_managesubscriptions.css rename to frontend/css/manage_subscriptions.css diff --git a/frontend/views/manage_subscriptions.ts b/frontend/views/manage_subscriptions.ts new file mode 100644 index 000000000..d8e9d17ee --- /dev/null +++ b/frontend/views/manage_subscriptions.ts @@ -0,0 +1,2 @@ +import "@/css/sidebar_layout.css"; +import "@/css/manage_subscriptions.css"; diff --git a/frontend/views/organization_managesubscriptions.ts b/frontend/views/organization_managesubscriptions.ts deleted file mode 100644 index 13536e73b..000000000 --- a/frontend/views/organization_managesubscriptions.ts +++ /dev/null @@ -1,2 +0,0 @@ -import "@/css/sidebar_layout.css"; -import "@/css/organization_managesubscriptions.css"; diff --git a/frontend/views/organization_payments.ts b/frontend/views/payments.ts similarity index 100% rename from frontend/views/organization_payments.ts rename to frontend/views/payments.ts diff --git a/frontend/views/organization_updatecard.ts b/frontend/views/update_card.ts similarity index 100% rename from frontend/views/organization_updatecard.ts rename to frontend/views/update_card.ts diff --git a/squarelet/templates/subscriptions/manage_subscriptions.html b/squarelet/templates/subscriptions/manage_subscriptions.html index 3bc03761b..a6ec4486c 100644 --- a/squarelet/templates/subscriptions/manage_subscriptions.html +++ b/squarelet/templates/subscriptions/manage_subscriptions.html @@ -6,7 +6,7 @@ {% block vite %} {{ block.super }} -{% vite_asset "frontend/views/organization_managesubscriptions.ts" %} +{% vite_asset "frontend/views/manage_subscriptions.ts" %} {% endblock vite %} {% block layout_id %}manage_billing{% endblock %} diff --git a/squarelet/templates/subscriptions/payments.html b/squarelet/templates/subscriptions/payments.html index da8ea4533..e82d10af5 100644 --- a/squarelet/templates/subscriptions/payments.html +++ b/squarelet/templates/subscriptions/payments.html @@ -6,7 +6,7 @@ {% block vite %} {{ block.super }} -{% vite_asset "frontend/views/organization_payments.ts" %} +{% vite_asset "frontend/views/payments.ts" %} {% endblock vite %} diff --git a/squarelet/templates/subscriptions/update_card.html b/squarelet/templates/subscriptions/update_card.html index 421d67339..bdf391baa 100644 --- a/squarelet/templates/subscriptions/update_card.html +++ b/squarelet/templates/subscriptions/update_card.html @@ -6,7 +6,7 @@ {% block vite %} {{ block.super }} -{% vite_asset "frontend/views/organization_updatecard.ts" %} +{% vite_asset "frontend/views/update_card.ts" %} {% endblock vite %} {% block layout_id %}update_card{% endblock %} From b89d763486c8a0da3b5485e64befa21c55cf8a78 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Tue, 7 Jul 2026 15:16:48 -0400 Subject: [PATCH 65/86] Remove unused imports --- squarelet/templates/subscriptions/cancel_subscription.html | 2 +- squarelet/templates/subscriptions/update_receipt_email.html | 6 +----- .../subscriptions/update_subscription_frequency.html | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/squarelet/templates/subscriptions/cancel_subscription.html b/squarelet/templates/subscriptions/cancel_subscription.html index a4744505d..3edbd3ac0 100644 --- a/squarelet/templates/subscriptions/cancel_subscription.html +++ b/squarelet/templates/subscriptions/cancel_subscription.html @@ -1,5 +1,5 @@ {% extends "layouts/modal_layout.html" %} -{% load django_vite i18n crispy_forms_tags %} +{% load i18n crispy_forms_tags %} {% load icon %} {% block title %}{{ subscription.plan.name }} | Cancel Subscription{% endblock %} diff --git a/squarelet/templates/subscriptions/update_receipt_email.html b/squarelet/templates/subscriptions/update_receipt_email.html index 297834744..9e48fb62e 100644 --- a/squarelet/templates/subscriptions/update_receipt_email.html +++ b/squarelet/templates/subscriptions/update_receipt_email.html @@ -1,13 +1,9 @@ {% extends "layouts/modal_layout.html" %} -{% load django_vite i18n crispy_forms_tags %} +{% load i18n crispy_forms_tags %} {% load icon %} {% block title %}{{ organization.name }} | Update Receipt Emails{% endblock %} -{% block vite %} -{{ block.super }} -{% endblock vite %} - {% block layout_id %}update_receipt_emails{% endblock %} {% block header %} diff --git a/squarelet/templates/subscriptions/update_subscription_frequency.html b/squarelet/templates/subscriptions/update_subscription_frequency.html index b2596819c..fc074aefd 100644 --- a/squarelet/templates/subscriptions/update_subscription_frequency.html +++ b/squarelet/templates/subscriptions/update_subscription_frequency.html @@ -1,5 +1,5 @@ {% extends "layouts/modal_layout.html" %} -{% load django_vite i18n crispy_forms_tags humanize %} +{% load i18n crispy_forms_tags humanize %} {% load icon %} {% block title %}{{ subscription.plan.name }} | Change Billing Frequency{% endblock %} From 597660ea78def1eb4188d8f5b9f6d97009774593 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Tue, 7 Jul 2026 15:20:29 -0400 Subject: [PATCH 66/86] Mobile styles and fixes --- frontend/css/manage_subscriptions.css | 8 ++++++++ frontend/css/modal_layout.css | 4 ++++ frontend/css/plan_card.css | 7 ++++++- .../templates/subscriptions/update_receipt_email.html | 2 +- 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/frontend/css/manage_subscriptions.css b/frontend/css/manage_subscriptions.css index f951de80a..d5bbe3003 100644 --- a/frontend/css/manage_subscriptions.css +++ b/frontend/css/manage_subscriptions.css @@ -28,6 +28,8 @@ .subscription-row { display: flex; + flex-wrap: wrap; + gap: 0.25rem 0; justify-content: space-between; align-items: center; } @@ -37,6 +39,12 @@ gap: 1rem; } +@media (max-width: 28rem) { + .subscription-group { + flex: 1 0 100%; + } +} + .subscription-detail { flex: 0 1 auto; font-size: var(--font-sm); diff --git a/frontend/css/modal_layout.css b/frontend/css/modal_layout.css index 8a7f15ccd..27ba0aea6 100644 --- a/frontend/css/modal_layout.css +++ b/frontend/css/modal_layout.css @@ -1,3 +1,7 @@ +.modal-layout { + box-sizing: border-box; +} + .modal-layout header { display: flex; flex-direction: row; diff --git a/frontend/css/plan_card.css b/frontend/css/plan_card.css index facd4297b..0309ee75a 100644 --- a/frontend/css/plan_card.css +++ b/frontend/css/plan_card.css @@ -59,8 +59,9 @@ a.plan-name { flex: 1 0 100%; padding: 0; display: flex; + flex-wrap: wrap; align-items: center; - gap: 1rem; + gap: 0.25rem 1rem; margin: 0.25rem 0; } @@ -130,6 +131,10 @@ a.plan-name { .plan-benefits.checklist { column-count: 1; } + + .plan-card .caption { + flex: 1 0 auto; + } } .plan-benefits .checklist-item { diff --git a/squarelet/templates/subscriptions/update_receipt_email.html b/squarelet/templates/subscriptions/update_receipt_email.html index 9e48fb62e..c4b6e27b7 100644 --- a/squarelet/templates/subscriptions/update_receipt_email.html +++ b/squarelet/templates/subscriptions/update_receipt_email.html @@ -24,7 +24,7 @@

      {% trans "Change receipt emails" %}

      {% endif %} - + {% crispy form form.helper %}
      From 072993584b535f3ec3cb83307ab002018d437da4 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Tue, 7 Jul 2026 15:22:12 -0400 Subject: [PATCH 67/86] Lint and format --- squarelet/organizations/forms.py | 3 --- squarelet/subscriptions/forms.py | 2 +- squarelet/subscriptions/views.py | 5 +++-- squarelet/users/views.py | 4 +--- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/squarelet/organizations/forms.py b/squarelet/organizations/forms.py index 896b25932..6242d5e40 100644 --- a/squarelet/organizations/forms.py +++ b/squarelet/organizations/forms.py @@ -6,9 +6,6 @@ from django.urls import reverse 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 diff --git a/squarelet/subscriptions/forms.py b/squarelet/subscriptions/forms.py index c9f9db0f5..73fccc673 100644 --- a/squarelet/subscriptions/forms.py +++ b/squarelet/subscriptions/forms.py @@ -8,7 +8,7 @@ # Third Party from crispy_forms.helper import FormHelper -from crispy_forms.layout import Field as CrispyField, Fieldset, Layout +from crispy_forms.layout import Field as CrispyField, Layout # Squarelet from squarelet.core.forms import StripeForm diff --git a/squarelet/subscriptions/views.py b/squarelet/subscriptions/views.py index b9e8329b0..6a62908a9 100644 --- a/squarelet/subscriptions/views.py +++ b/squarelet/subscriptions/views.py @@ -15,7 +15,6 @@ # 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 ( @@ -98,7 +97,9 @@ def _is_ajax(self): def form_valid(self, form): organization = self.object user = self.request.user - redirect_url = reverse(f"{self.subject}:subscriptions", args=[organization.slug]) + redirect_url = reverse( + f"{self.subject}:subscriptions", args=[organization.slug] + ) token = form.cleaned_data["stripe_token"] try: organization.save_card(token, user) diff --git a/squarelet/users/views.py b/squarelet/users/views.py index ebbed9a9d..271e03231 100644 --- a/squarelet/users/views.py +++ b/squarelet/users/views.py @@ -14,7 +14,6 @@ ) 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, @@ -31,7 +30,6 @@ import logging import sys import time -from datetime import datetime # Third Party from allauth.account.utils import get_next_redirect_url, send_email_confirmation @@ -710,4 +708,4 @@ class UpdateReceiptEmail(IndividualSubscriptionView, BaseUpdateReceiptEmail): class PaymentsList(IndividualSubscriptionView, BasePaymentsList): - pass \ No newline at end of file + pass From 70c85c2afcf6e43faf67aee4f2590c15347ae4fa Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Tue, 7 Jul 2026 15:34:45 -0400 Subject: [PATCH 68/86] Use consistent color scheme for plan names --- frontend/css/manage_subscriptions.css | 6 +++++- frontend/css/plan_card.css | 12 ++++++++---- .../templates/organizations/includes/plan_card.html | 2 +- .../templates/subscriptions/cancel_subscription.html | 2 +- .../subscriptions/manage_subscriptions.html | 2 +- .../subscriptions/update_subscription_frequency.html | 2 +- 6 files changed, 17 insertions(+), 9 deletions(-) diff --git a/frontend/css/manage_subscriptions.css b/frontend/css/manage_subscriptions.css index d5bbe3003..2a4653a6c 100644 --- a/frontend/css/manage_subscriptions.css +++ b/frontend/css/manage_subscriptions.css @@ -15,10 +15,14 @@ margin: 0 0 auto; } -.subscription-card .plan-name { +.subscription-card .professional { color: var(--blue-4); } +.subscription-card .organization { + color: var(--purple-4); +} + .subscription-frequency { display: flex; flex-direction: row; diff --git a/frontend/css/plan_card.css b/frontend/css/plan_card.css index 0309ee75a..c8ff79b60 100644 --- a/frontend/css/plan_card.css +++ b/frontend/css/plan_card.css @@ -35,6 +35,14 @@ font-weight: var(--font-semibold, 600); } +.plan-name.professional { + color: var(--blue-4); +} + +.plan-name.organization { + color: var(--purple-4); +} + a.plan-name { color: var(--blue-3); } @@ -113,10 +121,6 @@ a.plan-name { gap: 0.75rem; } -.plan-name { - color: var(--blue-4, #1367d0); -} - /* Plan benefits checklist */ .plan-benefits.checklist { column-count: 2; diff --git a/squarelet/templates/organizations/includes/plan_card.html b/squarelet/templates/organizations/includes/plan_card.html index 00a84cfa3..7b447a605 100644 --- a/squarelet/templates/organizations/includes/plan_card.html +++ b/squarelet/templates/organizations/includes/plan_card.html @@ -43,7 +43,7 @@

      {% trans "Your plans" %}

      {% for subscription in subscriptions %}
    • - {{ subscription.plan.name }} + {{ subscription.plan.name }} {% icon "calendar" %} {{ subscription.plan.annual|yesno:_("Annual,Monthly") }} diff --git a/squarelet/templates/subscriptions/cancel_subscription.html b/squarelet/templates/subscriptions/cancel_subscription.html index 3edbd3ac0..09f59fc3c 100644 --- a/squarelet/templates/subscriptions/cancel_subscription.html +++ b/squarelet/templates/subscriptions/cancel_subscription.html @@ -20,7 +20,7 @@

      {% trans "Cancel subscription" %}

      {% crispy form form.helper %}
      -

      +

      {{ subscription.plan.name }}

      {% trans "Are you sure you want to cancel this plan?" %}

      diff --git a/squarelet/templates/subscriptions/manage_subscriptions.html b/squarelet/templates/subscriptions/manage_subscriptions.html index a6ec4486c..73d538c2d 100644 --- a/squarelet/templates/subscriptions/manage_subscriptions.html +++ b/squarelet/templates/subscriptions/manage_subscriptions.html @@ -64,7 +64,7 @@

      {% trans "Your plans" %}

      {% with annual=subscription.plan.annual cancelled=subscription.cancelled %}
      -

      {{ subscription.plan.name }}

      +

      {{ subscription.plan.name }}

      {% if cancelled %} {% trans "Cancelled" %} diff --git a/squarelet/templates/subscriptions/update_subscription_frequency.html b/squarelet/templates/subscriptions/update_subscription_frequency.html index fc074aefd..c57f19744 100644 --- a/squarelet/templates/subscriptions/update_subscription_frequency.html +++ b/squarelet/templates/subscriptions/update_subscription_frequency.html @@ -20,7 +20,7 @@

      {% trans "Change billing frequency" %}

      {% crispy form form.helper %}
      -

      +

      {{ plan.name }}

      From 8d33cb516a66bb333ee45f8733af31cb157c93bd Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Tue, 7 Jul 2026 16:32:33 -0400 Subject: [PATCH 69/86] Remove unnecessary checks --- squarelet/organizations/views/detail.py | 8 ++------ squarelet/users/views.py | 14 +++++--------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/squarelet/organizations/views/detail.py b/squarelet/organizations/views/detail.py index 7453c26a0..f3c6b0975 100644 --- a/squarelet/organizations/views/detail.py +++ b/squarelet/organizations/views/detail.py @@ -54,16 +54,12 @@ def get_context_data(self, **kwargs): # Get subscriptions, if any upgrade_plan = Plan.objects.get(slug="organization") - subscriptions = None - if hasattr(org, "subscriptions"): - subscriptions = org.subscriptions.all() - context["subscriptions"] = subscriptions + context["subscriptions"] = org.subscriptions.all() context["upgrade_plan"] = upgrade_plan context["member_count"] = len(users) context["admin_count"] = len(admins) - if subscriptions: - context.update(self._get_subscription_context(org)) + context.update(self._get_subscription_context(org)) # Any member (not just admins) may request verification, but only if # they have confirmed an email address on their account. diff --git a/squarelet/users/views.py b/squarelet/users/views.py index 271e03231..cb71ebf62 100644 --- a/squarelet/users/views.py +++ b/squarelet/users/views.py @@ -169,17 +169,13 @@ def get_context_data(self, **kwargs): # Get the current plan and subscription, if any individual_org = user.individual_organization upgrade_plan = Plan.objects.filter(slug="professional").first() - subscriptions = None - if hasattr(individual_org, "subscriptions"): - subscriptions = individual_org.subscriptions.all() - context["subscriptions"] = subscriptions + context["subscriptions"] = individual_org.subscriptions.all() context["upgrade_plan"] = upgrade_plan # 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 + 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 # 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 From 0cd7e70aed1aaca2651addbc7284daf0fad9ef1f Mon Sep 17 00:00:00 2001 From: Allan Lasser Date: Fri, 10 Jul 2026 17:24:30 -0400 Subject: [PATCH 70/86] Fixes for individual payment pages 1. Use username for user payment routes, not individual_org slug. 2. Protect individual payment routes to just object owners or staff. --- squarelet/organizations/views/subscription.py | 16 +--- squarelet/subscriptions/views.py | 85 ++++++++++++------- .../organizations/includes/plan_card.html | 8 +- .../subscriptions/cancel_subscription.html | 2 +- .../subscriptions/manage_subscriptions.html | 10 +-- .../templates/subscriptions/payments.html | 2 +- .../templates/subscriptions/update_card.html | 2 +- .../subscriptions/update_receipt_email.html | 2 +- .../update_subscription_frequency.html | 2 +- squarelet/templates/users/user_detail.html | 3 +- squarelet/users/tests/test_views.py | 77 +++++++++++++++++ squarelet/users/urls.py | 14 +-- squarelet/users/views.py | 22 +++-- 13 files changed, 171 insertions(+), 74 deletions(-) diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py index cd1aa15cc..e5b5b83f3 100644 --- a/squarelet/organizations/views/subscription.py +++ b/squarelet/organizations/views/subscription.py @@ -176,15 +176,10 @@ def get_initial(self): class OrgSubscriptionView(OrganizationPermissionMixin): """Base class for org subscription views.""" - queryset = Organization.objects.filter(individual=False) subject = "organizations" + individual = False permission_required = "organizations.can_edit_subscription" - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["subject"] = self.subject - return context - class ManageSubscriptions(OrgSubscriptionView, BaseManageSubscriptions): pass @@ -208,16 +203,11 @@ class CancelSubscription(OrgSubscriptionView, BaseCancelSubscription): class PaymentsList(PermissionRequiredMixin, BasePaymentsList): subject = "organizations" + individual = False 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_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["subject"] = self.subject - return context + return user.has_perm("organizations.can_view_charge", self.get_organization()) @method_decorator(xframe_options_sameorigin, name="dispatch") diff --git a/squarelet/subscriptions/views.py b/squarelet/subscriptions/views.py index 6a62908a9..19fc49703 100644 --- a/squarelet/subscriptions/views.py +++ b/squarelet/subscriptions/views.py @@ -1,7 +1,7 @@ # Django from django.contrib import messages from django.http.response import JsonResponse -from django.shortcuts import redirect +from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -25,7 +25,50 @@ ) -class BaseManageSubscriptions(DetailView): +class SubscriptionObjectMixin: + """Resolve the organization for a subscription view and build the + subject-scoped URLs used to move between its pages. + + Organization views key these URLs on the organization slug. User + views key them on the member's username — the individual organization + is an implementation detail that is never exposed in the URL — by + setting ``subject_url_kwarg = "username"`` and overriding + ``get_organization``. + """ + + subject = None + subject_url_kwarg = "slug" + individual = False + + def get_organization(self): + return get_object_or_404( + Organization, + individual=self.individual, + slug=self.kwargs[self.subject_url_kwarg], + ) + + def get_object(self, queryset=None): + # pylint: disable=unused-argument + return self.get_organization() + + def get_subject_url_kwargs(self): + return {self.subject_url_kwarg: self.kwargs[self.subject_url_kwarg]} + + def reverse_subject(self, name, **kwargs): + """Reverse a subject-scoped URL (e.g. ``users:subscriptions``).""" + return reverse( + f"{self.subject}:{name}", + kwargs={**self.get_subject_url_kwargs(), **kwargs}, + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["subject"] = self.subject + context["subject_slug"] = self.kwargs[self.subject_url_kwarg] + return context + + +class BaseManageSubscriptions(SubscriptionObjectMixin, DetailView): template_name = "subscriptions/manage_subscriptions.html" def get_context_data(self, **kwargs): @@ -63,18 +106,10 @@ def get_context_data(self, **kwargs): return context -class BaseUpdateSubscriptionFrequency(UpdateView): +class BaseUpdateSubscriptionFrequency(SubscriptionObjectMixin, 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) @@ -85,7 +120,7 @@ def get_context_data(self, **kwargs): return context -class BaseUpdateCard(UpdateView): +class BaseUpdateCard(SubscriptionObjectMixin, UpdateView): """Update the credit card on file for an organization.""" form_class = CardForm @@ -97,9 +132,7 @@ def _is_ajax(self): def form_valid(self, form): organization = self.object user = self.request.user - redirect_url = reverse( - f"{self.subject}:subscriptions", args=[organization.slug] - ) + redirect_url = self.reverse_subject("subscriptions") token = form.cleaned_data["stripe_token"] try: organization.save_card(token, user) @@ -131,13 +164,13 @@ def get_context_data(self, **kwargs): return context -class BaseUpdateReceiptEmail(UpdateView): +class BaseUpdateReceiptEmail(SubscriptionObjectMixin, 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(f"{self.subject}:subscriptions", slug=self.object.slug) + return redirect(self.reverse_subject("subscriptions")) def get_initial(self): return { @@ -147,18 +180,10 @@ def get_initial(self): } -class BaseCancelSubscription(UpdateView): +class BaseCancelSubscription(SubscriptionObjectMixin, 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() @@ -173,19 +198,19 @@ def form_valid(self, form): if subscription: organization.remove_subscription(subscription) messages.success(self.request, _("Subscription cancelled.")) - return redirect(f"{self.subject}:subscriptions", slug=self.object.slug) + return redirect(self.reverse_subject("subscriptions")) -class BasePaymentsList(ListView): +class BasePaymentsList(SubscriptionObjectMixin, ListView): template_name = "subscriptions/payments.html" paginate_by = 20 def get_queryset(self): - return Charge.objects.filter(organization__slug=self.kwargs["slug"]) + return Charge.objects.filter(organization=self.get_organization()) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["organization"] = Organization.objects.get(slug=self.kwargs["slug"]) + context["organization"] = self.get_organization() return context diff --git a/squarelet/templates/organizations/includes/plan_card.html b/squarelet/templates/organizations/includes/plan_card.html index 7b447a605..fd8192e18 100644 --- a/squarelet/templates/organizations/includes/plan_card.html +++ b/squarelet/templates/organizations/includes/plan_card.html @@ -4,7 +4,7 @@ Plan card partial. Args: - org — subject Organization (used for payment / receipts URLs). + subject_slug — URL key for the subject (org slug or member username). plans — list of own Plans or empty list. inherited_plans. — list of (source_org, plan) tuples; may be empty. upgrade_plan — Plan to advertise when there is no own plan (optional). @@ -26,7 +26,7 @@

      {% trans "Payment card" %}

      {{ card.brand }} {% trans "ending in" %} {{ card.last4 }} - + {% trans "Add new card" %}
      @@ -55,7 +55,7 @@

      {% trans "Your plans" %}

      {% trans "Cancelled" %}
      {% else %} - + {% icon "gear" %} {% trans "Manage" %} @@ -76,7 +76,7 @@

      {% trans "Your plans" %}

      {% endif %}

      {% if can_edit_subscription %} - {% trans "Upgrade" %} + {% trans "Upgrade" %} {% endif %}
      {% if upgrade_plan %} diff --git a/squarelet/templates/subscriptions/cancel_subscription.html b/squarelet/templates/subscriptions/cancel_subscription.html index 09f59fc3c..b013c8d3d 100644 --- a/squarelet/templates/subscriptions/cancel_subscription.html +++ b/squarelet/templates/subscriptions/cancel_subscription.html @@ -16,7 +16,7 @@

      {% trans "Cancel subscription" %}

      {% endblock header %} {% block main %} - {% url subject|add:':subscriptions' organization.slug as subscriptions_url %} + {% url subject|add:':subscriptions' subject_slug as subscriptions_url %} {% crispy form form.helper %}
      diff --git a/squarelet/templates/subscriptions/manage_subscriptions.html b/squarelet/templates/subscriptions/manage_subscriptions.html index 73d538c2d..0a06c0dab 100644 --- a/squarelet/templates/subscriptions/manage_subscriptions.html +++ b/squarelet/templates/subscriptions/manage_subscriptions.html @@ -74,7 +74,7 @@

      {{ annual|yesno:_("Annual,Monthly") }} - {% url subject|add:':update-frequency' organization.slug subscription.id as update_url %} + {% url subject|add:':update-frequency' subject_slug subscription.id as update_url %} {% blocktrans with annual|yesno:_("monthly,annual") as frequency %} Change to {{ frequency }} @@ -105,7 +105,7 @@

      {% if not cancelled %} - {% url subject|add:':cancel-subscription' organization.slug subscription.id as cancel_url %} + {% url subject|add:':cancel-subscription' subject_slug subscription.id as cancel_url %} {% icon "x-circle" %} {% trans "Cancel plan" %} @@ -145,7 +145,7 @@

      {% trans "Payment card" %}

      - + {% trans "Add new card" %}
      @@ -166,7 +166,7 @@

      {% trans "Receipt emails" %}

      - {% url subject|add:':update-receipt-email' organization.slug as receipt_email_url %} + {% url subject|add:':update-receipt-email' subject_slug as receipt_email_url %} {% trans "Change email" %} @@ -179,7 +179,7 @@

      {% trans "Receipt emails" %}

      {% trans "Recent payments" %}

      - {% url subject|add:':payments' organization.slug as payments_url %} + {% url subject|add:':payments' subject_slug as payments_url %} {% icon "log" %} {% trans "View all" %} diff --git a/squarelet/templates/subscriptions/payments.html b/squarelet/templates/subscriptions/payments.html index e82d10af5..d1b2e8540 100644 --- a/squarelet/templates/subscriptions/payments.html +++ b/squarelet/templates/subscriptions/payments.html @@ -21,7 +21,7 @@

      {% trans "Payment history" %}

      {% endblock %} {% block sidebar %} -
      + {% icon "chevron-left" %} {% if subject == "users" %} {% trans "Back to your subscriptions" %} diff --git a/squarelet/templates/subscriptions/update_card.html b/squarelet/templates/subscriptions/update_card.html index bdf391baa..28755285b 100644 --- a/squarelet/templates/subscriptions/update_card.html +++ b/squarelet/templates/subscriptions/update_card.html @@ -50,7 +50,7 @@

      {% trans "Change payment card" %}

      {% trans "Update card" %} -
      + {% trans "Cancel" %} diff --git a/squarelet/templates/subscriptions/update_receipt_email.html b/squarelet/templates/subscriptions/update_receipt_email.html index c4b6e27b7..9e85d03e1 100644 --- a/squarelet/templates/subscriptions/update_receipt_email.html +++ b/squarelet/templates/subscriptions/update_receipt_email.html @@ -33,7 +33,7 @@

      {% trans "Change receipt emails" %}

      {% trans "Update email" %} - + {% trans "Cancel" %} diff --git a/squarelet/templates/subscriptions/update_subscription_frequency.html b/squarelet/templates/subscriptions/update_subscription_frequency.html index c57f19744..87f4ec108 100644 --- a/squarelet/templates/subscriptions/update_subscription_frequency.html +++ b/squarelet/templates/subscriptions/update_subscription_frequency.html @@ -42,7 +42,7 @@

      - + {% blocktrans with frequency=currentfreq %} Keep {{ frequency }} {% endblocktrans %} diff --git a/squarelet/templates/users/user_detail.html b/squarelet/templates/users/user_detail.html index 8d9c83c8c..1a9c8134c 100644 --- a/squarelet/templates/users/user_detail.html +++ b/squarelet/templates/users/user_detail.html @@ -144,8 +144,7 @@

      {% trans "Plans" %}

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

    {% crispy form form.helper %} -
  • - - {% icon "gear" %} - {% trans "Payment settings" %} - -
  • - -
  • - - {% icon "log" %} - {% trans "Recent payments" %} - -
  • - -{% endblock sidebar %} - -{% block main %} -
    -
    -
    -

    {% trans "Your plans" %}

    -
    -
    - - {% for subscription in subscriptions %} - {% with annual=subscription.plan.annual %} -
    -
    -

    {{ subscription.plan.name }}

    - -
    -
    -
    - - - {% icon "tag" %} - - - ${{ subscription.cost|floatformat:2|intcomma }} - {{ annual|yesno:_("per year,per month") }} - - - - - {% icon "calendar" %} - - - {{ subscription.plan.cancelled|yesno:_("Ends on,Renews on") }} - {{ subscription.next_date }} - - -
    - - {% icon "x-circle" %} - {% trans "Cancel plan" %} - -
    -
    - {% endwith %} - {% endfor %} - -{% endif %} -
    - {% trans "Current plan" %}: {{ organization.plan|default:"Free" }} - {% if organization.subscription.cancelled %} -
    - {% blocktrans with update_on=organization.subscription.update_on|date:"m/d/Y" %} - Subscription ends on {{ update_on }} - {% endblocktrans %} -
    - {% endif %} -
    - - {% crispy form form.helper %} - {% if form.stripe_token %}
    Cost: From 154a5ed9948fb0074956f491090f303a9b8067d2 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Wed, 22 Jul 2026 10:23:49 -0400 Subject: [PATCH 74/86] Use new payment brand and last4 fields --- squarelet/subscriptions/views.py | 13 ++++++------- .../templates/organizations/includes/plan_card.html | 4 ++-- .../organizations/organization_detail.html | 3 +-- .../subscriptions/manage_subscriptions.html | 4 ++-- squarelet/templates/subscriptions/update_card.html | 4 ++-- squarelet/users/views.py | 2 +- 6 files changed, 14 insertions(+), 16 deletions(-) diff --git a/squarelet/subscriptions/views.py b/squarelet/subscriptions/views.py index b309d572b..8257838ec 100644 --- a/squarelet/subscriptions/views.py +++ b/squarelet/subscriptions/views.py @@ -16,7 +16,6 @@ # 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, @@ -83,9 +82,9 @@ def get_context_data(self, **kwargs): context["subscriptions"] = subscriptions # Get card on file - details = self.object.customer().payment_details - context["card"] = details - context["card_brand"] = _payment_brand(details) if details else "" + customer = self.object.customer() + context["card_brand"] = customer.payment_brand + context["card_last4"] = customer.payment_last4 # Get all receipt emails context["receipt_emails"] = self.object.receipt_emails.all() @@ -149,9 +148,9 @@ def form_valid(self, form): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - details = self.object.customer().payment_details - context["card"] = details - context["card_brand"] = _payment_brand(details) if details else "" + customer = self.object.customer() + context["card_brand"] = customer.payment_brand + context["card_last4"] = customer.payment_last4 return context diff --git a/squarelet/templates/organizations/includes/plan_card.html b/squarelet/templates/organizations/includes/plan_card.html index 9b9de4296..51287c044 100644 --- a/squarelet/templates/organizations/includes/plan_card.html +++ b/squarelet/templates/organizations/includes/plan_card.html @@ -17,13 +17,13 @@
    {% if subscriptions %}
    {% endif %} diff --git a/squarelet/templates/subscriptions/manage_subscriptions.html b/squarelet/templates/subscriptions/manage_subscriptions.html index 0a06c0dab..8b35d6bb2 100644 --- a/squarelet/templates/subscriptions/manage_subscriptions.html +++ b/squarelet/templates/subscriptions/manage_subscriptions.html @@ -129,7 +129,7 @@

    {% trans "Payment settings" %}

    - {% if card %} + {% if card_brand and card_last4 %}

    {% trans "Payment card" %}

    @@ -138,7 +138,7 @@

    {% trans "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 %} {{ brand }} ending in {{ last4 }} {% endblocktrans %} diff --git a/squarelet/templates/subscriptions/update_card.html b/squarelet/templates/subscriptions/update_card.html index 17153fac5..2ee29d769 100644 --- a/squarelet/templates/subscriptions/update_card.html +++ b/squarelet/templates/subscriptions/update_card.html @@ -18,11 +18,11 @@

    {% trans "Change payment card" %}

    {% endblock header %} {% block main %} - {% if card %} + {% if card_brand and card_last4 %}
    {% 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 ffd6394cf..02a037faa 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, _payment_brand +from squarelet.organizations.models.payment import Plan from squarelet.organizations.views import UpdateSubscription from squarelet.services.models import Service from squarelet.subscriptions.views import ( From 95de17a55ad91672bb3eca94de4cd4a96948bdf5 Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Wed, 22 Jul 2026 10:28:21 -0400 Subject: [PATCH 75/86] Consolidate subscriptions module into payments --- squarelet/organizations/views/subscription.py | 2 +- squarelet/payments/forms.py | 87 +++++++ squarelet/payments/views.py | 220 ++++++++++++++++- squarelet/subscriptions/__init__.py | 0 squarelet/subscriptions/forms.py | 98 -------- squarelet/subscriptions/views.py | 221 ------------------ squarelet/users/views.py | 4 +- 7 files changed, 306 insertions(+), 326 deletions(-) delete mode 100644 squarelet/subscriptions/__init__.py delete mode 100644 squarelet/subscriptions/forms.py delete mode 100644 squarelet/subscriptions/views.py diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py index e5b5b83f3..8aa18a059 100644 --- a/squarelet/organizations/views/subscription.py +++ b/squarelet/organizations/views/subscription.py @@ -51,7 +51,7 @@ handle_subscription_deleted, handle_subscription_updated, ) -from squarelet.subscriptions.views import ( +from squarelet.payments.views import ( BaseCancelSubscription, BaseManageSubscriptions, BasePaymentsList, diff --git a/squarelet/payments/forms.py b/squarelet/payments/forms.py index 8ebb67ec3..5c26c0972 100644 --- a/squarelet/payments/forms.py +++ b/squarelet/payments/forms.py @@ -2,20 +2,25 @@ # Django from django import forms +from django.core.validators import validate_email from django.db.models import Q from django.utils.translation import gettext_lazy as _ # Standard Library import logging +import re import sys # Third Party import stripe from allauth.account.adapter import get_adapter from allauth.account.utils import has_verified_email +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Field as CrispyField, Layout # Squarelet from squarelet.core.forms import StripeForm +from squarelet.core.layout import Field from squarelet.organizations.models import Organization, Plan from squarelet.organizations.models.payment import get_payment_brand from squarelet.users.forms import NewOrganizationModelChoiceField @@ -421,3 +426,85 @@ def save(self, user): "payment_method": self.cleaned_data.get("payment_method"), "stripe_token": self.cleaned_data.get("stripe_token"), } + + +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/payments/views.py b/squarelet/payments/views.py index b3c8993ec..1bb4def04 100644 --- a/squarelet/payments/views.py +++ b/squarelet/payments/views.py @@ -4,25 +4,41 @@ from django.contrib.auth.views import redirect_to_login from django.db import transaction from django.http import Http404, JsonResponse -from django.shortcuts import redirect +from django.shortcuts import get_object_or_404, 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, RedirectView, TemplateView +from django.views.generic import ( + DetailView, + ListView, + RedirectView, + TemplateView, + UpdateView, +) # Standard Library import logging import sys +from datetime import datetime # Third Party import stripe # Squarelet -from squarelet.organizations.models import Organization, Plan +from squarelet.core.utils import format_stripe_error +from squarelet.organizations.models import Charge, Organization, Plan from squarelet.organizations.models.payment import Subscription, get_payment_brand from squarelet.organizations.payments.base import PaymentActionRequired from squarelet.organizations.payments.exceptions import SubscriptionError +from squarelet.organizations.payments.factory import get_payment_provider from squarelet.organizations.tasks import add_to_waitlist -from squarelet.payments.forms import PlanPurchaseForm +from squarelet.payments.forms import ( + CancelSubscriptionForm, + CardForm, + PlanPurchaseForm, + UpdateReceiptEmailForm, + UpdateSubscriptionFrequencyForm, +) logger = logging.getLogger(__name__) @@ -405,3 +421,199 @@ def get_redirect_url(self, *args, **kwargs): except Plan.DoesNotExist: raise Http404("No Plan found matching the query") + + +class SubscriptionObjectMixin: + """Resolve the organization for a subscription view and build the + subject-scoped URLs used to move between its pages. + + Organization views key these URLs on the organization slug. User + views key them on the member's username — the individual organization + is an implementation detail that is never exposed in the URL — by + setting ``subject_url_kwarg = "username"`` and overriding + ``get_organization``. + """ + + subject = None + subject_url_kwarg = "slug" + individual = False + + def get_organization(self): + return get_object_or_404( + Organization, + individual=self.individual, + slug=self.kwargs[self.subject_url_kwarg], + ) + + def get_object(self, queryset=None): + # pylint: disable=unused-argument + return self.get_organization() + + def get_subject_url_kwargs(self): + return {self.subject_url_kwarg: self.kwargs[self.subject_url_kwarg]} + + def reverse_subject(self, name, **kwargs): + """Reverse a subject-scoped URL (e.g. ``users:subscriptions``).""" + return reverse( + f"{self.subject}:{name}", + kwargs={**self.get_subject_url_kwargs(), **kwargs}, + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["subject"] = self.subject + context["subject_slug"] = self.kwargs[self.subject_url_kwarg] + return context + + +class BaseManageSubscriptions(SubscriptionObjectMixin, 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() + context["card_brand"] = customer.payment_brand + context["card_last4"] = customer.payment_last4 + + # 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(SubscriptionObjectMixin, UpdateView): + form_class = UpdateSubscriptionFrequencyForm + template_name = "subscriptions/update_subscription_frequency.html" + + 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(SubscriptionObjectMixin, 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 = self.reverse_subject("subscriptions") + 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(redirect_url) + 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(redirect_url) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + customer = self.object.customer() + context["card_brand"] = customer.payment_brand + context["card_last4"] = customer.payment_last4 + + return context + + +class BaseUpdateReceiptEmail(SubscriptionObjectMixin, 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(self.reverse_subject("subscriptions")) + + def get_initial(self): + return { + "receipt_emails": ", ".join( + r.email for r in self.object.receipt_emails.all() + ), + } + + +class BaseCancelSubscription(SubscriptionObjectMixin, UpdateView): + form_class = CancelSubscriptionForm + template_name = "subscriptions/cancel_subscription.html" + + 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(self.reverse_subject("subscriptions")) + + +class BasePaymentsList(SubscriptionObjectMixin, ListView): + template_name = "subscriptions/payments.html" + paginate_by = 20 + + def get_queryset(self): + return Charge.objects.filter(organization=self.get_organization()) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["organization"] = self.get_organization() + 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/subscriptions/__init__.py b/squarelet/subscriptions/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/squarelet/subscriptions/forms.py b/squarelet/subscriptions/forms.py deleted file mode 100644 index 73fccc673..000000000 --- a/squarelet/subscriptions/forms.py +++ /dev/null @@ -1,98 +0,0 @@ -# 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, 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 deleted file mode 100644 index 8257838ec..000000000 --- a/squarelet/subscriptions/views.py +++ /dev/null @@ -1,221 +0,0 @@ -# Django -from django.contrib import messages -from django.http.response import JsonResponse -from django.shortcuts import get_object_or_404, 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.models import Charge, Organization -from squarelet.organizations.payments.factory import get_payment_provider -from squarelet.subscriptions.forms import ( - CancelSubscriptionForm, - CardForm, - UpdateReceiptEmailForm, - UpdateSubscriptionFrequencyForm, -) - - -class SubscriptionObjectMixin: - """Resolve the organization for a subscription view and build the - subject-scoped URLs used to move between its pages. - - Organization views key these URLs on the organization slug. User - views key them on the member's username — the individual organization - is an implementation detail that is never exposed in the URL — by - setting ``subject_url_kwarg = "username"`` and overriding - ``get_organization``. - """ - - subject = None - subject_url_kwarg = "slug" - individual = False - - def get_organization(self): - return get_object_or_404( - Organization, - individual=self.individual, - slug=self.kwargs[self.subject_url_kwarg], - ) - - def get_object(self, queryset=None): - # pylint: disable=unused-argument - return self.get_organization() - - def get_subject_url_kwargs(self): - return {self.subject_url_kwarg: self.kwargs[self.subject_url_kwarg]} - - def reverse_subject(self, name, **kwargs): - """Reverse a subject-scoped URL (e.g. ``users:subscriptions``).""" - return reverse( - f"{self.subject}:{name}", - kwargs={**self.get_subject_url_kwargs(), **kwargs}, - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["subject"] = self.subject - context["subject_slug"] = self.kwargs[self.subject_url_kwarg] - return context - - -class BaseManageSubscriptions(SubscriptionObjectMixin, 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() - context["card_brand"] = customer.payment_brand - context["card_last4"] = customer.payment_last4 - - # 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(SubscriptionObjectMixin, UpdateView): - form_class = UpdateSubscriptionFrequencyForm - template_name = "subscriptions/update_subscription_frequency.html" - - 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(SubscriptionObjectMixin, 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 = self.reverse_subject("subscriptions") - 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(redirect_url) - 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(redirect_url) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - customer = self.object.customer() - context["card_brand"] = customer.payment_brand - context["card_last4"] = customer.payment_last4 - - return context - - -class BaseUpdateReceiptEmail(SubscriptionObjectMixin, 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(self.reverse_subject("subscriptions")) - - def get_initial(self): - return { - "receipt_emails": ", ".join( - r.email for r in self.object.receipt_emails.all() - ), - } - - -class BaseCancelSubscription(SubscriptionObjectMixin, UpdateView): - form_class = CancelSubscriptionForm - template_name = "subscriptions/cancel_subscription.html" - - 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(self.reverse_subject("subscriptions")) - - -class BasePaymentsList(SubscriptionObjectMixin, ListView): - template_name = "subscriptions/payments.html" - paginate_by = 20 - - def get_queryset(self): - return Charge.objects.filter(organization=self.get_organization()) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["organization"] = self.get_organization() - 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/users/views.py b/squarelet/users/views.py index 02a037faa..c1eaa592b 100644 --- a/squarelet/users/views.py +++ b/squarelet/users/views.py @@ -51,8 +51,7 @@ from squarelet.organizations.models import Invitation, ReceiptEmail from squarelet.organizations.models.payment import Plan from squarelet.organizations.views import UpdateSubscription -from squarelet.services.models import Service -from squarelet.subscriptions.views import ( +from squarelet.payments.views import ( BaseCancelSubscription, BaseManageSubscriptions, BasePaymentsList, @@ -60,6 +59,7 @@ BaseUpdateReceiptEmail, BaseUpdateSubscriptionFrequency, ) +from squarelet.services.models import Service from squarelet.users.forms import ( SignupForm, UserAutologinPreferenceForm, From 58a646dca72e6df1a15b01159ea0bd4018db434c Mon Sep 17 00:00:00 2001 From: Daniel Nass Date: Wed, 22 Jul 2026 10:59:35 -0400 Subject: [PATCH 76/86] Throw an error if OrganizationPermissionMixin is used with a non-organization object --- squarelet/organizations/mixins.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/squarelet/organizations/mixins.py b/squarelet/organizations/mixins.py index dda15cfe6..b9ce5de9e 100644 --- a/squarelet/organizations/mixins.py +++ b/squarelet/organizations/mixins.py @@ -1,6 +1,9 @@ # Django from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin +# Squarelet +from squarelet.organizations.models.organization import Organization + class OrganizationAdminMixin(UserPassesTestMixin): """Only allow access to organization admins""" @@ -23,6 +26,8 @@ class OrganizationPermissionMixin(PermissionRequiredMixin): def has_permission(self): user = self.request.user obj = self.get_object() + assert isinstance(obj, Organization) + perms = self.get_permission_required() return all(user.has_perm(perm, obj) for perm in perms) From be4b6ab28b15275b026bcf91efcd25caa6d6c92f Mon Sep 17 00:00:00 2001 From: Allan Lasser Date: Wed, 22 Jul 2026 13:19:41 -0400 Subject: [PATCH 77/86] Adds a view to show payments summary across all organizations --- config/urls.py | 3 +- frontend/css/payments_hub.css | 5 + frontend/css/plan_card.css | 9 +- frontend/views/payments_hub.ts | 2 + squarelet/payments/tests/test_hub.py | 157 ++++++++++++++++++ squarelet/payments/views.py | 63 +++++++ .../organizations/organization_payment.html | 2 +- .../templates/payments/payments_hub.html | 68 ++++++++ 8 files changed, 300 insertions(+), 9 deletions(-) create mode 100644 frontend/css/payments_hub.css create mode 100644 frontend/views/payments_hub.ts create mode 100644 squarelet/payments/tests/test_hub.py create mode 100644 squarelet/templates/payments/payments_hub.html diff --git a/config/urls.py b/config/urls.py index 97258ce96..9627ac2af 100644 --- a/config/urls.py +++ b/config/urls.py @@ -19,7 +19,7 @@ OrganizationViewSet as FEOrganizationViewSet, ) from squarelet.organizations.viewsets import ChargeViewSet, OrganizationViewSet -from squarelet.payments.views import PlanDetailView, PlanRedirectView +from squarelet.payments.views import PaymentsHubView, PlanDetailView, PlanRedirectView from squarelet.users.fe_api.viewsets import UserViewSet as FEUserViewSet from squarelet.users.views import ( LoginView, @@ -66,6 +66,7 @@ def redirect_erh(request, path=""): path("plans//", PlanRedirectView.as_view(), name="plan_detail_id"), path("plans//", PlanRedirectView.as_view(), name="plan_detail_slug"), path("selectplan/", SelectPlanView.as_view(), name="select_plan"), + path("payments/", PaymentsHubView.as_view(), name="payments"), # Django Admin, use {% url 'admin:index' %} path(settings.ADMIN_URL, admin.site.urls), # User management diff --git a/frontend/css/payments_hub.css b/frontend/css/payments_hub.css new file mode 100644 index 000000000..1b32ba440 --- /dev/null +++ b/frontend/css/payments_hub.css @@ -0,0 +1,5 @@ +header .title { + display: flex; + align-items: center; + gap: 0.5rem; +} diff --git a/frontend/css/plan_card.css b/frontend/css/plan_card.css index c8ff79b60..a470b6947 100644 --- a/frontend/css/plan_card.css +++ b/frontend/css/plan_card.css @@ -125,7 +125,7 @@ a.plan-name { .plan-benefits.checklist { column-count: 2; column-gap: 0.5rem; - margin: 0; + margin: 0.5rem 0 0; padding: 0.5rem; border: 1px solid var(--gray-2, #d8dee2); border-radius: 0.5rem; @@ -166,12 +166,7 @@ a.plan-name { } .plan-upgrade p { - margin: 0 0 0.5rem; -} - -.plan-upgrade .plan-benefits { - border: none; - padding: 0; + margin: 0; } .plan-upgrade .plan-benefits .checklist-item .icon svg { diff --git a/frontend/views/payments_hub.ts b/frontend/views/payments_hub.ts new file mode 100644 index 000000000..1b43da1ca --- /dev/null +++ b/frontend/views/payments_hub.ts @@ -0,0 +1,2 @@ +import "@/css/sidebar_layout.css"; +import "@/css/payments_hub.css"; diff --git a/squarelet/payments/tests/test_hub.py b/squarelet/payments/tests/test_hub.py new file mode 100644 index 000000000..653df7f94 --- /dev/null +++ b/squarelet/payments/tests/test_hub.py @@ -0,0 +1,157 @@ +# Django +from django.urls import reverse + +# Third Party +import factory +import pytest + +# Squarelet +from squarelet.core.tests.mixins import ViewTestMixin +from squarelet.payments import views + + +@pytest.mark.django_db() +class TestPaymentsHubView(ViewTestMixin): + """Test the cross-organization payments hub.""" + + view = views.PaymentsHubView + url = "/payments/" + + def test_unauthenticated_user_redirected_to_login(self, rf): + """Anonymous users are redirected to the login page.""" + response = self.call_view(rf, user=None) + + assert response.status_code == 302 + assert "/accounts/login/" in response.url + + def test_includes_personal_account(self, rf, user_factory): + """The user's personal account is always the first account listed.""" + user = user_factory() + + response = self.call_view(rf, user) + + accounts = response.context_data["accounts"] + assert accounts[0]["organization"] == user.individual_organization + assert accounts[0]["history_url"] == reverse( + "users:payments", kwargs={"username": user.username} + ) + + def test_includes_admin_orgs_but_not_member_orgs( + self, rf, user_factory, organization_factory, membership_factory + ): + """Admin orgs appear; orgs where the user is only a member do not.""" + user = user_factory() + admin_org = organization_factory() + admin_org.add_creator(user) + member_org = organization_factory() + membership_factory(user=user, organization=member_org, admin=False) + + response = self.call_view(rf, user) + + orgs = [ + account["organization"] for account in response.context_data["accounts"] + ] + assert admin_org in orgs + assert member_org not in orgs + + def test_admin_org_links_to_org_payment_history( + self, rf, user_factory, organization_factory + ): + """Each admin org links out to its full org payment history.""" + user = user_factory() + org = organization_factory() + org.add_creator(user) + + response = self.call_view(rf, user) + + account = next( + a for a in response.context_data["accounts"] if a["organization"] == org + ) + assert account["history_url"] == reverse( + "organizations:payments", kwargs={"slug": org.slug} + ) + + def test_account_includes_card_and_management_urls( + self, rf, user_factory, organization_factory + ): + """Each account exposes its card on file and the URLs used to + manage the subscription and payment method.""" + user = user_factory() + org = organization_factory() + org.add_creator(user) + customer = org.customer() + customer.payment_brand = "Visa" + customer.payment_last4 = "4242" + customer.save() + + response = self.call_view(rf, user) + + account = next( + a for a in response.context_data["accounts"] if a["organization"] == org + ) + assert account["card_brand"] == "Visa" + assert account["card_last4"] == "4242" + assert account["manage_url"] == reverse( + "organizations:subscriptions", kwargs={"slug": org.slug} + ) + assert account["update_card_url"] == reverse( + "organizations:update-card", kwargs={"slug": org.slug} + ) + + def test_personal_account_management_urls_use_username(self, rf, user_factory): + """The personal account's management URLs are keyed on username.""" + user = user_factory() + + response = self.call_view(rf, user) + + account = response.context_data["accounts"][0] + assert account["organization"] == user.individual_organization + assert account["manage_url"] == reverse( + "users:subscriptions", kwargs={"username": user.username} + ) + assert account["update_card_url"] == reverse( + "users:update-card", kwargs={"username": user.username} + ) + + def test_shows_five_most_recent_payments_per_account( + self, rf, user_factory, organization_factory, charge_factory + ): + """Each account shows at most its five most recent charges, newest first.""" + user = user_factory() + org = organization_factory() + org.add_creator(user) + charge_factory.create_batch( + 7, organization=org, charge_id=factory.Sequence(lambda n: f"ch_recent_{n}") + ) + + response = self.call_view(rf, user) + + account = next( + a for a in response.context_data["accounts"] if a["organization"] == org + ) + payments = account["payments"] + assert len(payments) == 5 + # Newest first + created = [p.created_at for p in payments] + assert created == sorted(created, reverse=True) + + def test_does_not_leak_other_org_payments( + self, rf, user_factory, organization_factory, charge_factory + ): + """Charges from an org the user cannot view are not shown.""" + user = user_factory() + own_org = organization_factory() + own_org.add_creator(user) + other_org = organization_factory() + charge_factory.create_batch( + 3, + organization=other_org, + charge_id=factory.Sequence(lambda n: f"ch_other_{n}"), + ) + + response = self.call_view(rf, user) + + orgs = [ + account["organization"] for account in response.context_data["accounts"] + ] + assert other_org not in orgs diff --git a/squarelet/payments/views.py b/squarelet/payments/views.py index 1bb4def04..6bb26b401 100644 --- a/squarelet/payments/views.py +++ b/squarelet/payments/views.py @@ -1,6 +1,7 @@ # Django from django.conf import settings from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.views import redirect_to_login from django.db import transaction from django.http import Http404, JsonResponse @@ -423,6 +424,68 @@ def get_redirect_url(self, *args, **kwargs): raise Http404("No Plan found matching the query") +class PaymentsHubView(LoginRequiredMixin, TemplateView): + """Cross-organization payments hub. + + Shows the most recent payments for every account whose charges the user + can view — their personal account and any organization they administer + (``can_view_charge`` is admin-only). Each account links out to its full + payment history so the user can dig deeper. + """ + + template_name = "payments/payments_hub.html" + recent_limit = 5 + + def _subject_url(self, organization, name): + """Reverse a subject-scoped URL for the account. + + Organization pages are keyed on the org slug; the personal account's + pages live under the ``users`` namespace and are keyed on the + member's username. + """ + if organization.individual: + return reverse( + f"users:{name}", kwargs={"username": self.request.user.username} + ) + return reverse(f"organizations:{name}", kwargs={"slug": organization.slug}) + + def get_accounts(self): + """Build per-account context: the organization, its most recent + charges, the card on file, and the URLs used to manage it.""" + user = self.request.user + organizations = [user.individual_organization] + organizations += list( + user.organizations.filter(individual=False, memberships__admin=True) + .distinct() + .order_by("name") + ) + + accounts = [] + for organization in organizations: + customer = organization.customer() + accounts.append( + { + "organization": organization, + "payments": list( + organization.charges.order_by("-created_at")[ + : self.recent_limit + ] + ), + "card_brand": customer.payment_brand, + "card_last4": customer.payment_last4, + "history_url": self._subject_url(organization, "payments"), + "manage_url": self._subject_url(organization, "subscriptions"), + "update_card_url": self._subject_url(organization, "update-card"), + } + ) + return accounts + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["accounts"] = self.get_accounts() + return context + + class SubscriptionObjectMixin: """Resolve the organization for a subscription view and build the subject-scoped URLs used to move between its pages. diff --git a/squarelet/templates/organizations/organization_payment.html b/squarelet/templates/organizations/organization_payment.html index bcd85acd0..ada9a0971 100644 --- a/squarelet/templates/organizations/organization_payment.html +++ b/squarelet/templates/organizations/organization_payment.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% load i18n django_vite crispy_forms_tags planinfo %} +{% load i18n django_vite crispy_forms_tags planinfo icon %} {% block title %}{{ organization.reference_name }}{% endblock %} diff --git a/squarelet/templates/payments/payments_hub.html b/squarelet/templates/payments/payments_hub.html new file mode 100644 index 000000000..e156546a1 --- /dev/null +++ b/squarelet/templates/payments/payments_hub.html @@ -0,0 +1,68 @@ +{% extends "layouts/sidebar_layout.html" %} +{% load django_vite i18n %} +{% load icon %} + +{% block title %}{% trans "Payments" %}{% endblock %} + +{% block vite %} +{{ block.super }} +{% vite_asset "frontend/views/payments_hub.ts" %} +{% endblock vite %} + +{% block layout_id %}payments_hub{% endblock %} + +{% block header %} +
    +

    {% trans "Payments" %}

    + {% icon "chevron-right" %} +

    {% trans "Recent activity across your accounts" %}

    +
    +{% endblock %} + +{% block sidebar %} +
    + {% icon "chevron-left" %} + {% trans "Back to your account" %} + + +{% endblock sidebar %} + +{% block main %} + {% for account in accounts %} +
    +
    +
    +

    {{ account.organization.name }}

    + {% if account.organization.individual %} + {% trans "Personal" %} + {% endif %} +
    + +
    + + {% include "subscriptions/includes/receipts_card.html" with payments=account.payments %} +
    + {% endfor %} +{% endblock main %} From b339af822725d4c178c137af5ddb7817e5b226ba Mon Sep 17 00:00:00 2001 From: Allan Lasser Date: Wed, 22 Jul 2026 13:22:28 -0400 Subject: [PATCH 78/86] Update organization URLs --- squarelet/organizations/urls.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/squarelet/organizations/urls.py b/squarelet/organizations/urls.py index 101c8a2df..16368a8bc 100644 --- a/squarelet/organizations/urls.py +++ b/squarelet/organizations/urls.py @@ -1,5 +1,6 @@ # Django from django.urls import path +from django.views.generic.base import RedirectView # Local from . import views @@ -21,15 +22,12 @@ name="subscriptions", ), path( - "/payment/", view=views.UpdateSubscription.as_view(), name="payment" - ), - path( - "/cancel//", + "/subscriptions//cancel", view=views.CancelSubscription.as_view(), name="cancel-subscription", ), path( - "/update-frequency//", + "/subscriptions//update", view=views.UpdateSubscriptionFrequency.as_view(), name="update-frequency", ), @@ -40,6 +38,11 @@ name="update-receipt-email", ), path("/payments/", view=views.PaymentsList.as_view(), name="payments"), + path( + "/payment/", + view=RedirectView.as_view(pattern_name="organizations:subscriptions"), + name="payment", + ), path("/update/", view=views.Update.as_view(), name="update"), path( "/request-profile-change/", From 5e9625ed82b592134e5ed142fd463ce3b595e7db Mon Sep 17 00:00:00 2001 From: Allan Lasser Date: Thu, 23 Jul 2026 16:14:38 -0400 Subject: [PATCH 79/86] Update templates --- frontend/css/manage_subscriptions.css | 31 +++++- frontend/css/plan_card.css | 22 +++++ .../organizations/organization_detail.html | 14 +-- .../templates/payments/payments_hub.html | 6 +- .../subscriptions/manage_subscriptions.html | 99 ++++++++++--------- squarelet/templates/users/user_detail.html | 10 +- 6 files changed, 122 insertions(+), 60 deletions(-) diff --git a/frontend/css/manage_subscriptions.css b/frontend/css/manage_subscriptions.css index 2a4653a6c..2bb1086c7 100644 --- a/frontend/css/manage_subscriptions.css +++ b/frontend/css/manage_subscriptions.css @@ -51,12 +51,11 @@ .subscription-detail { flex: 0 1 auto; - font-size: var(--font-sm); - opacity: 0.7; display: flex; align-items: center; - gap: 0.25rem; + gap: 0.5rem; color: var(--gray-5); + fill: var(--gray-4); } .subscription-detail .icon { @@ -69,3 +68,29 @@ .subscription-detail .emails :not(:last-child):after { content: ","; } + +.account-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.remove-card-form { + margin: 0; +} + +.account-card { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.account-card .icon { + display: flex; + align-items: center; +} + +.account-card-brand { + text-transform: capitalize; +} \ No newline at end of file diff --git a/frontend/css/plan_card.css b/frontend/css/plan_card.css index a470b6947..f64f4f2f5 100644 --- a/frontend/css/plan_card.css +++ b/frontend/css/plan_card.css @@ -172,3 +172,25 @@ a.plan-name { .plan-upgrade .plan-benefits .checklist-item .icon svg { fill: var(--green-3); } + +.account-row { + display: flex; + justify-contents: space-between; + align-items: center; + gap: 1rem; +} + +.account-card { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.account-card .icon { + display: flex; + align-items: center; +} + +.account-card-brand { + text-transform: capitalize; +} \ No newline at end of file diff --git a/squarelet/templates/organizations/organization_detail.html b/squarelet/templates/organizations/organization_detail.html index 3da0cf916..1e47c355b 100644 --- a/squarelet/templates/organizations/organization_detail.html +++ b/squarelet/templates/organizations/organization_detail.html @@ -66,9 +66,9 @@

    {% trans "Staff control panel" %}

    {% endif %} {% if can_view_subscription %}
  • - + {% icon "credit-card" %} - {% trans "Plans" %} + {% trans "Billing" %}
  • {% endif %} @@ -269,15 +269,17 @@

    {% trans "Affiliations" %}

    {% endif %} {% if can_view_subscription %} -
    +
    -

    {% trans "Plans" %}

    - {% if can_edit_subscription %} +

    {% trans "Billing" %}

    + {% if can_edit_subscription or can_view_charge %}
    diff --git a/squarelet/templates/subscriptions/manage_subscriptions.html b/squarelet/templates/subscriptions/manage_subscriptions.html index 8b35d6bb2..ef57275a5 100644 --- a/squarelet/templates/subscriptions/manage_subscriptions.html +++ b/squarelet/templates/subscriptions/manage_subscriptions.html @@ -31,15 +31,22 @@

    {% trans "Subscriptions" %}

    -
    +
    -

    {% trans "Plans" %}

    +

    {% trans "Billing" %}

    {% icon "gear" %} - {% trans "Manage plans" %} + {% trans "Manage billing" %} {% icon "log" %} From e5c99e54b727a59e3f6b26c9c9dd711d7e9162a1 Mon Sep 17 00:00:00 2001 From: Allan Lasser Date: Thu, 23 Jul 2026 17:49:27 -0400 Subject: [PATCH 80/86] Adds handling for removing a payment method --- squarelet/organizations/tests/test_views.py | 172 ++++++++++++++++++ squarelet/organizations/urls.py | 5 + squarelet/organizations/views/__init__.py | 2 + squarelet/organizations/views/subscription.py | 5 + squarelet/payments/views.py | 43 +++++ .../organizations/includes/plan_card.html | 31 ++-- .../subscriptions/manage_subscriptions.html | 9 + .../templates/subscriptions/update_card.html | 10 + squarelet/users/urls.py | 5 + squarelet/users/views.py | 5 + 10 files changed, 275 insertions(+), 12 deletions(-) diff --git a/squarelet/organizations/tests/test_views.py b/squarelet/organizations/tests/test_views.py index fde957030..9777e1ce5 100644 --- a/squarelet/organizations/tests/test_views.py +++ b/squarelet/organizations/tests/test_views.py @@ -1334,6 +1334,178 @@ def test_post_remove_card_on_file( self.assert_message(messages.SUCCESS, "Credit card removed") +@pytest.mark.django_db() +class TestRemoveCard(ViewTestMixin): + """Test the Organization Remove Card view""" + + view = views.RemoveCard + url = "/organizations/{slug}/card/remove/" + + @staticmethod + def _mock_card_on_file(mocker, present=True): + """Patch the customer's card-on-file state.""" + mocker.patch( + "squarelet.organizations.models.Customer.payment_details", + new_callable=mocker.PropertyMock, + return_value=mocker.Mock() if present else None, + ) + + def test_post_remove_card(self, rf, organization_factory, user_factory, mocker): + """Removes the card and redirects when there is no active subscription""" + self._mock_card_on_file(mocker) + mocked_remove = mocker.patch( + "squarelet.organizations.models.Organization.remove_payment_method" + ) + user = user_factory() + organization = organization_factory(admins=[user]) + response = self.call_view(rf, user, {}, slug=organization.slug) + assert response.status_code == 302 + mocked_remove.assert_called_once() + self.assert_message(messages.SUCCESS, "Credit card removed") + + def test_post_remove_card_stripe_error( + self, rf, organization_factory, user_factory, mocker + ): + """A Stripe error while removing surfaces an error message""" + self._mock_card_on_file(mocker) + mocker.patch( + "squarelet.organizations.models.Organization.remove_payment_method", + side_effect=stripe.StripeError("boom"), + ) + user = user_factory() + organization = organization_factory(admins=[user]) + response = self.call_view(rf, user, {}, slug=organization.slug) + assert response.status_code == 302 + # pylint: disable=protected-access + level, message, _extra = self.request._messages.add.call_args.args + assert level == messages.ERROR + assert message.startswith("Payment error:") + + def test_no_card_on_file(self, rf, organization_factory, user_factory, mocker): + """Removal with no card on file errors and never calls remove""" + self._mock_card_on_file(mocker, present=False) + mocked_remove = mocker.patch( + "squarelet.organizations.models.Organization.remove_payment_method" + ) + user = user_factory() + organization = organization_factory(admins=[user]) + response = self.call_view(rf, user, {}, slug=organization.slug) + assert response.status_code == 302 + mocked_remove.assert_not_called() + self.assert_message(messages.ERROR, "You do not have a card on file to remove.") + + def test_blocked_by_active_subscription( + self, + rf, + organization_factory, + user_factory, + plan_factory, + subscription_factory, + mocker, + ): + """A non-cancelled subscription blocks removal""" + self._mock_card_on_file(mocker) + mocked_remove = mocker.patch( + "squarelet.organizations.models.Organization.remove_payment_method" + ) + user = user_factory() + organization = organization_factory(admins=[user]) + subscription_factory( + organization=organization, plan=plan_factory(), cancelled=False + ) + response = self.call_view(rf, user, {}, slug=organization.slug) + assert response.status_code == 302 + mocked_remove.assert_not_called() + self.assert_message( + messages.ERROR, + "You must cancel your active subscriptions before " + "removing your payment method.", + ) + + def test_allowed_when_all_subscriptions_cancelled( + self, + rf, + organization_factory, + user_factory, + plan_factory, + subscription_factory, + mocker, + ): + """Removal is allowed when every subscription is cancelled""" + self._mock_card_on_file(mocker) + mocked_remove = mocker.patch( + "squarelet.organizations.models.Organization.remove_payment_method" + ) + user = user_factory() + organization = organization_factory(admins=[user]) + subscription_factory( + organization=organization, plan=plan_factory(), cancelled=True + ) + response = self.call_view(rf, user, {}, slug=organization.slug) + assert response.status_code == 302 + mocked_remove.assert_called_once() + self.assert_message(messages.SUCCESS, "Credit card removed") + + @staticmethod + def _ajax_post(rf, user, slug): + """Build an AJAX POST request to the remove-card route.""" + request = rf.post( + f"/organizations/{slug}/card/remove/", + {}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + request.user = user + # pylint: disable=protected-access + request._messages = MagicMock() + request.session = MagicMock() + return views.RemoveCard.as_view()(request, slug=slug) + + def test_ajax_remove_card(self, rf, organization_factory, user_factory, mocker): + """AJAX removal returns JSON with the redirect target and message""" + self._mock_card_on_file(mocker) + mocker.patch( + "squarelet.organizations.models.Organization.remove_payment_method" + ) + user = user_factory() + organization = organization_factory(admins=[user]) + response = self._ajax_post(rf, user, organization.slug) + assert response.status_code == 200 + assert json.loads(response.content)["message"] == "Credit card removed" + + def test_ajax_blocked_by_active_subscription( + self, + rf, + organization_factory, + user_factory, + plan_factory, + subscription_factory, + mocker, + ): + """AJAX removal returns a 400 with the error when blocked""" + self._mock_card_on_file(mocker) + mocked_remove = mocker.patch( + "squarelet.organizations.models.Organization.remove_payment_method" + ) + user = user_factory() + organization = organization_factory(admins=[user]) + subscription_factory( + organization=organization, plan=plan_factory(), cancelled=False + ) + response = self._ajax_post(rf, user, organization.slug) + assert response.status_code == 400 + mocked_remove.assert_not_called() + assert ( + "cancel your active subscriptions" in json.loads(response.content)["error"] + ) + + def test_get_not_allowed(self, rf, organization_factory, user_factory): + """Removal is POST-only""" + user = user_factory() + organization = organization_factory(admins=[user]) + response = self.call_view(rf, user, slug=organization.slug) + assert response.status_code == 405 + + @pytest.mark.django_db() class TestCreate(ViewTestMixin): """Test the Organization Create view""" diff --git a/squarelet/organizations/urls.py b/squarelet/organizations/urls.py index 16368a8bc..406d3396b 100644 --- a/squarelet/organizations/urls.py +++ b/squarelet/organizations/urls.py @@ -32,6 +32,11 @@ name="update-frequency", ), path("/card/", view=views.UpdateCard.as_view(), name="update-card"), + path( + "/card/remove/", + view=views.RemoveCard.as_view(), + name="remove-card", + ), path( "/receipt-email/", view=views.UpdateReceiptEmail.as_view(), diff --git a/squarelet/organizations/views/__init__.py b/squarelet/organizations/views/__init__.py index 36d153326..0fc50643a 100644 --- a/squarelet/organizations/views/__init__.py +++ b/squarelet/organizations/views/__init__.py @@ -18,6 +18,7 @@ ManageSubscriptions, PaymentsList, PDFChargeDetail, + RemoveCard, UpdateCard, UpdateReceiptEmail, UpdateSubscription, @@ -34,6 +35,7 @@ "ManageSubscriptions", "UpdateSubscription", "UpdateCard", + "RemoveCard", "UpdateSubscriptionFrequency", "UpdateReceiptEmail", "CancelSubscription", diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py index 8aa18a059..fea799c57 100644 --- a/squarelet/organizations/views/subscription.py +++ b/squarelet/organizations/views/subscription.py @@ -55,6 +55,7 @@ BaseCancelSubscription, BaseManageSubscriptions, BasePaymentsList, + BaseRemoveCard, BaseUpdateCard, BaseUpdateReceiptEmail, BaseUpdateSubscriptionFrequency, @@ -189,6 +190,10 @@ class UpdateCard(OrgSubscriptionView, BaseUpdateCard): pass +class RemoveCard(OrgSubscriptionView, BaseRemoveCard): + pass + + class UpdateSubscriptionFrequency(OrgSubscriptionView, BaseUpdateSubscriptionFrequency): pass diff --git a/squarelet/payments/views.py b/squarelet/payments/views.py index 6bb26b401..3dd71df23 100644 --- a/squarelet/payments/views.py +++ b/squarelet/payments/views.py @@ -15,6 +15,7 @@ RedirectView, TemplateView, UpdateView, + View, ) # Standard Library @@ -616,6 +617,48 @@ def get_context_data(self, **kwargs): return context +class BaseRemoveCard(SubscriptionObjectMixin, View): + """Remove the credit card on file for an organization.""" + + def _is_ajax(self): + return self.request.headers.get("X-Requested-With") == "XMLHttpRequest" + + def _error(self, message): + """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("subscriptions")) + + def post(self, request, *args, **kwargs): + organization = self.get_object() + redirect_url = self.reverse_subject("subscriptions") + + if organization.customer().payment_details is None: + return self._error(_("You do not have a card on file to remove.")) + + # A non-cancelled subscription still bills the card on file, so removing + # it would set up a failed renewal. Require cancellation first. + if organization.subscriptions.filter(cancelled=False).exists(): + return self._error( + _( + "You must cancel your active subscriptions before " + "removing your payment method." + ) + ) + + try: + organization.remove_payment_method() + except stripe.StripeError as exc: + return self._error(f"Payment error: {format_stripe_error(exc)}") + + success_msg = _("Credit card removed") + if self._is_ajax(): + return JsonResponse({"redirect": redirect_url, "message": str(success_msg)}) + messages.success(request, success_msg) + return redirect(redirect_url) + + class BaseUpdateReceiptEmail(SubscriptionObjectMixin, UpdateView): form_class = UpdateReceiptEmailForm template_name = "subscriptions/update_receipt_email.html" diff --git a/squarelet/templates/organizations/includes/plan_card.html b/squarelet/templates/organizations/includes/plan_card.html index 51287c044..ee4f801f3 100644 --- a/squarelet/templates/organizations/includes/plan_card.html +++ b/squarelet/templates/organizations/includes/plan_card.html @@ -17,20 +17,27 @@
    {% if subscriptions %}
    - {% if card_brand and card_last4 %} -
    -

    {% trans "Payment card" %}

    - - {% icon "credit-card" %} - - {{ card_brand }} {% trans "ending in" %} {{ card_last4 }} +

    {% trans "Your plans" %}

    diff --git a/squarelet/templates/subscriptions/manage_subscriptions.html b/squarelet/templates/subscriptions/manage_subscriptions.html index ef57275a5..f7c22ccba 100644 --- a/squarelet/templates/subscriptions/manage_subscriptions.html +++ b/squarelet/templates/subscriptions/manage_subscriptions.html @@ -156,6 +156,15 @@

    {% trans "Payment method" %}

    {% trans "No card on file" %} {% endif %}
    + {% if card_brand and card_last4 %} + + {% csrf_token %} + + + {% endif %}
    diff --git a/squarelet/templates/subscriptions/update_card.html b/squarelet/templates/subscriptions/update_card.html index 2ee29d769..bb1289771 100644 --- a/squarelet/templates/subscriptions/update_card.html +++ b/squarelet/templates/subscriptions/update_card.html @@ -55,4 +55,14 @@

    {% trans "Change payment card" %}

    + + {% if card_brand and card_last4 %} +
    + {% csrf_token %} + +
    + {% endif %} {% endblock main %} diff --git a/squarelet/users/urls.py b/squarelet/users/urls.py index e7dd3bf71..afcd76762 100644 --- a/squarelet/users/urls.py +++ b/squarelet/users/urls.py @@ -71,6 +71,11 @@ name="update-receipt-email", ), path("/card/", view=views.UpdateCard.as_view(), name="update-card"), + path( + "/card/remove/", + view=views.RemoveCard.as_view(), + name="remove-card", + ), path( "/payments/", view=views.PaymentsList.as_view(), name="payments" ), diff --git a/squarelet/users/views.py b/squarelet/users/views.py index c1eaa592b..33d8db4b2 100644 --- a/squarelet/users/views.py +++ b/squarelet/users/views.py @@ -55,6 +55,7 @@ BaseCancelSubscription, BaseManageSubscriptions, BasePaymentsList, + BaseRemoveCard, BaseUpdateCard, BaseUpdateReceiptEmail, BaseUpdateSubscriptionFrequency, @@ -703,6 +704,10 @@ class UpdateCard(IndividualSubscriptionView, BaseUpdateCard): pass +class RemoveCard(IndividualSubscriptionView, BaseRemoveCard): + pass + + class UpdateReceiptEmail(IndividualSubscriptionView, BaseUpdateReceiptEmail): pass From d0d375b73202c143faa444d0cde3c2e132b603ec Mon Sep 17 00:00:00 2001 From: Allan Lasser Date: Fri, 24 Jul 2026 09:41:29 -0400 Subject: [PATCH 81/86] Show navigation to view payment details for all orgs --- frontend/css/sidebar_layout.css | 10 ++ .../payments/tests/test_payments_list.py | 96 +++++++++++++++++++ squarelet/payments/views.py | 37 ++++++- .../templates/subscriptions/payments.html | 14 +++ 4 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 squarelet/payments/tests/test_payments_list.py diff --git a/frontend/css/sidebar_layout.css b/frontend/css/sidebar_layout.css index 0a841a1f3..44d833563 100644 --- a/frontend/css/sidebar_layout.css +++ b/frontend/css/sidebar_layout.css @@ -128,6 +128,16 @@ flex-shrink: 0; } +.sidebar-layout > .sidebar .nav-item.active, +.sidebar-layout > .sidebar a.nav-item.active { + background: var(--blue-1, #eef3f9); + color: var(--blue-5, #053775); +} + +.sidebar-layout > .sidebar .nav-item.active svg { + fill: var(--blue-5, #053775); +} + /* Content area */ .sidebar-layout > .content { diff --git a/squarelet/payments/tests/test_payments_list.py b/squarelet/payments/tests/test_payments_list.py new file mode 100644 index 000000000..5dc36747f --- /dev/null +++ b/squarelet/payments/tests/test_payments_list.py @@ -0,0 +1,96 @@ +# Django +from django.urls import reverse + +# Third Party +import pytest + +# Squarelet +from squarelet.core.tests.mixins import ViewTestMixin +from squarelet.organizations.views import subscription as org_views +from squarelet.users import views as user_views + + +@pytest.mark.django_db() +class TestOrganizationPaymentsListNav(ViewTestMixin): + """The org payment history sidebar navigates across the user's accounts.""" + + view = org_views.PaymentsList + url = "/organizations/{slug}/payments/" + + def test_nav_includes_personal_account_and_admin_orgs( + self, rf, user_factory, organization_factory, membership_factory + ): + """The sidebar lists the personal account and every administered org, + but not orgs where the user is only a member.""" + user = user_factory() + org = organization_factory() + org.add_creator(user) + member_org = organization_factory() + membership_factory(user=user, organization=member_org, admin=False) + + response = self.call_view(rf, user, slug=org.slug) + + nav_orgs = [ + account["organization"] for account in response.context_data["nav_accounts"] + ] + assert user.individual_organization in nav_orgs + assert org in nav_orgs + assert member_org not in nav_orgs + + def test_current_org_is_active(self, rf, user_factory, organization_factory): + """The organization being viewed is flagged active; the others are not.""" + user = user_factory() + org = organization_factory() + org.add_creator(user) + + response = self.call_view(rf, user, slug=org.slug) + + accounts = { + account["organization"]: account + for account in response.context_data["nav_accounts"] + } + assert accounts[org]["active"] is True + assert accounts[user.individual_organization]["active"] is False + + def test_nav_links_to_each_accounts_payment_history( + self, rf, user_factory, organization_factory + ): + """Each nav entry links to that account's payment history page.""" + user = user_factory() + org = organization_factory() + org.add_creator(user) + + response = self.call_view(rf, user, slug=org.slug) + + accounts = { + account["organization"]: account + for account in response.context_data["nav_accounts"] + } + assert accounts[org]["url"] == reverse( + "organizations:payments", kwargs={"slug": org.slug} + ) + assert accounts[user.individual_organization]["url"] == reverse( + "users:payments", kwargs={"username": user.username} + ) + + +@pytest.mark.django_db() +class TestUserPaymentsListNav(ViewTestMixin): + """The personal payment history sidebar flags the personal account active.""" + + view = user_views.PaymentsList + url = "/users/{username}/payments/" + + def test_personal_account_is_active(self, rf, user_factory, organization_factory): + user = user_factory() + org = organization_factory() + org.add_creator(user) + + response = self.call_view(rf, user, username=user.username) + + accounts = { + account["organization"]: account + for account in response.context_data["nav_accounts"] + } + assert accounts[user.individual_organization]["active"] is True + assert accounts[org]["active"] is False diff --git a/squarelet/payments/views.py b/squarelet/payments/views.py index 3dd71df23..563b5b2f6 100644 --- a/squarelet/payments/views.py +++ b/squarelet/payments/views.py @@ -703,9 +703,44 @@ class BasePaymentsList(SubscriptionObjectMixin, ListView): def get_queryset(self): return Charge.objects.filter(organization=self.get_organization()) + def _payments_url(self, organization): + """Reverse the payment history URL for an account the user can view. + + The personal account's pages live under the ``users`` namespace and + are keyed on the member's username; organization pages are keyed on + the org slug. + """ + if organization.individual: + return reverse( + "users:payments", kwargs={"username": self.request.user.username} + ) + return reverse("organizations:payments", kwargs={"slug": organization.slug}) + + def get_nav_accounts(self, organization): + """Build the sidebar nav across every account whose payments the user + can view — their personal account and any organization they + administer — flagging the one currently being viewed as active.""" + user = self.request.user + organizations = [user.individual_organization] + organizations += list( + user.organizations.filter(individual=False, memberships__admin=True) + .distinct() + .order_by("name") + ) + return [ + { + "organization": org, + "url": self._payments_url(org), + "active": org.pk == organization.pk, + } + for org in organizations + ] + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["organization"] = self.get_organization() + organization = self.get_organization() + context["organization"] = organization + context["nav_accounts"] = self.get_nav_accounts(organization) return context diff --git a/squarelet/templates/subscriptions/payments.html b/squarelet/templates/subscriptions/payments.html index d1b2e8540..357a050a8 100644 --- a/squarelet/templates/subscriptions/payments.html +++ b/squarelet/templates/subscriptions/payments.html @@ -29,6 +29,20 @@

    {% trans "Payment history" %}

    {% trans "Back to subscriptions" %} {% endif %} + {% endblock sidebar %} {% block main %} From 539b16dbaf3422b7d8ebe4f6b16dbbb61c019cc7 Mon Sep 17 00:00:00 2001 From: Allan Lasser Date: Fri, 24 Jul 2026 10:59:50 -0400 Subject: [PATCH 82/86] Adds activity stream logging for staff management actions --- squarelet/organizations/tests/test_views.py | 199 ++++++++++++++++++++ squarelet/payments/views.py | 21 ++- squarelet/users/tests/test_views.py | 69 +++++++ 3 files changed, 288 insertions(+), 1 deletion(-) diff --git a/squarelet/organizations/tests/test_views.py b/squarelet/organizations/tests/test_views.py index 9777e1ce5..aef904c12 100644 --- a/squarelet/organizations/tests/test_views.py +++ b/squarelet/organizations/tests/test_views.py @@ -1505,6 +1505,205 @@ def test_get_not_allowed(self, rf, organization_factory, user_factory): response = self.call_view(rf, user, slug=organization.slug) assert response.status_code == 405 + def test_staff_remove_card_creates_action( + self, rf, organization_factory, user_factory, mocker + ): + """Staff removing a card on someone's behalf is logged for accountability""" + self._mock_card_on_file(mocker) + mocker.patch( + "squarelet.organizations.models.Organization.remove_payment_method" + ) + staff_member = user_factory(is_staff=True) + staff_member = _assign_org_perm(staff_member, "can_edit_subscription") + organization = organization_factory() + response = self.call_view(rf, staff_member, {}, slug=organization.slug) + assert response.status_code == 302 + + action = Action.objects.filter( + actor_object_id=str(staff_member.pk), + verb="removed the payment method", + ).first() + assert action is not None + assert action.actor == staff_member + assert action.target == organization + assert action.public is False + + def test_non_staff_remove_card_no_action( + self, rf, organization_factory, user_factory, mocker + ): + """A regular admin removing their own card is not logged""" + self._mock_card_on_file(mocker) + mocker.patch( + "squarelet.organizations.models.Organization.remove_payment_method" + ) + admin = user_factory(is_staff=False) + organization = organization_factory(admins=[admin]) + response = self.call_view(rf, admin, {}, slug=organization.slug) + assert response.status_code == 302 + + assert not Action.objects.filter(verb="removed the payment method").exists() + + +@pytest.mark.django_db() +class TestUpdateCard(ViewTestMixin): + """Test staff-action logging on the Organization Update Card view""" + + view = views.UpdateCard + url = "/organizations/{slug}/card/" + + def test_staff_update_card_creates_action( + self, rf, organization_factory, user_factory, mocker + ): + """Staff updating a card on someone's behalf is logged for accountability""" + mocker.patch("squarelet.organizations.models.Customer.payment_details", None) + mocker.patch("squarelet.organizations.models.Organization.save_card") + staff_member = user_factory(is_staff=True) + staff_member = _assign_org_perm(staff_member, "can_edit_subscription") + organization = organization_factory() + data = {"stripe_token": "token", "stripe_pk": "key"} + response = self.call_view(rf, staff_member, data, slug=organization.slug) + assert response.status_code == 302 + + action = Action.objects.filter( + actor_object_id=str(staff_member.pk), + verb="updated the payment method", + ).first() + assert action is not None + assert action.actor == staff_member + assert action.target == organization + assert action.public is False + + def test_non_staff_update_card_no_action( + self, rf, organization_factory, user_factory, mocker + ): + """A regular admin updating their own card is not logged""" + mocker.patch("squarelet.organizations.models.Customer.payment_details", None) + mocker.patch("squarelet.organizations.models.Organization.save_card") + admin = user_factory(is_staff=False) + organization = organization_factory(admins=[admin]) + data = {"stripe_token": "token", "stripe_pk": "key"} + response = self.call_view(rf, admin, data, slug=organization.slug) + assert response.status_code == 302 + + assert not Action.objects.filter(verb="updated the payment method").exists() + + +@pytest.mark.django_db() +class TestCancelSubscription(ViewTestMixin): + """Test staff-action logging on the Organization Cancel Subscription view""" + + view = views.CancelSubscription + url = "/organizations/{slug}/subscriptions/{pk}/cancel" + + def test_staff_cancel_subscription_creates_action( + self, + rf, + organization_factory, + user_factory, + plan_factory, + subscription_factory, + mocker, + ): + """Staff cancelling a subscription on someone's behalf is logged""" + mocker.patch("squarelet.organizations.models.Organization.remove_subscription") + staff_member = user_factory(is_staff=True) + staff_member = _assign_org_perm(staff_member, "can_edit_subscription") + organization = organization_factory() + plan = plan_factory(name="Professional") + subscription = subscription_factory(organization=organization, plan=plan) + + response = self.call_view( + rf, staff_member, {}, slug=organization.slug, pk=subscription.pk + ) + assert response.status_code == 302 + + action = Action.objects.filter( + actor_object_id=str(staff_member.pk), + verb="cancelled a subscription", + ).first() + assert action is not None + assert action.actor == staff_member + assert action.target == organization + assert action.public is False + assert action.description == "Professional" + + def test_non_staff_cancel_subscription_no_action( + self, + rf, + organization_factory, + user_factory, + plan_factory, + subscription_factory, + mocker, + ): + """A regular admin cancelling their own subscription is not logged""" + mocker.patch("squarelet.organizations.models.Organization.remove_subscription") + admin = user_factory(is_staff=False) + organization = organization_factory(admins=[admin]) + subscription = subscription_factory( + organization=organization, plan=plan_factory() + ) + + response = self.call_view( + rf, admin, {}, slug=organization.slug, pk=subscription.pk + ) + assert response.status_code == 302 + + assert not Action.objects.filter(verb="cancelled a subscription").exists() + + +@pytest.mark.django_db() +class TestUpdateReceiptEmail(ViewTestMixin): + """Test staff-action logging on the Organization Update Receipt Email view""" + + view = views.UpdateReceiptEmail + url = "/organizations/{slug}/receipt-email/" + + def test_staff_update_receipt_email_creates_action( + self, rf, organization_factory, user_factory + ): + """Staff updating receipt emails on someone's behalf is logged""" + staff_member = user_factory(is_staff=True) + staff_member = _assign_org_perm(staff_member, "can_edit_subscription") + organization = organization_factory() + data = {"receipt_emails": "receipts@example.com"} + response = self.call_view(rf, staff_member, data, slug=organization.slug) + assert response.status_code == 302 + + action = Action.objects.filter( + actor_object_id=str(staff_member.pk), + verb="updated the receipt emails", + ).first() + assert action is not None + assert action.actor == staff_member + assert action.target == organization + assert action.public is False + + def test_non_staff_update_receipt_email_no_action( + self, rf, organization_factory, user_factory + ): + """A regular admin updating their own receipt emails is not logged""" + admin = user_factory(is_staff=False) + organization = organization_factory(admins=[admin]) + data = {"receipt_emails": "receipts@example.com"} + response = self.call_view(rf, admin, data, slug=organization.slug) + assert response.status_code == 302 + + assert not Action.objects.filter(verb="updated the receipt emails").exists() + + def test_staff_admin_of_own_org_no_action( + self, rf, organization_factory, user_factory + ): + """Staff managing an org they administer is not logged — it's their + own account, not an action taken on someone else's behalf""" + staff_admin = user_factory(is_staff=True) + organization = organization_factory(admins=[staff_admin]) + data = {"receipt_emails": "receipts@example.com"} + response = self.call_view(rf, staff_admin, data, slug=organization.slug) + assert response.status_code == 302 + + assert not Action.objects.filter(verb="updated the receipt emails").exists() + @pytest.mark.django_db() class TestCreate(ViewTestMixin): diff --git a/squarelet/payments/views.py b/squarelet/payments/views.py index 563b5b2f6..d05e40ddb 100644 --- a/squarelet/payments/views.py +++ b/squarelet/payments/views.py @@ -27,7 +27,7 @@ import stripe # Squarelet -from squarelet.core.utils import format_stripe_error +from squarelet.core.utils import format_stripe_error, new_action from squarelet.organizations.models import Charge, Organization, Plan from squarelet.organizations.models.payment import Subscription, get_payment_brand from squarelet.organizations.payments.base import PaymentActionRequired @@ -523,6 +523,19 @@ def reverse_subject(self, name, **kwargs): kwargs={**self.get_subject_url_kwargs(), **kwargs}, ) + def log_staff_action(self, verb, description=None): + """Record staff member actions for an accountability trail of changes + made on someone else's behalf.""" + user = self.request.user + organization = self.get_organization() + if user.is_staff and not organization.has_admin(user): + new_action( + actor=user, + verb=verb, + target=organization, + description=description, + ) + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["subject"] = self.subject @@ -600,6 +613,7 @@ def form_valid(self, form): messages.error(self.request, f"Payment error: {user_message}") return redirect(redirect_url) else: + self.log_staff_action("updated the payment method") success_msg = _("Credit card updated") if self._is_ajax(): return JsonResponse( @@ -652,6 +666,7 @@ def post(self, request, *args, **kwargs): except stripe.StripeError as exc: return self._error(f"Payment error: {format_stripe_error(exc)}") + self.log_staff_action("removed the payment method") success_msg = _("Credit card removed") if self._is_ajax(): return JsonResponse({"redirect": redirect_url, "message": str(success_msg)}) @@ -665,6 +680,7 @@ class BaseUpdateReceiptEmail(SubscriptionObjectMixin, UpdateView): def form_valid(self, form): self.object.set_receipt_emails(form.cleaned_data["receipt_emails"]) + self.log_staff_action("updated the receipt emails") return redirect(self.reverse_subject("subscriptions")) def get_initial(self): @@ -692,6 +708,9 @@ def form_valid(self, form): subscription = self.object.subscriptions.filter(id=self.kwargs["pk"]).first() if subscription: organization.remove_subscription(subscription) + self.log_staff_action( + "cancelled a subscription", description=subscription.plan.name + ) messages.success(self.request, _("Subscription cancelled.")) return redirect(self.reverse_subject("subscriptions")) diff --git a/squarelet/users/tests/test_views.py b/squarelet/users/tests/test_views.py index cf47588fc..b07ed6988 100644 --- a/squarelet/users/tests/test_views.py +++ b/squarelet/users/tests/test_views.py @@ -13,6 +13,7 @@ # Third Party import pytest +from actstream.models import Action from allauth.account.models import EmailAddress # Squarelet @@ -491,6 +492,74 @@ def test_anonymous_redirected_to_login(self, rf, user_factory): assert response.status_code == 302 +@pytest.mark.django_db() +class TestIndividualSubscriptionStaffActions(ViewTestMixin): + """Staff managing a user's billing is logged to the activity stream.""" + + view = views.CancelSubscription + url = "/users/{username}/cancel/{pk}/" + + def test_staff_cancel_subscription_creates_action( + self, rf, user_factory, plan_factory, subscription_factory, mocker + ): + """Staff cancelling a user's subscription targets the individual org""" + mocker.patch("squarelet.organizations.models.Organization.remove_subscription") + user = user_factory(username="dotted.name") + staff = user_factory(is_staff=True) + organization = user.individual_organization + plan = plan_factory(name="Professional") + subscription = subscription_factory(organization=organization, plan=plan) + + response = self.call_view( + rf, staff, {}, username=user.username, pk=subscription.pk + ) + assert response.status_code == 302 + + action = Action.objects.filter( + actor_object_id=str(staff.pk), + verb="cancelled a subscription", + ).first() + assert action is not None + assert action.actor == staff + assert action.target == organization + assert action.public is False + + def test_owner_cancel_subscription_no_action( + self, rf, user_factory, plan_factory, subscription_factory, mocker + ): + """A user cancelling their own subscription is not logged""" + mocker.patch("squarelet.organizations.models.Organization.remove_subscription") + user = user_factory(username="dotted.name") + subscription = subscription_factory( + organization=user.individual_organization, plan=plan_factory() + ) + + response = self.call_view( + rf, user, {}, username=user.username, pk=subscription.pk + ) + assert response.status_code == 302 + + assert not Action.objects.filter(verb="cancelled a subscription").exists() + + def test_staff_managing_own_account_no_action( + self, rf, user_factory, plan_factory, subscription_factory, mocker + ): + """A staff member managing their own individual account is not logged — + they are the owner (admin) of their own individual organization""" + mocker.patch("squarelet.organizations.models.Organization.remove_subscription") + staff = user_factory(is_staff=True, username="staffer") + subscription = subscription_factory( + organization=staff.individual_organization, plan=plan_factory() + ) + + response = self.call_view( + rf, staff, {}, username=staff.username, pk=subscription.pk + ) + assert response.status_code == 302 + + assert not Action.objects.filter(verb="cancelled a subscription").exists() + + @pytest.mark.django_db() class TestUserOnboardingView(ViewTestMixin): """Test the User Onboarding view""" From 29e3738c41764053c5aa0d831dcfbb3352870576 Mon Sep 17 00:00:00 2001 From: Allan Lasser Date: Fri, 24 Jul 2026 14:54:54 -0400 Subject: [PATCH 83/86] Fix card access in templates --- squarelet/templates/organizations/includes/plan_card.html | 8 ++++---- .../templates/subscriptions/manage_subscriptions.html | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/squarelet/templates/organizations/includes/plan_card.html b/squarelet/templates/organizations/includes/plan_card.html index ee4f801f3..aad08148a 100644 --- a/squarelet/templates/organizations/includes/plan_card.html +++ b/squarelet/templates/organizations/includes/plan_card.html @@ -20,9 +20,9 @@ - {% if account.card_brand and account.card_last4 %} + {% if card_brand and card_last4 %} {% trans "Update card" %} {% else %} {% trans "Add card" %} From 416c70446bf51abc5de3aaaae729b8fdb3d40247 Mon Sep 17 00:00:00 2001 From: Allan Lasser Date: Fri, 24 Jul 2026 15:20:47 -0400 Subject: [PATCH 84/86] Fix e2e tests --- e2e/organizations.spec.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/e2e/organizations.spec.ts b/e2e/organizations.spec.ts index df56e6197..2f1231ebf 100644 --- a/e2e/organizations.spec.ts +++ b/e2e/organizations.spec.ts @@ -91,7 +91,7 @@ test.describe("Organization Viewing", () => { test("does NOT see plan section", async ({ page }) => { await page.goto("/organizations/e2e-public-org/"); - await expect(page.locator("section#plan")).toHaveCount(0); + await expect(page.locator("section#billing")).toHaveCount(0); }); }); @@ -112,7 +112,7 @@ test.describe("Organization Viewing", () => { test("does NOT see plan section", async ({ page }) => { await page.goto("/organizations/e2e-public-org/"); - await expect(page.locator("section#plan")).toHaveCount(0); + await expect(page.locator("section#billing")).toHaveCount(0); }); test("sees only admins in member list", async ({ page }) => { @@ -141,7 +141,7 @@ test.describe("Organization Viewing", () => { test("sees plan section", async ({ page }) => { await page.goto("/organizations/e2e-public-org/"); - await expect(page.locator("section#plan")).toBeVisible(); + await expect(page.locator("section#billing")).toBeVisible(); }); test("sees all members in user list (not just admins)", async ({ @@ -204,7 +204,7 @@ test.describe("Organization Viewing", () => { test("sees plan section", async ({ page }) => { await page.goto("/organizations/e2e-public-org/"); - await expect(page.locator("section#plan")).toBeVisible(); + await expect(page.locator("section#billing")).toBeVisible(); }); test("sees button to change plans", async ({ page }) => { @@ -253,7 +253,7 @@ test.describe("Organization Viewing", () => { test("sees plan section", async ({ page }) => { await page.goto("/organizations/e2e-public-org/"); - await expect(page.locator("section#plan")).toBeVisible(); + await expect(page.locator("section#billing")).toBeVisible(); }); test("sees button to change plans", async ({ page }) => { From e37577750e2f2dd486fb9efdc2201390e07b1bd5 Mon Sep 17 00:00:00 2001 From: Allan Lasser Date: Mon, 27 Jul 2026 13:55:49 -0400 Subject: [PATCH 85/86] Remove billing frequency change logic The ability to change a subscription's billing frequency is not ready to ship with the billing redesign. Extract this work-in-progress to the 677-billing-frequency branch and remove it here: the org and user UpdateSubscriptionFrequency views, BaseUpdateSubscriptionFrequency, UpdateSubscriptionFrequencyForm, both update-frequency URLs, the update_subscription_frequency template, and the "Change to monthly/annual" link on the manage subscriptions page. The informational Annual/Monthly badge is kept. Co-Authored-By: Claude Opus 4.8 (1M context) --- squarelet/organizations/urls.py | 5 -- squarelet/organizations/views/__init__.py | 2 - squarelet/organizations/views/subscription.py | 5 -- squarelet/payments/forms.py | 13 ----- squarelet/payments/views.py | 15 ------ .../subscriptions/manage_subscriptions.html | 6 --- .../update_subscription_frequency.html | 53 ------------------- squarelet/users/urls.py | 5 -- squarelet/users/views.py | 7 --- 9 files changed, 111 deletions(-) delete mode 100644 squarelet/templates/subscriptions/update_subscription_frequency.html diff --git a/squarelet/organizations/urls.py b/squarelet/organizations/urls.py index 406d3396b..1030d9a2c 100644 --- a/squarelet/organizations/urls.py +++ b/squarelet/organizations/urls.py @@ -26,11 +26,6 @@ view=views.CancelSubscription.as_view(), name="cancel-subscription", ), - path( - "/subscriptions//update", - view=views.UpdateSubscriptionFrequency.as_view(), - name="update-frequency", - ), path("/card/", view=views.UpdateCard.as_view(), name="update-card"), path( "/card/remove/", diff --git a/squarelet/organizations/views/__init__.py b/squarelet/organizations/views/__init__.py index 0fc50643a..b01833129 100644 --- a/squarelet/organizations/views/__init__.py +++ b/squarelet/organizations/views/__init__.py @@ -22,7 +22,6 @@ UpdateCard, UpdateReceiptEmail, UpdateSubscription, - UpdateSubscriptionFrequency, stripe_webhook, ) @@ -36,7 +35,6 @@ "UpdateSubscription", "UpdateCard", "RemoveCard", - "UpdateSubscriptionFrequency", "UpdateReceiptEmail", "CancelSubscription", "PaymentsList", diff --git a/squarelet/organizations/views/subscription.py b/squarelet/organizations/views/subscription.py index fea799c57..9ec9529c6 100644 --- a/squarelet/organizations/views/subscription.py +++ b/squarelet/organizations/views/subscription.py @@ -58,7 +58,6 @@ BaseRemoveCard, BaseUpdateCard, BaseUpdateReceiptEmail, - BaseUpdateSubscriptionFrequency, ) logger = logging.getLogger(__name__) @@ -194,10 +193,6 @@ class RemoveCard(OrgSubscriptionView, BaseRemoveCard): pass -class UpdateSubscriptionFrequency(OrgSubscriptionView, BaseUpdateSubscriptionFrequency): - pass - - class UpdateReceiptEmail(OrgSubscriptionView, BaseUpdateReceiptEmail): pass diff --git a/squarelet/payments/forms.py b/squarelet/payments/forms.py index 5c26c0972..8090b814a 100644 --- a/squarelet/payments/forms.py +++ b/squarelet/payments/forms.py @@ -445,19 +445,6 @@ def __init__(self, *args, **kwargs): 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.""" diff --git a/squarelet/payments/views.py b/squarelet/payments/views.py index d05e40ddb..a66fd1816 100644 --- a/squarelet/payments/views.py +++ b/squarelet/payments/views.py @@ -39,7 +39,6 @@ CardForm, PlanPurchaseForm, UpdateReceiptEmailForm, - UpdateSubscriptionFrequencyForm, ) logger = logging.getLogger(__name__) @@ -576,20 +575,6 @@ def get_context_data(self, **kwargs): return context -class BaseUpdateSubscriptionFrequency(SubscriptionObjectMixin, UpdateView): - form_class = UpdateSubscriptionFrequencyForm - template_name = "subscriptions/update_subscription_frequency.html" - - 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(SubscriptionObjectMixin, UpdateView): """Update the credit card on file for an organization.""" diff --git a/squarelet/templates/subscriptions/manage_subscriptions.html b/squarelet/templates/subscriptions/manage_subscriptions.html index 9cacee0f5..3740656e3 100644 --- a/squarelet/templates/subscriptions/manage_subscriptions.html +++ b/squarelet/templates/subscriptions/manage_subscriptions.html @@ -81,12 +81,6 @@

    {{ annual|yesno:_("Annual,Monthly") }} - {% url subject|add:':update-frequency' subject_slug subscription.id as update_url %} - - {% blocktrans with annual|yesno:_("monthly,annual") as frequency %} - Change to {{ frequency }} - {% endblocktrans %} - {% endif %} diff --git a/squarelet/templates/subscriptions/update_subscription_frequency.html b/squarelet/templates/subscriptions/update_subscription_frequency.html deleted file mode 100644 index 87f4ec108..000000000 --- a/squarelet/templates/subscriptions/update_subscription_frequency.html +++ /dev/null @@ -1,53 +0,0 @@ -{% extends "layouts/modal_layout.html" %} -{% load i18n crispy_forms_tags humanize %} -{% load icon %} - -{% block title %}{{ subscription.plan.name }} | Change Billing Frequency{% endblock %} - -{% block layout_id %}update_frequency{% endblock %} - -{% block content %} -{% endblock content %} - -{% block header %} -

    {{ subscription.organization.name }}

    - {% icon "chevron-right" %} -

    {% trans "Change billing frequency" %}

    -{% endblock header %} - -{% block main %} - {% with plan=subscription.plan currentfreq=plan.annual|yesno:_("annual,monthly") newfreq=plan.annual|yesno:_("monthly,annual") %} -
    - {% crispy form form.helper %} -
    -

    - {{ plan.name }} -

    -

    - {% blocktrans with frequency=newfreq %} - Are you sure you want to change to {{ frequency }} billing? - {% endblocktrans %} -

    -
    -

    - {% blocktrans with cost=plan.base_price|floatformat:2|intcomma date=next_date %} - You will be charged ${{ cost }} on {{ date }}. - {% endblocktrans %} -

    -
    - - - - {% blocktrans with frequency=currentfreq %} - Keep {{ frequency }} - {% endblocktrans %} - -
    -
    - {% endwith %} -{% endblock main %} diff --git a/squarelet/users/urls.py b/squarelet/users/urls.py index afcd76762..dc987f8b7 100644 --- a/squarelet/users/urls.py +++ b/squarelet/users/urls.py @@ -60,11 +60,6 @@ view=views.CancelSubscription.as_view(), name="cancel-subscription", ), - path( - "/update-frequency//", - view=views.UpdateSubscriptionFrequency.as_view(), - name="update-frequency", - ), path( "/receipt-email/", view=views.UpdateReceiptEmail.as_view(), diff --git a/squarelet/users/views.py b/squarelet/users/views.py index 33d8db4b2..8ff7bdb4c 100644 --- a/squarelet/users/views.py +++ b/squarelet/users/views.py @@ -58,7 +58,6 @@ BaseRemoveCard, BaseUpdateCard, BaseUpdateReceiptEmail, - BaseUpdateSubscriptionFrequency, ) from squarelet.services.models import Service from squarelet.users.forms import ( @@ -694,12 +693,6 @@ class CancelSubscription(IndividualSubscriptionView, BaseCancelSubscription): pass -class UpdateSubscriptionFrequency( - IndividualSubscriptionView, BaseUpdateSubscriptionFrequency -): - pass - - class UpdateCard(IndividualSubscriptionView, BaseUpdateCard): pass From ed59dad4bce31f0a6be7cca86de56d3bfcb65eec Mon Sep 17 00:00:00 2001 From: Allan Lasser Date: Mon, 27 Jul 2026 17:32:03 -0400 Subject: [PATCH 86/86] Fix upgrade button link --- e2e/organizations.spec.ts | 8 ++++---- squarelet/templates/organizations/includes/plan_card.html | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/e2e/organizations.spec.ts b/e2e/organizations.spec.ts index 2f1231ebf..770dc16b0 100644 --- a/e2e/organizations.spec.ts +++ b/e2e/organizations.spec.ts @@ -207,10 +207,10 @@ test.describe("Organization Viewing", () => { await expect(page.locator("section#billing")).toBeVisible(); }); - test("sees button to change plans", async ({ page }) => { + test("sees button to upgrade plan", async ({ page }) => { await page.goto("/organizations/e2e-public-org/"); await expect( - page.locator('a[href$="/organizations/e2e-public-org/payment/"]'), + page.locator('a.btn.premium[href*="/plans/"][href$="-organization/"]'), ).toBeVisible(); }); }); @@ -256,10 +256,10 @@ test.describe("Organization Viewing", () => { await expect(page.locator("section#billing")).toBeVisible(); }); - test("sees button to change plans", async ({ page }) => { + test("sees button to upgrade plan", async ({ page }) => { await page.goto("/organizations/e2e-public-org/"); await expect( - page.locator('a[href$="/organizations/e2e-public-org/payment/"]'), + page.locator('a.btn.premium[href*="/plans/"][href$="-organization/"]'), ).toBeVisible(); }); diff --git a/squarelet/templates/organizations/includes/plan_card.html b/squarelet/templates/organizations/includes/plan_card.html index aad08148a..0c7a90c17 100644 --- a/squarelet/templates/organizations/includes/plan_card.html +++ b/squarelet/templates/organizations/includes/plan_card.html @@ -82,8 +82,8 @@

    {% trans "Your plans" %}

    {% trans "No active subscription." %} {% endif %}

    - {% if can_edit_subscription %} - {% trans "Upgrade" %} + {% if can_edit_subscription and upgrade_plan %} + {% trans "Upgrade" %} {% endif %} {% if upgrade_plan %}