# easy-invoice/2.3.4/includes/Addons/LicensePlanResolver.php

Easy Invoice – Invoice Generator, PDF Quotes &amp; Payments, version 2.3.4. 295 lines.

- Page: https://pluginprobe.com/plugins/easy-invoice/2.3.4/code/includes/Addons/LicensePlanResolver.php
- Raw: https://pluginprobe.com/plugins/easy-invoice/2.3.4/raw/includes/Addons/LicensePlanResolver.php
- Modified: 2026-05-21T08:29:24+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/easy-invoice/2.3.4/code/includes/Addons/LicensePlanResolver.php#L10-L20`.

```php
<?php
/**
 * Resolves the active Easy Invoice Pro license into a plan tier.
 *
 * Lives in the Free plugin so the addons listing UI can render even when Pro is
 * inactive (the Free plugin owns the listing; Pro owns the code that runs).
 *
 * Plan tier resolution order:
 *   1. `easy_invoice_pro_plan_tier_override` option (manual override, useful for testing)
 *   2. `price_id` field on the EDD license response (mapped via PRICE_ID_MAP)
 *   3. Item / product name string match on the EDD response
 *   4. `none` (no Pro license or pre-tier license)
 *
 * @package EasyInvoice
 */

namespace EasyInvoice\Addons;

if (!defined('ABSPATH')) {
    exit;
}

class LicensePlanResolver {
    public const PLAN_NONE         = 'none';
    public const PLAN_PERSONAL     = 'personal';
    public const PLAN_PROFESSIONAL = 'professional';
    public const PLAN_AGENCY       = 'agency';

    /**
     * Plan hierarchy — higher index unlocks lower ones.
     *
     * Personal is the entry-level paid tier; it covers the addons that
     * used to ship as always-on Pro features (recurring, partial payments,
     * client portal, etc. — see AddonRegistry::migratedProAddonIds()).
     * Professional and Agency licenses both satisfy the Personal
     * requirement automatically because they rank higher.
     */
    private const PLAN_RANK = [
        self::PLAN_NONE         => 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']);

```
