Skip to main content

OverLimitFlagManager

\Benecaster\License\OverLimitFlagManager

Class Premium

Evaluates over-limit state on every licence validation cycle and maintains the is_over_limit flag on subscriber tokens. Two independent limits are checked: the paying-subscriber limit and the total token cap. The union of both flagged sets is written in one sync; flags clear themselves on the next cycle once counts fall back inside both limits, with no subscriber action required.

This class is the only consumer of the two enforcement preferences, and that is a deliberate contract rather than an accident of where the code happened to go. Two surfaces read it back downstream, both through OverLimitEffect::resolve() rather than the option directly — FeedController::should_lock_over_cap_free_token() and SubscribersController — and the notes below explain why that is legitimate for them and nowhere else.

Constructor Dependencies

Type Description
\Benecaster\License\CapChecker Supplies the current plan's subscriber limit and token cap.
\Benecaster\Token\TokenRepository Reads active tokens and writes the is_over_limit flag.
\Benecaster\License\LicenseManager Supplies the enforcement preferences. Added as a third constructor parameter in feature/enforcement-preference-and-cap-lock — a breaking signature change for anything constructing this class directly.

Methods

Method Visibility Since Description
register(): void Public Hooks on_license_validated() to the benecaster_license_validated action.
on_license_validated( array $response ): void Public Recomputes and syncs flags for the current counts. On a plan unlimited on both axes, clears all flags and returns.
flag_all_active_tokens(): void Public Flags every active token site-wide, ignoring both enforcement preferences — see the notes. It has no caller in the plugin today. GraceExpiryTracker used to call it at Day 30; that path now bulk-revokes instead (revoke_all_active_with_reason( 'grace_timeout' )), so grace expiry no longer produces flagged tokens. The method stays public and callable by add-ons, but nothing in Benecaster reaches it.

Hooks Fired

  • benecaster-over-limit-flags-evaluated

Notes

The two axes answer to their preferences differently, and the asymmetry is intended.

The paying-subscriber limit flags under both settings. Demotion to the public feed is the correct interim outcome either way, because the licence server's rolling 24-hour window takes a day to mature and the site is over its limit throughout. Under auto-upgrade the plan is raised at the end of that window and the flags clear on the next cycle; under cap they persist.

The token cap flags only under cap. Under auto-upgrade the podcaster has asked for their bill to move rather than their listeners' access, so nothing is flagged locally and the server's cap window raises the plan instead.

Who gives up access first is a contract, not an implementation detail. Free-tier tokens are exhausted completely before a single paying token is selected, and within each group the newest go first — free status is the primary sort key, not a tiebreak between identical timestamps. A token whose tier cannot be resolved is treated as paying and sorts last, so it is never selected while a known free token remains. The ordering itself lives in TokenRepository::get_newest_token_ids(); this class is what consumes it.

The lock is what made that ordering load-bearing. Under demotion, selecting a paying subscriber ahead of a free one was merely unfair — they still received a working feed. Under locking, the same mistake means a paying customer gets a 410 having paid you. Anything that reorders this is a billing-fairness change, not a query optimisation, and it should be reviewed as one.

Do not move either preference check downstream — into FeedController or an add-on. is_over_limit is a single boolean with no record of which axis set it, so a consumer downstream of this class cannot consult one preference without getting the other axis wrong: reading the token-cap preference in the feed would wrongly release subscriber-axis flags, and the reverse. Gating at the flagging site keeps the decision where the axis is still known, and the feed inherits correct behaviour without a second read.

The one sanctioned exception, and why it does not break the rule above. FeedController::should_lock_over_cap_free_token() re-reads the token-cap preference — legitimate only because it has already narrowed the token to a free one, and the subscriber axis never flags free tokens. A flagged free token can therefore only have come from the cap axis, which makes the axis knowable again and the read safe. It is belt-and-braces rather than load-bearing: under auto-upgrade the cap axis flags nothing for it to act on. Do not treat it as licence for a second reader — it works because of a narrowing that a general consumer does not have.

flag_all_active_tokens() ignores both preferences, deliberately. It is a payment-failure consequence rather than a limit consequence, and no plan the licence server could upgrade to would resolve an unpaid licence. It is also the one path that flags free tokens without the cap axis being involved — anything downstream inferring "a flagged free token means the cap axis" must exclude it.

Billing is never touched by an over-limit condition. Over-limit enforcement behaves identically across every bridge, including the Stripe-backed built-in membership: the token is flagged and the feed is demoted or locked. Benecaster must never pause, cancel or refund a subscription in response to an over-limit condition, even where it owns the Stripe path directly — the podcaster is over a limit they can resolve, and their subscribers are paying for access they still substantially have.

Do not read that as "Benecaster never pauses billing", which stopped being true on 2026-08-31. HardCutoffBillingPauser stops billing on built-in-membership subscriptions when the podcaster's own licence reaches the hard cutoff described in Payment failure — a different trigger, a different fault, and one where the feed has stopped entirely. Nothing in this class participates in it.