Skip to content

MR-5: Metered Paywall#15

Open
av3nger wants to merge 11 commits into
feature/MR-31-paywallfrom
feature/MR-5-metered-paywall
Open

MR-5: Metered Paywall#15
av3nger wants to merge 11 commits into
feature/MR-31-paywallfrom
feature/MR-5-metered-paywall

Conversation

@av3nger

@av3nger av3nger commented May 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR adds a metered paywall on top of the existing Memberful paywall: a "free article allowance" that lets anonymous visitors and registered free members read a configurable number of matching posts within a rolling period before the paywall is shown.

What's included

  • New Memberful - Metering settings tab: enable toggle, rolling period, anonymous limit, registered free member limit, and an "apply to protected posts" option.
  • Visual rule builder. A post is metered when any rule group matches:
    • post type: is any of / is none of
    • category / tag: has any of / has none of
    • URL: contains / does not contain
  • Server-side engine: decision cached per request; paid members, exempt posts, non-matching posts, and ineligible requests (admin, feed, REST, non-GET, authors) are never metered.
  • Anonymous views tracked in a signed, HttpOnly cookie; registered views in user meta; anonymous views merge into the account on login.
  • Per-post "Exempt from metering" metabox.
  • Metering countdown block with a configurable {count} template, shown only while the visitor is still being sampled.

Test plan

  • Open Memberful - Metering, enable it, set a short period, an anonymous limit (e.g. 2) and a registered limit (e.g. 3), add a rule (e.g. post type is any of: Post), and save.
  • As a logged-out visitor, view matching posts past the limit:
    • confirm the first N render in full and the next shows the paywall
    • confirm re-opening an already-read post still renders (no double count)
    • confirm a non-matching post is never metered
  • Log in as a free member with prior anonymous views - confirm they carry over and the registered limit applies.
  • As a paid member, confirm matching posts are never metered.
  • Tick "Exempt this post from metering" and confirm the post stays fully readable and uncounted.
  • Insert the countdown block, use the {count} button, and confirm it shows the remaining count while sampling and disappears once the meter trips.
  • Disable metering and confirm no metering output or cache headers on protected posts.

Note

High Risk
Changes core content protection and paywall behavior, persists visitor state in cookies/user meta, and disables page caching on metered views—misconfiguration or edge-cache bypass could leak paywalled content or serve wrong counts.

Overview
Adds a metered paywall on top of the existing Memberful paywall: visitors get a rolling free-article allowance before content is gated.

Admin & config: New Metering settings tab (enable toggle, period, anonymous vs registered limits, optional metering of members-only posts). Include/except rule groups (post type, category/tag, URL) are edited via a new metering-admin.js UI with token-field search. Config is sanitized and stored in memberful_metering_config; a per-post Exempt from metering metabox is available when metering is on.

Runtime: Memberful_Metering_Access runs on template_redirect, matches rules, tracks views in a signed HttpOnly cookie (anonymous) or user meta (logged-in), merges anonymous views on login, and caches per-request decisions (allow_sample / trip_meter). Metered responses set no-cache headers. content_filter.php now skips paywall for sampled posts, forces the gate when the meter trips, and only applies metering to the queried singular post so related the_content passes don’t consume views.

Editor: Registers a Metering Countdown block with a {count} template, server-rendered only while the visitor is still in the free sample window.

Reviewed by Cursor Bugbot for commit dd979b9. Bugbot is set up for automated code reviews on this repo. Configure here.

@coderabbitai

coderabbitai Bot commented May 26, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added a new “Metering” settings area in the WordPress admin for configuring free-view limits, reset timing, and rule-based access.
    • Introduced a metering countdown block for editors, plus a per-post option to exempt content from metering.
    • Added rule builder controls for including/excluding content by post type, category, tag, or URL.
  • Bug Fixes

    • Improved content access handling so sample views, protected content, and metering limits behave more consistently across posts.

Walkthrough

Adds configurable metering rules, sampled-view tracking, admin editing surfaces, a metering countdown block, and per-post exemption handling across front-end and admin paths.

Changes

