, omits: array, hidden: bool}> */ public const PLAN_COPY = [ License::STARTER => [ // Rewritten when event_rsvp landed. Four sellable features no // longer fit as a prose list, and the previous line had already // dropped filter_by_place for length -- a buyer on the Freemius // checkout saw three of five. Naming the categories scales; naming // the features does not. 'description' => 'RSVP & registration, remote iCal feeds, front-end submissions, ' . 'conflict detection & venue filters', 'claims' => [ 'event_rsvp', 'remote_calendars', 'frontend_submission', 'conflict_detection', 'filter_by_place', ], 'omits' => [ 'image_sideload' => 'An iCal-import implementation detail, not something a buyer shops for.', ], 'hidden' => false, ], License::BUSINESS => [ 'description' => 'Everything in Starter, plus category merging', 'claims' => [ 'category_merge' ], 'omits' => [], 'hidden' => false, ], // Hidden since 2026-09-16: with multisite unbuilt, Agency adds nothing // over Business — same features, same single-site license. Unhide only // when a feature here has min_tier AGENCY and enforced=true. License::AGENCY => [ 'description' => '', 'claims' => [], 'omits' => [], 'hidden' => true, ], ]; /** * The feature matrix. */ private const FEATURES = [ // --- Starter+ (enforced, stripped from the free build) --- 'conflict_detection' => [ 'min_tier' => License::STARTER, 'label' => 'Conflict detection', 'desc' => 'Warns about overlapping events when creating or rescheduling, based on participant availability.', 'readme' => 'Conflict detection warns you about overlapping events', 'enforced' => true, 'stripped' => true, ], 'remote_calendars' => [ 'min_tier' => License::STARTER, 'label' => 'Remote calendar subscriptions', 'desc' => 'Subscribe to external iCal feeds (e.g. a public Google Calendar URL) with automatic hourly one-way sync.', 'readme' => 'Subscribe to remote iCal feeds with automatic hourly sync', 'enforced' => true, 'stripped' => true, ], 'image_sideload' => [ 'min_tier' => License::STARTER, 'label' => 'Image sideloading on iCal import', 'desc' => 'Images referenced by imported iCal events are downloaded into the WordPress Media Library automatically.', 'readme' => null, 'enforced' => true, 'stripped' => true, ], 'frontend_submission' => [ 'min_tier' => License::STARTER, 'label' => 'Front-end event submission', 'desc' => 'Site visitors can submit events from the public site into an admin approval queue.', 'readme' => 'Frontend event submissions with admin approval queue', 'enforced' => true, 'stripped' => true, ], 'filter_by_place' => [ 'min_tier' => License::STARTER, 'label' => 'Filter by venue and organizer', 'desc' => 'Adds venue and organizer filters to the calendar Filter Bar. Keyword, category and tag filtering are free on every tier.', 'readme' => 'Filter the calendar by venue and organizer', 'enforced' => true, // Not stripped: the filtering runs inside EventsController // alongside the free facets, so there is no separable file to // remove. The gate is the call to webcal_feature_available(). 'stripped' => false, ], 'event_rsvp' => [ 'min_tier' => License::STARTER, 'label' => 'Event registration (RSVP)', 'desc' => 'Visitors register for an event from the public site, with capacity limits, ' . 'a registration window, double opt-in by email, an attendee list and check-in.', 'readme' => 'Let visitors RSVP to events, with capacity limits and attendee check-in', 'enforced' => true, 'stripped' => true, ], // --- Business+ --- 'category_merge' => [ 'min_tier' => License::BUSINESS, 'label' => 'Category merge', 'desc' => 'Merge duplicate categories and reassign all of their events in one step.', 'readme' => 'Category merging for cleaning up duplicate categories', 'enforced' => true, 'stripped' => true, ], // Planned two-way Google/Outlook sync — not yet implemented. Declared // so call sites added later are gated correctly. Do not document or // market as a paid feature until enforced. 'remote_calendars_sync' => [ 'min_tier' => License::BUSINESS, 'label' => 'Two-way Google/Outlook sync', 'desc' => 'Two-way synchronization with Google and Outlook calendars.', 'readme' => null, 'enforced' => false, 'stripped' => false, ], // --- Agency --- // Planned — no multisite code exists yet. 'multisite' => [ 'min_tier' => License::AGENCY, 'label' => 'Multisite network calendars', 'desc' => 'Shared calendars across a WordPress multisite network.', 'readme' => null, 'enforced' => false, 'stripped' => false, ], // --- Deliberately free on every tier --- // Email notifications and reminders are free-tier features. 'email_notifications' => [ 'min_tier' => License::FREE, 'label' => 'Email notifications & reminders', 'desc' => 'Event email notifications and reminders via wp_mail().', 'readme' => null, // Free, and nothing gates it. Its only "call sites" were the two // hand-written JS flag lists, which no script ever read for this // key; webcal_feature_flags() replaced them. 'enforced' => false, 'stripped' => false, ], // AI/MCP integration is a free, built-in differentiator for Agentic // Daisy, available to all tiers including the WordPress.org edition. 'mcp' => [ 'min_tier' => License::FREE, 'label' => 'AI / MCP integration', 'desc' => 'Built-in MCP server and WordPress Abilities API integration so AI assistants can manage the calendar.', 'readme' => null, 'enforced' => true, 'stripped' => false, ], ]; /** * The full feature matrix. * * Consumed outside the Psalm project scope: bin/generate-feature-tiers.php * and tests/Unit/FeatureGatingSyncTest.php. * * @psalm-api * @return array */ public static function all(): array { return self::FEATURES; } /** * Minimum tier for a feature, or null if the key is not declared. * * @param string $feature Feature identifier. * @return string|null */ public static function min_tier( string $feature ): ?string { return self::FEATURES[ $feature ]['min_tier'] ?? null; } /** * Rank of a tier in the ladder. Unknown tier strings rank as free. * * @param string $tier Tier identifier. * @return int */ public static function tier_rank( string $tier ): int { $rank = array_search( $tier, self::TIER_ORDER, true ); return false === $rank ? 0 : $rank; } }