PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
fluent-cart / app / Helpers / Helper.php

Helper.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.5, at app/Helpers/Helper.php

2,117 lines 69.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Helpers;
4
5 use FluentCart\Api\Confirmation;
6 use FluentCart\Api\CurrencySettings;
7 use FluentCart\Api\StoreSettings;
8 use FluentCart\Api\Taxonomy;
9 use FluentCart\App\App;
10 use FluentCart\App\CPT\FluentProducts;
11 use FluentCart\App\Models\Customer;
12 use FluentCart\App\Models\ProductVariation;
13 use FluentCart\App\Models\User;
14 use FluentCart\App\Services\DateTime\DateTime;
15 use FluentCart\App\Services\Payments\PaymentHelper;
16 use FluentCart\App\Services\Localization\LocalizationManager;
17 use FluentCart\App\Services\Translations\TransStrings;
18 use FluentCart\App\Services\URL;
19 use FluentCart\Framework\Http\URL as BaseUrl;
20 use FluentCart\Framework\Support\Arr;
21
22 class Helper
23 {
24 const PRODUCT_TYPE_SIMPLE = 'simple';
25 const PRODUCT_TYPE_SIMPLE_VARIATION = 'simple_variations';
26 const PRODUCT_TYPE_ADVANCE_VARIATION = 'advanced_variations';
27
28 const INSTANT_CHECKOUT_URL_PARAM = 'fct_cart_hash';
29
30 const IN_STOCK = 'in-stock';
31 const OUT_OF_STOCK = 'out-of-stock';
32
33 const ROLE_CAPABILITY_PREFIX = 'fluent_cart/permissions/';
34
35 const USER_ROLE = 'fluent_cart_customer';
36
37 const MIN_INSTALLMENT_TIMES = 2;
38
39 /**
40 * An installment plan must bill at least twice. times = 0 means unlimited
41 * (a plain recurring subscription) and times = 1 collects a single payment,
42 * which is a one-time purchase, not an installment plan.
43 *
44 * @param array $otherInfo A variant's other_info payload
45 * @return string|null Error message, or null when valid
46 */
47 public static function installmentTimesError($otherInfo)
48 {
49 if (Arr::get($otherInfo, 'installment', 'no') !== 'yes') {
50 return null;
51 }
52
53 $times = Arr::get($otherInfo, 'times', 0);
54
55 if (!is_numeric($times) || (int)$times < static::MIN_INSTALLMENT_TIMES) {
56 return __('Installment count must be 2 or more. A single installment is just a one-time payment — set the payment type to one-time instead.', 'fluent-cart');
57 }
58
59 return null;
60 }
61
62 public static function getUidSerial()
63 {
64 static $id = 0;
65
66 $id = $id + 1;
67
68 return $id;
69
70 }
71
72 /**
73 * Build a spec-compliant "Upgrade to Pro" URL.
74 *
75 * Follows the shared Fluent* UTM spec:
76 * utm_source = fluent-cart (fixed vocabulary, never the wp.org slug)
77 * utm_medium = free_plugin | pro_plugin (acquisition vs cross-sell)
78 * utm_campaign= upgrade_pro (override for xsell_<target> / license_*)
79 * utm_content = the exact placement, e.g. feature_lock_advanced_inventory
80 * utm_term = plugin version that generated the link
81 * utm_id = promo id, blank normally (omit unless passed)
82 *
83 * @param string $content The utm_content placement.
84 * @param array $overrides Override any utm_* param (e.g. utm_campaign for cross-sell).
85 * @return string
86 */
87 public static function getUpgradeUrl($content = 'upgrade_page', $overrides = []): string
88 {
89 $baseUrl = (string) apply_filters(
90 'fluent_cart/pro_upgrade_base_url',
91 'https://fluentcart.com/discount-deal/'
92 );
93
94 $params = wp_parse_args($overrides, [
95 'utm_source' => 'fluent-cart',
96 'utm_medium' => App::isProActive() ? 'pro_plugin' : 'free_plugin',
97 'utm_campaign' => 'upgrade_pro',
98 'utm_content' => $content,
99 'utm_term' => defined('FLUENTCART_VERSION') ? FLUENTCART_VERSION : '',
100 ]);
101
102 // Drop any blank params (e.g. an unset utm_id) so they never hit the URL.
103 $params = array_filter($params, function ($value) {
104 return $value !== '' && $value !== null;
105 });
106
107 return add_query_arg($params, $baseUrl);
108 }
109
110 public static function getRestInfo()
111 {
112 $app = App::getInstance();
113
114 $ns = $app->config->get('app.rest_namespace');
115 $ver = $app->config->get('app.rest_version');
116
117 return [
118 'base_url' => self::getBaseRestUrl(),
119 'url' => self::getFullRestUrl($ns, $ver),
120 'nonce' => wp_create_nonce('wp_rest'),
121 'namespace' => $ns,
122 'version' => $ver,
123 ];
124 }
125
126
127 /**
128 * Get base rest url by examining the permalink.
129 *
130 * @see https://wordpress.stackexchange.com/questions/273144/can-i-use-rest-api-on-plain-permalink-format
131 *
132 * @return string
133 */
134 protected static function getBaseRestUrl()
135 {
136 if (get_option('permalink_structure')) {
137 return esc_url_raw(rest_url());
138 }
139
140 return esc_url_raw(
141 rtrim(get_site_url(), '/') . "/?rest_route=/"
142 );
143 }
144
145 /**
146 * Get the full rest url by examining the permalink
147 * (full means, including the namespace/version).
148 *
149 * @see https://wordpress.stackexchange.com/questions/273144/can-i-use-rest-api-on-plain-permalink-format
150 *
151 * @return string
152 */
153 protected static function getFullRestUrl($ns, $ver)
154 {
155 if (get_option('permalink_structure')) {
156 return esc_url_raw(rest_url($ns . '/' . $ver));
157 }
158
159 return esc_url_raw(
160 rtrim(get_site_url(), '/') . "/?rest_route=/{$ns}/{$ver}"
161 );
162 }
163
164
165 public static function shopConfig($key = false)
166 {
167 /**
168 * todo - need to review for more improvement : AR
169 */
170 $currencySettings = (fluentCart(CurrencySettings::class))->get();
171 $storeSettings = (new StoreSettings())->get([
172 'store_name', 'store_logo', 'weight_unit', 'dimension_unit'
173 ]);
174 $storeSettings['shipping_packages'] = self::getShippingPackages();
175
176 $settings = array_merge($currencySettings, $storeSettings);
177
178
179 if (!$key) {
180 return $settings;
181 }
182
183 if (is_array($key)) {
184 return Arr::only($settings, $key);
185 } else {
186 return Arr::get($settings, $key);
187 }
188
189 }
190
191 /**
192 * Convert weight between units using grams as base.
193 *
194 * @param float $value
195 * @param string $fromUnit
196 * @param string $toUnit
197 * @return float
198 */
199 public static function convertWeight($value, $fromUnit, $toUnit)
200 {
201 if ($fromUnit === $toUnit || !$value) {
202 return (float)$value;
203 }
204
205 $toGrams = [
206 'g' => 1,
207 'kg' => 1000,
208 'lbs' => 453.592,
209 'oz' => 28.3495,
210 ];
211
212 $fromFactor = isset($toGrams[$fromUnit]) ? $toGrams[$fromUnit] : 1;
213 $toFactor = isset($toGrams[$toUnit]) ? $toGrams[$toUnit] : 1;
214
215 $grams = $value * $fromFactor;
216 return round($grams / $toFactor, 6);
217 }
218
219 /**
220 * Get all shipping packages from store settings.
221 *
222 * @return array
223 */
224 public static function getShippingPackages()
225 {
226 static $packages = null;
227 if ($packages === null) {
228 $packages = fluent_cart_get_option('shipping_packages', []);
229 }
230 return $packages ?: [];
231 }
232
233 /**
234 * Get the default shipping package.
235 *
236 * @return array|null
237 */
238 public static function getDefaultPackage()
239 {
240 $packages = self::getShippingPackages();
241 foreach ($packages as $package) {
242 if (!empty($package['is_default'])) {
243 return $package;
244 }
245 }
246 return null;
247 }
248
249 /**
250 * Find a shipping package by slug.
251 *
252 * @param string $slug
253 * @return array|null
254 */
255 public static function getPackageBySlug($slug)
256 {
257 if (!$slug) {
258 return self::getDefaultPackage();
259 }
260 $packages = self::getShippingPackages();
261 foreach ($packages as $package) {
262 if (isset($package['slug']) && $package['slug'] === $slug) {
263 return $package;
264 }
265 }
266 return self::getDefaultPackage();
267 }
268
269 public static function invoiceSettings($key = false)
270 {
271 $settings = [
272 'invoice_prefix' => 'AS-',
273 ];
274 if (!$key) {
275 return $settings;
276 }
277
278 return Arr::get($settings, $key);
279 }
280
281 public static function getOrderStatuses()
282 {
283 $statuses = [
284 'on-hold' => __('On Hold', 'fluent-cart'),
285 'processing' => __('Processing', 'fluent-cart'),
286 'completed' => __('Completed', 'fluent-cart'),
287 //'archived' => __('Archived', 'fluent-cart'),
288 'cancelled' => __('Cancelled', 'fluent-cart'),
289 ];
290
291 return apply_filters('fluent_cart/order_statuses', $statuses, []);
292 }
293
294 public static function getEditableOrderStatuses()
295 {
296 $statuses = [
297 'on-hold' => __('On Hold', 'fluent-cart'),
298 'processing' => __('Processing', 'fluent-cart'),
299 'completed' => __('Completed', 'fluent-cart'),
300 // 'archived' => __('Archived', 'fluent-cart'),
301 'cancelled' => __('Cancelled', 'fluent-cart')
302 ];
303
304 return apply_filters('fluent_cart/editable_order_statuses', $statuses, []);
305 }
306
307 public static function getEditableCustomerStatuses()
308 {
309 $statuses = [
310 'active' => __('Active', 'fluent-cart'),
311 'inactive' => __('Inactive', 'fluent-cart'),
312 ];
313
314 return apply_filters('fluent_cart/editable_customer_statuses', $statuses, []);
315 }
316
317 public static function getShippingStatuses()
318 {
319 $statuses = [
320 'unshipped' => __('Unshipped', 'fluent-cart'),
321 'shipped' => __('Shipped', 'fluent-cart'),
322 'delivered' => __('Delivered', 'fluent-cart'),
323 'unshippable' => __('Unshippable', 'fluent-cart'),
324 ];
325
326 return apply_filters('fluent_cart/shipping_statuses', $statuses, []);
327 }
328
329 public static function getEditableShippingStatuses()
330 {
331 $statuses = [
332 'unshipped' => __('Unshipped', 'fluent-cart'),
333 'shipped' => __('Shipped', 'fluent-cart'),
334 'delivered' => __('Delivered', 'fluent-cart'),
335 'unshippable' => __('Unshippable', 'fluent-cart'),
336 ];
337
338 return apply_filters('fluent_cart/editable_shipping_statuses', $statuses, []);
339 }
340
341 public static function getOrderSuccessStatuses()
342 {
343 return [
344 'completed',
345 // 'archived',
346 'processing',
347 ];
348 }
349
350 public static function getOrderFailedStatuses()
351 {
352 return [
353 'failed',
354 //'refunded',
355 'cancelled',
356 ];
357 }
358
359 public static function getTransactionSuccessStatuses()
360 {
361 return [
362 'paid',
363 ];
364 }
365
366 public static function getTransactionStatuses($withLabel = true)
367 {
368 $statuses = [
369 'pending' => __('Pending', 'fluent-cart'),
370 'paid' => __('Paid', 'fluent-cart'),
371 'require_capture' => __('Authorized (Require Capture)', 'fluent-cart'),
372 'failed' => __('Failed', 'fluent-cart'),
373 'refunded' => __('Refunded', 'fluent-cart'),
374 'active' => __('Active', 'fluent-cart'),
375 ];
376
377 $statuses = apply_filters('fluent_cart/transaction_statuses', $statuses, []);
378
379 if ($withLabel) {
380 return $statuses;
381 }
382
383 return array_keys($statuses);
384 }
385
386 public static function getEditableTransactionStatuses($withLabel = true)
387 {
388 $statuses = [
389 'pending' => __('Pending', 'fluent-cart'),
390 'paid' => __('Paid', 'fluent-cart'),
391 'failed' => __('Failed', 'fluent-cart'),
392 'refunded' => __('Refunded', 'fluent-cart'),
393 ];
394
395 $statuses = apply_filters('fluent_cart/editable_transaction_statuses', $statuses, []);
396
397 if ($withLabel) {
398 return $statuses;
399 }
400
401 return array_keys($statuses);
402 }
403
404 public static function productStatuses($withLabel = true): array
405 {
406 $statues = [
407 'publish' => __('Publish', 'fluent-cart'),
408 'draft' => __('Draft', 'fluent-cart'),
409 'future' => __('Scheduled', 'fluent-cart'),
410 'private' => __('Private', 'fluent-cart'),
411 'trash' => __('Trashed', 'fluent-cart'),
412 ];
413
414 if ($withLabel) {
415 return $statues;
416 }
417
418 return array_keys($statues);
419 }
420
421 public static function productAdminAllStatuses()
422 {
423 $statuses = self::productStatuses();
424 unset($statuses['trash']);
425 return array_keys($statuses);
426 }
427
428 public static function getCartDriver()
429 {
430 return 'db';
431 }
432
433 /**
434 * Convert an amount to a formatted decimal string.
435 *
436 * @param float $amount The amount to convert. (required)
437 * @param bool $withCurrency Whether to include the currency symbol. (optional, default: true)
438 * @param string|null $currencyCode The currency code to use. (optional, default: null)
439 * @param bool $formatted Whether to format the amount. (optional, default: true)
440 * @param bool $showDecimals Whether to show decimal places. (optional, default: true)
441 * @param bool $thousand_separator Whether to include thousand separators. (optional, default: true)
442 *
443 * @return string The formatted amount.
444 *
445 * @dev Note: If you don't want the thousand separator to be applied,
446 * you need to set $formatted to true and $thousand_separator to false.
447 *
448 * @hook fluent_cart/hide_unnecessary_decimals - Filter to control whether unnecessary decimals (like .00) should be hidden.
449 * Usage: add_filter('fluent_cart/hide_unnecessary_decimals', '__return_true'); // This will show 10 instead of 10.00
450 */
451 public static function toDecimal($amount, $withCurrency = true, $currencyCode = null, $formatted = true, $showDecimals = true, $thousand_separator = true, $withTranslatedDigit = true)
452 {
453 if (!is_numeric($amount)) {
454 return $amount;
455 }
456
457 // Set default decimal places to 2
458 $decimal = 2;
459
460 // Check if the shop is using a zero-decimal currency
461 if (self::shopConfig('is_zero_decimal')) {
462 $decimal = 0;
463 }
464
465 // Use provided or default currency code
466 if (!$currencyCode) {
467 $currencyCode = self::shopConfig('currency');
468 }
469
470 // Get currency sign
471 $sign = CurrenciesHelper::getCurrencySign($currencyCode);
472
473 $amount = floatVal($amount / 100);
474
475 // If $showDecimals is false, we override decimal places to 0
476 if (!$showDecimals) {
477 $decimal = 0;
478 }
479
480 // Format the amount based on the decimal configuration
481 if ($formatted) {
482 $decimal_separator = self::shopConfig('decimal_separator') === 'comma' ? ',' : '.';
483
484 $thousand_separator = $decimal_separator === ',' ? '.' : ',';
485
486 // Check if we should hide unnecessary decimal places (e.g., 10.00 -> 10)
487 $hideUnnecessaryDecimals = apply_filters('fluent_cart/hide_unnecessary_decimals', false, [
488 'amount' => $amount,
489 'decimal' => $decimal
490 ]);
491
492 $amount = number_format(
493 $amount,
494 $decimal,
495 $decimal_separator,
496 $thousand_separator
497 );
498
499 if ($hideUnnecessaryDecimals && $decimal > 0) {
500 // Remove trailing zeros only from the decimal portion
501 $parts = explode($decimal_separator, $amount);
502 if (count($parts) === 2) {
503 $parts[1] = rtrim($parts[1], '0');
504 if ($parts[1] === '') {
505 $amount = $parts[0];
506 } else {
507 $amount = $parts[0] . $decimal_separator . $parts[1];
508 }
509 }
510 }
511
512 if ($withTranslatedDigit) {
513 $amount = self::translateNumber($amount);
514 }
515 }
516
517 // If $withCurrency is false, just return the formatted amount
518 if (!$withCurrency) {
519 return $amount;
520 }
521
522 // Get currency position and return formatted amount with currency
523 $position = self::shopConfig('currency_position');
524
525 switch ($position) {
526 case 'before':
527 return $sign . $amount;
528 case 'after':
529 return $amount . $sign;
530 case 'iso_before':
531 return $currencyCode . ' ' . $amount;
532 case 'iso_after':
533 return $amount . ' ' . $currencyCode;
534 case 'symbool_before_iso':
535 return $sign . $amount . ' ' . $currencyCode;
536 case 'symbool_after_iso':
537 return $currencyCode . ' ' . $amount . $sign;
538 case 'symbool_and_iso':
539 return $currencyCode . ' ' . $sign . $amount;
540 default:
541 return $sign . $amount;
542 }
543 }
544
545 public static function toCent($amount): int
546 {
547 if (!is_numeric($amount)) {
548 return 0;
549 }
550
551 $amount = floatval($amount) * 100; // Convert to float and multiply
552 $amount = (int)round($amount); // Round to nearest integer, then cast
553 return $amount;
554 }
555
556 /**
557 * Normalize a value that is ALREADY in cents to a whole-cent int.
558 *
559 * Unlike toCent(), this does not scale — use it when the incoming value is a
560 * cents amount that may have arrived as a float. Clients computing cents in
561 * JavaScript send artifacts like 1998.9999999999998 for 1999, and a bare
562 * (int) cast truncates those, silently undercharging by a cent. Non-numeric
563 * input (including the null the request pipeline injects for omitted keys)
564 * normalizes to 0.
565 *
566 * Magnitudes beyond float's exact-integer range (2^53) are REJECTED, not
567 * coerced: is_numeric() accepts exponential notation like 1e19, and casting
568 * that float to int wraps to -8446744073709551616 — a VALID signed BIGINT —
569 * so the corrupt value would persist silently where an un-normalized float
570 * used to fail loudly at MySQL. No real cents amount approaches this bound.
571 */
572 public static function roundCent($amount): int
573 {
574 if (!is_numeric($amount)) {
575 return 0;
576 }
577
578 $amount = (float) $amount;
579
580 if (!is_finite($amount) || $amount > 9.0e15 || $amount < -9.0e15) {
581 throw new \InvalidArgumentException(
582 'Value is out of the representable cents range: ' . var_export($amount, true)
583 );
584 }
585
586 return (int) round($amount);
587 }
588
589 public static function toDecimalWithoutComma($amount)
590 {
591
592 if (!is_numeric($amount)) {
593 return 0;
594 }
595
596 // Convert to float and divide by 100
597 $result = floatval($amount) / 100;
598
599 // Ensure exactly two decimal places
600 return round($result, 2);
601 }
602
603 public static function getCustomerByUser($user)
604 {
605 if (is_numeric($user)) {
606 $user = get_user_by('ID', $user);
607 }
608
609 if (!$user) {
610 return null;
611 }
612
613 // Identity only — see CustomerResource::getCurrentCustomer() for why an
614 // email match must never reach a customer row.
615 return Customer::query()->where('user_id', $user->ID)
616 ->orderBy('id', 'ASC')
617 ->first();
618 }
619
620 /**
621 * @param array $order_data
622 *
623 * @return array return ['billing_address','shipping_address','others'];
624 */
625
626 /**
627 *
628 * @return string
629 */
630 public static function getProductImageBaseUri(): string
631 {
632 $uploads = wp_upload_dir();
633
634 return $uploads['baseurl'] . '/' . FLUENTCART_UPLOAD_DIR . '/product_image/';
635 }
636
637 public static function getProductImageBaseDir()
638 {
639 $uploads = wp_upload_dir();
640
641 return $uploads['basedir'] . '/' . FLUENTCART_UPLOAD_DIR . '/product_image/';
642 }
643
644 public static function getAvailableCurrencyList()
645 {
646 $currencies = [
647 'BDT' => [
648 "label" => __('Bangladeshi Taka', 'fluent-cart'),
649 "value" => 'BDT',
650 "symbol" => '৳',
651 ],
652 'USD' => [
653 "label" => __('United State Dollar', 'fluent-cart'),
654 "value" => 'USD',
655 "symbol" => '$',
656 ],
657 'GBP' => [
658 "label" => __('United Kingdom', 'fluent-cart'),
659 "value" => 'GBP',
660 "symbol" => '£',
661 ],
662 ];
663
664 return apply_filters('fluent_cart/available_currencies', $currencies, []);
665 }
666
667 public static function getSymbolForCurrency($currency = 'BDT')
668 {
669
670 $symbol = '৳';
671 $list = self::getAvailableCurrencyList();
672
673 return $list[$currency]['symbol'] ?? $symbol;
674 }
675
676 public function getConfirmationSettings()
677 {
678 return (new Confirmation())->get();
679 }
680
681
682 /**
683 *
684 * @param $remove
685 * @return string
686 */
687 public static function getCheckoutPageLinkAfterRemovingGetParams($remove = [])
688 {
689
690 global $fct_store;
691
692 $link = Arr::get($fct_store, 'checkout_link');
693
694 $link .= '?';
695
696 if (!empty($remove)) {
697 $params = App::request()->all();
698
699 foreach ($params as $key => $val) {
700
701 if (!in_array($key, $remove)) {
702
703 $link .= $key . '=' . $val . '&';
704 }
705 }
706 }
707
708 return rtrim($link, '&');
709 }
710
711
712 /**
713 *
714 * @return bool
715 */
716 public static function isSingleProductPage(): bool
717 {
718 return is_singular([FluentProducts::CPT_NAME]);
719 }
720
721
722 public static function isTrue($array, $key)
723 {
724 $value = $array[$key] ?? false;
725 if (is_bool($value)) {
726 return $value;
727 }
728 if ($value === 'false' || !$value) {
729 return false;
730 }
731 return true;
732 }
733
734 public static function is_valid_json($string): bool
735 {
736 if (!is_string($string)) {
737 return false;
738 }
739
740 $trimmed = trim($string);
741
742 // Basic check: must start with { or [ and end with } or ]
743 if (!preg_match('/^(\{.*\}|\[.*\])$/s', $trimmed)) {
744 return false;
745 }
746
747 json_decode($trimmed);
748 return json_last_error() === JSON_ERROR_NONE;
749 }
750
751 public static function getStockStatuses($withLabel = true)
752 {
753 $statues = [
754 'in-stock' => __('In Stock', 'fluent-cart'),
755 'out-of-stock' => __('Out Of Stock', 'fluent-cart'),
756 ];
757
758 if ($withLabel) {
759 return $statues;
760 }
761
762 return array_keys($statues);
763 }
764
765 public static function getFulfilmentTypes($withLabel = true)
766 {
767 $statues = [
768 'physical' => __('Physical', 'fluent-cart'),
769 'digital' => __('Digital', 'fluent-cart'),
770 ];
771
772 if ($withLabel) {
773 return $statues;
774 }
775
776 return array_keys($statues);
777 }
778
779 public static function getVariationTypes($withLabel = true)
780 {
781 // advanced_variations is advertised in free too so the variation-type
782 // dropdown can offer it (shown Pro-locked with a crown and an upgrade
783 // modal while Pro is inactive). Pro re-registers the same key via the
784 // filter below when active.
785 $types = [
786 'simple' => __('Simple', 'fluent-cart'),
787 'simple_variations' => __('Simple Variations', 'fluent-cart'),
788 'advanced_variations' => __('Advanced Variations', 'fluent-cart'),
789 ];
790
791 $types = apply_filters('fluent_cart/variation_types', $types);
792
793 if ($withLabel) {
794 return $types;
795 }
796
797 return array_keys($types);
798 }
799
800 public static function isValueEncrypted($raw_value)
801 {
802 if (!$raw_value || !is_string($raw_value) || !extension_loaded('openssl')) {
803 return false;
804 }
805
806 // Check if input is valid base64
807 if (!preg_match('/^[a-zA-Z0-9\/\r\n+]*={0,2}$/', $raw_value)) {
808 return false;
809 }
810
811 $decoded = base64_decode($raw_value, true);
812 if ($decoded === false) {
813 return false;
814 }
815
816 // Check if decoded string is long enough for IV
817 $method = 'aes-256-ctr';
818 $ivlen = openssl_cipher_iv_length($method);
819 if (strlen($decoded) < $ivlen) {
820 return false;
821 }
822
823 $iv = substr($decoded, 0, $ivlen);
824 $ciphertext = substr($decoded, $ivlen);
825
826 $key = (defined('FLUENT_CART_ENCRYPTION_KEY'))
827 ? FLUENT_CART_ENCRYPTION_KEY
828 : ((defined('LOGGED_IN_KEY') && '' !== LOGGED_IN_KEY)
829 ? LOGGED_IN_KEY
830 : 'this-is-a-fallback-key-but-not-secure');
831 $salt = (defined('LOGGED_IN_SALT') && '' !== LOGGED_IN_SALT)
832 ? LOGGED_IN_SALT
833 : 'this-is-a-fallback-salt-but-not-secure';
834
835 $value = openssl_decrypt($ciphertext, $method, $key, 0, $iv);
836 if ($value === false) {
837 return false;
838 }
839
840
841 return substr($value, -strlen($salt)) === $salt;
842 }
843
844 public static function encryptKey($value)
845 {
846 if (!$value) {
847 return $value;
848 }
849
850 if (!extension_loaded('openssl')) {
851 return $value;
852 }
853
854 if (self::isValueEncrypted($value)) {
855 return $value;
856 }
857
858 $salt = (defined('LOGGED_IN_SALT') && '' !== LOGGED_IN_SALT) ? LOGGED_IN_SALT : 'this-is-a-fallback-salt-but-not-secure';
859
860 if (defined('FLUENT_CART_ENCRYPTION_KEY')) {
861 $key = FLUENT_CART_ENCRYPTION_KEY;
862 } else {
863 $key = (defined('LOGGED_IN_KEY') && '' !== LOGGED_IN_KEY) ? LOGGED_IN_KEY : 'this-is-a-fallback-key-but-not-secure';
864 }
865
866 $method = 'aes-256-ctr';
867 $ivlen = openssl_cipher_iv_length($method);
868 $iv = openssl_random_pseudo_bytes($ivlen);
869
870 $raw_value = openssl_encrypt($value . $salt, $method, $key, 0, $iv);
871 if (!$raw_value) {
872 return false;
873 }
874
875 return base64_encode($iv . $raw_value); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
876 }
877
878 public static function decryptKey($raw_value)
879 {
880
881 if (!$raw_value) {
882 return $raw_value;
883 }
884
885 if (!extension_loaded('openssl')) {
886 return $raw_value;
887 }
888
889 $decoded = base64_decode($raw_value, true); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
890
891 if ($decoded === false) {
892 return $raw_value;
893 }
894
895 $method = 'aes-256-ctr';
896 $ivlen = openssl_cipher_iv_length($method);
897
898 if (strlen($decoded) <= $ivlen) {
899 return $raw_value;
900 }
901
902 $iv = substr($decoded, 0, $ivlen);
903
904 $ciphertext = substr($decoded, $ivlen);
905
906 if (defined('FLUENT_CART_ENCRYPTION_KEY')) {
907 $key = FLUENT_CART_ENCRYPTION_KEY;
908 } else {
909 $key = (defined('LOGGED_IN_KEY') && '' !== LOGGED_IN_KEY) ? LOGGED_IN_KEY : 'this-is-a-fallback-key-but-not-secure';
910 }
911
912 $salt = (defined('LOGGED_IN_SALT') && '' !== LOGGED_IN_SALT) ? LOGGED_IN_SALT : 'this-is-a-fallback-salt-but-not-secure';
913
914 $decrypted = openssl_decrypt($ciphertext, $method, $key, 0, $iv);
915 if (!$decrypted || substr($decrypted, -strlen($salt)) !== $salt) {
916 return false;
917 }
918
919 return substr($decrypted, 0, -strlen($salt));
920 }
921
922 /**
923 * @return array
924 * For kses_post to allow some html tags
925 */
926 public static function allowedHTMLForCheckout(): array
927 {
928
929 $allowedTags = [
930 'svg' => [
931 'class' => true,
932 'aria-hidden' => true,
933 'aria-labelledby' => true,
934 'role' => true,
935 'xmlns' => true,
936 'width' => true,
937 'height' => true,
938 'viewbox' => true,
939 'fill' => true
940 ],
941 'g' => [
942 'fill' => true
943 ],
944 'title' => ['title' => true],
945 'path' => [
946 'd' => true,
947 'fill' => true,
948 'stroke' => true,
949 'fill-rule' => true,
950 'clip-rule' => true,
951 ],
952 ];
953
954 foreach (['input', 'label', 'div', 'span', 'p', 'select', 'option', 'textarea', 'button', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as $tag) {
955 $allowedTags[$tag] = [
956 'selected',
957 'type' => [],
958 "selected='selected'" => [],
959 'name' => [],
960 'value' => [],
961 'autocomplete' => [],
962 'placeholder' => [],
963 'data-required' => [],
964 'data-type' => [],
965 'id' => [],
966 'class' => [],
967 'required' => [],
968 'disabled' => [],
969 'for' => [],
970 'style' => [],
971 'checked' => [],
972 'maxlength' => [],
973 'data-id' => [],
974 'data-country' => [],
975 'data-fluent-cart-checkout-page-form-address-modal-open-button' => [],
976 'data-fluent-cart-checkout-page-form-address-modal-wrapper' => [],
977 'data-fluent-cart-checkout-page-form-input-wrapper' => [],
978 'data-fluent_cart_checkout_error' => [],
979 'data-fluent-cart-checkout-page-form-address-modal-body' => [],
980 'data-fluent-cart-checkout-page-form-address-modal-address-selector-button' => [],
981 'data-fluent-cart-checkout-page-form-address-input' => [],
982 'data-fluent-cart-checkout-page-form-address-select-wrapper' => [],
983 'data-fluent-cart-checkout-page-form-address-modal-close-button' => [],
984 'data-fluent-cart-checkout-page-form-address-modal-address-selector-button-wrapper' => [],
985 'data-fluent-cart-checkout-page-form-address-show-add-new-modal-button' => [],
986 'data-fluent-cart-checkout-page-form-address-modal-apply-button' => [],
987 'data-fluent-cart-checkout-page-form-address-show-add-new-modal-form-wrapper' => [],
988 'data-fluent-cart-checkout-page-form-address-show-add-new-modal-submit-button' => [],
989 'data-fluent-cart-checkout-page-form-address-show-add-new-modal-cancel-button' => [],
990 'data-fluent-cart-address-type' => [],
991 'data-fluent-cart-checkout-page-form-address-info-wrapper' => [],
992 'data-fluent-cart-checkout-page-form-error' => [],
993 'data-fluent-cart-checkout-page-form-section' => [],
994 'data-fluent-cart-checkout-page-discount-container' => [],
995 'data-fluent-cart-checkout-page-final-amount-container' => [],
996 'data-fluent-cart-checkout-page-new-total-amount' => [],
997 'data-fluent-cart-checkout-page-final-amount' => [],
998 'data-fluent-cart-checkout-page-coupon-validate' => [],
999 'data-fluent-cart-checkout-coupon-items-toggle' => [],
1000 'data-fluent-cart-checkout-coupon-items-wrapper' => []
1001
1002
1003 ];
1004 }
1005
1006 return $allowedTags;
1007 }
1008
1009 public static function getCouponStatuses()
1010 {
1011 $statuses = [
1012 'active' => __('Active', 'fluent-cart'),
1013 'expired' => __('Expired', 'fluent-cart'),
1014 'disabled' => __('Disabled', 'fluent-cart'),
1015 ];
1016
1017 return apply_filters('fluent_cart/coupon_statuses', $statuses, []);
1018 }
1019
1020 public static function getCouponSuccessStatuses()
1021 {
1022 return [
1023 'active'
1024 ];
1025 }
1026
1027
1028 /**
1029 * Compact subscription terms as text
1030 *
1031 * Expected $data keys:
1032 * - trial_days (int)
1033 * - interval ('daily'|'weekly'|'monthly'|'yearly')
1034 * - interval_count (int, default 1)
1035 * - times (int; 0 = open-ended, >0 = finite number of payments/cycles)
1036 * - price (string; formatted with currency)
1037 * - signup_fee (string; 0 if none)
1038 * - compare_price (string; 0 if none)
1039 *
1040 * Output examples:
1041 * - "30 days free then $100.00 per year"
1042 * - "$100 per year + $10 one-time signup fee"
1043 * - "$100/month for 4 months"
1044 * - "30 days free then $100/month for 4 months"
1045 * - "$99 per month"
1046 */
1047 public static function getSubscriptionTermText(array $data, $asHtml = false): string
1048 {
1049 // Fixed interval options
1050 $intervalOptions = static::getAvailableSubscriptionIntervalMaps();
1051
1052 // Normalize / defaults
1053 $trialDays = $data['trial_days'] ?? 0;
1054 $interval = (string)($data['interval'] ? $data['interval'] : 'monthly');
1055
1056 $unit = '';
1057 if (isset($intervalOptions[$interval])) {
1058 $unit = $intervalOptions[$interval];
1059 } else if ($interval) {
1060 $intervalOptions = static::getAvailableSubscriptionIntervalOptions();
1061 foreach ($intervalOptions as $option) {
1062 if ($option['value'] === $interval) {
1063 $unit = strtolower($option['label']);
1064 break;
1065 }
1066 }
1067
1068 if (!$unit) {
1069 $unit = strtolower(str_replace(['_', '-'], ' ', $interval));
1070 }
1071 }
1072
1073 if (!$unit) {
1074 $unit = 'year';
1075 }
1076
1077 $count = max(1, (int)($data['interval_count'] ?? 1));
1078 $times = (int)($data['times'] ?? 0);
1079 $price = (string)($data['price'] ?? '');
1080 $signupFee = $data['signup_fee'] ?? '';
1081 $compare = $data['compare_price'] ?? '';
1082
1083 $signupFeeLabel = $data['signup_fee_label'] ?? __('signup fee', 'fluent-cart');
1084
1085 // helpers
1086 $pluralUnit = static function (string $unit, int $n): string {
1087 switch ($unit) {
1088 case 'day':
1089 return _n('day', 'days', $n, 'fluent-cart');
1090 case 'week':
1091 return _n('week', 'weeks', $n, 'fluent-cart');
1092 case 'quarter':
1093 return _n('quarter', 'quarters', $n, 'fluent-cart');
1094 case 'half_year':
1095 return _n('six month', 'six months', $n, 'fluent-cart');
1096 case 'year':
1097 return _n('year', 'years', $n, 'fluent-cart');
1098 case 'month':
1099 return _n('month', 'months', $n, 'fluent-cart');
1100 default:
1101 // For custom intervals, add 's' for plural
1102 return ($n > 1) ? $unit . 's' : $unit;
1103 }
1104 };
1105
1106 // Build the “per …” phrase (e.g., "per month", "per 3 months")
1107 $perPhrase = ($count === 1)
1108 ? sprintf(
1109 /* translators: %s is the singular unit name (e.g., day, month, year) */
1110 __('per %s', 'fluent-cart'),
1111 $pluralUnit($unit, 1)
1112 )
1113 : sprintf(
1114 /* translators: %1$d is the count number, %2$s is the plural unit name (e.g., days, months, years) */
1115 __('per %1$d %2$s', 'fluent-cart'),
1116 $count,
1117 $pluralUnit($unit, $count)
1118 );
1119
1120 // Main price phrase:
1121 // - If monthly installments (times > 0 and unit == month and count == 1): "$X/month for N months"
1122 // - Else if times > 0: "$X per {count unit(s)} for N {cycle(s)}"
1123 // - Else: "$X per {count unit(s)}"
1124 if ($times > 0) {
1125 if ($unit === 'month' && $count === 1) {
1126 $installmentTail = sprintf(
1127 /* translators: %s: pluralized 'month(s)' */
1128 __('for %1$d %2$s', 'fluent-cart'),
1129 $times,
1130 $pluralUnit('month', $times)
1131 );
1132 $main = sprintf(
1133 /* translators: Compact monthly installment, e.g. "$100/month for 4 months" */
1134 __('%1$s/%2$s %3$s', 'fluent-cart'),
1135 $price,
1136 __('month', 'fluent-cart'),
1137 $installmentTail
1138 );
1139 } else {
1140 // Generic installment wording: "for N cycle(s)"
1141 $cyclesTail = sprintf(
1142 /* translators: %1$d is the number of cycles, %2$s is "cycle" or "cycles" */
1143 __('for %1$d %2$s', 'fluent-cart'),
1144 $times,
1145 _n('cycle', 'cycles', $times, 'fluent-cart')
1146 );
1147 $main = sprintf(
1148 /* translators: e.g. "$100 per 3 months for 4 cycles" */
1149 __('%1$s %2$s %3$s', 'fluent-cart'),
1150 $price,
1151 $perPhrase,
1152 $cyclesTail
1153 );
1154 }
1155 } else {
1156 // Open-ended subscription
1157 $main = sprintf(
1158 /* translators: e.g. "$99 per month" */
1159 __('%1$s %2$s', 'fluent-cart'),
1160 $price,
1161 $perPhrase
1162 );
1163 }
1164
1165 // Prefix trial if present: "N days free then …"
1166 if ($trialDays > 0) {
1167 $trialFrag = sprintf(
1168 /* translators: "30 days free then" */
1169 __('%1$d %2$s free then', 'fluent-cart'),
1170 $trialDays,
1171 $pluralUnit('day', $trialDays)
1172 );
1173 $main = $trialFrag . ' ' . $main;
1174 }
1175
1176 // Append signup fee if any: " + $10 one-time signup fee"
1177 if ($signupFee) {
1178 $main .= ' ' . sprintf(
1179 /* translators: e.g. "+ $10 one-time signup fee" */
1180 __('+ %1$s one-time %2$s', 'fluent-cart'),
1181 $signupFee,
1182 $signupFeeLabel
1183 );
1184 }
1185
1186 // No trailing period (matches your examples)
1187 return $main;
1188 }
1189
1190 public static function getTranslatedIntervalUnit(string $unit): string
1191 {
1192 switch ($unit) {
1193 case 'day':
1194 return __('day', 'fluent-cart');
1195 case 'week':
1196 return __('week', 'fluent-cart');
1197 case 'month':
1198 return __('month', 'fluent-cart');
1199 case 'quarter':
1200 return __('quarter', 'fluent-cart');
1201 case 'half_year':
1202 return __('six month', 'fluent-cart');
1203 case 'year':
1204 return __('year', 'fluent-cart');
1205 default:
1206 return $unit;
1207 }
1208 }
1209
1210 public static function generateSubscriptionInfo($otherInfo, $itemPrice, $currencyCode = null): ?string
1211 {
1212 // Convert to array only if it's an object
1213 if (is_object($otherInfo)) {
1214 $otherInfo = json_decode(json_encode($otherInfo), true);
1215 }
1216
1217 $price = self::toDecimal($itemPrice, true, $currencyCode);
1218 $recurringDiscountAmount = Arr::get($otherInfo, 'recurring_discounts.amount', 0);
1219
1220 if ($recurringDiscountAmount) {
1221 $newRecurringAmount = $itemPrice - $recurringDiscountAmount;
1222 $price = "<del>" . $price . "</del> " . self::toDecimal($newRecurringAmount, true, $currencyCode);
1223 }
1224
1225 $repeatInterval = Arr::get($otherInfo, 'repeat_interval', '');
1226 $occurrence = (int)Arr::get($otherInfo, 'times', 0);
1227
1228 $intervalOptions = static::getAvailableSubscriptionIntervalMaps();
1229
1230 $intervalUnit = '';
1231 if (isset($intervalOptions[$repeatInterval])) {
1232 $intervalUnit = $intervalOptions[$repeatInterval];
1233 } else if ($repeatInterval) {
1234 $intervalOptions = static::getAvailableSubscriptionIntervalOptions();
1235 foreach ($intervalOptions as $option) {
1236 if ($option['value'] === $repeatInterval) {
1237 $intervalUnit = strtolower($option['label']);
1238 break;
1239 }
1240 }
1241
1242 if (!$intervalUnit) {
1243 $intervalUnit = ucwords(str_replace(['_', '-'], ' ', $repeatInterval));
1244 }
1245 }
1246
1247 $intervalLabel = Helper::getTranslatedIntervalUnit($intervalUnit);
1248
1249 $interval = $intervalUnit
1250 ? sprintf(
1251 /* translators: %s is the interval (e.g., day, week, month, quarter, half_year, year) */
1252 __('per %s', 'fluent-cart'),
1253 $intervalLabel
1254 )
1255 : '';
1256
1257 $time = $intervalUnit
1258 ? $intervalLabel
1259 : '';
1260
1261 $paymentInfo = sprintf(
1262 /* translators: %1$s is the price, %2$s is the interval, %3$s is "until cancel" text, %4$s is the occurrence count, %5$s is the time period */
1263 __('%1$s %2$s, for %3$s %4$s', 'fluent-cart'),
1264 $price,
1265 $interval,
1266 $occurrence,
1267 $time
1268 );
1269
1270 if (empty($occurrence)) {
1271 $paymentInfo = sprintf(
1272 /* translators: %1$s is the price, %2$s is the interval, %3$s is "until cancel" text */
1273 __('%1$s %2$s %3$s', 'fluent-cart'),
1274 $price,
1275 $interval,
1276 __('until cancel', 'fluent-cart')
1277 );
1278 }
1279
1280 return !empty($otherInfo) ? $paymentInfo : null;
1281 }
1282
1283 /**
1284 * Billing-cycle text for a subscription with a config-defined schedule
1285 * (see SubscriptionHelper::getBillingSchedule()) — cadences the
1286 * billing_interval enum cannot express, e.g. "$100 per 3 years on Aug 19".
1287 * Subscriptions without a schedule keep generateSubscriptionInfo().
1288 */
1289 public static function generateScheduleSubscriptionInfo(array $schedule, $otherInfo, $itemPrice, $currencyCode = null): ?string
1290 {
1291 if (is_object($otherInfo)) {
1292 $otherInfo = json_decode(json_encode($otherInfo), true);
1293 }
1294
1295 $count = max(1, (int) Arr::get($schedule, 'interval', 1));
1296
1297 switch (Arr::get($schedule, 'period')) {
1298 case 'day':
1299 $unitLabel = _n('day', 'days', $count, 'fluent-cart');
1300 break;
1301 case 'week':
1302 $unitLabel = _n('week', 'weeks', $count, 'fluent-cart');
1303 break;
1304 case 'month':
1305 $unitLabel = _n('month', 'months', $count, 'fluent-cart');
1306 break;
1307 case 'year':
1308 $unitLabel = _n('year', 'years', $count, 'fluent-cart');
1309 break;
1310 default:
1311 return self::generateSubscriptionInfo($otherInfo, $itemPrice, $currencyCode);
1312 }
1313
1314 $price = self::toDecimal($itemPrice, true, $currencyCode);
1315 $recurringDiscountAmount = Arr::get($otherInfo, 'recurring_discounts.amount', 0);
1316
1317 if ($recurringDiscountAmount) {
1318 $newRecurringAmount = $itemPrice - $recurringDiscountAmount;
1319 $price = "<del>" . $price . "</del> " . self::toDecimal($newRecurringAmount, true, $currencyCode);
1320 }
1321
1322 $interval = $count === 1
1323 ? sprintf(
1324 /* translators: %s is the interval unit (e.g., day, week, month, year) */
1325 __('per %s', 'fluent-cart'),
1326 $unitLabel
1327 )
1328 : sprintf(
1329 /* translators: %1$d is the count number, %2$s is the plural unit name (e.g., days, months, years) */
1330 __('per %1$d %2$s', 'fluent-cart'),
1331 $count,
1332 $unitLabel
1333 );
1334
1335 if ($anchorText = self::getScheduleAnchorText($schedule['period'], Arr::get($schedule, 'anchor', []))) {
1336 $interval .= ' ' . $anchorText;
1337 }
1338
1339 $occurrence = (int) Arr::get($otherInfo, 'times', 0);
1340
1341 if (empty($occurrence)) {
1342 return sprintf(
1343 /* translators: %1$s is the price, %2$s is the interval, %3$s is "until cancel" text */
1344 __('%1$s %2$s %3$s', 'fluent-cart'),
1345 $price,
1346 $interval,
1347 __('until cancel', 'fluent-cart')
1348 );
1349 }
1350
1351 return sprintf(
1352 /* translators: %1$s is the price, %2$s is the interval, %3$s is the occurrence count, %4$s is "cycle(s)" */
1353 __('%1$s %2$s, for %3$s %4$s', 'fluent-cart'),
1354 $price,
1355 $interval,
1356 $occurrence,
1357 _n('cycle', 'cycles', $occurrence, 'fluent-cart')
1358 );
1359 }
1360
1361 /**
1362 * Human-readable billing anchor, e.g. "on Friday", "on the 10th",
1363 * "on the last day", "on Aug 19". Anchor day 31 encodes "last day of
1364 * the month" (see SubscriptionHelper::getBillingSchedule()).
1365 */
1366 private static function getScheduleAnchorText(string $period, $anchor): string
1367 {
1368 if (!is_array($anchor) || !$anchor) {
1369 return '';
1370 }
1371
1372 if ($period === 'week' && !empty($anchor['weekday'])) {
1373 $weekdays = [
1374 1 => __('Monday', 'fluent-cart'),
1375 2 => __('Tuesday', 'fluent-cart'),
1376 3 => __('Wednesday', 'fluent-cart'),
1377 4 => __('Thursday', 'fluent-cart'),
1378 5 => __('Friday', 'fluent-cart'),
1379 6 => __('Saturday', 'fluent-cart'),
1380 7 => __('Sunday', 'fluent-cart'),
1381 ];
1382
1383 if (isset($weekdays[$anchor['weekday']])) {
1384 /* translators: %s is a weekday name, e.g. "on Friday" */
1385 return sprintf(__('on %s', 'fluent-cart'), $weekdays[$anchor['weekday']]);
1386 }
1387
1388 return '';
1389 }
1390
1391 if ($period === 'month' && !empty($anchor['day'])) {
1392 $day = (int) $anchor['day'];
1393
1394 if ($day === 31) {
1395 return __('on the last day', 'fluent-cart');
1396 }
1397
1398 /* translators: %s is an ordinal day of month, e.g. "on the 10th" */
1399 return sprintf(__('on the %s', 'fluent-cart'), gmdate('jS', gmmktime(12, 0, 0, 1, $day, 2001)));
1400 }
1401
1402 if ($period === 'year' && (!empty($anchor['day']) || !empty($anchor['month']))) {
1403 $day = (int) Arr::get($anchor, 'day', 0);
1404 $month = (int) Arr::get($anchor, 'month', 0);
1405
1406 if ($month && $day) {
1407 /* translators: %s is a date, e.g. "on Aug 19" */
1408 // wp_date(), not gmdate(): gmdate() has no locale, so this month
1409 // name stayed English inside an otherwise translated sentence.
1410 return sprintf(__('on %s', 'fluent-cart'), wp_date('M', gmmktime(12, 0, 0, $month, 1, 2001), new \DateTimeZone('UTC')) . ' ' . $day);
1411 }
1412
1413 if ($month) {
1414 /* translators: %s is a month name, e.g. "in August" */
1415 return sprintf(__('in %s', 'fluent-cart'), wp_date('F', gmmktime(12, 0, 0, $month, 1, 2001), new \DateTimeZone('UTC')));
1416 }
1417
1418 /* translators: %s is an ordinal day of month, e.g. "on the 10th" */
1419 return sprintf(__('on the %s', 'fluent-cart'), gmdate('jS', gmmktime(12, 0, 0, 1, $day, 2001)));
1420 }
1421
1422 return '';
1423 }
1424
1425 public static function generateSetupFeeInfo($otherInfo, $asArray = false)
1426 {
1427 // Convert to array if it's an object
1428 if (is_object($otherInfo)) {
1429 $otherInfo = json_decode(json_encode($otherInfo), true);
1430 }
1431
1432 $signupFeeName = Arr::get($otherInfo, 'signup_fee_name', __('Setup Fee', 'fluent-cart'));
1433 $fee = Arr::get($otherInfo, 'signup_fee', 0);
1434 $manageSetupFee = Arr::get($otherInfo, 'manage_setup_fee', 'no');
1435
1436 if ($manageSetupFee !== 'yes' || !$fee) {
1437 return '';
1438 }
1439
1440
1441 if ($originalSetupFee = Arr::get($otherInfo, 'original_signup_fee', 0)) {
1442 if ($fee != $originalSetupFee) {
1443 $title = __('Adjusted setup fee', 'fluent-cart');
1444 $formattedAmount = CurrencySettings::getPriceHtml($fee, null, true, true);
1445
1446 if ($asArray) {
1447 return [
1448 'signup_fee_name' => $title,
1449 'signup_fee' => $fee,
1450 'signup_fee_formatted' => $formattedAmount,
1451 ];
1452 }
1453 return $title . $formattedAmount;
1454 }
1455 }
1456
1457 $formattedAmount = CurrencySettings::getPriceHtml($fee, null, true, true);
1458 if ($asArray) {
1459 return [
1460 'signup_fee_name' => $signupFeeName,
1461 'signup_fee' => $fee,
1462 'signup_fee_formatted' => $formattedAmount,
1463 ];
1464 }
1465
1466
1467 return $signupFeeName . ' ' . $formattedAmount;
1468 }
1469
1470 public static function generateTrialInfo($otherInfo)
1471 {
1472 $trialInfo = '';
1473
1474 $trialDays = Arr::get($otherInfo, 'trial_days', 0);
1475
1476 if ($trialDays && Arr::get($otherInfo, 'is_trial_days_simulated', 'no') !== 'yes') {
1477 $trialInfo = sprintf(
1478 /* translators: %d is the number of trial days */
1479 __('Free Trial: %d days', 'fluent-cart'),
1480 $trialDays
1481 );
1482 }
1483
1484 return apply_filters('fluent_cart/trial_info', $trialInfo, $otherInfo);
1485 }
1486
1487
1488 public static function getCountryList(): array
1489 {
1490 $options = App::getInstance('localization')->countriesOptions();
1491
1492 return apply_filters('fluent_cart/util/countries', $options, []);
1493 }
1494
1495 public static function getCountyIsoLists(): array
1496 {
1497 return App::getInstance('localization')->getCountyIsoLists();
1498 }
1499
1500 public static function getCountryCode($country_name)
1501 {
1502 $countries = self::getCountryList();
1503 foreach ($countries as $country) {
1504 if ($country['name'] === $country_name) {
1505 return $country['value'];
1506 }
1507 }
1508 return '';
1509 }
1510
1511 /**
1512 * Get the country's name with country code,
1513 *
1514 * @param $code
1515 * @return string
1516 */
1517 public static function getCountryName($code): string
1518 {
1519 if (!$code || !is_string($code)) {
1520 return '';
1521 }
1522
1523 $countries = self::getCountyIsoLists();
1524
1525 return $countries[$code] ?? $code;
1526 }
1527
1528 public static function languageCodes(): array
1529 {
1530 return $langCodes = [
1531 'AF' => 'fa-AF',
1532 'AL' => 'sq-AL',
1533 'DZ' => 'ar-DZ',
1534 'AS' => 'sm-AS',
1535 'AD' => 'ca-AD',
1536 'AO' => 'pt-AO',
1537 'AR' => 'es-AR',
1538 'AM' => 'hy-AM',
1539 'AU' => 'en-AU',
1540 'AT' => 'de-AT',
1541 'AZ' => 'az-AZ',
1542 'BH' => 'ar-BH',
1543 'BD' => 'bn-BD',
1544 'BY' => 'be-BY',
1545 'BE' => 'nl-BE',
1546 'BZ' => 'en-BZ',
1547 'BJ' => 'fr-BJ',
1548 'BT' => 'dz-BT',
1549 'BO' => 'es-BO',
1550 'BA' => 'bs-BA',
1551 'BW' => 'en-BW',
1552 'BR' => 'pt-BR',
1553 'BN' => 'ms-BN',
1554 'BG' => 'bg-BG',
1555 'BF' => 'fr-BF',
1556 'BI' => 'fr-BI',
1557 'KH' => 'km-KH',
1558 'CM' => 'en-CM',
1559 'CA' => 'en-CA',
1560 'CV' => 'pt-CV',
1561 'CF' => 'fr-CF',
1562 'TD' => 'fr-TD',
1563 'CL' => 'es-CL',
1564 'CN' => 'zh-CN',
1565 'CO' => 'es-CO',
1566 'KM' => 'ar-KM',
1567 'CD' => 'fr-CD',
1568 'CG' => 'fr-CG',
1569 'CR' => 'es-CR',
1570 'CI' => 'fr-CI',
1571 'HR' => 'hr-HR',
1572 'CU' => 'es-CU',
1573 'CY' => 'el-CY',
1574 'CZ' => 'cs-CZ',
1575 'DK' => 'da-DK',
1576 'DJ' => 'fr-DJ',
1577 'DM' => 'en-DM',
1578 'DO' => 'es-DO',
1579 'EC' => 'es-EC',
1580 'EG' => 'ar-EG',
1581 'SV' => 'es-SV',
1582 'GQ' => 'es-GQ',
1583 'ER' => 'ti-ER',
1584 'EE' => 'et-EE',
1585 'ET' => 'am-ET',
1586 'FJ' => 'en-FJ',
1587 'FI' => 'fi-FI',
1588 'FR' => 'fr-FR',
1589 'GA' => 'fr-GA',
1590 'GM' => 'en-GM',
1591 'GE' => 'ka-GE',
1592 'DE' => 'de-DE',
1593 'GH' => 'en-GH',
1594 'GR' => 'el-GR',
1595 'GD' => 'en-GD',
1596 'GT' => 'es-GT',
1597 'GN' => 'fr-GN',
1598 'GW' => 'pt-GW',
1599 'GY' => 'en-GY',
1600 'HT' => 'fr-HT',
1601 'HN' => 'es-HN',
1602 'HU' => 'hu-HU',
1603 'IS' => 'is-IS',
1604 'IN' => 'hi-IN',
1605 'ID' => 'id-ID',
1606 'IR' => 'fa-IR',
1607 'IQ' => 'ar-IQ',
1608 'IE' => 'en-IE',
1609 'IL' => 'he-IL',
1610 'IT' => 'it-IT',
1611 'JM' => 'en-JM',
1612 'JP' => 'ja-JP',
1613 'JO' => 'ar-JO',
1614 'KZ' => 'kk-KZ',
1615 'KE' => 'sw-KE',
1616 'KI' => 'en-KI',
1617 'KR' => 'ko-KR',
1618 'KW' => 'ar-KW',
1619 'KG' => 'ky-KG',
1620 'LA' => 'lo-LA',
1621 'LV' => 'lv-LV',
1622 'LB' => 'ar-LB',
1623 'LS' => 'en-LS',
1624 'LR' => 'en-LR',
1625 'LY' => 'ar-LY',
1626 'LI' => 'de-LI',
1627 'LT' => 'lt-LT',
1628 'LU' => 'lb-LU',
1629 'MG' => 'mg-MG',
1630 'MW' => 'en-MW',
1631 'MY' => 'ms-MY',
1632 'MV' => 'dv-MV',
1633 'ML' => 'fr-ML',
1634 'MT' => 'mt-MT',
1635 'MH' => 'mh-MH',
1636 'MR' => 'ar-MR',
1637 'MU' => 'mfe-MU',
1638 'MX' => 'es-MX',
1639 'FM' => 'en-FM',
1640 'MD' => 'ro-MD',
1641 'MC' => 'fr-MC',
1642 'MN' => 'mn-MN',
1643 'ME' => 'sr-ME',
1644 'MA' => 'ar-MA',
1645 'MZ' => 'pt-MZ',
1646 'NA' => 'en-NA',
1647 'NR' => 'en-NR',
1648 'NP' => 'ne-NP',
1649 'NL' => 'nl-NL',
1650 'NZ' => 'en-NZ',
1651 'NI' => 'es-NI',
1652 'NG' => 'en-NG',
1653 'NO' => 'no-NO',
1654 'OM' => 'ar-OM',
1655 'PK' => 'ur-PK',
1656 'PW' => 'en-PW',
1657 'PA' => 'es-PA',
1658 'PG' => 'en-PG',
1659 'PY' => 'es-PY',
1660 'PE' => 'es-PE',
1661 'PH' => 'en-PH',
1662 'PL' => 'pl-PL',
1663 'PT' => 'pt-PT',
1664 'QA' => 'ar-QA',
1665 'RO' => 'ro-RO',
1666 'RU' => 'ru-RU',
1667 'RW' => 'rw-RW',
1668 'WS' => 'sm-WS',
1669 'SM' => 'it-SM',
1670 'SA' => 'ar-SA',
1671 'SN' => 'fr-SN',
1672 'RS' => 'sr-RS',
1673 'SC' => 'fr-SC',
1674 'SL' => 'en-SL',
1675 'SG' => 'en-SG',
1676 'SK' => 'sk-SK',
1677 'SI' => 'sl-SI',
1678 'SB' => 'en-SB',
1679 'SO' => 'so-SO',
1680 'ZA' => 'en-ZA',
1681 'ES' => 'es-ES',
1682 'LK' => 'si-LK',
1683 'SD' => 'ar-SD',
1684 'SR' => 'nl-SR',
1685 'SZ' => 'en-SZ',
1686 'SE' => 'sv-SE',
1687 'CH' => 'de-CH',
1688 'SY' => 'ar-SY',
1689 'TW' => 'zh-TW',
1690 'TJ' => 'tg-TJ',
1691 'TZ' => 'sw-TZ',
1692 'TH' => 'th-TH',
1693 'TL' => 'pt-TL',
1694 'TG' => 'fr-TG',
1695 'TO' => 'to-TO',
1696 'TT' => 'en-TT',
1697 'TN' => 'ar-TN',
1698 'TR' => 'tr-TR',
1699 'TM' => 'tk-TM',
1700 'TV' => 'en-TV',
1701 'UG' => 'en-UG',
1702 'UA' => 'uk-UA',
1703 'AE' => 'ar-AE',
1704 'GB' => 'en-GB',
1705 'US' => 'en-US',
1706 'UY' => 'es-UY',
1707 'UZ' => 'uz-UZ',
1708 'VU' => 'bi-VU',
1709 'VE' => 'es-VE',
1710 'VN' => 'vi-VN',
1711 'YE' => 'ar-YE',
1712 'ZM' => 'en-ZM',
1713 'ZW' => 'en-ZW'
1714 ];
1715 }
1716
1717 /**
1718 * Returns a translatable string with a shortcode inserted in the correct format.
1719 *
1720 * @param string $shortcode The shortcode to be inserted (e.g., '[fluent_cart_receipt]').
1721 * @return string The formatted translatable string.
1722 */
1723 public static function getShortcodeInstructionString(string $shortcode, $pageName = ''): string
1724 {
1725 $copyToClipboard = __('Copy to clipboard', 'fluent-cart');
1726 return sprintf(
1727 /* translators: %s: Shortcode */
1728 '<p>' . _x('Use %1$s shortcode in your page.', 'Shortcode instruction message', 'fluent-cart') . '</p>',
1729 '<code class="copyable-content" title="' . $copyToClipboard . '">' . ($shortcode) . '</code>',
1730 //$pageName
1731 );
1732 }
1733
1734
1735 /**
1736 * Get the current user Model.
1737 * @return User|\FluentCart\Framework\Database\Orm\Builder|\FluentCart\Framework\Database\Orm\Builder[]|\FluentCart\Framework\Database\Orm\Collection|\FluentCart\Framework\Database\Orm\Model|null
1738 */
1739 public static function getCurrentUser($refresh = false)
1740 {
1741 static $user = false;
1742
1743 if (!$refresh && $user !== false) {
1744 return $user;
1745 }
1746
1747 $userId = get_current_user_id();
1748 if (!$userId) {
1749 $user = null;
1750 return $user;
1751 }
1752
1753 $user = User::query()->find($userId);
1754
1755 return $user;
1756
1757 }
1758
1759 public static function hasLicense($product): bool
1760 {
1761 if (empty($product)) {
1762 return false;
1763 }
1764
1765 $meta = Arr::get($product, 'licensesMeta.meta_value', []);
1766
1767 if (empty($meta)) {
1768 return false;
1769 }
1770
1771 $meta = is_string($meta) ? json_decode($meta, true) : $meta;
1772
1773 return Arr::get($meta, 'enabled') === 'yes';
1774
1775 }
1776
1777
1778 public static function generateDownloadFileLink($productDownload, $orderId = null, $validityInMinutes = 60, $isAdmin = false): string
1779 {
1780 $identifier = Arr::get($productDownload, 'download_identifier', '');
1781
1782 $validityInMinutes = apply_filters('fluent_cart/download_link_validity_in_minutes', $validityInMinutes, [
1783 'product_download' => $productDownload,
1784 'order_id' => $orderId,
1785 'is_admin' => $isAdmin,
1786 ]);
1787
1788 $signParams = [
1789 'download_identifier' => $identifier,
1790 'valid_till' => DateTime::now()
1791 ->addMinutes($validityInMinutes ?? 60)
1792 ->getTimestamp()
1793 ];
1794
1795 if ($orderId) {
1796 $orderId = Arr::wrap($orderId);
1797 $signParams['order_id'] = json_encode($orderId);
1798 }
1799
1800 $url = (new BaseUrl())->sign(site_url('/'), $signParams);
1801
1802 return URL::appendQueryParams($url, [
1803 'fluent-cart' => 'download-by-id',
1804 ]);
1805
1806 }
1807
1808
1809 public static function readableFileSize($bytes): string
1810 {
1811 // Converts bytes to a human-readable format (e.g., KB, MB, GB)
1812 // Example: 1024 -> "1 KB"
1813 // Example: 1048576 -> "1 MB"
1814
1815 if (!$bytes && $bytes !== 0) return '';
1816 $units = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
1817 $i = floor(log($bytes, 2) / 10);
1818 $size = $bytes / pow(1024, $i);
1819 return round($size, 2) . ' ' . $units[$i];
1820 }
1821
1822
1823 public static function getSitePrefix()
1824 {
1825 $siteUrl = rtrim(home_url(), '/');
1826 // remove http:// or https:// from the URL
1827 $siteUrl = preg_replace('#^https?://#', '', $siteUrl);
1828 $sitePrefix = str_replace(['/', '.'], '_', $siteUrl);
1829
1830 return apply_filters('fluent_cart/site_prefix', $sitePrefix, []);
1831 }
1832
1833 public static function humanIntervalMaps($interval = '')
1834 {
1835 $intervals = [
1836 'daily' => __('day', 'fluent-cart'),
1837 'weekly' => __('week', 'fluent-cart'),
1838 'monthly' => __('month', 'fluent-cart'),
1839 'quarterly' => __('quarter', 'fluent-cart'),
1840 'half_yearly' => __('six month', 'fluent-cart'),
1841 'yearly' => __('year', 'fluent-cart'),
1842 ];
1843
1844 return Arr::get($intervals, $interval);
1845 }
1846
1847 /**
1848 * @return array Array of intervals with label and value
1849 */
1850 public static function getAvailableSubscriptionIntervalOptions(): array
1851 {
1852 $intervals = [
1853 [
1854 'label' => __('Yearly', 'fluent-cart'),
1855 'value' => 'yearly',
1856 'map_value' => 'year',
1857 ],
1858 [
1859 'label' => __('Half Yearly', 'fluent-cart'),
1860 'value' => 'half_yearly',
1861 'map_value' => 'half_year',
1862 ],
1863 [
1864 'label' => __('Quarterly', 'fluent-cart'),
1865 'value' => 'quarterly',
1866 'map_value' => 'quarter',
1867 ],
1868 [
1869 'label' => __('Monthly', 'fluent-cart'),
1870 'value' => 'monthly',
1871 'map_value' => 'month',
1872 ],
1873 [
1874 'label' => __('Weekly', 'fluent-cart'),
1875 'value' => 'weekly',
1876 'map_value' => 'week',
1877 ],
1878 [
1879 'label' => __('Daily', 'fluent-cart'),
1880 'value' => 'daily',
1881 'map_value' => 'day',
1882 ]
1883 ];
1884
1885 return apply_filters('fluent_cart/available_subscription_interval_options', $intervals);
1886 }
1887
1888 public static function translateIntervalToStandardFormat($repeatInterval)
1889 {
1890 if (empty($repeatInterval)) {
1891 return 'year';
1892 }
1893
1894 $intervalMaps = static::getAvailableSubscriptionIntervalMaps();
1895
1896 if (!isset($intervalMaps[$repeatInterval])) {
1897 return 'year';
1898 }
1899
1900 return $intervalMaps[$repeatInterval];
1901 }
1902
1903 public static function getAvailableSubscriptionIntervalMaps()
1904 {
1905 $intervalOptions = static::getAvailableSubscriptionIntervalOptions();
1906
1907 $intervalMaps = [];
1908 foreach ($intervalOptions as $option) {
1909 $intervalMaps[$option['value']] = $option['map_value'];
1910 }
1911
1912 return $intervalMaps;
1913
1914 }
1915
1916 public static function calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval)
1917 {
1918 $intervalInDays = static::subscriptionIntervalInDays($repeatInterval);
1919
1920 $maxTrialDaysAllowed = apply_filters('fluent_cart/max_trial_days_allowed', 365, [
1921 'existing_trial_days' => $trialDays,
1922 'repeat_interval' => $repeatInterval,
1923 'interval_in_days' => $intervalInDays,
1924 ]);
1925
1926 return min($trialDays + $intervalInDays, $maxTrialDaysAllowed); // return the minimum of the sum of the existing trial days and the interval days, and the max trial allowed
1927
1928 }
1929
1930 public static function subscriptionIntervalInDays($interval)
1931 {
1932 return PaymentHelper::getIntervalDays($interval);
1933 }
1934
1935 public static function parseTermIdsForFilter($filters): array
1936 {
1937 $taxonomies = Taxonomy::getTaxonomies();
1938 if (is_string($filters)) {
1939 $filters = json_decode($filters, true);
1940 }
1941 $formattedFilters = [];
1942
1943 foreach ($taxonomies as $key => $taxonomy) {
1944 $terms = Arr::get($filters, $key, []);
1945 if (!is_array($terms)) {
1946 $terms = [$terms];
1947 }
1948
1949 $terms = array_filter($terms, function ($term) {
1950 return !empty($term);
1951 });
1952
1953 if (!empty($terms)) {
1954 $terms = array_map(function ($term) {
1955 return sanitize_text_field((string)$term);
1956 }, $terms);
1957
1958
1959 $formattedFilters[$key] = $terms;
1960 }
1961
1962
1963 }
1964
1965 return $formattedFilters;
1966 }
1967
1968 public static function mergeTermIdsForFilter($array1 = [], $array2 = []): array
1969 {
1970 $result = [];
1971
1972 foreach ([$array1, $array2] as $array) {
1973 foreach ($array as $key => $values) {
1974 if (!isset($result[$key])) {
1975 $result[$key] = [];
1976 }
1977 $result[$key] = array_values(array_unique(array_merge($result[$key], $values)));
1978 }
1979 }
1980
1981 return $result;
1982 }
1983
1984 public static function loadBundleChild(array $variants, $select = ['id', 'variation_title']): array
1985 {
1986 $allChildVariants = Arr::pluck($variants, 'other_info.bundle_child_ids');
1987
1988 $allChildVariants = array_unique(Arr::flatten($allChildVariants));
1989 $allChildVariants = array_values(array_diff(
1990 $allChildVariants,
1991 Arr::pluck($variants, 'id')
1992 ));
1993 $allChildVariants = array_filter($allChildVariants);
1994 $childVariants = ProductVariation::query()
1995 ->whereIn('id', $allChildVariants)
1996 ->with('product:ID,post_title')
1997 ->select($select)
1998 ->get()
1999 ->toArray();
2000
2001 // Extract post_title from product and remove product object to keep data clean
2002 foreach ($childVariants as $key => $childVariant) {
2003 $postTitle = Arr::get($childVariant, 'product.post_title');
2004 if ($postTitle) {
2005 $childVariants[$key]['post_title'] = $postTitle;
2006 unset($childVariants[$key]['product']);
2007 }
2008 }
2009
2010 foreach ($variants as &$variant) {
2011 $childIds = Arr::get($variant, 'other_info.bundle_child_ids', []);
2012 $variant['bundle_child_ids'] = $childIds;
2013 if (count($childIds) < 1) {
2014 $variant['child_variants'] = [];
2015 continue;
2016 }
2017 foreach ($childVariants as $childVariant) {
2018 if (in_array($childVariant['id'], Arr::get($variant, 'other_info.bundle_child_ids', []))) {
2019 $variant['child_variants'][$childVariant['id']] = $childVariant;
2020 }
2021 }
2022 }
2023
2024 return $variants;
2025 }
2026
2027 /**
2028 * Translate digits in a number according to the configured numeric system.
2029 *
2030 * Converts the digits 0-9 in the given number to their equivalents
2031 * defined in the numeric system string from `dateTimeStrings()`.
2032 * This allows displaying numbers in other numeral systems, e.g., Bengali, Arabic, Hindi, etc.
2033 *
2034 * Only digits are translated; other characters such as decimal points, currency symbols, or text remain unchanged.
2035 *
2036 * @param int|float|string $number The number to translate.
2037 * @return string The number with digits translated according to the configured numeric system.
2038 */
2039 public static function translateNumber($number): string
2040 {
2041 $config = TransStrings::dateTimeStrings();
2042 $numericSystem = Arr::get($config, 'numericSystem', '0_1_2_3_4_5_6_7_8_9');
2043 $digits = explode('_', $numericSystem);
2044
2045 return strtr(
2046 (string)$number,
2047 array_combine(range(0, 9),
2048 $digits)
2049 );
2050 }
2051
2052 public static function isModalCheckoutEnabled(): bool
2053 {
2054 //$storeSettings = new StoreSettings();
2055 //$enableModalCheckout = $storeSettings->get('enable_modal_checkout', 'no');
2056 return apply_filters('fluent_cart/enable_modal_checkout', false);
2057 }
2058
2059 public static function isAdminUser(): bool
2060 {
2061 return current_user_can('manage_options');
2062 }
2063
2064 /**
2065 * Convert string/boolean to actual boolean value.
2066 * Handles shortcode string attributes like "true"/"false"
2067 *
2068 * @param mixed $value The value to convert to boolean
2069 * @param bool $default Default value if conversion fails
2070 * @return bool The boolean result
2071 */
2072 public static function toBool($value, bool $default = false): bool
2073 {
2074 if (is_bool($value)) {
2075 return $value;
2076 }
2077
2078 if (is_string($value)) {
2079 $value = strtolower(trim($value));
2080 if (in_array($value, ['true', '1', 'yes', 'on'], true)) {
2081 return true;
2082 }
2083 if (in_array($value, ['false', '0', 'no', 'off'], true)) {
2084 return false;
2085 }
2086 }
2087
2088 return $default;
2089 }
2090
2091 public static function formatTaxRatePercent(float $rate): string
2092 {
2093 $formatted = number_format($rate, 4, '.', '');
2094 if (strpos($formatted, '.') !== false) {
2095 $formatted = rtrim($formatted, '0');
2096 $formatted = rtrim($formatted, '.');
2097 }
2098 return $formatted;
2099 }
2100
2101 /**
2102 * Returns the tax label for order-level tax rows (tax_total, not per-item).
2103 * For mixed orders, indicates tax varies per item.
2104 *
2105 * @param \FluentCart\App\Models\Order $order
2106 * @return string
2107 */
2108 public static function getOrderTaxLabel($order) {
2109 if ((int) $order->tax_behavior === 3) {
2110 return esc_html__('(Varies)', 'fluent-cart');
2111 }
2112 return (int) $order->tax_behavior === 2
2113 ? esc_html__('(Included)', 'fluent-cart')
2114 : esc_html__('(Excluded)', 'fluent-cart');
2115 }
2116 }
2117