PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.3.2
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.3.2
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.3.2, at includes/Addons/LicensePlanResolver.php

295 lines 12.4 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 → Personal
52 *
53 * Both lifetime SKUs are sold under the Personal plan tier — they only
54 * remove the renewal, they don't grant access to higher-tier addons.
55 */
56 private const PRICE_ID_MAP = [
57 '1' => self::PLAN_PERSONAL,
58 '2' => self::PLAN_PROFESSIONAL,
59 '3' => self::PLAN_AGENCY,
60 '4' => self::PLAN_PERSONAL,
61 '5' => self::PLAN_PERSONAL,
62 ];
63
64 /**
65 * Per-request memo for getCurrentPlan(). The full resolution chain hits
66 * 3-4 options plus the License helper on every call, and the result is
67 * referenced from the addons grid, the sidebar, every addon card, and
68 * each addon's gate check — easily 20+ calls per admin page load.
69 *
70 * Cache stays alive for the duration of one PHP request. The invalidator
71 * hooks at the bottom of the file clear it when the underlying options
72 * change so a same-request license activation isn't masked by stale data.
73 *
74 * @var string|null
75 */
76 private static $memoizedPlan = null;
77
78 /**
79 * Drop the per-request memo. Wired to license-option update hooks so any
80 * code that changes the license inside the request sees a fresh value
81 * on the next call. Also exposed publicly for tests / explicit refresh.
82 */
83 public static function clearMemo(): void {
84 self::$memoizedPlan = null;
85 }
86
87 /**
88 * Resolve the active plan tier for the current site.
89 */
90 public static function getCurrentPlan(): string {
91 if (self::$memoizedPlan !== null) {
92 return self::$memoizedPlan;
93 }
94
95 // Override hook — exposed for support / testing / dev.
96 $override = get_option('easy_invoice_pro_plan_tier_override', '');
97 if ($override && self::isValidPlan($override)) {
98 return self::$memoizedPlan = $override;
99 }
100
101 // No Pro plugin installed at all = no plan (Free tier).
102 if (!function_exists('easy_invoice_has_pro') || !easy_invoice_has_pro()) {
103 return self::$memoizedPlan = self::PLAN_NONE;
104 }
105
106 // Pro plugin IS installed. Personal is the entry-level tier and
107 // does NOT require a license key — any merchant who has the Pro
108 // plugin installed gets every Personal-tier addon for free.
109 // A valid license key only unlocks the higher tiers (Professional,
110 // Agency) below.
111 //
112 // Rationale: Personal is the lowest paid tier in the original
113 // pricing model, and the product owner has decided to make it
114 // the default-on tier for installed Pro plugins so addonized
115 // features (recurring invoices, partial payments, client portal,
116 // reports, etc.) work out-of-the-box.
117 if (!class_exists('\\EasyInvoicePro\\Updater\\License')) {
118 return self::$memoizedPlan = self::PLAN_PERSONAL;
119 }
120 if (!\EasyInvoicePro\Updater\License::has_valid_license()) {
121 return self::$memoizedPlan = self::PLAN_PERSONAL;
122 }
123
124 $details = \EasyInvoicePro\Updater\License::get_license_details();
125
126 // Preferred: price_id from EDD.
127 $priceId = '';
128 if (is_object($details) && isset($details->price_id)) {
129 $priceId = (string) $details->price_id;
130 }
131 if ($priceId !== '' && isset(self::PRICE_ID_MAP[$priceId])) {
132 return self::$memoizedPlan = self::PRICE_ID_MAP[$priceId];
133 }
134
135 // Fallback: scan item / product name for a tier keyword.
136 // Order matters — check the most specific terms first. The product
137 // name itself is "Easy Invoice Pro", so a bare "pro" substring match
138 // would mis-classify Personal/Agency licenses as Professional.
139 // Both lifetime SKUs ("Personal Lifetime", "Unlimited Lifetime") are
140 // Personal-tier per the store catalog.
141 $itemName = '';
142 if (is_object($details)) {
143 $itemName = strtolower((string) ($details->item_name ?? $details->name ?? ''));
144 }
145 if ($itemName !== '') {
146 if (strpos($itemName, 'agency') !== false) {
147 return self::$memoizedPlan = self::PLAN_AGENCY;
148 }
149 if (strpos($itemName, 'professional') !== false) {
150 return self::$memoizedPlan = self::PLAN_PROFESSIONAL;
151 }
152 if (strpos($itemName, 'personal') !== false
153 || strpos($itemName, 'unlimited') !== false
154 || strpos($itemName, 'lifetime') !== false) {
155 return self::$memoizedPlan = self::PLAN_PERSONAL;
156 }
157 }
158
159 // Valid license but no recognizable tier info — fall back to the
160 // entry-level paid tier (Personal). Promoting to Professional here
161 // would silently grant access to higher-tier addons.
162 return self::$memoizedPlan = self::PLAN_PERSONAL;
163 }
164
165 /**
166 * Does the current plan satisfy (or exceed) the required plan?
167 */
168 public static function planSatisfies(string $required): bool {
169 $current = self::getCurrentPlan();
170 $currentRank = self::PLAN_RANK[$current] ?? 0;
171 $requiredRank = self::PLAN_RANK[$required] ?? 0;
172 return $currentRank >= $requiredRank;
173 }
174
175 /**
176 * Human-readable plan label.
177 */
178 public static function getPlanLabel(string $plan): string {
179 switch ($plan) {
180 case self::PLAN_AGENCY: return __('Agency', 'easy-invoice');
181 case self::PLAN_PROFESSIONAL: return __('Professional', 'easy-invoice');
182 case self::PLAN_PERSONAL: return __('Personal', 'easy-invoice');
183 case self::PLAN_NONE: return __('Free', 'easy-invoice');
184 }
185 return ucfirst($plan);
186 }
187
188 /**
189 * Short label for tight spots — chip badges, table cells, anywhere
190 * "Professional" wraps awkwardly. The full label remains on the License
191 * page and inside addon cards / details where space allows.
192 */
193 public static function getPlanLabelShort(string $plan): string {
194 switch ($plan) {
195 case self::PLAN_AGENCY: return __('Agency', 'easy-invoice');
196 case self::PLAN_PROFESSIONAL: return __('Pro', 'easy-invoice');
197 case self::PLAN_PERSONAL: return __('Personal', 'easy-invoice');
198 case self::PLAN_NONE: return __('Free', 'easy-invoice');
199 }
200 return self::getPlanLabel($plan);
201 }
202
203 /**
204 * Brand color for the plan badge. Centralised so the License page and
205 * Addons grid stay visually consistent.
206 */
207 public static function getPlanColor(string $plan): string {
208 switch ($plan) {
209 case self::PLAN_AGENCY: return '#7c3aed'; // purple
210 case self::PLAN_PROFESSIONAL: return '#2563eb'; // blue
211 case self::PLAN_PERSONAL: return '#0d9488'; // teal
212 case self::PLAN_NONE: return '#6b7280'; // grey
213 }
214 return '#6b7280';
215 }
216
217 public static function isValidPlan(string $plan): bool {
218 return array_key_exists($plan, self::PLAN_RANK);
219 }
220
221 /**
222 * EDD price_id → variant label. Mirrors the SKU list in the store.
223 *
224 * price_id 4 ("Personal Lifetime") and price_id 5 ("Unlimited Lifetime")
225 * both ship under the same "Personal Lifetime" plan label — the only
226 * difference between them is the activation/site count, which is shown
227 * separately on the License page as Activations: X / Y.
228 */
229 private const PRICE_ID_VARIANT_LABELS = [
230 '1' => 'Personal',
231 '2' => 'Professional',
232 '3' => 'Agency',
233 '4' => 'Personal Lifetime',
234 '5' => 'Personal Lifetime',
235 ];
236
237 /**
238 * Resolve a human-readable variant label for the current license.
239 *
240 * Precedence:
241 * 1. `price_id` → variant label table (our canonical SKU names)
242 * 2. Fallback to EDD `item_name` for any unknown price_id
243 * 3. Empty string when nothing useful is available
244 *
245 * The price_id table wins over item_name so both Lifetime variants
246 * show as "Personal Lifetime" even if EDD returns the raw SKU name
247 * "Unlimited Lifetime" for price_id 5.
248 *
249 * Returns the variant string only — never falls back to the plan tier,
250 * so callers can decide whether to show a separate row or hide entirely.
251 */
252 public static function getPlanVariantLabel(): string {
253 if (!class_exists('\\EasyInvoicePro\\Updater\\License')) {
254 return '';
255 }
256 if (!\EasyInvoicePro\Updater\License::has_valid_license()) {
257 return '';
258 }
259
260 $details = \EasyInvoicePro\Updater\License::get_license_details();
261 if (!is_object($details)) {
262 return '';
263 }
264
265 $priceId = (string) ($details->price_id ?? '');
266 if ($priceId !== '' && isset(self::PRICE_ID_VARIANT_LABELS[$priceId])) {
267 return self::PRICE_ID_VARIANT_LABELS[$priceId];
268 }
269
270 // Fallback when EDD didn't return a recognized price_id: use the
271 // raw item_name with the parent product prefix stripped, so
272 // "Easy Invoice Pro - Personal Lifetime" shows as "Personal Lifetime".
273 $itemName = trim((string) ($details->item_name ?? $details->name ?? ''));
274 if ($itemName !== '') {
275 $cleaned = preg_replace('/^easy\s*invoice\s*pro\s*[-:–]?\s*/i', '', $itemName);
276 $cleaned = trim((string) $cleaned);
277 return $cleaned !== '' ? $cleaned : $itemName;
278 }
279
280 return '';
281 }
282 }
283
284 // Invalidate the per-request memo when any option the resolver reads changes.
285 // Without this, a license activate / deactivate inside the same request would
286 // keep showing the old plan to subsequent callers.
287 add_action('updated_option_easy_invoice_pro_plan_tier_override', [\EasyInvoice\Addons\LicensePlanResolver::class, 'clearMemo']);
288 add_action('updated_option_easy_invoice_pro_license_details', [\EasyInvoice\Addons\LicensePlanResolver::class, 'clearMemo']);
289 add_action('updated_option_easy_invoice_pro_license_key', [\EasyInvoice\Addons\LicensePlanResolver::class, 'clearMemo']);
290 add_action('updated_option_easy_invoice_pro_license_status', [\EasyInvoice\Addons\LicensePlanResolver::class, 'clearMemo']);
291 add_action('added_option_easy_invoice_pro_plan_tier_override', [\EasyInvoice\Addons\LicensePlanResolver::class, 'clearMemo']);
292 add_action('added_option_easy_invoice_pro_license_details', [\EasyInvoice\Addons\LicensePlanResolver::class, 'clearMemo']);
293 add_action('added_option_easy_invoice_pro_license_key', [\EasyInvoice\Addons\LicensePlanResolver::class, 'clearMemo']);
294 add_action('added_option_easy_invoice_pro_license_status', [\EasyInvoice\Addons\LicensePlanResolver::class, 'clearMemo']);
295