Skip to main content

BuyupsAccountController

\Benecaster\REST\BuyupsAccountController

Class Premium

Subscriber-facing REST endpoints for buy-up listing, purchase, and cancellation. Uses cookie authentication (permission_callback_subscriber). Exposes GET /shows/{id}/buyups, POST /shows/{id}/buyups/{buyup_id}/subscribe, and POST /shows/{id}/buyups/{buyup_id}/cancel (cancel is a POST, not a DELETE — it schedules a cancel-at-period-end, it does not remove anything immediately).

All routes are show-scoped: the subscriber’s tier (for available_to_tiers filtering) and the Stripe subscription being modified are both per-show, and buy-up definitions are per-show as of db_version 1.44.0. Every route resolves {buyup_id} against {show_id} first and treats a mismatch as a 404 — never a 403 — so a route can never confirm that a given buy-up ID exists on some other show.

Self-service requires an active native (Phase 2) subscription with a stripe_subscription_id — Phase 1 bridge subscribers get 412 native_subscription_required from both write endpoints (a bridge doesn’t have Benecaster-managed billing to add an item to). A buy-up the caller cannot buy right now (a pending billing-period switch, outside its sale window, no price row at their billing period, a different currency, or a row not yet provisioned in Stripe) is flagged is_billing_incompatible in the listing and refused by subscribe() with 403 buyup_billing_incompatible before Stripe is ever called; the rule lives in BuyupBillingCompatibility.

Prices are per subscriber, not per buy-up. The listing’s price, amount_cents, currency, interval_unit and interval_count describe the price row matching the caller’s own billing period, and price is null (never 0) when there is no such row. subscribe() uses that same row, from BuyupBillingCompatibility::matching_price(), for both the consent amount and the Stripe Price.

subscribe() carries two protections beyond authentication: consent — confirm_amount_cents must match the buy-up’s current server-side price, so a price that changed between render and click is refused (409 amount_mismatch, carrying the live amount) rather than silently charged at the new one — and idempotency — an Idempotency-Key header is forwarded to Stripe and the response cached for 24 hours, so a retry cannot create a second subscription item. Capped buy-ups additionally serialize the count-check-then-insert path through a per-buyup advisory lock so two concurrent purchases cannot both pass the cap check; uncapped buy-ups skip the lock since there is nothing to race against.

Constructor Dependencies

Type Description
\Benecaster\Membership\BuyupRepository Resolves buy-up definitions, scoped to a show.
\Benecaster\Membership\SubscriberBuyupRepository Reads/writes a subscriber's buy-up grant rows (active grants, cap counts, cancel-at-period-end).
\Benecaster\Membership\SubscriptionRepository Resolves the subscriber's active native subscription (and its Stripe subscription ID).
\Benecaster\Token\TokenRepository Resolves the subscriber's current tier slug for buy-up eligibility.
\Benecaster\Payment\StripeClient Resolves the show's active Stripe mode (test/live) to pick the matching Price ID column.
\Benecaster\Payment\StripeSubscriptionManager Adds and cancels Stripe subscription items.
\Benecaster\Membership\BuyupCapLock Per-buyup advisory lock serializing the purchase path for capped buy-ups only.
\Benecaster\Membership\BuyupBillingCompatibility Optional (nullable param, not a promoted property); defaults to a new instance wired with MembershipPriceRepository() when not injected. Decides interval/currency compatibility with the subscriber's existing subscription.

Methods

