BuyupsAccountController
\Benecaster\REST\BuyupsAccountController
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.