| @@ -46,8 +46,10 @@ | ||
| 46 | 46 | add_action( 'wp_ajax_srfm_stripe_cancel_subscription', [ $this, 'ajax_cancel_subscription' ] ); |
| 47 | 47 | add_action( 'wp_ajax_srfm_stripe_pause_subscription', [ $this, 'ajax_pause_subscription' ] ); |
| 48 | 48 | // Hook into unified refund filter system. |
| 49 | 49 | add_filter( 'srfm_process_transaction_refund', [ $this, 'process_stripe_refund' ], 10, 2 ); |
| 50 | + // Hook into unified subscription cancellation filter system. | |
| 51 | + add_filter( 'srfm_process_subscription_cancellation', [ $this, 'process_stripe_subscription_cancellation' ], 10, 2 ); | |
| 50 | 52 | // Admin notices. |
| 51 | 53 | add_action( 'admin_notices', [ $this, 'webhook_configuration_notice' ] ); |
| 52 | 54 | } |
| 53 | 55 | |
| @@ -86,9 +88,9 @@ | ||
| 86 | 88 | |
| 87 | 89 | // Get payment record. |
| 88 | 90 | $payment = Payments::get( $payment_id ); |
| 89 | 91 | |
| 90 | - $this->payment_mode = $payment['payment_mode'] ?? 'test'; | |
| 92 | + $this->payment_mode = ! empty( $payment['mode'] ) && is_string( $payment['mode'] ) ? $payment['mode'] : 'test'; | |
| 91 | 93 | if ( ! $payment ) { |
| 92 | 94 | wp_send_json_error( [ 'message' => esc_html__( 'Payment not found in the database.', 'sureforms' ) ] ); |
| 93 | 95 | } |
| 94 | 96 | |
| @@ -100,23 +102,89 @@ | ||
| 100 | 102 | if ( empty( $payment['subscription_id'] ) ) { |
| 101 | 103 | wp_send_json_error( [ 'message' => esc_html__( 'Subscription ID not found.', 'sureforms' ) ] ); |
| 102 | 104 | } |
| 103 | 105 | |
| 104 | - // Cancel the subscription. | |
| 105 | - $cancel_result = $this->cancel_subscription( $payment['subscription_id'] ); | |
| 106 | + // Cancel via the gateway-agnostic filter so the payment's OWN gateway (Stripe or | |
| 107 | + // PayPal) performs the cancellation. Previously this called the Stripe API directly, | |
| 108 | + // which failed for PayPal subscriptions cancelled from the admin screen. Both gateways | |
| 109 | + // hook 'srfm_process_subscription_cancellation' (Stripe + PayPal), matching the | |
| 110 | + // frontend cancellation path. | |
| 111 | + $cancel_result = apply_filters( | |
| 112 | + 'srfm_process_subscription_cancellation', | |
| 113 | + [ | |
| 114 | + 'success' => false, | |
| 115 | + 'message' => __( 'Cancellation is not supported for this payment gateway.', 'sureforms' ), | |
| 116 | + ], | |
| 117 | + $payment | |
| 118 | + ); | |
| 119 | + | |
| 120 | + if ( empty( $cancel_result['success'] ) ) { | |
| 121 | + wp_send_json_error( | |
| 122 | + [ | |
| 123 | + 'message' => ! empty( $cancel_result['message'] ) && is_string( $cancel_result['message'] ) | |
| 124 | + ? esc_html( $cancel_result['message'] ) | |
| 125 | + : esc_html__( 'Subscription cancellation failed.', 'sureforms' ), | |
| 126 | + ] | |
| 127 | + ); | |
| 128 | + } | |
| 129 | + | |
| 130 | + // The gateway callback (Stripe/PayPal) is the single source of truth: it has already | |
| 131 | + // cancelled at the gateway AND persisted subscription_status + the "Subscription Canceled" | |
| 132 | + // activity log. Just report success here — mirroring the frontend cancel path — so we don't | |
| 133 | + // write the DB a second time or append a duplicate log entry. | |
| 134 | + wp_send_json_success( | |
| 135 | + [ | |
| 136 | + 'message' => ! empty( $cancel_result['message'] ) && is_string( $cancel_result['message'] ) | |
| 137 | + ? esc_html( $cancel_result['message'] ) | |
| 138 | + : esc_html__( 'Subscription cancelled successfully.', 'sureforms' ), | |
| 139 | + ] | |
| 140 | + ); | |
| 141 | + } | |
| 142 | + | |
| 143 | + /** | |
| 144 | + * Process Stripe subscription cancellation via filter system. | |
| 145 | + * | |
| 146 | + * Filter callback for 'srfm_process_subscription_cancellation'. | |
| 147 | + * Used by both admin and frontend to cancel Stripe subscriptions. | |
| 148 | + * | |
| 149 | + * @since 2.8.0 | |
| 150 | + * @param array<string,mixed> $result Default result array. | |
| 151 | + * @param array<string,mixed> $payment Payment record from database. | |
| 152 | + * @return array<string,mixed> Result with success status and message. | |
| 153 | + */ | |
| 154 | + public function process_stripe_subscription_cancellation( $result, $payment ) { | |
| 155 | + // Process Stripe payments. Stripe is the only gateway in the free plugin, and the | |
| 156 | + // `gateway` column defaults to '' for legacy/imported rows — so an empty gateway is | |
| 157 | + // treated as Stripe. Only an explicitly different gateway (e.g. 'paypal') is skipped. | |
| 158 | + if ( ! empty( $payment['gateway'] ) && 'stripe' !== $payment['gateway'] ) { | |
| 159 | + return $result; | |
| 160 | + } | |
| 161 | + | |
| 162 | + if ( empty( $payment['subscription_id'] ) || ! is_string( $payment['subscription_id'] ) ) { | |
| 163 | + return [ | |
| 164 | + 'success' => false, | |
| 165 | + 'message' => __( 'Subscription ID not found.', 'sureforms' ), | |
| 166 | + ]; | |
| 167 | + } | |
| 168 | + | |
| 169 | + $subscription_id = $payment['subscription_id']; | |
| 170 | + $this->payment_mode = ! empty( $payment['mode'] ) && is_string( $payment['mode'] ) ? $payment['mode'] : 'test'; | |
| 171 | + | |
| 172 | + $cancel_result = $this->cancel_subscription( $subscription_id ); | |
| 106 | 173 | if ( ! $cancel_result ) { |
| 107 | - wp_send_json_error( [ 'message' => esc_html__( 'Subscription cancellation failed.', 'sureforms' ) ] ); | |
| 174 | + return [ | |
| 175 | + 'success' => false, | |
| 176 | + 'message' => __( 'Subscription cancellation failed.', 'sureforms' ), | |
| 177 | + ]; | |
| 108 | 178 | } |
| 109 | 179 | |
| 110 | - // Get current logs and add cancel log entry. | |
| 180 | + // Build log entry. | |
| 111 | 181 | $current_logs = Helper::get_array_value( $payment['log'] ); |
| 112 | - | |
| 113 | - // Build log messages array. | |
| 114 | 182 | $log_messages = [ |
| 115 | 183 | sprintf( |
| 116 | 184 | /* translators: %s: Stripe subscription ID */ |
| 117 | 185 | __( 'Subscription ID: %s', 'sureforms' ), |
| 118 | - $payment['subscription_id'] | |
| 186 | + $subscription_id | |
| 119 | 187 | ), |
| 120 | 188 | sprintf( |
| 121 | 189 | /* translators: %s: payment gateway name */ |
| 122 | 190 | __( 'Payment Gateway: %s', 'sureforms' ), |
| @@ -131,33 +199,31 @@ | ||
| 131 | 199 | /* translators: %s: user display name */ |
| 132 | 200 | __( 'Canceled by: %s', 'sureforms' ), |
| 133 | 201 | wp_get_current_user()->display_name |
| 134 | 202 | ), |
| 135 | - __( 'Note: The subscription has been permanently canceled. The customer will no longer be charged and will lose access to subscription benefits.', 'sureforms' ), | |
| 136 | 203 | ]; |
| 137 | 204 | |
| 138 | - // Create new log entry. | |
| 139 | - $new_log = [ | |
| 205 | + $current_logs[] = [ | |
| 140 | 206 | 'title' => __( 'Subscription Canceled', 'sureforms' ), |
| 141 | 207 | 'created_at' => current_time( 'mysql' ), |
| 142 | 208 | 'messages' => $log_messages, |
| 143 | 209 | ]; |
| 144 | - $current_logs[] = $new_log; | |
| 145 | 210 | |
| 146 | - // Update database status to canceled (following WPForms pattern). | |
| 147 | - $updated = Payments::update( | |
| 211 | + $payment_id = isset( $payment['id'] ) && is_numeric( $payment['id'] ) ? absint( $payment['id'] ) : 0; | |
| 212 | + // Preserve the transaction `status` so the admin Refund option stays enabled | |
| 213 | + // after the customer cancels from the My Account page. | |
| 214 | + Payments::update( | |
| 148 | 215 | $payment_id, |
| 149 | 216 | [ |
| 150 | 217 | 'subscription_status' => 'canceled', |
| 151 | - 'status' => 'canceled', | |
| 152 | 218 | 'log' => $current_logs, |
| 153 | 219 | ] |
| 154 | 220 | ); |
| 155 | - if ( ! $updated ) { | |
| 156 | - wp_send_json_error( [ 'message' => esc_html__( 'Failed to update subscription status in database.', 'sureforms' ) ] ); | |
| 157 | - } | |
| 158 | 221 | |
| 159 | - wp_send_json_success( [ 'message' => esc_html__( 'Subscription canceled successfully!', 'sureforms' ) ] ); | |
| 222 | + return [ | |
| 223 | + 'success' => true, | |
| 224 | + 'message' => __( 'Subscription cancelled successfully.', 'sureforms' ), | |
| 225 | + ]; | |
| 160 | 226 | } |
| 161 | 227 | |
| 162 | 228 | /** |
| 163 | 229 | * Process Stripe payment refund via filter system. |
| @@ -201,9 +267,9 @@ | ||
| 201 | 267 | ]; |
| 202 | 268 | } |
| 203 | 269 | |
| 204 | 270 | try { |
| 205 | - $this->payment_mode = $payment['payment_mode'] ?? 'test'; | |
| 271 | + $this->payment_mode = ! empty( $payment['mode'] ) && is_string( $payment['mode'] ) ? $payment['mode'] : 'test'; | |
| 206 | 272 | |
| 207 | 273 | // Detect subscription payments and route to specialized handler (following WPForms pattern). |
| 208 | 274 | if ( isset( $payment['type'], $payment['subscription_id'] ) && ! empty( $payment['type'] ) && ! empty( $payment['subscription_id'] ) ) { |
| 209 | 275 | return $this->refund_subscription_payment_via_filter( $payment, $refund_amount, $refund_notes ); |
| @@ -393,9 +459,9 @@ | ||
| 393 | 459 | if ( ! $payment ) { |
| 394 | 460 | wp_send_json_error( [ 'message' => esc_html__( 'Payment not found in the database.', 'sureforms' ) ] ); |
| 395 | 461 | } |
| 396 | 462 | |
| 397 | - $this->payment_mode = $payment['payment_mode'] ?? 'test'; | |
| 463 | + $this->payment_mode = ! empty( $payment['mode'] ) && is_string( $payment['mode'] ) ? $payment['mode'] : 'test'; | |
| 398 | 464 | |
| 399 | 465 | // Validate it's a subscription payment. |
| 400 | 466 | if ( empty( $payment['type'] ) || 'subscription' !== $payment['type'] ) { |
| 401 | 467 | wp_send_json_error( [ 'message' => esc_html__( 'This is not a subscription payment.', 'sureforms' ) ] ); |
| @@ -766,9 +832,11 @@ | ||
| 766 | 832 | } |
| 767 | 833 | |
| 768 | 834 | // Step 3: Verify subscription payment status. |
| 769 | 835 | // Note: 'active' status is used for subscription records, while 'succeeded' is used for one-time payments. |
| 770 | - $refundable_statuses = [ 'active', 'succeeded', 'partially_refunded' ]; | |
| 836 | + // 'canceled' is accepted because the initial charge on a canceled subscription is still refundable | |
| 837 | + // (and historical rows persisted with `status='canceled'` should remain refundable). | |
| 838 | + $refundable_statuses = [ 'active', 'succeeded', 'partially_refunded', 'canceled' ]; | |
| 771 | 839 | if ( empty( $payment['status'] ) || ! in_array( $payment['status'], $refundable_statuses, true ) ) { |
| 772 | 840 | return [ |
| 773 | 841 | 'success' => false, |
| 774 | 842 | 'message' => __( 'Only active, succeeded, or partially refunded subscription payments can be refunded.', 'sureforms' ), |
| @@ -947,9 +1015,9 @@ | ||
| 947 | 1015 | if ( is_string( $charge_id ) && '' !== $charge_id ) { |
| 948 | 1016 | return $this->create_refund_by_charge( $payment, $charge_id, $refund_amount, $refund_notes ); |
| 949 | 1017 | } |
| 950 | 1018 | |
| 951 | - throw new \Exception( __( 'Unable to determine the appropriate refund method for this subscription payment.', 'sureforms' ) ); | |
| 1019 | + throw new \Exception( esc_html__( 'Unable to determine the appropriate refund method for this subscription payment.', 'sureforms' ) ); | |
| 952 | 1020 | } |
| 953 | 1021 | |
| 954 | 1022 | /** |
| 955 | 1023 | * Create refund using charge ID |