ManualSubscriberController
\Benecaster\REST\ManualSubscriberController
Admin endpoint that comps a subscriber onto a paid tier without going through Stripe — the “Add subscriber manually” action. Registers POST /wp-json/benecaster/v1/shows/{id}/subscribers/manual with permission_callback_admin (manage_options + wp_rest nonce).
Flow: validate the show and that tier_slug maps to an active, paid tier (free/follower tiers are rejected) → gate on benecaster_bridge_supports_manual_membership for the show’s active bridge, 501 bridge_unsupported when the bridge doesn’t advertise support (only NativeBridge does in Benecaster; bridge add-ons flip the filter when they ship a real implementation) → validate optional expiry_at and joined_at before anything is created, so a bad date 400s with nothing left to clean up → resolve or create the WordPress user by email → reject with 409 if the user already has an active subscription for the show → delegate provisioning to the active bridge’s create_manual_membership() → optionally backdate the newly-minted token’s created_at to joined_at so TenureBadgeCalculator credits tenure earned on another platform, rather than stamping the supporter as brand new.
Constructor Dependencies
| Type | Description |
|---|---|
\Benecaster\Membership\MembershipTierRepository |
Resolves and validates the requested tier slug against the show's active, paid tiers. |
\Benecaster\Membership\SubscriptionRepository |
Checks for an existing active subscription before provisioning. |
\Benecaster\Bridge\BridgeManager |
Resolves the show's active bridge to check manual-membership support and delegate provisioning. |
\Benecaster\Token\TokenRepository |
Looks up the newly-minted token row to backdate created_at when joined_at is supplied. |
Methods
| Method | Visibility | Since | Description |
|---|---|---|---|
register_routes(): void |
Public | — | Registers POST /shows/{id}/subscribers/manual with permission_callback_admin and a numeric-id validate_callback. |
create( WP_REST_Request $request ): WP_REST_Response|WP_Error |
Public | — | Handles POST /shows/{id}/subscribers/manual. Body: email (required), tier_slug (required, must be an active paid tier), display_name (optional, applied only when a NEW user is created — an existing user's display name is left alone), expiry_at (optional ISO 8601, parsed to UTC by parse_expiry() — an hourly job revokes the token past this time), joined_at (optional Y-m-d or Y-m-d H:i:s UTC, validated by BackdateGuard). 400s on invalid email, missing/invalid/free tier_slug, invalid expiry_at, or a joined_at BackdateGuard rejects (future date, or earlier than BackdateGuard::FLOOR). 501 bridge_unsupported when the active bridge doesn't support manual membership — both from the pre-check filter and, as defence in depth, when create_manual_membership() itself throws BadMethodCallException. 409 already_subscribed when the resolved user already holds an active subscription for the show. 500 token_generation_failed if the bridge creates the subscription but no token is minted. On success (201): resolves/creates the WP user via resolve_user_id(), delegates to the bridge's create_manual_membership(), backdates the token's created_at when joined_at was given, fires benecaster_manual_subscriber_created, and returns {subscription_id, token_hash_prefix, user_id}. |
resolve_user_id( string $email, string $display_name, int $show_id = 0 ): int|WP_Error |
— | — | Returns an existing WP user's ID by email, or creates one with wp_create_user() and a random 24-character password. On creation: marks the account via GeneratedPasswordMarker::mark() (nobody ever learns this password, so the account page's current-password gate must not apply to it), fires benecaster_subscriber_account_provisioned with source 'manual_add' (which triggers Benecaster's guest_password_set email so the person can get into the account made for them), and applies display_name if non-empty. Fires the provisioning action ONLY on creation — an account that already existed is left untouched, so the operator adding an existing user is never mistaken for triggering a password-reset email at them. |
derive_username( string $email ): string |
— | — | Derives a WP login from the email's local part via sanitize_user(), falling back to 'subscriber' if that sanitizes to empty, and appending a numeric suffix (-2, -3, …) until the login is free. |
parse_expiry( string $raw ): ?string |
— | — | Parses an ISO 8601 string to a UTC 'Y-m-d H:i:s' MySQL DATETIME string via DateTimeImmutable, returning null on a malformed value so the caller can 400. The hourly expiry job compares against a PHP-computed gmdate() cutoff bound into its query, not MySQL's clock. |
joined_at_message( string $reason ): string |
— | — | Translates a BackdateGuard reason code (REASON_FUTURE, REASON_BEFORE_FLOOR, or unrecognized) into an operator-facing message, distinct from the guard's own developer-oriented exception text. |
Hooks Fired
Notes
Extends the plugin's internal RestController base — not part of the public API; its own docblock tells add-on developers not to extend it, and to register independent routes on rest_api_init with the public benecaster_rest_permission_admin() function instead. Inherits register() (hooks register_routes() onto rest_api_init), the permission_callback_admin / permission_callback_editor / permission_callback_subscriber callbacks, rest_error(), and not_found().
benecaster_subscriber_account_provisioned fires with source: 'manual_add' from this class; the same hook fires with other source values from other provisioning flows (bulk enrollment, email-first signup, the donation webhook) — see the hook's own CPT entry for the full list.