TokenRepository
\Benecaster\Token\TokenRepository
Data-access layer for subscriber feed tokens — the supported entry point for reading and writing token records. Provides typed query methods for token lookup, creation, and mutation. Consumed by controllers and cron jobs that need direct token access without going through the full activation cascade.
Methods
| Method | Visibility | Since | Description |
|---|---|---|---|
insert( int $user_id, int $show_id, string $token_hash, string $token_prefix, string $tier_slug, string $token_type = 'subscriber', ?string $locale = null, ?string $created_at = null ): bool |
Public | — | Inserts a new token row. $token_type defaults to subscriber (bridge or built-in member); pass follower for free direct-signup tokens, which never count toward the paying-subscriber limit. $created_at exists for exactly one reason: migration. A podcaster moving an established audience across from Patreon, Supercast or a membership plugin arrives holding a join date per subscriber, and TenureBadgeCalculator derives tenure from the earliest created_at across a user's active tokens. Without a way to carry the original date over, every imported token is stamped at import time and a five-year supporter renders as a New Member. Pass a MySQL DATETIME string (Y-m-d H:i:s) in UTC. A bare Y-m-d is also accepted and widens to midnight UTC, because that is the shape a CSV export actually has. Pass null — the default — and insert() stamps gmdate() itself; it no longer leans on the column's CURRENT_TIMESTAMP default, so any doc saying that default applies is out of date. The column is still omitted from the INSERT rather than sent as NULL for a null argument, so behaviour for existing callers is unchanged. A refused date throws and no row is written. The value is validated by BackdateGuard, which never clamps: a date in the future, older than BackdateGuard::FLOOR, or in a format it cannot read raises InvalidArgumentException. The guard is applied here because every write path funnels through this method, so it cannot be bypassed. An importer walking many rows should ask BackdateGuard::reason() per row first, so it can report every bad row at once instead of aborting on the first. Prefer TokenManager::generate() if you need the plaintext token — this method never returns it. |
set_created_at( int $id, string $created_at ): bool |
Public | — | Backdates an existing token row's created_at. Takes Y-m-d H:i:s or a bare Y-m-d, UTC, and throws InvalidArgumentException on a date BackdateGuard refuses. It exists alongside insert() because insert() cannot reach the manual-add flow — there the token is minted by the activation cascade several layers below the operator's form, so the date is applied afterwards. Both run the same BackdateGuard: an UPDATE that skipped it would be a route around a rule the INSERT enforces, reachable from an admin form. Why there are two write paths, since the second reads as redundant otherwise. insert() serves a caller that mints the token itself — an importer, for instance. The manual-subscriber flow cannot use it: there the token is minted by the activation cascade (benecaster_subscription_activated → SubscriptionListener → TokenManager) several layers below the operator's form, so the date has to be applied to the row afterwards. This method sits beside set_manual_grant(), which stamps the same row for the same reason. It runs the same BackdateGuard as insert(), and must continue to. Two write paths and one guard is only safe while both paths actually run it — an UPDATE that skipped the check would be a way around a rule the INSERT enforces, reachable from an admin form rather than from an importer. A caller that wants to report rather than abort should ask BackdateGuard::reason() first. The REST layer does exactly that, before it provisions anything. |
set_manual_grant( int $id, bool $is_manual_grant, ?string $expiry_at ): bool |
Public | — | Updates is_manual_grant and manual_expiry_at on a token row in a single UPDATE. Returns true on success. Used by ManualSubscriberController and admin flows that revoke or re-grant manual status. |
find_expired_manual_grants( string $now_utc, int $limit = 500 ): object[] |
Public | — | Returns active manual-grant token rows whose manual_expiry_at has elapsed. $now_utc is a UTC datetime string (YYYY-MM-DD HH:MM:SS). Consumed by the hourly manual-grant expiry job. Bounded by $limit (default 500). |
revoke( int $id, ?string $reason = null ): bool |
Public | — | Revokes a token row by ID. The second parameter stamps revocation_reason, either to the given sentinel or to NULL (recoverable). Prefer TokenManager::revoke() over calling this directly: the manager fires the benecaster_token_revoked cascade and clears feed cache, and this method does neither. |
find_active_token_type( int $user_id, int $show_id ): ?string |
Public | — | The raw stored type of the user's ACTIVE token on a show, or null when they hold none (never had one, or it is revoked). It is not collapsed to subscriber or follower: it can also be purchaser or a type an add-on registered. Resolve behaviour with TokenTypeRegistry::resolve_base() and display with label_for(). It is the one question the front-end admin surfaces ask to tell a follower from a subscriber. Classification only, never a gate: callers use it to mark a follower differently, not to hide one — followers stay included. Consumed by FollowerBadgeAppender::append_follower_badge() and BadgeChipRenderer::token_type_for(). |
find_never_accessed_candidates( int $grace_days, int $limit, int $offset ): object[] |
Public | — | Returns active tokens issued more than $grace_days ago whose last_accessed_at is still NULL and whose engagement_status is not already never_accessed. Paginated via $limit/$offset. Consumed by SubscriberEngagementCron's never-accessed pass. The cron passes its own hardcoded default (7), not the filtered per-show value — which is why benecaster_subscriber_never_accessed_grace_days cannot lower the window below 7. |
reset( int $id, string $new_token_hash, string $new_token_prefix ): bool |
Public | — | Rotates a token's hash and prefix. It un-revokes on the same UPDATE: it also sets status = 'active', clears revoked_at and revocation_reason, and resets engagement_status = 'active'. This makes Reset the single podcaster-facing path for reinstating a deliberately revoked subscriber. A bridge activation event cannot do it — generate_or_reactivate() refuses a non-recoverable revocation_reason and fires benecaster_token_reactivation_blocked instead. Reset is the way back. On an already-active row the un-revoke columns are no-ops, so this is safe on the ordinary reset path. benecaster_token_reset still fires with an unchanged signature. |
get_subscriber_summary( int $show_id ): array |
Public | — | Per-show counts for the subscriber-list summary chips. Keys: active, paying, followers, by_tier and never — the last being the count of status = 'active' AND engagement_status = 'never_accessed'. never is strict and cron-driven, counting only tokens the nightly job has confirmed were never accessed; it deliberately does not sweep in fresh signups the way a bare last_accessed_at IS NULL test would. |
get_newest_token_ids( int $count ): array |
Public | — | Returns the tokens that give up private-feed access first when a site is over its total token cap. Read the ordering contract in the notes rather than the method name — the name does not describe what it returns. |
get_all_active_token_ids(): array |
Public | — | Every active token site-wide, unordered and unlimited. Powers the payment-failure grace path, where the whole set is affected and selection order is irrelevant. |
find_subscribers_paginated( int $show_id, int $page = 1, int $per_page = 20, string $status = 'active', ?string $tier_slug = null, ?string $search = null, ?string $last_seen = null, ?string $migration_filter = null, string $orderby = 'created_at', string $order = 'DESC', ?string $token_type = null ): array |
Public | — | Paginated subscriber rows for the Subscribers screen, returning items and total. It also selects free_token_expr() as is_free_token_raw, so the REST controller can resolve each row's over_limit_effect from one query rather than asking per token. The raw column is an input to that decision, not the decision — consumers should read the published over_limit_effect rather than re-deriving the outcome from this column and the tier. $token_type ('subscriber' / 'follower', or null for both) is a presentation filter only: it narrows which rows come back but never changes the summary counts, which come from get_subscriber_summary() and take no filters of their own. |
token_is_free( int $token_id ): bool |
Public | — | INTERNAL — public for the feed's own use, not developer surface. Do not call it from an add-on. It has no deprecation path and can change or disappear without notice. Whether a single token counts as free-tier, under the same definition the over-cap ordering uses. Consumed by the feed's over-cap lock, which must never lock a paying customer. Returns false for an unknown token id — an unresolvable token is treated as paying, the safe direction here exactly as it is in the ordering. If you are reaching for this to tell a demotion from a lockout, there is a supported answer: the over_limit_effect key on benecaster_feed_request_dispatched — 'demoted' or 'locked'. That is the one thing add-ons wanted this method for. |
sync_over_limit_flags( array $ids ): void |
Public | — | Sets is_over_limit on exactly the supplied token IDs and clears it on every other active token, in one operation. The single write path for over-limit state. |
clear_all_over_limit_flags(): void |
Public | — | Clears every over-limit flag. Used when a plan is unlimited on both axes. |
clear_over_limit( int $id ): bool |
Public | — | Clears the over-limit flag on one token, where clear_all_over_limit_flags() is the site-wide sweep. It exists because a token type transition can move a row out of the axis that flagged it: a subscriber flagged over-limit who converts to a purchaser is no longer selected by either over-limit query, so nothing else would ever clear the flag. |
revoke_all_active_with_reason( string $reason ): int |
Public | — | Bulk-revokes every active token, tagging each with a reason so recovery can restore exactly this set. Returns the count revoked. |
restore_revoked_with_reason( string $reason ): int |
Public | — | Restores only tokens carrying the given revocation reason, leaving admin, bridge and manual revocations made in the same window untouched. Returns the count restored. |
free_token_expr(): string |
— | — | The current single definition of "this token is free" — used by the over-cap selection order, the feed's over-cap lock, and find_subscribers_paginated()'s is_free_token_raw column. Replaces the deprecated FREE_TOKEN_EXPR constant: rather than hard-coding 'follower', it builds a CASE WHEN t.token_type IN (…) THEN 1 ELSE COALESCE(tm.is_free_tier, 0) END from PAYING_EXEMPT_TOKEN_TYPES, already bound through wpdb::prepare() — no leftover placeholders. It is an SQL fragment, like the constant it replaces: expects the tokens table aliased t and the tier map aliased tm, not a value to compare against in PHP. The fragment needs BOTH tier sources LEFT JOINed, as tm (benecaster_tier_map) and mt (benecaster_membership_tiers). A caller that joins only the tier map gets a SQL error. A bridge show has rows in tm and a built-in-membership show has rows in mt, so asking one table answers nothing for the other kind. Free now means every source that has a row agrees the tier is free; a missing row does not vote, and no row at all means paying, the direction that cannot under-count. |
cap_exempt_condition( string $alias = '' ): string |
— | — | The token-cap exclusion as an already-prepared SQL fragment (token_type NOT IN (…) over CAP_EXEMPT_TOKEN_TYPES, values bound, no placeholders left). Same shape and same reason as free_token_expr(): it can be concatenated into a query that is not itself passed to prepare(). $alias is the table alias plus its dot, or empty for an unaliased query. |
Constants
| Name | Value | Description |
|---|---|---|
FREE_TOKEN_EXPR |
( CASE WHEN t.token_type = 'follower' THEN 1 ELSE COALESCE(tm.is_free_tier, 0) END ) |
Deprecated — use free_token_expr() instead. It does not reflect current behaviour: it describes a one-table shape, which reads tm alone and is wrong for a built-in-membership show. This constant reads only 'follower', not the full PAYING_EXEMPT_TOKEN_TYPES list, so it goes wrong the moment a second exempt type exists (Episode Sales' 'purchaser', for one). Nothing in Benecaster reads it; it remains only so code that references it does not break. It defines "free" as a follower token, or a token on a tier mapped as free. |
CAP_EXEMPT_TOKEN_TYPES |
[ 'purchaser' ] |
Token types that do not count toward the total token cap and are never flagged over the cap. This is a different list from PAYING_EXEMPT_TOKEN_TYPES, and neither is a superset of the other. A follower pays nothing, so it is on the paying-exempt list, but it occupies a token, so it is not cap-exempt. A purchaser is on both: its access was bought, and Benecaster does not take away access somebody bought. Read both lists together before assuming one implies the other. Neither list has a filter: no add-on may opt its listeners out of a plan limit. |
PAYING_EXEMPT_TOKEN_TYPES |
[ 'follower', 'purchaser' ] |
Token types that never count as paying, whatever tier they sit on. The rule is never to under-count. Paying is every active token on a paid tier except these types — a deny-list, not an allow-list of 'subscriber'. A type Benecaster has never heard of (one an add-on writes, or a deactivated add-on's rows) therefore counts toward the plan's limit and can be flagged over-limit exactly like a subscriber. Every paying query and free_token_expr() read this one list. Deliberately no filter — an exemption is Benecaster's call, since anything that could add a type here could opt its listeners out of the plan's limit. |
Notes
The selection order for get_newest_token_ids() is the contract; the method name is not. It does not return purely "newest" — it returns who gives up access first. An add-on author going by the name alone will draw the wrong conclusion.
The order is: every free-tier token before any paying token, and within each group newest first. Free-tier listeners are exhausted completely before a single paying subscriber is selected. 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.
Free status is not a tiebreaker between identical timestamps: a paying subscriber who joined yesterday is never selected ahead of a free member who joined a year ago.
Why the ordering is load-bearing rather than a nicety. For a paying token, selection means demotion to the public feed. For a free token under cap enforcement it now means a 410 — the feed stops serving — and the ordering is what keeps a paying customer out of that branch. Anything that reorders this is a billing-fairness change, not a query optimisation.
"Free" has exactly one definition, and that is deliberate. The query that decides who is flagged over-limit and the query that decides who is locked at feed time share a single expression (free_token_expr(), replacing the deprecated FREE_TOKEN_EXPR constant) rather than each spelling the rule out. It is the exact complement of the paying definition the subscriber counts use — both are built from the same deny-list, PAYING_EXEMPT_TOKEN_TYPES, so "exact complement" holds however many token types exist.
The two queries must not be able to disagree, and the failure modes are asymmetric but both bad. A token flagged free but evaluated as paying at feed time would be demoted and never locked — the enforcement quietly does nothing. The reverse would 410 a paying customer. Neither would raise an error. If you are changing what counts as free, change the one expression; do not add a second test next to it.
The two exemption lists exist because the two limits ask different questions. PAYING_EXEMPT_TOKEN_TYPES answers "does this listener pay?" and holds follower and purchaser. CAP_EXEMPT_TOKEN_TYPES answers "does this listener occupy a token the plan counts?" and holds purchaser only. follower is on the first list and not the second.
count_active_by_tier() excludes purchaser; count_active_by_show() does not. A purchaser's stored tier_slug is a snapshot of a tier they no longer hold, so counting them on it would overstate that tier. They are still part of the show's audience, so the show total keeps them.