Metering Feature Implementation

Layer / File(s) Summary
Storage infrastructure and configuration management
src/metering/config.php, src/metering/sanitizer.php, src/metering/storage.php, src/options.php, src/urls.php
Configuration defaults, sanitization, signed anonymous view storage, user-meta view storage, pruning, option defaults, and admin URL helpers.
Metering access decisions and view enforcement
src/metering/access.php, src/metering.php, memberful-wp.php, src/block-editor.php, src/content_filter.php
Registers metering hooks, computes per-request decisions, merges anonymous and logged-in views, applies content gating, and resolves block registration for the metering countdown block.
Admin settings page and rule configuration backend
src/admin.php, views/option_tabs.php, views/metering/settings.php
Adds the metering settings page, saves configuration, exposes localized editor data, and adds the metering tab to the plugin options UI.
Admin rule UI templates for groups and conditions
views/metering/group.php, views/metering/condition.php, views/metering/chip.php, views/metering/icon-remove.php
Server-rendered templates for rule groups, condition rows, value chips, and remove icons used by the metering settings interface.
Admin JavaScript and styling for dynamic rule editing
js/src/metering-admin.js, webpack.config.js, stylesheets/admin.css
Client-side logic for adding and editing rule groups and tokenfield values, plus the admin stylesheet and build entry for the metering admin bundle.
Metering countdown Gutenberg block
js/src/blocks/metering-countdown/block.json, js/src/blocks/metering-countdown/edit.js, js/src/blocks/metering-countdown/index.js, js/src/blocks/metering-countdown/render.php, js/src/editor-scripts.js
Registers the metering countdown block, provides the editor UI for inserting the {count} placeholder, and renders the sampled message on the server.
Per-post metering exemption controls
src/metering/metabox.php, views/metering/metabox.php
Registers the metabox for exempting a post from metering and renders the checkbox UI used by the save handler.

Possibly Related PRs

  • TheCodeCompany/memberful-wp#8: Shares the content-gating path in src/content_filter.php; this PR changes how metering decisions alter that flow.

Poem

