Last active
May 29, 2026 10:50
-
-
Save andrewlimaza/c158e5fbf16533ce4c5c704d6da28510 to your computer and use it in GitHub Desktop.
PMPro Group Account Upgrade/Downgrade Seats Proration
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * For existing group parents, show current seat count and pre-fill the seats field. | |
| * Runs before the group-accounts plugin's own pmpro_checkout_boxes hook (priority 10). | |
| */ | |
| function my_pmpro_group_parent_existing_seats_notice() { | |
| if ( ! function_exists( 'pmprogroupacct_get_settings_for_level' ) || ! class_exists( 'PMProGroupAcct_Group' ) ) { | |
| return; | |
| } | |
| $user_id = get_current_user_id(); | |
| if ( ! $user_id ) { | |
| return; | |
| } | |
| $level = pmpro_getLevelAtCheckout(); | |
| if ( empty( $level->id ) ) { | |
| return; | |
| } | |
| $settings = pmprogroupacct_get_settings_for_level( $level->id ); | |
| if ( empty( $settings ) ) { | |
| return; | |
| } | |
| $existing_group = PMProGroupAcct_Group::get_group_by_parent_user_id_and_parent_level_id( $user_id, $level->id ); | |
| if ( empty( $existing_group ) ) { | |
| return; | |
| } | |
| $existing_seats = intval( $existing_group->group_total_seats ); | |
| $ajax_url = admin_url( 'admin-ajax.php' ); | |
| $ajax_nonce = wp_create_nonce( 'my_pmpro_seats_cost_text' ); | |
| ?> | |
| <script> | |
| document.addEventListener( 'DOMContentLoaded', function () { | |
| var field = document.getElementById( 'pmprogroupacct_seats' ); | |
| if ( ! field ) { | |
| return; | |
| } | |
| // Pre-fill with existing seat count. | |
| // (We do not bump field.min — the server still allows downgrades down to the plugin's min_seats.) | |
| field.value = <?php echo (int) $existing_seats; ?>; | |
| // Live-update the level cost text whenever the seat count changes. | |
| var levelId = <?php echo (int) $level->id; ?>; | |
| var ajaxUrl = <?php echo wp_json_encode( $ajax_url ); ?>; | |
| var ajaxNonce = <?php echo wp_json_encode( $ajax_nonce ); ?>; | |
| var debounceId = null; | |
| function refreshLevelCostText() { | |
| var target = document.querySelector( '.pmpro_level_cost_text' ); | |
| if ( ! target ) { | |
| return; | |
| } | |
| var seats = parseInt( field.value, 10 ); | |
| if ( isNaN( seats ) ) { | |
| return; | |
| } | |
| var body = new URLSearchParams(); | |
| body.append( 'action', 'my_pmpro_seats_cost_text' ); | |
| body.append( '_ajax_nonce', ajaxNonce ); | |
| body.append( 'level_id', levelId ); | |
| body.append( 'pmprogroupacct_seats', seats ); | |
| fetch( ajaxUrl, { | |
| method: 'POST', | |
| credentials: 'same-origin', | |
| headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |
| body: body.toString() | |
| } ) | |
| .then( function ( r ) { return r.json(); } ) | |
| .then( function ( data ) { | |
| if ( data && data.success && data.data && typeof data.data.cost_text === 'string' ) { | |
| target.innerHTML = data.data.cost_text; | |
| } | |
| } ) | |
| .catch( function () { /* ignore */ } ); | |
| } | |
| field.addEventListener( 'input', function () { | |
| clearTimeout( debounceId ); | |
| debounceId = setTimeout( refreshLevelCostText, 250 ); | |
| } ); | |
| } ); | |
| </script> | |
| <p class="<?php echo esc_attr( pmpro_get_element_class( 'pmpro_message' ) ); ?>"> | |
| <?php | |
| printf( | |
| /* translators: %s: current seat count */ | |
| esc_html( _n( 'You currently have %s seat. Enter the new total number of seats you want.', 'You currently have %s seats. Enter the new total number of seats you want.', $existing_seats, 'pmpro-group-accounts' ) ), | |
| '<strong>' . esc_html( number_format_i18n( $existing_seats ) ) . '</strong>' | |
| ); | |
| ?> | |
| <br /> | |
| <?php | |
| if ( pmpro_isLevelRecurring( $level ) ) { | |
| // Show the customer's next billing date so they know it stays the same. | |
| $next_payment_date_str = ''; | |
| if ( class_exists( 'PMPro_Subscription' ) ) { | |
| $subscriptions = PMPro_Subscription::get_subscriptions_for_user( $user_id, $level->id ); | |
| if ( ! empty( $subscriptions ) ) { | |
| $next_payment_ts = $subscriptions[0]->get_next_payment_date( 'timestamp' ); | |
| if ( ! empty( $next_payment_ts ) ) { | |
| $next_payment_date_str = date_i18n( get_option( 'date_format' ), $next_payment_ts ); | |
| } | |
| } | |
| } | |
| if ( ! empty( $next_payment_date_str ) ) { | |
| printf( | |
| /* translators: %s: next billing date */ | |
| esc_html__( 'If you add seats, you will only be charged a prorated amount today for the remaining days in your current billing cycle. If you remove seats, you will not be charged today. Either way, your next billing date stays %s and the new seat total takes effect then.', 'pmpro-group-accounts' ), | |
| '<strong>' . esc_html( $next_payment_date_str ) . '</strong>' | |
| ); | |
| } else { | |
| esc_html_e( 'If you add seats, you will only be charged a prorated amount today for the remaining days in your current billing cycle. If you remove seats, you will not be charged today. Your billing date stays the same.', 'pmpro-group-accounts' ); | |
| } | |
| } else { | |
| esc_html_e( 'Today you will only be charged for the additional seats added to your group.', 'pmpro-group-accounts' ); | |
| } | |
| ?> | |
| </p> | |
| <?php | |
| } | |
| add_action( 'pmpro_checkout_boxes', 'my_pmpro_group_parent_existing_seats_notice', 5 ); | |
| /** | |
| * Ajax handler: return the formatted level cost text for the given level + seat count. | |
| * | |
| * `pmpro_getLevelAtCheckout()` runs the `pmpro_checkout_level` filter chain (including | |
| * our prorate filter), and reads `$_REQUEST['pmprogroupacct_seats']` — which the JS | |
| * sends. `pmpro_getLevelCost()` then renders the standard "X now and then Y per Z" string. | |
| */ | |
| function my_pmpro_seats_cost_text_ajax() { | |
| check_ajax_referer( 'my_pmpro_seats_cost_text' ); | |
| $level_id = isset( $_POST['level_id'] ) ? intval( $_POST['level_id'] ) : 0; | |
| if ( ! $level_id || ! function_exists( 'pmpro_getLevelAtCheckout' ) ) { | |
| wp_send_json_error(); | |
| } | |
| $level = pmpro_getLevelAtCheckout( $level_id ); | |
| if ( empty( $level ) ) { | |
| wp_send_json_error(); | |
| } | |
| wp_send_json_success( array( | |
| 'cost_text' => pmpro_getLevelCost( $level ), | |
| ) ); | |
| } | |
| add_action( 'wp_ajax_my_pmpro_seats_cost_text', 'my_pmpro_seats_cost_text_ajax' ); | |
| add_action( 'wp_ajax_nopriv_my_pmpro_seats_cost_text', 'my_pmpro_seats_cost_text_ajax' ); | |
| /** | |
| * Prorate a group account parent level checkout when an existing parent adds seats. | |
| * | |
| * Runs after pmprogroupacct_pmpro_checkout_level_parent (priority 10) which already | |
| * added new_total_seats × price_per_seat to initial_payment and/or billing_amount. | |
| * | |
| * What this does: | |
| * - Initial payment → price of ADDITIONAL seats only (new_total − existing_total). | |
| * - Recurring billing → price of ALL seats (new_total) — already correct, left alone. | |
| * - profile_start_date → member's current enddate (next billing date) so the | |
| * subscription cycle continues rather than restarting from today. | |
| */ | |
| function my_pmpro_prorate_parent_seat_upgrade( $level ) { | |
| // Require the group-accounts plugin to be active. | |
| if ( ! function_exists( 'pmprogroupacct_get_settings_for_level' ) || ! class_exists( 'PMProGroupAcct_Group' ) ) { | |
| return $level; | |
| } | |
| $user_id = get_current_user_id(); | |
| if ( ! $user_id ) { | |
| return $level; | |
| } | |
| // Only act on group parent levels with per-seat (fixed) pricing. | |
| $settings = pmprogroupacct_get_settings_for_level( $level->id ); | |
| if ( empty( $settings ) || $settings['pricing_model'] !== 'fixed' ) { | |
| return $level; | |
| } | |
| // Only prorate recurring levels. Non-recurring (one-time) levels with an | |
| // expiration date should charge the full amount and let PMPro extend the | |
| // enddate by their remaining days as it normally would. | |
| if ( ! pmpro_isLevelRecurring( $level ) ) { | |
| return $level; | |
| } | |
| // Only act when an existing group is being upgraded. | |
| $existing_group = PMProGroupAcct_Group::get_group_by_parent_user_id_and_parent_level_id( $user_id, $level->id ); | |
| if ( empty( $existing_group ) ) { | |
| return $level; | |
| } | |
| $price_per_seat = (float) $settings['pricing_model_settings']; | |
| $new_total_seats = intval( isset( $_REQUEST['pmprogroupacct_seats'] ) ? $_REQUEST['pmprogroupacct_seats'] : $settings['min_seats'] ); | |
| $existing_seats = intval( $existing_group->group_total_seats ); | |
| $is_downgrade = $new_total_seats < $existing_seats; | |
| $additional_seats = $is_downgrade ? 0 : ( $new_total_seats - $existing_seats ); | |
| // Fetch the existing subscription's next payment date — needed for both proration | |
| // math (upgrade) and preserving the billing anchor (downgrade). | |
| $existing_next_payment_ts = 0; | |
| if ( class_exists( 'PMPro_Subscription' ) ) { | |
| $subscriptions = PMPro_Subscription::get_subscriptions_for_user( $user_id, $level->id ); | |
| if ( ! empty( $subscriptions ) ) { | |
| $existing_next_payment_ts = (int) $subscriptions[0]->get_next_payment_date( 'timestamp' ); | |
| } | |
| } | |
| // True mid-cycle proration: charge only for the days remaining in the current | |
| // billing cycle. e.g. 5 added seats at $10/mo with 3 days left = 5 × $10 × (3/30) = $5. | |
| $proration_ratio = 1.0; | |
| if ( ! empty( $existing_next_payment_ts ) && ! empty( $level->cycle_number ) && ! empty( $level->cycle_period ) ) { | |
| $now = current_time( 'timestamp' ); | |
| $cycle_seconds = strtotime( '+' . (int) $level->cycle_number . ' ' . $level->cycle_period, $now ) - $now; | |
| $remaining_secs = max( 0, $existing_next_payment_ts - $now ); | |
| if ( $cycle_seconds > 0 ) { | |
| $proration_ratio = min( 1.0, $remaining_secs / $cycle_seconds ); | |
| } | |
| } | |
| switch ( $settings['price_application'] ) { | |
| case 'both': | |
| case 'initial': | |
| // Upgrade: charge a prorated amount for the additional seats only. | |
| // Downgrade: $0 today; the lower recurring amount takes effect next cycle. | |
| $level->initial_payment = $additional_seats * $price_per_seat * $proration_ratio; | |
| if ( $level->initial_payment > 0 && $level->initial_payment < 0.5 ) { | |
| $level->initial_payment = 1; | |
| } | |
| break; | |
| } | |
| // Always preserve the existing subscription's next_payment_date — billing rhythm | |
| // stays exactly where it was for both upgrades and downgrades. | |
| if ( ! empty( $existing_next_payment_ts ) ) { | |
| $level->profile_start_date = date( 'Y-m-d H:i:s', $existing_next_payment_ts ); | |
| } | |
| return $level; | |
| } | |
| add_filter( 'pmpro_checkout_level', 'my_pmpro_prorate_parent_seat_upgrade', 20 ); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment