Skip to content

Commit 6d04d03

Browse files
MILAN88888claudey000yal
authored
UR-4386 Feat - Allow 100% discount coupons on memberships (no minimum-payment restriction) (#1354)
* UR-4386 Feat - Allow 100% discount coupons on memberships (Stripe delayed-start subscription + one-time free order) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * UR-4386 Fix - Activate 100% Stripe subscription on schedule creation; keep pending if creation fails * UR-4386 Feat - Allow 100% coupon on PayPal subscription via future start_time (delayed start, plain REGULAR plan) * UR-4386 Fix - Guard prepare_membership_data against null post_content (PHP warning line 275) * UR-4386 Fix - Guard OrdersListTable membership filters against null post_content (PHP warning line 683) * UR-4386 Fix - Guard Orders membership filter against null post_content (PHP warning line 357) * UR-4386 Fix - Guard members list table membership filter against null post_content (PHP warning line 1119) * UR-4386: fix payment success admin email — show $0.00 for 100% coupons (all gateways) and point Click Here to the URM member page --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: yoel limbu <87514350+y000yal@users.noreply.github.com>
1 parent d0845c3 commit 6d04d03

14 files changed

Lines changed: 339 additions & 47 deletions

assets/js/modules/membership/frontend/user-registration-membership-frontend.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,12 @@
351351
user_data.membership = membership_input.val();
352352
user_data.payment_method = "free";
353353
user_data.password = password.val();
354-
if (membership_input.data("urm-pg-type") !== "free") {
354+
// UR-4386: one-time plan zeroed by a 100% coupon (data-urm-free-order="1") stays a
355+
// $0 free order and keeps payment_method="free".
356+
if (
357+
membership_input.data("urm-pg-type") !== "free" &&
358+
membership_input.attr("data-urm-free-order") !== "1"
359+
) {
355360
user_data.payment_method = $(
356361
'input[name="urm_payment_method"]:checked'
357362
).val();
@@ -1080,6 +1085,21 @@
10801085
$(".urm-pre-subtotal-divider").toggle(hasDiscount);
10811086
$(".urm-membership-sub-total-value").toggle(hasAdjustment);
10821087
$(".urm-pre-total-divider").toggle(hasAdjustment);
1088+
1089+
// UR-4386: one-time (paid) plan zeroed by a 100% coupon => hide the gateway and submit
1090+
// as a free order. Subscriptions are untouched (they keep the gateway for renewals).
1091+
if ("paid" === $this.data("urm-pg-type")) {
1092+
if (parseFloat(totalDetails.total) <= 0) {
1093+
$(".ur_payment_gateway_container").addClass("urm-d-none");
1094+
$(".stripe-container").addClass("urm-d-none");
1095+
$this.attr("data-urm-free-order", "1");
1096+
} else if ($this.attr("data-urm-free-order") === "1") {
1097+
// Discount no longer covers the full amount — restore the gateway selector.
1098+
$(".ur_payment_gateway_container").removeClass("urm-d-none");
1099+
$(".stripe-container").removeClass("urm-d-none");
1100+
$this.removeAttr("data-urm-free-order");
1101+
}
1102+
}
10831103
},
10841104
upgrade_membership: function (
10851105
data,

assets/js/modules/membership/frontend/user-registration-membership-frontend.min.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

includes/admin/settings/class-ur-members-list-table.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1116,7 +1116,7 @@ public function get_all_memberships() {
11161116
function ( $item ) {
11171117
$content = json_decode( wp_unslash( $item['post_content'] ), true );
11181118

1119-
return $content['status'];
1119+
return ! empty( $content['status'] );
11201120
}
11211121
);
11221122

includes/admin/settings/class-ur-members-menu.php

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -388,11 +388,17 @@ public function handle_actions() {
388388
return;
389389
}
390390

391-
check_admin_referer( 'bulk-users' );
391+
$action = sanitize_text_field( wp_unslash( $_REQUEST['action'] ) );
392+
393+
// edit/view only render the member for display (no state change), so they are not
394+
// nonce-gated — lets the payment-success admin email "Click Here" deep link work.
395+
// State-changing actions below stay nonce-protected.
396+
if ( ! in_array( $action, array( 'edit', 'view' ), true ) ) {
397+
check_admin_referer( 'bulk-users' );
398+
}
392399

393400
if ( current_user_can( 'edit_users' ) ) {
394401

395-
$action = sanitize_text_field( wp_unslash( $_REQUEST['action'] ) );
396402
$userids = array();
397403

398404
if ( ! empty( $_REQUEST['users'] ) ) {
@@ -1065,11 +1071,11 @@ public function render_members_edit_page() {
10651071
* @since 4.1
10661072
*/
10671073
public function render_members_view_page() {
1068-
// Verify nonce using the same action used in single_row()
1069-
if ( ! isset( $_REQUEST['_wpnonce'] ) || ! wp_verify_nonce(
1070-
sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) ),
1071-
'bulk-users'
1072-
) ) {
1074+
// Read-only member view: protect by capability, not a nonce, so the payment-success
1075+
// admin email "Click Here" deep link works (a background email can't carry a valid
1076+
// nonce). A valid nonce still passes.
1077+
$has_valid_nonce = isset( $_REQUEST['_wpnonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) ), 'bulk-users' );
1078+
if ( ! $has_valid_nonce && ! current_user_can( 'list_users' ) && ! current_user_can( 'manage_user_registration' ) ) {
10731079
wp_die( esc_html__( 'Security check failed.', 'user-registration' ) );
10741080
}
10751081

includes/admin/settings/class-ur-users-menu.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1326,11 +1326,17 @@ public function handle_actions() {
13261326
return;
13271327
}
13281328

1329-
check_admin_referer( 'bulk-users' );
1329+
$action = sanitize_text_field( wp_unslash( $_REQUEST['action'] ) );
1330+
1331+
// edit/view only render the member for display (no state change), so they are not
1332+
// nonce-gated — lets the payment-success admin email "Click Here" deep link work.
1333+
// State-changing actions below stay nonce-protected.
1334+
if ( ! in_array( $action, array( 'edit', 'view' ), true ) ) {
1335+
check_admin_referer( 'bulk-users' );
1336+
}
13301337

13311338
if ( current_user_can( 'edit_users' ) ) {
13321339

1333-
$action = sanitize_text_field( wp_unslash( $_REQUEST['action'] ) );
13341340
$userids = array();
13351341

13361342
if ( ! empty( $_REQUEST['users'] ) ) {

includes/admin/settings/emails/class-ur-settings-payment-success-admin-email.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ public static function ur_get_payment_success_admin_email() {
159159
</p>
160160
<p style="margin: 0 0 16px 0; color: #000000; font-size: 16px; line-height: 1.6;">
161161
View full payment details: <br>
162-
<a href="{{home_url}}/wp-admin/user-edit.php?user_id={{user_id}}" style="color: #4A90E2; text-decoration: none;">Click Here</a>
162+
<a href="{{home_url}}/wp-admin/admin.php?page=user-registration-users&view_user&action=edit&user_id={{user_id}}" style="color: #4A90E2; text-decoration: none;">Click Here</a>
163163
</p>
164164
<p style="margin: 0 0 16px 0; color: #000000; font-size: 16px; line-height: 1.6;">
165165
Thanks

includes/class-ur-smart-tags.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1137,10 +1137,10 @@ function ( &$value ) {
11371137

11381138
case 'payment_amount':
11391139
$payment_amount = '';
1140-
// Check if user is a member by checking if membership_tags exists and has payment amount.
1141-
if ( ! empty( $values['membership_tags'] ) && isset( $values['membership_tags']['membership_plan_payment_amount'] ) ) {
1142-
// User is a member, use membership payment amount.
1143-
$payment_amount = $values['membership_tags']['membership_plan_payment_amount'];
1140+
// Member payment: use the actual paid total, not the plan's list price.
1141+
if ( ! empty( $values['membership_tags'] ) && isset( $values['membership_tags']['membership_plan_total'] ) ) {
1142+
// membership_plan_total is discount-applied, so a 100% coupon shows $0.00.
1143+
$payment_amount = $values['membership_tags']['membership_plan_total'];
11441144
} else {
11451145
// User is not a member, find single_item field value.
11461146
// Look for any key in $values that starts with 'single_item'.

modules/membership/includes/Admin.php

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -435,25 +435,30 @@ public function process_membership_after_registration( $success_params, $valid_f
435435
// is never a valid gateway for a non-free membership.
436436
if ( 'free' !== $membership_type ) {
437437
if ( 'free' === $data['payment_method'] ) {
438-
wp_delete_user( absint( $member_id ) );
439-
wp_send_json_error( array( 'message' => esc_html__( 'Invalid payment method for this membership.', 'user-registration' ) ) );
440-
}
441-
$configured_gateways = array();
442-
if ( ! empty( $membership_meta['payment_gateways'] ) && is_array( $membership_meta['payment_gateways'] ) ) {
443-
foreach ( $membership_meta['payment_gateways'] as $gw_key => $gw_data ) {
444-
if ( isset( $gw_data['status'] ) && 'on' === $gw_data['status'] ) {
445-
$configured_gateways[] = $gw_key;
438+
// UR-4386: 'free' on a paid plan is only valid when a 100% coupon zeroes a
439+
// one-time plan (free order, no gateway). Re-validate server-side; reject a forge.
440+
if ( ! $this->is_full_discount_free_order( $membership_type, $membership_meta, $data ) ) {
441+
wp_delete_user( absint( $member_id ) );
442+
wp_send_json_error( array( 'message' => esc_html__( 'Invalid payment method for this membership.', 'user-registration' ) ) );
443+
}
444+
} else {
445+
$configured_gateways = array();
446+
if ( ! empty( $membership_meta['payment_gateways'] ) && is_array( $membership_meta['payment_gateways'] ) ) {
447+
foreach ( $membership_meta['payment_gateways'] as $gw_key => $gw_data ) {
448+
if ( isset( $gw_data['status'] ) && 'on' === $gw_data['status'] ) {
449+
$configured_gateways[] = $gw_key;
450+
}
446451
}
447452
}
448-
}
449-
// Also include globally active gateways (Settings > Payments) so that
450-
// gateways enabled site-wide are accepted even if not per-membership saved.
451-
$global_gateways = array_keys( urm_get_all_active_payment_gateways( $membership_type ) );
452-
$configured_gateways = array_unique( array_merge( $configured_gateways, $global_gateways ) );
453+
// Also include globally active gateways (Settings > Payments) so that
454+
// gateways enabled site-wide are accepted even if not per-membership saved.
455+
$global_gateways = array_keys( urm_get_all_active_payment_gateways( $membership_type ) );
456+
$configured_gateways = array_unique( array_merge( $configured_gateways, $global_gateways ) );
453457

454-
if ( ! empty( $configured_gateways ) && ! in_array( $data['payment_method'], $configured_gateways, true ) ) {
455-
wp_delete_user( absint( $member_id ) );
456-
wp_send_json_error( array( 'message' => esc_html__( 'Invalid payment method for this membership.', 'user-registration' ) ) );
458+
if ( ! empty( $configured_gateways ) && ! in_array( $data['payment_method'], $configured_gateways, true ) ) {
459+
wp_delete_user( absint( $member_id ) );
460+
wp_send_json_error( array( 'message' => esc_html__( 'Invalid payment method for this membership.', 'user-registration' ) ) );
461+
}
457462
}
458463
}
459464

@@ -598,6 +603,32 @@ public function process_membership_after_registration( $success_params, $valid_f
598603
}
599604
}
600605

606+
/**
607+
* Whether a payment_method="free" submission on a non-free plan is a legitimate 100%-coupon
608+
* free order. Only ONE-TIME (paid) plans qualify; the coupon is re-validated against the DB
609+
* so a forged payment_method="free" cannot bypass payment. UR-4386.
610+
*
611+
* @param string $membership_type Membership type (free|paid|subscription).
612+
* @param array $membership_meta Membership meta.
613+
* @param array $data Submitted registration data.
614+
* @return bool
615+
*/
616+
private function is_full_discount_free_order( $membership_type, $membership_meta, $data ) {
617+
if ( 'paid' !== $membership_type || empty( $data['coupon'] ) || ! ur_check_module_activation( 'coupon' ) ) {
618+
return false;
619+
}
620+
$coupon_details = ur_get_coupon_details( sanitize_text_field( $data['coupon'] ) );
621+
if ( empty( $coupon_details ) || empty( $coupon_details['coupon_status'] ) ) {
622+
return false;
623+
}
624+
$plan_amount = floatval( $membership_meta['amount'] ?? 0 );
625+
$discount_type = $coupon_details['coupon_discount_type'] ?? 'fixed';
626+
$discount_value = floatval( $coupon_details['coupon_discount'] ?? 0 );
627+
$discount = ( 'percent' === $discount_type ) ? ( $plan_amount * $discount_value / 100 ) : $discount_value;
628+
629+
return ( $plan_amount > 0 ) && ( 0.0 === round( max( 0, $plan_amount - $discount ), 2 ) );
630+
}
631+
601632
public function update_redirect_url_for_membership( $redirect_url, $form_id ) {
602633
$thank_you_page_id = get_option( 'user_registration_thank_you_page_id' );
603634
$login_option = ur_get_form_setting_by_key( $form_id, 'user_registration_form_setting_login_options' );

modules/membership/includes/Admin/Services/OrderService.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,9 @@ public function prepare_orders_data( $data, $member_id, $subscription, $upgrade_
131131
'transaction_id' => $transaction_id,
132132
'payment_method' => ( $data['membership_data']['payment_method'] ) ? sanitize_text_field( $data['membership_data']['payment_method'] ) : '',
133133
'total_amount' => number_format( $total, 2, '.', '' ),
134-
'status' => ( 'free' === $membership_meta['type'] || $is_admin ) ? 'completed' : 'pending',
134+
// UR-4386: a one-time free order (paid plan + 100% coupon, payment_method="free") has no
135+
// gateway step, so complete it immediately like a free plan.
136+
'status' => ( 'free' === $membership_meta['type'] || $is_admin || ( 'free' === ( $data['membership_data']['payment_method'] ?? '' ) && 0.0 === (float) $total && $coupon_discount_amount > 0 ) ) ? 'completed' : 'pending',
135137
'order_type' => $order_type,
136138
'trial_status' => ( ! empty( $upgrade_details ) && ( 'on' === $upgrade_details['trial_status'] ) ) ? 'on' : ( isset( $membership_meta['trial_status'] ) ? sanitize_text_field( $membership_meta['trial_status'] ) : 'off' ),
137139
'notes' => $note,

modules/membership/includes/Admin/Services/Paypal/NewPaypalService.php

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,13 @@ private function prepare_paypal_context( $data, $membership, $member_email, $sub
414414
'is_subscription_upgrade_revise' => false,
415415
);
416416

417+
// UR-4386: a valid 100% coupon zeroes a subscription's amount. Bill nothing now and start
418+
// one period later (future start_time) with a plain REGULAR plan, instead of a $0 TRIAL cycle.
419+
$context['is_full_discount_sub'] = $context['is_subscription']
420+
&& ! $is_upgrading
421+
&& ! empty( $coupon_details )
422+
&& 0.0 === (float) $final_amount;
423+
417424
if (
418425
$context['is_subscription'] &&
419426
$context['is_upgrading'] &&
@@ -749,8 +756,10 @@ private function create_paypal_subscription_order( $context ) {
749756
// }
750757
$has_trial = ! empty( $context['data']['trial_status'] ) && 'on' === $context['data']['trial_status'];
751758

759+
// UR-4386: full-discount subs use a plain REGULAR plan + future start_time (below), not a
760+
// discounted/$0 TRIAL override cycle.
752761
$plan_override = array();
753-
if ( ! $has_trial ) {
762+
if ( ! $has_trial && empty( $context['is_full_discount_sub'] ) ) {
754763
$plan_override = $this->build_subscription_plan_override( $context );
755764
}
756765

@@ -773,8 +782,21 @@ private function create_paypal_subscription_order( $context ) {
773782
$payload['plan'] = $plan_override;
774783
}
775784

776-
// Start time can help prevent immediate timezone confusion.
777-
$payload['start_time'] = gmdate( 'Y-m-d\TH:i:s\Z', time() + 60 );
785+
// Start time. UR-4386: for a 100% coupon the first period is free, so start billing one full
786+
// billing period out (nothing charged now); otherwise start almost immediately.
787+
if ( ! empty( $context['is_full_discount_sub'] ) ) {
788+
$sub_data = ! empty( $context['has_team'] )
789+
? array(
790+
'duration' => $context['data']['team_data']['team_duration_period'] ?? 'month',
791+
'value' => $context['data']['team_data']['team_duration_value'] ?? 1,
792+
)
793+
: ( $context['data']['subscription'] ?? array() );
794+
$value = max( 1, (int) ( $sub_data['value'] ?? 1 ) );
795+
$duration = strtolower( (string) ( $sub_data['duration'] ?? 'month' ) );
796+
$payload['start_time'] = gmdate( 'Y-m-d\TH:i:s\Z', strtotime( "+{$value} {$duration}" ) );
797+
} else {
798+
$payload['start_time'] = gmdate( 'Y-m-d\TH:i:s\Z', time() + 60 );
799+
}
778800

779801
$response = $this->create_paypal_subscription( $payload, $context['paypal_options'] );
780802

@@ -3102,9 +3124,12 @@ private function build_paypal_plan_payload( $context, $product_id ) {
31023124
),
31033125
),
31043126
);
3105-
// Coupon: plan must define a TRIAL cycle so the subscription override can set a discounted first-cycle price.
3127+
// Coupon: plan must define a TRIAL cycle so the subscription override can set a discounted
3128+
// first-cycle price. Skipped for full-discount subs (UR-4386) — they use a plain REGULAR plan
3129+
// + future start_time instead.
31063130
if (
31073131
! empty( $context['coupon_details'] ) &&
3132+
empty( $context['is_full_discount_sub'] ) &&
31083133
( empty( $context['data']['trial_status'] ) || 'on' !== $context['data']['trial_status'] )
31093134
) {
31103135
$billing_cycles = array(
@@ -3230,7 +3255,7 @@ private function build_paypal_plan_cache_key( $context ) {
32303255
'trial_status' => isset( $context['data']['trial_status'] ) ? $context['data']['trial_status'] : '',
32313256
'trial_data' => isset( $context['data']['trial_data'] ) ? $context['data']['trial_data'] : array(),
32323257
'tax_rate' => isset( $context['tax_rate'] ) ? $context['tax_rate'] : 0,
3233-
'has_coupon_cycle' => ! empty( $context['coupon_details'] ) && ( empty( $context['data']['trial_status'] ) || 'on' !== $context['data']['trial_status'] ),
3258+
'has_coupon_cycle' => ! empty( $context['coupon_details'] ) && empty( $context['is_full_discount_sub'] ) && ( empty( $context['data']['trial_status'] ) || 'on' !== $context['data']['trial_status'] ),
32343259
)
32353260
);
32363261
}

0 commit comments

Comments
 (0)