PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.2
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.2
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.2, at app/Helpers/Helper.php

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