#13769·polar

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

Author: demegireCreated Aug 15, 2026Updated Aug 15, 2026

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):

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

Minimal reproduction

python
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.