Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save andrewlimaza/c158e5fbf16533ce4c5c704d6da28510 to your computer and use it in GitHub Desktop.

Select an option

Save andrewlimaza/c158e5fbf16533ce4c5c704d6da28510 to your computer and use it in GitHub Desktop.
PMPro Group Account Upgrade/Downgrade Seats Proration
<?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