Skip to content
2 changes: 2 additions & 0 deletions squarelet/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
ProfessionalPlanFactory,
ProfileChangeRequestFactory,
SubscriptionFactory,
SubscriptionItemFactory,
)
from squarelet.users.tests.factories import UserFactory

Expand All @@ -38,6 +39,7 @@
register(ProfessionalPlanFactory)
register(ProfileChangeRequestFactory)
register(SubscriptionFactory)
register(SubscriptionItemFactory)
register(CustomerFactory)
register(PaymentMethodFactory)

Expand Down
2 changes: 1 addition & 1 deletion squarelet/core/management/commands/export_orgs.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def handle(self, *args, **kwargs):
)
for org in orgs:
subtypes = ", ".join(str(s) for s in org.subtypes.all())
plans = ", ".join(str(p) for p in org.plans.all())
plans = ", ".join(str(p) for p in org.get_plans())
email_domains = [
e.email.split("@")[1]
for e in EmailAddress.objects.filter(user__organizations=org)
Expand Down
4 changes: 3 additions & 1 deletion squarelet/core/management/commands/import_documentcloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ def handle(self, *args, **kwargs):
if first_admin:
organization.set_billing_email(first_admin.email)
if organization.user_count() > organization.max_users:
active_subs = list(organization.subscriptions.select_related("plan"))
active_subs = list(
organization.subscription_items.select_related("plan")
)
paid_subs = [s for s in active_subs if not s.plan.free]
if not paid_subs:
organization.max_users = organization.user_count()
Expand Down
16 changes: 9 additions & 7 deletions squarelet/core/management/commands/sync_odoo.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ def _build_org_vals(org, odoo_plan_ids, sunlight_status, member_tag_ids):

def _compute_org_plans_and_status(org, inherited_plan_ids):
"""Return (odoo_plan_ids, sunlight_status) for an org."""
plans = list(org.plans.values_list("name", "wix"))
plans = list(org.get_plans().values_list("name", "wix"))
has_sunlight = any(wix for _, wix in plans)
own_plan_ids = [
pid for pid in (_resolve_plan_id(name) for name, _ in plans) if pid is not None
Expand Down Expand Up @@ -292,7 +292,9 @@ def get_or_create_org(org, dry_run=False, member_tag_ids=None, inherited_plan_id

def _member_desired_plans(user, org_plan_ids):
"""Union of the org's inherited plans and the user's own personal plans."""
personal = list(user.individual_organization.plans.values_list("name", flat=True))
personal = list(
user.individual_organization.get_plans().values_list("name", flat=True)
)
personal_plan_ids = [
pid for pid in (_resolve_plan_id(name) for name in personal) if pid is not None
]
Expand Down Expand Up @@ -603,7 +605,7 @@ def _load_collaborative_data():
collab_orgs = Organization.objects.filter(
collective_enabled=True,
individual=False,
).prefetch_related("plans", "members")
).prefetch_related("subscriptions__plans", "members")
for collab_org in collab_orgs:
tag_id = settings.COLLABORATIVE_TAGS.get(collab_org.slug)
if tag_id is None:
Expand All @@ -619,9 +621,9 @@ def _load_collaborative_data():
pid
for pid in (
_resolve_plan_id(name)
for name in collab_org.plans.filter(wix=True).values_list(
"name", flat=True
)
for name in collab_org.get_plans()
.filter(wix=True)
.values_list("name", flat=True)
)
if pid is not None
]
Expand All @@ -636,7 +638,7 @@ def _build_org_queryset(collaborative_data):
"""Return the queryset of all orgs to sync."""
sunlight_slugs = set(
Organization.objects.filter(
plans__wix=True,
subscriptions__plans__wix=True,
individual=False,
)
.values_list("slug", flat=True)
Expand Down
21 changes: 14 additions & 7 deletions squarelet/core/tests/test_odoo_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,10 @@ class TestComputeOrgPlansAndStatus:
def test_confirmed_when_any_wix_plan(self):
"""An own wix plan sets status Confirmed and resolves all plan ids."""
org = Mock()
org.plans.values_list.return_value = [("Pro", True), ("Free", False)]
org.get_plans.return_value.values_list.return_value = [
("Pro", True),
("Free", False),
]
with patch.object(sync_odoo, "_resolve_plan_id", side_effect=[10, 20]):
ids, status = sync_odoo._compute_org_plans_and_status(org, None)
assert ids == [10, 20]
Expand All @@ -222,15 +225,15 @@ def test_confirmed_when_any_wix_plan(self):
def test_no_status_without_wix_plan(self):
"""No own wix plan leaves the sunlight status unset."""
org = Mock()
org.plans.values_list.return_value = [("Free", False)]
org.get_plans.return_value.values_list.return_value = [("Free", False)]
with patch.object(sync_odoo, "_resolve_plan_id", return_value=20):
_, status = sync_odoo._compute_org_plans_and_status(org, None)
assert status is None

def test_inherited_plans_merged_and_deduped(self):
"""Inherited ids are merged with own ids and duplicates removed."""
org = Mock()
org.plans.values_list.return_value = [("Pro", True)]
org.get_plans.return_value.values_list.return_value = [("Pro", True)]
with patch.object(sync_odoo, "_resolve_plan_id", return_value=10):
ids, _ = sync_odoo._compute_org_plans_and_status(org, [10, 30])
assert ids == [10, 30]
Expand All @@ -240,7 +243,7 @@ def test_inherited_plans_do_not_set_sunlight_status(self):
inherited (collaborative/enterprise) plans must not confirm it."""
org = Mock()
# own plans: none of them wix
org.plans.values_list.return_value = [("Free", False)]
org.get_plans.return_value.values_list.return_value = [("Free", False)]
with patch.object(sync_odoo, "_resolve_plan_id", return_value=20):
ids, status = sync_odoo._compute_org_plans_and_status(
org, inherited_plan_ids=[101, 102]
Expand All @@ -254,7 +257,7 @@ def test_own_wix_plan_confirms_even_with_inherited(self):
"""An own wix plan sets Confirmed; inherited plans are additive, not
the trigger."""
org = Mock()
org.plans.values_list.return_value = [("Sunlight Basic", True)]
org.get_plans.return_value.values_list.return_value = [("Sunlight Basic", True)]
with patch.object(sync_odoo, "_resolve_plan_id", return_value=10):
ids, status = sync_odoo._compute_org_plans_and_status(
org, inherited_plan_ids=[101]
Expand All @@ -269,7 +272,9 @@ class TestMemberDesiredPlans:
def test_unions_org_and_personal_plans(self):
"""Org plans and the user's personal plans are unioned."""
user = Mock()
user.individual_organization.plans.values_list.return_value = ["Personal"]
user.individual_organization.get_plans.return_value.values_list.return_value = [
"Personal"
]
with patch.object(sync_odoo, "_resolve_plan_id", return_value=30):
assert sync_odoo._member_desired_plans(user, [10, 20]) == [10, 20, 30]

Expand All @@ -278,7 +283,9 @@ def test_drops_unresolved_personal_plan(self):
This shouldn't ever happen as we ensure all plans at the beginning,
but it is important we still have a test case."""
user = Mock()
user.individual_organization.plans.values_list.return_value = ["Broken"]
user.individual_organization.get_plans.return_value.values_list.return_value = [
"Broken"
]
with patch.object(sync_odoo, "_resolve_plan_id", return_value=None):
assert sync_odoo._member_desired_plans(user, [10]) == [10]

Expand Down
4 changes: 2 additions & 2 deletions squarelet/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ def get_context_data(self, **kwargs):
pro_plan = None
org_plans = None
if not user.is_anonymous:
pro_plan = user.individual_organization.subscriptions.first()
pro_plan = user.individual_organization.subscription_items.first()
org_plans = user.organizations.filter(
subscriptions__isnull=False,
subscriptions__items__isnull=False,
individual=False,
).distinct()
context["user"] = user
Expand Down
4 changes: 3 additions & 1 deletion squarelet/oidc/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ def send_cache_invalidations(model, uuids):
def oidc_login_hook(request, user, client):
"""Log which client users login to"""
# take an arbitrary non-individual organization, since most users will have one org
organizations = list(user.organizations.values("id", "name", plan=F("plans__name")))
organizations = list(
user.organizations.values("id", "name", plan=F("subscriptions__plans__name"))
)
user.logins.create(
client=client,
metadata={
Expand Down
29 changes: 22 additions & 7 deletions squarelet/organizations/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
ProfileChangeRequest,
ReceiptEmail,
Subscription,
SubscriptionItem,
)
from squarelet.organizations.payments.factory import get_payment_provider
from squarelet.users.models import User
Expand Down Expand Up @@ -78,7 +79,7 @@ def format_value(self, value):

class SubscriptionInline(admin.TabularInline):
model = Subscription
readonly_fields = ("plan", "subscription_id", "cancelled", "quantity")
readonly_fields = ("subscription_id", "interval", "collection_method", "cancelled")
extra = 0
can_delete = False

Expand Down Expand Up @@ -240,8 +241,8 @@ def queryset(self, request, queryset):
if value is None:
return queryset
if value == "none":
return queryset.filter(subscriptions__isnull=True)
return queryset.filter(subscriptions__plan_id=value)
return queryset.filter(subscriptions__items__isnull=True)
return queryset.filter(subscriptions__items__plan_id=value)


class OverdueInvoiceFilter(admin.SimpleListFilter):
Expand Down Expand Up @@ -459,12 +460,22 @@ def get_queryset(self, request):
)
plan_value = request.GET.get("plan")
if plan_value and plan_value != "none":
# `to_attr` attaches to the last relation in the path, so the
# subscriptions are collected on the organization and their
# matching lines prefetched underneath.
qs = qs.prefetch_related(
Prefetch(
"subscriptions",
queryset=Subscription.objects.filter(
plan_id=plan_value
).select_related("plan"),
queryset=Subscription.objects.filter(items__plan_id=plan_value)
.distinct()
.prefetch_related(
Prefetch(
"items",
queryset=SubscriptionItem.objects.filter(
plan_id=plan_value
).select_related("plan"),
)
),
to_attr="plan_subscriptions",
)
)
Expand Down Expand Up @@ -527,7 +538,11 @@ def get_subscription_renews(self, obj):
# A subscription renews only if it hasn't been cancelled and its plan
# is set to auto-renew (plans with auto_renew=False are created to
# cancel at period end).
return not any(s.cancelled or not s.plan.auto_renew for s in subs)
return not any(
sub.cancelled or not item.plan.auto_renew
for sub in subs
for item in sub.items.all()
)

get_subscription_renews.short_description = "Will Renew"
get_subscription_renews.boolean = True
Expand Down
2 changes: 1 addition & 1 deletion squarelet/organizations/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ class MergeForm(forms.Form):
)
bad_organization = forms.ModelChoiceField(
queryset=Organization.objects.filter(
subscriptions__isnull=True,
subscriptions__items__isnull=True,
individual=False,
merged=None,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import stripe

# Squarelet
from squarelet.organizations.models.payment import Customer, Subscription
from squarelet.organizations.models.payment import Customer, SubscriptionItem

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -40,7 +40,7 @@ def _datetimes_match(a, b):


class Command(BaseCommand):
"""Compare local Subscription records against Stripe and report mismatches.
"""Compare local SubscriptionItem records against Stripe and report mismatches.

Checks for:
- Local subscriptions with no subscription_id
Expand Down Expand Up @@ -114,7 +114,7 @@ def handle(self, *args, **options):

def _load_local_subs(self, org_filter):
"""Return (local_subs, id→sub map) for subscriptions with a Stripe ID."""
qs = Subscription.objects.select_related("plan", "organization").exclude(
qs = SubscriptionItem.objects.select_related("plan", "organization").exclude(
subscription_id=None
)
if org_filter:
Expand All @@ -127,7 +127,7 @@ def _load_local_subs(self, org_filter):

def _report_no_stripe_id(self, org_filter):
"""Print paid subscriptions with no subscription_id; return count."""
qs = Subscription.objects.select_related("plan", "organization").filter(
qs = SubscriptionItem.objects.select_related("plan", "organization").filter(
subscription_id=None, plan__base_price__gt=0
)
if org_filter:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@
import stripe

# Squarelet
from squarelet.organizations.models.payment import Subscription
from squarelet.organizations.models.payment import SubscriptionItem
from squarelet.organizations.payments.factory import get_payment_provider


class Command(BaseCommand):
"""Sync local Subscription fields from Stripe.
"""Sync local SubscriptionItem fields from Stripe.

Fetches the live Stripe subscription for each local record and updates
stripe_status and current_period_end. Safe to re-run — skips records
Expand All @@ -39,7 +39,7 @@ def handle(self, *args, **options):
org_filter = options["org"]
dry_run = options["dry_run"]

qs = Subscription.objects.select_related("plan", "organization").exclude(
qs = SubscriptionItem.objects.select_related("plan", "organization").exclude(
subscription_id=None
)
if org_filter:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from django.db import migrations


class Migration(migrations.Migration):
"""Rename Subscription to SubscriptionItem.

Hand-written: makemigrations cannot infer a rename without being asked
interactively, and non-interactively it emits CreateModel + DeleteModel,
which would drop every subscription.

First half of splitting the model in two. A SubscriptionItem is one line
on a Stripe subscription; the Subscription that owns those lines arrives
next. Renaming on its own first means every existing reference fails
loudly rather than silently binding to a `Subscription` that now means
something different.
"""

dependencies = [
("organizations", "0083_merge_20260909_1055"),
]

operations = [
migrations.RenameModel(
old_name="Subscription",
new_name="SubscriptionItem",
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Generated by Django 5.2.12 on 2026-08-26 17:20

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("organizations", "0083_rename_subscription_to_item"),
]

operations = [
migrations.AlterField(
model_name="subscriptionitem",
name="organization",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="subscription_items",
to="organizations.organization",
verbose_name="organization",
),
),
migrations.AlterField(
model_name="subscriptionitem",
name="plan",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="subscription_items",
to="organizations.plan",
verbose_name="plan",
),
),
migrations.AlterField(
model_name="subscriptionitem",
name="plan_price",
field=models.ForeignKey(
blank=True,
help_text="The price this subscription is billed at. Nullable until every subscription has been migrated off the legacy plan foreign key.",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="subscription_items",
to="organizations.planprice",
verbose_name="plan price",
),
),
]
Loading
Loading