Skip to main content

Count Paying Subscribers Correctly

The benecaster_tokens table stores three distinct populations: paying subscribers, free-tier bridge members, and direct followers. A query that counts all active tokens overcounts — it includes both non-paying groups. Getting a correct paying subscriber count requires excluding both, and they are excluded by different conditions.

The Two Exclusions

token_type = 'subscriber' — excludes direct followers

Followers sign up via the [benecaster_follower_signup] shortcode and have no bridge membership. Their rows carry token_type = 'follower'. Free-tier bridge members also carry no paid membership, but they came in through a bridge and still carry token_type = 'subscriber' — so token_type alone does not identify them as non-paying.

tm.is_free_tier = 0 — excludes free-tier bridge members

Bridge members on a $0-mapped tier (for example, a “Free” level in MemberPress mapped to Benecaster) have is_free_tier = 1 in benecaster_tier_map. They carry token_type = 'subscriber', so a token_type check alone does not exclude them.

Applying only one filter silently miscounts:

  • Checking only token_type = 'subscriber' — includes free-tier bridge members
  • Checking only is_free_tier = 0 — includes followers (who have no benecaster_tier_map row; an INNER JOIN would drop them but a careless LEFT JOIN with no null-check would not)

Use both conditions together.

The Query

global $wpdb;

$tokens   = $wpdb->prefix . 'benecaster_tokens';
$tier_map = $wpdb->prefix . 'benecaster_tier_map';

// Paying subscribers for a single show.
$count = (int) $wpdb->get_var( $wpdb->prepare(
    "SELECT COUNT(*) FROM {$tokens} t
     INNER JOIN {$tier_map} tm
         ON tm.show_id = t.show_id AND tm.internal_tier_slug = t.tier_slug
     WHERE t.show_id = %d AND t.status = 'active'
       AND t.token_type = 'subscriber' AND tm.is_free_tier = 0",
    $show_id
) );

// Site-wide paying subscriber count — used by the daily license validation payload.
$site_count = (int) $wpdb->get_var(
    "SELECT COUNT(*) FROM {$tokens} t
     INNER JOIN {$tier_map} tm
         ON tm.show_id = t.show_id AND tm.internal_tier_slug = t.tier_slug
     WHERE t.status = 'active'
       AND t.token_type = 'subscriber' AND tm.is_free_tier = 0"
);

The INNER JOIN on benecaster_tier_map naturally excludes followers — they have no tier map row. Combined with token_type = 'subscriber', both non-paying groups are excluded in a single pass.

Prefer the Repository Methods

Inside the plugin or an add-on that has access to the DI container, use the built-in repository methods rather than hand-rolling SQL:

$repository = $container->make( \Benecaster\Token\TokenRepository::class );

$show_count = $repository->count_paying_by_show( $show_id );
$site_count = $repository->count_paying_site_wide();

Hand-rolled SQL is appropriate for outside-the-container code: WP-CLI commands in sibling plugins, ad-hoc audit queries in a SQL client, or scripts running outside the WordPress request cycle.

TokenRepository::count_active_site_wide() includes followers and free-tier members — it drives the total_token_count field on the license validation payload. Use this for any total-token-cap UI. Tier upgrade thresholds (Launch, Starter, Growth) read count_paying_*; total token caps read count_active_site_wide. The split is intentional.