Repeating discount expires one billing cycle early for subscriptions anchored on the 29th–31st
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_atMinimal 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:
- A repeating discount is added mid-subscription via update
(
subscription_update.pysetssubscription.discountbut leavesdiscount_applied_at = None). _clear_expired_discount(subscription/service.py) stampsdiscount_applied_at = current_period_startat the next renewal — a clamped day if that renewal falls in a short month (Feb 28 foranchor_day = 31).- Re-anchoring only happens on trial exit (
subscription/service.py), so a subscription anchored on the 29th–31st keepsanchor_daylarge.
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.
Source: polarsource/polar