Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions squarelet/organizations/models/organization.py
Original file line number Diff line number Diff line change
Expand Up @@ -546,21 +546,40 @@ def member_users(self, request=None):
return users_list

@transaction.atomic
def add_subscription(self, plan, max_users, user, token=None, payment_method=None):
def add_subscription(
self,
plan,
max_users,
user,
token=None,
payment_method=None,
nonprofit=False,
):
"""Add a new subscription to a plan.

Raises SubscriptionError if the org already holds a line for this
plan, cancelled or not. A cancelled line still occupies it:
`unique_together` is (subscription, plan), so a second line for the
same plan on the same subscription cannot exist. Reviving one is
`uncancel`'s job, reached through Resubscribe.

The question is asked about the *resolved* plan, because that is
what the line will be stored under. Asking about the picked row
instead found nothing for anyone buying an annual or nonprofit
variant, and the check that exists to raise this error politely was
skipped in favour of an IntegrityError from inside the transaction.
"""
# pylint: disable=import-outside-toplevel
# Squarelet
from squarelet.organizations.models.payment import SubscriptionItem

# Lock this org row to serialize concurrent subscription attempts
# (e.g. double form submit), preventing a race between the exists()
# check and the INSERT.
Organization.objects.select_for_update().filter(pk=self.pk).get()

if self.subscription_items.filter(plan=plan).exists():
canonical_plan = SubscriptionItem.objects.canonical_plan(plan, nonprofit)
if self.subscription_items.filter(plan=canonical_plan).exists():
raise SubscriptionError(
f"Organization already has an active subscription to {plan}"
)
Expand Down Expand Up @@ -590,6 +609,7 @@ def add_subscription(self, plan, max_users, user, token=None, payment_method=Non
plan=plan,
payment_method=payment_method,
quantity=max_users,
nonprofit=nonprofit,
)

if is_first and stripe_subscription:
Expand Down
114 changes: 91 additions & 23 deletions squarelet/organizations/models/payment.py
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,9 @@ def stripe_subscription(self):
@property
def free(self):
"""A subscription costs nothing when every line does."""
return all(item.plan is None or item.plan.free for item in self.items.all())
return all(
item.is_free for item in self.items.select_related("plan", "plan_price")
)

@property
def auto_renew(self):
Expand Down Expand Up @@ -593,14 +595,14 @@ def stripe_items(self, include_ids=False):
each line's own id to update it in place rather than replace it.
"""
specs = []
for item in self.items.select_related("plan"):
for item in self.items.select_related("plan", "plan_price"):
if item.is_free:
# A free plan has no Stripe Plan behind it - make_stripe_plan
# skips those - so naming it would reference an object that
# does not exist and fail the whole call, including the paid
# lines alongside it.
# Nothing for Stripe to bill. A comped or free line has no
# Stripe counterpart at all, so naming it would reference an
# object that does not exist and fail the whole call -
# including the paid lines alongside it.
continue
spec = {"plan": item.plan.stripe_id, "quantity": item.quantity}
spec = {"plan": item.stripe_price_id, "quantity": item.quantity}
if include_ids and item.stripe_item_id:
spec["id"] = item.stripe_item_id
specs.append(spec)
Expand Down Expand Up @@ -640,10 +642,14 @@ def sync_stripe_item_ids(self, stripe_sub):
if price_id:
by_price[price_id] = stripe_item["id"]

for item in self.items.select_related("plan"):
for item in self.items.select_related("plan", "plan_price"):
if item.is_free:
continue
item_id = by_price.get(item.plan.stripe_id)
# Whatever `stripe_items` sends as the price is what Stripe
# echoes back, so the two have to read the same field. This
# branch moves the specs onto PlanPrice; the lookup follows, or
# it silently matches nothing and the self-heal stops healing.
item_id = by_price.get(item.stripe_price_id)
if item_id and item_id != item.stripe_item_id:
item.stripe_item_id = item_id
item.save(update_fields=["stripe_item_id"])
Expand Down Expand Up @@ -1055,8 +1061,14 @@ def proration_behavior(self):
return "create_prorations"
return "always_invoice"

def stripe_modify(self):
"""Push local state to Stripe for every item on this subscription."""
def stripe_modify(self, proration_behavior=None):
"""Push local state to Stripe for every item on this subscription.