A tiny meter starts to tick,
With rules and chips and admin clicks.
{count} appears on pages bright,
While exempt posts slip from sight.
Cookies hum and settings bloom,
Quiet meters fill the room.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: adding a metered paywall.
Description check ✅ Passed The description clearly matches the changeset and explains the new metering paywall, settings, rules, tracking, and block.
Docstring Coverage ✅ Passed Docstring coverage is 88.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/MR-5-metered-paywall
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch feature/MR-5-metered-paywall

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@wordpress/wp-content/plugins/memberful-wp/src/metering/sanitizer.php`:
- Around line 30-31: The sanitizer currently coerces missing limits to 0 by
using $input['anonymous_limit'] ?? 0 and $input['registered_limit'] ?? 0 which
can prematurely trip meters; update the logic in the sanitizer to fall back to
the provided $defaults (e.g. $defaults['anonymous_limit'] and
$defaults['registered_limit']) before applying absint and min, so compute
$anonymous_limit = absint($input['anonymous_limit'] ??
$defaults['anonymous_limit']) and $registered_limit =
absint($input['registered_limit'] ?? $defaults['registered_limit']) and then set
$clean['anonymous_limit'] = min($anonymous_limit,
Memberful_Metering_Storage::MAX_VIEWS) and $clean['registered_limit'] =
min($registered_limit, Memberful_Metering_Storage::MAX_VIEWS).

In `@wordpress/wp-content/plugins/memberful-wp/stylesheets/admin.css`:
- Around line 170-172: The section comment
"/*--------------------------------------------------------- Metering
------------------------------------------------------------ */" violates
Stylelint's comment-whitespace-inside; update the comment in admin.css (the
Metering section header) to include a space after the opening /* (e.g. "/*
---------------------------------------------------------") so there is
whitespace inside the comment delimiters and the linter rule passes.

In `@wordpress/wp-content/plugins/memberful-wp/views/option_tabs.php`:
- Around line 18-22: The translated tab title for the 'metering' tab is missing
the plugin text domain; update the array entry with id 'metering' so the 'title'
uses the plugin domain (use __('Metering', 'memberful')) instead of __(
'Metering' ) to ensure proper translation loading in Memberful's locale
files—modify the 'title' value in the array where id => 'metering' and leave the
rest unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: TheCodeCompany/coderabbit/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 13cf76e1-dd64-4067-8296-8dbb280c28c5

📥 Commits

Reviewing files that changed from the base of the PR and between e6f32c1 and 29c7c23.

📒 Files selected for processing (23)
  • wordpress/wp-content/plugins/memberful-wp/js/src/blocks/metering-countdown/block.json
  • wordpress/wp-content/plugins/memberful-wp/js/src/blocks/metering-countdown/edit.js
  • wordpress/wp-content/plugins/memberful-wp/js/src/blocks/metering-countdown/index.js
  • wordpress/wp-content/plugins/memberful-wp/js/src/blocks/metering-countdown/render.php
  • wordpress/wp-content/plugins/memberful-wp/js/src/editor-scripts.js
  • wordpress/wp-content/plugins/memberful-wp/js/src/metering-admin.js
  • wordpress/wp-content/plugins/memberful-wp/memberful-wp.php
  • wordpress/wp-content/plugins/memberful-wp/src/admin.php
  • wordpress/wp-content/plugins/memberful-wp/src/block-editor.php
  • wordpress/wp-content/plugins/memberful-wp/src/content_filter.php
  • wordpress/wp-content/plugins/memberful-wp/src/metering.php
  • wordpress/wp-content/plugins/memberful-wp/src/metering/access.php
  • wordpress/wp-content/plugins/memberful-wp/src/metering/config.php
  • wordpress/wp-content/plugins/memberful-wp/src/metering/metabox.php
  • wordpress/wp-content/plugins/memberful-wp/src/metering/sanitizer.php
  • wordpress/wp-content/plugins/memberful-wp/src/metering/storage.php
  • wordpress/wp-content/plugins/memberful-wp/src/options.php
  • wordpress/wp-content/plugins/memberful-wp/src/urls.php
  • wordpress/wp-content/plugins/memberful-wp/stylesheets/admin.css
  • wordpress/wp-content/plugins/memberful-wp/views/metering/metabox.php
  • wordpress/wp-content/plugins/memberful-wp/views/metering/settings.php
  • wordpress/wp-content/plugins/memberful-wp/views/option_tabs.php
  • wordpress/wp-content/plugins/memberful-wp/webpack.config.js

Comment thread wordpress/wp-content/plugins/memberful-wp/src/metering/sanitizer.php Outdated
Comment thread wordpress/wp-content/plugins/memberful-wp/stylesheets/admin.css Outdated
Comment thread wordpress/wp-content/plugins/memberful-wp/views/option_tabs.php
Comment thread wordpress/wp-content/plugins/memberful-wp/src/metering/access.php Outdated
Comment thread wordpress/wp-content/plugins/memberful-wp/src/metering/config.php
Comment thread wordpress/wp-content/plugins/memberful-wp/src/metering/access.php

if ( Memberful_Metering_Access::DECISION_ALLOW_SAMPLE === $metering_decision ) {
return memberful_wp_strip_paywall_divider_marker( $content );
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metering sample bypasses member ACL

High Severity

When apply_to_protected_posts is enabled, a visitor within their free allowance can receive DECISION_ALLOW_SAMPLE on a members-only post. memberful_wp_protect_content then returns the full post without calling memberful_can_user_access_post, so unpaid users can read restricted content while the meter only decrements their allowance.

Fix in Cursor Fix in Web

Triggered by team rule: Code Review Guidelines

Reviewed by Cursor Bugbot for commit 8bc1dda. Configure here.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
wordpress/wp-content/plugins/memberful-wp/src/metering/access.php (1)

305-309: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid repeated term queries for taxonomy conditions.

condition_matches() can call term_slugs_for_post() multiple times per request for the same post/taxonomy, causing redundant wp_get_post_terms() lookups.

  private static function term_slugs_for_post( int $post_id, string $taxonomy ): array {
    static $cache = array();

    $cache_key = $post_id . ':' . $taxonomy;
    if ( isset( $cache[ $cache_key ] ) ) {
      return $cache[ $cache_key ];
    }

    $terms = wp_get_post_terms( $post_id, $taxonomy, array( 'fields' => 'all' ) );
    if ( is_wp_error( $terms ) || empty( $terms ) ) {
      $cache[ $cache_key ] = array();
      return $cache[ $cache_key ];
    }

    $result = array();
    foreach ( $terms as $term ) {
      $result[] = strtolower( $term->slug );
      $result[] = strtolower( $term->name );
    }

    $cache[ $cache_key ] = array_values( array_unique( $result ) );
    return $cache[ $cache_key ];
  }

Also applies to: 346-359

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wordpress/wp-content/plugins/memberful-wp/src/metering/access.php` around
lines 305 - 309, The term_slugs_for_post() method performs redundant
wp_get_post_terms() lookups when called multiple times for the same post and
taxonomy combination within a single request. Implement a static cache within
the term_slugs_for_post() method using a cache key combining post_id and
taxonomy (e.g., "post_id:taxonomy"). At the start of the method, check if the
result already exists in this static cache and return it immediately if found.
If not cached, proceed with the wp_get_post_terms() call, then store the
processed result (including both lowercase slugs and names) in the static cache
before returning it. This eliminates redundant database queries for the same
post/taxonomy combinations across multiple condition evaluations in a single
request.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@wordpress/wp-content/plugins/memberful-wp/src/metering/access.php`:
- Around line 305-309: The term_slugs_for_post() method performs redundant
wp_get_post_terms() lookups when called multiple times for the same post and
taxonomy combination within a single request. Implement a static cache within
the term_slugs_for_post() method using a cache key combining post_id and
taxonomy (e.g., "post_id:taxonomy"). At the start of the method, check if the
result already exists in this static cache and return it immediately if found.
If not cached, proceed with the wp_get_post_terms() call, then store the
processed result (including both lowercase slugs and names) in the static cache
before returning it. This eliminates redundant database queries for the same
post/taxonomy combinations across multiple condition evaluations in a single
request.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: TheCodeCompany/coderabbit/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 73b27091-dc92-486f-a075-97f4e09e0940

📥 Commits

Reviewing files that changed from the base of the PR and between a095303 and 8bc1dda.

📒 Files selected for processing (11)
  • wordpress/wp-content/plugins/memberful-wp/js/src/metering-admin.js
  • wordpress/wp-content/plugins/memberful-wp/src/admin.php
  • wordpress/wp-content/plugins/memberful-wp/src/metering/access.php
  • wordpress/wp-content/plugins/memberful-wp/src/metering/config.php
  • wordpress/wp-content/plugins/memberful-wp/src/metering/sanitizer.php
  • wordpress/wp-content/plugins/memberful-wp/stylesheets/admin.css
  • wordpress/wp-content/plugins/memberful-wp/views/metering/chip.php
  • wordpress/wp-content/plugins/memberful-wp/views/metering/condition.php
  • wordpress/wp-content/plugins/memberful-wp/views/metering/group.php
  • wordpress/wp-content/plugins/memberful-wp/views/metering/icon-remove.php
  • wordpress/wp-content/plugins/memberful-wp/views/metering/settings.php

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit dd979b9. Configure here.

if ( $user_id && memberful_can_user_access_post( $user_id, $post->ID ) ) {
self::cache( $post->ID, self::DECISION_IGNORE );
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registered-only posts skip metering

Medium Severity

Using anonymous ACL failure to mark a post as protected, then skipping metering whenever the logged-in visitor already has access, stops view counting for matching “any registered user” (and similar ACL) posts. Logged-in free members no longer consume their configured registered limit on that content, unlike the prior acl_gated check tied to the current user.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit dd979b9. Configure here.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
wordpress/wp-content/plugins/memberful-wp/views/metering/settings.php (1)

24-25: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use the scope-specific fallback match.

$match falls back to 'all' for both scopes, but new exclude_rules groups default to 'any' later in this file. If saved data is missing or carries an invalid match, reopening and saving the form will silently tighten exception groups and meter posts that should stay excluded.

    $conditions    = isset( $group['conditions'] ) && is_array( $group['conditions'] ) ? $group['conditions'] : array();
    $default_match = 'exclude_rules' === $scope ? 'any' : 'all';
    $match         = isset( $group['match'] ) && in_array( $group['match'], Memberful_Metering_Config::MATCH_TYPES, true ) ? $group['match'] : $default_match;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wordpress/wp-content/plugins/memberful-wp/views/metering/settings.php` around
