Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/paid-member-subscriptions/assets/libs/pms-add-ons-listing/pms-add-ons-listing.php
+++ b/paid-member-subscriptions/assets/libs/pms-add-ons-listing/pms-add-ons-listing.php
@@ -200,6 +200,9 @@
$documentation = '<a target="_blank" class="right" href="'. $item['doc_url'] . '">' . __( 'Documentation', $this->text_domain ) . '</a>';//phpcs:ignore
+ // allow add-ons to adjust their own action button
+ $action = apply_filters( 'pms_add_ons_listing_action', $action, $item, $this );
+
return $action . $documentation;
}
--- a/paid-member-subscriptions/extend/profile-builder/functions.php
+++ b/paid-member-subscriptions/extend/profile-builder/functions.php
@@ -44,8 +44,6 @@
if( !$user )
return $redirect_url;
- $nonce = wp_create_nonce( 'autologin-'. $user->ID .'-'. (int)( time() / 60 ) );
-
if ( wppb_get_admin_approval_option_value() === 'yes' ) {
if( !empty( $wppb_general_settings['adminApprovalOnUserRole'] ) ) {
foreach ($user->roles as $role) {
@@ -59,7 +57,11 @@
}
}
- $redirect_url = add_query_arg( array( 'autologin' => 'true', 'uid' => $user->ID, '_wpnonce' => $nonce ), $redirect_url );
+ if ( ! function_exists( 'wppb_get_autologin_query_args' ) ) {
+ return $redirect_url;
+ }
+
+ $redirect_url = add_query_arg( wppb_get_autologin_query_args( $user->ID ), $redirect_url );
return $redirect_url;
--- a/paid-member-subscriptions/includes/admin/class-admin-payments-list-table.php
+++ b/paid-member-subscriptions/includes/admin/class-admin-payments-list-table.php
@@ -244,6 +244,9 @@
// Set subscription plan if it exists
if( ! empty( $_GET['pms-filter-subscription-plan'] ) ) {
$args['subscription_plan_id'] = (int)$_GET['pms-filter-subscription-plan'];
+
+ // opt into matching Order Bumps bundle payments where the selected plan is a bump, not only the primary
+ $args['match_subscription_plan_as_bump'] = true;
}
if( ! empty( $_GET['pms-filter-payment-type'] ) ) {
@@ -499,6 +502,32 @@
$output = '<a href="' . esc_url( $url ) . '" class="pms-payment-list-subscription" title="' . ( !empty( $payment->member_subscription_id ) ? esc_html__( 'Edit Subscription', 'paid-member-subscriptions' ) : esc_html__( 'Edit Member', 'paid-member-subscriptions' ) ) . '">' . esc_html( $subscription_plan->name ) . '</a>';
+ // Order Bumps bundles: list each bump subscription on its own line after the primary, so the column reflects every subscription the payment granted
+ // - data persists in payment meta past add-on deactivation, so this lives in core rather than in the add-on; missing / empty meta short-circuits, leaving single-plan rows untouched
+ $bump_member_subscription_ids = pms_get_payment_meta( $item['id'], '_pms_order_bumps_member_subscription_ids', true );
+
+ if( !empty( $bump_member_subscription_ids ) && is_array( $bump_member_subscription_ids ) ) {
+
+ foreach( $bump_member_subscription_ids as $bump_subscription_id ) {
+
+ $bump_subscription = pms_get_member_subscription( absint( $bump_subscription_id ) );
+
+ if( empty( $bump_subscription->id ) )
+ continue;
+
+ $bump_subscription_plan = pms_get_subscription_plan( $bump_subscription->subscription_plan_id );
+
+ if( empty( $bump_subscription_plan->name ) )
+ continue;
+
+ $bump_url = add_query_arg( array( 'page' => 'pms-members-page', 'pms-action' => 'edit_member', 'subpage' => 'edit_subscription', 'subscription_id' => $bump_subscription->id ), admin_url( 'admin.php' ) );
+
+ $output .= '<br><a href="' . esc_url( $bump_url ) . '" class="pms-payment-list-subscription" title="' . esc_attr__( 'Edit Subscription', 'paid-member-subscriptions' ) . '">' . esc_html( $bump_subscription_plan->name ) . '</a>';
+
+ }
+
+ }
+
return $output;
}
@@ -662,6 +691,12 @@
// Keep only payment types that exist in the database
$payment_types = array_intersect_key( $payment_types, array_flip( $existing_types ) );
+ // Order Bumps: expose a virtual "Bundle Payment" type, but only when bundle payments exist (detected by _pms_order_items meta)
+ // - "bundle_payment" is not a real pms_payments.type value; pms_get_payments() translates it into a meta-exists clause
+ // - gated on existing data so sites that never used Order Bumps don't get a filter option that always returns empty, and sites that used it then deactivated the add-on keep the option (the data persists)
+ if( pms_payments_bundle_data_exists() )
+ $payment_types['bundle_payment'] = __( 'Bundle Payment', 'paid-member-subscriptions' );
+
set_transient( 'pms_existing_payment_types', $payment_types, HOUR_IN_SECONDS );
}
--- a/paid-member-subscriptions/includes/admin/functions-admin.php
+++ b/paid-member-subscriptions/includes/admin/functions-admin.php
@@ -1024,6 +1024,8 @@
</div>
</div>
+ <?php do_action( 'pms_admin_refund_modal_before_form', $payment->id ); ?>
+
<form id="pms-payment-refund-form">
<div class="pms-refund-modal__refund_settings">
<h3><?php esc_html_e( 'Refund Settings', 'paid-member-subscriptions' ); ?></h3>
--- a/paid-member-subscriptions/includes/admin/views/view-page-addons.php
+++ b/paid-member-subscriptions/includes/admin/views/view-page-addons.php
@@ -93,6 +93,13 @@
$pms_addons_listing->section_header_free = array( 'title' => __('Pro Add-ons', 'paid-member-subscriptions' ), 'description' => sprintf( __( 'Get access to these Add-ons with a Pro or Agency license. %sBuy now%s', 'paid-member-subscriptions' ), '<a href="https://www.cozmoslabs.com/wordpress-paid-member-subscriptions/?utm_source=pms-addons-pro&utm_medium=client-site&utm_campaign=pms-pro-addons-upsell#pricing" taget="_blank">', '</a>' ) );
$pms_addons_listing->section_versions = array( 'Paid Member Subscriptions - Pro', 'Paid Member Subscriptions - Agency', 'Paid Member Subscriptions - Dev', 'Paid Member Subscriptions - Unlimited', 'Paid Member Subscriptions Pro', 'Paid Member Subscriptions Agency', 'Paid Member Subscriptions Dev', 'Paid Member Subscriptions Unlimited' );
$pms_addons_listing->items = array(
+ array( 'slug' => 'pms-add-on-order-bumps/index.php',
+ 'type' => 'add-on',
+ 'name' => __( 'Order Bumps', 'paid-member-subscriptions' ),
+ 'description' => __( 'Allow customers to add extra subscription plans to the same checkout and purchase them in one transaction.', 'paid-member-subscriptions' ),
+ 'icon' => 'pms-add-on-order-bumps-logo.png',
+ 'doc_url' => 'https://www.cozmoslabs.com/docs/paid-member-subscriptions/add-ons/order-bumps/?utm_source=pms-addons-pro&utm_medium=client-site&utm_campaign=pms-order-bumps-addon',
+ ),
array( 'slug' => 'pms-add-on-gift-subscriptions/index.php',
'type' => 'add-on',
'name' => __( 'Gift Subscriptions', 'paid-member-subscriptions' ),
--- a/paid-member-subscriptions/includes/admin/views/view-page-basic-info.php
+++ b/paid-member-subscriptions/includes/admin/views/view-page-basic-info.php
@@ -314,6 +314,18 @@
<div class="cozmoslabs-basic-info-addons">
<div>
+ <a href="https://www.cozmoslabs.com/add-ons/order-bumps/?utm_source=pms-basic-info&utm_medium=client-site&utm_campaign=pms-order-bumps-addon" target="_blank">
+ <h4 class="pms-add-on-name"><?php esc_html_e( 'Order Bumps', 'paid-member-subscriptions' ); ?></h4>
+ </a>
+
+ <a href="https://www.cozmoslabs.com/add-ons/order-bumps/?utm_source=pms-basic-info&utm_medium=client-site&utm_campaign=pms-order-bumps-addon" target="_blank" class="pms-addon-image-container">
+ <img src="<?php echo esc_url( PMS_PLUGIN_DIR_URL ); ?>assets/images/pms-add-on-order-bumps-banner.png" alt="Order Bumps" class="pms-addon-image" />
+ </a>
+
+ <p class="cozmoslabs-description"><?php esc_html_e( 'Allow customers to add extra subscription plans to the same checkout and purchase them in one transaction.', 'paid-member-subscriptions' ); ?></p>
+ </div>
+
+ <div>
<a href="https://www.cozmoslabs.com/add-ons/gift-subscriptions/?utm_source=pms-basic-info&utm_medium=client-site&utm_campaign=pms-gift-subscriptions-addon" target="_blank">
<h4 class="pms-add-on-name"><?php esc_html_e( 'Gift Subscriptions', 'paid-member-subscriptions' ); ?></h4>
</a>
--- a/paid-member-subscriptions/includes/admin/views/view-page-payments-add-new-edit.php
+++ b/paid-member-subscriptions/includes/admin/views/view-page-payments-add-new-edit.php
@@ -136,6 +136,39 @@
</div>
<?php endif; ?>
+ <!-- Payment Bump Subscriptions -->
+ <?php
+ $bump_member_subscription_ids = ( $action == 'edit_payment' && !empty( $payment->id ) ) ? pms_get_payment_meta( $payment->id, '_pms_order_bumps_member_subscription_ids', true ) : array();
+ $bump_member_subscription_ids = is_array( $bump_member_subscription_ids ) ? array_filter( array_map( 'absint', $bump_member_subscription_ids ) ) : array();
+ ?>
+
+ <?php if( !empty( $bump_member_subscription_ids ) ): ?>
+ <div class="cozmoslabs-form-field-wrapper">
+
+ <label for="pms-payment-bump-subscriptions" class="cozmoslabs-form-field-label"><?php esc_html_e( 'Bump Subscriptions', 'paid-member-subscriptions' ); ?></label>
+
+ <span id="pms-payment-bump-subscriptions" class="readonly medium">
+ <?php
+ $bump_subscription_links = array();
+
+ foreach( $bump_member_subscription_ids as $bump_member_subscription_id ) {
+
+ $bump_subscription_links[] = sprintf(
+ '<a href="%1$s" title="%2$s">%3$s</a>',
+ esc_url( add_query_arg( array( 'page' => 'pms-members-page', 'pms-action' => 'edit_member', 'subpage' => 'edit_subscription', 'subscription_id' => $bump_member_subscription_id ), admin_url( 'admin.php' ) ) ),
+ esc_attr__( 'Edit Subscription', 'paid-member-subscriptions' ),
+ esc_html( $bump_member_subscription_id )
+ );
+
+ }
+
+ echo wp_kses_post( implode( ', ', $bump_subscription_links ) );
+ ?>
+ </span>
+
+ </div>
+ <?php endif; ?>
+
<!-- Payment Amount -->
<?php
--- a/paid-member-subscriptions/includes/admin/views/view-page-settings-misc.php
+++ b/paid-member-subscriptions/includes/admin/views/view-page-settings-misc.php
@@ -391,6 +391,18 @@
<label for="upgrade-downgrade-sign-up-fee" class="cozmoslabs-description"><?php esc_html_e( 'Charge users sign-up fees for Subscription Upgrades and Downgrades.', 'paid-member-subscriptions' ); ?></label>
</div>
</div>
+
+ <div class="cozmoslabs-form-field-wrapper cozmoslabs-toggle-switch">
+ <label class="cozmoslabs-form-field-label" for="order-summary"><?php esc_html_e( 'Order Summary', 'paid-member-subscriptions' ) ?></label>
+
+ <div class="cozmoslabs-toggle-container">
+ <input type="checkbox" id="order-summary" name="pms_misc_settings[payments][order_summary]" value="1" <?php echo ( !empty( $this->options['payments']['order_summary'] ) ? 'checked' : '' ); ?> />
+ <label class="cozmoslabs-toggle-track" for="order-summary"></label>
+ </div>
+ <div class="cozmoslabs-toggle-description">
+ <label for="order-summary" class="cozmoslabs-description"><?php esc_html_e( 'Display an order summary on checkout forms, even when no add-on requires it.', 'paid-member-subscriptions' ); ?></label>
+ </div>
+ </div>
</div>
<?php if( pms_payment_gateways_support( pms_get_payment_gateways( true ), 'plugin_scheduled_payments' ) ) : ?>
--- a/paid-member-subscriptions/includes/class-emails.php
+++ b/paid-member-subscriptions/includes/class-emails.php
@@ -527,8 +527,8 @@
'activate' => __( 'Congratulations {{display_name}}! The "{{subscription_name}}" plan has been successfully activated.', 'paid-member-subscriptions' ),
'cancel' => __( 'Hello {{display_name}}, The "{{subscription_name}}" plan has been canceled.', 'paid-member-subscriptions' ),
'expired' => __( 'Hello {{display_name}}, The "{{subscription_name}}" plan has expired.', 'paid-member-subscriptions' ),
- 'payment_failed' => __( 'Your latest payment for the "{{subscription_name}}" plan has failed. You can go to the <a href="{{account_page_url}}">account page</a> and login in order to try again.<br><br>{{automatic_retry_message}}', 'paid-member-subscriptions' ),
- 'pending_manual_payment' => __( 'Hello {{display_name}}!<br>We received your order for "{{subscription_name}}" plan.<br>You can make the payment using the following bank details:', 'paid-member-subscriptions' ),
+ 'payment_failed' => __( 'Your latest payment for <em>{{order_subscription_plans}}</em> has failed. You can go to the <a href="{{account_page_url}}">account page</a> and login in order to try again.<br><br>{{automatic_retry_message}}', 'paid-member-subscriptions' ),
+ 'pending_manual_payment' => __( 'Hello {{display_name}}!<br>We received your order for <em>{{order_subscription_plans}}</em>.<br>You can make the payment using the following bank details:', 'paid-member-subscriptions' ),
'renew' => __( 'Hello {{display_name}}, The "{{subscription_name}}" plan has been renewed.', 'paid-member-subscriptions' ),
'reset_password' => __('Someone has just requested a password reset for the following account: {{site_name}} <br> If this was a mistake, just ignore this email and nothing will happen. <br> To reset your password, visit the following link: {{reset_link}}', 'paid-member-subscriptions'),
);
@@ -544,7 +544,7 @@
'cancel' => __( 'The "{{subscription_name}}" plan has been canceled for user {{display_name}}.', 'paid-member-subscriptions' ),
'expired' => __( 'The "{{subscription_name}}" plan has expired for user {{display_name}}.', 'paid-member-subscriptions' ),
'renew' => __( 'The "{{subscription_name}}" plan was renewed for user {{display_name}}.', 'paid-member-subscriptions' ),
- 'pending_manual_payment' => __( '{{display_name}} has just placed an order for "{{subscription_name}}" plan.<br><strong>Manual Payment</strong> option was used and the status is <strong>Pending</strong>.', 'paid-member-subscriptions' ),
+ 'pending_manual_payment' => __( '{{display_name}} has just placed an order for <em>{{order_subscription_plans}}</em>.<br><strong>Manual Payment</strong> option was used and the status is <strong>Pending</strong>.', 'paid-member-subscriptions' ),
);
}
--- a/paid-member-subscriptions/includes/class-form-handler.php
+++ b/paid-member-subscriptions/includes/class-form-handler.php
@@ -31,6 +31,8 @@
add_filter( 'login_redirect', array( __CLASS__, 'validate_login_form' ), 10 ,3 );
add_action( 'pms_register_form_after_create_user', array( __CLASS__, 'automatically_log_in') );
+ add_action( 'pms_process_checkout_validations', array( __CLASS__, 'validate_checkout_subscription_ownership' ), 5 );
+
}
@@ -793,13 +795,12 @@
return;
// Get member subscription
- $member = pms_get_member( get_current_user_id() );
$member_subscription = pms_get_member_subscription( absint( $_POST['subscription_id'] ) );
if( is_null( $member_subscription ) )
return;
- if( ! in_array( $member_subscription->id, $member->get_subscription_ids() ) )
+ if( ! self::member_owns_subscription( $member_subscription, pms_get_current_user_id() ) )
return;
if( $member_subscription->status !== 'active' || ! $member_subscription->is_auto_renewing() )
@@ -898,13 +899,12 @@
return;
// Get member subscription
- $member = pms_get_member( get_current_user_id() );
$member_subscription = pms_get_member_subscription( absint( $_POST['subscription_id'] ) );
if( is_null( $member_subscription ) )
return;
- if( ! in_array( $member_subscription->id, $member->get_subscription_ids() ) )
+ if( ! self::member_owns_subscription( $member_subscription, pms_get_current_user_id() ) )
return;
// Remove subscription if confirm button was pressed
@@ -988,13 +988,12 @@
return;
// Get member and the member's subscription
- $member = pms_get_member( get_current_user_id() );
$member_subscription = pms_get_member_subscription( absint( $_POST['subscription_id'] ) );
if( is_null( $member_subscription ) )
return;
- if( ! in_array( $member_subscription->id, $member->get_subscription_ids() ) )
+ if( ! self::member_owns_subscription( $member_subscription, pms_get_current_user_id() ) )
return;
if( !$member_subscription->is_auto_renewing() || !pms_payment_gateways_support( array( $member_subscription->payment_gateway ), 'update_payment_method' ) )
@@ -1850,6 +1849,47 @@
// NOTE: Should be refactored in a new class
/**
+ * Whether the subscription row belongs to the given member.
+ *
+ * @param int|PMS_Member_Subscription|null $subscription Subscription row or id.
+ * @param int $user_id
+ * @return bool
+ */
+ public static function member_owns_subscription( $subscription, $user_id ) {
+
+ if( empty( $user_id ) )
+ return false;
+
+ if( $subscription instanceof PMS_Member_Subscription )
+ $subscription_id = (int) $subscription->id;
+ else
+ $subscription_id = absint( $subscription );
+
+ if( empty( $subscription_id ) )
+ return false;
+
+ $member = pms_get_member( $user_id );
+
+ $subscription_ids = array_map( 'absint', $member->get_subscription_ids() );
+
+ return in_array( $subscription_id, $subscription_ids, true );
+
+ }
+
+ /**
+ * Reject checkout when pms_current_subscription is not owned by the current user.
+ */
+ public static function validate_checkout_subscription_ownership() {
+
+ if( ! is_user_logged_in() || empty( $_POST['pms_current_subscription'] ) )
+ return;
+
+ if( ! self::member_owns_subscription( absint( $_POST['pms_current_subscription'] ), pms_get_current_user_id() ) )
+ pms_errors()->add( 'subscription_plans', __( 'Something went wrong.', 'paid-member-subscriptions' ) );
+
+ }
+
+ /**
* Checkout process
*
* - validates the data from the forms
@@ -2049,6 +2089,15 @@
}
+ if( ! self::member_owns_subscription( $subscription, $user_data['user_id'] ) ) {
+ pms_errors()->add( 'subscription_plans', __( 'Something went wrong.', 'paid-member-subscriptions' ) );
+
+ if( wp_doing_ajax() )
+ self::return_generated_errors_for_ajax();
+ else
+ return;
+ }
+
}
/**
--- a/paid-member-subscriptions/includes/class-merge-tags.php
+++ b/paid-member-subscriptions/includes/class-merge-tags.php
@@ -37,6 +37,8 @@
add_filter( 'pms_merge_tag_reset_key', array( $this, 'pms_tag_reset_key' ), 10, 6 );
add_filter( 'pms_merge_tag_reset_url', array( $this, 'pms_tag_reset_url' ), 10, 6 );
add_filter( 'pms_merge_tag_reset_link', array( $this, 'pms_tag_reset_link' ), 10, 6 );
+ add_filter( 'pms_merge_tag_order_subscription_plans', array( $this, 'pms_tag_order_subscription_plans' ), 10, 6 );
+ add_filter( 'pms_merge_tag_order_breakdown', array( $this, 'pms_tag_order_breakdown' ), 10, 6 );
}
@@ -100,7 +102,9 @@
'account_page_url',
'reset_key',
'reset_url',
- 'reset_link'
+ 'reset_link',
+ 'order_subscription_plans',
+ 'order_breakdown'
);
$available_merge_tags = apply_filters( 'pms_merge_tags', $available_merge_tags );
@@ -583,6 +587,124 @@
return $link;
}
}
+
+ /**
+ * Replace the {{order_subscription_plans}} tag
+ *
+ * - comma-separated, esc_html'd plan names: primary + bumps for bundle payments, just the primary for single-plan, empty otherwise
+ * - the `pms_merge_tag_order_subscription_plans_actions` filter sets which email actions resolve (defaults: pending_manual_payment, payment_failed, activate)
+ *
+ */
+ public function pms_tag_order_subscription_plans( $value, $user_info, $subscription_id, $payment_id, $action, $data ) {
+
+ $allowed_actions = apply_filters( 'pms_merge_tag_order_subscription_plans_actions', array( 'pending_manual_payment', 'payment_failed', 'activate' ) );
+
+ if( !in_array( $action, $allowed_actions, true ) )
+ return '';
+
+ if( empty( $payment_id ) )
+ return '';
+
+ $names = array();
+ $order_items_meta = pms_get_payment_meta( $payment_id, '_pms_order_items', true );
+
+ if( !empty( $order_items_meta ) && is_array( $order_items_meta ) && !empty( $order_items_meta['items'] ) && is_array( $order_items_meta['items'] ) && count( $order_items_meta['items'] ) >= 2 ) {
+
+ foreach( $order_items_meta['items'] as $item ) {
+
+ if( !empty( $item['name'] ) )
+ $names[] = $item['name'];
+
+ }
+
+ } else {
+
+ $payment = pms_get_payment( $payment_id );
+
+ if( !empty( $payment->id ) && !empty( $payment->subscription_id ) ) {
+
+ $subscription_plan = pms_get_subscription_plan( $payment->subscription_id );
+
+ if( !empty( $subscription_plan->name ) )
+ $names[] = $subscription_plan->name;
+
+ }
+
+ }
+
+ if( empty( $names ) )
+ return '';
+
+ return implode( ', ', array_map( 'esc_html', $names ) );
+
+ }
+
+ /**
+ * Replace the {{order_breakdown}} tag
+ *
+ * - bundle: `Primary: <name> (amount)` + `Bump: <name> (amount)` lines + bold total line; single-plan: one `Subscription Plan: <name> (amount)` line, no total
+ * - amounts are post-discount and post-tax (already baked into `_pms_order_items[].total` or the payment row's amount), no separate tax row
+ * - the `pms_merge_tag_order_breakdown_actions` filter sets which email actions resolve (defaults: pending_manual_payment, payment_failed, activate)
+ *
+ */
+ public function pms_tag_order_breakdown( $value, $user_info, $subscription_id, $payment_id, $action, $data ) {
+
+ $allowed_actions = apply_filters( 'pms_merge_tag_order_breakdown_actions', array( 'pending_manual_payment', 'payment_failed', 'activate' ) );
+
+ if( !in_array( $action, $allowed_actions, true ) )
+ return '';
+
+ if( empty( $payment_id ) )
+ return '';
+
+ $order_items_meta = pms_get_payment_meta( $payment_id, '_pms_order_items', true );
+
+ if( !empty( $order_items_meta ) && is_array( $order_items_meta ) && !empty( $order_items_meta['items'] ) && is_array( $order_items_meta['items'] ) && count( $order_items_meta['items'] ) >= 2 ) {
+
+ $currency = !empty( $order_items_meta['currency'] ) ? $order_items_meta['currency'] : pms_get_active_currency();
+ $lines = array();
+
+ foreach( $order_items_meta['items'] as $item ) {
+
+ if( empty( $item['name'] ) )
+ continue;
+
+ $label = ( isset( $item['type'] ) && $item['type'] === 'primary' )
+ ? esc_html__( 'Primary:', 'paid-member-subscriptions' )
+ : esc_html__( 'Bump:', 'paid-member-subscriptions' );
+
+ $lines[] = $label . ' <em>' . esc_html( $item['name'] ) . '</em> (' . pms_format_price( (float) $item['total'], $currency ) . ')';
+
+ }
+
+ if( empty( $lines ) )
+ return '';
+
+ $total = isset( $order_items_meta['total'] ) ? (float) $order_items_meta['total'] : 0;
+
+ $lines[] = '<strong>' . sprintf( esc_html__( 'Total: %s', 'paid-member-subscriptions' ), pms_format_price( $total, $currency ) ) . '</strong>';
+
+ return implode( '<br>', $lines );
+
+ }
+
+ // single-plan payment: read directly from the payment row + plan
+ $payment = pms_get_payment( $payment_id );
+
+ if( empty( $payment->id ) || empty( $payment->subscription_id ) )
+ return '';
+
+ $subscription_plan = pms_get_subscription_plan( $payment->subscription_id );
+
+ if( empty( $subscription_plan->id ) || empty( $subscription_plan->name ) )
+ return '';
+
+ $currency = !empty( $payment->currency ) ? $payment->currency : pms_get_active_currency();
+
+ return esc_html__( 'Subscription Plan:', 'paid-member-subscriptions' ) . ' <em>' . esc_html( $subscription_plan->name ) . '</em> (' . pms_format_price( $payment->amount, $currency ) . ')';
+
+ }
+
}
--- a/paid-member-subscriptions/includes/class-order-summary.php
+++ b/paid-member-subscriptions/includes/class-order-summary.php
@@ -0,0 +1,212 @@
+<?php
+
+// Exit if accessed directly
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+/**
+ * Handles Order Summary frontend placement
+ *
+ */
+class PMS_Order_Summary {
+
+ /**
+ * Whether any add-on or core feature has enabled the shared Order Summary
+ *
+ * @var bool
+ *
+ */
+ private static $enabled = false;
+
+
+ /**
+ * Initializes the Order Summary placement controller
+ *
+ */
+ public function __construct() {
+
+ // register placement after add-ons have had a chance to enable the summary
+ add_action( 'plugins_loaded', array( $this, 'register_placement_hooks' ), 20 );
+
+ // enqueue the shared frontend script when the summary is active
+ add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_order_summary_script' ), 15 );
+
+ }
+
+
+ /**
+ * Marks the shared Order Summary as enabled
+ *
+ * - add-ons call this from their constructor (via the pms_enable_order_summary() helper) to declare they need the summary rendered
+ *
+ */
+ public static function enable() {
+
+ self::$enabled = true;
+
+ }
+
+
+ /**
+ * Registers the frontend placement hooks
+ *
+ */
+ public function register_placement_hooks() {
+
+ if( !self::is_enabled() )
+ return;
+
+ // Different placement when form designs are active
+ if( function_exists( 'pms_get_active_form_design' ) && in_array( pms_get_active_form_design(), array( 'form-style-1', 'form-style-2', 'form-style-3' ) ) ){
+
+ add_action( 'pms_get_output_payment_gateways_after_paygates', array( $this, 'output_payment_gateways_order_summary' ), 60, 2 );
+ add_action( 'pms_pb_add_form_extra_fields_after_output', array( $this, 'output_order_summary' ), 60 );
+
+ } else {
+
+ add_action( 'pms_register_form_bottom', array( $this, 'output_order_summary' ), 60 );
+ add_action( 'pms_new_subscription_form_bottom', array( $this, 'output_order_summary' ), 60 );
+ add_action( 'pms_upgrade_subscription_form_bottom', array( $this, 'output_order_summary' ), 60 );
+ add_action( 'pms_renew_subscription_form_bottom', array( $this, 'output_order_summary' ), 60 );
+ add_action( 'pms_change_subscription_form_bottom', array( $this, 'output_order_summary' ), 60 );
+ add_action( 'pms_retry_payment_form_bottom', array( $this, 'output_order_summary' ), 60 );
+ add_action( 'pms_gift_subscription_form_bottom', array( $this, 'output_order_summary' ), 60 );
+ add_action( 'pms_pb_add_form_extra_fields_after_output', array( $this, 'output_order_summary' ), 60 );
+
+ }
+
+ }
+
+
+ /**
+ * Checks if the shared Order Summary is enabled
+ *
+ * @return bool
+ *
+ */
+ public static function is_enabled() {
+
+ $misc_settings = get_option( 'pms_misc_settings', array() );
+
+ return self::$enabled || !empty( $misc_settings['payments']['order_summary'] );
+
+ }
+
+
+ /**
+ * Enqueues the shared Order Summary frontend script
+ *
+ */
+ public function enqueue_order_summary_script() {
+
+ if( !self::is_enabled() )
+ return;
+
+ if( !pms_should_load_scripts() )
+ return;
+
+ $payments_settings = get_option( 'pms_payments_settings', array() );
+ $active_currency = pms_get_active_currency();
+
+ wp_enqueue_script( 'pms-order-summary', PMS_PLUGIN_DIR_URL . 'assets/js/order-summary.js', array( 'jquery' ), PMS_VERSION, true );
+
+ /**
+ * Filters the data localized for the Order Summary frontend script
+ *
+ * - addons that change the rendering currency at request time (Multiple Currencies in particular) hook this to swap `currency`, `currency_symbol` and `currency_position`
+ * - keeps the summary's static currency context in sync with the dynamically-computed per-row amounts the addon's contributors emit
+ *
+ * @param array $data Localized data passed to wp_localize_script
+ */
+ $localized_data = apply_filters( 'pms_order_summary_localize_data', array(
+ 'currency' => $active_currency,
+ 'currency_symbol' => pms_get_currency_symbol( $active_currency ),
+ 'currency_position' => pms_get_currency_position(),
+ 'zero_decimal_currencies' => pms_get_zero_decimal_currencies(),
+ 'locale' => str_replace( '_', '-', get_locale() ),
+ 'price_trim_zeroes' => ( !isset( $payments_settings['price-display-format'] ) || $payments_settings['price-display-format'] == 'without_insignificant_zeroes' ) ? 'true' : 'false',
+ 'default_item_label' => __( 'Subscription Plan', 'paid-member-subscriptions' ),
+ ) );
+
+ wp_localize_script( 'pms-order-summary', 'pms_order_summary', $localized_data );
+
+ }
+
+
+ /**
+ * Returns the Order Summary HTML
+ *
+ * @param array $args
+ *
+ * @return string
+ *
+ */
+ public static function get_output( $args = array() ) {
+
+ $defaults = array(
+ 'heading' => __( 'Your Purchase', 'paid-member-subscriptions' ),
+ );
+
+ $args = wp_parse_args( $args, $defaults );
+
+ ob_start();
+
+ include PMS_PLUGIN_DIR_PATH . 'includes/views/view-order-summary.php';
+
+ return ob_get_clean();
+
+ }
+
+
+ /**
+ * Outputs the Order Summary
+ *
+ * @param array $args
+ *
+ */
+ public static function output( $args = array() ) {
+
+ //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ echo self::get_output( $args );
+
+ }
+
+
+ /**
+ * Outputs the Order Summary as a placement-hook callback
+ *
+ * - signature adapter for the WP actions registered in register_placement_hooks (pms_register_form_bottom, pms_new_subscription_form_bottom, etc.)
+ * - those actions pass form atts and other args to their callbacks; this wrapper discards them so output() runs with a clean empty $args and the defaults apply
+ * - external callers that want to render the summary should call the static output() / get_output() directly instead of going through this method
+ *
+ */
+ public function output_order_summary() {
+
+ self::output();
+
+ }
+
+
+ /**
+ * Outputs the Order Summary after payment gateways
+ *
+ * - signature adapter for the pms_get_output_payment_gateways_after_paygates action, which fires inside the form-design rendering path
+ * - that action passes ($settings, $form_location); we read $form_location to skip Profile Builder's wppb_register flow (which has its own placement)
+ * - for every other form_location, delegates to the static output() with clean empty $args
+ *
+ * @param array $settings
+ * @param string $form_location
+ *
+ */
+ public function output_payment_gateways_order_summary( $settings, $form_location ) {
+
+ // Profile Builder has a separate placement hook in this flow
+ if( $form_location == 'wppb_register' )
+ return;
+
+ self::output();
+
+ }
+
+}
+
+$pms_order_summary = new PMS_Order_Summary();
--- a/paid-member-subscriptions/includes/features/discount-codes/includes/functions-discount.php
+++ b/paid-member-subscriptions/includes/features/discount-codes/includes/functions-discount.php
@@ -472,7 +472,7 @@
/**
* Function that checks for and returns the discount errors
* @param string $code The discount code entered
- * @param string $subscription The subscription plan ID
+ * @param int|array $subscription Single subscription plan ID, or an array of plan IDs for bundle-style checkouts (Order Bumps); eligibility passes when at least one of the supplied plans is in the discount's allowed list
* @return string
*/
function pms_in_dc_get_discount_error( $code, $subscription ){
@@ -496,8 +496,19 @@
if ( empty($subscription) )
return __('Please select a subscription plan and try again.', 'paid-member-subscriptions');
- if ( !in_array( $subscription, $discount_subscriptions ) || ( !empty( $discount_meta['pms_discount_new_users_only'][0] ) && in_array( $form_location, array( 'renew_subscription' ) ) ) ) {
- //discount not valid for this subscription
+ // $subscription is an int (single plan) or an array of ints (multi-plan); normalize to an array so the loop below accepts the discount when any one id is in the allowed list
+ $subscription_ids = is_array( $subscription ) ? array_filter( array_map( 'absint', $subscription ) ) : array( absint( $subscription ) );
+
+ $has_eligible_plan = false;
+ foreach( $subscription_ids as $plan_id ) {
+ if( in_array( $plan_id, $discount_subscriptions ) ) {
+ $has_eligible_plan = true;
+ break;
+ }
+ }
+
+ if ( !$has_eligible_plan || ( !empty( $discount_meta['pms_discount_new_users_only'][0] ) && in_array( $form_location, array( 'renew_subscription' ) ) ) ) {
+ //discount not valid for any of the supplied subscription plans
return __('The discount is not valid for this subscription plan.', 'paid-member-subscriptions');
}
--- a/paid-member-subscriptions/includes/features/discount-codes/index.php
+++ b/paid-member-subscriptions/includes/features/discount-codes/index.php
@@ -237,7 +237,10 @@
// Assemble the response
if ( !empty( $code ) && !empty( $subscription ) ) {
- $error = pms_in_dc_get_discount_error( $code, $subscription );
+ // addons that extend checkout with additional purchasable items (Order Bumps, etc.) hook this filter to append their plan IDs so the discount is validated against the whole purchase rather than the primary alone
+ $subscription_plan_ids = apply_filters( 'pms_in_dc_validate_against_plan_ids', array( $subscription ) );
+
+ $error = pms_in_dc_get_discount_error( $code, $subscription_plan_ids );
// Setup user message
if( ! empty( $error ) )
@@ -288,6 +291,20 @@
if( !empty( $discount_meta ) )
$response['recurring_payments'] = $discount_meta['pms_discount_recurring_payments'][0] == 'checked' ? 1 : 0;
+ /**
+ * Filters the discount code AJAX response before it is sent to the client
+ *
+ * - lets bundle-aware integrations (Order Bumps) augment the response with bundle totals and per-item breakdowns without forking the AJAX handler
+ * - existing fields (success.message, discounted_price, original_discounted_price, is_full_discount, recurring_payments) remain for backward compatibility; additional fields are namespaced (bundle_*) by convention
+ *
+ * @param array $response
+ * @param string $code
+ * @param int $subscription primary subscription plan id
+ * @param PMS_IN_Discount_Code $discount_obj
+ *
+ */
+ $response = apply_filters( 'pms_dc_ajax_response', $response, $code, $subscription, $discount_obj );
+
wp_send_json($response);
}
@@ -297,6 +314,25 @@
add_action( 'wp_ajax_nopriv_pms_discount_code', 'pms_in_dc_output_apply_discount_message' );
/**
+ * Returns the list of subscription plan IDs the discount code should be evaluated against, drawn from the current request
+ *
+ * - by default returns just the selected primary plan; addons that extend checkout with additional purchasable items (e.g. Order Bumps) hook pms_in_dc_validate_against_plan_ids to append their plan IDs so eligibility is checked across the whole purchase
+ * - pms_in_dc_get_discount_error() accepts the resulting array and passes eligibility when at least one of the supplied plans is in the discount's allowed list
+ *
+ */
+function pms_in_dc_collect_request_subscription_plan_ids() {
+
+ $plan_ids = array();
+
+ if( !empty( $_POST['subscription_plans'] ) )
+ $plan_ids[] = absint( $_POST['subscription_plans'] );
+
+ return apply_filters( 'pms_in_dc_validate_against_plan_ids', $plan_ids );
+
+}
+
+
+/**
* Validates the discount code on the different form
*
*/
@@ -304,10 +340,10 @@
if ( !empty($_POST['discount_code']) && !empty($_POST['subscription_plans']) ) {
- $code = sanitize_text_field( $_POST['discount_code'] );
- $subscription_plan_id = absint( $_POST['subscription_plans'] );
+ $code = sanitize_text_field( $_POST['discount_code'] );
+ $subscription_plan_ids = pms_in_dc_collect_request_subscription_plan_ids();
- $error = pms_in_dc_get_discount_error( $code, $subscription_plan_id );
+ $error = pms_in_dc_get_discount_error( $code, $subscription_plan_ids );
if ( !empty($error) ) {
pms_errors()->add('discount_error', $error);
@@ -325,10 +361,10 @@
function pms_in_dc_add_pbform_discount_error( $message ) {
if ( !empty($_POST['discount_code']) && !empty($_POST['subscription_plans']) ) {
- $code = sanitize_text_field( $_POST['discount_code'] );
- $subscription_plan_id = absint( $_POST['subscription_plans'] );
+ $code = sanitize_text_field( $_POST['discount_code'] );
+ $subscription_plan_ids = pms_in_dc_collect_request_subscription_plan_ids();
- $error = pms_in_dc_get_discount_error( $code, $subscription_plan_id );
+ $error = pms_in_dc_get_discount_error( $code, $subscription_plan_ids );
if ( !empty( $error ) )
$message = $error;
@@ -404,39 +440,56 @@
if( empty( $_POST['subscription_plans'] ) )
return $payment_data;
- $subscription_plan_id = (int)$_POST['subscription_plans'];
+ $subscription_plan_ids = pms_in_dc_collect_request_subscription_plan_ids();
- $error = pms_in_dc_get_discount_error( $discount->code, $subscription_plan_id );
+ $error = pms_in_dc_get_discount_error( $discount->code, $subscription_plan_ids );
if ( !empty( $error ) )
return $payment_data;
- $exclude_signup_fee = apply_filters( 'pms_discount_exclude_signup_fee', false, $discount );
- $signup_fee_amount = 0;
+ /**
+ * Filters whether to skip the flat-amount discount override on payment_data
+ *
+ * - addons that compute per-item discount math before this filter runs (e.g. Order Bumps' order builder writes the post-discount aggregate to $payment_data['amount']) hook this to return true so the single-plan override below does not double-discount
+ *
+ * @param bool $skip
+ * @param PMS_IN_Discount_Code $discount
+ *
+ */
+ $skip_payment_data_discount = (bool) apply_filters( 'pms_in_dc_skip_payment_data_discount', false, $discount );
+
+ if( !$skip_payment_data_discount ) {
- if( $exclude_signup_fee ){
- $subscription_plan = pms_get_subscription_plan( $subscription_plan_id );
+ $subscription_plan_id = (int) $_POST['subscription_plans'];
+
+ $exclude_signup_fee = apply_filters( 'pms_discount_exclude_signup_fee', false, $discount );
+ $signup_fee_amount = 0;
+
+ if( $exclude_signup_fee ){
+ $subscription_plan = pms_get_subscription_plan( $subscription_plan_id );
- if( !empty( $subscription_plan->sign_up_fee ) ){
- $form_location = PMS_Form_Handler::get_request_form_location();
+ if( !empty( $subscription_plan->sign_up_fee ) ){
+ $form_location = PMS_Form_Handler::get_request_form_location();
- if( !is_user_logged_in() || in_array( $form_location, apply_filters( 'pms_checkout_signup_fee_form_locations', array( 'register', 'new_subscription', 'retry_payment', 'register_email_confirmation', 'change_subscription', 'wppb_register' ) ) ) ){
- $signup_fee_amount = (float)$subscription_plan->sign_up_fee;
+ if( !is_user_logged_in() || in_array( $form_location, apply_filters( 'pms_checkout_signup_fee_form_locations', array( 'register', 'new_subscription', 'retry_payment', 'register_email_confirmation', 'change_subscription', 'wppb_register' ) ) ) ){
+ $signup_fee_amount = (float)$subscription_plan->sign_up_fee;
+ }
}
}
- }
- if( $signup_fee_amount > 0 ){
- $payment_data['sign_up_amount'] = pms_in_calculate_discounted_amount( $payment_data['amount'] - $signup_fee_amount, $discount ) + $signup_fee_amount;
- } else {
- $payment_data['sign_up_amount'] = pms_in_calculate_discounted_amount( $payment_data['amount'], $discount );
- }
+ if( $signup_fee_amount > 0 ){
+ $payment_data['sign_up_amount'] = pms_in_calculate_discounted_amount( $payment_data['amount'] - $signup_fee_amount, $discount ) + $signup_fee_amount;
+ } else {
+ $payment_data['sign_up_amount'] = pms_in_calculate_discounted_amount( $payment_data['amount'], $discount );
+ }
- if( false == $payment_data['recurring'] )
- $payment_data['amount'] = $payment_data['sign_up_amount'];
+ if( false == $payment_data['recurring'] )
+ $payment_data['amount'] = $payment_data['sign_up_amount'];
- if( true == $payment_data['recurring'] && ! empty( $discount->recurring_payments ) )
- $payment_data['amount'] = $payment_data['sign_up_amount'];
+ if( true == $payment_data['recurring'] && ! empty( $discount->recurring_payments ) )
+ $payment_data['amount'] = $payment_data['sign_up_amount'];
+
+ }
// Save corresponding discount code for the payment in the db
@@ -454,11 +507,14 @@
'discount_code' => $discount->code
);
- // Update payment amount if it was discounted
- if ( !is_null($payment_data['sign_up_amount']) ) {
+ // non-skipped paths set sign_up_amount above to the post-discount amount; skipped paths (addons that already computed per-item math) leave it unset, so fall back to $payment_data['amount'] which was written by the addon's order builder
+ $effective_amount = isset( $payment_data['sign_up_amount'] ) ? $payment_data['sign_up_amount'] : ( isset( $payment_data['amount'] ) ? $payment_data['amount'] : null );
+
+ // Update payment amount if it was discounted; auto-complete on a zero effective amount so $0 single-plan flows and $0 addon-extended flows both reach the activation handler
+ if ( !is_null( $effective_amount ) ) {
- $update_args['amount'] = $payment_data['sign_up_amount'];
- $update_args['status'] = $payment_data['sign_up_amount'] == 0 ? 'completed' : $payment->status;
+ $update_args['amount'] = $effective_amount;
+ $update_args['status'] = $effective_amount == 0 ? 'completed' : $payment->status;
}
--- a/paid-member-subscriptions/includes/features/discount-codes/views/view-meta-box-discount-codes.php
+++ b/paid-member-subscriptions/includes/features/discount-codes/views/view-meta-box-discount-codes.php
@@ -27,24 +27,16 @@
<div class="pms-meta-box-field-wrapper cozmoslabs-form-field-wrapper">
- <label for="pms-discount-type" class="pms-meta-box-field-label cozmoslabs-form-field-label"><?php esc_html_e( 'Type', 'paid-member-subscriptions' ); ?></label>
+ <label for="pms-discount-amount" class="pms-meta-box-field-label cozmoslabs-form-field-label"><?php esc_html_e( 'Discount Amount / Type', 'paid-member-subscriptions' ); ?></label>
+
+ <input type="text" id="pms-discount-amount" name="pms_discount_amount" class="small" value="<?php echo esc_attr( $discount->amount ); ?>" />
<select id="pms-discount-type" name="pms_discount_type">
<option value="percent" <?php selected( 'percent', $discount->type, true ); ?>><?php esc_html_e( 'Percent', 'paid-member-subscriptions' ); ?></option>
<option value="fixed" <?php selected( 'fixed', $discount->type, true ); ?>><?php esc_html_e( 'Fixed amount', 'paid-member-subscriptions' ); ?></option>
</select>
- <p class="cozmoslabs-description cozmoslabs-description-space-left"><?php esc_html_e( 'The type of discount to apply for the purchase.', 'paid-member-subscriptions' ); ?></p>
-
-</div>
-
-
-<div class="pms-meta-box-field-wrapper cozmoslabs-form-field-wrapper">
-
- <label for="pms-discount-amount" class="pms-meta-box-field-label cozmoslabs-form-field-label"><?php esc_html_e( 'Amount', 'paid-member-subscriptions' ); ?></label>
-
- <input type="text" id="pms-discount-amount" name="pms_discount_amount" class="small" value="<?php echo esc_attr( $discount->amount ); ?>" /> <span class="pms-discount-currency"> <?php echo esc_html( pms_get_active_currency() ); ?></span>
- <p class="cozmoslabs-description cozmoslabs-description-space-left"><?php esc_html_e( 'Enter the discount amount.', 'paid-member-subscriptions' ); ?></p>
+ <p class="cozmoslabs-description cozmoslabs-description-space-left"><?php esc_html_e( 'Set the discount applied to eligible purchases.', 'paid-member-subscriptions' ); ?></p>
</div>
--- a/paid-member-subscriptions/includes/functions-bundle-payments.php
+++ b/paid-member-subscriptions/includes/functions-bundle-payments.php
@@ -0,0 +1,688 @@
+<?php
+
+// Exit if accessed directly
+if ( ! defined( 'ABSPATH' ) ) exit;
+
+/*
+ * Bundle (Order Bumps) payment support that lives in core
+ *
+ * - the Order Bumps add-on writes bundle data (_pms_order_items and the related bump meta) on a payment at checkout
+ * - the surfaces below read that persisted data, so they are kept in core and keep working after the add-on is deactivated: Payments list filtering, refund cascade, refund modal notice, the bundle subscription-log messages and the per-plan amount breakdown on the Payments list and payment-history amount cells
+ *
+ */
+
+
+/**
+ * Returns whether any Order Bumps bundle payments exist in the database
+ *
+ * - detected by the presence of `_pms_order_items` payment meta, written on bundle checkout
+ * - lives in core (not the add-on) so admin surfaces that read persisted bundle data keep working after the add-on is deactivated; result is cached because it gates UI that renders on every Payments list table load
+ *
+ */
+function pms_payments_bundle_data_exists() {
+
+ $exists = get_transient( 'pms_payments_bundle_data_exists' );
+
+ if( $exists === false ) {
+
+ global $wpdb;
+
+ $found = $wpdb->get_var( "SELECT 1 FROM {$wpdb->prefix}pms_paymentmeta WHERE meta_key = '_pms_order_items' LIMIT 1" );
+
+ $exists = !empty( $found ) ? 'yes' : 'no';
+
+ set_transient( 'pms_payments_bundle_data_exists', $exists, HOUR_IN_SECONDS );
+
+ }
+
+ return $exists === 'yes';
+
+}
+
+
+/**
+ * Returns payment IDs whose Order Bumps bundle includes the given subscription plan as a bump
+ *
+ * - reads `_pms_order_bumps_subscription_plan_ids` payment meta (the list of bump plan IDs stored per bundle payment); the SQL prefilter uses a LIKE on the serialized integer fragment, then each candidate is unserialized and verified with in_array so a serialized-key coincidence cannot false-match
+ * - lives in core so the Payments list table's plan filter keeps matching bumps after the add-on is deactivated (the meta persists)
+ *
+ */
+function pms_get_payment_ids_with_plan_as_bump( $plan_id ) {
+
+ global $wpdb;
+
+ $plan_id = absint( $plan_id );
+
+ if( empty( $plan_id ) )
+ return array();
+
+ $rows = $wpdb->get_results( $wpdb->prepare(
+ "SELECT payment_id, meta_value FROM {$wpdb->prefix}pms_paymentmeta WHERE meta_key = %s AND meta_value LIKE %s",
+ '_pms_order_bumps_subscription_plan_ids',
+ '%i:' . $plan_id . ';%'
+ ), ARRAY_A );
+
+ if( empty( $rows ) )
+ return array();
+
+ $matched = array();
+
+ foreach( $rows as $row ) {
+
+ $bump_plan_ids = maybe_unserialize( $row['meta_value'] );
+
+ if( !is_array( $bump_plan_ids ) )
+ continue;
+
+ if( in_array( $plan_id, array_map( 'absint', $bump_plan_ids ), true ) )
+ $matched[] = (int) $row['payment_id'];
+
+ }
+
+ return $matched;
+
+}
+
+
+/**
+ * Injects the bundle-specific conditions into the payments query WHERE clause
+ *
+ * - keeps add-on-specific SQL out of the core pms_get_payments() / pms_get_payments_count() functions: core builds a plain WHERE clause and exposes it on pms_get_payments_query_where, this callback rewrites the relevant fragments
+ * - "bundle_payment" is a virtual type with no payment row column, so the literal type match core emits is replaced with an EXISTS on the bundle's _pms_order_items meta
+ * - the opt-in plan-as-bump match widens the scalar plan condition to also include bundle payments where the plan is a bump, not only the primary
+ * - runs before $wpdb->prepare, so it only rewrites pre-sanitized integer fragments and never touches the %d / %s placeholders
+ *
+ */
+function pms_bundle_payments_filter_query_where( $query_where, $args ) {
+
+ global $wpdb;
+
+ if( !empty( $args['type'] ) && $args['type'] === 'bundle_payment' ) {
+ $query_where = str_replace(
+ "pms_payments.type LIKE 'bundle_payment'",
+ "EXISTS (SELECT 1 FROM {$wpdb->prefix}pms_paymentmeta pms_bundle_meta WHERE pms_bundle_meta.payment_id = pms_payments.id AND pms_bundle_meta.meta_key = '_pms_order_items')",
+ $query_where
+ );
+ }
+
+ // only the scalar plan match is widened; the array (IN) form is left untouched, mirroring the original inline behavior
+ if( !empty( $args['match_subscription_plan_as_bump'] ) && !empty( $args['subscription_plan_id'] ) && !is_array( $args['subscription_plan_id'] ) ) {
+
+ $subscription_plan_id = (int) trim( $args['subscription_plan_id'] );
+ $bump_payment_ids = pms_get_payment_ids_with_plan_as_bump( $subscription_plan_id );
+
+ if( !empty( $bump_payment_ids ) ) {
+
+ $bump_payment_ids = implode( ',', array_map( 'absint', $bump_payment_ids ) );
+
+ $query_where = str_replace(
+ "pms_payments.subscription_plan_id = {$subscription_plan_id}",
+ "( pms_payments.subscription_plan_id = {$subscription_plan_id} OR pms_payments.id IN ({$bump_payment_ids}) )",
+ $query_where
+ );
+
+ }
+
+ }
+
+ return $query_where;
+
+}
+add_filter( 'pms_get_payments_query_where', 'pms_bundle_payments_filter_query_where', 10, 2 );
+
+
+/**
+ * Cancels or expires Order Bump subscriptions when their bundle payment is refunded
+ *
+ * - refund-side counterpart to PMS's existing primary-subscription refund handling; the three refund paths (admin refund button, Stripe charge.refunded webhook, PayPal PAYMENT.CAPTURE.REFUNDED webhook) all converge on pms_payment_update with status refunded, so a single handler covers every refund path
+ * - lives in core so existing bundle payments still cascade correctly when the Order Bumps add-on is deactivated after checkout - the bump member subscriptions and the bundle payment meta remain in the database regardless of the add-on's state
+ * - mirrors the primary subscription's chosen fate across every bump in the bundle: admin's subscription_status_after choice on the admin button, gateway-refund-behavior misc setting on webhooks; idempotent on replayed webhooks via the per-bump status check inside the loop
+ * - trial-anchored bundles refunded while the primary is still in its trial window also force-expire the primary subscription (the customer has no recurring charge yet, so the "keep access until billing date" canceled-state semantics do not apply); admin choice still drives the bump cascade in this case
+ *
+ */
+function pms_cascade_bundle_payment_refund( $payment_id, $data, $old_payment ) {
+
+ if( !isset( $data['status'] ) || $data['status'] !== 'refunded' )
+ return;
+
+ // only initial bundle payments carry _pms_order_items meta; single-plan payments and later renewal payments short-circuit here
+ $order_items = pms_get_payment_meta( $payment_id, '_pms_order_items', true );
+
+ if( empty( $order_items ) || !is_array( $order_items ) )
+ return;
+
+ // skip stale lineage: when a failed bundle payment was replaced by a successful retry, the retry adopted this bundle's subscriptions. Refunding the old superseded row must not cascade into the live retry lineage and expire/cancel its active subscriptions
+ $superseded_by_payment_id = pms_get_payment_meta( $payment_id, '_pms_order_bumps_superseded_by_payment_id', true );
+
+ if( !empty( $superseded_by_payment_id ) )
+ return;
+
+ $cascade = pms_resolve_bundle_refund_cascade( $payment_id );
+
+ if( empty( $cascade['bump_target_status'] ) && empty( $cascade['force_primary_expire'] ) )
+ return;
+
+ if( !empty( $cascade['bump_target_status'] ) ) {
+
+ $bump_member_subscription_ids = pms_get_payment_meta( $payment_id, '_pms_order_bumps_member_subscription_ids', true );
+
+ if( !empty( $bump_member_subscription_ids ) && is_array( $bump_member_subscription_ids ) ) {
+
+ foreach( $bump_member_subscription_ids as $bump_member_subscription_id ) {
+
+ $bump = pms_get_member_subscription( absint( $bump_member_subscription_id ) );
+
+ if( empty( $bump->id ) )
+ continue;
+
+ // idempotency: a replayed pms_payment_update on an already-cascaded bundle is a no-op for bumps already in the target status
+ if( $bump->status === $cascade['bump_target_status'] )
+ continue;
+
+ // pms_resolve_bundle_refund_cascade() narrows the value to either 'canceled' or 'expired' for cascading paths, so the else branch below covers the 'expired' case
+ if( $cascade['bump_target_status'] === 'canceled' ) {
+ $subscription_data = array(
+ 'status' => 'canceled',
+ 'expiration_date' => !empty( $bump->billing_next_payment ) ? $bump->billing_next_payment : $bump->expiration_date,
+ 'billing_next_payment' => '',
+ );
+ } else {
+ $subscription_data = array(
+ 'status' => 'expired',
+ 'expiration_date' => date( 'Y-m-d H:i:s' ),
+ 'billing_next_payment' => '',
+ 'billing_duration' => '',
+ 'billing_duration_unit' => '',
+ );
+ }
+
+ if( $bump->update( $subscription_data ) ) {
+ pms_add_member_subscription_log( $bump->id, 'order_bump_canceled_via_refund', array(
+ 'bundle_payment_id' => $payment_id,
+ 'new_status' => $cascade['bump_target_status'],
+ ) );
+ }
+
+ }
+
+ }
+
+ }
+
+ if( !empty( $cascade['force_primary_expire'] ) ) {
+
+ $primary_member_subscription_id = (int) pms_get_payment_meta( $payment_id, 'subscription_id', true );
+
+ if( !empty( $primary_member_subscription_id ) ) {
+
+ $primary_subscription = pms_get_member_subscription( $primary_member_subscription_id );
+
+ // idempotency: replayed pms_payment_update on an already-expired primary is a no-op
+ if( !empty( $primary_subscription->id ) && $primary_subscription->status !== 'expired' ) {
+
+ $primary_subscription->update( array(
+ 'status' => 'expired',
+ 'expiration_date' => date( 'Y-m-d H:i:s' ),
+ 'trial_end' => '',
+ 'billing_next_payment' => '',
+ 'billing_duration' => '',
+ 'billing_duration_unit' => '',
+ ) );
+
+ pms_add_member_subscription_log( $primary_subscription->id, 'bundle_primary_trial_refunded', array(
+ 'bundle_payment_id' => $payment_id,
+ ) );
+
+ }
+
+ }
+
+ }
+
+}
+add_action( 'pms_payment_update', 'pms_cascade_bundle_payment_refund', 10, 3 );
+
+
+/**
+ * Resolves the target subscription status for a bundle refund cascade
+ *
+ * - admin refunds carry $_POST['pms_refund_data'] and mirror the admin's explicit subscription_status_after choice (current_status / canceled / expired); an unrecognised admin choice falls back to expired so a tampered or otherwise unexpected value still ends access safely
+ * - gateway webhooks do not carry pms_refund_data, so the cascade mirrors the same gateway-refund-behavior misc setting that controls PMS's primary expiration: when the setting is on, no cascade; when off (the default), expired
+ * - trial-anchored bundles (identified by _pms_order_bumps_primary_trial_end payment meta with a future timestamp) additionally force the primary subscription to expire even when the admin chose current_status, because no recurring charge ex