`proration_behavior` overrides the answer above for one call. Pass
"none" for a change that is not meant to alter what the customer
pays, such as moving a line onto the Price that represents the same
money it was already billing.
"""
if self.stripe_subscription:
# Learn any missing line ids before describing the lines, not
# after. A line with no `stripe_item_id` is sent with no id, and
Expand Down Expand Up @@ -1089,7 +1101,11 @@ def stripe_modify(self):
days_until_due=(
30 if self.collection_method == "send_invoice" else None
),
proration_behavior=self.proration_behavior,
proration_behavior=(
self.proration_behavior
if proration_behavior is None
else proration_behavior
),
)
)
if updated:
Expand Down Expand Up @@ -1256,6 +1272,47 @@ def __str__(self):
plan_name = self.plan.name if self.plan else "Free"
return f"SubscriptionItem: {self.subscription.organization} to {plan_name}"

@property
def is_free(self):
"""Whether this line costs anything.

Reads the price once the line has one, and falls back to the plan
while `plan_price` can still be null - which it is for every
subscriber the backfill deliberately skipped, and for every signup
until the purchase flow starts recording a price.
"""
if self.plan_price_id:
return self.plan_price.amount == 0
return self.plan is None or self.plan.free

@property
def is_nonprofit(self):
"""Whether this line is billing at a nonprofit rate.

A fact about the customer rather than about the tier, so it has to
survive a move between tiers. Self-reported and on the honour
system, the way the checkbox that sets it is.
"""
return bool(self.plan_price_id and self.plan_price.label == "nonprofit")

@property
def stripe_price_id(self):
"""The Stripe object this line bills against.

Prefers the `PlanPrice`'s Stripe Price. Falls back to the plan's
legacy id in two cases: while `plan_price` is still null, and when
a price exists but has no Stripe Price yet - a partial state
`consolidate_stripe_products` can leave and completes on a re-run.
Falling back means the line keeps billing exactly as it did before,
which is the safe reading of "not ready yet".

A free line has no Stripe counterpart at all; `stripe_items` drops
those before asking.
"""
if self.plan_price_id and self.plan_price.stripe_price_id:
return self.plan_price.stripe_price_id
return self.plan.stripe_id

@property
def organization(self):
"""The owning organization, reached through the parent subscription.
Expand All @@ -1279,15 +1336,6 @@ def next_date(self):
"""
return self.subscription.next_date

@property
def is_free(self):
"""Whether this line costs anything.

A free plan has no Stripe counterpart at all, so it is dropped
before the subscription's items are described to Stripe.
"""
return self.plan is None or self.plan.free

def modify(self, plan):
"""Change which plan this line bills.

Expand All @@ -1304,14 +1352,33 @@ def modify(self, plan):
changing a line's plan can change whether the subscription bills at
all: a free line becoming paid needs a Stripe subscription created,
and the last paid line becoming free needs one deleted.

Re-resolves the price, because the price is what the line bills
against now. Moving the plan and leaving `plan_price` behind kept
the line on the old tier's Stripe Price - the customer would have
been moved on paper and charged the old amount - and `is_free`
would have answered about the tier they left.

A line already on a nonprofit price stays on one: the label is a
fact about the customer, not about the tier they are moving to.
"""
# Identify this line on Stripe *before* changing the plan, because
# the plan is what identifies it: `sync_stripe_item_ids` matches on
# the Price, so once the local plan has moved on it matches nothing
# and the line is described to Stripe with no id - which asks Stripe
# to add a line rather than update one, leaving the customer billed
# for the plan they left as well as the one they chose.
interval = "annual" if plan.annual else "monthly"
# The billing shape follows the resolved price, not `plan.annual` -
# the same reason `start` does it that way, since the row handed in
# is not always the row that ends up being billed.
canonical_plan, plan_price = SubscriptionItem.objects.resolve_purchase(
plan, nonprofit=self.is_nonprofit
)
interval = (
plan_price.interval
if plan_price
else "annual" if plan.annual else "monthly"
)
if interval != self.subscription.interval:
raise SubscriptionError(
f"Cannot change {self.plan} to {plan} in place: it bills "
Expand All @@ -1334,7 +1401,8 @@ def modify(self, plan):
self.subscription.cancelled and not self.subscription.auto_renew
)

self.plan = plan
self.plan = canonical_plan
self.plan_price = plan_price
self.save()

if ending_only_because_nothing_renews and self.subscription.auto_renew:
Expand Down
143 changes: 143 additions & 0 deletions squarelet/organizations/plan_mapping.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""Where each legacy plan lands under the consolidated pricing model.

Lives here rather than inside the migration command because two callers
need it and they want different things from it:

- the migration asks "where does this existing subscription land", and the
answer legitimately includes comped and negotiated outcomes;
- the sign-up flow asks "what does this cost a new customer", which must
never resolve to comped.

Keeping one table avoids two descriptions of the same relationship
drifting apart; `resolve_target` below is what keeps the second caller
honest.
"""

