PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.6
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.6
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 1.3.19 All 49 releases
← All changes | app/Helpers/Helper.php +396 -150 1.3.26 → 1.6.6 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,33 @@
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 + 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 +
36 62 public static function getUidSerial()
37 63 {
38 64 static $id = 0;
39 65
@@ -42,8 +68,46 @@
42 68 return $id;
43 69
44 70 }
45 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 +
46 110 public static function getRestInfo()
47 111 {
48 112 $app = App::getInstance();
49 113
@@ -134,9 +198,9 @@
134 198 */
135 199 public static function convertWeight($value, $fromUnit, $toUnit)
136 200 {
137 201 if ($fromUnit === $toUnit || !$value) {
138 - return (float) $value;
202 + return (float)$value;
139 203 }
140 204
141 205 $toGrams = [
142 206 'g' => 1,
@@ -215,17 +279,15 @@
215 279 }
216 280
217 281 public static function getOrderStatuses()
218 282 {
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.');
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 + ];
228 290
229 291 return apply_filters('fluent_cart/order_statuses', $statuses, []);
230 292 }
231 293
@@ -230,17 +292,15 @@
230 292 }
231 293
232 294 public static function getEditableOrderStatuses()
233 295 {
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.');
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 + ];
243 303
244 304 return apply_filters('fluent_cart/editable_order_statuses', $statuses, []);
245 305 }
246 306
@@ -245,14 +305,12 @@
245 305 }
246 306
247 307 public static function getEditableCustomerStatuses()
248 308 {
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.');
309 + $statuses = [
310 + 'active' => __('Active', 'fluent-cart'),
311 + 'inactive' => __('Inactive', 'fluent-cart'),
312 + ];
255 313
256 314 return apply_filters('fluent_cart/editable_customer_statuses', $statuses, []);
257 315 }
258 316
@@ -257,16 +315,14 @@
257 315 }
258 316
259 317 public static function getShippingStatuses()
260 318 {
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.');
319 + $statuses = [
320 + 'unshipped' => __('Unshipped', 'fluent-cart'),
321 + 'shipped' => __('Shipped', 'fluent-cart'),
322 + 'delivered' => __('Delivered', 'fluent-cart'),
323 + 'unshippable' => __('Unshippable', 'fluent-cart'),
324 + ];
269 325
270 326 return apply_filters('fluent_cart/shipping_statuses', $statuses, []);
271 327 }
272 328
@@ -271,16 +327,14 @@
271 327 }
272 328
273 329 public static function getEditableShippingStatuses()
274 330 {
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.');
331 + $statuses = [
332 + 'unshipped' => __('Unshipped', 'fluent-cart'),
333 + 'shipped' => __('Shipped', 'fluent-cart'),
334 + 'delivered' => __('Delivered', 'fluent-cart'),
335 + 'unshippable' => __('Unshippable', 'fluent-cart'),
336 + ];
283 337
284 338 return apply_filters('fluent_cart/editable_shipping_statuses', $statuses, []);
285 339 }
286 340
@@ -310,18 +364,16 @@
310 364 }
311 365
312 366 public static function getTransactionStatuses($withLabel = true)
313 367 {
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.');
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 + ];
324 376
325 377 $statuses = apply_filters('fluent_cart/transaction_statuses', $statuses, []);
326 378
327 379 if ($withLabel) {
@@ -332,16 +384,14 @@
332 384 }
333 385
334 386 public static function getEditableTransactionStatuses($withLabel = true)
335 387 {
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.');
388 + $statuses = [
389 + 'pending' => __('Pending', 'fluent-cart'),
390 + 'paid' => __('Paid', 'fluent-cart'),
391 + 'failed' => __('Failed', 'fluent-cart'),
392 + 'refunded' => __('Refunded', 'fluent-cart'),
393 + ];
344 394
345 395 $statuses = apply_filters('fluent_cart/editable_transaction_statuses', $statuses, []);
346 396
347 397 if ($withLabel) {
@@ -350,21 +400,8 @@
350 400
351 401 return array_keys($statuses);
352 402 }
353 403
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 404 public static function productStatuses($withLabel = true): array
368 405 {
369 406 $statues = [
370 407 'publish' => __('Publish', 'fluent-cart'),
@@ -471,9 +508,9 @@
471 508 }
472 509 }
473 510 }
474 511
475 - if($withTranslatedDigit) {
512 + if ($withTranslatedDigit) {
476 513 $amount = self::translateNumber($amount);
477 514 }
478 515 }
479 516
@@ -515,8 +552,41 @@
515 552 $amount = (int)round($amount); // Round to nearest integer, then cast
516 553 return $amount;
517 554 }
518 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 +
519 589 public static function toDecimalWithoutComma($amount)
520 590 {
521 591
522 592 if (!is_numeric($amount)) {
@@ -539,10 +609,12 @@
539 609 if (!$user) {
540 610 return null;
541 611 }
542 612
613 + // Identity only — see CustomerResource::getCurrentCustomer() for why an
614 + // email match must never reach a customer row.
543 615 return Customer::query()->where('user_id', $user->ID)
544 - ->orWhere('email', $user->user_email)
616 + ->orderBy('id', 'ASC')
545 617 ->first();
546 618 }
547 619
548 620 /**
@@ -570,10 +642,9 @@
570 642 }
571 643
572 644 public static function getAvailableCurrencyList()
573 645 {
574 - $currencies = apply_filters_deprecated('fluent-cart/available_currencies', [
575 - [
646 + $currencies = [
576 647 'BDT' => [
577 648 "label" => __('Bangladeshi Taka', 'fluent-cart'),
578 649 "value" => 'BDT',
579 650 "symbol" => '৳',
@@ -587,10 +658,9 @@
587 658 "label" => __('United Kingdom', 'fluent-cart'),
588 659 "value" => 'GBP',
589 660 "symbol" => '£',
590 661 ],
591 - ], []
592 - ], '1.3.16', 'fluent_cart/available_currencies', 'Use fluent_cart/available_currencies instead of fluent-cart/available_currencies.');
662 + ];
593 663
594 664 return apply_filters('fluent_cart/available_currencies', $currencies, []);
595 665 }
596 666
@@ -707,18 +777,25 @@
707 777 }
708 778
709 779 public static function getVariationTypes($withLabel = true)
710 780 {
711 - $statues = [
712 - 'simple' => __('Simple', 'fluent-cart'),
713 - 'simple_variations' => __('Simple Variation', 'fluent-cart'),
781 + // advanced_variations is advertised in free too so the variation-type
782 + // dropdown can offer it (shown Pro-locked with a crown and an upgrade
783 + // modal while Pro is inactive). Pro re-registers the same key via the
784 + // filter below when active.
785 + $types = [
786 + 'simple' => __('Simple', 'fluent-cart'),
787 + 'simple_variations' => __('Simple Variations', 'fluent-cart'),
788 + 'advanced_variations' => __('Advanced Variations', 'fluent-cart'),
714 789 ];
715 790
791 + $types = apply_filters('fluent_cart/variation_types', $types);
792 +
716 793 if ($withLabel) {
717 - return $statues;
794 + return $types;
718 795 }
719 796
720 - return array_keys($statues);
797 + return array_keys($types);
721 798 }
722 799
723 800 public static function isValueEncrypted($raw_value)
724 801 {
@@ -930,15 +1007,13 @@
930 1007 }
931 1008
932 1009 public static function getCouponStatuses()
933 1010 {
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.');
1011 + $statuses = [
1012 + 'active' => __('Active', 'fluent-cart'),
1013 + 'expired' => __('Expired', 'fluent-cart'),
1014 + 'disabled' => __('Disabled', 'fluent-cart'),
1015 + ];
941 1016
942 1017 return apply_filters('fluent_cart/coupon_statuses', $statuses, []);
943 1018 }
944 1019
@@ -975,9 +1050,9 @@
975 1050 $intervalOptions = static::getAvailableSubscriptionIntervalMaps();
976 1051
977 1052 // Normalize / defaults
978 1053 $trialDays = $data['trial_days'] ?? 0;
979 - $interval = (string)($data['interval'] ? $data['interval'] : 'monthly');
1054 + $interval = (string)($data['interval'] ? $data['interval'] : 'monthly');
980 1055
981 1056 $unit = '';
982 1057 if (isset($intervalOptions[$interval])) {
983 1058 $unit = $intervalOptions[$interval];
@@ -1131,9 +1206,9 @@
1131 1206 return $unit;
1132 1207 }
1133 1208 }
1134 1209
1135 - public static function generateSubscriptionInfo($otherInfo, $itemPrice): ?string
1210 + public static function generateSubscriptionInfo($otherInfo, $itemPrice, $currencyCode = null): ?string
1136 1211 {
1137 1212 // Convert to array only if it's an object
1138 1213 if (is_object($otherInfo)) {
1139 1214 $otherInfo = json_decode(json_encode($otherInfo), true);
@@ -1138,14 +1213,14 @@
1138 1213 if (is_object($otherInfo)) {
1139 1214 $otherInfo = json_decode(json_encode($otherInfo), true);
1140 1215 }
1141 1216
1142 - $price = self::toDecimal($itemPrice);
1217 + $price = self::toDecimal($itemPrice, true, $currencyCode);
1143 1218 $recurringDiscountAmount = Arr::get($otherInfo, 'recurring_discounts.amount', 0);
1144 1219
1145 1220 if ($recurringDiscountAmount) {
1146 1221 $newRecurringAmount = $itemPrice - $recurringDiscountAmount;
1147 - $price = "<del>" . $price . "</del> " . self::toDecimal($newRecurringAmount);
1222 + $price = "<del>" . $price . "</del> " . self::toDecimal($newRecurringAmount, true, $currencyCode);
1148 1223 }
1149 1224
1150 1225 $repeatInterval = Arr::get($otherInfo, 'repeat_interval', '');
1151 1226 $occurrence = (int)Arr::get($otherInfo, 'times', 0);
@@ -1155,19 +1230,19 @@
1155 1230 $intervalUnit = '';
1156 1231 if (isset($intervalOptions[$repeatInterval])) {
1157 1232 $intervalUnit = $intervalOptions[$repeatInterval];
1158 1233 } 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 - }
1234 + $intervalOptions = static::getAvailableSubscriptionIntervalOptions();
1235 + foreach ($intervalOptions as $option) {
1236 + if ($option['value'] === $repeatInterval) {
1237 + $intervalUnit = strtolower($option['label']);
1238 + break;
1165 1239 }
1240 + }
1166 1241
1167 - if (!$intervalUnit) {
1168 - $intervalUnit = ucwords(str_replace(['_', '-'], ' ', $repeatInterval));
1169 - }
1242 + if (!$intervalUnit) {
1243 + $intervalUnit = ucwords(str_replace(['_', '-'], ' ', $repeatInterval));
1244 + }
1170 1245 }
1171 1246
1172 1247 $intervalLabel = Helper::getTranslatedIntervalUnit($intervalUnit);
1173 1248
@@ -1172,9 +1247,9 @@
1172 1247 $intervalLabel = Helper::getTranslatedIntervalUnit($intervalUnit);
1173 1248
1174 1249 $interval = $intervalUnit
1175 1250 ? sprintf(
1176 - /* translators: %s is the interval (e.g., day, week, month, quarter, half_year, year) */
1251 + /* translators: %s is the interval (e.g., day, week, month, quarter, half_year, year) */
1177 1252 __('per %s', 'fluent-cart'),
1178 1253 $intervalLabel
1179 1254 )
1180 1255 : '';
@@ -1204,10 +1279,152 @@
1204 1279
1205 1280 return !empty($otherInfo) ? $paymentInfo : null;
1206 1281 }
1207 1282
1208 - public static function generateSetupFeeInfo($otherInfo): ?string
1283 + /**
1284 + * Billing-cycle text for a subscription with a config-defined schedule
1285 + * (see SubscriptionHelper::getBillingSchedule()) — cadences the
1286 + * billing_interval enum cannot express, e.g. "$100 per 3 years on Aug 19".
1287 + * Subscriptions without a schedule keep generateSubscriptionInfo().
1288 + */
1289 + public static function generateScheduleSubscriptionInfo(array $schedule, $otherInfo, $itemPrice, $currencyCode = null): ?string
1209 1290 {
1291 + if (is_object($otherInfo)) {
1292 + $otherInfo = json_decode(json_encode($otherInfo), true);
1293 + }
1294 +
1295 + $count = max(1, (int) Arr::get($schedule, 'interval', 1));
1296 +
1297 + switch (Arr::get($schedule, 'period')) {
1298 + case 'day':
1299 + $unitLabel = _n('day', 'days', $count, 'fluent-cart');
1300 + break;
1301 + case 'week':
1302 + $unitLabel = _n('week', 'weeks', $count, 'fluent-cart');
1303 + break;
1304 + case 'month':
1305 + $unitLabel = _n('month', 'months', $count, 'fluent-cart');
1306 + break;
1307 + case 'year':
1308 + $unitLabel = _n('year', 'years', $count, 'fluent-cart');
1309 + break;
1310 + default:
1311 + return self::generateSubscriptionInfo($otherInfo, $itemPrice, $currencyCode);
1312 + }
1313 +
1314 + $price = self::toDecimal($itemPrice, true, $currencyCode);
1315 + $recurringDiscountAmount = Arr::get($otherInfo, 'recurring_discounts.amount', 0);
1316 +
1317 + if ($recurringDiscountAmount) {
1318 + $newRecurringAmount = $itemPrice - $recurringDiscountAmount;
1319 + $price = "<del>" . $price . "</del> " . self::toDecimal($newRecurringAmount, true, $currencyCode);
1320 + }
1321 +
1322 + $interval = $count === 1
1323 + ? sprintf(
1324 + /* translators: %s is the interval unit (e.g., day, week, month, year) */
1325 + __('per %s', 'fluent-cart'),
1326 + $unitLabel
1327 + )
1328 + : sprintf(
1329 + /* translators: %1$d is the count number, %2$s is the plural unit name (e.g., days, months, years) */
1330 + __('per %1$d %2$s', 'fluent-cart'),
1331 + $count,
1332 + $unitLabel
1333 + );
1334 +
1335 + if ($anchorText = self::getScheduleAnchorText($schedule['period'], Arr::get($schedule, 'anchor', []))) {
1336 + $interval .= ' ' . $anchorText;
1337 + }
1338 +
1339 + $occurrence = (int) Arr::get($otherInfo, 'times', 0);
1340 +
1341 + if (empty($occurrence)) {
1342 + return sprintf(
1343 + /* translators: %1$s is the price, %2$s is the interval, %3$s is "until cancel" text */
1344 + __('%1$s %2$s %3$s', 'fluent-cart'),
1345 + $price,
1346 + $interval,
1347 + __('until cancel', 'fluent-cart')
1348 + );
1349 + }
1350 +
1351 + return sprintf(
1352 + /* translators: %1$s is the price, %2$s is the interval, %3$s is the occurrence count, %4$s is "cycle(s)" */
1353 + __('%1$s %2$s, for %3$s %4$s', 'fluent-cart'),
1354 + $price,
1355 + $interval,
1356 + $occurrence,
1357 + _n('cycle', 'cycles', $occurrence, 'fluent-cart')
1358 + );
1359 + }
1360 +
1361 + /**
1362 + * Human-readable billing anchor, e.g. "on Friday", "on the 10th",
1363 + * "on the last day", "on Aug 19". Anchor day 31 encodes "last day of
1364 + * the month" (see SubscriptionHelper::getBillingSchedule()).
1365 + */
1366 + private static function getScheduleAnchorText(string $period, $anchor): string
1367 + {
1368 + if (!is_array($anchor) || !$anchor) {
1369 + return '';
1370 + }
1371 +
1372 + if ($period === 'week' && !empty($anchor['weekday'])) {
1373 + $weekdays = [
1374 + 1 => __('Monday', 'fluent-cart'),
1375 + 2 => __('Tuesday', 'fluent-cart'),
1376 + 3 => __('Wednesday', 'fluent-cart'),
1377 + 4 => __('Thursday', 'fluent-cart'),
1378 + 5 => __('Friday', 'fluent-cart'),
1379 + 6 => __('Saturday', 'fluent-cart'),
1380 + 7 => __('Sunday', 'fluent-cart'),
1381 + ];
1382 +
1383 + if (isset($weekdays[$anchor['weekday']])) {
1384 + /* translators: %s is a weekday name, e.g. "on Friday" */
1385 + return sprintf(__('on %s', 'fluent-cart'), $weekdays[$anchor['weekday']]);
1386 + }
1387 +
1388 + return '';
1389 + }
1390 +
1391 + if ($period === 'month' && !empty($anchor['day'])) {
1392 + $day = (int) $anchor['day'];
1393 +
1394 + if ($day === 31) {
1395 + return __('on the last day', 'fluent-cart');
1396 + }
1397 +
1398 + /* translators: %s is an ordinal day of month, e.g. "on the 10th" */
1399 + return sprintf(__('on the %s', 'fluent-cart'), gmdate('jS', gmmktime(12, 0, 0, 1, $day, 2001)));
1400 + }
1401 +
1402 + if ($period === 'year' && (!empty($anchor['day']) || !empty($anchor['month']))) {
1403 + $day = (int) Arr::get($anchor, 'day', 0);
1404 + $month = (int) Arr::get($anchor, 'month', 0);
1405 +
1406 + if ($month && $day) {
1407 + /* translators: %s is a date, e.g. "on Aug 19" */
1408 + // wp_date(), not gmdate(): gmdate() has no locale, so this month
1409 + // name stayed English inside an otherwise translated sentence.
1410 + return sprintf(__('on %s', 'fluent-cart'), wp_date('M', gmmktime(12, 0, 0, $month, 1, 2001), new \DateTimeZone('UTC')) . ' ' . $day);
1411 + }
1412 +
1413 + if ($month) {
1414 + /* translators: %s is a month name, e.g. "in August" */
1415 + return sprintf(__('in %s', 'fluent-cart'), wp_date('F', gmmktime(12, 0, 0, $month, 1, 2001), new \DateTimeZone('UTC')));
1416 + }
1417 +
1418 + /* translators: %s is an ordinal day of month, e.g. "on the 10th" */
1419 + return sprintf(__('on the %s', 'fluent-cart'), gmdate('jS', gmmktime(12, 0, 0, 1, $day, 2001)));
1420 + }
1421 +
1422 + return '';
1423 + }
1424 +
1425 + public static function generateSetupFeeInfo($otherInfo, $asArray = false)
1426 + {
1210 1427 // Convert to array if it's an object
1211 1428 if (is_object($otherInfo)) {
1212 1429 $otherInfo = json_decode(json_encode($otherInfo), true);
1213 1430 }
@@ -1222,14 +1439,33 @@
1222 1439
1223 1440
1224 1441 if ($originalSetupFee = Arr::get($otherInfo, 'original_signup_fee', 0)) {
1225 1442 if ($fee != $originalSetupFee) {
1226 - return __('Adjusted setup fee', 'fluent-cart') . CurrencySettings::getPriceHtml($fee, null, true, true);
1443 + $title = __('Adjusted setup fee', 'fluent-cart');
1444 + $formattedAmount = CurrencySettings::getPriceHtml($fee, null, true, true);
1445 +
1446 + if ($asArray) {
1447 + return [
1448 + 'signup_fee_name' => $title,
1449 + 'signup_fee' => $fee,
1450 + 'signup_fee_formatted' => $formattedAmount,
1451 + ];
1452 + }
1453 + return $title . $formattedAmount;
1227 1454 }
1228 1455 }
1229 1456
1457 + $formattedAmount = CurrencySettings::getPriceHtml($fee, null, true, true);
1458 + if ($asArray) {
1459 + return [
1460 + 'signup_fee_name' => $signupFeeName,
1461 + 'signup_fee' => $fee,
1462 + 'signup_fee_formatted' => $formattedAmount,
1463 + ];
1464 + }
1230 1465
1231 - return $signupFeeName . ' ' . CurrencySettings::getPriceHtml($fee, null, true, true);
1466 +
1467 + return $signupFeeName . ' ' . $formattedAmount;
1232 1468 }
1233 1469
1234 1470 public static function generateTrialInfo($otherInfo)
1235 1471 {
@@ -1251,9 +1487,8 @@
1251 1487
1252 1488 public static function getCountryList(): array
1253 1489 {
1254 1490 $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 1491
1257 1492 return apply_filters('fluent_cart/util/countries', $options, []);
1258 1493 }
1259 1494
@@ -1500,13 +1735,13 @@
1500 1735 /**
1501 1736 * Get the current user Model.
1502 1737 * @return User|\FluentCart\Framework\Database\Orm\Builder|\FluentCart\Framework\Database\Orm\Builder[]|\FluentCart\Framework\Database\Orm\Collection|\FluentCart\Framework\Database\Orm\Model|null
1503 1738 */
1504 - public static function getCurrentUser()
1739 + public static function getCurrentUser($refresh = false)
1505 1740 {
1506 1741 static $user = false;
1507 1742
1508 - if ($user !== false) {
1743 + if (!$refresh && $user !== false) {
1509 1744 return $user;
1510 1745 }
1511 1746
1512 1747 $userId = get_current_user_id();
@@ -1597,12 +1832,14 @@
1597 1832
1598 1833 public static function humanIntervalMaps($interval = '')
1599 1834 {
1600 1835 $intervals = [
1601 - 'daily' => 'day',
1602 - 'weekly' => 'week',
1603 - 'monthly' => 'month',
1604 - 'yearly' => 'year'
1836 + 'daily' => __('day', 'fluent-cart'),
1837 + 'weekly' => __('week', 'fluent-cart'),
1838 + 'monthly' => __('month', 'fluent-cart'),
1839 + 'quarterly' => __('quarter', 'fluent-cart'),
1840 + 'half_yearly' => __('six month', 'fluent-cart'),
1841 + 'yearly' => __('year', 'fluent-cart'),
1605 1842 ];
1606 1843
1607 1844 return Arr::get($intervals, $interval);
1608 1845 }
@@ -1613,35 +1850,35 @@
1613 1850 public static function getAvailableSubscriptionIntervalOptions(): array
1614 1851 {
1615 1852 $intervals = [
1616 1853 [
1617 - 'label' => __('Yearly', 'fluent-cart'),
1618 - 'value' => 'yearly',
1854 + 'label' => __('Yearly', 'fluent-cart'),
1855 + 'value' => 'yearly',
1619 1856 'map_value' => 'year',
1620 1857 ],
1621 1858 [
1622 - 'label' => __('Half Yearly', 'fluent-cart'),
1623 - 'value' => 'half_yearly',
1859 + 'label' => __('Half Yearly', 'fluent-cart'),
1860 + 'value' => 'half_yearly',
1624 1861 'map_value' => 'half_year',
1625 1862 ],
1626 1863 [
1627 - 'label' => __('Quarterly', 'fluent-cart'),
1628 - 'value' => 'quarterly',
1864 + 'label' => __('Quarterly', 'fluent-cart'),
1865 + 'value' => 'quarterly',
1629 1866 'map_value' => 'quarter',
1630 1867 ],
1631 1868 [
1632 - 'label' => __('Monthly', 'fluent-cart'),
1633 - 'value' => 'monthly',
1869 + 'label' => __('Monthly', 'fluent-cart'),
1870 + 'value' => 'monthly',
1634 1871 'map_value' => 'month',
1635 1872 ],
1636 1873 [
1637 - 'label' => __('Weekly', 'fluent-cart'),
1638 - 'value' => 'weekly',
1874 + 'label' => __('Weekly', 'fluent-cart'),
1875 + 'value' => 'weekly',
1639 1876 'map_value' => 'week',
1640 1877 ],
1641 1878 [
1642 - 'label' => __('Daily', 'fluent-cart'),
1643 - 'value' => 'daily',
1879 + 'label' => __('Daily', 'fluent-cart'),
1880 + 'value' => 'daily',
1644 1881 'map_value' => 'day',
1645 1882 ]
1646 1883 ];
1647 1884
@@ -1681,10 +1918,10 @@
1681 1918 $intervalInDays = static::subscriptionIntervalInDays($repeatInterval);
1682 1919
1683 1920 $maxTrialDaysAllowed = apply_filters('fluent_cart/max_trial_days_allowed', 365, [
1684 1921 'existing_trial_days' => $trialDays,
1685 - 'repeat_interval' => $repeatInterval,
1686 - 'interval_in_days' => $intervalInDays,
1922 + 'repeat_interval' => $repeatInterval,
1923 + 'interval_in_days' => $intervalInDays,
1687 1924 ]);
1688 1925
1689 1926 return min($trialDays + $intervalInDays, $maxTrialDaysAllowed); // return the minimum of the sum of the existing trial days and the interval days, and the max trial allowed
1690 1927
@@ -1691,26 +1928,9 @@
1691 1928 }
1692 1929
1693 1930 public static function subscriptionIntervalInDays($interval)
1694 1931 {
1695 - switch ($interval) {
1696 - case 'daily':
1697 - return 1;
1698 - case 'weekly':
1699 - return 7;
1700 - case 'monthly':
1701 - return 30;
1702 - case 'quarterly':
1703 - return 90;
1704 - case 'half_yearly':
1705 - return 182;
1706 - case 'yearly':
1707 - return 365;
1708 - default:
1709 - return apply_filters('fluent_cart/subscription_interval_in_days', 0, [
1710 - 'interval' => $interval,
1711 - ]);
1712 - }
1932 + return PaymentHelper::getIntervalDays($interval);
1713 1933 }
1714 1934
1715 1935 public static function parseTermIdsForFilter($filters): array
1716 1936 {
@@ -1765,12 +1985,12 @@
1765 1985 {
1766 1986 $allChildVariants = Arr::pluck($variants, 'other_info.bundle_child_ids');
1767 1987
1768 1988 $allChildVariants = array_unique(Arr::flatten($allChildVariants));
1769 - $allChildVariants = Arr::except(
1989 + $allChildVariants = array_values(array_diff(
1770 1990 $allChildVariants,
1771 1991 Arr::pluck($variants, 'id')
1772 - );
1992 + ));
1773 1993 $allChildVariants = array_filter($allChildVariants);
1774 1994 $childVariants = ProductVariation::query()
1775 1995 ->whereIn('id', $allChildVariants)
1776 1996 ->with('product:ID,post_title')
@@ -1823,10 +2043,10 @@
1823 2043 $digits = explode('_', $numericSystem);
1824 2044
1825 2045 return strtr(
1826 2046 (string)$number,
1827 - array_combine(range(0,9),
1828 - $digits)
2047 + array_combine(range(0, 9),
2048 + $digits)
1829 2049 );
1830 2050 }
1831 2051
1832 2052 public static function isModalCheckoutEnabled(): bool
@@ -1865,6 +2085,32 @@
1865 2085 }
1866 2086 }
1867 2087
1868 2088 return $default;
2089 + }
2090 +
2091 + public static function formatTaxRatePercent(float $rate): string
2092 + {
2093 + $formatted = number_format($rate, 4, '.', '');
2094 + if (strpos($formatted, '.') !== false) {
2095 + $formatted = rtrim($formatted, '0');
2096 + $formatted = rtrim($formatted, '.');
2097 + }
2098 + return $formatted;
2099 + }
2100 +
2101 + /**
2102 + * Returns the tax label for order-level tax rows (tax_total, not per-item).
2103 + * For mixed orders, indicates tax varies per item.
2104 + *
2105 + * @param \FluentCart\App\Models\Order $order
2106 + * @return string
2107 + */
2108 + public static function getOrderTaxLabel($order) {
2109 + if ((int) $order->tax_behavior === 3) {
2110 + return esc_html__('(Varies)', 'fluent-cart');
2111 + }
2112 + return (int) $order->tax_behavior === 2
2113 + ? esc_html__('(Included)', 'fluent-cart')
2114 + : esc_html__('(Excluded)', 'fluent-cart');
1869 2115 }
1870 2116 }