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:
- A repeating discount is added mid-subscription via update
(subscription_update.py sets subscription.discount but leaves
discount_applied_at = None).
_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).
- 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.
Repeating discount expires one billing cycle early for subscriptions anchored on the 29th–31st
Summary
Discount.is_repetition_expiredcomputes a repeating discount's end date with araw
relativedeltamonth-add, but billing periods advance via the anchoredschedule (
RecurringInterval.get_next_period), which clamps short-month days andthen re-expands them to the anchor day. When
discount_applied_atlands on aclamped day (e.g. Feb 28) while the subscription's
anchor_dayis 29–31, theNth period start overshoots
end_atby 1–3 days and the discount is dropped onecycle early. A repeating discount with
duration_in_months = Napplies to onlyN−1 cycles, always in the customer-overcharge direction.
Location
server/polar/models/discount.py(Discount.is_repetition_expired):Minimal reproduction
end_at = 2025-04-28; anchored period starts are Feb 28, Mar 31, Apr 30;Apr 30 > Apr 28trips expiry one cycle early.How a subscription reaches this state
A plain checkout-with-discount is safe (
discount_applied_atandanchor_dayare set together, same day). The divergence needs
discount_applied_at.dayto be smaller than
anchor_day:(
subscription_update.pysetssubscription.discountbut leavesdiscount_applied_at = None)._clear_expired_discount(subscription/service.py) stampsdiscount_applied_at = current_period_startat the next renewal — a clampedday if that renewal falls in a short month (Feb 28 for
anchor_day = 31).subscription/service.py), so asubscription anchored on the 29th–31st keeps
anchor_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. iterateget_next_periodN-1timesfrom
discount_applied_atusing the subscription's interval/anchor_day, orcount 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); theend_atline is unchanged since.