PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
1.7.0 1.6.6 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 All 50 releases
← All changes | app/Helpers/Helper.php +474 -151 1.3.27 → 1.7.0 View file →
@@ -11,8 +11,9 @@
11 11 use FluentCart\App\Models\Customer;
12 12 use FluentCart\App\Models\ProductVariation;
13 13 use FluentCart\App\Models\User;
14 14 use FluentCart\App\Services\DateTime\DateTime;
15 +use FluentCart\App\Services\Payments\PaymentHelper;
15 16 use FluentCart\App\Services\Localization\LocalizationManager;
16 17 use FluentCart\App\Services\Translations\TransStrings;
17 18 use FluentCart\App\Services\URL;
18 19 use FluentCart\Framework\Http\URL as BaseUrl;
@@ -32,8 +33,78 @@
32 33 const ROLE_CAPABILITY_PREFIX = 'fluent_cart/permissions/';
33 34
34 35 const USER_ROLE = 'fluent_cart_customer';
35 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 + /**
48 + * One resolver for every `photo` a FluentCart record appends: the
49 + * uploaded customer photo for the user when one is set, otherwise the
50 + * WordPress avatar by user id or email. Gravatar is asked with
51 + * default=404, so an address with no avatar of its own fails to load
52 + * and the UI falls back to its placeholder icon instead of Gravatar's
53 + * stock image. esc_url_raw throughout — the value travels through
54 + * JSON, where esc_url's entity-encoded ampersands would break the
55 + * query string.
56 + *
57 + * @param int|string|null $userId user id owning a possible uploaded photo
58 + * @param string|null $email fallback identity when there is no user
59 + * @return string the photo URL, or '' when avatars are off or the
60 + * record carries no identity to resolve one from
61 + */
62 + public static function getUserAvatarUrl($userId, $email, int $size = 100): string
63 + {
64 + if (!get_option('show_avatars')) {
65 + return '';
66 + }
67 +
68 + if ($userId) {
69 + $customPhotoUrl = esc_url_raw(
70 + (string) get_user_meta((int) $userId, 'fc_customer_photo_url', true)
71 + );
72 +
73 + if ($customPhotoUrl) {
74 + return $customPhotoUrl;
75 + }
76 + }
77 +
78 + $userIdOrEmail = ((int) $userId) ?: (string) $email;
79 +
80 + if (!$userIdOrEmail) {
81 + return '';
82 + }
83 +
84 + $url = get_avatar_url($userIdOrEmail, [
85 + 'size' => $size,
86 + 'default' => '404',
87 + ]);
88 +
89 + return $url ? esc_url_raw($url) : '';
90 + }
91 +
92 + public static function installmentTimesError($otherInfo)
93 + {
94 + if (Arr::get($otherInfo, 'installment', 'no') !== 'yes') {
95 + return null;
96 + }
97 +
98 + $times = Arr::get($otherInfo, 'times', 0);
99 +
100 + if (!is_numeric($times) || (int)$times < static::MIN_INSTALLMENT_TIMES) {
101 + 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');
102 + }
103 +
104 + return null;
105 + }
106 +
36 107 public static function getUidSerial()
37 108 {
38 109 static $id = 0;
39 110
@@ -42,8 +113,46 @@
42 113 return $id;
43 114
44 115 }
45 116
117 + /**
118 + * Build a spec-compliant "Upgrade to Pro" URL.
119 + *
120 + * Follows the shared Fluent* UTM spec:
121 + * utm_source = fluent-cart (fixed vocabulary, never the wp.org slug)
122 + * utm_medium = free_plugin | pro_plugin (acquisition vs cross-sell)
123 + * utm_campaign= upgrade_pro (override for xsell_<target> / license_*)
124 + * utm_content = the exact placement, e.g. feature_lock_advanced_inventory
125 + * utm_term = plugin version that generated the link
126 + * utm_id = promo id, blank normally (omit unless passed)
127 + *
128 + * @param string $content The utm_content placement.
129 + * @param array $overrides Override any utm_* param (e.g. utm_campaign for cross-sell).
130 + * @return string
131 + */
132 + public static function getUpgradeUrl($content = 'upgrade_page', $overrides = []): string
133 + {
134 + $baseUrl = (string) apply_filters(
135 + 'fluent_cart/pro_upgrade_base_url',
136 + 'https://fluentcart.com/discount-deal/'
137 + );
138 +
139 + $params = wp_parse_args($overrides, [
140 + 'utm_source' => 'fluent-cart',
141 + 'utm_medium' => App::isProActive() ? 'pro_plugin' : 'free_plugin',
142 + 'utm_campaign' => 'upgrade_pro',
143 + 'utm_content' => $content,
144 + 'utm_term' => defined('FLUENTCART_VERSION') ? FLUENTCART_VERSION : '',
145 + ]);
146 +
147 + // Drop any blank params (e.g. an unset utm_id) so they never hit the URL.
148 + $params = array_filter($params, function ($value) {
149 + return $value !== '' && $value !== null;
150 + });
151 +
152 + return add_query_arg($params, $baseUrl);
153 + }
154 +
46 155 public static function getRestInfo()
47 156 {
48 157 $app = App::getInstance();
49 158
@@ -134,9 +243,9 @@
134 243 */
135 244 public static function convertWeight($value, $fromUnit, $toUnit)
136 245 {
137 246 if ($fromUnit === $toUnit || !$value) {
138 - return (float) $value;
247 + return (float)$value;
139 248 }
140 249
141 250 $toGrams = [
142 251 'g' => 1,
@@ -215,17 +324,15 @@
215 324 }
216 325
217 326 public static function getOrderStatuses()
218 327 {
219 - $statuses = apply_filters_deprecated('fluent-cart/order_statuses', [
220 - [
221 - 'on-hold' => __('On Hold', 'fluent-cart'),
222 - 'processing' => __('Processing', 'fluent-cart'),
223 - 'completed' => __('Completed', 'fluent-cart'),
224 - //'archived' => __('Archived', 'fluent-cart'),
225 - 'cancelled' => __('Cancelled', 'fluent-cart'),
226 - ], []
227 - ], '1.3.16', 'fluent_cart/order_statuses', 'Use fluent_cart/order_statuses instead of fluent-cart/order_statuses.');
328 + $statuses = [
329 + 'on-hold' => __('On Hold', 'fluent-cart'),
330 + 'processing' => __('Processing', 'fluent-cart'),
331 + 'completed' => __('Completed', 'fluent-cart'),
332 + //'archived' => __('Archived', 'fluent-cart'),
333 + 'cancelled' => __('Cancelled', 'fluent-cart'),
334 + ];
228 335
229 336 return apply_filters('fluent_cart/order_statuses', $statuses, []);
230 337 }
231 338
@@ -230,17 +337,15 @@
230 337 }
231 338
232 339 public static function getEditableOrderStatuses()
233 340 {
234 - $statuses = apply_filters_deprecated('fluent-cart/editable_order_statuses', [
235 - [
236 - 'on-hold' => __('On Hold', 'fluent-cart'),
237 - 'processing' => __('Processing', 'fluent-cart'),
238 - 'completed' => __('Completed', 'fluent-cart'),
239 - // 'archived' => __('Archived', 'fluent-cart'),
240 - 'cancelled' => __('Cancelled', 'fluent-cart')
241 - ], []
242 - ], '1.3.16', 'fluent_cart/editable_order_statuses', 'Use fluent_cart/editable_order_statuses instead of fluent-cart/editable_order_statuses.');
341 + $statuses = [
342 + 'on-hold' => __('On Hold', 'fluent-cart'),
343 + 'processing' => __('Processing', 'fluent-cart'),
344 + 'completed' => __('Completed', 'fluent-cart'),
345 + // 'archived' => __('Archived', 'fluent-cart'),
346 + 'cancelled' => __('Cancelled', 'fluent-cart')
347 + ];
243 348
244 349 return apply_filters('fluent_cart/editable_order_statuses', $statuses, []);
245 350 }
246 351
@@ -245,14 +350,12 @@
245 350 }
246 351
247 352 public static function getEditableCustomerStatuses()
248 353 {
249 - $statuses = apply_filters_deprecated('fluent-cart/editable_customer_statuses', [
250 - [
251 - 'active' => __('Active', 'fluent-cart'),
252 - 'inactive' => __('Inactive', 'fluent-cart'),
253 - ], []
254 - ], '1.3.16', 'fluent_cart/editable_customer_statuses', 'Use fluent_cart/editable_customer_statuses instead of fluent-cart/editable_customer_statuses.');
354 + $statuses = [
355 + 'active' => __('Active', 'fluent-cart'),
356 + 'inactive' => __('Inactive', 'fluent-cart'),
357 + ];
255 358
256 359 return apply_filters('fluent_cart/editable_customer_statuses', $statuses, []);
257 360 }
258 361
@@ -257,16 +360,14 @@
257 360 }
258 361
259 362 public static function getShippingStatuses()
260 363 {
261 - $statuses = apply_filters_deprecated('fluent-cart/shipping_statuses', [
262 - [
263 - 'unshipped' => __('Unshipped', 'fluent-cart'),
264 - 'shipped' => __('Shipped', 'fluent-cart'),
265 - 'delivered' => __('Delivered', 'fluent-cart'),
266 - 'unshippable' => __('Unshippable', 'fluent-cart'),
267 - ], []
268 - ], '1.3.16', 'fluent_cart/shipping_statuses', 'Use fluent_cart/shipping_statuses instead of fluent-cart/shipping_statuses.');
364 + $statuses = [
365 + 'unshipped' => __('Unshipped', 'fluent-cart'),
366 + 'shipped' => __('Shipped', 'fluent-cart'),
367 + 'delivered' => __('Delivered', 'fluent-cart'),
368 + 'unshippable' => __('Unshippable', 'fluent-cart'),
369 + ];
269 370
270 371 return apply_filters('fluent_cart/shipping_statuses', $statuses, []);
271 372 }
272 373
@@ -271,16 +372,14 @@
271 372 }
272 373
273 374 public static function getEditableShippingStatuses()
274 375 {
275 - $statuses = apply_filters_deprecated('fluent-cart/editable_order_statuses', [
276 - [
277 - 'unshipped' => __('Unshipped', 'fluent-cart'),
278 - 'shipped' => __('Shipped', 'fluent-cart'),
279 - 'delivered' => __('Delivered', 'fluent-cart'),
280 - 'unshippable' => __('Unshippable', 'fluent-cart'),
281 - ], []
282 - ], '1.3.16', 'fluent_cart/editable_shipping_statuses', 'Use fluent_cart/editable_shipping_statuses instead of fluent-cart/editable_order_statuses.');
376 + $statuses = [
377 + 'unshipped' => __('Unshipped', 'fluent-cart'),
378 + 'shipped' => __('Shipped', 'fluent-cart'),
379 + 'delivered' => __('Delivered', 'fluent-cart'),
380 + 'unshippable' => __('Unshippable', 'fluent-cart'),
381 + ];
283 382
284 383 return apply_filters('fluent_cart/editable_shipping_statuses', $statuses, []);
285 384 }
286 385
@@ -310,18 +409,16 @@
310 409 }
311 410
312 411 public static function getTransactionStatuses($withLabel = true)
313 412 {
314 - $statuses = apply_filters_deprecated('fluent-cart/transaction_statuses', [
315 - [
316 - 'pending' => __('Pending', 'fluent-cart'),
317 - 'paid' => __('Paid', 'fluent-cart'),
318 - 'require_capture' => __('Authorized (Require Capture)', 'fluent-cart'),
319 - 'failed' => __('Failed', 'fluent-cart'),
320 - 'refunded' => __('Refunded', 'fluent-cart'),
321 - 'active' => __('Active', 'fluent-cart'),
322 - ], []
323 - ], '1.3.16', 'fluent_cart/transaction_statuses', 'Use fluent_cart/transaction_statuses instead of fluent-cart/transaction_statuses.');
413 + $statuses = [
414 + 'pending' => __('Pending', 'fluent-cart'),
415 + 'paid' => __('Paid', 'fluent-cart'),
416 + 'require_capture' => __('Authorized (Require Capture)', 'fluent-cart'),
417 + 'failed' => __('Failed', 'fluent-cart'),
418 + 'refunded' => __('Refunded', 'fluent-cart'),
419 + 'active' => __('Active', 'fluent-cart'),
420 + ];
324 421
325 422 $statuses = apply_filters('fluent_cart/transaction_statuses', $statuses, []);
326 423
327 424 if ($withLabel) {
@@ -332,16 +429,14 @@
332 429 }
333 430
334 431 public static function getEditableTransactionStatuses($withLabel = true)
335 432 {
336 - $statuses = apply_filters_deprecated('fluent-cart/editable_transaction_statuses', [
337 - [
338 - 'pending' => __('Pending', 'fluent-cart'),
339 - 'paid' => __('Paid', 'fluent-cart'),
340 - 'failed' => __('Failed', 'fluent-cart'),
341 - 'refunded' => __('Refunded', 'fluent-cart'),
342 - ], []
343 - ], '1.3.16', 'fluent_cart/editable_transaction_statuses', 'Use fluent_cart/editable_transaction_statuses instead of fluent-cart/editable_transaction_statuses.');
433 + $statuses = [
434 + 'pending' => __('Pending', 'fluent-cart'),
435 + 'paid' => __('Paid', 'fluent-cart'),
436 + 'failed' => __('Failed', 'fluent-cart'),
437 + 'refunded' => __('Refunded', 'fluent-cart'),
438 + ];
344 439
345 440 $statuses = apply_filters('fluent_cart/editable_transaction_statuses', $statuses, []);
346 441
347 442 if ($withLabel) {
@@ -350,21 +445,8 @@
350 445
351 446 return array_keys($statuses);
352 447 }
353 448
354 - public static function loadSpoutLib()
355 - {
356 - static $loaded;
357 -
358 - if ($loaded) {
359 - return $loaded;
360 - }
361 -
362 - require_once FLUENTCART_PLUGIN_PATH . 'app/Services/Libs/Spout/Autoloader/autoload.php';
363 -
364 - return true;
365 - }
366 -
367 449 public static function productStatuses($withLabel = true): array
368 450 {
369 451 $statues = [
370 452 'publish' => __('Publish', 'fluent-cart'),
@@ -471,9 +553,9 @@
471 553 }
472 554 }
473 555 }
474 556
475 - if($withTranslatedDigit) {
557 + if ($withTranslatedDigit) {
476 558 $amount = self::translateNumber($amount);
477 559 }
478 560 }
479 561
@@ -515,8 +597,41 @@
515 597 $amount = (int)round($amount); // Round to nearest integer, then cast
516 598 return $amount;
517 599 }
518 600
601 + /**
602 + * Normalize a value that is ALREADY in cents to a whole-cent int.
603 + *
604 + * Unlike toCent(), this does not scale — use it when the incoming value is a
605 + * cents amount that may have arrived as a float. Clients computing cents in
606 + * JavaScript send artifacts like 1998.9999999999998 for 1999, and a bare
607 + * (int) cast truncates those, silently undercharging by a cent. Non-numeric
608 + * input (including the null the request pipeline injects for omitted keys)
609 + * normalizes to 0.
610 + *
611 + * Magnitudes beyond float's exact-integer range (2^53) are REJECTED, not
612 + * coerced: is_numeric() accepts exponential notation like 1e19, and casting
613 + * that float to int wraps to -8446744073709551616 — a VALID signed BIGINT —
614 + * so the corrupt value would persist silently where an un-normalized float
615 + * used to fail loudly at MySQL. No real cents amount approaches this bound.
616 + */
617 + public static function roundCent($amount): int
618 + {
619 + if (!is_numeric($amount)) {
620 + return 0;
621 + }
622 +
623 + $amount = (float) $amount;
624 +
625 + if (!is_finite($amount) || $amount > 9.0e15 || $amount < -9.0e15) {
626 + throw new \InvalidArgumentException(
627 + 'Value is out of the representable cents range: ' . var_export($amount, true)
628 + );
629 + }
630 +
631 + return (int) round($amount);
632 + }
633 +
519 634 public static function toDecimalWithoutComma($amount)
520 635 {
521 636
522 637 if (!is_numeric($amount)) {
@@ -539,10 +654,12 @@
539 654 if (!$user) {
540 655 return null;
541 656 }
542 657
658 + // Identity only — see CustomerResource::getCurrentCustomer() for why an
659 + // email match must never reach a customer row.
543 660 return Customer::query()->where('user_id', $user->ID)
544 - ->orWhere('email', $user->user_email)
661 + ->orderBy('id', 'ASC')
545 662 ->first();
546 663 }
547 664
548 665 /**
@@ -570,10 +687,9 @@
570 687 }
571 688
572 689 public static function getAvailableCurrencyList()
573 690 {
574 - $currencies = apply_filters_deprecated('fluent-cart/available_currencies', [
575 - [
691 + $currencies = [
576 692 'BDT' => [
577 693 "label" => __('Bangladeshi Taka', 'fluent-cart'),
578 694 "value" => 'BDT',
579 695 "symbol" => '৳',
@@ -587,10 +703,9 @@
587 703 "label" => __('United Kingdom', 'fluent-cart'),
588 704 "value" => 'GBP',
589 705 "symbol" => '£',
590 706 ],
591 - ], []
592 - ], '1.3.16', 'fluent_cart/available_currencies', 'Use fluent_cart/available_currencies instead of fluent-cart/available_currencies.');
707 + ];
593 708
594 709 return apply_filters('fluent_cart/available_currencies', $currencies, []);
595 710 }
596 711
@@ -707,18 +822,25 @@
707 822 }
708 823
709 824 public static function getVariationTypes($withLabel = true)
710 825 {
711 - $statues = [
712 - 'simple' => __('Simple', 'fluent-cart'),
713 - 'simple_variations' => __('Simple Variation', 'fluent-cart'),
826 + // advanced_variations is advertised in free too so the variation-type
827 + // dropdown can offer it (shown Pro-locked with a crown and an upgrade
828 + // modal while Pro is inactive). Pro re-registers the same key via the
829 + // filter below when active.
830 + $types = [
831 + 'simple' => __('Simple', 'fluent-cart'),
832 + 'simple_variations' => __('Simple Variations', 'fluent-cart'),
833 + 'advanced_variations' => __('Advanced Variations', 'fluent-cart'),
714 834 ];
715 835
836 + $types = apply_filters('fluent_cart/variation_types', $types);
837 +
716 838 if ($withLabel) {
717 - return $statues;
839 + return $types;
718 840 }
719 841
720 - return array_keys($statues);
842 + return array_keys($types);
721 843 }
722 844
723 845 public static function isValueEncrypted($raw_value)
724 846 {
@@ -930,15 +1052,13 @@
930 1052 }
931 1053
932 1054 public static function getCouponStatuses()
933 1055 {
934 - $statuses = apply_filters_deprecated('fluent-cart/coupon_statuses', [
935 - [
936 - 'active' => __('Active', 'fluent-cart'),
937 - 'expired' => __('Expired', 'fluent-cart'),
938 - 'disabled' => __('Disabled', 'fluent-cart'),
939 - ], []
940 - ], '1.3.16', 'fluent_cart/coupon_statuses', 'Use fluent_cart/coupon_statuses instead of fluent-cart/coupon_statuses.');
1056 + $statuses = [
1057 + 'active' => __('Active', 'fluent-cart'),
1058 + 'expired' => __('Expired', 'fluent-cart'),
1059 + 'disabled' => __('Disabled', 'fluent-cart'),
1060 + ];
941 1061
942 1062 return apply_filters('fluent_cart/coupon_statuses', $statuses, []);
943 1063 }
944 1064
@@ -975,9 +1095,9 @@
975 1095 $intervalOptions = static::getAvailableSubscriptionIntervalMaps();
976 1096
977 1097 // Normalize / defaults
978 1098 $trialDays = $data['trial_days'] ?? 0;
979 - $interval = (string)($data['interval'] ? $data['interval'] : 'monthly');
1099 + $interval = (string)($data['interval'] ? $data['interval'] : 'monthly');
980 1100
981 1101 $unit = '';
982 1102 if (isset($intervalOptions[$interval])) {
983 1103 $unit = $intervalOptions[$interval];
@@ -1131,9 +1251,9 @@
1131 1251 return $unit;
1132 1252 }
1133 1253 }
1134 1254
1135 - public static function generateSubscriptionInfo($otherInfo, $itemPrice): ?string
1255 + public static function generateSubscriptionInfo($otherInfo, $itemPrice, $currencyCode = null): ?string
1136 1256 {
1137 1257 // Convert to array only if it's an object
1138 1258 if (is_object($otherInfo)) {
1139 1259 $otherInfo = json_decode(json_encode($otherInfo), true);
@@ -1138,14 +1258,14 @@
1138 1258 if (is_object($otherInfo)) {
1139 1259 $otherInfo = json_decode(json_encode($otherInfo), true);
1140 1260 }
1141 1261
1142 - $price = self::toDecimal($itemPrice);
1262 + $price = self::toDecimal($itemPrice, true, $currencyCode);
1143 1263 $recurringDiscountAmount = Arr::get($otherInfo, 'recurring_discounts.amount', 0);
1144 1264
1145 1265 if ($recurringDiscountAmount) {
1146 1266 $newRecurringAmount = $itemPrice - $recurringDiscountAmount;
1147 - $price = "<del>" . $price . "</del> " . self::toDecimal($newRecurringAmount);
1267 + $price = "<del>" . $price . "</del> " . self::toDecimal($newRecurringAmount, true, $currencyCode);
1148 1268 }
1149 1269
1150 1270 $repeatInterval = Arr::get($otherInfo, 'repeat_interval', '');
1151 1271 $occurrence = (int)Arr::get($otherInfo, 'times', 0);
@@ -1155,19 +1275,19 @@
1155 1275 $intervalUnit = '';
1156 1276 if (isset($intervalOptions[$repeatInterval])) {
1157 1277 $intervalUnit = $intervalOptions[$repeatInterval];
1158 1278 } else if ($repeatInterval) {
1159 - $intervalOptions = static::getAvailableSubscriptionIntervalOptions();
1160 - foreach ($intervalOptions as $option) {
1161 - if ($option['value'] === $repeatInterval) {
1162 - $intervalUnit = strtolower($option['label']);
1163 - break;
1164 - }
1279 + $intervalOptions = static::getAvailableSubscriptionIntervalOptions();
1280 + foreach ($intervalOptions as $option) {
1281 + if ($option['value'] === $repeatInterval) {
1282 + $intervalUnit = strtolower($option['label']);
1283 + break;
1165 1284 }
1285 + }
1166 1286
1167 - if (!$intervalUnit) {
1168 - $intervalUnit = ucwords(str_replace(['_', '-'], ' ', $repeatInterval));
1169 - }
1287 + if (!$intervalUnit) {
1288 + $intervalUnit = ucwords(str_replace(['_', '-'], ' ', $repeatInterval));
1289 + }
1170 1290 }
1171 1291
1172 1292 $intervalLabel = Helper::getTranslatedIntervalUnit($intervalUnit);
1173 1293
@@ -1172,9 +1292,9 @@
1172 1292 $intervalLabel = Helper::getTranslatedIntervalUnit($intervalUnit);
1173 1293
1174 1294 $interval = $intervalUnit
1175 1295 ? sprintf(
1176 - /* translators: %s is the interval (e.g., day, week, month, quarter, half_year, year) */
1296 + /* translators: %s is the interval (e.g., day, week, month, quarter, half_year, year) */
1177 1297 __('per %s', 'fluent-cart'),
1178 1298 $intervalLabel
1179 1299 )
1180 1300 : '';
@@ -1204,10 +1324,152 @@
1204 1324
1205 1325 return !empty($otherInfo) ? $paymentInfo : null;
1206 1326 }
1207 1327
1208 - public static function generateSetupFeeInfo($otherInfo): ?string
1328 + /**
1329 + * Billing-cycle text for a subscription with a config-defined schedule
1330 + * (see SubscriptionHelper::getBillingSchedule()) — cadences the
1331 + * billing_interval enum cannot express, e.g. "$100 per 3 years on Aug 19".
1332 + * Subscriptions without a schedule keep generateSubscriptionInfo().
1333 + */
1334 + public static function generateScheduleSubscriptionInfo(array $schedule, $otherInfo, $itemPrice, $currencyCode = null): ?string
1209 1335 {
1336 + if (is_object($otherInfo)) {
1337 + $otherInfo = json_decode(json_encode($otherInfo), true);
1338 + }
1339 +
1340 + $count = max(1, (int) Arr::get($schedule, 'interval', 1));
1341 +
1342 + switch (Arr::get($schedule, 'period')) {
1343 + case 'day':
1344 + $unitLabel = _n('day', 'days', $count, 'fluent-cart');
1345 + break;
1346 + case 'week':
1347 + $unitLabel = _n('week', 'weeks', $count, 'fluent-cart');
1348 + break;
1349 + case 'month':
1350 + $unitLabel = _n('month', 'months', $count, 'fluent-cart');
1351 + break;
1352 + case 'year':
1353 + $unitLabel = _n('year', 'years', $count, 'fluent-cart');
1354 + break;
1355 + default:
1356 + return self::generateSubscriptionInfo($otherInfo, $itemPrice, $currencyCode);
1357 + }
1358 +
1359 + $price = self::toDecimal($itemPrice, true, $currencyCode);
1360 + $recurringDiscountAmount = Arr::get($otherInfo, 'recurring_discounts.amount', 0);
1361 +
1362 + if ($recurringDiscountAmount) {
1363 + $newRecurringAmount = $itemPrice - $recurringDiscountAmount;
1364 + $price = "<del>" . $price . "</del> " . self::toDecimal($newRecurringAmount, true, $currencyCode);
1365 + }
1366 +
1367 + $interval = $count === 1
1368 + ? sprintf(
1369 + /* translators: %s is the interval unit (e.g., day, week, month, year) */
1370 + __('per %s', 'fluent-cart'),
1371 + $unitLabel
1372 + )
1373 + : sprintf(
1374 + /* translators: %1$d is the count number, %2$s is the plural unit name (e.g., days, months, years) */
1375 + __('per %1$d %2$s', 'fluent-cart'),
1376 + $count,
1377 + $unitLabel
1378 + );
1379 +
1380 + if ($anchorText = self::getScheduleAnchorText($schedule['period'], Arr::get($schedule, 'anchor', []))) {
1381 + $interval .= ' ' . $anchorText;
1382 + }
1383 +
1384 + $occurrence = (int) Arr::get($otherInfo, 'times', 0);
1385 +
1386 + if (empty($occurrence)) {
1387 + return sprintf(
1388 + /* translators: %1$s is the price, %2$s is the interval, %3$s is "until cancel" text */
1389 + __('%1$s %2$s %3$s', 'fluent-cart'),
1390 + $price,
1391 + $interval,
1392 + __('until cancel', 'fluent-cart')
1393 + );
1394 + }
1395 +
1396 + return sprintf(
1397 + /* translators: %1$s is the price, %2$s is the interval, %3$s is the occurrence count, %4$s is "cycle(s)" */
1398 + __('%1$s %2$s, for %3$s %4$s', 'fluent-cart'),
1399 + $price,
1400 + $interval,
1401 + $occurrence,
1402 + _n('cycle', 'cycles', $occurrence, 'fluent-cart')
1403 + );
1404 + }
1405 +
1406 + /**
1407 + * Human-readable billing anchor, e.g. "on Friday", "on the 10th",
1408 + * "on the last day", "on Aug 19". Anchor day 31 encodes "last day of
1409 + * the month" (see SubscriptionHelper::getBillingSchedule()).
1410 + */
1411 + private static function getScheduleAnchorText(string $period, $anchor): string
1412 + {
1413 + if (!is_array($anchor) || !$anchor) {
1414 + return '';
1415 + }
1416 +
1417 + if ($period === 'week' && !empty($anchor['weekday'])) {
1418 + $weekdays = [
1419 + 1 => __('Monday', 'fluent-cart'),
1420 + 2 => __('Tuesday', 'fluent-cart'),
1421 + 3 => __('Wednesday', 'fluent-cart'),
1422 + 4 => __('Thursday', 'fluent-cart'),
1423 + 5 => __('Friday', 'fluent-cart'),
1424 + 6 => __('Saturday', 'fluent-cart'),
1425 + 7 => __('Sunday', 'fluent-cart'),
1426 + ];
1427 +
1428 + if (isset($weekdays[$anchor['weekday']])) {
1429 + /* translators: %s is a weekday name, e.g. "on Friday" */
1430 + return sprintf(__('on %s', 'fluent-cart'), $weekdays[$anchor['weekday']]);
1431 + }
1432 +
1433 + return '';
1434 + }
1435 +
1436 + if ($period === 'month' && !empty($anchor['day'])) {
1437 + $day = (int) $anchor['day'];
1438 +
1439 + if ($day === 31) {
1440 + return __('on the last day', 'fluent-cart');
1441 + }
1442 +
1443 + /* translators: %s is an ordinal day of month, e.g. "on the 10th" */
1444 + return sprintf(__('on the %s', 'fluent-cart'), gmdate('jS', gmmktime(12, 0, 0, 1, $day, 2001)));
1445 + }
1446 +
1447 + if ($period === 'year' && (!empty($anchor['day']) || !empty($anchor['month']))) {
1448 + $day = (int) Arr::get($anchor, 'day', 0);
1449 + $month = (int) Arr::get($anchor, 'month', 0);
1450 +
1451 + if ($month && $day) {
1452 + /* translators: %s is a date, e.g. "on Aug 19" */
1453 + // wp_date(), not gmdate(): gmdate() has no locale, so this month
1454 + // name stayed English inside an otherwise translated sentence.
1455 + return sprintf(__('on %s', 'fluent-cart'), wp_date('M', gmmktime(12, 0, 0, $month, 1, 2001), new \DateTimeZone('UTC')) . ' ' . $day);
1456 + }
1457 +
1458 + if ($month) {
1459 + /* translators: %s is a month name, e.g. "in August" */
1460 + return sprintf(__('in %s', 'fluent-cart'), wp_date('F', gmmktime(12, 0, 0, $month, 1, 2001), new \DateTimeZone('UTC')));
1461 + }
1462 +
1463 + /* translators: %s is an ordinal day of month, e.g. "on the 10th" */
1464 + return sprintf(__('on the %s', 'fluent-cart'), gmdate('jS', gmmktime(12, 0, 0, 1, $day, 2001)));
1465 + }
1466 +
1467 + return '';
1468 + }
1469 +
1470 + public static function generateSetupFeeInfo($otherInfo, $asArray = false)
1471 + {
1210 1472 // Convert to array if it's an object
1211 1473 if (is_object($otherInfo)) {
1212 1474 $otherInfo = json_decode(json_encode($otherInfo), true);
1213 1475 }
@@ -1222,14 +1484,33 @@
1222 1484
1223 1485
1224 1486 if ($originalSetupFee = Arr::get($otherInfo, 'original_signup_fee', 0)) {
1225 1487 if ($fee != $originalSetupFee) {
1226 - return __('Adjusted setup fee', 'fluent-cart') . CurrencySettings::getPriceHtml($fee, null, true, true);
1488 + $title = __('Adjusted setup fee', 'fluent-cart');
1489 + $formattedAmount = CurrencySettings::getPriceHtml($fee, null, true, true);
1490 +
1491 + if ($asArray) {
1492 + return [
1493 + 'signup_fee_name' => $title,
1494 + 'signup_fee' => $fee,
1495 + 'signup_fee_formatted' => $formattedAmount,
1496 + ];
1497 + }
1498 + return $title . $formattedAmount;
1227 1499 }
1228 1500 }
1229 1501
1502 + $formattedAmount = CurrencySettings::getPriceHtml($fee, null, true, true);
1503 + if ($asArray) {
1504 + return [
1505 + 'signup_fee_name' => $signupFeeName,
1506 + 'signup_fee' => $fee,
1507 + 'signup_fee_formatted' => $formattedAmount,
1508 + ];
1509 + }
1230 1510
1231 - return $signupFeeName . ' ' . CurrencySettings::getPriceHtml($fee, null, true, true);
1511 +
1512 + return $signupFeeName . ' ' . $formattedAmount;
1232 1513 }
1233 1514
1234 1515 public static function generateTrialInfo($otherInfo)
1235 1516 {
@@ -1251,9 +1532,8 @@
1251 1532
1252 1533 public static function getCountryList(): array
1253 1534 {
1254 1535 $options = App::getInstance('localization')->countriesOptions();
1255 - $options = apply_filters_deprecated('fluent-cart/util/countries', [$options, []], '1.3.16', 'fluent_cart/util/countries', 'Use fluent_cart/util/countries instead of fluent-cart/util/countries.');
1256 1536
1257 1537 return apply_filters('fluent_cart/util/countries', $options, []);
1258 1538 }
1259 1539
@@ -1500,13 +1780,13 @@
1500 1780 /**
1501 1781 * Get the current user Model.
1502 1782 * @return User|\FluentCart\Framework\Database\Orm\Builder|\FluentCart\Framework\Database\Orm\Builder[]|\FluentCart\Framework\Database\Orm\Collection|\FluentCart\Framework\Database\Orm\Model|null
1503 1783 */
1504 - public static function getCurrentUser()
1784 + public static function getCurrentUser($refresh = false)
1505 1785 {
1506 1786 static $user = false;
1507 1787
1508 - if ($user !== false) {
1788 + if (!$refresh && $user !== false) {
1509 1789 return $user;
1510 1790 }
1511 1791
1512 1792 $userId = get_current_user_id();
@@ -1597,14 +1877,14 @@
1597 1877
1598 1878 public static function humanIntervalMaps($interval = '')
1599 1879 {
1600 1880 $intervals = [
1601 - 'daily' => __('day', 'fluent-cart'),
1602 - 'weekly' => __('week', 'fluent-cart'),
1603 - 'monthly' => __('month', 'fluent-cart'),
1604 - 'quarterly' => __('quarter', 'fluent-cart'),
1881 + 'daily' => __('day', 'fluent-cart'),
1882 + 'weekly' => __('week', 'fluent-cart'),
1883 + 'monthly' => __('month', 'fluent-cart'),
1884 + 'quarterly' => __('quarter', 'fluent-cart'),
1605 1885 'half_yearly' => __('six month', 'fluent-cart'),
1606 - 'yearly' => __('year', 'fluent-cart'),
1886 + 'yearly' => __('year', 'fluent-cart'),
1607 1887 ];
1608 1888
1609 1889 return Arr::get($intervals, $interval);
1610 1890 }
@@ -1615,35 +1895,35 @@
1615 1895 public static function getAvailableSubscriptionIntervalOptions(): array
1616 1896 {
1617 1897 $intervals = [
1618 1898 [
1619 - 'label' => __('Yearly', 'fluent-cart'),
1620 - 'value' => 'yearly',
1899 + 'label' => __('Yearly', 'fluent-cart'),
1900 + 'value' => 'yearly',
1621 1901 'map_value' => 'year',
1622 1902 ],
1623 1903 [
1624 - 'label' => __('Half Yearly', 'fluent-cart'),
1625 - 'value' => 'half_yearly',
1904 + 'label' => __('Half Yearly', 'fluent-cart'),
1905 + 'value' => 'half_yearly',
1626 1906 'map_value' => 'half_year',
1627 1907 ],
1628 1908 [
1629 - 'label' => __('Quarterly', 'fluent-cart'),
1630 - 'value' => 'quarterly',
1909 + 'label' => __('Quarterly', 'fluent-cart'),
1910 + 'value' => 'quarterly',
1631 1911 'map_value' => 'quarter',
1632 1912 ],
1633 1913 [
1634 - 'label' => __('Monthly', 'fluent-cart'),
1635 - 'value' => 'monthly',
1914 + 'label' => __('Monthly', 'fluent-cart'),
1915 + 'value' => 'monthly',
1636 1916 'map_value' => 'month',
1637 1917 ],
1638 1918 [
1639 - 'label' => __('Weekly', 'fluent-cart'),
1640 - 'value' => 'weekly',
1919 + 'label' => __('Weekly', 'fluent-cart'),
1920 + 'value' => 'weekly',
1641 1921 'map_value' => 'week',
1642 1922 ],
1643 1923 [
1644 - 'label' => __('Daily', 'fluent-cart'),
1645 - 'value' => 'daily',
1924 + 'label' => __('Daily', 'fluent-cart'),
1925 + 'value' => 'daily',
1646 1926 'map_value' => 'day',
1647 1927 ]
1648 1928 ];
1649 1929
@@ -1683,10 +1963,10 @@
1683 1963 $intervalInDays = static::subscriptionIntervalInDays($repeatInterval);
1684 1964
1685 1965 $maxTrialDaysAllowed = apply_filters('fluent_cart/max_trial_days_allowed', 365, [
1686 1966 'existing_trial_days' => $trialDays,
1687 - 'repeat_interval' => $repeatInterval,
1688 - 'interval_in_days' => $intervalInDays,
1967 + 'repeat_interval' => $repeatInterval,
1968 + 'interval_in_days' => $intervalInDays,
1689 1969 ]);
1690 1970
1691 1971 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
1692 1972
@@ -1693,26 +1973,9 @@
1693 1973 }
1694 1974
1695 1975 public static function subscriptionIntervalInDays($interval)
1696 1976 {
1697 - switch ($interval) {
1698 - case 'daily':
1699 - return 1;
1700 - case 'weekly':
1701 - return 7;
1702 - case 'monthly':
1703 - return 30;
1704 - case 'quarterly':
1705 - return 90;
1706 - case 'half_yearly':
1707 - return 182;
1708 - case 'yearly':
1709 - return 365;
1710 - default:
1711 - return apply_filters('fluent_cart/subscription_interval_in_days', 0, [
1712 - 'interval' => $interval,
1713 - ]);
1714 - }
1977 + return PaymentHelper::getIntervalDays($interval);
1715 1978 }
1716 1979
1717 1980 public static function parseTermIdsForFilter($filters): array
1718 1981 {
@@ -1767,12 +2030,12 @@
1767 2030 {
1768 2031 $allChildVariants = Arr::pluck($variants, 'other_info.bundle_child_ids');
1769 2032
1770 2033 $allChildVariants = array_unique(Arr::flatten($allChildVariants));
1771 - $allChildVariants = Arr::except(
2034 + $allChildVariants = array_values(array_diff(
1772 2035 $allChildVariants,
1773 2036 Arr::pluck($variants, 'id')
1774 - );
2037 + ));
1775 2038 $allChildVariants = array_filter($allChildVariants);
1776 2039 $childVariants = ProductVariation::query()
1777 2040 ->whereIn('id', $allChildVariants)
1778 2041 ->with('product:ID,post_title')
@@ -1825,10 +2088,10 @@
1825 2088 $digits = explode('_', $numericSystem);
1826 2089
1827 2090 return strtr(
1828 2091 (string)$number,
1829 - array_combine(range(0,9),
1830 - $digits)
2092 + array_combine(range(0, 9),
2093 + $digits)
1831 2094 );
1832 2095 }
1833 2096
1834 2097 public static function isModalCheckoutEnabled(): bool
@@ -1867,6 +2130,66 @@
1867 2130 }
1868 2131 }
1869 2132
1870 2133 return $default;
2134 + }
2135 +
2136 + public static function formatTaxRatePercent(float $rate): string
2137 + {
2138 + $formatted = number_format($rate, 4, '.', '');
2139 + if (strpos($formatted, '.') !== false) {
2140 + $formatted = rtrim($formatted, '0');
2141 + $formatted = rtrim($formatted, '.');
2142 + }
2143 + return $formatted;
2144 + }
2145 +
2146 + /**
2147 + * Returns the tax label for order-level tax rows (tax_total, not per-item).
2148 + * For mixed orders, indicates tax varies per item.
2149 + *
2150 + * @param \FluentCart\App\Models\Order $order
2151 + * @return string
2152 + */
2153 + public static function getOrderTaxLabel($order) {
2154 + if ((int) $order->tax_behavior === 3) {
2155 + return esc_html__('(Varies)', 'fluent-cart');
2156 + }
2157 + return (int) $order->tax_behavior === 2
2158 + ? esc_html__('(Included)', 'fluent-cart')
2159 + : esc_html__('(Excluded)', 'fluent-cart');
2160 + }
2161 +
2162 + /**
2163 + * Product placeholder illustration (an open box holding a picture) shown
2164 + * wherever a product has no image. The web copy is a 1200px WebP (sharp on 2x screens); email
2165 + * clients (Outlook) and PDF renderers get a 300px PNG of the same art.
2166 + */
2167 + const PRODUCT_PLACEHOLDER_IMAGE = 'images/product-placeholder.webp';
2168 + const PRODUCT_PLACEHOLDER_EMAIL_IMAGE = 'images/product-placeholder.png';
2169 +
2170 + public static function getProductPlaceholderUrl(): string
2171 + {
2172 + return \FluentCart\App\Vite::getAssetUrl(static::PRODUCT_PLACEHOLDER_IMAGE);
2173 + }
2174 +
2175 + public static function getProductPlaceholderEmailUrl(): string
2176 + {
2177 + return \FluentCart\App\Vite::getAssetUrl(static::PRODUCT_PLACEHOLDER_EMAIL_IMAGE);
2178 + }
2179 +
2180 + /**
2181 + * A product image URL, or the placeholder when there is none. Carts store
2182 + * the thumbnail URL in cart_data, so carts built before the illustration
2183 + * still carry the old generic icon (images/placeholder.svg); map that too.
2184 + */
2185 + public static function productImageOrPlaceholder($url): string
2186 + {
2187 + $url = is_string($url) ? $url : '';
2188 +
2189 + if ($url === '' || preg_match('#/images/placeholder\.svg(\?.*)?$#', $url)) {
2190 + return static::getProductPlaceholderUrl();
2191 + }
2192 +
2193 + return $url;
1871 2194 }
1872 2195 }