BillingPortalController
\Benecaster\REST\BillingPortalController
Subscriber-facing REST endpoints for the native (Phase 2 / NativeBridge) membership billing portal — payment method update via Stripe Elements re-mount, cancel-at-period-end with confirmation, and a billing history list, rather than redirecting to the Stripe-hosted customer portal. Uses cookie authentication (permission_callback_subscriber) — no admin nonce required. All four write endpoints, plus the read endpoint, first call require_subscription(), which 401s when logged out, 400s on a missing show_id, and 404s when the (user, show) pair has no row in benecaster_subscriptions — non-native (Phase 1 bridge) subscribers never reach any of this, since the account template only renders the portal when that row exists. All write endpoints reflect changes on the local subscription record immediately, without waiting for the Stripe webhook round trip. Delegates all Stripe API calls to StripeSubscriptionManager; StripeClient resolves Stripe credentials from a per-show meta override with site-wide fallback, so one site running multiple shows can collect membership payments to separate Stripe accounts.
Before cancelling, cancel_subscription() releases any pending plan change the subscriber has scheduled, so Stripe is never left holding a price-change schedule against a subscription that is set to end at the same renewal.
Constructor Dependencies
| Type | Description |
|---|---|
\Benecaster\Membership\SubscriptionRepository |
Reads the subscription row and writes cancel_at_period_end locally after a Stripe call succeeds. |
\Benecaster\Payment\StripeClient |
Resolves Stripe credentials for the show; supplies the publishable key returned to the portal UI. |
\Benecaster\Payment\StripeSubscriptionManager |
All Stripe API calls (invoices, SetupIntents, payment methods, customer/subscription reads and updates). |
\Benecaster\Bridge\Bridges\NativeBridge |
dispatch_activation() on resume, so the existing activation cascade (tier snapshot refresh) runs through the normal bridge path. |
\Benecaster\Membership\CadenceSwitch\CadenceSwitchService |
Optional, defaults to null. Releases any pending plan-change schedule before a cancellation. |
Methods
| Method | Visibility | Since | Description |
|---|---|---|---|
register_routes(): void |
Public | — | Registers GET /account/billing-portal and POST /account/billing-portal/{setup-intent,payment-method,cancel,resume}, all permission_callback_subscriber. |
get_portal( WP_REST_Request $request ): WP_REST_Response|WP_Error |
Public | — | GET /account/billing-portal?show_id=. Returns the subscription (project_subscription()), the resolved default payment method (resolve_payment_method() — prefers the subscription's pinned default, falls back to the Stripe customer's invoice_settings default), up to 24 invoices (project_invoice() over StripeSubscriptionManager::list_invoices()), and the show's Stripe publishable key. When the row has no stripe_customer_id yet, invoices/payment_method are empty/null rather than erroring. The subscription carries pending_free_switch (bool), true while a move onto the free tier is scheduled; the portal then hides Cancel and Resume and points at the Free Plan section, which owns that change. |
create_setup_intent( WP_REST_Request $request ): WP_REST_Response|WP_Error |
Public | — | POST /account/billing-portal/setup-intent. Body: show_id. 409 benecaster_billing_no_customer when the subscription has no Stripe customer on file. Otherwise creates a SetupIntent via StripeSubscriptionManager and returns its client_secret and intent_id for the front-end Stripe Elements flow. |
update_payment_method( WP_REST_Request $request ): WP_REST_Response|WP_Error |
Public | — | POST /account/billing-portal/payment-method. Body: show_id, payment_method_id. 400 when payment_method_id is missing; 409 benecaster_billing_no_customer when no Stripe customer is on file. Attaches the PaymentMethod to the customer, and — when the subscription has a stripe_subscription_id — also sets it as that subscription's default. Maps NonProductionChargeBlocked to a subscriber-safe 409 and ApiErrorException to a 502. Fires benecaster_subscription_payment_method_updated on success and returns the projected payment method. |
cancel_subscription( WP_REST_Request $request ): WP_REST_Response|WP_Error |
Public | — | POST /account/billing-portal/cancel. Body: show_id. 409 benecaster_billing_no_subscription when there is no Stripe subscription on file. Releases any pending plan change first — Stripe must never hold a schedule that would move the price at the same renewal the subscription is set to end at, and the cancellation is not attempted if the release fails. Cancels the Stripe subscription at period end, writes cancel_at_period_end = 1 locally so the account page shows "Cancels on …" without waiting for the webhook, and fires benecaster_subscription_cancellation_requested. Access continues until the period ends; the terminal benecaster_subscription_cancelled fires later, from the webhook handler, not from here. |
resume_subscription( WP_REST_Request $request ): WP_REST_Response|WP_Error |
Public | — | POST /account/billing-portal/resume. Body: show_id. 409 benecaster_billing_no_subscription when there is no Stripe subscription on file. Clears cancel_at_period_end on the Stripe subscription and locally. Also routes through NativeBridge::dispatch_activation() with source 'resumed' — access was never actually interrupted (cancel_at_period_end never touched the row's status), so on an already-active row this only refreshes the tier snapshot; it mints no new token and does not touch the feed URL. That call fires the generic benecaster_subscription_activated (source: resumed) through the bridge; the dedicated benecaster_subscription_resumed fired directly below carries the Stripe subscription ID, which the generic action cannot. |
require_subscription( int $user_id, int $show_id ): array|WP_Error |
— | — | Shared guard for all five endpoints: 401 when logged out, 400 rest_invalid_param when show_id is 0, 404 when SubscriptionRepository::find_active_by_user_show() finds no row. Returns the subscription row array on success. |
project_subscription( array $row, ?Stripe\Subscription $stripe = null ): array |
— | — | Maps a subscription row to its REST shape. When a live Stripe\Subscription object is passed (post-cancel/resume), cancel_at_period_end is read from it instead of the local row, so the response reflects the just-completed Stripe call rather than stale local state. |
project_invoice( object $invoice ): array |
— | — | Maps a Stripe invoice (or StripeObject, which is what nested Collection items deserialize to) to its REST shape: id, number, created_at as ISO 8601, amounts, uppercased currency, status, and both hosted URLs. |
project_payment_method( ?Stripe\PaymentMethod $pm ): ?array |
— | — | Maps a Stripe PaymentMethod's card details (brand, last4, exp_month, exp_year) to its REST shape, or null when $pm is null. |
resolve_payment_method( int $show_id, array $row, string $customer_id ): ?array |
— | — | Retrieves the Stripe customer and reads invoice_settings.default_payment_method; returns null if there is none. Otherwise retrieves and projects that PaymentMethod. Returns null (rather than erroring the whole request) on any Stripe API error. |
non_production_error( NonProductionChargeBlocked $e ): WP_Error |
— | — | Converts a refused money operation to a subscriber-facing 409 benecaster_billing_unavailable with a generic message — deliberately never mentions clones, staging, or wp-config, since a subscriber can do nothing with that and it would read as their own account being broken. The operator's copy of the same event, naming both URLs, is written to the debug log by OutboundChargeGuard, once per operation per hour. |
stripe_error( ApiErrorException $e ): WP_Error |
— | — | Converts a Stripe API exception to a 502 benecaster_billing_stripe_error, using the exception's message when non-empty. |
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(), not_found(), and non_production_billing_error() — though this class keeps its own non_production_error() wrapper rather than calling the inherited one, with the same message.
benecaster_subscription_activated (source: resumed) is fired indirectly, by NativeBridge::dispatch_activation() inside resume_subscription() — not by a do_action() call in this class itself, so it is not listed above. See NativeBridge's own entry for that hook.