Method Visibility Since Description
register_routes(): void Public — Registers GET /shows/{show_id}/buyups, POST /shows/{show_id}/buyups/{buyup_id}/subscribe, and POST /shows/{show_id}/buyups/{buyup_id}/cancel, all permission_callback_subscriber.
list_buyups( WP_REST_Request $request ): WP_REST_Response|WP_Error Public — GET /shows/{id}/buyups. When the caller has no active token/tier for the show, returns an empty items list plus has_native_subscription and tier_slug: ''. Otherwise lists buy-ups active for the subscriber's tier, joined against the subscriber's active grants and, for capped buy-ups the subscriber doesn't already hold, a live sold-out check. Each item also carries is_billing_incompatible / billing_incompatible_message from billing_compatibility_fields() — never flagged for a bridge (non-native) subscriber, since they cannot buy at all and the template already explains why.
subscribe( WP_REST_Request $request ): WP_REST_Response|WP_Error Public — POST /shows/{id}/buyups/{buyup_id}/subscribe. If an Idempotency-Key header is present and a cached response exists for this (user, show, buyup, key), replays it immediately WITHOUT re-running any eligibility checks — a cap that filled in the meantime must not change the answer to a purchase that already succeeded. Otherwise: 412 native_subscription_required without an active Stripe-backed native subscription; 404 for a buy-up not found on this show or inactive; 403 buyup_unavailable when the tier doesn't qualify; 403 buyup_billing_incompatible from BuyupBillingCompatibility before consent is even checked; the confirm_amount_cents consent check (400/409, see check_confirm_amount()); 409 already_subscribed if a grant already exists. For capped buy-ups, acquires the per-buy-up lock before re-checking the cap under the lock (409 concurrent_purchase if the lock can't be acquired, 409 sold_out if the cap is already met). Resolves the show-mode-correct Stripe Price ID (503 stripe_not_configured if missing), adds the item to the subscriber's Stripe subscription (mapping ApiErrorException to 502, NonProductionChargeBlocked to the shared 409 billing-unavailable response, and any other Throwable to a generic 500), inserts the local grant row (500 grant_persist_failed if that write fails despite Stripe succeeding), fires benecaster_buyup_purchased, caches the 201 response under the idempotency key for 24 hours when one was supplied, and releases the cap lock in a finally block regardless of outcome.
cancel( WP_REST_Request $request ): WP_REST_Response|WP_Error Public — POST /shows/{id}/buyups/{buyup_id}/cancel. 404 (not 403) when the buy-up doesn't resolve against this show — a distinct status would confirm the ID exists elsewhere, which scoping is meant to hide. 404 when the caller has no active grant. Idempotent no-op (200, unchanged payload) when the grant is already cancel_at_period_end. Otherwise marks the Stripe subscription item to cancel at period end (mapping errors the same way as subscribe()), updates the local grant row, and fires benecaster_buyup_cancelled — which is NOT the same event as the grant actually ending (that is benecaster_buyup_revoked, fired elsewhere).
subscriber_tier_slug( int $user_id, int $show_id ): string — — Returns the caller's active token's tier_slug for the show, or '' if there is no active token.
has_native_subscription( int $user_id, int $show_id ): bool — — Whether native_subscription() resolves to a non-null row.
native_subscription( int $user_id, int $show_id ): ?array — — The subscriber's active Benecaster-managed subscription with a non-empty stripe_subscription_id, or null — the row buy-up items can actually be added to.
billing_compatibility_fields( ?array $native, int $buyup_id ): array — — Returns {is_billing_incompatible, billing_incompatible_message} for one buy-up. Always false/null when $native is null (a bridge subscriber can't buy at all, so the flag is meaningless for them).
check_confirm_amount( WP_REST_Request $request, array $buyup, array $price_row ): ?WP_Error — — 400 confirm_amount_required when confirm_amount_cents is missing/empty; 400 confirm_amount_invalid when it isn't a whole number; 409 amount_mismatch (carrying the live amount_cents and currency) when it doesn't match the matched price row's amount_cents. Returns null when the purchase may proceed.
priced_fields( ?array $native, int $buyup_id ): array — — The listing's per-subscriber price fields (price, amount_cents, currency, interval_unit, interval_count) from the row matching the caller's billing period. With no match, price and amount_cents are null rather than 0, because 0 is a real price.
read_idempotency_key( WP_REST_Request $request ): string — — Reads and sanitizes the Idempotency-Key request header (alphanumeric, hyphen, underscore only; capped at 128 chars). Returns '' when absent — the protection is deliberately optional so omitting it only forgoes retry-safety, it breaks nothing.
idempotency_transient_key( int $user_id, int $show_id, int $buyup_id, string $key ): string — — Transient key scoped by user, show, buyup, and the caller's key — so one subscriber's key can never replay another's cached response, and the same key reused across two shows is treated as two distinct purchases.
stripe_idempotency_key( int $user_id, int $buyup_id, string $key ): string — — Namespaced key sent to Stripe, including user and buyup, so a caller reusing one key for two different purchases doesn't have the second silently answered with the first purchase's subscription item.
buyup_available_to_tier( array $buyup, string $tier_slug ): bool — — True when the buy-up's available_to_tiers is empty (available to all tiers) or contains the given tier slug.
price_id_for_active_mode( array $price_row, int $show_id ): string — — Resolves the matched price row's stripe_price_id_test or stripe_price_id_live column, matched to the show's own active Stripe mode — deliberately per-show, not site-wide, since a site-wide answer could pick a live Price ID for a show still on test keys, and Stripe rejects a price from another mode/account.

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() — used directly here, unlike BillingPortalController's local wrapper of the same message.

benecaster_buyup_revoked — fired when a scheduled cancellation actually takes effect at period end — is a distinct hook fired elsewhere (the subscription lifecycle listener, not this controller); do not confuse it with benecaster_buyup_cancelled above, which only means the subscriber scheduled the cancellation.