PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 1.2.0 All 47 releases
fluent-cart / app / Helpers / Helper.php

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

2,113 lines 69.1 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 return Customer::query()->where('user_id', $user->ID)
614 ->orWhere('email', $user->user_email)
615 ->first();
616 }
617
618 /**
619 * @param array $order_data
620 *
621 * @return array return ['billing_address','shipping_address','others'];
622 */
623
624 /**
625 *
626 * @return string
627 */
628 public static function getProductImageBaseUri(): string
629 {
630 $uploads = wp_upload_dir();
631
632 return $uploads['baseurl'] . '/' . FLUENTCART_UPLOAD_DIR . '/product_image/';
633 }
634
635 public static function getProductImageBaseDir()
636 {
637 $uploads = wp_upload_dir();
638
639 return $uploads['basedir'] . '/' . FLUENTCART_UPLOAD_DIR . '/product_image/';
640 }
641
642 public static function getAvailableCurrencyList()
643 {
644 $currencies = [
645 'BDT' => [
646 "label" => __('Bangladeshi Taka', 'fluent-cart'),
647 "value" => 'BDT',
648 "symbol" => '',
649 ],
650 'USD' => [
651 "label" => __('United State Dollar', 'fluent-cart'),
652 "value" => 'USD',
653 "symbol" => '$',
654 ],
655 'GBP' => [
656 "label" => __('United Kingdom', 'fluent-cart'),
657 "value" => 'GBP',
658 "symbol" => '£',
659 ],
660 ];
661
662 return apply_filters('fluent_cart/available_currencies', $currencies, []);
663 }
664
665 public static function getSymbolForCurrency($currency = 'BDT')
666 {
667
668 $symbol = '';
669 $list = self::getAvailableCurrencyList();
670
671 return $list[$currency]['symbol'] ?? $symbol;
672 }
673
674 public function getConfirmationSettings()
675 {
676 return (new Confirmation())->get();
677 }
678
679
680 /**
681 *
682 * @param $remove
683 * @return string
684 */
685 public static function getCheckoutPageLinkAfterRemovingGetParams($remove = [])
686 {
687
688 global $fct_store;
689
690 $link = Arr::get($fct_store, 'checkout_link');
691
692 $link .= '?';
693
694 if (!empty($remove)) {
695 $params = App::request()->all();
696
697 foreach ($params as $key => $val) {
698
699 if (!in_array($key, $remove)) {
700
701 $link .= $key . '=' . $val . '&';
702 }
703 }
704 }
705
706 return rtrim($link, '&');
707 }
708
709
710 /**
711 *
712 * @return bool
713 */
714 public static function isSingleProductPage(): bool
715 {
716 return is_singular([FluentProducts::CPT_NAME]);
717 }
718
719
720 public static function isTrue($array, $key)
721 {
722 $value = $array[$key] ?? false;
723 if (is_bool($value)) {
724 return $value;
725 }
726 if ($value === 'false' || !$value) {
727 return false;
728 }
729 return true;
730 }
731
732 public static function is_valid_json($string): bool
733 {
734 if (!is_string($string)) {
735 return false;
736 }
737
738 $trimmed = trim($string);
739
740 // Basic check: must start with { or [ and end with } or ]
741 if (!preg_match('/^(\{.*\}|\[.*\])$/s', $trimmed)) {
742 return false;
743 }
744
745 json_decode($trimmed);
746 return json_last_error() === JSON_ERROR_NONE;
747 }
748
749 public static function getStockStatuses($withLabel = true)
750 {
751 $statues = [
752 'in-stock' => __('In Stock', 'fluent-cart'),
753 'out-of-stock' => __('Out Of Stock', 'fluent-cart'),
754 ];
755
756 if ($withLabel) {
757 return $statues;
758 }
759
760 return array_keys($statues);
761 }
762
763 public static function getFulfilmentTypes($withLabel = true)
764 {
765 $statues = [
766 'physical' => __('Physical', 'fluent-cart'),
767 'digital' => __('Digital', 'fluent-cart'),
768 ];
769
770 if ($withLabel) {
771 return $statues;
772 }
773
774 return array_keys($statues);
775 }
776
777 public static function getVariationTypes($withLabel = true)
778 {
779 // advanced_variations is advertised in free too so the variation-type
780 // dropdown can offer it (shown Pro-locked with a crown and an upgrade
781 // modal while Pro is inactive). Pro re-registers the same key via the
782 // filter below when active.
783 $types = [
784 'simple' => __('Simple', 'fluent-cart'),
785 'simple_variations' => __('Simple Variations', 'fluent-cart'),
786 'advanced_variations' => __('Advanced Variations', 'fluent-cart'),
787 ];
788
789 $types = apply_filters('fluent_cart/variation_types', $types);
790
791 if ($withLabel) {
792 return $types;
793 }
794
795 return array_keys($types);
796 }
797
798 public static function isValueEncrypted($raw_value)
799 {
800 if (!$raw_value || !is_string($raw_value) || !extension_loaded('openssl')) {
801 return false;
802 }
803
804 // Check if input is valid base64
805 if (!preg_match('/^[a-zA-Z0-9\/\r\n+]*={0,2}$/', $raw_value)) {
806 return false;
807 }
808
809 $decoded = base64_decode($raw_value, true);
810 if ($decoded === false) {
811 return false;
812 }
813
814 // Check if decoded string is long enough for IV
815 $method = 'aes-256-ctr';
816 $ivlen = openssl_cipher_iv_length($method);
817 if (strlen($decoded) < $ivlen) {
818 return false;
819 }
820
821 $iv = substr($decoded, 0, $ivlen);
822 $ciphertext = substr($decoded, $ivlen);
823
824 $key = (defined('FLUENT_CART_ENCRYPTION_KEY'))
825 ? FLUENT_CART_ENCRYPTION_KEY
826 : ((defined('LOGGED_IN_KEY') && '' !== LOGGED_IN_KEY)
827 ? LOGGED_IN_KEY
828 : 'this-is-a-fallback-key-but-not-secure');
829 $salt = (defined('LOGGED_IN_SALT') && '' !== LOGGED_IN_SALT)
830 ? LOGGED_IN_SALT
831 : 'this-is-a-fallback-salt-but-not-secure';
832
833 $value = openssl_decrypt($ciphertext, $method, $key, 0, $iv);
834 if ($value === false) {
835 return false;
836 }
837
838
839 return substr($value, -strlen($salt)) === $salt;
840 }
841
842 public static function encryptKey($value)
843 {
844 if (!$value) {
845 return $value;
846 }
847
848 if (!extension_loaded('openssl')) {
849 return $value;
850 }
851
852 if (self::isValueEncrypted($value)) {
853 return $value;
854 }
855
856 $salt = (defined('LOGGED_IN_SALT') && '' !== LOGGED_IN_SALT) ? LOGGED_IN_SALT : 'this-is-a-fallback-salt-but-not-secure';
857
858 if (defined('FLUENT_CART_ENCRYPTION_KEY')) {
859 $key = FLUENT_CART_ENCRYPTION_KEY;
860 } else {
861 $key = (defined('LOGGED_IN_KEY') && '' !== LOGGED_IN_KEY) ? LOGGED_IN_KEY : 'this-is-a-fallback-key-but-not-secure';
862 }
863
864 $method = 'aes-256-ctr';
865 $ivlen = openssl_cipher_iv_length($method);
866 $iv = openssl_random_pseudo_bytes($ivlen);
867
868 $raw_value = openssl_encrypt($value . $salt, $method, $key, 0, $iv);
869 if (!$raw_value) {
870 return false;
871 }
872
873 return base64_encode($iv . $raw_value); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
874 }
875
876 public static function decryptKey($raw_value)
877 {
878
879 if (!$raw_value) {
880 return $raw_value;
881 }
882
883 if (!extension_loaded('openssl')) {
884 return $raw_value;
885 }
886
887 $decoded = base64_decode($raw_value, true); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
888
889 if ($decoded === false) {
890 return $raw_value;
891 }
892
893 $method = 'aes-256-ctr';
894 $ivlen = openssl_cipher_iv_length($method);
895
896 if (strlen($decoded) <= $ivlen) {
897 return $raw_value;
898 }
899
900 $iv = substr($decoded, 0, $ivlen);
901
902 $ciphertext = substr($decoded, $ivlen);
903
904 if (defined('FLUENT_CART_ENCRYPTION_KEY')) {
905 $key = FLUENT_CART_ENCRYPTION_KEY;
906 } else {
907 $key = (defined('LOGGED_IN_KEY') && '' !== LOGGED_IN_KEY) ? LOGGED_IN_KEY : 'this-is-a-fallback-key-but-not-secure';
908 }
909
910 $salt = (defined('LOGGED_IN_SALT') && '' !== LOGGED_IN_SALT) ? LOGGED_IN_SALT : 'this-is-a-fallback-salt-but-not-secure';
911
912 $decrypted = openssl_decrypt($ciphertext, $method, $key, 0, $iv);
913 if (!$decrypted || substr($decrypted, -strlen($salt)) !== $salt) {
914 return false;
915 }
916
917 return substr($decrypted, 0, -strlen($salt));
918 }
919
920 /**
921 * @return array
922 * For kses_post to allow some html tags
923 */
924 public static function allowedHTMLForCheckout(): array
925 {
926
927 $allowedTags = [
928 'svg' => [
929 'class' => true,
930 'aria-hidden' => true,
931 'aria-labelledby' => true,
932 'role' => true,
933 'xmlns' => true,
934 'width' => true,
935 'height' => true,
936 'viewbox' => true,
937 'fill' => true
938 ],
939 'g' => [
940 'fill' => true
941 ],
942 'title' => ['title' => true],
943 'path' => [
944 'd' => true,
945 'fill' => true,
946 'stroke' => true,
947 'fill-rule' => true,
948 'clip-rule' => true,
949 ],
950 ];
951
952 foreach (['input', 'label', 'div', 'span', 'p', 'select', 'option', 'textarea', 'button', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as $tag) {
953 $allowedTags[$tag] = [
954 'selected',
955 'type' => [],
956 "selected='selected'" => [],
957 'name' => [],
958 'value' => [],
959 'autocomplete' => [],
960 'placeholder' => [],
961 'data-required' => [],
962 'data-type' => [],
963 'id' => [],
964 'class' => [],
965 'required' => [],
966 'disabled' => [],
967 'for' => [],
968 'style' => [],
969 'checked' => [],
970 'maxlength' => [],
971 'data-id' => [],
972 'data-country' => [],
973 'data-fluent-cart-checkout-page-form-address-modal-open-button' => [],
974 'data-fluent-cart-checkout-page-form-address-modal-wrapper' => [],
975 'data-fluent-cart-checkout-page-form-input-wrapper' => [],
976 'data-fluent_cart_checkout_error' => [],
977 'data-fluent-cart-checkout-page-form-address-modal-body' => [],
978 'data-fluent-cart-checkout-page-form-address-modal-address-selector-button' => [],
979 'data-fluent-cart-checkout-page-form-address-input' => [],
980 'data-fluent-cart-checkout-page-form-address-select-wrapper' => [],
981 'data-fluent-cart-checkout-page-form-address-modal-close-button' => [],
982 'data-fluent-cart-checkout-page-form-address-modal-address-selector-button-wrapper' => [],
983 'data-fluent-cart-checkout-page-form-address-show-add-new-modal-button' => [],
984 'data-fluent-cart-checkout-page-form-address-modal-apply-button' => [],
985 'data-fluent-cart-checkout-page-form-address-show-add-new-modal-form-wrapper' => [],
986 'data-fluent-cart-checkout-page-form-address-show-add-new-modal-submit-button' => [],
987 'data-fluent-cart-checkout-page-form-address-show-add-new-modal-cancel-button' => [],
988 'data-fluent-cart-address-type' => [],
989 'data-fluent-cart-checkout-page-form-address-info-wrapper' => [],
990 'data-fluent-cart-checkout-page-form-error' => [],
991 'data-fluent-cart-checkout-page-form-section' => [],
992 'data-fluent-cart-checkout-page-discount-container' => [],
993 'data-fluent-cart-checkout-page-final-amount-container' => [],
994 'data-fluent-cart-checkout-page-new-total-amount' => [],
995 'data-fluent-cart-checkout-page-final-amount' => [],
996 'data-fluent-cart-checkout-page-coupon-validate' => [],
997 'data-fluent-cart-checkout-coupon-items-toggle' => [],
998 'data-fluent-cart-checkout-coupon-items-wrapper' => []
999
1000
1001 ];
1002 }
1003
1004 return $allowedTags;
1005 }
1006
1007 public static function getCouponStatuses()
1008 {
1009 $statuses = [
1010 'active' => __('Active', 'fluent-cart'),
1011 'expired' => __('Expired', 'fluent-cart'),
1012 'disabled' => __('Disabled', 'fluent-cart'),
1013 ];
1014
1015 return apply_filters('fluent_cart/coupon_statuses', $statuses, []);
1016 }
1017
1018 public static function getCouponSuccessStatuses()
1019 {
1020 return [
1021 'active'
1022 ];
1023 }
1024
1025
1026 /**
1027 * Compact subscription terms as text
1028 *
1029 * Expected $data keys:
1030 * - trial_days (int)
1031 * - interval ('daily'|'weekly'|'monthly'|'yearly')
1032 * - interval_count (int, default 1)
1033 * - times (int; 0 = open-ended, >0 = finite number of payments/cycles)
1034 * - price (string; formatted with currency)
1035 * - signup_fee (string; 0 if none)
1036 * - compare_price (string; 0 if none)
1037 *
1038 * Output examples:
1039 * - "30 days free then $100.00 per year"
1040 * - "$100 per year + $10 one-time signup fee"
1041 * - "$100/month for 4 months"
1042 * - "30 days free then $100/month for 4 months"
1043 * - "$99 per month"
1044 */
1045 public static function getSubscriptionTermText(array $data, $asHtml = false): string
1046 {
1047 // Fixed interval options
1048 $intervalOptions = static::getAvailableSubscriptionIntervalMaps();
1049
1050 // Normalize / defaults
1051 $trialDays = $data['trial_days'] ?? 0;
1052 $interval = (string)($data['interval'] ? $data['interval'] : 'monthly');
1053
1054 $unit = '';
1055 if (isset($intervalOptions[$interval])) {
1056 $unit = $intervalOptions[$interval];
1057 } else if ($interval) {
1058 $intervalOptions = static::getAvailableSubscriptionIntervalOptions();
1059 foreach ($intervalOptions as $option) {
1060 if ($option['value'] === $interval) {
1061 $unit = strtolower($option['label']);
1062 break;
1063 }
1064 }
1065
1066 if (!$unit) {
1067 $unit = strtolower(str_replace(['_', '-'], ' ', $interval));
1068 }
1069 }
1070
1071 if (!$unit) {
1072 $unit = 'year';
1073 }
1074
1075 $count = max(1, (int)($data['interval_count'] ?? 1));
1076 $times = (int)($data['times'] ?? 0);
1077 $price = (string)($data['price'] ?? '');
1078 $signupFee = $data['signup_fee'] ?? '';
1079 $compare = $data['compare_price'] ?? '';
1080
1081 $signupFeeLabel = $data['signup_fee_label'] ?? __('signup fee', 'fluent-cart');
1082
1083 // helpers
1084 $pluralUnit = static function (string $unit, int $n): string {
1085 switch ($unit) {
1086 case 'day':
1087 return _n('day', 'days', $n, 'fluent-cart');
1088 case 'week':
1089 return _n('week', 'weeks', $n, 'fluent-cart');
1090 case 'quarter':
1091 return _n('quarter', 'quarters', $n, 'fluent-cart');
1092 case 'half_year':
1093 return _n('six month', 'six months', $n, 'fluent-cart');
1094 case 'year':
1095 return _n('year', 'years', $n, 'fluent-cart');
1096 case 'month':
1097 return _n('month', 'months', $n, 'fluent-cart');
1098 default:
1099 // For custom intervals, add 's' for plural
1100 return ($n > 1) ? $unit . 's' : $unit;
1101 }
1102 };
1103
1104 // Build the “per …” phrase (e.g., "per month", "per 3 months")
1105 $perPhrase = ($count === 1)
1106 ? sprintf(
1107 /* translators: %s is the singular unit name (e.g., day, month, year) */
1108 __('per %s', 'fluent-cart'),
1109 $pluralUnit($unit, 1)
1110 )
1111 : sprintf(
1112 /* translators: %1$d is the count number, %2$s is the plural unit name (e.g., days, months, years) */
1113 __('per %1$d %2$s', 'fluent-cart'),
1114 $count,
1115 $pluralUnit($unit, $count)
1116 );
1117
1118 // Main price phrase:
1119 // - If monthly installments (times > 0 and unit == month and count == 1): "$X/month for N months"
1120 // - Else if times > 0: "$X per {count unit(s)} for N {cycle(s)}"
1121 // - Else: "$X per {count unit(s)}"
1122 if ($times > 0) {
1123 if ($unit === 'month' && $count === 1) {
1124 $installmentTail = sprintf(
1125 /* translators: %s: pluralized 'month(s)' */
1126 __('for %1$d %2$s', 'fluent-cart'),
1127 $times,
1128 $pluralUnit('month', $times)
1129 );
1130 $main = sprintf(
1131 /* translators: Compact monthly installment, e.g. "$100/month for 4 months" */
1132 __('%1$s/%2$s %3$s', 'fluent-cart'),
1133 $price,
1134 __('month', 'fluent-cart'),
1135 $installmentTail
1136 );
1137 } else {
1138 // Generic installment wording: "for N cycle(s)"
1139 $cyclesTail = sprintf(
1140 /* translators: %1$d is the number of cycles, %2$s is "cycle" or "cycles" */
1141 __('for %1$d %2$s', 'fluent-cart'),
1142 $times,
1143 _n('cycle', 'cycles', $times, 'fluent-cart')
1144 );
1145 $main = sprintf(
1146 /* translators: e.g. "$100 per 3 months for 4 cycles" */
1147 __('%1$s %2$s %3$s', 'fluent-cart'),
1148 $price,
1149 $perPhrase,
1150 $cyclesTail
1151 );
1152 }
1153 } else {
1154 // Open-ended subscription
1155 $main = sprintf(
1156 /* translators: e.g. "$99 per month" */
1157 __('%1$s %2$s', 'fluent-cart'),
1158 $price,
1159 $perPhrase
1160 );
1161 }
1162
1163 // Prefix trial if present: "N days free then …"
1164 if ($trialDays > 0) {
1165 $trialFrag = sprintf(
1166 /* translators: "30 days free then" */
1167 __('%1$d %2$s free then', 'fluent-cart'),
1168 $trialDays,
1169 $pluralUnit('day', $trialDays)
1170 );
1171 $main = $trialFrag . ' ' . $main;
1172 }
1173
1174 // Append signup fee if any: " + $10 one-time signup fee"
1175 if ($signupFee) {
1176 $main .= ' ' . sprintf(
1177 /* translators: e.g. "+ $10 one-time signup fee" */
1178 __('+ %1$s one-time %2$s', 'fluent-cart'),
1179 $signupFee,
1180 $signupFeeLabel
1181 );
1182 }
1183
1184 // No trailing period (matches your examples)
1185 return $main;
1186 }
1187
1188 public static function getTranslatedIntervalUnit(string $unit): string
1189 {
1190 switch ($unit) {
1191 case 'day':
1192 return __('day', 'fluent-cart');
1193 case 'week':
1194 return __('week', 'fluent-cart');
1195 case 'month':
1196 return __('month', 'fluent-cart');
1197 case 'quarter':
1198 return __('quarter', 'fluent-cart');
1199 case 'half_year':
1200 return __('six month', 'fluent-cart');
1201 case 'year':
1202 return __('year', 'fluent-cart');
1203 default:
1204 return $unit;
1205 }
1206 }
1207
1208 public static function generateSubscriptionInfo($otherInfo, $itemPrice, $currencyCode = null): ?string
1209 {
1210 // Convert to array only if it's an object
1211 if (is_object($otherInfo)) {
1212 $otherInfo = json_decode(json_encode($otherInfo), true);
1213 }
1214
1215 $price = self::toDecimal($itemPrice, true, $currencyCode);
1216 $recurringDiscountAmount = Arr::get($otherInfo, 'recurring_discounts.amount', 0);
1217
1218 if ($recurringDiscountAmount) {
1219 $newRecurringAmount = $itemPrice - $recurringDiscountAmount;
1220 $price = "<del>" . $price . "</del> " . self::toDecimal($newRecurringAmount, true, $currencyCode);
1221 }
1222
1223 $repeatInterval = Arr::get($otherInfo, 'repeat_interval', '');
1224 $occurrence = (int)Arr::get($otherInfo, 'times', 0);
1225
1226 $intervalOptions = static::getAvailableSubscriptionIntervalMaps();
1227
1228 $intervalUnit = '';
1229 if (isset($intervalOptions[$repeatInterval])) {
1230 $intervalUnit = $intervalOptions[$repeatInterval];
1231 } else if ($repeatInterval) {
1232 $intervalOptions = static::getAvailableSubscriptionIntervalOptions();
1233 foreach ($intervalOptions as $option) {
1234 if ($option['value'] === $repeatInterval) {
1235 $intervalUnit = strtolower($option['label']);
1236 break;
1237 }
1238 }
1239
1240 if (!$intervalUnit) {
1241 $intervalUnit = ucwords(str_replace(['_', '-'], ' ', $repeatInterval));
1242 }
1243 }
1244
1245 $intervalLabel = Helper::getTranslatedIntervalUnit($intervalUnit);
1246
1247 $interval = $intervalUnit
1248 ? sprintf(
1249 /* translators: %s is the interval (e.g., day, week, month, quarter, half_year, year) */
1250 __('per %s', 'fluent-cart'),
1251 $intervalLabel
1252 )
1253 : '';
1254
1255 $time = $intervalUnit
1256 ? $intervalLabel
1257 : '';
1258
1259 $paymentInfo = sprintf(
1260 /* 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 */
1261 __('%1$s %2$s, for %3$s %4$s', 'fluent-cart'),
1262 $price,
1263 $interval,
1264 $occurrence,
1265 $time
1266 );
1267
1268 if (empty($occurrence)) {
1269 $paymentInfo = sprintf(
1270 /* translators: %1$s is the price, %2$s is the interval, %3$s is "until cancel" text */
1271 __('%1$s %2$s %3$s', 'fluent-cart'),
1272 $price,
1273 $interval,
1274 __('until cancel', 'fluent-cart')
1275 );
1276 }
1277
1278 return !empty($otherInfo) ? $paymentInfo : null;
1279 }
1280
1281 /**
1282 * Billing-cycle text for a subscription with a config-defined schedule
1283 * (see SubscriptionHelper::getBillingSchedule()) — cadences the
1284 * billing_interval enum cannot express, e.g. "$100 per 3 years on Aug 19".
1285 * Subscriptions without a schedule keep generateSubscriptionInfo().
1286 */
1287 public static function generateScheduleSubscriptionInfo(array $schedule, $otherInfo, $itemPrice, $currencyCode = null): ?string
1288 {
1289 if (is_object($otherInfo)) {
1290 $otherInfo = json_decode(json_encode($otherInfo), true);
1291 }
1292
1293 $count = max(1, (int) Arr::get($schedule, 'interval', 1));
1294
1295 switch (Arr::get($schedule, 'period')) {
1296 case 'day':
1297 $unitLabel = _n('day', 'days', $count, 'fluent-cart');
1298 break;
1299 case 'week':
1300 $unitLabel = _n('week', 'weeks', $count, 'fluent-cart');
1301 break;
1302 case 'month':
1303 $unitLabel = _n('month', 'months', $count, 'fluent-cart');
1304 break;
1305 case 'year':
1306 $unitLabel = _n('year', 'years', $count, 'fluent-cart');
1307 break;
1308 default:
1309 return self::generateSubscriptionInfo($otherInfo, $itemPrice, $currencyCode);
1310 }
1311
1312 $price = self::toDecimal($itemPrice, true, $currencyCode);
1313 $recurringDiscountAmount = Arr::get($otherInfo, 'recurring_discounts.amount', 0);
1314
1315 if ($recurringDiscountAmount) {
1316 $newRecurringAmount = $itemPrice - $recurringDiscountAmount;
1317 $price = "<del>" . $price . "</del> " . self::toDecimal($newRecurringAmount, true, $currencyCode);
1318 }
1319
1320 $interval = $count === 1
1321 ? sprintf(
1322 /* translators: %s is the interval unit (e.g., day, week, month, year) */
1323 __('per %s', 'fluent-cart'),
1324 $unitLabel
1325 )
1326 : sprintf(
1327 /* translators: %1$d is the count number, %2$s is the plural unit name (e.g., days, months, years) */
1328 __('per %1$d %2$s', 'fluent-cart'),
1329 $count,
1330 $unitLabel
1331 );
1332
1333 if ($anchorText = self::getScheduleAnchorText($schedule['period'], Arr::get($schedule, 'anchor', []))) {
1334 $interval .= ' ' . $anchorText;
1335 }
1336
1337 $occurrence = (int) Arr::get($otherInfo, 'times', 0);
1338
1339 if (empty($occurrence)) {
1340 return sprintf(
1341 /* translators: %1$s is the price, %2$s is the interval, %3$s is "until cancel" text */
1342 __('%1$s %2$s %3$s', 'fluent-cart'),
1343 $price,
1344 $interval,
1345 __('until cancel', 'fluent-cart')
1346 );
1347 }
1348
1349 return sprintf(
1350 /* translators: %1$s is the price, %2$s is the interval, %3$s is the occurrence count, %4$s is "cycle(s)" */
1351 __('%1$s %2$s, for %3$s %4$s', 'fluent-cart'),
1352 $price,
1353 $interval,
1354 $occurrence,
1355 _n('cycle', 'cycles', $occurrence, 'fluent-cart')
1356 );
1357 }
1358
1359 /**
1360 * Human-readable billing anchor, e.g. "on Friday", "on the 10th",
1361 * "on the last day", "on Aug 19". Anchor day 31 encodes "last day of
1362 * the month" (see SubscriptionHelper::getBillingSchedule()).
1363 */
1364 private static function getScheduleAnchorText(string $period, $anchor): string
1365 {
1366 if (!is_array($anchor) || !$anchor) {
1367 return '';
1368 }
1369
1370 if ($period === 'week' && !empty($anchor['weekday'])) {
1371 $weekdays = [
1372 1 => __('Monday', 'fluent-cart'),
1373 2 => __('Tuesday', 'fluent-cart'),
1374 3 => __('Wednesday', 'fluent-cart'),
1375 4 => __('Thursday', 'fluent-cart'),
1376 5 => __('Friday', 'fluent-cart'),
1377 6 => __('Saturday', 'fluent-cart'),
1378 7 => __('Sunday', 'fluent-cart'),
1379 ];
1380
1381 if (isset($weekdays[$anchor['weekday']])) {
1382 /* translators: %s is a weekday name, e.g. "on Friday" */
1383 return sprintf(__('on %s', 'fluent-cart'), $weekdays[$anchor['weekday']]);
1384 }
1385
1386 return '';
1387 }
1388
1389 if ($period === 'month' && !empty($anchor['day'])) {
1390 $day = (int) $anchor['day'];
1391
1392 if ($day === 31) {
1393 return __('on the last day', 'fluent-cart');
1394 }
1395
1396 /* translators: %s is an ordinal day of month, e.g. "on the 10th" */
1397 return sprintf(__('on the %s', 'fluent-cart'), gmdate('jS', gmmktime(12, 0, 0, 1, $day, 2001)));
1398 }
1399
1400 if ($period === 'year' && (!empty($anchor['day']) || !empty($anchor['month']))) {
1401 $day = (int) Arr::get($anchor, 'day', 0);
1402 $month = (int) Arr::get($anchor, 'month', 0);
1403
1404 if ($month && $day) {
1405 /* translators: %s is a date, e.g. "on Aug 19" */
1406 return sprintf(__('on %s', 'fluent-cart'), gmdate('M', gmmktime(12, 0, 0, $month, 1, 2001)) . ' ' . $day);
1407 }
1408
1409 if ($month) {
1410 /* translators: %s is a month name, e.g. "in August" */
1411 return sprintf(__('in %s', 'fluent-cart'), gmdate('F', gmmktime(12, 0, 0, $month, 1, 2001)));
1412 }
1413
1414 /* translators: %s is an ordinal day of month, e.g. "on the 10th" */
1415 return sprintf(__('on the %s', 'fluent-cart'), gmdate('jS', gmmktime(12, 0, 0, 1, $day, 2001)));
1416 }
1417
1418 return '';
1419 }
1420
1421 public static function generateSetupFeeInfo($otherInfo, $asArray = false)
1422 {
1423 // Convert to array if it's an object
1424 if (is_object($otherInfo)) {
1425 $otherInfo = json_decode(json_encode($otherInfo), true);
1426 }
1427
1428 $signupFeeName = Arr::get($otherInfo, 'signup_fee_name', __('Setup Fee', 'fluent-cart'));
1429 $fee = Arr::get($otherInfo, 'signup_fee', 0);
1430 $manageSetupFee = Arr::get($otherInfo, 'manage_setup_fee', 'no');
1431
1432 if ($manageSetupFee !== 'yes' || !$fee) {
1433 return '';
1434 }
1435
1436
1437 if ($originalSetupFee = Arr::get($otherInfo, 'original_signup_fee', 0)) {
1438 if ($fee != $originalSetupFee) {
1439 $title = __('Adjusted setup fee', 'fluent-cart');
1440 $formattedAmount = CurrencySettings::getPriceHtml($fee, null, true, true);
1441
1442 if ($asArray) {
1443 return [
1444 'signup_fee_name' => $title,
1445 'signup_fee' => $fee,
1446 'signup_fee_formatted' => $formattedAmount,
1447 ];
1448 }
1449 return $title . $formattedAmount;
1450 }
1451 }
1452
1453 $formattedAmount = CurrencySettings::getPriceHtml($fee, null, true, true);
1454 if ($asArray) {
1455 return [
1456 'signup_fee_name' => $signupFeeName,
1457 'signup_fee' => $fee,
1458 'signup_fee_formatted' => $formattedAmount,
1459 ];
1460 }
1461
1462
1463 return $signupFeeName . ' ' . $formattedAmount;
1464 }
1465
1466 public static function generateTrialInfo($otherInfo)
1467 {
1468 $trialInfo = '';
1469
1470 $trialDays = Arr::get($otherInfo, 'trial_days', 0);
1471
1472 if ($trialDays && Arr::get($otherInfo, 'is_trial_days_simulated', 'no') !== 'yes') {
1473 $trialInfo = sprintf(
1474 /* translators: %d is the number of trial days */
1475 __('Free Trial: %d days', 'fluent-cart'),
1476 $trialDays
1477 );
1478 }
1479
1480 return apply_filters('fluent_cart/trial_info', $trialInfo, $otherInfo);
1481 }
1482
1483
1484 public static function getCountryList(): array
1485 {
1486 $options = App::getInstance('localization')->countriesOptions();
1487
1488 return apply_filters('fluent_cart/util/countries', $options, []);
1489 }
1490
1491 public static function getCountyIsoLists(): array
1492 {
1493 return App::getInstance('localization')->getCountyIsoLists();
1494 }
1495
1496 public static function getCountryCode($country_name)
1497 {
1498 $countries = self::getCountryList();
1499 foreach ($countries as $country) {
1500 if ($country['name'] === $country_name) {
1501 return $country['value'];
1502 }
1503 }
1504 return '';
1505 }
1506
1507 /**
1508 * Get the country's name with country code,
1509 *
1510 * @param $code
1511 * @return string
1512 */
1513 public static function getCountryName($code): string
1514 {
1515 if (!$code || !is_string($code)) {
1516 return '';
1517 }
1518
1519 $countries = self::getCountyIsoLists();
1520
1521 return $countries[$code] ?? $code;
1522 }
1523
1524 public static function languageCodes(): array
1525 {
1526 return $langCodes = [
1527 'AF' => 'fa-AF',
1528 'AL' => 'sq-AL',
1529 'DZ' => 'ar-DZ',
1530 'AS' => 'sm-AS',
1531 'AD' => 'ca-AD',
1532 'AO' => 'pt-AO',
1533 'AR' => 'es-AR',
1534 'AM' => 'hy-AM',
1535 'AU' => 'en-AU',
1536 'AT' => 'de-AT',
1537 'AZ' => 'az-AZ',
1538 'BH' => 'ar-BH',
1539 'BD' => 'bn-BD',
1540 'BY' => 'be-BY',
1541 'BE' => 'nl-BE',
1542 'BZ' => 'en-BZ',
1543 'BJ' => 'fr-BJ',
1544 'BT' => 'dz-BT',
1545 'BO' => 'es-BO',
1546 'BA' => 'bs-BA',
1547 'BW' => 'en-BW',
1548 'BR' => 'pt-BR',
1549 'BN' => 'ms-BN',
1550 'BG' => 'bg-BG',
1551 'BF' => 'fr-BF',
1552 'BI' => 'fr-BI',
1553 'KH' => 'km-KH',
1554 'CM' => 'en-CM',
1555 'CA' => 'en-CA',
1556 'CV' => 'pt-CV',
1557 'CF' => 'fr-CF',
1558 'TD' => 'fr-TD',
1559 'CL' => 'es-CL',
1560 'CN' => 'zh-CN',
1561 'CO' => 'es-CO',
1562 'KM' => 'ar-KM',
1563 'CD' => 'fr-CD',
1564 'CG' => 'fr-CG',
1565 'CR' => 'es-CR',
1566 'CI' => 'fr-CI',
1567 'HR' => 'hr-HR',
1568 'CU' => 'es-CU',
1569 'CY' => 'el-CY',
1570 'CZ' => 'cs-CZ',
1571 'DK' => 'da-DK',
1572 'DJ' => 'fr-DJ',
1573 'DM' => 'en-DM',
1574 'DO' => 'es-DO',
1575 'EC' => 'es-EC',
1576 'EG' => 'ar-EG',
1577 'SV' => 'es-SV',
1578 'GQ' => 'es-GQ',
1579 'ER' => 'ti-ER',
1580 'EE' => 'et-EE',
1581 'ET' => 'am-ET',
1582 'FJ' => 'en-FJ',
1583 'FI' => 'fi-FI',
1584 'FR' => 'fr-FR',
1585 'GA' => 'fr-GA',
1586 'GM' => 'en-GM',
1587 'GE' => 'ka-GE',
1588 'DE' => 'de-DE',
1589 'GH' => 'en-GH',
1590 'GR' => 'el-GR',
1591 'GD' => 'en-GD',
1592 'GT' => 'es-GT',
1593 'GN' => 'fr-GN',
1594 'GW' => 'pt-GW',
1595 'GY' => 'en-GY',
1596 'HT' => 'fr-HT',
1597 'HN' => 'es-HN',
1598 'HU' => 'hu-HU',
1599 'IS' => 'is-IS',
1600 'IN' => 'hi-IN',
1601 'ID' => 'id-ID',
1602 'IR' => 'fa-IR',
1603 'IQ' => 'ar-IQ',
1604 'IE' => 'en-IE',
1605 'IL' => 'he-IL',
1606 'IT' => 'it-IT',
1607 'JM' => 'en-JM',
1608 'JP' => 'ja-JP',
1609 'JO' => 'ar-JO',
1610 'KZ' => 'kk-KZ',
1611 'KE' => 'sw-KE',
1612 'KI' => 'en-KI',
1613 'KR' => 'ko-KR',
1614 'KW' => 'ar-KW',
1615 'KG' => 'ky-KG',
1616 'LA' => 'lo-LA',
1617 'LV' => 'lv-LV',
1618 'LB' => 'ar-LB',
1619 'LS' => 'en-LS',
1620 'LR' => 'en-LR',
1621 'LY' => 'ar-LY',
1622 'LI' => 'de-LI',
1623 'LT' => 'lt-LT',
1624 'LU' => 'lb-LU',
1625 'MG' => 'mg-MG',
1626 'MW' => 'en-MW',
1627 'MY' => 'ms-MY',
1628 'MV' => 'dv-MV',
1629 'ML' => 'fr-ML',
1630 'MT' => 'mt-MT',
1631 'MH' => 'mh-MH',
1632 'MR' => 'ar-MR',
1633 'MU' => 'mfe-MU',
1634 'MX' => 'es-MX',
1635 'FM' => 'en-FM',
1636 'MD' => 'ro-MD',
1637 'MC' => 'fr-MC',
1638 'MN' => 'mn-MN',
1639 'ME' => 'sr-ME',
1640 'MA' => 'ar-MA',
1641 'MZ' => 'pt-MZ',
1642 'NA' => 'en-NA',
1643 'NR' => 'en-NR',
1644 'NP' => 'ne-NP',
1645 'NL' => 'nl-NL',
1646 'NZ' => 'en-NZ',
1647 'NI' => 'es-NI',
1648 'NG' => 'en-NG',
1649 'NO' => 'no-NO',
1650 'OM' => 'ar-OM',
1651 'PK' => 'ur-PK',
1652 'PW' => 'en-PW',
1653 'PA' => 'es-PA',
1654 'PG' => 'en-PG',
1655 'PY' => 'es-PY',
1656 'PE' => 'es-PE',
1657 'PH' => 'en-PH',
1658 'PL' => 'pl-PL',
1659 'PT' => 'pt-PT',
1660 'QA' => 'ar-QA',
1661 'RO' => 'ro-RO',
1662 'RU' => 'ru-RU',
1663 'RW' => 'rw-RW',
1664 'WS' => 'sm-WS',
1665 'SM' => 'it-SM',
1666 'SA' => 'ar-SA',
1667 'SN' => 'fr-SN',
1668 'RS' => 'sr-RS',
1669 'SC' => 'fr-SC',
1670 'SL' => 'en-SL',
1671 'SG' => 'en-SG',
1672 'SK' => 'sk-SK',
1673 'SI' => 'sl-SI',
1674 'SB' => 'en-SB',
1675 'SO' => 'so-SO',
1676 'ZA' => 'en-ZA',
1677 'ES' => 'es-ES',
1678 'LK' => 'si-LK',
1679 'SD' => 'ar-SD',
1680 'SR' => 'nl-SR',
1681 'SZ' => 'en-SZ',
1682 'SE' => 'sv-SE',
1683 'CH' => 'de-CH',
1684 'SY' => 'ar-SY',
1685 'TW' => 'zh-TW',
1686 'TJ' => 'tg-TJ',
1687 'TZ' => 'sw-TZ',
1688 'TH' => 'th-TH',
1689 'TL' => 'pt-TL',
1690 'TG' => 'fr-TG',
1691 'TO' => 'to-TO',
1692 'TT' => 'en-TT',
1693 'TN' => 'ar-TN',
1694 'TR' => 'tr-TR',
1695 'TM' => 'tk-TM',
1696 'TV' => 'en-TV',
1697 'UG' => 'en-UG',
1698 'UA' => 'uk-UA',
1699 'AE' => 'ar-AE',
1700 'GB' => 'en-GB',
1701 'US' => 'en-US',
1702 'UY' => 'es-UY',
1703 'UZ' => 'uz-UZ',
1704 'VU' => 'bi-VU',
1705 'VE' => 'es-VE',
1706 'VN' => 'vi-VN',
1707 'YE' => 'ar-YE',
1708 'ZM' => 'en-ZM',
1709 'ZW' => 'en-ZW'
1710 ];
1711 }
1712
1713 /**
1714 * Returns a translatable string with a shortcode inserted in the correct format.
1715 *
1716 * @param string $shortcode The shortcode to be inserted (e.g., '[fluent_cart_receipt]').
1717 * @return string The formatted translatable string.
1718 */
1719 public static function getShortcodeInstructionString(string $shortcode, $pageName = ''): string
1720 {
1721 $copyToClipboard = __('Copy to clipboard', 'fluent-cart');
1722 return sprintf(
1723 /* translators: %s: Shortcode */
1724 '<p>' . _x('Use %1$s shortcode in your page.', 'Shortcode instruction message', 'fluent-cart') . '</p>',
1725 '<code class="copyable-content" title="' . $copyToClipboard . '">' . ($shortcode) . '</code>',
1726 //$pageName
1727 );
1728 }
1729
1730
1731 /**
1732 * Get the current user Model.
1733 * @return User|\FluentCart\Framework\Database\Orm\Builder|\FluentCart\Framework\Database\Orm\Builder[]|\FluentCart\Framework\Database\Orm\Collection|\FluentCart\Framework\Database\Orm\Model|null
1734 */
1735 public static function getCurrentUser($refresh = false)
1736 {
1737 static $user = false;
1738
1739 if (!$refresh && $user !== false) {
1740 return $user;
1741 }
1742
1743 $userId = get_current_user_id();
1744 if (!$userId) {
1745 $user = null;
1746 return $user;
1747 }
1748
1749 $user = User::query()->find($userId);
1750
1751 return $user;
1752
1753 }
1754
1755 public static function hasLicense($product): bool
1756 {
1757 if (empty($product)) {
1758 return false;
1759 }
1760
1761 $meta = Arr::get($product, 'licensesMeta.meta_value', []);
1762
1763 if (empty($meta)) {
1764 return false;
1765 }
1766
1767 $meta = is_string($meta) ? json_decode($meta, true) : $meta;
1768
1769 return Arr::get($meta, 'enabled') === 'yes';
1770
1771 }
1772
1773
1774 public static function generateDownloadFileLink($productDownload, $orderId = null, $validityInMinutes = 60, $isAdmin = false): string
1775 {
1776 $identifier = Arr::get($productDownload, 'download_identifier', '');
1777
1778 $validityInMinutes = apply_filters('fluent_cart/download_link_validity_in_minutes', $validityInMinutes, [
1779 'product_download' => $productDownload,
1780 'order_id' => $orderId,
1781 'is_admin' => $isAdmin,
1782 ]);
1783
1784 $signParams = [
1785 'download_identifier' => $identifier,
1786 'valid_till' => DateTime::now()
1787 ->addMinutes($validityInMinutes ?? 60)
1788 ->getTimestamp()
1789 ];
1790
1791 if ($orderId) {
1792 $orderId = Arr::wrap($orderId);
1793 $signParams['order_id'] = json_encode($orderId);
1794 }
1795
1796 $url = (new BaseUrl())->sign(site_url('/'), $signParams);
1797
1798 return URL::appendQueryParams($url, [
1799 'fluent-cart' => 'download-by-id',
1800 ]);
1801
1802 }
1803
1804
1805 public static function readableFileSize($bytes): string
1806 {
1807 // Converts bytes to a human-readable format (e.g., KB, MB, GB)
1808 // Example: 1024 -> "1 KB"
1809 // Example: 1048576 -> "1 MB"
1810
1811 if (!$bytes && $bytes !== 0) return '';
1812 $units = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
1813 $i = floor(log($bytes, 2) / 10);
1814 $size = $bytes / pow(1024, $i);
1815 return round($size, 2) . ' ' . $units[$i];
1816 }
1817
1818
1819 public static function getSitePrefix()
1820 {
1821 $siteUrl = rtrim(home_url(), '/');
1822 // remove http:// or https:// from the URL
1823 $siteUrl = preg_replace('#^https?://#', '', $siteUrl);
1824 $sitePrefix = str_replace(['/', '.'], '_', $siteUrl);
1825
1826 return apply_filters('fluent_cart/site_prefix', $sitePrefix, []);
1827 }
1828
1829 public static function humanIntervalMaps($interval = '')
1830 {
1831 $intervals = [
1832 'daily' => __('day', 'fluent-cart'),
1833 'weekly' => __('week', 'fluent-cart'),
1834 'monthly' => __('month', 'fluent-cart'),
1835 'quarterly' => __('quarter', 'fluent-cart'),
1836 'half_yearly' => __('six month', 'fluent-cart'),
1837 'yearly' => __('year', 'fluent-cart'),
1838 ];
1839
1840 return Arr::get($intervals, $interval);
1841 }
1842
1843 /**
1844 * @return array Array of intervals with label and value
1845 */
1846 public static function getAvailableSubscriptionIntervalOptions(): array
1847 {
1848 $intervals = [
1849 [
1850 'label' => __('Yearly', 'fluent-cart'),
1851 'value' => 'yearly',
1852 'map_value' => 'year',
1853 ],
1854 [
1855 'label' => __('Half Yearly', 'fluent-cart'),
1856 'value' => 'half_yearly',
1857 'map_value' => 'half_year',
1858 ],
1859 [
1860 'label' => __('Quarterly', 'fluent-cart'),
1861 'value' => 'quarterly',
1862 'map_value' => 'quarter',
1863 ],
1864 [
1865 'label' => __('Monthly', 'fluent-cart'),
1866 'value' => 'monthly',
1867 'map_value' => 'month',
1868 ],
1869 [
1870 'label' => __('Weekly', 'fluent-cart'),
1871 'value' => 'weekly',
1872 'map_value' => 'week',
1873 ],
1874 [
1875 'label' => __('Daily', 'fluent-cart'),
1876 'value' => 'daily',
1877 'map_value' => 'day',
1878 ]
1879 ];
1880
1881 return apply_filters('fluent_cart/available_subscription_interval_options', $intervals);
1882 }
1883
1884 public static function translateIntervalToStandardFormat($repeatInterval)
1885 {
1886 if (empty($repeatInterval)) {
1887 return 'year';
1888 }
1889
1890 $intervalMaps = static::getAvailableSubscriptionIntervalMaps();
1891
1892 if (!isset($intervalMaps[$repeatInterval])) {
1893 return 'year';
1894 }
1895
1896 return $intervalMaps[$repeatInterval];
1897 }
1898
1899 public static function getAvailableSubscriptionIntervalMaps()
1900 {
1901 $intervalOptions = static::getAvailableSubscriptionIntervalOptions();
1902
1903 $intervalMaps = [];
1904 foreach ($intervalOptions as $option) {
1905 $intervalMaps[$option['value']] = $option['map_value'];
1906 }
1907
1908 return $intervalMaps;
1909
1910 }
1911
1912 public static function calculateAdjustedTrialDaysForInterval($trialDays, $repeatInterval)
1913 {
1914 $intervalInDays = static::subscriptionIntervalInDays($repeatInterval);
1915
1916 $maxTrialDaysAllowed = apply_filters('fluent_cart/max_trial_days_allowed', 365, [
1917 'existing_trial_days' => $trialDays,
1918 'repeat_interval' => $repeatInterval,
1919 'interval_in_days' => $intervalInDays,
1920 ]);
1921
1922 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
1923
1924 }
1925
1926 public static function subscriptionIntervalInDays($interval)
1927 {
1928 return PaymentHelper::getIntervalDays($interval);
1929 }
1930
1931 public static function parseTermIdsForFilter($filters): array
1932 {
1933 $taxonomies = Taxonomy::getTaxonomies();
1934 if (is_string($filters)) {
1935 $filters = json_decode($filters, true);
1936 }
1937 $formattedFilters = [];
1938
1939 foreach ($taxonomies as $key => $taxonomy) {
1940 $terms = Arr::get($filters, $key, []);
1941 if (!is_array($terms)) {
1942 $terms = [$terms];
1943 }
1944
1945 $terms = array_filter($terms, function ($term) {
1946 return !empty($term);
1947 });
1948
1949 if (!empty($terms)) {
1950 $terms = array_map(function ($term) {
1951 return sanitize_text_field((string)$term);
1952 }, $terms);
1953
1954
1955 $formattedFilters[$key] = $terms;
1956 }
1957
1958
1959 }
1960
1961 return $formattedFilters;
1962 }
1963
1964 public static function mergeTermIdsForFilter($array1 = [], $array2 = []): array
1965 {
1966 $result = [];
1967
1968 foreach ([$array1, $array2] as $array) {
1969 foreach ($array as $key => $values) {
1970 if (!isset($result[$key])) {
1971 $result[$key] = [];
1972 }
1973 $result[$key] = array_values(array_unique(array_merge($result[$key], $values)));
1974 }
1975 }
1976
1977 return $result;
1978 }
1979
1980 public static function loadBundleChild(array $variants, $select = ['id', 'variation_title']): array
1981 {
1982 $allChildVariants = Arr::pluck($variants, 'other_info.bundle_child_ids');
1983
1984 $allChildVariants = array_unique(Arr::flatten($allChildVariants));
1985 $allChildVariants = array_values(array_diff(
1986 $allChildVariants,
1987 Arr::pluck($variants, 'id')
1988 ));
1989 $allChildVariants = array_filter($allChildVariants);
1990 $childVariants = ProductVariation::query()
1991 ->whereIn('id', $allChildVariants)
1992 ->with('product:ID,post_title')
1993 ->select($select)
1994 ->get()
1995 ->toArray();
1996
1997 // Extract post_title from product and remove product object to keep data clean
1998 foreach ($childVariants as $key => $childVariant) {
1999 $postTitle = Arr::get($childVariant, 'product.post_title');
2000 if ($postTitle) {
2001 $childVariants[$key]['post_title'] = $postTitle;
2002 unset($childVariants[$key]['product']);
2003 }
2004 }
2005
2006 foreach ($variants as &$variant) {
2007 $childIds = Arr::get($variant, 'other_info.bundle_child_ids', []);
2008 $variant['bundle_child_ids'] = $childIds;
2009 if (count($childIds) < 1) {
2010 $variant['child_variants'] = [];
2011 continue;
2012 }
2013 foreach ($childVariants as $childVariant) {
2014 if (in_array($childVariant['id'], Arr::get($variant, 'other_info.bundle_child_ids', []))) {
2015 $variant['child_variants'][$childVariant['id']] = $childVariant;
2016 }
2017 }
2018 }
2019
2020 return $variants;
2021 }
2022
2023 /**
2024 * Translate digits in a number according to the configured numeric system.
2025 *
2026 * Converts the digits 0-9 in the given number to their equivalents
2027 * defined in the numeric system string from `dateTimeStrings()`.
2028 * This allows displaying numbers in other numeral systems, e.g., Bengali, Arabic, Hindi, etc.
2029 *
2030 * Only digits are translated; other characters such as decimal points, currency symbols, or text remain unchanged.
2031 *
2032 * @param int|float|string $number The number to translate.
2033 * @return string The number with digits translated according to the configured numeric system.
2034 */
2035 public static function translateNumber($number): string
2036 {
2037 $config = TransStrings::dateTimeStrings();
2038 $numericSystem = Arr::get($config, 'numericSystem', '0_1_2_3_4_5_6_7_8_9');
2039 $digits = explode('_', $numericSystem);
2040
2041 return strtr(
2042 (string)$number,
2043 array_combine(range(0, 9),
2044 $digits)
2045 );
2046 }
2047
2048 public static function isModalCheckoutEnabled(): bool
2049 {
2050 //$storeSettings = new StoreSettings();
2051 //$enableModalCheckout = $storeSettings->get('enable_modal_checkout', 'no');
2052 return apply_filters('fluent_cart/enable_modal_checkout', false);
2053 }
2054
2055 public static function isAdminUser(): bool
2056 {
2057 return current_user_can('manage_options');
2058 }
2059
2060 /**
2061 * Convert string/boolean to actual boolean value.
2062 * Handles shortcode string attributes like "true"/"false"
2063 *
2064 * @param mixed $value The value to convert to boolean
2065 * @param bool $default Default value if conversion fails
2066 * @return bool The boolean result
2067 */
2068 public static function toBool($value, bool $default = false): bool
2069 {
2070 if (is_bool($value)) {
2071 return $value;
2072 }
2073
2074 if (is_string($value)) {
2075 $value = strtolower(trim($value));
2076 if (in_array($value, ['true', '1', 'yes', 'on'], true)) {
2077 return true;
2078 }
2079 if (in_array($value, ['false', '0', 'no', 'off'], true)) {
2080 return false;
2081 }
2082 }
2083
2084 return $default;
2085 }
2086
2087 public static function formatTaxRatePercent(float $rate): string
2088 {
2089 $formatted = number_format($rate, 4, '.', '');
2090 if (strpos($formatted, '.') !== false) {
2091 $formatted = rtrim($formatted, '0');
2092 $formatted = rtrim($formatted, '.');
2093 }
2094 return $formatted;
2095 }
2096
2097 /**
2098 * Returns the tax label for order-level tax rows (tax_total, not per-item).
2099 * For mixed orders, indicates tax varies per item.
2100 *
2101 * @param \FluentCart\App\Models\Order $order
2102 * @return string
2103 */
2104 public static function getOrderTaxLabel($order) {
2105 if ((int) $order->tax_behavior === 3) {
2106 return esc_html__('(Varies)', 'fluent-cart');
2107 }
2108 return (int) $order->tax_behavior === 2
2109 ? esc_html__('(Included)', 'fluent-cart')
2110 : esc_html__('(Excluded)', 'fluent-cart');
2111 }
2112 }
2113