| @@ -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 | |
| @@ -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. It will be removed in v1.4.3.'); | |
| 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. It will be removed in v1.4.3.'); | |
| 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. It will be removed in v1.4.3.'); | |
| 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. It will be removed in v1.4.3.'); | |
| 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. It will be removed in v1.4.3.'); | |
| 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. It will be removed in v1.4.3.'); | |
| 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. It will be removed in v1.4.3.'); | |
| 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'), |
| @@ -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,27 +687,25 @@ | ||
| 570 | 687 | } |
| 571 | 688 | |
| 572 | 689 | public static function getAvailableCurrencyList() |
| 573 | 690 | { |
| 574 | - $currencies = apply_filters_deprecated('fluent-cart/available_currencies', [ | |
| 575 | - [ | |
| 576 | - 'BDT' => [ | |
| 577 | - "label" => __('Bangladeshi Taka', 'fluent-cart'), | |
| 578 | - "value" => 'BDT', | |
| 579 | - "symbol" => '৳', | |
| 580 | - ], | |
| 581 | - 'USD' => [ | |
| 582 | - "label" => __('United State Dollar', 'fluent-cart'), | |
| 583 | - "value" => 'USD', | |
| 584 | - "symbol" => '$', | |
| 585 | - ], | |
| 586 | - 'GBP' => [ | |
| 587 | - "label" => __('United Kingdom', 'fluent-cart'), | |
| 588 | - "value" => 'GBP', | |
| 589 | - "symbol" => '£', | |
| 590 | - ], | |
| 591 | - ], [] | |
| 592 | - ], '1.3.16', 'fluent_cart/available_currencies', 'Use fluent_cart/available_currencies instead of fluent-cart/available_currencies. It will be removed in v1.4.3.'); | |
| 691 | + $currencies = [ | |
| 692 | + 'BDT' => [ | |
| 693 | + "label" => __('Bangladeshi Taka', 'fluent-cart'), | |
| 694 | + "value" => 'BDT', | |
| 695 | + "symbol" => '৳', | |
| 696 | + ], | |
| 697 | + 'USD' => [ | |
| 698 | + "label" => __('United State Dollar', 'fluent-cart'), | |
| 699 | + "value" => 'USD', | |
| 700 | + "symbol" => '$', | |
| 701 | + ], | |
| 702 | + 'GBP' => [ | |
| 703 | + "label" => __('United Kingdom', 'fluent-cart'), | |
| 704 | + "value" => 'GBP', | |
| 705 | + "symbol" => '£', | |
| 706 | + ], | |
| 707 | + ]; | |
| 593 | 708 | |
| 594 | 709 | return apply_filters('fluent_cart/available_currencies', $currencies, []); |
| 595 | 710 | } |
| 596 | 711 | |
| @@ -937,15 +1052,13 @@ | ||
| 937 | 1052 | } |
| 938 | 1053 | |
| 939 | 1054 | public static function getCouponStatuses() |
| 940 | 1055 | { |
| 941 | - $statuses = apply_filters_deprecated('fluent-cart/coupon_statuses', [ | |
| 942 | - [ | |
| 943 | - 'active' => __('Active', 'fluent-cart'), | |
| 944 | - 'expired' => __('Expired', 'fluent-cart'), | |
| 945 | - 'disabled' => __('Disabled', 'fluent-cart'), | |
| 946 | - ], [] | |
| 947 | - ], '1.3.16', 'fluent_cart/coupon_statuses', 'Use fluent_cart/coupon_statuses instead of fluent-cart/coupon_statuses. It will be removed in v1.4.3.'); | |
| 1056 | + $statuses = [ | |
| 1057 | + 'active' => __('Active', 'fluent-cart'), | |
| 1058 | + 'expired' => __('Expired', 'fluent-cart'), | |
| 1059 | + 'disabled' => __('Disabled', 'fluent-cart'), | |
| 1060 | + ]; | |
| 948 | 1061 | |
| 949 | 1062 | return apply_filters('fluent_cart/coupon_statuses', $statuses, []); |
| 950 | 1063 | } |
| 951 | 1064 | |
| @@ -1211,8 +1324,150 @@ | ||
| 1211 | 1324 | |
| 1212 | 1325 | return !empty($otherInfo) ? $paymentInfo : null; |
| 1213 | 1326 | } |
| 1214 | 1327 | |
| 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 | |
| 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 | + | |
| 1215 | 1470 | public static function generateSetupFeeInfo($otherInfo, $asArray = false) |
| 1216 | 1471 | { |
| 1217 | 1472 | // Convert to array if it's an object |
| 1218 | 1473 | if (is_object($otherInfo)) { |
| @@ -1277,9 +1532,8 @@ | ||
| 1277 | 1532 | |
| 1278 | 1533 | public static function getCountryList(): array |
| 1279 | 1534 | { |
| 1280 | 1535 | $options = App::getInstance('localization')->countriesOptions(); |
| 1281 | - $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. It will be removed in v1.4.3.'); | |
| 1282 | 1536 | |
| 1283 | 1537 | return apply_filters('fluent_cart/util/countries', $options, []); |
| 1284 | 1538 | } |
| 1285 | 1539 | |
| @@ -1719,26 +1973,9 @@ | ||
| 1719 | 1973 | } |
| 1720 | 1974 | |
| 1721 | 1975 | public static function subscriptionIntervalInDays($interval) |
| 1722 | 1976 | { |
| 1723 | - switch ($interval) { | |
| 1724 | - case 'daily': | |
| 1725 | - return 1; | |
| 1726 | - case 'weekly': | |
| 1727 | - return 7; | |
| 1728 | - case 'monthly': | |
| 1729 | - return 30; | |
| 1730 | - case 'quarterly': | |
| 1731 | - return 90; | |
| 1732 | - case 'half_yearly': | |
| 1733 | - return 182; | |
| 1734 | - case 'yearly': | |
| 1735 | - return 365; | |
| 1736 | - default: | |
| 1737 | - return apply_filters('fluent_cart/subscription_interval_in_days', 0, [ | |
| 1738 | - 'interval' => $interval, | |
| 1739 | - ]); | |
| 1740 | - } | |
| 1977 | + return PaymentHelper::getIntervalDays($interval); | |
| 1741 | 1978 | } |
| 1742 | 1979 | |
| 1743 | 1980 | public static function parseTermIdsForFilter($filters): array |
| 1744 | 1981 | { |
| @@ -1793,12 +2030,12 @@ | ||
| 1793 | 2030 | { |
| 1794 | 2031 | $allChildVariants = Arr::pluck($variants, 'other_info.bundle_child_ids'); |
| 1795 | 2032 | |
| 1796 | 2033 | $allChildVariants = array_unique(Arr::flatten($allChildVariants)); |
| 1797 | - $allChildVariants = Arr::except( | |
| 2034 | + $allChildVariants = array_values(array_diff( | |
| 1798 | 2035 | $allChildVariants, |
| 1799 | 2036 | Arr::pluck($variants, 'id') |
| 1800 | - ); | |
| 2037 | + )); | |
| 1801 | 2038 | $allChildVariants = array_filter($allChildVariants); |
| 1802 | 2039 | $childVariants = ProductVariation::query() |
| 1803 | 2040 | ->whereIn('id', $allChildVariants) |
| 1804 | 2041 | ->with('product:ID,post_title') |
| @@ -1919,6 +2156,40 @@ | ||
| 1919 | 2156 | } |
| 1920 | 2157 | return (int) $order->tax_behavior === 2 |
| 1921 | 2158 | ? esc_html__('(Included)', 'fluent-cart') |
| 1922 | 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; | |
| 1923 | 2194 | } |
| 1924 | 2195 | } |