PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.4.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.4.0
2.4.0 2.3.0 2.2.0 2.1.0 2.0.0 1.11.2 1.11.1 1.11.0 1.10.9 1.10.8 1.10.7 1.10.6 1.10.5 1.10.4 1.10.3 1.10.2 1.10.1 1.10.0 1.9.6 1.9.5 trunk 1.3.0 1.3.1 1.3.2 1.4.0 All 65 releases
← All changes | includes/Illuminate/Helper.php +488 -27 1.9.5 → 2.4.0 View file →
@@ -1,5 +1,10 @@
1 1 <?php
2 +/**
3 + * Subscription helper utilities.
4 + *
5 + * @package SpringDevs\Subscription\Illuminate
6 + */
2 7
3 8 namespace SpringDevs\Subscription\Illuminate;
4 9
5 10 use SpringDevs\Subscription\Illuminate\Gateways\Stripe\Stripe;
@@ -59,15 +64,18 @@
59 64 * Get verbose status from status slug.
60 65 *
61 66 * @param string $status Status.
62 67 * @param bool $return_all Whether to return all statuses or a single status.
68 + *
69 + * @return string|array Verbose status label, or the full status map when $return_all is true.
63 70 */
64 - public static function get_verbose_status( $status, $return_all = false ): string|array {
71 + public static function get_verbose_status( $status, $return_all = false ) {
65 72 $statuses = array(
66 73 'pending' => __( 'Pending', 'subscription' ),
67 74 'active' => __( 'Active', 'subscription' ),
68 75 'on-hold' => __( 'On Hold', 'subscription' ),
69 76 'expired' => __( 'Expired', 'subscription' ),
77 + 'completed' => __( 'Completed', 'subscription' ),
70 78 'pe_cancelled' => __( 'Pending Cancellation', 'subscription' ),
71 79 'cancelled' => __( 'Cancelled', 'subscription' ),
72 80 'draft' => __( 'Draft', 'subscription' ),
73 81 'trash' => __( 'Trash', 'subscription' ),
@@ -81,8 +89,22 @@
81 89 return isset( $statuses[ $status ] ) ? $statuses[ $status ] : '';
82 90 }
83 91
84 92 /**
93 + * Whether a subscription in this status still has a payment ahead.
94 + *
95 + * Cancelling or expiring keeps `_subscrpt_next_date` (the cancellation sweep
96 + * and reactivation read it), so views check the status before showing it.
97 + *
98 + * @param string $status Status.
99 + *
100 + * @return bool
101 + */
102 + public static function has_next_payment( $status ) {
103 + return ! in_array( $status, array( 'cancelled', 'expired' ), true );
104 + }
105 +
106 + /**
85 107 * Generate start date
86 108 *
87 109 * @param null|string $trial Trial.
88 110 *
@@ -495,12 +517,16 @@
495 517
496 518 // Allow modification of split payment arguments
497 519 $split_payment_args = apply_filters( 'subscrpt_split_payment_args', $split_payment_args, $order_item, $product );
498 520
521 + // Own the subscription from the order's customer, not the current user.
522 + $parent_order = wc_get_order( $order_item->get_order_id() );
523 +
499 524 $args = array(
500 525 'post_title' => 'Subscription',
501 526 'post_type' => 'subscrpt_order',
502 527 'post_status' => $split_payment_args['post_status'],
528 + 'post_author' => $parent_order ? (int) $parent_order->get_customer_id() : get_current_user_id(),
503 529 );
504 530 $subscription_id = wp_insert_post( $args );
505 531 wp_update_post(
506 532 array(
@@ -508,9 +534,10 @@
508 534 'post_title' => "Subscription #{$subscription_id}",
509 535 )
510 536 );
511 537 // Check if this is a split payment subscription
512 - $payment_type = $product->get_meta( '_subscrpt_payment_type' ) ?: 'recurring';
538 + $payment_type = $product->get_meta( '_subscrpt_payment_type' );
539 + $payment_type = $payment_type ? $payment_type : 'recurring';
513 540 $max_payments = $product->get_meta( '_subscrpt_max_no_payment' );
514 541
515 542 $comment_content = '';
516 543 $activity_type = '';
@@ -561,8 +588,408 @@
561 588 return $subscription_id;
562 589 }
563 590
564 591 /**
592 + * Resolve applied coupon discounts for a single cart item, split by whether they recur.
593 + *
594 + * WooCommerce computes a per-coupon, per-item discount breakdown while calculating cart
595 + * totals but never persists it — only the aggregated per-coupon and per-item sums survive.
596 + * Replaying the coupons through a fresh WC_Discounts instance recovers that breakdown,
597 + * which is what lets recurring and one-time discounts be told apart for one cart line.
598 + *
599 + * Amounts are returned in the same space WC_Discounts works in, i.e. `get_price() * qty`,
600 + * so they are tax-inclusive only when the store's prices include tax.
601 + *
602 + * @param string $cart_item_key Cart item key.
603 + *
604 + * @return array{recurring:float,non_recurring:float,total:float,recurring_limit:int}
605 + */
606 + public static function get_cart_item_coupon_discounts( $cart_item_key ) {
607 + $empty = array(
608 + 'recurring' => 0.0,
609 + 'non_recurring' => 0.0,
610 + 'total' => 0.0,
611 + 'recurring_limit' => 0,
612 + );
613 +
614 + if ( ! function_exists( 'WC' ) || ! WC()->cart ) {
615 + return $empty;
616 + }
617 +
618 + $coupons = WC()->cart->get_coupons();
619 + if ( empty( $coupons ) ) {
620 + return $empty;
621 + }
622 +
623 + // Memoized per cart state: recurring totals are read several times per request, and
624 + // replaying coupons re-runs coupon validation, which hits the database.
625 + static $cache = array();
626 +
627 + $cache_key = md5( WC()->cart->get_cart_hash() . '|' . implode( ',', array_keys( $coupons ) ) );
628 +
629 + if ( ! isset( $cache[ $cache_key ] ) ) {
630 + // Replay in cart order so stacked coupons resolve exactly as WC_Cart_Totals resolved them.
631 + $discounts = new \WC_Discounts( WC()->cart );
632 + foreach ( $coupons as $coupon ) {
633 + $discounts->apply_coupon( $coupon );
634 + }
635 + $cache[ $cache_key ] = $discounts->get_discounts();
636 + }
637 +
638 + $result = $empty;
639 + $limits = array();
640 +
641 + foreach ( $cache[ $cache_key ] as $coupon_code => $item_discounts ) {
642 + $amount = (float) ( $item_discounts[ $cart_item_key ] ?? 0 );
643 + if ( 0 >= $amount ) {
644 + continue;
645 + }
646 +
647 + $coupon = $coupons[ $coupon_code ] ?? new \WC_Coupon( $coupon_code );
648 +
649 + /**
650 + * Filters whether a coupon's discount also applies to subscription renewals.
651 + *
652 + * The free plugin has no recurring-coupon concept, so discounts apply to the
653 + * first payment only unless an extension — the pro plugin — says otherwise.
654 + *
655 + * @param bool $is_recurring Whether the discount recurs. Default false.
656 + * @param \WC_Coupon $coupon Coupon object.
657 + * @param string $cart_item_key Cart item key the discount applies to.
658 + */
659 + $is_recurring = (bool) apply_filters( 'subscrpt_coupon_is_recurring', false, $coupon, $cart_item_key );
660 +
661 + if ( ! $is_recurring ) {
662 + $result['non_recurring'] += $amount;
663 + continue;
664 + }
665 +
666 + /**
667 + * Filters how many payments a recurring coupon's discount covers.
668 + *
669 + * @param int $limit Number of payments, including the initial one. 0 means unlimited.
670 + * @param \WC_Coupon $coupon Coupon object.
671 + * @param string $cart_item_key Cart item key the discount applies to.
672 + */
673 + $limit = (int) apply_filters( 'subscrpt_coupon_recurring_limit', 0, $coupon, $cart_item_key );
674 +
675 + // A limit of one covers the initial payment only, so it never reaches a renewal —
676 + // whatever the coupon is flagged as, its effect here is a one-time discount.
677 + if ( 1 === $limit ) {
678 + $result['non_recurring'] += $amount;
679 + continue;
680 + }
681 +
682 + $result['recurring'] += $amount;
683 +
684 + if ( $limit > 0 ) {
685 + $limits[] = $limit;
686 + }
687 + }
688 +
689 + $result['total'] = $result['recurring'] + $result['non_recurring'];
690 +
691 + // The earliest limit to expire is when the discounted figure stops being true.
692 + $result['recurring_limit'] = empty( $limits ) ? 0 : min( $limits );
693 +
694 + return $result;
695 + }
696 +
697 + /**
698 + * Build the discount-aware recurring price figures and markup for one cart item.
699 + *
700 + * A recurring coupon lowers what every renewal costs, so it is folded into the recurring
701 + * figures. A one-time coupon lowers only what is paid today, so the recurring figures keep
702 + * the full price and the caller discloses the first-payment amount separately.
703 + *
704 + * Discounts come back from WC_Discounts in `get_price() * qty` space, so they are converted
705 + * to the tax-inclusive display space by ratio rather than by re-deriving tax.
706 + *
707 + * @param array $cart_item Cart item.
708 + * @param string $cart_item_key Cart item key.
709 + * @param string $type_label Human readable timing label, e.g. "Month".
710 + *
711 + * @return array
712 + */
713 + public static function build_cart_recurring_price_data( $cart_item, $cart_item_key, $type_label ) {
714 + $product = $cart_item['data'];
715 + $quantity = (int) $cart_item['quantity'];
716 + $per_cost = (float) ( $cart_item['subscription']['per_cost'] ?? 0 );
717 +
718 + $full_total = (float) wc_get_price_including_tax( $product, [ 'qty' => $quantity ] );
719 + $discounts = self::get_cart_item_coupon_discounts( $cart_item_key );
720 +
721 + // Same basis WC_Discounts used, so the discount and the basis are directly comparable.
722 + $basis = (float) $product->get_price() * $quantity;
723 + $recurring_ratio = $basis > 0 ? ( $basis - $discounts['recurring'] ) / $basis : 1.0;
724 + $first_ratio = $basis > 0 ? ( $basis - $discounts['total'] ) / $basis : 1.0;
725 +
726 + $total = $full_total * $recurring_ratio;
727 + $timing_html = "<span class='wpsubs-subscription-timing'>&nbsp;/&nbsp;{$type_label}</span>";
728 +
729 + $has_recurring_discount = $discounts['recurring'] > 0;
730 + $full_price_html = wc_price( $full_total ) . $timing_html;
731 + $price_html = $has_recurring_discount
732 + ? '<del aria-hidden="true">' . wc_price( $full_total ) . '</del> <ins>' . wc_price( $total ) . '</ins>' . $timing_html
733 + : $full_price_html;
734 +
735 + return array(
736 + 'price_html' => $price_html,
737 + 'full_price_html' => $full_price_html,
738 + 'price' => $per_cost * $recurring_ratio,
739 + 'full_price' => $per_cost,
740 + 'total' => $total,
741 + 'full_total' => $full_total,
742 + 'first_total' => $full_total * $first_ratio,
743 + 'has_recurring_discount' => $has_recurring_discount,
744 + 'has_one_time_discount' => $discounts['non_recurring'] > 0,
745 + 'recurring_limit' => $discounts['recurring_limit'],
746 + );
747 + }
748 +
749 + /**
750 + * Resolve the discount that still applies to a subscription's future renewals.
751 + *
752 + * Only coupons flagged as recurring survive into renewal orders, and only while their
753 + * recurring limit holds — this mirrors the skip conditions in the pro plugin's
754 + * `Coupon::maybe_add_coupon_to_renewal_order()`, so what is displayed matches what the
755 + * next renewal order will actually be charged.
756 + *
757 + * A subscription order always holds exactly one line item (enforced by
758 + * `Frontend\Cart::validate_cart_items()`), so each coupon line's whole discount belongs
759 + * to that item.
760 + *
761 + * @param int $subscription_id Subscription ID.
762 + * @param \WC_Order|null $order Source order. Resolved from the subscription when omitted.
763 + * @param \WC_Order_Item|null $order_item Source order item. Used to rebase the discount when the
764 + * recurring price has since drifted, e.g. after a switch.
765 + *
766 + * @return array{amount:float,limit:int,exhausted:bool}
767 + */
768 + public static function get_subscription_recurring_discount( $subscription_id, $order = null, $order_item = null ) {
769 + $result = array(
770 + 'amount' => 0.0,
771 + 'limit' => 0,
772 + 'exhausted' => false,
773 + );
774 +
775 + // Memoized per request: list views and the single view each resolve the same
776 + // subscription two or three times, and a coupon'd subscription costs a query.
777 + static $cache = array();
778 +
779 + $cache_key = $subscription_id . '|' . ( $order_item ? $order_item->get_id() : 0 );
780 +
781 + if ( isset( $cache[ $cache_key ] ) ) {
782 + return $cache[ $cache_key ];
783 + }
784 +
785 + if ( ! $order ) {
786 + $order_item_id = get_post_meta( $subscription_id, '_subscrpt_order_item_id', true );
787 + $order = $order_item_id ? wc_get_order( wc_get_order_id_by_order_item_id( $order_item_id ) ) : null;
788 + }
789 +
790 + if ( ! $order ) {
791 + $cache[ $cache_key ] = $result;
792 + return $result;
793 + }
794 +
795 + $coupon_lines = $order->get_items( 'coupon' );
796 + if ( empty( $coupon_lines ) ) {
797 + $cache[ $cache_key ] = $result;
798 + return $result;
799 + }
800 +
801 + /*
802 + * Position the next renewal will take in this subscription's order sequence.
803 + *
804 + * Note this is deliberately one more than the current order count, and so is NOT the
805 + * same expression pro's Coupon::maybe_add_coupon_to_renewal_order() evaluates: that
806 + * runs after the new order's relation row is already inserted, so its count includes
807 + * the order being created. Both mean "is this order still within the limit".
808 + */
809 + $next_order_position = count( self::get_related_orders( (int) $subscription_id ) ) + 1;
810 + $limits = array();
811 +
812 + foreach ( $coupon_lines as $coupon_line ) {
813 + $coupon = new \WC_Coupon( $coupon_line->get_code() );
814 +
815 + /** This filter is documented in includes/Illuminate/Helper.php */
816 + if ( ! apply_filters( 'subscrpt_coupon_is_recurring', false, $coupon, '' ) ) {
817 + continue;
818 + }
819 +
820 + /** This filter is documented in includes/Illuminate/Helper.php */
821 + $limit = (int) apply_filters( 'subscrpt_coupon_recurring_limit', 0, $coupon, '' );
822 +
823 + if ( $limit > 0 ) {
824 + $limits[] = $limit;
825 +
826 + // The next renewal is past the limit, so it will be charged full price.
827 + if ( $next_order_position > $limit ) {
828 + $result['exhausted'] = true;
829 + continue;
830 + }
831 + }
832 +
833 + $result['amount'] += (float) $coupon_line->get_discount();
834 + }
835 +
836 + $result['limit'] = empty( $limits ) ? 0 : min( $limits );
837 +
838 + // Rebase onto the current recurring price when it no longer matches what was discounted.
839 + $discounted_subtotal = $order_item ? (float) $order_item->get_subtotal() : 0.0;
840 + $recurring_subtotal = $order_item ? (float) self::get_subscription_total( $subscription_id ) * max( 1, (int) $order_item->get_quantity() ) : 0.0;
841 +
842 + if ( $result['amount'] > 0 && $discounted_subtotal > 0 && abs( $discounted_subtotal - $recurring_subtotal ) > 0.01 ) {
843 + $result['amount'] = $result['amount'] * ( $recurring_subtotal / $discounted_subtotal );
844 + }
845 +
846 + $cache[ $cache_key ] = $result;
847 +
848 + return $result;
849 + }
850 +
851 + /**
852 + * Build the figures the My Account subscription views display.
853 + *
854 + * The recurring price in `_subscrpt_price` is always the undiscounted product price, so the
855 + * renewal figure has to be derived: full price, minus whatever discount recurs, plus tax on
856 + * the discounted amount. Tax is scaled from the order item's own tax ratio rather than
857 + * recalculated, which keeps the figures consistent with the order they came from.
858 + *
859 + * @param int $subscription_id Subscription ID.
860 + * @param \WC_Order_Item|null $order_item Source order item.
861 + *
862 + * @return array{full_excl:float,discount:float,discount_tax:float,tax:float,total:float,has_discount:bool}
863 + */
864 + public static function get_subscription_display_totals( $subscription_id, $order_item = null ) {
865 + $quantity = $order_item ? max( 1, (int) $order_item->get_quantity() ) : 1;
866 + $full_excl = (float) self::get_subscription_total( $subscription_id ) * $quantity;
867 +
868 + $item_subtotal = $order_item ? (float) $order_item->get_subtotal() : 0.0;
869 + $item_tax = $order_item ? (float) $order_item->get_subtotal_tax() : 0.0;
870 + $tax_ratio = $item_subtotal > 0 ? $item_tax / $item_subtotal : 0.0;
871 +
872 + $order = $order_item ? wc_get_order( $order_item->get_order_id() ) : null;
873 + $discount = self::get_subscription_recurring_discount( $subscription_id, $order, $order_item );
874 + $discount = min( (float) $discount['amount'], $full_excl );
875 +
876 + $discount_tax = $discount * $tax_ratio;
877 + $tax = ( $full_excl * $tax_ratio ) - $discount_tax;
878 +
879 + return array(
880 + 'full_excl' => $full_excl,
881 + 'discount' => $discount,
882 + 'discount_tax' => $discount_tax,
883 + 'tax' => $tax,
884 + 'total' => $full_excl - $discount + $tax,
885 + 'has_discount' => $discount > 0,
886 + );
887 + }
888 +
889 + /**
890 + * Resolve the pieces every recurring-amount display needs.
891 + *
892 + * @param int $subscription_id Subscription ID.
893 + * @param \WC_Order_Item|null $order_item Source order item.
894 + *
895 + * @return array|false {discounted:string,full:string,has_discount:bool}, or false when the
896 + * order item is missing or carries no subscription meta.
897 + */
898 + protected static function get_subscription_recurring_price_parts( $subscription_id, $order_item = null ) {
899 + if ( ! $order_item ) {
900 + return false;
901 + }
902 +
903 + $totals = self::get_subscription_display_totals( $subscription_id, $order_item );
904 + $discounted = self::format_price_with_order_item( $totals['total'], $order_item->get_id() );
905 +
906 + if ( ! $discounted ) {
907 + return false;
908 + }
909 +
910 + // Undiscounted amount including its own tax.
911 + $full = $totals['full_excl'] + $totals['tax'] + $totals['discount_tax'];
912 + $order = wc_get_order( $order_item->get_order_id() );
913 +
914 + return array(
915 + 'discounted' => $discounted,
916 + 'full' => wc_price(
917 + $full,
918 + array(
919 + 'currency' => $order ? $order->get_currency() : '',
920 + )
921 + ),
922 + 'has_discount' => $totals['has_discount'],
923 + );
924 + }
925 +
926 + /**
927 + * Formatted recurring amount for a subscription, striking the original when a discount recurs.
928 + *
929 + * Produces the same `<del>` / `<ins>` shape the cart's recurring totals use, so a customer
930 + * sees one consistent treatment of a recurring discount from cart through to order details.
931 + * Use `get_subscription_recurring_price_text()` anywhere the output may reach a plain-text
932 + * context, such as an email that renders in both HTML and plain.
933 + *
934 + * @param int $subscription_id Subscription ID.
935 + * @param \WC_Order_Item|null $order_item Source order item.
936 + * @param array $args Optional. 'del_style' is an inline style for the struck-through
937 + * amount — email clients strip stylesheets, so email callers
938 + * must pass one.
939 + *
940 + * @return string|false Formatted price, or false when the order item has no subscription meta.
941 + */
942 + public static function get_subscription_recurring_price_html( $subscription_id, $order_item = null, $args = array() ) {
943 + $parts = self::get_subscription_recurring_price_parts( $subscription_id, $order_item );
944 +
945 + if ( ! $parts ) {
946 + return false;
947 + }
948 +
949 + if ( ! $parts['has_discount'] ) {
950 + return $parts['discounted'];
951 + }
952 +
953 + $del_style = $args['del_style'] ?? '';
954 + $del_attributes = $del_style ? ' style="' . esc_attr( $del_style ) . '"' : '';
955 +
956 + return '<del aria-hidden="true"' . $del_attributes . '>' . $parts['full'] . '</del> <ins>' . $parts['discounted'] . '</ins>';
957 + }
958 +
959 + /**
960 + * Formatted recurring amount for a subscription, as markup-free text.
961 + *
962 + * For contexts that cannot render `<del>` — plain-text emails above all, where stripping the
963 + * tags would leave two bare amounts side by side and no way to tell which is charged.
964 + *
965 + * @param int $subscription_id Subscription ID.
966 + * @param \WC_Order_Item|null $order_item Source order item.
967 + *
968 + * @return string|false Formatted price, or false when the order item has no subscription meta.
969 + */
970 + public static function get_subscription_recurring_price_text( $subscription_id, $order_item = null ) {
971 + $parts = self::get_subscription_recurring_price_parts( $subscription_id, $order_item );
972 +
973 + if ( ! $parts ) {
974 + return false;
975 + }
976 +
977 + $discounted = wp_strip_all_tags( $parts['discounted'] );
978 +
979 + if ( ! $parts['has_discount'] ) {
980 + return $discounted;
981 + }
982 +
983 + return sprintf(
984 + // translators: 1: discounted recurring amount, 2: original amount before the discount.
985 + __( '%1$s (discounted from %2$s)', 'subscription' ),
986 + $discounted,
987 + wp_strip_all_tags( $parts['full'] )
988 + );
989 + }
990 +
991 + /**
565 992 * Get recurrings items from cart items.
566 993 *
567 994 * @param array $cart_items Cart items.
568 995 *
@@ -573,25 +1000,29 @@
573 1000 foreach ( $cart_items as $key => $cart_item ) {
574 1001 $product = $cart_item['data'];
575 1002 if ( $product->is_type( 'simple' ) && isset( $cart_item['subscription'] ) ) {
576 1003 $cart_subscription = $cart_item['subscription'];
577 - $type = ucfirst( $cart_subscription['type'] );
1004 + // Cadence word must respect the frequency (plan items store the raw
1005 + // plural interval, e.g. "months"): singular for 1, plural + count above.
1006 + $sub_time = max( 1, (int) ( $cart_subscription['time'] ?? 1 ) );
1007 + $type = ( 1 === $sub_time ? '' : $sub_time . ' ' ) . ucfirst( self::get_typos( $sub_time, $cart_subscription['type'] ) );
1008 + $price_data = self::build_cart_recurring_price_data( $cart_item, $key, $type );
578 1009
579 - // Total amount with tax
580 - $quantity = (int) $cart_item['quantity'];
581 - $total_amount = wc_get_price_including_tax( $product, [ 'qty' => $quantity ] );
582 - $timing_html = "<span class='wpsubs-subscription-timing'>&nbsp;/&nbsp;{$type}</span>";
583 - $price_html = wc_price( (float) $total_amount ) . $timing_html;
584 -
585 - $recurrs[ $key ] = array(
586 - 'trial_status' => ! is_null( $cart_subscription['trial'] ),
587 - 'price_html' => $price_html,
588 - 'start_date' => self::start_date( $cart_subscription['trial'] ),
589 - 'next_date' => self::next_date( ( $cart_subscription['time'] ?? 1 ) . ' ' . $cart_subscription['type'], $cart_subscription['trial'] ),
590 - 'can_user_cancel' => $cart_item['data']->get_meta( '_subscrpt_user_cancel' ),
591 - 'max_no_payment' => $cart_item['data']->get_meta( '_subscrpt_max_no_payment' ),
592 - 'price' => (float) $cart_subscription['per_cost'],
593 - 'quantity' => (int) $cart_item['quantity'],
1010 + $recurrs[ $key ] = array_merge(
1011 + $price_data,
1012 + array(
1013 + 'trial_status' => ! is_null( $cart_subscription['trial'] ),
1014 + 'start_date' => self::start_date( $cart_subscription['trial'] ),
1015 + 'next_date' => self::next_date( ( $cart_subscription['time'] ?? 1 ) . ' ' . $cart_subscription['type'], $cart_subscription['trial'] ),
1016 + 'can_user_cancel' => self::can_user_cancel( $cart_item['data']->get_meta( '_subscrpt_user_cancel' ) ) ? 'yes' : 'no',
1017 + 'max_no_payment' => ! empty( $cart_item['subscrpt_max_no_payment'] )
1018 + ? (int) $cart_item['subscrpt_max_no_payment']
1019 + : $cart_item['data']->get_meta( '_subscrpt_max_no_payment' ),
1020 + // Exact plan total for split items (the entered price the split is
1021 + // divided from); null for classic split items which have no plan total.
1022 + 'split_total' => isset( $cart_item['subscrpt_split_total'] ) ? (float) $cart_item['subscrpt_split_total'] : null,
1023 + 'quantity' => (int) $cart_item['quantity'],
1024 + )
594 1025 );
595 1026 }
596 1027 }
597 1028
@@ -628,8 +1059,9 @@
628 1059 /**
629 1060 * Create renewal order when subscription expired. [wip]
630 1061 *
631 1062 * @param int $subscription_id Subscription ID.
1063 + * @return false|\WC_Order Renewal order object or false on failure.
632 1064 * @throws \WC_Data_Exception Exception.
633 1065 * @throws \Exception Exception.
634 1066 */
635 1067 public static function create_renewal_order( $subscription_id ) {
@@ -646,10 +1078,22 @@
646 1078 $order_id = wc_get_order_id_by_order_item_id( $order_item_id );
647 1079 $old_order = self::check_order_for_renewal( $order_id );
648 1080
649 1081 if ( ! $old_order ) {
1082 + // The stored item may belong to a trashed or non-completed order (e.g. a renewal that was deleted). Walk the relation table newest-first to find the last completed order we can use as the renewal source.
1083 + foreach ( self::get_related_orders( $subscription_id ) as $row ) {
1084 + $candidate = wc_get_order( (int) ( $row->order_id ?? 0 ) );
1085 + if ( $candidate && 'completed' === $candidate->get_status() ) {
1086 + $old_order = $candidate;
1087 + $order_item_id = (int) ( $row->order_item_id ?? 0 );
1088 + break;
1089 + }
1090 + }
1091 + }
1092 +
1093 + if ( ! $old_order ) {
650 1094 subscrpt_write_log( "Old order not found for renewal. Skipping creating renewal order. [ Subscription ID: {$subscription_id} ]" );
651 - return;
1095 + return false;
652 1096 }
653 1097
654 1098 $order_item = $old_order->get_item( $order_item_id );
655 1099 $subscription_price = (float) get_post_meta( $subscription_id, '_subscrpt_price', true );
@@ -674,9 +1118,9 @@
674 1118 // creating new order.
675 1119 $new_order_data = self::create_new_order_for_renewal( $old_order, $order_item, $product_args );
676 1120 if ( ! $new_order_data ) {
677 1121 subscrpt_write_log( "Failed to create renewal order. [ Subscription ID: {$subscription_id} ]" );
678 - return;
1122 + return false;
679 1123 }
680 1124 $new_order = $new_order_data['order'];
681 1125 $new_order_item_id = $new_order_data['order_item_id'];
682 1126
@@ -693,11 +1137,13 @@
693 1137 $new_order->calculate_totals();
694 1138 $new_order->save();
695 1139
696 1140 if ( ! is_admin() && function_exists( 'wc_add_notice' ) && WC()->session ) {
697 - $message = 'Renewal Order(#' . $new_order->get_id() . ') Created.';
1141 + /* translators: %d: renewal order ID. */
1142 + $message = sprintf( __( 'Renewal order #%d created.', 'subscription' ), $new_order->get_id() );
698 1143 if ( $new_order->has_status( 'pending' ) ) {
699 - $message .= 'Please <a href="' . $new_order->get_checkout_payment_url() . '">Pay now</a>';
1144 + /* translators: %s: URL of the order payment page. */
1145 + $message .= ' ' . sprintf( __( 'Please <a href="%s">pay now</a>.', 'subscription' ), esc_url( $new_order->get_checkout_payment_url() ) );
700 1146 }
701 1147 wc_add_notice( $message, 'success' );
702 1148 }
703 1149
@@ -949,9 +1395,9 @@
949 1395
950 1396 $comment_id = wp_insert_comment(
951 1397 array(
952 1398 'comment_author' => 'Subscription for WooCommerce',
953 - 'comment_content' => sprintf( 'Subscription Renewal order successfully created. order is %s', $new_order_id ),
1399 + 'comment_content' => sprintf( 'Subscription Renewal order successfully created. Order #%s', $new_order_id ),
954 1400 'comment_post_ID' => $subscription_id,
955 1401 'comment_type' => 'order_note',
956 1402 )
957 1403 );
@@ -959,8 +1405,22 @@
959 1405 update_comment_meta( $comment_id, '_subscrpt_activity_type', 'renewal_order' );
960 1406 }
961 1407
962 1408 /**
1409 + * Whether a stored `_subscrpt_user_cancel` value lets the customer cancel.
1410 + *
1411 + * Only an explicit "no" forbids it. An empty value means the option was never
1412 + * saved - plan-connected products never store it - and the product form shows
1413 + * "Yes" for that case, so the answer here has to be yes too.
1414 + *
1415 + * @param mixed $value Stored meta value.
1416 + * @return bool
1417 + */
1418 + public static function can_user_cancel( $value ): bool {
1419 + return 'no' !== $value;
1420 + }
1421 +
1422 + /**
963 1423 * Get a subscription data.
964 1424 *
965 1425 * @param int $subscription_id Subscription ID.
966 1426 * @return array|null
@@ -970,9 +1430,9 @@
970 1430 return null;
971 1431 }
972 1432
973 1433 $subs_post = get_post( $subscription_id );
974 - $user_id = (int) $subs_post->post_author ?? 0;
1434 + $user_id = ! empty( $subs_post ) ? (int) $subs_post->post_author : 0;
975 1435
976 1436 $product_id = get_post_meta( $subscription_id, '_subscrpt_product_id', true );
977 1437 $product_id = ! empty( $product_id ) ? (int) $product_id : 0;
978 1438
@@ -980,9 +1440,9 @@
980 1440 $variation_id = ! empty( $variation_id ) ? (int) $variation_id : 0;
981 1441
982 1442 $chk_product_id = $variation_id ? $variation_id : $product_id;
983 1443
984 - $status = get_post_status( $subscription_id );
1444 + $status = get_post_status( $subscription_id ); // pending, active, cancelled, pe_cancelled, expired
985 1445 $price = get_post_meta( $subscription_id, '_subscrpt_price', true );
986 1446
987 1447 $signup_fee = get_post_meta( $subscription_id, '_subscrpt_signup_fee', true );
988 1448 $signup_fee = ! empty( $signup_fee ) ? $signup_fee : 0;
@@ -989,9 +1449,9 @@
989 1449
990 1450 $order_id = get_post_meta( $subscription_id, '_subscrpt_order_id', true );
991 1451 $order_item_id = get_post_meta( $subscription_id, '_subscrpt_order_item_id', true );
992 1452
993 - $can_user_cancel = in_array( get_post_meta( $subscription_id, '_subscrpt_user_cancel', true ), array( 1, '1', 'true', 'yes' ), true );
1453 + $can_user_cancel = self::can_user_cancel( get_post_meta( $subscription_id, '_subscrpt_user_cancel', true ) );
994 1454
995 1455 $start_datetime = (int) get_post_meta( $subscription_id, '_subscrpt_start_date', true );
996 1456 $start_date = ! empty( $start_datetime ) ? gmdate( DATE_RFC2822, $start_datetime ) : null;
997 1457
@@ -1050,9 +1510,10 @@
1050 1510 );
1051 1511 }
1052 1512
1053 1513 if (
1054 - ! in_array( strtolower( $status ), array( 'cancelled', 'pending' ), true )
1514 + ! in_array( strtolower( $status ), array( 'cancelled', 'pending', 'completed' ), true )
1515 + && ! empty( $next_date )
1055 1516 && $next_datetime - time() <= 0
1056 1517 && (int) $default_grace_period > 0
1057 1518 ) {
1058 1519 $subscription_data['grace_period'] = array(