PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.0
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.0
2.4.0 2.4.1 2.3.8 2.3.7 2.3.6 2.3.5 2.3.4 2.3.3 2.3.2 2.3.1 2.2.0 2.1.21 2.1.20 2.1.19 2.1.18 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.13 2.1.14 2.1.15 2.1.16 2.1.2 All 57 releases
easy-invoice / includes / Addons / LicensePlanResolver.php

LicensePlanResolver.php in Easy Invoice – Invoice Generator, PDF Quotes & Payments 2.4.0, at includes/Addons/LicensePlanResolver.php

315 lines 13.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Resolves the active Easy Invoice Pro license into a plan tier.
4 *
5 * Lives in the Free plugin so the addons listing UI can render even when Pro is
6 * inactive (the Free plugin owns the listing; Pro owns the code that runs).
7 *
8 * Plan tier resolution order:
9 * 1. `easy_invoice_pro_plan_tier_override` option (manual override, useful for testing)
10 * 2. `price_id` field on the EDD license response (mapped via PRICE_ID_MAP)
11 * 3. Item / product name string match on the EDD response
12 * 4. `none` (no Pro license or pre-tier license)
13 *
14 * @package EasyInvoice
15 */
16
17 namespace EasyInvoice\Addons;
18
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 class LicensePlanResolver {
24 public const PLAN_NONE = 'none';
25 public const PLAN_PERSONAL = 'personal';
26 public const PLAN_PROFESSIONAL = 'professional';
27 public const PLAN_AGENCY = 'agency';
28
29 /**
30 * Plan hierarchy — higher index unlocks lower ones.
31 *
32 * Personal is the entry-level paid tier; it covers the addons that
33 * used to ship as always-on Pro features (recurring, partial payments,
34 * client portal, etc. — see AddonRegistry::migratedProAddonIds()).
35 * Professional and Agency licenses both satisfy the Personal
36 * requirement automatically because they rank higher.
37 */
38 private const PLAN_RANK = [
39 self::PLAN_NONE => 0,
40 self::PLAN_PERSONAL => 5,
41 self::PLAN_PROFESSIONAL => 10,
42 self::PLAN_AGENCY => 20,
43 ];
44
45 /**
46 * EDD price_id → plan tier. Reflects the live store variants:
47 * price_id 1 = Personal (yearly) → Personal
48 * price_id 2 = Professional (yearly) → Professional
49 * price_id 3 = Agency (yearly) → Agency
50 * price_id 4 = Personal Lifetime → Personal
51 * price_id 5 = Unlimited Lifetime (LEGACY) → Personal
52 * price_id 6 = Professional Lifetime → Professional
53 * price_id 7 = Agency Lifetime → Agency
54 *
55 * Lifetime SKUs (#4, #6, #7) grant the same tier rank as their yearly
56 * counterparts (#1, #2, #3) — they only remove the renewal, the feature
57 * set matches the equivalent yearly tier.
58 *
59 * #5 is the discontinued "Unlimited Lifetime" ($399, Personal-tier
60 * unlimited-sites SKU sold before the lifetime tier split). EDD no
61 * longer offers it, but legacy customers still carry price_id=5 on
62 * their license meta. Keep the Personal-tier mapping so they don't
63 * suddenly lose access on this upgrade.
64 */
65 private const PRICE_ID_MAP = [
66 '1' => self::PLAN_PERSONAL,
67 '2' => self::PLAN_PROFESSIONAL,
68 '3' => self::PLAN_AGENCY,
69 '4' => self::PLAN_PERSONAL,
70 '5' => self::PLAN_PERSONAL,
71 '6' => self::PLAN_PROFESSIONAL,
72 '7' => self::PLAN_AGENCY,
73 ];
74
75 /**
76 * Per-request memo for getCurrentPlan(). The full resolution chain hits
77 * 3-4 options plus the License helper on every call, and the result is
78 * referenced from the addons grid, the sidebar, every addon card, and
79 * each addon's gate check — easily 20+ calls per admin page load.
80 *
81 * Cache stays alive for the duration of one PHP request. The invalidator
82 * hooks at the bottom of the file clear it when the underlying options
83 * change so a same-request license activation isn't masked by stale data.
84 *
85 * @var string|null
86 */
87 private static $memoizedPlan = null;
88
89 /**
90 * Drop the per-request memo. Wired to license-option update hooks so any
91 * code that changes the license inside the request sees a fresh value
92 * on the next call. Also exposed publicly for tests / explicit refresh.
93 */
94 public static function clearMemo(): void {
95 self::$memoizedPlan = null;
96 }
97
98 /**
99 * Resolve the active plan tier for the current site.
100 */
101 public static function getCurrentPlan(): string {
102 if (self::$memoizedPlan !== null) {
103 return self::$memoizedPlan;
104 }
105
106 // Override hook — exposed for support / testing / dev.
107 $override = get_option('easy_invoice_pro_plan_tier_override', '');
108 if ($override && self::isValidPlan($override)) {
109 return self::$memoizedPlan = $override;
110 }
111
112 // No Pro plugin installed at all = no plan (Free tier).
113 if (!function_exists('easy_invoice_has_pro') || !easy_invoice_has_pro()) {
114 return self::$memoizedPlan = self::PLAN_NONE;
115 }
116
117 // Pro plugin IS installed. Personal is the entry-level tier and
118 // does NOT require a license key — any merchant who has the Pro
119 // plugin installed gets every Personal-tier addon for free.
120 // A valid license key only unlocks the higher tiers (Professional,
121 // Agency) below.
122 //
123 // Rationale: Personal is the lowest paid tier in the original
124 // pricing model, and the product owner has decided to make it
125 // the default-on tier for installed Pro plugins so addonized
126 // features (recurring invoices, partial payments, client portal,
127 // reports, etc.) work out-of-the-box.
128 if (!class_exists('\\EasyInvoicePro\\Updater\\License')) {
129 return self::$memoizedPlan = self::PLAN_PERSONAL;
130 }
131 if (!\EasyInvoicePro\Updater\License::has_valid_license()) {
132 return self::$memoizedPlan = self::PLAN_PERSONAL;
133 }
134
135 $details = \EasyInvoicePro\Updater\License::get_license_details();
136
137 // Preferred: price_id from EDD.
138 $priceId = '';
139 if (is_object($details) && isset($details->price_id)) {
140 $priceId = (string) $details->price_id;
141 }
142 if ($priceId !== '' && isset(self::PRICE_ID_MAP[$priceId])) {
143 return self::$memoizedPlan = self::PRICE_ID_MAP[$priceId];
144 }
145
146 // Fallback: scan item / product name for a tier keyword.
147 // Order matters — check the most specific terms first. The product
148 // name itself is "Easy Invoice Pro", so a bare "pro" substring match
149 // would mis-classify Personal/Agency licenses as Professional.
150 //
151 // Tier-by-SKU mapping (all matched on lowercased item_name):
152 // "Agency" / "Agency Lifetime" → Agency
153 // "Professional" / "Professional Lifetime" → Professional
154 // "Personal" / "Personal Lifetime" → Personal
155 // "Unlimited Lifetime" (legacy SKU) → Personal
156 // The "agency" and "professional" checks run before the
157 // personal/lifetime/unlimited check so the new tiered Lifetime
158 // SKUs land in the right tier rather than being lumped into Personal.
159 $itemName = '';
160 if (is_object($details)) {
161 $itemName = strtolower((string) ($details->item_name ?? $details->name ?? ''));
162 }
163 if ($itemName !== '') {
164 if (strpos($itemName, 'agency') !== false) {
165 return self::$memoizedPlan = self::PLAN_AGENCY;
166 }
167 if (strpos($itemName, 'professional') !== false) {
168 return self::$memoizedPlan = self::PLAN_PROFESSIONAL;
169 }
170 if (strpos($itemName, 'personal') !== false
171 || strpos($itemName, 'unlimited') !== false
172 || strpos($itemName, 'lifetime') !== false) {
173 return self::$memoizedPlan = self::PLAN_PERSONAL;
174 }
175 }
176
177 // Valid license but no recognizable tier info — fall back to the
178 // entry-level paid tier (Personal). Promoting to Professional here
179 // would silently grant access to higher-tier addons.
180 return self::$memoizedPlan = self::PLAN_PERSONAL;
181 }
182
183 /**
184 * Does the current plan satisfy (or exceed) the required plan?
185 */
186 public static function planSatisfies(string $required): bool {
187 $current = self::getCurrentPlan();
188 $currentRank = self::PLAN_RANK[$current] ?? 0;
189 $requiredRank = self::PLAN_RANK[$required] ?? 0;
190 return $currentRank >= $requiredRank;
191 }
192
193 /**
194 * Human-readable plan label.
195 */
196 public static function getPlanLabel(string $plan): string {
197 switch ($plan) {
198 case self::PLAN_AGENCY: return __('Agency', 'easy-invoice');
199 case self::PLAN_PROFESSIONAL: return __('Professional', 'easy-invoice');
200 case self::PLAN_PERSONAL: return __('Personal', 'easy-invoice');
201 case self::PLAN_NONE: return __('Free', 'easy-invoice');
202 }
203 return ucfirst($plan);
204 }
205
206 /**
207 * Short label for tight spots — chip badges, table cells, anywhere
208 * "Professional" wraps awkwardly. The full label remains on the License
209 * page and inside addon cards / details where space allows.
210 */
211 public static function getPlanLabelShort(string $plan): string {
212 switch ($plan) {
213 case self::PLAN_AGENCY: return __('Agency', 'easy-invoice');
214 case self::PLAN_PROFESSIONAL: return __('Pro', 'easy-invoice');
215 case self::PLAN_PERSONAL: return __('Personal', 'easy-invoice');
216 case self::PLAN_NONE: return __('Free', 'easy-invoice');
217 }
218 return self::getPlanLabel($plan);
219 }
220
221 /**
222 * Brand color for the plan badge. Centralised so the License page and
223 * Addons grid stay visually consistent.
224 */
225 public static function getPlanColor(string $plan): string {
226 switch ($plan) {
227 case self::PLAN_AGENCY: return '#7c3aed'; // purple
228 case self::PLAN_PROFESSIONAL: return '#2563eb'; // blue
229 case self::PLAN_PERSONAL: return '#0d9488'; // teal
230 case self::PLAN_NONE: return '#6b7280'; // grey
231 }
232 return '#6b7280';
233 }
234
235 public static function isValidPlan(string $plan): bool {
236 return array_key_exists($plan, self::PLAN_RANK);
237 }
238
239 /**
240 * EDD price_id → variant label. Mirrors the SKU list in the store.
241 *
242 * price_id 5 is the discontinued "Unlimited Lifetime" SKU — labeled
243 * here as "Personal Lifetime" so legacy customers see the same row
244 * as buyers of the current Personal Lifetime (#4). The activation /
245 * site count differs and is shown separately on the License page.
246 */
247 private const PRICE_ID_VARIANT_LABELS = [
248 '1' => 'Personal',
249 '2' => 'Professional',
250 '3' => 'Agency',
251 '4' => 'Personal Lifetime',
252 '5' => 'Personal Lifetime',
253 '6' => 'Professional Lifetime',
254 '7' => 'Agency Lifetime',
255 ];
256
257 /**
258 * Resolve a human-readable variant label for the current license.
259 *
260 * Precedence:
261 * 1. `price_id` → variant label table (our canonical SKU names)
262 * 2. Fallback to EDD `item_name` for any unknown price_id
263 * 3. Empty string when nothing useful is available
264 *
265 * The price_id table wins over item_name so both Lifetime variants
266 * show as "Personal Lifetime" even if EDD returns the raw SKU name
267 * "Unlimited Lifetime" for price_id 5.
268 *
269 * Returns the variant string only — never falls back to the plan tier,
270 * so callers can decide whether to show a separate row or hide entirely.
271 */
272 public static function getPlanVariantLabel(): string {
273 if (!class_exists('\\EasyInvoicePro\\Updater\\License')) {
274 return '';
275 }
276 if (!\EasyInvoicePro\Updater\License::has_valid_license()) {
277 return '';
278 }
279
280 $details = \EasyInvoicePro\Updater\License::get_license_details();
281 if (!is_object($details)) {
282 return '';
283 }
284
285 $priceId = (string) ($details->price_id ?? '');
286 if ($priceId !== '' && isset(self::PRICE_ID_VARIANT_LABELS[$priceId])) {
287 return self::PRICE_ID_VARIANT_LABELS[$priceId];
288 }
289
290 // Fallback when EDD didn't return a recognized price_id: use the
291 // raw item_name with the parent product prefix stripped, so
292 // "Easy Invoice Pro - Personal Lifetime" shows as "Personal Lifetime".
293 $itemName = trim((string) ($details->item_name ?? $details->name ?? ''));
294 if ($itemName !== '') {
295 $cleaned = preg_replace('/^easy\s*invoice\s*pro\s*[-:–]?\s*/i', '', $itemName);
296 $cleaned = trim((string) $cleaned);
297 return $cleaned !== '' ? $cleaned : $itemName;
298 }
299
300 return '';
301 }
302 }
303
304 // Invalidate the per-request memo when any option the resolver reads changes.
305 // Without this, a license activate / deactivate inside the same request would
306 // keep showing the old plan to subsequent callers.
307 add_action('updated_option_easy_invoice_pro_plan_tier_override', [\EasyInvoice\Addons\LicensePlanResolver::class, 'clearMemo']);
308 add_action('updated_option_easy_invoice_pro_license_details', [\EasyInvoice\Addons\LicensePlanResolver::class, 'clearMemo']);
309 add_action('updated_option_easy_invoice_pro_license_key', [\EasyInvoice\Addons\LicensePlanResolver::class, 'clearMemo']);
310 add_action('updated_option_easy_invoice_pro_license_status', [\EasyInvoice\Addons\LicensePlanResolver::class, 'clearMemo']);
311 add_action('added_option_easy_invoice_pro_plan_tier_override', [\EasyInvoice\Addons\LicensePlanResolver::class, 'clearMemo']);
312 add_action('added_option_easy_invoice_pro_license_details', [\EasyInvoice\Addons\LicensePlanResolver::class, 'clearMemo']);
313 add_action('added_option_easy_invoice_pro_license_key', [\EasyInvoice\Addons\LicensePlanResolver::class, 'clearMemo']);
314 add_action('added_option_easy_invoice_pro_license_status', [\EasyInvoice\Addons\LicensePlanResolver::class, 'clearMemo']);
315