+
+
+
+
+
+
+
+
-
-
-
+
- ${includeInvoiceOption ? `
-
-
-
-
- ` : ''}
-
+
-
-
-
-
Manage payment methods
-
`;
}
// Test Suite
-describe('Plan View - Payment Form Logic', () => {
+describe('Plan Purchase Form', () => {
let mockElement: MockStripeElement;
let mockElements: MockStripeElements;
let mockStripe: MockStripe;
@@ -145,7 +147,7 @@ describe('Plan View - Payment Form Logic', () => {
const loadModule = async () => {
// Import the module which will execute the DOMContentLoaded listener
- await import('./plan');
+ await import('./plan_purchase_form');
// Trigger DOMContentLoaded
document.dispatchEvent(new Event('DOMContentLoaded'));
};
@@ -248,7 +250,7 @@ describe('Plan View - Payment Form Logic', () => {
const existingCardRadio = document.querySelector('input[value="existing-card"]') as HTMLInputElement;
const newCardRadio = document.querySelector('input[value="new-card"]') as HTMLInputElement;
const cardField = document.querySelector('.card-field') as HTMLDivElement;
- const saveCardCheckbox = document.querySelector('input[name="save_card"]')?.closest('label') as HTMLLabelElement;
+ const saveCardCheckbox = document.querySelector('.save-card-option') as HTMLLabelElement;
select.value = 'org-1';
select.dispatchEvent(new Event('change'));
@@ -272,7 +274,7 @@ describe('Plan View - Payment Form Logic', () => {
const newCardRadio = document.querySelector('input[value="new-card"]') as HTMLInputElement;
const invoiceRadio = document.querySelector('input[value="invoice"]') as HTMLInputElement;
const cardField = document.querySelector('.card-field') as HTMLDivElement;
- const saveCardCheckbox = document.querySelector('input[name="save_card"]')?.closest('label') as HTMLLabelElement;
+ const saveCardCheckbox = document.querySelector('.save-card-option') as HTMLLabelElement;
select.value = 'org-1';
select.dispatchEvent(new Event('change'));
diff --git a/frontend/views/plan_purchase_form.ts b/frontend/views/plan_purchase_form.ts
new file mode 100644
index 00000000..75c38c56
--- /dev/null
+++ b/frontend/views/plan_purchase_form.ts
@@ -0,0 +1,361 @@
+import "@/css/plan_purchase_form.css"
+
+// Helper function to format numbers with commas
+function formatPrice(price: number): string {
+ return price.toLocaleString('en-US');
+}
+
+// Stripe input styling
+const cardInputStyle = {
+ base: {
+ backgroundColor: '#FFFFFF',
+ color: '#3F3F3F',
+ fontSize: '16px',
+ fontFamily: '"Source Sans 3", "Source Sans Pro", system-ui, sans-serif',
+ fontSmoothing: 'antialiased',
+ '::placeholder': {
+ color: '#899194',
+ }
+ },
+ invalid: {
+ color: '#e5424d',
+ ':focus': {
+ color: '#303238',
+ },
+ },
+};
+
+interface OrgCard {
+ last4: string;
+ brand: string;
+}
+
+interface OrgCards {
+ [orgId: string]: OrgCard;
+}
+
+interface PlanData {
+ annual: boolean;
+ is_sunlight_plan: boolean;
+ base_price: number;
+ price_per_user: number;
+ minimum_users: number;
+ has_nonprofit_variant?: boolean;
+ nonprofit_base_price?: number;
+ nonprofit_price_per_user?: number;
+}
+
+interface FormElements {
+ cardOnFileOption: HTMLDivElement | null;
+ cardField: HTMLDivElement | null;
+ paymentMethods: HTMLDivElement | null;
+ saveCardCheckbox: HTMLLabelElement | null;
+ existingCardRadio: HTMLInputElement | null;
+ newCardRadio: HTMLInputElement | null;
+ invoiceRadio: HTMLInputElement | null;
+ managePaymentLink: HTMLAnchorElement | null;
+ newOrgInput: HTMLInputElement | null;
+ newOrgField: HTMLLabelElement | null;
+ submitButton: HTMLButtonElement | null;
+ nonprofitCheckbox: HTMLInputElement | null;
+ nonprofitContainer: HTMLDivElement | null;
+ originalPrice: HTMLSpanElement | null;
+ discountedPrice: HTMLSpanElement | null;
+}
+
+/**
+ * Initialize a plan purchase form instance
+ */
+function initPlanPurchaseForm(container: HTMLElement): void {
+ // Get data from JSON script tags within this container
+ const orgCardsScript = container.querySelector('#org-card-data');
+ const planDataScript = container.querySelector('#plan-data');
+
+ const orgCards: OrgCards = orgCardsScript
+ ? JSON.parse(orgCardsScript.textContent || '{}')
+ : {};
+ const planData: PlanData = planDataScript
+ ? JSON.parse(planDataScript.textContent || '{}')
+ : {};
+
+ // Find the parent form element
+ const form = container.closest('form') as HTMLFormElement;
+ if (!form) return;
+
+ // Initialize Stripe if we have the necessary elements
+ const stripePkInput = form.querySelector('#id_stripe_pk') as HTMLInputElement;
+ const tokenInput = form.querySelector('#id_stripe_token') as HTMLInputElement;
+ const cardField = container.querySelector('.card-field') as HTMLDivElement;
+
+ let cardElement: any = null;
+ let stripe: any = null;
+
+ if (stripePkInput && tokenInput && cardField) {
+ const stripePk = stripePkInput.value;
+ stripe = (window as any).Stripe(stripePk);
+ const elements = stripe.elements();
+ cardElement = elements.create('card', { style: cardInputStyle });
+ const cardElementMount = cardField.querySelector('.card-element');
+ if (cardElementMount) {
+ cardElement.mount(cardElementMount);
+ }
+
+ // Handle real-time validation errors from the card element
+ cardElement.on('change', function(event: any) {
+ const displayError = cardField.querySelector('.card-element-errors');
+ if (displayError) {
+ displayError.textContent = event.error ? event.error.message : '';
+ }
+ });
+
+ // Clear token input
+ tokenInput.value = '';
+ }
+
+ // Get form elements
+ function getFormElements(): FormElements {
+ return {
+ cardOnFileOption: container.querySelector('.card-on-file-option'),
+ cardField: container.querySelector('.card-field'),
+ paymentMethods: container.querySelector('.payment-methods'),
+ saveCardCheckbox: container.querySelector('.save-card-option'),
+ existingCardRadio: container.querySelector('input[value="existing-card"]'),
+ newCardRadio: container.querySelector('input[value="new-card"]'),
+ invoiceRadio: container.querySelector('input[value="invoice"]'),
+ managePaymentLink: container.querySelector('.manage-payment-link'),
+ newOrgInput: container.querySelector('#id_new_organization_name'),
+ newOrgField: container.querySelector('#id_new_organization_name')?.closest('label') as HTMLLabelElement | null,
+ submitButton: form.querySelector('button[type="submit"]'),
+ nonprofitCheckbox: container.querySelector('#id_is_nonprofit'),
+ nonprofitContainer: container.querySelector('.nonprofit-checkbox'),
+ originalPrice: form.querySelector('.original-price'),
+ discountedPrice: form.querySelector('.discounted-price'),
+ };
+ }
+
+ function updateManagePaymentLink(selectElement: HTMLSelectElement, managePaymentLink: HTMLAnchorElement): void {
+ const selectedOption = selectElement.options[selectElement.selectedIndex];
+ const isIndividual = selectedOption.getAttribute('data-individual') === 'true';
+ const orgSlug = selectedOption.getAttribute('data-slug');
+
+ if (isIndividual) {
+ managePaymentLink.href = '/users/~payment/';
+ } else if (orgSlug) {
+ managePaymentLink.href = `/organizations/${orgSlug}/payment/`;
+ }
+
+ // Only show the link if there's a valid org (not "new" or empty)
+ managePaymentLink.style.display = orgSlug ? 'inline-block' : 'none';
+ }
+
+ function updateCardOptions(selectedOrg: string, elements: FormElements): void {
+ const { cardOnFileOption, existingCardRadio, newCardRadio, invoiceRadio } = elements;
+ if (!cardOnFileOption || !existingCardRadio || !newCardRadio) return;
+
+ const orgHasCard = Object.hasOwn(orgCards, selectedOrg) ? orgCards[selectedOrg] : null;
+ const noSelection = !existingCardRadio.checked && !newCardRadio.checked && !(invoiceRadio && invoiceRadio.checked);
+ if (orgHasCard && cardOnFileOption) {
+ // Show existing card option and update card info
+ cardOnFileOption.style.display = 'block';
+ const cardInfo = cardOnFileOption.querySelector('.card-info');
+ if (cardInfo) {
+ cardInfo.textContent = `Use existing ${orgCards[selectedOrg].brand} ending in ${orgCards[selectedOrg].last4}`;
+ }
+
+ // Default to existing card if no selection made
+ if (noSelection) {
+ existingCardRadio.checked = true;
+ }
+
+ // Remove only-option class if it exists
+ const newCardOption = newCardRadio?.closest('.new-card-option');
+ if (newCardOption) {
+ newCardOption.classList.remove('only-option');
+ }
+ } else if (cardOnFileOption) {
+ // Hide existing card option
+ cardOnFileOption.style.display = 'none';
+
+ // Switch to new card if existing was selected but not available
+ // Default to new card if no selection made
+ if (existingCardRadio.checked || noSelection) {
+ newCardRadio.checked = true;
+ }
+ }
+ }
+
+ function updatePaymentUI(elements: FormElements): void {
+ const { cardField, saveCardCheckbox, existingCardRadio, newCardRadio, invoiceRadio } = elements;
+ if (!cardField || !saveCardCheckbox) return;
+
+ if (existingCardRadio?.checked) {
+ cardField.style.display = 'none';
+ saveCardCheckbox.style.display = 'none';
+ } else if (newCardRadio?.checked) {
+ cardField.style.display = 'block';
+ saveCardCheckbox.style.display = 'block';
+ } else if (invoiceRadio?.checked) {
+ cardField.style.display = 'none';
+ saveCardCheckbox.style.display = 'none';
+ }
+ }
+
+ function hideAllPaymentElements(elements: FormElements): void {
+ const { paymentMethods, cardField, saveCardCheckbox, managePaymentLink } = elements;
+
+ if (paymentMethods) paymentMethods.style.display = 'none';
+ if (cardField) cardField.style.display = 'none';
+ if (saveCardCheckbox) saveCardCheckbox.style.display = 'none';
+ if (managePaymentLink) managePaymentLink.style.display = 'none';
+ }
+
+ function toggleNewOrgField(selectedOrg: string, elements: FormElements): void {
+ const { newOrgField, newOrgInput } = elements;
+ if (!newOrgField || !newOrgInput) return;
+
+ if (selectedOrg === 'new') {
+ newOrgField.classList.add('showField');
+ newOrgInput.required = true;
+ } else {
+ newOrgField.classList.remove('showField');
+ newOrgInput.required = false;
+ newOrgInput.value = ''; // Clear the value when hidden
+ }
+ }
+
+ function updateSubmitButton(selectedOrg: string, elements: FormElements): void {
+ const { submitButton } = elements;
+ if (!submitButton) return;
+
+ // Enable the button when an organization is selected
+ submitButton.disabled = !selectedOrg || selectedOrg === '';
+ }
+
+ function updatePriceDisplay(elements: FormElements): void {
+ const { nonprofitCheckbox, originalPrice, discountedPrice } = elements;
+ if (!nonprofitCheckbox || !originalPrice || !discountedPrice) return;
+
+ if (nonprofitCheckbox.checked && planData.has_nonprofit_variant) {
+ // Show discounted price from nonprofit plan variant
+ originalPrice.style.textDecoration = 'line-through';
+ originalPrice.style.opacity = '0.6';
+ discountedPrice.textContent = `$${formatPrice(planData.nonprofit_base_price || 0)}`;
+ discountedPrice.style.display = 'inline';
+ } else {
+ // Show normal price
+ discountedPrice.style.display = 'none';
+ originalPrice.style.textDecoration = 'none';
+ originalPrice.style.opacity = '1';
+ }
+ }
+
+ // Organization selection handling
+ const orgSelect = container.querySelector('.org-select') as HTMLSelectElement;
+ if (orgSelect) {
+ orgSelect.addEventListener('change', function() {
+ const selectedOrg = this.value;
+ const elements = getFormElements();
+
+ if (selectedOrg) {
+ // Toggle new organization name field
+ toggleNewOrgField(selectedOrg, elements);
+
+ // Show payment methods section
+ if (elements.paymentMethods) {
+ elements.paymentMethods.style.display = 'block';
+ }
+
+ // Update manage payment methods link
+ if (elements.managePaymentLink) {
+ updateManagePaymentLink(this, elements.managePaymentLink);
+ }
+
+ // Update card options based on organization
+ updateCardOptions(selectedOrg, elements);
+
+ // Update UI based on current payment method selection
+ updatePaymentUI(elements);
+
+ // Enable the submit button
+ updateSubmitButton(selectedOrg, elements);
+
+ // Update price display for nonprofit checkbox
+ updatePriceDisplay(elements);
+ } else {
+ // No organization selected - hide everything
+ toggleNewOrgField(selectedOrg, elements);
+ hideAllPaymentElements(elements);
+
+ // Disable the submit button
+ updateSubmitButton(selectedOrg, elements);
+ }
+ });
+
+ // Trigger initial state
+ orgSelect.dispatchEvent(new Event('change'));
+ }
+
+ // Payment method selection handling
+ const paymentMethodRadios = container.querySelectorAll('input[name="payment_method"]');
+ paymentMethodRadios.forEach(radio => {
+ radio.addEventListener('change', function() {
+ const elements = getFormElements();
+ updatePaymentUI(elements);
+ });
+ });
+
+ // Nonprofit checkbox handling
+ const nonprofitCheckbox = container.querySelector('#id_is_nonprofit') as HTMLInputElement;
+ if (nonprofitCheckbox) {
+ nonprofitCheckbox.addEventListener('change', function() {
+ const elements = getFormElements();
+ updatePriceDisplay(elements);
+ });
+ }
+
+ // Form submission handling with Stripe
+ if (stripe && cardElement && tokenInput) {
+ form.addEventListener('submit', function(event) {
+ event.preventDefault();
+
+ if (tokenInput.value) {
+ // Token already exists, continue with normal submission
+ form.submit();
+ return;
+ }
+
+ const payMethodInput = container.querySelector(
+ 'input[name=payment_method]:checked'
+ ) as HTMLInputElement;
+
+ if (
+ payMethodInput != null &&
+ (['existing-card', 'invoice'].includes(payMethodInput.value))
+ ) {
+ // Do not try to get token if using a card on file or invoice
+ form.submit();
+ } else {
+ stripe.createToken(cardElement).then(function(result: any) {
+ if (result.error) {
+ // Inform the customer that there was an error
+ const displayError = cardField?.querySelector('.card-element-errors');
+ if (displayError) {
+ displayError.textContent = result.error.message;
+ }
+ } else {
+ // Set the token value and submit the form
+ tokenInput.value = result.token.id;
+ form.submit();
+ }
+ });
+ }
+ });
+ }
+}
+
+// Initialize all plan purchase forms on page load
+document.addEventListener('DOMContentLoaded', function() {
+ document.querySelectorAll('[data-plan-purchase-form]').forEach((formElement) => {
+ initPlanPurchaseForm(formElement as HTMLElement);
+ });
+});
diff --git a/squarelet/payments/forms.py b/squarelet/payments/forms.py
new file mode 100644
index 00000000..5242f489
--- /dev/null
+++ b/squarelet/payments/forms.py
@@ -0,0 +1,360 @@
+"""Forms for plan purchase functionality"""
+
+# Django
+from django import forms
+from django.db.models import Q
+from django.utils.translation import gettext_lazy as _
+
+# Squarelet
+from squarelet.core.forms import StripeForm
+from squarelet.organizations.models import Organization, Plan
+from squarelet.users.forms import NewOrganizationModelChoiceField
+
+
+class PlanPurchaseForm(StripeForm):
+ """
+ Form for purchasing a plan subscription.
+
+ Handles:
+ - Organization selection (individual, admin orgs, or new)
+ - Payment method selection (existing-card, new-card, invoice)
+ - Nonprofit discount for Sunlight plans
+ - Stripe token handling
+
+ Usage:
+ form = PlanPurchaseForm(plan=plan, user=request.user)
+
+ In templates, renders with {{ form }} using the custom template_name.
+ """
+
+ template_name = "payments/forms/plan_purchase.html"
+
+ # Payment method choices
+ PAYMENT_METHOD_CHOICES = [
+ ("existing-card", _("Use existing card")),
+ ("new-card", _("Use new card")),
+ ("invoice", _("Pay by invoice")),
+ ]
+
+ organization = NewOrganizationModelChoiceField(
+ label=_("Organization"),
+ queryset=None, # Set dynamically in __init__
+ required=True,
+ empty_label=_("Choose an organization"),
+ )
+
+ new_organization_name = forms.CharField(
+ label=_("Organization name"),
+ required=False,
+ max_length=255,
+ widget=forms.TextInput(),
+ )
+
+ payment_method = forms.ChoiceField(
+ label=_("Payment method"),
+ choices=PAYMENT_METHOD_CHOICES,
+ required=False,
+ widget=forms.RadioSelect(),
+ )
+
+ is_nonprofit = forms.BooleanField(
+ label=_("My organization is non-profit"),
+ required=False,
+ help_text=_(
+ "Non-profit organizations receive a discount on "
+ "Sunlight Research Desk plans."
+ ),
+ )
+
+ save_card = forms.BooleanField(
+ label=_("Save as default card"),
+ required=False,
+ initial=True,
+ )
+
+ def __init__(self, *args, plan=None, user=None, **kwargs):
+ """
+ Initialize the form with plan and user context.
+
+ Args:
+ plan: The Plan instance being purchased
+ user: The authenticated User instance
+ """
+ # Don't pass instance to parent - we handle organization differently
+ kwargs.pop("instance", None)
+ super().__init__(*args, **kwargs)
+
+ self.plan = plan
+ self.user = user
+ self.fields["stripe_token"].required = False
+
+ # Remove inherited fields we don't use from StripeForm
+ if "use_card_on_file" in self.fields:
+ del self.fields["use_card_on_file"]
+ if "remove_card_on_file" in self.fields:
+ del self.fields["remove_card_on_file"]
+
+ self._configure_organization_field()
+ self._configure_payment_method_field()
+ self._configure_nonprofit_field()
+
+ def _configure_organization_field(self):
+ """Configure organization queryset based on user and plan"""
+ if self.user and self.user.is_authenticated:
+ individual_org = self.user.individual_organization
+
+ # Start with organizations where user is admin
+ admin_orgs = Organization.objects.filter(
+ memberships__user=self.user,
+ memberships__admin=True,
+ individual=False,
+ ).distinct()
+
+ # Filter by plan's private organizations if applicable
+ if (
+ self.plan
+ and not self.plan.public
+ and self.plan.private_organizations.exists()
+ ):
+ admin_orgs = admin_orgs.filter(
+ pk__in=self.plan.private_organizations.all()
+ )
+
+ # Build queryset based on plan type
+ if self.plan:
+ if self.plan.for_individuals and self.plan.for_groups:
+ # Both individuals and groups allowed
+ base_queryset = Organization.objects.filter(
+ Q(pk=individual_org.pk) | Q(pk__in=admin_orgs)
+ ).distinct()
+ elif self.plan.for_individuals and not self.plan.for_groups:
+ # Individual only
+ base_queryset = Organization.objects.filter(pk=individual_org.pk)
+ elif self.plan.for_groups and not self.plan.for_individuals:
+ # Groups only
+ base_queryset = admin_orgs
+ else:
+ # Neither - shouldn't happen but handle gracefully
+ base_queryset = Organization.objects.none()
+ else:
+ # No plan specified - show all
+ base_queryset = Organization.objects.filter(
+ Q(pk=individual_org.pk) | Q(pk__in=admin_orgs)
+ ).distinct()
+
+ # Exclude organizations already subscribed to this plan
+ if self.plan:
+ subscribed_orgs = Organization.objects.filter(
+ subscriptions__plan=self.plan,
+ subscriptions__cancelled=False,
+ )
+ base_queryset = base_queryset.exclude(pk__in=subscribed_orgs)
+
+ self.fields["organization"].queryset = base_queryset
+ else:
+ self.fields["organization"].queryset = Organization.objects.none()
+
+ def _configure_payment_method_field(self):
+ """Configure payment method choices based on plan"""
+ choices = [
+ ("existing-card", _("Use card on file")),
+ ("new-card", _("Use new card")),
+ ]
+
+ # Invoice option only for annual plans
+ if self.plan and self.plan.annual:
+ choices.append(("invoice", _("Pay by invoice")))
+
+ self.fields["payment_method"].choices = choices
+
+ def _configure_nonprofit_field(self):
+ """
+ Show nonprofit field only for Sunlight
+ plans that aren't nonprofit variants
+ """
+ is_nonprofit_variant = self.plan and self.plan.slug.startswith(
+ "sunlight-nonprofit-"
+ )
+ if not self.plan or not self.plan.is_sunlight_plan or is_nonprofit_variant:
+ del self.fields["is_nonprofit"]
+
+ def get_org_cards_data(self):
+ """
+ Build a mapping of organization IDs to their saved card info.
+ Used by the frontend to dynamically update payment options.
+
+ Returns:
+ dict: Mapping of org_id (str) to card info dict with 'last4' and 'brand'
+ """
+ org_cards = {}
+ if not self.user or not self.user.is_authenticated:
+ return org_cards
+
+ for org in self.fields["organization"].queryset:
+ card = org.customer().card
+ if card:
+ org_cards[str(org.pk)] = {
+ "last4": card.last4,
+ "brand": card.brand,
+ }
+
+ return org_cards
+
+ def get_plan_data(self):
+ """
+ Build plan data for frontend JavaScript.
+
+ Returns:
+ dict: Plan information for JS
+ """
+ if not self.plan:
+ return {}
+
+ data = {
+ "annual": self.plan.annual,
+ "is_sunlight_plan": self.plan.is_sunlight_plan,
+ "base_price": self.plan.base_price,
+ "price_per_user": self.plan.price_per_user,
+ "minimum_users": self.plan.minimum_users,
+ }
+
+ # Add nonprofit plan pricing if available
+ data["has_nonprofit_variant"] = False
+ if self.plan.is_sunlight_plan:
+ nonprofit_slug = self.plan.nonprofit_variant_slug
+ if nonprofit_slug:
+ try:
+ nonprofit_plan = Plan.objects.get(slug=nonprofit_slug)
+ data["nonprofit_base_price"] = nonprofit_plan.base_price
+ data["nonprofit_price_per_user"] = nonprofit_plan.price_per_user
+ data["has_nonprofit_variant"] = True
+ except Plan.DoesNotExist:
+ pass
+
+ return data
+
+ def clean_new_organization_name(self):
+ """Validate new organization name is provided when creating new org"""
+ name = self.cleaned_data.get("new_organization_name", "").strip()
+ organization = self.data.get("organization")
+
+ if organization == "new" and not name:
+ raise forms.ValidationError(
+ _("Please provide a name for the new organization")
+ )
+
+ return name
+
+ def clean_is_nonprofit(self):
+ """Validate nonprofit checkbox is only used for Sunlight plans"""
+ is_nonprofit = self.cleaned_data.get("is_nonprofit", False)
+
+ if is_nonprofit and self.plan and not self.plan.is_sunlight_plan:
+ raise forms.ValidationError(
+ _("Non-profit discount is only available for Sunlight plans")
+ )
+
+ return is_nonprofit
+
+ def clean(self):
+ """
+ Cross-field validation for payment method and organization.
+ """
+ data = super().clean()
+
+ organization = data.get("organization")
+ payment_method = data.get("payment_method")
+ stripe_token = data.get("stripe_token")
+
+ # Validate payment method matches available options
+ if payment_method == "existing-card":
+ if organization and organization != "new":
+ if not organization.customer().card:
+ self.add_error(
+ "payment_method",
+ _("No payment method on file. Please add a card."),
+ )
+ elif payment_method == "new-card":
+ if not stripe_token:
+ self.add_error(
+ "stripe_token",
+ _("Please provide card information."),
+ )
+ elif payment_method == "invoice":
+ if self.plan and not self.plan.annual:
+ self.add_error(
+ "payment_method",
+ _("Invoice payment is only available for annual plans."),
+ )
+
+ return data
+
+ def get_selected_plan(self):
+ """
+ Get the actual plan to subscribe to, handling nonprofit substitution.
+
+ Returns:
+ Plan instance (may be nonprofit variant if is_nonprofit is True)
+ """
+ if not self.is_valid():
+ return self.plan
+
+ is_nonprofit = self.cleaned_data.get("is_nonprofit", False)
+
+ if is_nonprofit and self.plan and self.plan.is_sunlight_plan:
+ nonprofit_slug = self.plan.nonprofit_variant_slug
+ if nonprofit_slug:
+ try:
+ return Plan.objects.get(slug=nonprofit_slug)
+ except Plan.DoesNotExist:
+ pass
+
+ return self.plan
+
+ def get_or_create_organization(self, user):
+ """
+ Get or create the organization for the subscription.
+
+ Args:
+ user: The user creating the subscription
+
+ Returns:
+ Organization instance
+ """
+ organization = self.cleaned_data.get("organization")
+
+ if organization == "new":
+ new_org = Organization.objects.create(
+ name=self.cleaned_data["new_organization_name"],
+ private=False,
+ )
+ new_org.add_creator(user)
+ return new_org
+ elif organization:
+ return organization
+ else:
+ return user.individual_organization
+
+ def save(self, user):
+ """
+ Process the subscription.
+
+ Note: This does NOT create the subscription directly.
+ It returns the data needed for the view to create the subscription
+ with appropriate transaction handling.
+
+ Args:
+ user: The authenticated user
+
+ Returns:
+ dict with organization, plan, payment_method, and stripe_token
+ """
+ organization = self.get_or_create_organization(user)
+ selected_plan = self.get_selected_plan()
+
+ return {
+ "organization": organization,
+ "plan": selected_plan,
+ "payment_method": self.cleaned_data.get("payment_method"),
+ "stripe_token": self.cleaned_data.get("stripe_token"),
+ }
diff --git a/squarelet/payments/tests/test_forms.py b/squarelet/payments/tests/test_forms.py
new file mode 100644
index 00000000..85d05a43
--- /dev/null
+++ b/squarelet/payments/tests/test_forms.py
@@ -0,0 +1,414 @@
+"""Tests for PlanPurchaseForm"""
+
+# Standard Library
+from pathlib import Path
+
+# Third Party
+import pytest
+
+# Squarelet
+from squarelet.organizations.models import Organization
+from squarelet.payments.forms import PlanPurchaseForm
+
+
+@pytest.mark.django_db
+class TestPlanPurchaseFormInit:
+ """Test PlanPurchaseForm initialization"""
+
+ def test_init_no_params(self):
+ """Form initializes with no parameters"""
+ form = PlanPurchaseForm()
+ assert form.fields["organization"].queryset.count() == 0
+ assert not form.fields["stripe_token"].required
+
+ def test_init_with_user_shows_individual_org(self, user_factory, plan_factory):
+ """Form shows user's individual organization"""
+ user = user_factory()
+ plan = plan_factory(public=True, for_individuals=True)
+
+ form = PlanPurchaseForm(plan=plan, user=user)
+
+ # Should include individual org
+ assert user.individual_organization in form.fields["organization"].queryset
+
+ def test_init_with_user_shows_admin_orgs(
+ self, user_factory, organization_factory, plan_factory
+ ):
+ """Form shows organizations where user is admin"""
+ user = user_factory()
+ org = organization_factory()
+ org.add_creator(user)
+ plan = plan_factory(public=True, for_groups=True)
+
+ form = PlanPurchaseForm(plan=plan, user=user)
+
+ # Should include admin org
+ assert org in form.fields["organization"].queryset
+
+ def test_init_excludes_already_subscribed_orgs(
+ self, user_factory, organization_factory, plan_factory, subscription_factory
+ ):
+ """Form excludes organizations already subscribed to the plan"""
+ user = user_factory()
+ org = organization_factory()
+ org.add_creator(user)
+ plan = plan_factory(public=True, for_groups=True)
+
+ # Create an active subscription for the org
+ subscription_factory(organization=org, plan=plan, cancelled=False)
+
+ form = PlanPurchaseForm(plan=plan, user=user)
+
+ # Should NOT include subscribed org
+ assert org not in form.fields["organization"].queryset
+
+ def test_init_with_individual_only_plan(self, user_factory, plan_factory):
+ """Form only shows individual org for individual-only plans"""
+ user = user_factory()
+ plan = plan_factory(public=True, for_individuals=True, for_groups=False)
+
+ form = PlanPurchaseForm(plan=plan, user=user)
+
+ # Should only include individual org
+ assert form.fields["organization"].queryset.count() == 1
+ assert user.individual_organization in form.fields["organization"].queryset
+
+ def test_init_with_group_only_plan(
+ self, user_factory, organization_factory, plan_factory
+ ):
+ """Form only shows group orgs for group-only plans"""
+ user = user_factory()
+ org = organization_factory()
+ org.add_creator(user)
+ plan = plan_factory(public=True, for_individuals=False, for_groups=True)
+
+ form = PlanPurchaseForm(plan=plan, user=user)
+
+ # Should NOT include individual org, only group orgs
+ assert user.individual_organization not in form.fields["organization"].queryset
+ assert org in form.fields["organization"].queryset
+
+ def test_init_with_sunlight_plan_shows_nonprofit(self, user_factory, plan_factory):
+ """Nonprofit field shown for Sunlight plans"""
+ user = user_factory()
+ # Create a plan that looks like a Sunlight plan (slug starts with sunlight-)
+ plan = plan_factory(
+ slug="sunlight-test",
+ public=True,
+ for_individuals=True,
+ for_groups=False,
+ )
+
+ form = PlanPurchaseForm(plan=plan, user=user)
+
+ assert "is_nonprofit" in form.fields
+
+ def test_init_with_non_sunlight_plan_hides_nonprofit(
+ self, user_factory, plan_factory
+ ):
+ """Nonprofit field hidden for non-Sunlight plans"""
+ user = user_factory()
+ plan = plan_factory(
+ slug="professional",
+ public=True,
+ for_individuals=True,
+ for_groups=False,
+ )
+
+ form = PlanPurchaseForm(plan=plan, user=user)
+
+ assert "is_nonprofit" not in form.fields
+
+ def test_init_annual_plan_shows_invoice_option(self, user_factory, plan_factory):
+ """Invoice payment option shown for annual plans"""
+ user = user_factory()
+ plan = plan_factory(annual=True, public=True, for_individuals=True)
+
+ form = PlanPurchaseForm(plan=plan, user=user)
+
+ choices = dict(form.fields["payment_method"].choices)
+ assert "invoice" in choices
+
+ def test_init_monthly_plan_hides_invoice_option(self, user_factory, plan_factory):
+ """Invoice payment option hidden for monthly plans"""
+ user = user_factory()
+ plan = plan_factory(annual=False, public=True, for_individuals=True)
+
+ form = PlanPurchaseForm(plan=plan, user=user)
+
+ choices = dict(form.fields["payment_method"].choices)
+ assert "invoice" not in choices
+
+
+@pytest.mark.django_db
+class TestPlanPurchaseFormValidation:
+ """Test PlanPurchaseForm validation"""
+
+ def test_valid_new_card_payment(self, user_factory, plan_factory):
+ """Valid submission with new card"""
+ user = user_factory()
+ plan = plan_factory(public=True, for_individuals=True)
+
+ data = {
+ "organization": str(user.individual_organization.pk),
+ "payment_method": "new-card",
+ "stripe_token": "tok_visa",
+ "stripe_pk": "pk_test",
+ }
+
+ form = PlanPurchaseForm(data, plan=plan, user=user)
+ assert form.is_valid(), f"Form errors: {form.errors}"
+
+ def test_new_card_requires_stripe_token(self, user_factory, plan_factory):
+ """New card payment requires stripe token"""
+ user = user_factory()
+ plan = plan_factory(public=True, for_individuals=True)
+
+ data = {
+ "organization": str(user.individual_organization.pk),
+ "payment_method": "new-card",
+ "stripe_pk": "pk_test",
+ # Missing stripe_token
+ }
+
+ form = PlanPurchaseForm(data, plan=plan, user=user)
+ assert not form.is_valid()
+ assert "stripe_token" in form.errors
+
+ def test_existing_card_requires_card_on_file(
+ self, user_factory, plan_factory, mocker
+ ):
+ """Existing card payment requires card on file"""
+ user = user_factory()
+ plan = plan_factory(public=True, for_individuals=True)
+
+ # Mock that organization has NO card
+ mock_customer = mocker.MagicMock()
+ mock_customer.card = None
+ mocker.patch.object(Organization, "customer", return_value=mock_customer)
+
+ data = {
+ "organization": str(user.individual_organization.pk),
+ "payment_method": "existing-card",
+ "stripe_pk": "pk_test",
+ }
+
+ form = PlanPurchaseForm(data, plan=plan, user=user)
+ assert not form.is_valid()
+ assert "payment_method" in form.errors
+
+ def test_invoice_only_for_annual_plans(self, user_factory, plan_factory):
+ """Invoice payment only allowed for annual plans"""
+ user = user_factory()
+ plan = plan_factory(annual=False, public=True, for_individuals=True)
+
+ data = {
+ "organization": str(user.individual_organization.pk),
+ "payment_method": "invoice",
+ "stripe_pk": "pk_test",
+ }
+
+ form = PlanPurchaseForm(data, plan=plan, user=user)
+ assert not form.is_valid()
+ assert "payment_method" in form.errors
+
+ def test_new_organization_requires_name(self, user_factory, plan_factory):
+ """Creating new organization requires name"""
+ user = user_factory()
+ plan = plan_factory(for_groups=True, public=True)
+
+ data = {
+ "organization": "new",
+ "new_organization_name": "", # Empty
+ "payment_method": "new-card",
+ "stripe_token": "tok_visa",
+ "stripe_pk": "pk_test",
+ }
+
+ form = PlanPurchaseForm(data, plan=plan, user=user)
+ assert not form.is_valid()
+ assert "new_organization_name" in form.errors
+
+ def test_existing_card_valid_when_org_has_card(
+ self, user_factory, plan_factory, mocker
+ ):
+ """Submitting existing-card should be valid when org has a card on file.
+
+ The JS dynamically adds an "existing-card" radio option when an org has
+ a saved card. The Python form must accept "existing-card" as a valid
+ choice so that Django's ChoiceField validation doesn't reject it.
+ """
+ user = user_factory()
+ plan = plan_factory(public=True, for_individuals=True)
+
+ # Mock that organization HAS a card on file
+ mock_card = mocker.MagicMock()
+ mock_card.last4 = "4242"
+ mock_card.brand = "Visa"
+ mock_customer = mocker.MagicMock()
+ mock_customer.card = mock_card
+ mocker.patch.object(Organization, "customer", return_value=mock_customer)
+
+ data = {
+ "organization": str(user.individual_organization.pk),
+ "payment_method": "existing-card",
+ "stripe_pk": "pk_test",
+ }
+
+ form = PlanPurchaseForm(data, plan=plan, user=user)
+ assert form.is_valid(), f"Form errors: {form.errors}"
+
+
+class TestPlanPurchaseFormTemplate:
+ """Test that the template renders as expected"""
+
+ TEMPLATE_PATH = (
+ Path(__file__).resolve().parents[2]
+ / "templates"
+ / "payments"
+ / "forms"
+ / "plan_purchase.html"
+ )
+
+ @pytest.fixture(autouse=True)
+ def _load_template(self):
+ self.template_content = self.TEMPLATE_PATH.read_text()
+
+ def test_template_shows_organization_errors(self):
+ """Template should render organization field errors"""
+ assert "form.organization.errors" in self.template_content
+
+ def test_template_shows_new_organization_name_errors(self):
+ """Template should render new_organization_name field errors"""
+ assert "form.new_organization_name.errors" in self.template_content
+
+ def test_template_shows_payment_method_errors(self):
+ """Template should render payment_method field errors"""
+ assert "form.payment_method.errors" in self.template_content
+
+ def test_template_shows_stripe_token_errors(self):
+ """Template should render stripe_token field errors"""
+ assert "form.stripe_token.errors" in self.template_content
+
+ def test_template_shows_non_field_errors(self):
+ """Template should render non-field errors"""
+ assert "form.non_field_errors" in self.template_content
+
+
+@pytest.mark.django_db
+class TestPlanPurchaseFormSave:
+ """Test PlanPurchaseForm save method"""
+
+ def test_save_returns_subscription_data(self, user_factory, plan_factory):
+ """Save returns data needed for subscription"""
+ user = user_factory()
+ plan = plan_factory(public=True, for_individuals=True)
+
+ data = {
+ "organization": str(user.individual_organization.pk),
+ "payment_method": "new-card",
+ "stripe_token": "tok_visa",
+ "stripe_pk": "pk_test",
+ }
+
+ form = PlanPurchaseForm(data, plan=plan, user=user)
+ assert form.is_valid()
+
+ result = form.save(user)
+
+ assert result["organization"] == user.individual_organization
+ assert result["plan"] == plan
+ assert result["payment_method"] == "new-card"
+ assert result["stripe_token"] == "tok_visa"
+
+ def test_save_creates_new_organization(self, user_factory, plan_factory):
+ """Save creates new organization when selected"""
+ user = user_factory()
+ plan = plan_factory(for_groups=True, public=True)
+
+ data = {
+ "organization": "new",
+ "new_organization_name": "My New Org",
+ "payment_method": "new-card",
+ "stripe_token": "tok_visa",
+ "stripe_pk": "pk_test",
+ }
+
+ form = PlanPurchaseForm(data, plan=plan, user=user)
+ assert form.is_valid(), f"Form errors: {form.errors}"
+
+ result = form.save(user)
+
+ assert result["organization"].name == "My New Org"
+ assert result["organization"].has_admin(user)
+
+
+@pytest.mark.django_db
+class TestPlanPurchaseFormOrgCards:
+ """Test get_org_cards_data method"""
+
+ def test_returns_empty_for_anonymous_user(self, plan_factory):
+ """Returns empty dict for anonymous users"""
+ plan = plan_factory()
+ form = PlanPurchaseForm(plan=plan, user=None)
+
+ assert not form.get_org_cards_data()
+
+ def test_returns_card_info_for_orgs_with_cards(
+ self, user_factory, plan_factory, mocker
+ ):
+ """Returns card info for organizations with saved cards"""
+ user = user_factory()
+ plan = plan_factory(public=True, for_individuals=True)
+
+ # Mock card on file
+ mock_card = mocker.MagicMock()
+ mock_card.last4 = "4242"
+ mock_card.brand = "Visa"
+
+ mock_customer = mocker.MagicMock()
+ mock_customer.card = mock_card
+
+ mocker.patch.object(Organization, "customer", return_value=mock_customer)
+
+ form = PlanPurchaseForm(plan=plan, user=user)
+ org_cards = form.get_org_cards_data()
+
+ org_id = str(user.individual_organization.pk)
+ assert org_id in org_cards
+ assert org_cards[org_id]["last4"] == "4242"
+ assert org_cards[org_id]["brand"] == "Visa"
+
+
+@pytest.mark.django_db
+class TestPlanPurchaseFormPlanData:
+ """Test get_plan_data method"""
+
+ def test_returns_plan_info(self, user_factory, plan_factory, mocker):
+ """Returns plan information for frontend"""
+ user = user_factory()
+ # Mock stripe to avoid API calls
+ mocker.patch("stripe.Plan.create")
+ plan = plan_factory(
+ public=True,
+ for_individuals=True,
+ annual=True,
+ base_price=1000,
+ price_per_user=100,
+ minimum_users=5,
+ )
+
+ form = PlanPurchaseForm(plan=plan, user=user)
+ plan_data = form.get_plan_data()
+
+ assert plan_data["annual"] is True
+ assert plan_data["base_price"] == 1000
+ assert plan_data["price_per_user"] == 100
+ assert plan_data["minimum_users"] == 5
+
+ def test_returns_empty_dict_without_plan(self, user_factory):
+ """Returns empty dict when no plan provided"""
+ user = user_factory()
+ form = PlanPurchaseForm(plan=None, user=user)
+
+ assert not form.get_plan_data()
diff --git a/squarelet/payments/tests/test_views.py b/squarelet/payments/tests/test_views.py
index 3fbe757b..246d386b 100644
--- a/squarelet/payments/tests/test_views.py
+++ b/squarelet/payments/tests/test_views.py
@@ -36,7 +36,9 @@ def test_create_new_organization_with_subscription(
data = {
"organization": "new",
"new_organization_name": "My New Organization",
+ "payment_method": "new-card",
"stripe_token": "tok_visa",
+ "stripe_pk": "pk_test",
}
response = self.call_view(rf, user, data=data, pk=plan.pk, slug=plan.slug)
@@ -52,7 +54,7 @@ def test_create_new_organization_with_subscription(
plan=plan,
max_users=plan.minimum_users,
user=user,
- payment_method=None,
+ payment_method="new-card",
)
# Should redirect to the organization
@@ -67,13 +69,15 @@ def test_create_new_organization_without_name(self, rf, user_factory, plan_facto
data = {
"organization": "new",
"new_organization_name": "", # Empty name
+ "payment_method": "new-card",
"stripe_token": "tok_visa",
+ "stripe_pk": "pk_test",
}
response = self.call_view(rf, user, data=data, pk=plan.pk, slug=plan.slug)
- # Should redirect with error message
- assert response.status_code == 302
+ # Should re-render form with error (200, not redirect)
+ assert response.status_code == 200
# Verify no organization was created
assert not Organization.objects.filter(name="").exists()
@@ -91,7 +95,9 @@ def test_new_organization_adds_user_as_admin(
data = {
"organization": "new",
"new_organization_name": "Test Org",
+ "payment_method": "new-card",
"stripe_token": "tok_visa",
+ "stripe_pk": "pk_test",
}
self.call_view(rf, user, data=data, pk=plan.pk, slug=plan.slug)
@@ -121,7 +127,9 @@ def test_existing_organization_flow_still_works(
data = {
"organization": str(org.pk),
+ "payment_method": "new-card",
"stripe_token": "tok_visa",
+ "stripe_pk": "pk_test",
}
response = self.call_view(rf, user, data=data, pk=plan.pk, slug=plan.slug)
@@ -132,7 +140,7 @@ def test_existing_organization_flow_still_works(
plan=plan,
max_users=plan.minimum_users,
user=user,
- payment_method=None,
+ payment_method="new-card",
)
# Should redirect to the organization
@@ -146,7 +154,9 @@ def test_unauthenticated_user_redirected_to_login(self, rf, plan_factory):
data = {
"organization": "new",
"new_organization_name": "Test Org",
+ "payment_method": "new-card",
"stripe_token": "tok_visa",
+ "stripe_pk": "pk_test",
}
response = self.call_view(rf, user=None, data=data, pk=plan.pk, slug=plan.slug)
@@ -158,7 +168,7 @@ def test_unauthenticated_user_redirected_to_login(self, rf, plan_factory):
def test_already_subscribed_organization(
self, rf, user_factory, organization_factory, plan_factory, subscription_factory
):
- """Test that already subscribed organizations show warning"""
+ """Test that already subscribed organizations are excluded from form"""
user = user_factory()
plan = plan_factory(for_groups=True, public=True)
org = organization_factory()
@@ -169,13 +179,16 @@ def test_already_subscribed_organization(
data = {
"organization": str(org.pk),
+ "payment_method": "new-card",
"stripe_token": "tok_visa",
+ "stripe_pk": "pk_test",
}
response = self.call_view(rf, user, data=data, pk=plan.pk, slug=plan.slug)
- # Should redirect back to plan
- assert response.status_code == 302
+ # Form validation should fail because org is excluded from queryset
+ # Should re-render form with error (200, not redirect)
+ assert response.status_code == 200
def test_existing_card_without_card_on_file(
self, rf, user_factory, organization_factory, plan_factory, mocker
@@ -189,19 +202,19 @@ def test_existing_card_without_card_on_file(
# Mock that organization has NO card
mock_customer = mocker.MagicMock()
mock_customer.card = None
- mocker.patch.object(org, "customer", return_value=mock_customer)
+ mocker.patch.object(Organization, "customer", return_value=mock_customer)
data = {
"organization": str(org.pk),
"payment_method": "existing-card",
"stripe_token": "",
+ "stripe_pk": "pk_test",
}
response = self.call_view(rf, user, data=data, pk=plan.pk, slug=plan.slug)
- # Should redirect back to plan with error
- assert response.status_code == 302
- assert response.url == plan.get_absolute_url()
+ # Should re-render form with error (200, not redirect)
+ assert response.status_code == 200
def test_new_card_without_stripe_token(
self, rf, user_factory, organization_factory, plan_factory
@@ -216,13 +229,13 @@ def test_new_card_without_stripe_token(
"organization": str(org.pk),
"payment_method": "new-card",
"stripe_token": "", # No token provided
+ "stripe_pk": "pk_test",
}
response = self.call_view(rf, user, data=data, pk=plan.pk, slug=plan.slug)
- # Should redirect back to plan with error
- assert response.status_code == 302
- assert response.url == plan.get_absolute_url()
+ # Should re-render form with error (200, not redirect)
+ assert response.status_code == 200
def test_invoice_payment_for_non_annual_plan(
self, rf, user_factory, organization_factory, plan_factory
@@ -236,13 +249,13 @@ def test_invoice_payment_for_non_annual_plan(
data = {
"organization": str(org.pk),
"payment_method": "invoice",
+ "stripe_pk": "pk_test",
}
response = self.call_view(rf, user, data=data, pk=plan.pk, slug=plan.slug)
- # Should redirect back to plan with error
- assert response.status_code == 302
- assert response.url == plan.get_absolute_url()
+ # Should re-render form with error (200, not redirect)
+ assert response.status_code == 200
def test_invoice_payment_for_annual_plan_succeeds(
self, rf, user_factory, organization_factory, plan_factory, mocker
@@ -261,13 +274,14 @@ def test_invoice_payment_for_annual_plan_succeeds(
data = {
"organization": str(org.pk),
"payment_method": "invoice",
+ "stripe_pk": "pk_test",
}
response = self.call_view(rf, user, data=data, pk=plan.pk, slug=plan.slug)
# Should succeed and call set_subscription with invoice payment method
mock_set_subscription.assert_called_once_with(
- token=None,
+ token="",
plan=plan,
max_users=plan.minimum_users,
user=user,
diff --git a/squarelet/payments/views.py b/squarelet/payments/views.py
index 2694a9e4..d80b2b3c 100644
--- a/squarelet/payments/views.py
+++ b/squarelet/payments/views.py
@@ -10,7 +10,6 @@
from django.views.generic import DetailView, RedirectView, TemplateView
# Standard Library
-import json
import logging
import sys
@@ -18,6 +17,7 @@
from squarelet.organizations.models import Organization, Plan
from squarelet.organizations.models.payment import Subscription
from squarelet.organizations.tasks import add_to_waitlist
+from squarelet.payments.forms import PlanPurchaseForm
logger = logging.getLogger(__name__)
@@ -80,40 +80,22 @@ def get_object(self, queryset=None):
protect_private_plan(plan, self.request.user)
return plan
+ def get_form(self):
+ """Get the plan purchase form"""
+ plan = self.get_object()
+ user = self.request.user if self.request.user.is_authenticated else None
+
+ if self.request.method == "POST":
+ return PlanPurchaseForm(self.request.POST, plan=plan, user=user)
+ return PlanPurchaseForm(plan=plan, user=user)
+
def get_context_data(self, **kwargs):
- # pylint: disable=too-many-locals,too-many-branches
context = super().get_context_data(**kwargs)
plan = self.get_object()
- # Add plan data for JSON serialization
- context["plan_data"] = {
- "annual": plan.annual,
- "is_sunlight_plan": plan.is_sunlight_plan,
- "is_nonprofit_variant": plan.slug.startswith("sunlight-nonprofit-"),
- "base_price": plan.base_price,
- "price_per_user": plan.price_per_user,
- "minimum_users": plan.minimum_users,
- }
-
- # Add nonprofit plan pricing if available
- if plan.is_sunlight_plan:
- nonprofit_slug = plan.nonprofit_variant_slug
- if nonprofit_slug:
- try:
- nonprofit_plan = Plan.objects.get(slug=nonprofit_slug)
- context["plan_data"][
- "nonprofit_base_price"
- ] = nonprofit_plan.base_price
- context["plan_data"][
- "nonprofit_price_per_user"
- ] = nonprofit_plan.price_per_user
- context["plan_data"]["has_nonprofit_variant"] = True
- except Plan.DoesNotExist:
- context["plan_data"]["has_nonprofit_variant"] = False
- else:
- context["plan_data"]["has_nonprofit_variant"] = False
- else:
- context["plan_data"]["has_nonprofit_variant"] = False
+ # Add form to context
+ if "form" not in kwargs:
+ context["form"] = self.get_form()
# Add matching plan tier with different payment schedule (for Sunlight plans)
context["matching_plan"] = get_matching_plan_tier(plan)
@@ -121,7 +103,6 @@ def get_context_data(self, **kwargs):
if self.request.user.is_authenticated:
user = self.request.user
existing_subscriptions = []
- admin_organizations = []
# Check user's individual organization
individual_org = user.individual_organization
@@ -150,28 +131,10 @@ def get_context_data(self, **kwargs):
).first()
if org_subscription:
existing_subscriptions.append((org_subscription, org))
- else:
- admin_organizations.append(org)
-
- # Check if individual org can subscribe (not already subscribed)
- can_subscribe_individual = not individual_subscription
-
- # Add default payment methods for organizations
- individual_card = individual_org.customer().card
- if individual_card:
- context["individual_default_card"] = individual_card
-
- # Build org_cards mapping for all organizations (individual + admin)
- org_cards = self._get_org_cards(individual_org, admin_organizations)
context.update(
{
"existing_subscriptions": existing_subscriptions,
- "admin_organizations": admin_organizations,
- "can_subscribe_individual": can_subscribe_individual,
- "individual_organization": individual_org,
- "org_cards": org_cards,
- "org_cards_json": json.dumps(org_cards),
"stripe_pk": settings.STRIPE_PUB_KEY,
"show_waitlist": not plan.has_available_slots() and plan.wix,
}
@@ -217,130 +180,39 @@ def _get_org_cards(self, individual_org, admin_orgs):
return org_cards
def post(self, request, *args, **kwargs):
- # pylint: disable=too-many-return-statements,too-many-branches
- # pylint: disable=too-many-locals,too-many-statements
"""
- This receives a form submission for subscribing to the plan.
- The form supports selecting an existing organization or creating a new one,
- and choosing a payment method (new card, existing card, or invoice).
-
- Except! Invoice handling is getting expanded support in #461—we are only
- using invoices as a silent fallback for when no card has been provided.
- This is an edge case and not a user-selectable option at this time.
+ Handle form submission for subscribing to the plan.
+ Uses PlanPurchaseForm for validation and data extraction.
"""
- plan = self.get_object()
+ self.object = self.get_object()
+ plan = self.object
if not request.user.is_authenticated:
# Redirect unauthenticated users to login, then back to this page
return redirect_to_login(request.get_full_path())
- # pylint: disable=pointless-string-statement
- # It's not pointless, it's a block comment
- """
- # Get form data
-
- - Organization
- - If creating a new organization, the ID will be "new"
- - If creating a new organization, get the name from the form
- - Stripe token
- - If the user entered a credit card, the stripe_token will be populated
- - If they are using a saved card, this value will be empty.
- - If they are using an existing organization, there's a chance we'll have
- a saved card on file already. If it's a new organization, we should expect
- to have a token value.
- - Payment method: one of "new-card", "existing-card", or "invoice"
- - "new-card": user entered a new card (stripe_token will be populated)
- - "existing-card": user selected an existing saved card (stripe_token empty)
- - "invoice": user selected invoice payment (stripe_token empty)
- """
+ form = self.get_form()
- organization_id = request.POST.get("organization")
- new_organization_name = request.POST.get("new_organization_name")
- stripe_token = request.POST.get("stripe_token")
- payment_method = request.POST.get("payment_method")
- is_nonprofit = request.POST.get("is_nonprofit") == "on"
-
- # Handle nonprofit plan substitution
- selected_plan = plan # Original plan from URL
-
- if is_nonprofit:
- # Validate nonprofit checkbox is only used for Sunlight plans
- if not plan.is_sunlight_plan:
- messages.error(
- request,
- _(
- "Non-profit discount is only available for "
- "Sunlight Research Desk plans"
- ),
- )
- return redirect(plan)
-
- # Get nonprofit variant slug
- nonprofit_slug = plan.nonprofit_variant_slug
- if nonprofit_slug:
- try:
- nonprofit_plan = Plan.objects.get(slug=nonprofit_slug)
- selected_plan = nonprofit_plan
- except Plan.DoesNotExist:
- messages.error(
- request,
- _("Non-profit plan variant not found. Please contact support."),
- )
- return redirect(plan)
+ if not form.is_valid():
+ # Re-render with form errors
+ return self.render_to_response(self.get_context_data(form=form))
with transaction.atomic():
try:
- # Get or create the organization
- if organization_id == "new":
- # Create a new organization
- if not new_organization_name:
- messages.error(
- request, _("Please provide a name for the new organization")
- )
- return redirect(plan)
-
- organization = Organization.objects.create(
- name=new_organization_name,
- private=False,
- )
- # Add the user as an admin
- organization.add_creator(request.user)
- elif organization_id:
- organization = Organization.objects.get(
- pk=organization_id, users=request.user, memberships__admin=True
- )
- else:
- organization = request.user.individual_organization
-
- # Check if already subscribed
- # (check original plan for duplicate subscriptions)
+ # Get form results
+ result = form.save(request.user)
+ organization = result["organization"]
+ selected_plan = result["plan"]
+ payment_method = result["payment_method"]
+ stripe_token = result["stripe_token"]
+
+ # Check if already subscribed (check original plan)
if organization.subscriptions.filter(
plan=plan, cancelled=False
).exists():
- # Already subscribed
messages.warning(request, _("Already subscribed"))
return redirect(plan)
- # Validate payment method matches available options
- if payment_method == "existing-card":
- if not organization.customer().card:
- messages.error(
- request,
- _("No payment method on file. Please add a card."),
- )
- return redirect(plan)
- elif payment_method == "new-card":
- if not stripe_token:
- messages.error(request, _("Please provide card information."))
- return redirect(plan)
- elif payment_method == "invoice":
- if not selected_plan.annual:
- messages.error(
- request,
- _("Invoice payment is only available for annual plans."),
- )
- return redirect(plan)
-
# For Sunlight plans, use transaction with
# row locking to prevent race conditions
if plan.slug.startswith("sunlight-") and plan.wix:
@@ -384,7 +256,7 @@ def post(self, request, *args, **kwargs):
)
# Success - redirect to organization page
- messages.success(request, _("Succesfully subscribed"))
+ messages.success(request, _("Successfully subscribed"))
return redirect(organization)
except Organization.DoesNotExist:
diff --git a/squarelet/templates/payments/forms/plan_purchase.html b/squarelet/templates/payments/forms/plan_purchase.html
new file mode 100644
index 00000000..784e0250
--- /dev/null
+++ b/squarelet/templates/payments/forms/plan_purchase.html
@@ -0,0 +1,168 @@
+{% load i18n humanize django_vite %}
+
+
diff --git a/squarelet/templates/payments/plan.html b/squarelet/templates/payments/plan.html
index a2e7618a..ee7685b6 100644
--- a/squarelet/templates/payments/plan.html
+++ b/squarelet/templates/payments/plan.html
@@ -12,12 +12,6 @@
{% vite_asset "frontend/views/plan.ts" %}
{% endblock %}
-{% block javascript %}
-{{ block.super }}
-{{ org_cards|json_script:"org-card-data"}}
-{{ plan_data|json_script:"plan-data" }}
-{% endblock %}
-
{% block content %}
@@ -60,140 +54,38 @@
{% trans "Subscription Limit Reached" %}
{% else %}
-
-