0, self::PLAN_PERSONAL => 5, self::PLAN_PROFESSIONAL => 10, self::PLAN_AGENCY => 20, ]; /** * EDD price_id → plan tier. Reflects the live store variants: * price_id 1 = Personal (yearly) → Personal * price_id 2 = Professional (yearly) → Professional * price_id 3 = Agency (yearly) → Agency * price_id 4 = Personal Lifetime → Personal * price_id 5 = Unlimited Lifetime → Personal * * Both lifetime SKUs are sold under the Personal plan tier — they only * remove the renewal, they don't grant access to higher-tier addons. */ private const PRICE_ID_MAP = [ '1' => self::PLAN_PERSONAL, '2' => self::PLAN_PROFESSIONAL, '3' => self::PLAN_AGENCY, '4' => self::PLAN_PERSONAL, '5' => self::PLAN_PERSONAL, ]; /** * Per-request memo for getCurrentPlan(). The full resolution chain hits * 3-4 options plus the License helper on every call, and the result is * referenced from the addons grid, the sidebar, every addon card, and * each addon's gate check — easily 20+ calls per admin page load. * * Cache stays alive for the duration of one PHP request. The invalidator * hooks at the bottom of the file clear it when the underlying options * change so a same-request license activation isn't masked by stale data. * * @var string|null */ private static $memoizedPlan = null; /** * Drop the per-request memo. Wired to license-option update hooks so any * code that changes the license inside the request sees a fresh value * on the next call. Also exposed publicly for tests / explicit refresh. */ public static function clearMemo(): void { self::$memoizedPlan = null; } /** * Resolve the active plan tier for the current site. */ public static function getCurrentPlan(): string { if (self::$memoizedPlan !== null) { return self::$memoizedPlan; } // Override hook — exposed for support / testing / dev. $override = get_option('easy_invoice_pro_plan_tier_override', ''); if ($override && self::isValidPlan($override)) { return self::$memoizedPlan = $override; } // No Pro plugin installed at all = no plan (Free tier). if (!function_exists('easy_invoice_has_pro') || !easy_invoice_has_pro()) { return self::$memoizedPlan = self::PLAN_NONE; } // Pro plugin IS installed. Personal is the entry-level tier and // does NOT require a license key — any merchant who has the Pro // plugin installed gets every Personal-tier addon for free. // A valid license key only unlocks the higher tiers (Professional, // Agency) below. // // Rationale: Personal is the lowest paid tier in the original // pricing model, and the product owner has decided to make it // the default-on tier for installed Pro plugins so addonized // features (recurring invoices, partial payments, client portal, // reports, etc.) work out-of-the-box. if (!class_exists('\\EasyInvoicePro\\Updater\\License')) { return self::$memoizedPlan = self::PLAN_PERSONAL; } if (!\EasyInvoicePro\Updater\License::has_valid_license()) { return self::$memoizedPlan = self::PLAN_PERSONAL; } $details = \EasyInvoicePro\Updater\License::get_license_details(); // Preferred: price_id from EDD. $priceId = ''; if (is_object($details) && isset($details->price_id)) { $priceId = (string) $details->price_id; } if ($priceId !== '' && isset(self::PRICE_ID_MAP[$priceId])) { return self::$memoizedPlan = self::PRICE_ID_MAP[$priceId]; } // Fallback: scan item / product name for a tier keyword. // Order matters — check the most specific terms first. The product // name itself is "Easy Invoice Pro", so a bare "pro" substring match // would mis-classify Personal/Agency licenses as Professional. // Both lifetime SKUs ("Personal Lifetime", "Unlimited Lifetime") are // Personal-tier per the store catalog. $itemName = ''; if (is_object($details)) { $itemName = strtolower((string) ($details->item_name ?? $details->name ?? '')); } if ($itemName !== '') { if (strpos($itemName, 'agency') !== false) { return self::$memoizedPlan = self::PLAN_AGENCY; } if (strpos($itemName, 'professional') !== false) { return self::$memoizedPlan = self::PLAN_PROFESSIONAL; } if (strpos($itemName, 'personal') !== false || strpos($itemName, 'unlimited') !== false || strpos($itemName, 'lifetime') !== false) { return self::$memoizedPlan = self::PLAN_PERSONAL; } } // Valid license but no recognizable tier info — fall back to the // entry-level paid tier (Personal). Promoting to Professional here // would silently grant access to higher-tier addons. return self::$memoizedPlan = self::PLAN_PERSONAL; } /** * Does the current plan satisfy (or exceed) the required plan? */ public static function planSatisfies(string $required): bool { $current = self::getCurrentPlan(); $currentRank = self::PLAN_RANK[$current] ?? 0; $requiredRank = self::PLAN_RANK[$required] ?? 0; return $currentRank >= $requiredRank; } /** * Human-readable plan label. */ public static function getPlanLabel(string $plan): string { switch ($plan) { case self::PLAN_AGENCY: return __('Agency', 'easy-invoice'); case self::PLAN_PROFESSIONAL: return __('Professional', 'easy-invoice'); case self::PLAN_PERSONAL: return __('Personal', 'easy-invoice'); case self::PLAN_NONE: return __('Free', 'easy-invoice'); } return ucfirst($plan); } /** * Short label for tight spots — chip badges, table cells, anywhere * "Professional" wraps awkwardly. The full label remains on the License * page and inside addon cards / details where space allows. */ public static function getPlanLabelShort(string $plan): string { switch ($plan) { case self::PLAN_AGENCY: return __('Agency', 'easy-invoice'); case self::PLAN_PROFESSIONAL: return __('Pro', 'easy-invoice'); case self::PLAN_PERSONAL: return __('Personal', 'easy-invoice'); case self::PLAN_NONE: return __('Free', 'easy-invoice'); } return self::getPlanLabel($plan); } /** * Brand color for the plan badge. Centralised so the License page and * Addons grid stay visually consistent. */ public static function getPlanColor(string $plan): string { switch ($plan) { case self::PLAN_AGENCY: return '#7c3aed'; // purple case self::PLAN_PROFESSIONAL: return '#2563eb'; // blue case self::PLAN_PERSONAL: return '#0d9488'; // teal case self::PLAN_NONE: return '#6b7280'; // grey } return '#6b7280'; } public static function isValidPlan(string $plan): bool { return array_key_exists($plan, self::PLAN_RANK); } /** * EDD price_id → variant label. Mirrors the SKU list in the store. * * price_id 4 ("Personal Lifetime") and price_id 5 ("Unlimited Lifetime") * both ship under the same "Personal Lifetime" plan label — the only * difference between them is the activation/site count, which is shown * separately on the License page as Activations: X / Y. */ private const PRICE_ID_VARIANT_LABELS = [ '1' => 'Personal', '2' => 'Professional', '3' => 'Agency', '4' => 'Personal Lifetime', '5' => 'Personal Lifetime', ]; /** * Resolve a human-readable variant label for the current license. * * Precedence: * 1. `price_id` → variant label table (our canonical SKU names) * 2. Fallback to EDD `item_name` for any unknown price_id * 3. Empty string when nothing useful is available * * The price_id table wins over item_name so both Lifetime variants * show as "Personal Lifetime" even if EDD returns the raw SKU name * "Unlimited Lifetime" for price_id 5. * * Returns the variant string only — never falls back to the plan tier, * so callers can decide whether to show a separate row or hide entirely. */ public static function getPlanVariantLabel(): string { if (!class_exists('\\EasyInvoicePro\\Updater\\License')) { return ''; } if (!\EasyInvoicePro\Updater\License::has_valid_license()) { return ''; } $details = \EasyInvoicePro\Updater\License::get_license_details(); if (!is_object($details)) { return ''; } $priceId = (string) ($details->price_id ?? ''); if ($priceId !== '' && isset(self::PRICE_ID_VARIANT_LABELS[$priceId])) { return self::PRICE_ID_VARIANT_LABELS[$priceId]; } // Fallback when EDD didn't return a recognized price_id: use the // raw item_name with the parent product prefix stripped, so // "Easy Invoice Pro - Personal Lifetime" shows as "Personal Lifetime". $itemName = trim((string) ($details->item_name ?? $details->name ?? '')); if ($itemName !== '') { $cleaned = preg_replace('/^easy\s*invoice\s*pro\s*[-:–]?\s*/i', '', $itemName); $cleaned = trim((string) $cleaned); return $cleaned !== '' ? $cleaned : $itemName; } return ''; } } // Invalidate the per-request memo when any option the resolver reads changes. // Without this, a license activate / deactivate inside the same request would // keep showing the old plan to subsequent callers. add_action('updated_option_easy_invoice_pro_plan_tier_override', [\EasyInvoice\Addons\LicensePlanResolver::class, 'clearMemo']); add_action('updated_option_easy_invoice_pro_license_details', [\EasyInvoice\Addons\LicensePlanResolver::class, 'clearMemo']); add_action('updated_option_easy_invoice_pro_license_key', [\EasyInvoice\Addons\LicensePlanResolver::class, 'clearMemo']); add_action('updated_option_easy_invoice_pro_license_status', [\EasyInvoice\Addons\LicensePlanResolver::class, 'clearMemo']); add_action('added_option_easy_invoice_pro_plan_tier_override', [\EasyInvoice\Addons\LicensePlanResolver::class, 'clearMemo']); add_action('added_option_easy_invoice_pro_license_details', [\EasyInvoice\Addons\LicensePlanResolver::class, 'clearMemo']); add_action('added_option_easy_invoice_pro_license_key', [\EasyInvoice\Addons\LicensePlanResolver::class, 'clearMemo']); add_action('added_option_easy_invoice_pro_license_status', [\EasyInvoice\Addons\LicensePlanResolver::class, 'clearMemo']);