Skip to content

Repeating discount expires one billing cycle early for subscriptions anchored on the 29th–31st #13769

Description

@demegire

Repeating discount expires one billing cycle early for subscriptions anchored on the 29th–31st

Summary

Discount.is_repetition_expired computes a repeating discount's end date with a
raw relativedelta month-add, but billing periods advance via the anchored
schedule (RecurringInterval.get_next_period), which clamps short-month days and
then re-expands them to the anchor day. When discount_applied_at lands on a
clamped day (e.g. Feb 28) while the subscription's anchor_day is 29–31, the
Nth period start overshoots end_at by 1–3 days and the discount is dropped one
cycle early. A repeating discount with duration_in_months = N applies to only
N−1 cycles
, always in the customer-overcharge direction.

Location

server/polar/models/discount.py (Discount.is_repetition_expired):

end_at = discount_applied_at + relativedelta(months=self.duration_in_months - 1)
return current_period_start > end_at

Minimal reproduction

from datetime import datetime
from dateutil.relativedelta import relativedelta
import calendar

def next_period(d, anchor_day):                    # RecurringInterval.get_next_period (month)
    nxt = d + relativedelta(months=1)
    if nxt.day != anchor_day:
        _, mx = calendar.monthrange(nxt.year, nxt.month)
        nxt = nxt.replace(day=min(anchor_day, mx))
    return nxt

def is_repetition_expired(N, applied_at, period_start):   # current code
    end_at = applied_at + relativedelta(months=N - 1)
    return period_start > end_at

anchor, applied, N = 31, datetime(2025, 2, 28), 3
p = applied; discounted = 0
for _ in range(N + 2):
    if is_repetition_expired(N, applied, p): break
    discounted += 1
    p = next_period(p, anchor)

print(discounted)   # -> 2   (expected 3: the April cycle loses its discount)

end_at = 2025-04-28; anchored period starts are Feb 28, Mar 31, Apr 30;
Apr 30 > Apr 28 trips expiry one cycle early.

How a subscription reaches this state

A plain checkout-with-discount is safe (discount_applied_at and anchor_day
are set together, same day). The divergence needs discount_applied_at.day
to be smaller than anchor_day:

  1. A repeating discount is added mid-subscription via update
    (subscription_update.py sets subscription.discount but leaves
    discount_applied_at = None).
  2. _clear_expired_discount (subscription/service.py) stamps
    discount_applied_at = current_period_start at the next renewal — a clamped
    day if that renewal falls in a short month (Feb 28 for anchor_day = 31).
  3. Re-anchoring only happens on trial exit (subscription/service.py), so a
    subscription anchored on the 29th–31st keeps anchor_day large.

Impact

Merchant-of-Record over-charges the customer by exactly one cycle's discount
(e.g. "$20/mo off for 6 months" delivers 5). The renewal preview understates it
too, so it is not visibly wrong before the charge.

Suggested fix

Derive the end of the discount window from the anchored schedule rather than
a single clamped relativedelta — e.g. iterate get_next_period N-1 times
from discount_applied_at using the subscription's interval/anchor_day, or
count elapsed anchored cycles, and compare cycle index rather than raw date.

Note on discovery

Found by an automated invariant check ("a repeating N-month discount applies to
exactly N billing cycles") run against the money kernels, then reproduced with
the real functions and confirmed reachable by tracing the call sites. The
property is machine-checked in Lean (proved for the anchored/corrected rule for
all N; refuted for the current formula on the witness above). Introduced in
3283f4add (PR #8827); the end_at line is unchanged since.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions