PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.4.1
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.4.1
2.4.1 2.4.0 2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.2 2.2.1 2.2.0 2.1.2 2.1.1 trunk 0.0.1 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 All 68 releases
← All changes | includes/WooCommerce/Subscriptions.php +732 -34 2.3.3 → 2.4.1 View file →
@@ -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 }
@@ -1859,9 +2353,9 @@
1859 2353 return;
1860 2354 }
1861 2355
1862 2356 $keys = StripeService::get_global_keys();
1863 - $bp_order_id = 'stripe_' . uniqid();
2357 + $bp_order_id = \Better_Payment\Lite\Classes\Handler::new_order_id( 'stripe' );
1864 2358
1865 2359 $data = array(
1866 2360 'bp_order_id' => $bp_order_id,
1867 2361 'wc_order_id' => $renewal->get_id(),
@@ -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