# Where each legacy plan's subscriptions land, keyed on the legacy slug and
# whether the subscription is actually billing.
#
# The slug alone is not enough. Admins granted free access for years by
# putting organizations on a paid plan without a Stripe subscription, so
# `organization` means the standard price for its paying subscribers and the
# comped price for those. `is_billing` - whether a Stripe subscription
# exists - is what separates them.
#
# Every target was chosen against the legacy plan it replaces so that no
# subscriber's bill changes. Values are (canonical slug, interval, label,
# code).
LEGACY_PLAN_MAP = {
# MuckRock Professional
("professional", True): ("professional", "monthly", "standard", ""),
("professional", False): ("professional", "monthly", "comped", ""),
("professional-pre-paid", True): ("professional", "annual", "standard", ""),
# Beta - early users grandfathered onto a free plan, not a distinct tier
("beta", False): ("professional", "monthly", "comped", ""),
("beta", True): ("professional", "monthly", "comped", ""),
# MuckRock Organization
("organization", False): ("organization", "monthly", "comped", ""),
# Comped organizations, previously each with their own plan
("muckrock-editorial-partner", False): (
"organization",
"monthly",
"comped",
"",
),
("premium-org-comp", False): ("organization", "monthly", "comped", ""),
("education-grant", False): ("organization", "monthly", "comped", ""),
("startsmall-grants", False): ("organization", "monthly", "comped", ""),
("education-plan", False): ("organization", "monthly", "comped", ""),
# A negotiated rate, so a price of its own rather than a coupon
("insideclimate-news-plan", True): (
"organization",
"monthly",
"standard",
"insideclimate",
),
# Sunlight
("sunlight-enterprise-rnn", False): (
"sunlight-enterprise",
"annual",
"comped",
"",
),
# Admin keeps its own plan - the only one granting staff access across
# all three products - and simply gains a comped price.
("admin", False): ("admin", "monthly", "comped", ""),
# --- Plans a new customer can still pick -------------------------------
#
# These are not legacy rows being consolidated away; they are the tiers
# themselves, and they are here because a purchase resolves through this
# same table. Annual is a separate `Plan` row today rather than an
# interval, so it maps onto the canonical tier with interval="annual" -
# which is exactly what the migration does with them too.
("organization", True): ("organization", "monthly", "standard", ""),
("sunlight-essential", True): ("sunlight-essential", "monthly", "standard", ""),
("sunlight-essential-annual", True): (
"sunlight-essential",
"annual",
"standard",
"",
),
("sunlight-enhanced", True): ("sunlight-enhanced", "monthly", "standard", ""),
("sunlight-enhanced-annual", True): ("sunlight-enhanced", "annual", "standard", ""),
# Nonprofit variants are not public - `get_selected_plan()` substitutes
# one in when the checkbox is ticked - so a purchase arrives here already
# on the variant slug. Mapping them means the label comes out right
# without the form having to change.
("sunlight-nonprofit-essential", True): (
"sunlight-essential",
"monthly",
"nonprofit",
"",
),
("sunlight-nonprofit-essential-annual", True): (
"sunlight-essential",
"annual",
"nonprofit",
"",
),
("sunlight-nonprofit-enhanced", True): (
"sunlight-enhanced",
"monthly",
"nonprofit",
"",
),
("sunlight-nonprofit-enhanced-annual", True): (
"sunlight-enhanced",
"annual",
"nonprofit",
"",
),
}

# Deliberately left alone. Each needs a decision or an action outside this
# command, given per entry below.
DEFERRED_SLUGS = {
# Two organizations going opposite ways - one cancelled, one comped - so
# the slug alone cannot decide.
"custom-crp",
# Its one subscription belongs to an organization that was merged away.
"sunlight-premium-annual",
}


def resolve_target(slug, *, allow_comped):
"""The canonical (slug, interval, label, code) for a legacy plan slug.

Returns None when the slug is unmapped, which callers treat as "leave
it on the legacy plan" rather than as an error.

`allow_comped` is the distinction between the two callers. A comped
target is correct for a subscription that is *already* comped and is
being migrated; it is never correct for a new self-service purchase,
which would be handing out a free subscription. Negotiated (`code`)
targets are deliberately allowed for both: reaching one requires being
granted the private plan in the first place, and that grant is the
authorisation.
"""
target = LEGACY_PLAN_MAP.get((slug, True))
if target is None:
return None
if not allow_comped and target[2] == "comped":
return None
return target
Loading
Loading