| @@ -158,8 +158,37 @@ | ||
| 158 | 158 | */ |
| 159 | 159 | const RENEWAL_PARENT_META = '_bp_subscription_parent'; |
| 160 | 160 | |
| 161 | 161 | /** |
| 162 | + * Renewal-order meta: 'yes' once this renewal payment has advanced the | |
| 163 | + * parent's schedule. settle_renewal() advances the schedule by a full | |
| 164 | + * cycle, so it must run exactly once per renewal order — the payment can | |
| 165 | + * reach it from the gateway return AND from the order-status bridge | |
| 166 | + * (maybe_complete_from_status()), and a second pass would hand the | |
| 167 | + * customer a free billing period. | |
| 168 | + */ | |
| 169 | + const RENEWAL_SETTLED_META = '_bp_subscription_renewal_settled'; | |
| 170 | + | |
| 171 | + /** | |
| 172 | + * Parent-order meta: the subscription's agreed line prices, snapshotted | |
| 173 | + * at activation (see renewal_line_snapshot()). Renewals are billed from | |
| 174 | + * it, so editing the product's price never silently re-prices a running | |
| 175 | + * subscription — the same principle the schedule snapshot already | |
| 176 | + * follows. Absent on subscriptions that were already running when this | |
| 177 | + * shipped, which keep renewing at the product's current price exactly as | |
| 178 | + * before. | |
| 179 | + */ | |
| 180 | + const PRICE_SNAPSHOT_META = '_bp_subscription_price_snapshot'; | |
| 181 | + | |
| 182 | + /** | |
| 183 | + * User meta: the role this module granted the customer for the | |
| 184 | + * subscription lifecycle. The role sync removes only what it granted, so | |
| 185 | + * a customer's own roles (a membership plugin's role, `customer`, a | |
| 186 | + * custom staff role) survive both activation and cancellation. | |
| 187 | + */ | |
| 188 | + const GRANTED_ROLE_META = '_bp_subscription_granted_role'; | |
| 189 | + | |
| 190 | + /** | |
| 162 | 191 | * Parent-order meta: 'no' when the customer switched automatic renewal |
| 163 | 192 | * off from their My Account page ('' / 'yes' = renew automatically). |
| 164 | 193 | * Only meaningful while automatic renewal is enabled site-wide. |
| 165 | 194 | */ |
| @@ -242,8 +271,16 @@ | ||
| 242 | 271 | // renewal payment (cron-charged or manually paid) converge on the |
| 243 | 272 | // module's payment-complete hook. |
| 244 | 273 | add_action( 'better_payment/woocommerce/payment_complete', array( __CLASS__, 'on_order_paid' ), 10, 2 ); |
| 245 | 274 | |
| 275 | + // Orders that become paid without passing through the gateway return | |
| 276 | + // — an admin marking a subscription order complete, an offline | |
| 277 | + // payment on an invoiced renewal — reach the same completion | |
| 278 | + // contract here. Both handlers below it are idempotent, so an order | |
| 279 | + // that already went through the return path changes nothing. | |
| 280 | + add_action( 'woocommerce_order_status_processing', array( __CLASS__, 'maybe_complete_from_status' ), 20 ); | |
| 281 | + add_action( 'woocommerce_order_status_completed', array( __CLASS__, 'maybe_complete_from_status' ), 20 ); | |
| 282 | + | |
| 246 | 283 | // Renewal scheduler. |
| 247 | 284 | add_action( self::CRON_HOOK, array( __CLASS__, 'process_due_subscriptions' ) ); |
| 248 | 285 | |
| 249 | 286 | if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { |
| @@ -274,8 +311,13 @@ | ||
| 274 | 311 | // filter) and the order-pay page (invoiced renewals + pending |
| 275 | 312 | // subscription orders). |
| 276 | 313 | add_filter( 'woocommerce_available_payment_gateways', array( __CLASS__, 'restrict_available_gateways' ) ); |
| 277 | 314 | |
| 315 | + // …and when that restriction leaves nothing available (the gateway is | |
| 316 | + // switched off or Stripe is not configured), say so instead of | |
| 317 | + // letting WooCommerce blame the customer's country. | |
| 318 | + add_filter( 'woocommerce_no_available_payment_methods_message', array( __CLASS__, 'no_payment_methods_message' ) ); | |
| 319 | + | |
| 278 | 320 | // Customer Auto-Renew Control setting: customer-facing automatic-renewal |
| 279 | 321 | // on/off. Rendered only from My Account > Subscriptions, exactly like |
| 280 | 322 | // the cancel button above — never on an order-details screen. |
| 281 | 323 | add_action( 'template_redirect', array( __CLASS__, 'maybe_handle_auto_renew_toggle' ) ); |
| @@ -399,13 +441,43 @@ | ||
| 399 | 441 | .wc-wp-version-gte-70 .woocommerce_options_panel .bp-subscription-options .form-field:not(:has(input[type=checkbox], input[type=radio])) label { |
| 400 | 442 | line-height: 1.5; |
| 401 | 443 | padding-top: 10px; |
| 402 | 444 | } |
| 445 | + | |
| 446 | + .woocommerce_options_panel .bp-subscription-options .bp-subscription-gateway-warning { | |
| 447 | + padding: 0 12px 0 12px; | |
| 448 | + } | |
| 449 | + | |
| 450 | + .woocommerce_options_panel .bp-subscription-options .bp-subscription-gateway-warning__text { | |
| 451 | + display: block; | |
| 452 | + border-left: 4px solid #b32d2e; | |
| 453 | + background: #fcf0f1; | |
| 454 | + padding: 8px 12px; | |
| 455 | + line-height: 1.5; | |
| 456 | + } | |
| 403 | 457 | </style> |
| 404 | 458 | <?php |
| 405 | 459 | |
| 406 | - echo '<div class="options_group bp-subscription-options">'; | |
| 460 | + // Hidden for the product types a subscription cannot be sold on (see | |
| 461 | + // type_supports_subscription()). Unknown types from other plugins are | |
| 462 | + // left visible — the box still works there. | |
| 463 | + echo '<div class="options_group bp-subscription-options hide_if_variable hide_if_grouped hide_if_external">'; | |
| 407 | 464 | |
| 465 | + $gateway_state = self::gateway_state(); | |
| 466 | + | |
| 467 | + // A full-width notice rather than a labelled field, so it opts out of | |
| 468 | + // the panel's 150px label column (see the style block above). | |
| 469 | + if ( 'ready' !== $gateway_state ) { | |
| 470 | + printf( | |
| 471 | + '<p class="form-field bp-subscription-gateway-warning"><span class="bp-subscription-gateway-warning__text">%s</span></p>', | |
| 472 | + esc_html( | |
| 473 | + 'disabled' === $gateway_state | |
| 474 | + ? __( 'Better Payment (Stripe) is switched off in WooCommerce → Settings → Payments. A subscription product can only be paid with it, so customers will see no payment method at checkout until it is enabled.', 'better-payment' ) | |
| 475 | + : __( 'Better Payment has no Stripe keys yet. A subscription product can only be paid with the Better Payment (Stripe) gateway, so customers will see no payment method at checkout until Stripe is configured.', 'better-payment' ) | |
| 476 | + ) | |
| 477 | + ); | |
| 478 | + } | |
| 479 | + | |
| 408 | 480 | woocommerce_wp_checkbox( |
| 409 | 481 | array( |
| 410 | 482 | 'id' => self::PRODUCT_ENABLED_META, |
| 411 | 483 | 'label' => __( 'Better Payment Subscription', 'better-payment' ), |
| @@ -576,8 +648,41 @@ | ||
| 576 | 648 | * Detection + pure helpers |
| 577 | 649 | * ------------------------------------------------------------------- */ |
| 578 | 650 | |
| 579 | 651 | /** |
| 652 | + * Product types this module cannot sell as a subscription. Pure. | |
| 653 | + * | |
| 654 | + * A variable product's meta lives on the PARENT, while the cart, the | |
| 655 | + * order line and the renewal all carry the VARIATION — and a variation | |
| 656 | + * does not inherit its parent's custom meta. So a variable product with | |
| 657 | + * the box ticked advertised "/ month" on the shop and in the cart, was | |
| 658 | + * locked to this gateway at checkout, and then activated no | |
| 659 | + * subscription at all: the customer was billed once for something sold | |
| 660 | + * as recurring. Grouped and external products have no line of their own | |
| 661 | + * to bill either. Rejecting them here keeps every surface telling the | |
| 662 | + * same story; a product type from another plugin is left alone (it may | |
| 663 | + * well work), so nothing that worked before stops working. | |
| 664 | + * | |
| 665 | + * @param mixed $type Product type slug. | |
| 666 | + * @return bool | |
| 667 | + */ | |
| 668 | + public static function type_supports_subscription( $type ) { | |
| 669 | + /** | |
| 670 | + * Filters the product types a Better Payment subscription cannot use. | |
| 671 | + * | |
| 672 | + * @since 2.4.0 | |
| 673 | + * | |
| 674 | + * @param string[] $unsupported Unsupported product type slugs. | |
| 675 | + */ | |
| 676 | + $unsupported = apply_filters( | |
| 677 | + 'better_payment/woocommerce/unsupported_subscription_product_types', | |
| 678 | + array( 'variable', 'grouped', 'external' ) | |
| 679 | + ); | |
| 680 | + | |
| 681 | + return ! in_array( (string) $type, (array) $unsupported, true ); | |
| 682 | + } | |
| 683 | + | |
| 684 | + /** | |
| 580 | 685 | * Whether a product is a Better Payment subscription product. |
| 581 | 686 | * |
| 582 | 687 | * @param mixed $product Product (or anything else — safely rejected). |
| 583 | 688 | * @return bool |
| @@ -582,9 +687,13 @@ | ||
| 582 | 687 | * @param mixed $product Product (or anything else — safely rejected). |
| 583 | 688 | * @return bool |
| 584 | 689 | */ |
| 585 | 690 | public static function product_is_subscription( $product ) { |
| 586 | - return $product instanceof \WC_Product && 'yes' === $product->get_meta( self::PRODUCT_ENABLED_META ); | |
| 691 | + if ( ! $product instanceof \WC_Product || 'yes' !== $product->get_meta( self::PRODUCT_ENABLED_META ) ) { | |
| 692 | + return false; | |
| 693 | + } | |
| 694 | + | |
| 695 | + return self::type_supports_subscription( $product->get_type() ); | |
| 587 | 696 | } |
| 588 | 697 | |
| 589 | 698 | /** |
| 590 | 699 | * The order's line items whose product is a subscription product. |
| @@ -816,8 +925,19 @@ | ||
| 816 | 925 | $user_id = (int) get_current_user_id(); |
| 817 | 926 | |
| 818 | 927 | $eligible = $product_id > 0 && ! self::customer_has_subscription_for_product( $user_id, $product_id ); |
| 819 | 928 | |
| 929 | + // A guest has no account to check, so the trial used to be given to | |
| 930 | + // every guest checkout — including the same person buying the same | |
| 931 | + // product for the fifth time. The billing e-mail is the only identity | |
| 932 | + // a guest has, and by the time WooCommerce recalculates the totals | |
| 933 | + // for the checkout form it knows it, so use it. Still "benefit of the | |
| 934 | + // doubt" while the e-mail is unknown (the shop page, the cart page): | |
| 935 | + // nothing is charged there, and the checkout recalculation decides. | |
| 936 | + if ( $eligible && $user_id < 1 ) { | |
| 937 | + $eligible = ! self::customer_has_subscription_for_email( self::current_billing_email(), $product_id ); | |
| 938 | + } | |
| 939 | + | |
| 820 | 940 | /** |
| 821 | 941 | * Filters whether the current customer is eligible for a |
| 822 | 942 | * subscription product's free trial. |
| 823 | 943 | * |
| @@ -830,8 +950,94 @@ | ||
| 830 | 950 | return (bool) apply_filters( 'better_payment/woocommerce/trial_eligible', $eligible, $product_id, $user_id ); |
| 831 | 951 | } |
| 832 | 952 | |
| 833 | 953 | /** |
| 954 | + * The billing e-mail WooCommerce currently holds for the shopper, or '' | |
| 955 | + * when it has none (a logged-out visitor who has not reached the | |
| 956 | + * checkout form yet). | |
| 957 | + * | |
| 958 | + * @return string | |
| 959 | + */ | |
| 960 | + public static function current_billing_email() { | |
| 961 | + if ( ! function_exists( 'WC' ) || null === WC()->customer ) { | |
| 962 | + return ''; | |
| 963 | + } | |
| 964 | + | |
| 965 | + $email = (string) WC()->customer->get_billing_email(); | |
| 966 | + | |
| 967 | + return is_email( $email ) ? $email : ''; | |
| 968 | + } | |
| 969 | + | |
| 970 | + /** | |
| 971 | + * Whether a billing e-mail has already been used for a Better Payment | |
| 972 | + * subscription containing the product — the guest counterpart of | |
| 973 | + * customer_has_subscription_for_product(). Any status counts: a | |
| 974 | + * cancelled trial still used the trial up. | |
| 975 | + * | |
| 976 | + * @param mixed $email Billing e-mail ('' and non-strings never match). | |
| 977 | + * @param mixed $product_id Product (or variation) id. | |
| 978 | + * @return bool | |
| 979 | + */ | |
| 980 | + public static function customer_has_subscription_for_email( $email, $product_id ) { | |
| 981 | + static $cache = array(); | |
| 982 | + | |
| 983 | + $email = is_string( $email ) ? trim( $email ) : ''; | |
| 984 | + $product_id = (int) $product_id; | |
| 985 | + | |
| 986 | + if ( '' === $email || $product_id < 1 || ! function_exists( 'wc_get_orders' ) ) { | |
| 987 | + return false; | |
| 988 | + } | |
| 989 | + | |
| 990 | + $key = strtolower( $email ) . ':' . $product_id; | |
| 991 | + | |
| 992 | + if ( isset( $cache[ $key ] ) ) { | |
| 993 | + return $cache[ $key ]; | |
| 994 | + } | |
| 995 | + | |
| 996 | + $orders = wc_get_orders( | |
| 997 | + array( | |
| 998 | + 'billing_email' => $email, | |
| 999 | + 'limit' => 100, | |
| 1000 | + 'type' => 'shop_order', | |
| 1001 | + 'return' => 'objects', | |
| 1002 | + 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded (limit 100), per-request cached, trial products only. | |
| 1003 | + array( | |
| 1004 | + 'key' => self::STATUS_META, | |
| 1005 | + 'compare' => 'EXISTS', | |
| 1006 | + ), | |
| 1007 | + ), | |
| 1008 | + ) | |
| 1009 | + ); | |
| 1010 | + | |
| 1011 | + $cache[ $key ] = self::orders_contain_product( $orders, $product_id ); | |
| 1012 | + | |
| 1013 | + return $cache[ $key ]; | |
| 1014 | + } | |
| 1015 | + | |
| 1016 | + /** | |
| 1017 | + * Whether any of the orders carries the product as a subscription line. | |
| 1018 | + * | |
| 1019 | + * @param mixed $orders Orders to scan. | |
| 1020 | + * @param int $product_id Product (or variation) id. | |
| 1021 | + * @return bool | |
| 1022 | + */ | |
| 1023 | + private static function orders_contain_product( $orders, $product_id ) { | |
| 1024 | + if ( ! is_array( $orders ) ) { | |
| 1025 | + return false; | |
| 1026 | + } | |
| 1027 | + | |
| 1028 | + foreach ( $orders as $order ) { | |
| 1029 | + foreach ( self::order_subscription_items( $order ) as $item ) { | |
| 1030 | + if ( (int) $item->get_product_id() === $product_id || (int) $item->get_variation_id() === $product_id ) { | |
| 1031 | + return true; | |
| 1032 | + } | |
| 1033 | + } | |
| 1034 | + } | |
| 1035 | + | |
| 1036 | + return false; | |
| 1037 | + } | |
| 1038 | + | |
| 1039 | + /** | |
| 834 | 1040 | * Whether a customer has (or had) a Better Payment subscription order |
| 835 | 1041 | * containing the product. Scans the customer's subscription parent |
| 836 | 1042 | * orders (STATUS_META present); any status counts — a cancelled trial |
| 837 | 1043 | * still used up the trial. Cached per request; the scan is bounded and |
| @@ -871,24 +1077,11 @@ | ||
| 871 | 1077 | ), |
| 872 | 1078 | ) |
| 873 | 1079 | ); |
| 874 | 1080 | |
| 875 | - $found = false; | |
| 1081 | + $cache[ $key ] = self::orders_contain_product( $orders, $product_id ); | |
| 876 | 1082 | |
| 877 | - if ( is_array( $orders ) ) { | |
| 878 | - foreach ( $orders as $order ) { | |
| 879 | - foreach ( self::order_subscription_items( $order ) as $item ) { | |
| 880 | - if ( (int) $item->get_product_id() === $product_id || (int) $item->get_variation_id() === $product_id ) { | |
| 881 | - $found = true; | |
| 882 | - break 2; | |
| 883 | - } | |
| 884 | - } | |
| 885 | - } | |
| 886 | - } | |
| 887 | - | |
| 888 | - $cache[ $key ] = $found; | |
| 889 | - | |
| 890 | - return $found; | |
| 1083 | + return $cache[ $key ]; | |
| 891 | 1084 | } |
| 892 | 1085 | |
| 893 | 1086 | /** |
| 894 | 1087 | * One page of a customer's subscription parent orders, newest first — |
| @@ -1098,8 +1291,74 @@ | ||
| 1098 | 1291 | return 'better_payment_stripe'; |
| 1099 | 1292 | } |
| 1100 | 1293 | |
| 1101 | 1294 | /** |
| 1295 | + * Pure: what state the Better Payment gateway is in for a subscription | |
| 1296 | + * sale — 'ready', 'disabled' (the WooCommerce gateway is switched off) | |
| 1297 | + * or 'unconfigured' (switched on, but Better Payment has no Stripe keys). | |
| 1298 | + * | |
| 1299 | + * Both failing states mean the same thing at checkout: the subscription | |
| 1300 | + * restriction removes every other gateway and leaves nothing behind, so | |
| 1301 | + * the product cannot be bought at all. That is the safe outcome (no | |
| 1302 | + * other gateway can store the card renewals are charged with) but it was | |
| 1303 | + * also a silent one — WooCommerce just says no payment methods are | |
| 1304 | + * available. This is what the product screen and the checkout notice | |
| 1305 | + * explain instead. | |
| 1306 | + * | |
| 1307 | + * @param mixed $settings The gateway's WooCommerce settings option. | |
| 1308 | + * @param bool $stripe_configured Whether Better Payment has Stripe keys. | |
| 1309 | + * @return string 'ready' | 'disabled' | 'unconfigured' | |
| 1310 | + */ | |
| 1311 | + public static function gateway_ready_state( $settings, $stripe_configured ) { | |
| 1312 | + $enabled = is_array( $settings ) && isset( $settings['enabled'] ) ? (string) $settings['enabled'] : 'no'; | |
| 1313 | + | |
| 1314 | + if ( 'yes' !== $enabled ) { | |
| 1315 | + return 'disabled'; | |
| 1316 | + } | |
| 1317 | + | |
| 1318 | + return $stripe_configured ? 'ready' : 'unconfigured'; | |
| 1319 | + } | |
| 1320 | + | |
| 1321 | + /** | |
| 1322 | + * The live gateway state (see gateway_ready_state()). | |
| 1323 | + * | |
| 1324 | + * @return string 'ready' | 'disabled' | 'unconfigured' | |
| 1325 | + */ | |
| 1326 | + public static function gateway_state() { | |
| 1327 | + return self::gateway_ready_state( | |
| 1328 | + get_option( 'woocommerce_' . self::gateway_id() . '_settings' ), | |
| 1329 | + StripeService::is_configured() | |
| 1330 | + ); | |
| 1331 | + } | |
| 1332 | + | |
| 1333 | + /** | |
| 1334 | + * Pure: the checkout message for a subscription purchase that has no | |
| 1335 | + * payment method left, or the untouched default for every other cart. | |
| 1336 | + * | |
| 1337 | + * @param string $message WooCommerce's own message. | |
| 1338 | + * @param bool $restricted Whether this purchase requires the BP gateway. | |
| 1339 | + * @param string $state Gateway state (see gateway_ready_state()). | |
| 1340 | + * @return string | |
| 1341 | + */ | |
| 1342 | + public static function no_payment_methods_text( $message, $restricted, $state ) { | |
| 1343 | + if ( ! $restricted || 'ready' === $state ) { | |
| 1344 | + return $message; | |
| 1345 | + } | |
| 1346 | + | |
| 1347 | + return __( 'This subscription can only be paid through Better Payment (Stripe), and that payment method is not available yet. Please contact the store owner.', 'better-payment' ); | |
| 1348 | + } | |
| 1349 | + | |
| 1350 | + /** | |
| 1351 | + * Filter callback for `woocommerce_no_available_payment_methods_message`. | |
| 1352 | + * | |
| 1353 | + * @param string $message WooCommerce's own message. | |
| 1354 | + * @return string | |
| 1355 | + */ | |
| 1356 | + public static function no_payment_methods_message( $message ) { | |
| 1357 | + return self::no_payment_methods_text( (string) $message, self::checkout_requires_bp_gateway(), self::gateway_state() ); | |
| 1358 | + } | |
| 1359 | + | |
| 1360 | + /** | |
| 1102 | 1361 | * Pure: reduce an available-gateways list to the Better Payment gateway |
| 1103 | 1362 | * alone when the purchase requires it. |
| 1104 | 1363 | * |
| 1105 | 1364 | * When the Better Payment gateway is not itself in the list (disabled or |
| @@ -1233,8 +1492,102 @@ | ||
| 1233 | 1492 | return $contains_subscription && $payable_status && (float) $total < 0.01; |
| 1234 | 1493 | } |
| 1235 | 1494 | |
| 1236 | 1495 | /** |
| 1496 | + * The agreed per-cycle line prices for a subscription's items, taken at | |
| 1497 | + * activation. | |
| 1498 | + * | |
| 1499 | + * The **undiscounted** line subtotal is what is stored, deliberately: | |
| 1500 | + * | |
| 1501 | + * - Renewals used to be built from the product's CURRENT price, so | |
| 1502 | + * editing the product silently re-priced every running subscription — | |
| 1503 | + * the one thing the schedule snapshot beside it exists to prevent. | |
| 1504 | + * - A checkout coupon is not carried forward. It discounted the first | |
| 1505 | + * payment; perpetuating it would hand out a permanent price cut the | |
| 1506 | + * shop owner never agreed to, and there is no UI here to say | |
| 1507 | + * otherwise. (Same default WooCommerce applies to a non-recurring | |
| 1508 | + * coupon.) | |
| 1509 | + * - A trial line checked out at $0. The trial is free, the subscription | |
| 1510 | + * is not, so the product's price is snapshotted instead of the zero. | |
| 1511 | + * | |
| 1512 | + * @param mixed $items Subscription order items. | |
| 1513 | + * @param bool $trial_applied Whether the trial zeroed the lines at checkout. | |
| 1514 | + * @return array[] Each entry: `product_id`, `quantity`, `subtotal`, `total`. | |
| 1515 | + */ | |
| 1516 | + public static function renewal_line_snapshot( $items, $trial_applied ) { | |
| 1517 | + $lines = array(); | |
| 1518 | + | |
| 1519 | + if ( ! is_array( $items ) ) { | |
| 1520 | + return $lines; | |
| 1521 | + } | |
| 1522 | + | |
| 1523 | + foreach ( $items as $item ) { | |
| 1524 | + if ( ! $item instanceof \WC_Order_Item_Product ) { | |
| 1525 | + continue; | |
| 1526 | + } | |
| 1527 | + | |
| 1528 | + $product = $item->get_product(); | |
| 1529 | + $product_id = $product ? (int) $product->get_id() : (int) $item->get_product_id(); | |
| 1530 | + $quantity = max( 1, (int) $item->get_quantity() ); | |
| 1531 | + | |
| 1532 | + if ( $product_id < 1 ) { | |
| 1533 | + continue; | |
| 1534 | + } | |
| 1535 | + | |
| 1536 | + $subtotal = ( $trial_applied && $product ) | |
| 1537 | + ? (float) $product->get_price() * $quantity | |
| 1538 | + : (float) $item->get_subtotal(); | |
| 1539 | + | |
| 1540 | + $lines[] = array( | |
| 1541 | + 'product_id' => $product_id, | |
| 1542 | + 'quantity' => $quantity, | |
| 1543 | + 'subtotal' => $subtotal, | |
| 1544 | + 'total' => $subtotal, | |
| 1545 | + ); | |
| 1546 | + } | |
| 1547 | + | |
| 1548 | + return $lines; | |
| 1549 | + } | |
| 1550 | + | |
| 1551 | + /** | |
| 1552 | + * Pure: the snapshotted amounts for one renewal line, or null when the | |
| 1553 | + * subscription has no snapshot for it — every subscription that was | |
| 1554 | + * already running when this shipped, which keeps renewing at the | |
| 1555 | + * product's current price exactly as it always did. | |
| 1556 | + * | |
| 1557 | + * @param mixed $snapshot Stored snapshot. | |
| 1558 | + * @param int $product_id Product (or variation) id of the line. | |
| 1559 | + * @param int $occurrence Which line of that product (0-based), for a | |
| 1560 | + * subscription holding the same product twice. | |
| 1561 | + * @return array|null `subtotal` and `total`. | |
| 1562 | + */ | |
| 1563 | + public static function renewal_line_amounts( $snapshot, $product_id, $occurrence = 0 ) { | |
| 1564 | + if ( ! is_array( $snapshot ) ) { | |
| 1565 | + return null; | |
| 1566 | + } | |
| 1567 | + | |
| 1568 | + $product_id = (int) $product_id; | |
| 1569 | + $seen = 0; | |
| 1570 | + | |
| 1571 | + foreach ( $snapshot as $line ) { | |
| 1572 | + if ( ! is_array( $line ) || ! isset( $line['product_id'] ) || (int) $line['product_id'] !== $product_id ) { | |
| 1573 | + continue; | |
| 1574 | + } | |
| 1575 | + | |
| 1576 | + if ( $seen === (int) $occurrence ) { | |
| 1577 | + return array( | |
| 1578 | + 'subtotal' => isset( $line['subtotal'] ) ? (float) $line['subtotal'] : 0.0, | |
| 1579 | + 'total' => isset( $line['total'] ) ? (float) $line['total'] : 0.0, | |
| 1580 | + ); | |
| 1581 | + } | |
| 1582 | + | |
| 1583 | + $seen++; | |
| 1584 | + } | |
| 1585 | + | |
| 1586 | + return null; | |
| 1587 | + } | |
| 1588 | + | |
| 1589 | + /** | |
| 1237 | 1590 | * Pure: whether a renewal total must be charged through Stripe at all. |
| 1238 | 1591 | * |
| 1239 | 1592 | * A renewal can legitimately come due at $0 — create_renewal_order() |
| 1240 | 1593 | * re-adds the parent's products at their CURRENT price, so a 100% sale |
| @@ -1438,9 +1791,10 @@ | ||
| 1438 | 1791 | |
| 1439 | 1792 | return array( |
| 1440 | 1793 | 'amount' => (float) $data['amount'], |
| 1441 | 1794 | 'order_id' => (string) $data['bp_order_id'], |
| 1442 | - 'payment_date' => current_time( 'mysql' ), | |
| 1795 | + // UTC, like every other writer of this table (see Classes\PaymentDateMigration). | |
| 1796 | + 'payment_date' => gmdate( 'Y-m-d H:i:s' ), | |
| 1443 | 1797 | 'source' => 'stripe', |
| 1444 | 1798 | 'transaction_id' => ! empty( $intent->id ) ? sanitize_text_field( $intent->id ) : '', |
| 1445 | 1799 | 'customer_info' => maybe_serialize( $intent ), |
| 1446 | 1800 | 'form_fields_info' => maybe_serialize( $form_fields_info ), |
| @@ -1456,8 +1810,93 @@ | ||
| 1456 | 1810 | * Activation + settlement |
| 1457 | 1811 | * ------------------------------------------------------------------- */ |
| 1458 | 1812 | |
| 1459 | 1813 | /** |
| 1814 | + * An order reached a paid status without the gateway return telling us. | |
| 1815 | + * | |
| 1816 | + * The completion contract used to be reachable only from the Stripe | |
| 1817 | + * return (and from the cron charge), so a subscription order an admin | |
| 1818 | + * marked complete by hand — or an invoiced renewal settled offline and | |
| 1819 | + * ticked off in wp-admin — activated nothing and advanced nothing, while | |
| 1820 | + * the cron went on charging the card for a period the customer had | |
| 1821 | + * already paid. Both handlers on the contract are idempotent (the | |
| 1822 | + * subscription's status meta; the renewal's settled meta), so an order | |
| 1823 | + * that DID come through the return path changes nothing here. | |
| 1824 | + * | |
| 1825 | + * @param mixed $order_id The order that changed status. | |
| 1826 | + * @return void | |
| 1827 | + */ | |
| 1828 | + public static function maybe_complete_from_status( $order_id ) { | |
| 1829 | + if ( ! function_exists( 'wc_get_order' ) ) { | |
| 1830 | + return; | |
| 1831 | + } | |
| 1832 | + | |
| 1833 | + $order = wc_get_order( $order_id ); | |
| 1834 | + | |
| 1835 | + if ( ! $order instanceof \WC_Order || $order->needs_payment() ) { | |
| 1836 | + return; | |
| 1837 | + } | |
| 1838 | + | |
| 1839 | + $is_renewal = (int) $order->get_meta( self::RENEWAL_PARENT_META ) > 0; | |
| 1840 | + | |
| 1841 | + // Already activated: nothing to do, and worth answering from meta | |
| 1842 | + // before touching the line items — this hook fires for every order on | |
| 1843 | + // the site, including one an admin re-completes. | |
| 1844 | + if ( ! $is_renewal && '' !== (string) $order->get_meta( self::STATUS_META ) ) { | |
| 1845 | + return; | |
| 1846 | + } | |
| 1847 | + | |
| 1848 | + if ( ! $is_renewal && ! self::order_contains_subscription( $order ) ) { | |
| 1849 | + return; // Ordinary purchase — the overwhelming majority. | |
| 1850 | + } | |
| 1851 | + | |
| 1852 | + /** | |
| 1853 | + * Fires for a Better Payment subscription order that became paid | |
| 1854 | + * outside the gateway return. Same contract, same consumers. | |
| 1855 | + * | |
| 1856 | + * @since 2.4.0 | |
| 1857 | + * | |
| 1858 | + * @param \WC_Order $order The paid order. | |
| 1859 | + * @param object|null $row The order's Better Payment transaction row, when it has one. | |
| 1860 | + */ | |
| 1861 | + do_action( 'better_payment/woocommerce/payment_complete', $order, self::transaction_row( $order ) ); | |
| 1862 | + } | |
| 1863 | + | |
| 1864 | + /** | |
| 1865 | + * The Better Payment transaction row an order was paid with, or null. | |
| 1866 | + * | |
| 1867 | + * The status bridge above must pass it, and this is not a nicety: the | |
| 1868 | + * engine's own consumer marks the order paid with `payment_complete()` | |
| 1869 | + * and fires the module's hook *after* that, so the status transition — | |
| 1870 | + * and therefore the bridge — runs FIRST. Passing null there would | |
| 1871 | + * activate the subscription with no row, `harvest_payment_method()` | |
| 1872 | + * would find no Checkout Session to read, and every subscription bought | |
| 1873 | + * through the normal checkout would end up with no reusable payment | |
| 1874 | + * method: correct-looking, and silently invoiced by hand forever after. | |
| 1875 | + * The row is already written and confirmed by then, so reading it back is | |
| 1876 | + * the whole fix. An order with no row (a $0 trial under a manual renewal | |
| 1877 | + * policy, an offline payment) legitimately returns null. | |
| 1878 | + * | |
| 1879 | + * @param mixed $order The order. | |
| 1880 | + * @return object|null | |
| 1881 | + */ | |
| 1882 | + public static function transaction_row( $order ) { | |
| 1883 | + if ( ! $order instanceof \WC_Order ) { | |
| 1884 | + return null; | |
| 1885 | + } | |
| 1886 | + | |
| 1887 | + $row_id = (int) $order->get_meta( '_bp_transaction_id' ); | |
| 1888 | + | |
| 1889 | + if ( $row_id < 1 ) { | |
| 1890 | + return null; | |
| 1891 | + } | |
| 1892 | + | |
| 1893 | + $row = DB::get_transaction( $row_id ); | |
| 1894 | + | |
| 1895 | + return empty( $row->id ) ? null : $row; | |
| 1896 | + } | |
| 1897 | + | |
| 1898 | + /** | |
| 1460 | 1899 | * Consume the module's payment-complete hook. A first payment on a |
| 1461 | 1900 | * subscription cart activates the subscription; a paid renewal order |
| 1462 | 1901 | * (cron-charged or manually paid) advances the parent's schedule. |
| 1463 | 1902 | * |
| @@ -1535,8 +1974,12 @@ | ||
| 1535 | 1974 | $order->update_meta_data( self::PERIOD_META, $period ); |
| 1536 | 1975 | $order->update_meta_data( self::NEXT_PAYMENT_META, (string) $next ); |
| 1537 | 1976 | $order->update_meta_data( self::USER_CANCEL_META, $user_cancel ); |
| 1538 | 1977 | $order->update_meta_data( self::RENEWAL_COUNT_META, '0' ); |
| 1978 | + // What the customer agreed to pay per cycle. A trial line checked out | |
| 1979 | + // at $0, so the snapshot takes the product's price in that case — | |
| 1980 | + // the trial is free, the subscription is not. | |
| 1981 | + $order->update_meta_data( self::PRICE_SNAPSHOT_META, self::renewal_line_snapshot( $items, $trial_applied ) ); | |
| 1539 | 1982 | |
| 1540 | 1983 | if ( $trial_applied ) { |
| 1541 | 1984 | $order->update_meta_data( self::TRIAL_APPLIED_META, 'yes' ); |
| 1542 | 1985 | } |
| @@ -1677,13 +2120,28 @@ | ||
| 1677 | 2120 | * A renewal order was paid — advance the parent's schedule and (re)set |
| 1678 | 2121 | * it to active. This is also the past_due recovery path: manually paying |
| 1679 | 2122 | * the invoiced renewal order lands here through the same hook. |
| 1680 | 2123 | * |
| 1681 | - * @param \WC_Order $renewal_order The paid renewal order. | |
| 1682 | - * @param int $parent_id Parent (subscription) order id. | |
| 2124 | + * @param mixed $renewal_order The paid renewal order (anything else is rejected). | |
| 2125 | + * @param mixed $parent_id Parent (subscription) order id. | |
| 1683 | 2126 | * @return void |
| 1684 | 2127 | */ |
| 1685 | 2128 | public static function settle_renewal( $renewal_order, $parent_id ) { |
| 2129 | + if ( ! $renewal_order instanceof \WC_Order ) { | |
| 2130 | + return; | |
| 2131 | + } | |
| 2132 | + | |
| 2133 | + // Settling advances the schedule a whole cycle, so it happens once | |
| 2134 | + // per renewal order. The same payment can arrive twice — the gateway | |
| 2135 | + // return and the order-status bridge both lead here — and the second | |
| 2136 | + // pass would give the customer a free billing period. | |
| 2137 | + if ( 'yes' === (string) $renewal_order->get_meta( self::RENEWAL_SETTLED_META ) ) { | |
| 2138 | + return; | |
| 2139 | + } | |
| 2140 | + | |
| 2141 | + $renewal_order->update_meta_data( self::RENEWAL_SETTLED_META, 'yes' ); | |
| 2142 | + $renewal_order->save(); | |
| 2143 | + | |
| 1686 | 2144 | $parent = function_exists( 'wc_get_order' ) ? wc_get_order( $parent_id ) : false; |
| 1687 | 2145 | |
| 1688 | 2146 | if ( ! $parent ) { |
| 1689 | 2147 | OrderHandler::log( 'Renewal order #' . $renewal_order->get_id() . ' paid but parent order #' . $parent_id . ' was not found.', 'error' ); |
| @@ -1752,13 +2210,36 @@ | ||
| 1752 | 2210 | if ( ! function_exists( 'wc_get_orders' ) || ! StripeService::is_configured() ) { |
| 1753 | 2211 | return; |
| 1754 | 2212 | } |
| 1755 | 2213 | |
| 2214 | + /** | |
| 2215 | + * Filters how many due subscriptions one cron run charges. Each one | |
| 2216 | + * is a Stripe round-trip, so the batch is bounded; a shop with more | |
| 2217 | + * due subscriptions than this catches up over the following runs. | |
| 2218 | + * | |
| 2219 | + * @since 2.4.0 | |
| 2220 | + * | |
| 2221 | + * @param int $limit Subscriptions per run. | |
| 2222 | + */ | |
| 2223 | + $limit = (int) apply_filters( 'better_payment/woocommerce/renewal_batch_size', 20 ); | |
| 2224 | + | |
| 2225 | + /** | |
| 2226 | + * Filters the parent-order statuses a due subscription is renewed | |
| 2227 | + * from. A subscription order parked in any other status (on-hold, | |
| 2228 | + * refunded, a custom status) is skipped in silence, which is why | |
| 2229 | + * this is filterable rather than fixed. | |
| 2230 | + * | |
| 2231 | + * @since 2.4.0 | |
| 2232 | + * | |
| 2233 | + * @param string[] $statuses Order statuses. | |
| 2234 | + */ | |
| 2235 | + $statuses = (array) apply_filters( 'better_payment/woocommerce/renewal_parent_statuses', array( 'wc-processing', 'wc-completed' ) ); | |
| 2236 | + | |
| 1756 | 2237 | $orders = wc_get_orders( |
| 1757 | 2238 | array( |
| 1758 | - 'limit' => 20, | |
| 2239 | + 'limit' => max( 1, $limit ), | |
| 1759 | 2240 | 'type' => 'shop_order', |
| 1760 | - 'status' => array( 'wc-processing', 'wc-completed' ), | |
| 2241 | + 'status' => $statuses, | |
| 1761 | 2242 | 'return' => 'objects', |
| 1762 | 2243 | 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded (limit 20), hourly cron. |
| 1763 | 2244 | array( |
| 1764 | 2245 | 'key' => self::STATUS_META, |
| @@ -1826,10 +2307,23 @@ | ||
| 1826 | 2307 | self::request_manual_renewal( $parent ); |
| 1827 | 2308 | return; |
| 1828 | 2309 | } |
| 1829 | 2310 | |
| 1830 | - $renewal = self::create_renewal_order( $parent ); | |
| 2311 | + // An invoice may already be outstanding — the renewal was invoiced | |
| 2312 | + // while renewals were manual (site policy, the customer's own | |
| 2313 | + // opt-out, or no stored card) and automatic charging has since come | |
| 2314 | + // back. Charge THAT order instead of creating a second one: the | |
| 2315 | + // customer holds a pay link for it, and two live orders for one | |
| 2316 | + // billing period is how a subscription gets paid twice. | |
| 2317 | + $renewal = self::pending_renewal_order( $parent ); | |
| 1831 | 2318 | |
| 2319 | + if ( $renewal instanceof \WC_Order ) { | |
| 2320 | + $renewal->add_order_note( __( 'Better Payment: automatic renewal resumed — this invoiced renewal is being charged instead of a new order being created.', 'better-payment' ) ); | |
| 2321 | + $renewal->save(); | |
| 2322 | + } else { | |
| 2323 | + $renewal = self::create_renewal_order( $parent ); | |
| 2324 | + } | |
| 2325 | + | |
| 1832 | 2326 | if ( ! $renewal instanceof \WC_Order ) { |
| 1833 | 2327 | OrderHandler::log( 'Could not create a renewal order for subscription order #' . $parent->get_id() . '.', 'error' ); |
| 1834 | 2328 | return; |
| 1835 | 2329 | } |
| @@ -1886,8 +2380,21 @@ | ||
| 1886 | 2380 | __( 'the charge did not succeed (status: %s)', 'better-payment' ), |
| 1887 | 2381 | ! empty( $intent->status ) ? sanitize_text_field( $intent->status ) : 'unknown' |
| 1888 | 2382 | ); |
| 1889 | 2383 | |
| 2384 | + // A mis-configured Stripe account is not a failed payment. When | |
| 2385 | + // the keys are swapped (test ↔ live, or a new account), every | |
| 2386 | + // stored customer and payment method belongs to the other | |
| 2387 | + // account, so EVERY subscription would fail at once — each one | |
| 2388 | + // set past_due and each customer emailed an invoice for a card | |
| 2389 | + // that was working yesterday. Hold the subscription instead: no | |
| 2390 | + // status change, no customer email, an order note and a log line | |
| 2391 | + // for the shop owner, and the next cron run retries. | |
| 2392 | + if ( is_wp_error( $intent ) && self::renewal_failure_is_configuration( $intent->get_error_data() ) ) { | |
| 2393 | + self::hold_for_configuration( $parent, $renewal, $reason ); | |
| 2394 | + return; | |
| 2395 | + } | |
| 2396 | + | |
| 1890 | 2397 | self::mark_past_due( $parent, $renewal, $reason ); |
| 1891 | 2398 | |
| 1892 | 2399 | /** |
| 1893 | 2400 | * Fires after an automatic subscription renewal charge fails. |
| @@ -1961,9 +2468,11 @@ | ||
| 1961 | 2468 | if ( is_wp_error( $renewal ) ) { |
| 1962 | 2469 | return null; |
| 1963 | 2470 | } |
| 1964 | 2471 | |
| 1965 | - $added = 0; | |
| 2472 | + $added = 0; | |
| 2473 | + $snapshot = $parent->get_meta( self::PRICE_SNAPSHOT_META ); | |
| 2474 | + $seen = array(); | |
| 1966 | 2475 | |
| 1967 | 2476 | foreach ( $items as $item ) { |
| 1968 | 2477 | $product = $item->get_product(); |
| 1969 | 2478 | |
| @@ -1970,9 +2479,27 @@ | ||
| 1970 | 2479 | if ( ! $product ) { |
| 1971 | 2480 | continue; |
| 1972 | 2481 | } |
| 1973 | 2482 | |
| 1974 | - $renewal->add_product( $product, $item->get_quantity() ); | |
| 2483 | + $product_id = (int) $product->get_id(); | |
| 2484 | + $occurrence = isset( $seen[ $product_id ] ) ? $seen[ $product_id ] : 0; | |
| 2485 | + | |
| 2486 | + $seen[ $product_id ] = $occurrence + 1; | |
| 2487 | + | |
| 2488 | + // The agreed price, when the subscription has one. Without a | |
| 2489 | + // snapshot — a subscription already running when this shipped — | |
| 2490 | + // add_product() prices the line from the product, which is the | |
| 2491 | + // behavior those subscriptions have always had. | |
| 2492 | + $amounts = self::renewal_line_amounts( $snapshot, $product_id, $occurrence ); | |
| 2493 | + | |
| 2494 | + $renewal->add_product( | |
| 2495 | + $product, | |
| 2496 | + $item->get_quantity(), | |
| 2497 | + null === $amounts ? array() : array( | |
| 2498 | + 'subtotal' => $amounts['subtotal'], | |
| 2499 | + 'total' => $amounts['total'], | |
| 2500 | + ) | |
| 2501 | + ); | |
| 1975 | 2502 | $added++; |
| 1976 | 2503 | } |
| 1977 | 2504 | |
| 1978 | 2505 | if ( 0 === $added ) { |
| @@ -2013,8 +2540,129 @@ | ||
| 2013 | 2540 | return $renewal; |
| 2014 | 2541 | } |
| 2015 | 2542 | |
| 2016 | 2543 | /** |
| 2544 | + * Pure: whether a failed renewal charge is the SITE's fault rather than | |
| 2545 | + * the customer's — the Stripe keys were changed, the account no longer | |
| 2546 | + * holds this customer, the request was rejected outright. | |
| 2547 | + * | |
| 2548 | + * Stripe's own taxonomy does the sorting: `card_error` is the customer | |
| 2549 | + * (declined, expired, insufficient funds), and everything else is the | |
| 2550 | + * integration — `authentication_error` (bad key), `invalid_request_error` | |
| 2551 | + * with `resource_missing` (the customer/payment method belongs to | |
| 2552 | + * another account), `api_error`, `rate_limit_error`. | |
| 2553 | + * | |
| 2554 | + * @param mixed $data WP_Error data from StripeService (`type`, `code`, …). | |
| 2555 | + * @return bool | |
| 2556 | + */ | |
| 2557 | + public static function renewal_failure_is_configuration( $data ) { | |
| 2558 | + if ( ! is_array( $data ) ) { | |
| 2559 | + return false; | |
| 2560 | + } | |
| 2561 | + | |
| 2562 | + $type = isset( $data['type'] ) ? (string) $data['type'] : ''; | |
| 2563 | + $code = isset( $data['code'] ) ? (string) $data['code'] : ''; | |
| 2564 | + | |
| 2565 | + if ( 'card_error' === $type ) { | |
| 2566 | + return false; | |
| 2567 | + } | |
| 2568 | + | |
| 2569 | + if ( in_array( $type, array( 'authentication_error', 'api_error', 'rate_limit_error', 'idempotency_error' ), true ) ) { | |
| 2570 | + return true; | |
| 2571 | + } | |
| 2572 | + | |
| 2573 | + return 'invalid_request_error' === $type | |
| 2574 | + && in_array( $code, array( 'resource_missing', 'account_invalid', 'api_key_expired' ), true ); | |
| 2575 | + } | |
| 2576 | + | |
| 2577 | + /** | |
| 2578 | + * A renewal failed for a reason the customer cannot fix. Hold the | |
| 2579 | + * subscription exactly where it is — active, due, retried on the next | |
| 2580 | + * cron run — and tell the shop owner. Nothing is emailed to the | |
| 2581 | + * customer, and the status is deliberately NOT past_due: a key swap | |
| 2582 | + * would otherwise flip every subscription on the site at once. | |
| 2583 | + * | |
| 2584 | + * @param \WC_Order $parent Parent (subscription) order. | |
| 2585 | + * @param \WC_Order|null $renewal The pending renewal order. | |
| 2586 | + * @param string $reason Failure reason for the order note/log. | |
| 2587 | + * @return void | |
| 2588 | + */ | |
| 2589 | + public static function hold_for_configuration( $parent, $renewal, $reason ) { | |
| 2590 | + $parent->add_order_note( | |
| 2591 | + sprintf( | |
| 2592 | + /* translators: %s: failure reason */ | |
| 2593 | + __( 'Better Payment: the renewal could not be charged because of a Stripe configuration problem — %s. The subscription is unchanged and the renewal will be retried; the customer has not been contacted.', 'better-payment' ), | |
| 2594 | + $reason | |
| 2595 | + ) | |
| 2596 | + ); | |
| 2597 | + | |
| 2598 | + if ( $renewal instanceof \WC_Order ) { | |
| 2599 | + self::remember_pending_renewal( $parent, $renewal ); | |
| 2600 | + } | |
| 2601 | + | |
| 2602 | + $parent->save(); | |
| 2603 | + | |
| 2604 | + OrderHandler::log( 'Subscription order #' . $parent->get_id() . ' renewal held — Stripe configuration problem: ' . $reason, 'error' ); | |
| 2605 | + | |
| 2606 | + /** | |
| 2607 | + * Fires when a renewal is held because Stripe is mis-configured. | |
| 2608 | + * | |
| 2609 | + * @since 2.4.0 | |
| 2610 | + * | |
| 2611 | + * @param \WC_Order $parent The parent (subscription) order. | |
| 2612 | + * @param \WC_Order|null $renewal The pending renewal order. | |
| 2613 | + * @param string $reason Failure reason. | |
| 2614 | + */ | |
| 2615 | + do_action( 'better_payment/woocommerce/subscription_renewal_held', $parent, $renewal, $reason ); | |
| 2616 | + } | |
| 2617 | + | |
| 2618 | + /** | |
| 2619 | + * Remember the renewal order that is awaiting payment, so the next cron | |
| 2620 | + * run charges (or re-invoices) THAT order instead of stacking a second | |
| 2621 | + * one beside it. Sets the meta on the parent without saving — callers | |
| 2622 | + * save as part of their own update. | |
| 2623 | + * | |
| 2624 | + * @param mixed $parent Parent (subscription) order. | |
| 2625 | + * @param mixed $renewal Pending renewal order. | |
| 2626 | + * @return void | |
| 2627 | + */ | |
| 2628 | + public static function remember_pending_renewal( $parent, $renewal ) { | |
| 2629 | + if ( ! $parent instanceof \WC_Order || ! $renewal instanceof \WC_Order ) { | |
| 2630 | + return; | |
| 2631 | + } | |
| 2632 | + | |
| 2633 | + $parent->update_meta_data( self::PENDING_RENEWAL_META, (string) $renewal->get_id() ); | |
| 2634 | + } | |
| 2635 | + | |
| 2636 | + /** | |
| 2637 | + * Cancel the subscription's outstanding renewal invoice, if it has one | |
| 2638 | + * and it is still unpaid, and forget it. The parent's own meta change is | |
| 2639 | + * left for the caller to save (both callers are mid-update). | |
| 2640 | + * | |
| 2641 | + * An unpaid renewal order keeps a working pay link in the customer's | |
| 2642 | + * inbox, so it has to be closed whenever the schedule it belongs to | |
| 2643 | + * stops applying — a cancellation, or a reactivation that rewrote the | |
| 2644 | + * due date. A renewal that was already PAID is never touched. | |
| 2645 | + * | |
| 2646 | + * @param mixed $parent Parent (subscription) order. | |
| 2647 | + * @param string $note Order note for the cancelled renewal. | |
| 2648 | + * @return void | |
| 2649 | + */ | |
| 2650 | + public static function close_pending_renewal( $parent, $note ) { | |
| 2651 | + if ( ! $parent instanceof \WC_Order ) { | |
| 2652 | + return; | |
| 2653 | + } | |
| 2654 | + | |
| 2655 | + $pending = self::pending_renewal_order( $parent ); | |
| 2656 | + | |
| 2657 | + if ( $pending instanceof \WC_Order ) { | |
| 2658 | + $pending->update_status( 'cancelled', (string) $note ); | |
| 2659 | + } | |
| 2660 | + | |
| 2661 | + $parent->delete_meta_data( self::PENDING_RENEWAL_META ); | |
| 2662 | + } | |
| 2663 | + | |
| 2664 | + /** | |
| 2017 | 2665 | * A renewal could not be charged: stop auto-charging (past_due) and, when |
| 2018 | 2666 | * a pending renewal order exists, email it to the customer to pay |
| 2019 | 2667 | * manually. Paying it reactivates the subscription (settle_renewal()). |
| 2020 | 2668 | * |
| @@ -2024,8 +2672,15 @@ | ||
| 2024 | 2672 | * @return void |
| 2025 | 2673 | */ |
| 2026 | 2674 | public static function mark_past_due( $parent, $renewal, $reason ) { |
| 2027 | 2675 | $parent->update_meta_data( self::STATUS_META, 'past_due' ); |
| 2676 | + | |
| 2677 | + // The invoice the customer is about to be emailed is the outstanding | |
| 2678 | + // one: a later policy change must charge it, not open a second. | |
| 2679 | + if ( $renewal instanceof \WC_Order ) { | |
| 2680 | + self::remember_pending_renewal( $parent, $renewal ); | |
| 2681 | + } | |
| 2682 | + | |
| 2028 | 2683 | $parent->add_order_note( |
| 2029 | 2684 | sprintf( |
| 2030 | 2685 | /* translators: %s: failure reason */ |
| 2031 | 2686 | __( 'Better Payment: automatic renewal failed — %s. Automatic charging is paused until the pending renewal order is paid.', 'better-payment' ), |
| @@ -2421,8 +3076,15 @@ | ||
| 2421 | 3076 | } |
| 2422 | 3077 | |
| 2423 | 3078 | $order->update_meta_data( self::STATUS_META, 'cancelled' ); |
| 2424 | 3079 | $order->add_order_note( '' !== $note ? $note : __( 'Better Payment: subscription cancelled — no further automatic renewals will be charged.', 'better-payment' ) ); |
| 3080 | + | |
| 3081 | + // An invoiced renewal is an open bill with a working pay link. Left | |
| 3082 | + // alone it outlives the subscription: the customer pays for a period | |
| 3083 | + // they cancelled, and settle_renewal() refuses to advance a | |
| 3084 | + // cancelled schedule — so the money buys nothing. | |
| 3085 | + self::close_pending_renewal( $order, __( 'Better Payment: cancelled with the subscription — this renewal is no longer due.', 'better-payment' ) ); | |
| 3086 | + | |
| 2425 | 3087 | $order->save(); |
| 2426 | 3088 | |
| 2427 | 3089 | OrderHandler::log( 'Subscription cancelled on order #' . $order->get_id() . '.' ); |
| 2428 | 3090 | |
| @@ -2470,8 +3132,13 @@ | ||
| 2470 | 3132 | // The subscription is live again — a later cancellation must record |
| 2471 | 3133 | // its own actor, never inherit this one's. |
| 2472 | 3134 | $order->delete_meta_data( self::CANCELLED_BY_TYPE_META ); |
| 2473 | 3135 | $order->delete_meta_data( self::CANCELLED_BY_META ); |
| 3136 | + | |
| 3137 | + // Reactivation just wrote a new due date, so an invoice raised | |
| 3138 | + // against the old one is stale. Leaving it payable means the next | |
| 3139 | + // renewal charge and that invoice can both be paid for one period. | |
| 3140 | + self::close_pending_renewal( $order, __( 'Better Payment: cancelled — the subscription was reactivated on a new schedule, so this renewal is no longer due.', 'better-payment' ) ); | |
| 2474 | 3141 | $order->add_order_note( |
| 2475 | 3142 | '' !== $note ? $note : sprintf( |
| 2476 | 3143 | /* translators: %s: next renewal date */ |
| 2477 | 3144 | __( 'Better Payment: subscription reactivated. Next renewal: %s.', 'better-payment' ), |
| @@ -2578,24 +3245,53 @@ | ||
| 2578 | 3245 | } |
| 2579 | 3246 | |
| 2580 | 3247 | $user = get_user_by( 'id', $user_id ); |
| 2581 | 3248 | |
| 2582 | - if ( ! $user || in_array( $role, (array) $user->roles, true ) || self::user_role_is_protected( $user->roles ) ) { | |
| 3249 | + if ( ! $user || self::user_role_is_protected( $user->roles ) ) { | |
| 2583 | 3250 | return; |
| 2584 | 3251 | } |
| 2585 | 3252 | |
| 2586 | - $user->set_role( $role ); | |
| 3253 | + $granted = (string) get_user_meta( $user_id, self::GRANTED_ROLE_META, true ); | |
| 2587 | 3254 | |
| 3255 | + if ( in_array( $role, (array) $user->roles, true ) ) { | |
| 3256 | + // Already there. Record it as ours only if we put it there — a | |
| 3257 | + // role the customer already had is not this module's to remove. | |
| 3258 | + if ( $granted === $role ) { | |
| 3259 | + return; | |
| 3260 | + } | |
| 3261 | + | |
| 3262 | + if ( '' === $granted ) { | |
| 3263 | + return; | |
| 3264 | + } | |
| 3265 | + } | |
| 3266 | + | |
| 3267 | + // ADD the subscription role and REMOVE only the one this module | |
| 3268 | + // granted last time. set_role() — what this used to call — replaces | |
| 3269 | + // every role the user has, so activating a subscription wiped a | |
| 3270 | + // membership plugin's role, a custom staff role, or plain `customer`, | |
| 3271 | + // and cancelling it "restored" whatever the settings happened to say | |
| 3272 | + // rather than what the customer actually had. Nothing outside this | |
| 3273 | + // module's own grant is touched now. | |
| 3274 | + if ( '' !== $granted && $granted !== $role && in_array( $granted, (array) $user->roles, true ) ) { | |
| 3275 | + $user->remove_role( $granted ); | |
| 3276 | + } | |
| 3277 | + | |
| 3278 | + if ( ! in_array( $role, (array) $user->roles, true ) ) { | |
| 3279 | + $user->add_role( $role ); | |
| 3280 | + } | |
| 3281 | + | |
| 3282 | + update_user_meta( $user_id, self::GRANTED_ROLE_META, $role ); | |
| 3283 | + | |
| 2588 | 3284 | $note_template = $active |
| 2589 | 3285 | /* translators: %s: role slug */ |
| 2590 | - ? __( 'Better Payment: customer role set to "%s" — subscription active.', 'better-payment' ) | |
| 3286 | + ? __( 'Better Payment: the customer was given the "%s" role — subscription active. Their other roles are unchanged.', 'better-payment' ) | |
| 2591 | 3287 | /* translators: %s: role slug */ |
| 2592 | - : __( 'Better Payment: customer role set to "%s" — subscription ended.', 'better-payment' ); | |
| 3288 | + : __( 'Better Payment: the customer was given the "%s" role — subscription ended. Their other roles are unchanged.', 'better-payment' ); | |
| 2593 | 3289 | |
| 2594 | 3290 | $order->add_order_note( sprintf( $note_template, $role ) ); |
| 2595 | 3291 | $order->save(); |
| 2596 | 3292 | |
| 2597 | - OrderHandler::log( 'Customer #' . $user_id . ' role set to ' . $role . ' for subscription order #' . $order->get_id() . '.' ); | |
| 3293 | + OrderHandler::log( 'Customer #' . $user_id . ' given the ' . $role . ' role for subscription order #' . $order->get_id() . '.' ); | |
| 2598 | 3294 | } |
| 2599 | 3295 | |
| 2600 | 3296 | /** |
| 2601 | 3297 | * Whether the order's customer holds another live (active or past_due) |
| @@ -2646,11 +3342,13 @@ | ||
| 2646 | 3342 | * meta. Safe to call whether or not WooCommerce is active — returns null |
| 2647 | 3343 | * when WooCommerce (or the order) is unavailable, and the caller keeps |
| 2648 | 3344 | * its un-hydrated base row. |
| 2649 | 3345 | * |
| 2650 | - * Dates are returned as site-local `Y-m-d H:i:s` strings — the same shape | |
| 2651 | - * as the transactions table's `payment_date` — so the React admin formats | |
| 2652 | - * them with the one date helper it already has. | |
| 3346 | + * Dates are returned as site-local `Y-m-d H:i:s` strings (the subscription | |
| 3347 | + * filter compares `start_date` in site time), and the React admin renders | |
| 3348 | + * them as wall-clock times with `formatDateCompact()`. Note the transactions | |
| 3349 | + * table's `payment_date` is different: it is stored in UTC and rendered with | |
| 3350 | + * `formatDateTime()`, which converts it to the site timezone. | |
| 2653 | 3351 | * |
| 2654 | 3352 | * @param int $subscription_id Parent (subscription) order id. |
| 2655 | 3353 | * @return array|null |
| 2656 | 3354 | * @since 2.4.0 |