lines 24 - 25, The scope-specific default for the group match is not being
preserved in the settings form, so invalid or missing values always fall back to
"all". Update the logic in settings.php where $conditions and $match are
initialized to use a scope-aware fallback based on $scope (keeping exclude_rules
on "any" and other scopes on "all"), and make sure the later
form-saving/rendering paths use that same default behavior so reopening and
saving does not tighten exclusion groups.
wordpress/wp-content/plugins/memberful-wp/src/metering/access.php (1)

325-335: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Match URL rules against full permalinks too.

Line 326 strips the permalink to only its path, so an admin-entered full URL like https://example.com/news/foo will not match /news/foo. That can silently skip metering or exclusion rules configured with copied permalinks.

      case 'url':
        $permalink = (string) get_permalink( $post->ID );
        $path      = wp_parse_url( $permalink, PHP_URL_PATH );
        $path      = is_string( $path ) ? $path : '';
        $matches   = false;
        foreach ( $values as $fragment ) {
          $fragment = (string) $fragment;
          if ( '' !== $fragment && ( false !== strpos( $path, $fragment ) || false !== strpos( $permalink, $fragment ) ) ) {
            $matches = true;
            break;
          }
        }
        break;

As per path instructions, review possible edge cases and unexpected behaviour for PHP/WordPress code.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wordpress/wp-content/plugins/memberful-wp/src/metering/access.php` around
lines 325 - 335, The URL matching logic in the access metering rule currently
only checks the parsed path from get_permalink(), so full URLs entered in admin
rules will not match. Update the 'url' case in access.php to compare each
fragment against both the permalink path and the full permalink string, using
the existing get_permalink(), wp_parse_url(), and strpos() flow, so copied URLs
like https://example.com/news/foo are matched correctly.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@wordpress/wp-content/plugins/memberful-wp/src/metering/access.php`:
- Around line 325-335: The URL matching logic in the access metering rule
currently only checks the parsed path from get_permalink(), so full URLs entered
in admin rules will not match. Update the 'url' case in access.php to compare
each fragment against both the permalink path and the full permalink string,
using the existing get_permalink(), wp_parse_url(), and strpos() flow, so copied
URLs like https://example.com/news/foo are matched correctly.

In `@wordpress/wp-content/plugins/memberful-wp/views/metering/settings.php`:
- Around line 24-25: The scope-specific default for the group match is not being
preserved in the settings form, so invalid or missing values always fall back to
"all". Update the logic in settings.php where $conditions and $match are
initialized to use a scope-aware fallback based on $scope (keeping exclude_rules
on "any" and other scopes on "all"), and make sure the later
form-saving/rendering paths use that same default behavior so reopening and
saving does not tighten exclusion groups.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: TheCodeCompany/coderabbit/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 943b259b-e4f2-44f0-b016-faa5fdf141c8

📥 Commits

Reviewing files that changed from the base of the PR and between 8bc1dda and dd979b9.

📒 Files selected for processing (5)
  • wordpress/wp-content/plugins/memberful-wp/src/metering/access.php
  • wordpress/wp-content/plugins/memberful-wp/src/metering/config.php
  • wordpress/wp-content/plugins/memberful-wp/src/metering/storage.php
  • wordpress/wp-content/plugins/memberful-wp/stylesheets/admin.css
  • wordpress/wp-content/plugins/memberful-wp/views/metering/settings.php

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant