JezK
Edit File: AjaxEndpoints.php
<?php namespace FluentForm\App\Modules\Payments; if (!defined('ABSPATH')) { exit; // Exit if accessed directly. } use FluentForm\App\Helpers\Helper; use FluentForm\App\Models\Form; use FluentForm\App\Models\Submission; use FluentForm\App\Models\Subscription; use FluentForm\App\Models\Transaction; use FluentForm\App\Modules\Form\FormFieldsParser; use FluentForm\App\Modules\Payments\PaymentMethods\BaseProcessor; use FluentForm\Database\Migrations\Submissions; use FluentForm\Framework\Helpers\ArrayHelper; use FluentForm\App\Modules\Payments\Classes\PaymentManagement; use FluentForm\App\Modules\Payments\Migrations\Migration; use FluentForm\App\Modules\Payments\PaymentMethods\Stripe\ConnectConfig; class AjaxEndpoints { public function handleEndpoint($route) { $validRoutes = [ 'enable_payment' => 'enablePaymentModule', 'update_global_settings' => 'updateGlobalSettings', 'get_payment_method_settings' => 'getPaymentMethodSettings', 'save_payment_method_settings' => 'savePaymentMethodSettings', 'get_form_settings' => 'getFormSettings', 'save_form_settings' => 'saveFormSettings', 'update_transaction' => 'updateTransaction', 'get_stripe_connect_config' => 'getStripeConnectConfig', 'disconnect_stripe_connection' => 'disconnectStripeConnect', 'get_pages' => 'getWpPages', 'cancel_subscription' => 'cancelSubscription' ]; if (isset($validRoutes[$route])) { $this->{$validRoutes[$route]}(); } else { do_action('fluentform/handle_payment_ajax_endpoint', $route); } die(); } public function enablePaymentModule() { $this->upgradeDb(); // Update settings $settings = PaymentHelper::updatePaymentSettings([ 'status' => 'yes' ]); // send response to reload the page wp_send_json_success([ 'message' => __('Payment Module successfully enabled!', 'fluentform'), 'settings' => $settings, 'reload' => 'yes' ]); } private function upgradeDB() { global $wpdb; $table = $wpdb->prefix . 'fluentform_transactions'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder -- Checking table structure, %1s is for identifier $cols = $wpdb->get_col($wpdb->prepare("DESC %1s", $table), 0); if ($cols && in_array('subscription_id', $cols) && in_array('transaction_hash', $cols)) { // We are good } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder -- Migration, dropping table to recreate, %1s is for identifier $wpdb->query($wpdb->prepare("DROP TABLE IF EXISTS %1s", $table)); Migration::migrate(); // Migrate the database Submissions::migrate(true); // Add payment_total } } public function updateGlobalSettings() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified in route registration, sanitized in updatePaymentSettings() $request = wpFluentForm()->request; $settings = wp_unslash($request->get('settings', [])); $sanitizeMap = [ 'status' => 'sanitize_text_field', 'currency' => 'sanitize_text_field', ]; $settings = fluentform_backend_sanitizer($settings, $sanitizeMap); // Update settings $settings = PaymentHelper::updatePaymentSettings($settings); // send response to reload the page wp_send_json_success([ 'message' => __('Settings successfully updated!', 'fluentform'), 'settings' => $settings, 'reload' => 'yes' ]); } public function getPaymentMethodSettings() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in route registration $request = wpFluentForm()->request; $method = sanitize_text_field($request->get('method', '')); $paymentSettings = apply_filters_deprecated( 'fluentform_payment_settings_' . $method, [ [] ], FLUENTFORM_FRAMEWORK_UPGRADE, 'fluentform/payment_settings_' . $method, 'Use fluentform/payment_settings_' . $method . ' instead of fluentform_payment_settings_' . $method ); $settings = apply_filters('fluentform/payment_settings_' . $method, $paymentSettings); wp_send_json_success([ 'settings' => ($settings) ? $settings : false ]); } public function savePaymentMethodSettings() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in route registration $request = wpFluentForm()->request; $method = sanitize_text_field($request->get('method', '')); $settings = wp_unslash($request->get('settings', [])); $sanitizeMap = [ 'status' => 'sanitize_text_field', ]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified in route registration, sanitized in validation filter $settings = fluentform_backend_sanitizer($settings, $sanitizeMap); $settingsValidation = apply_filters_deprecated( 'fluentform_payment_method_settings_validation_' . $method, [ [], $settings ], FLUENTFORM_FRAMEWORK_UPGRADE, 'fluentform/payment_method_settings_validation_' . $method, 'Use fluentform/payment_method_settings_validation_' . $method . ' instead of fluentform_payment_method_settings_validation_' . $method ); $validationErrors = apply_filters('fluentform/payment_method_settings_validation_' . $method, $settingsValidation, $settings); if ($validationErrors) { wp_send_json_error([ 'message' => __('Failed to save settings', 'fluentform'), 'errors' => $validationErrors ], 423); } $settings = apply_filters_deprecated( 'fluentform_payment_method_settings_save_' . $method, [ $settings ], FLUENTFORM_FRAMEWORK_UPGRADE, 'fluentform/payment_method_settings_save_' . $method, 'Use fluentform/payment_method_settings_save_' . $method . ' instead of fluentform_payment_method_settings_save_' . $method ); $settings = apply_filters('fluentform/payment_method_settings_save_' . $method, $settings); update_option('fluentform_payment_settings_' . $method, $settings, 'yes'); wp_send_json_success([ 'message' => __('Settings successfully updated', 'fluentform') ]); } public function getFormSettings() { $request = wpFluentForm()->request; $formId = intval($request->get('form_id', 0)); $settings = PaymentHelper::getFormSettings($formId, 'admin'); $form = Form::find($formId); $addressFields = array_values(FormFieldsParser::getAddressFields($form)); $paymentSettings = [ 'settings' => $settings, 'currencies' => PaymentHelper::getCurrencies(), 'payment_methods' => PaymentHelper::getFormPaymentMethods($formId), 'addressFields' => array_filter($addressFields) ]; $paymentSettings = apply_filters('fluentform/form_payment_settings', $paymentSettings, $formId); wp_send_json_success($paymentSettings, 200); } public function saveFormSettings() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in route registration $request = wpFluentForm()->request; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified in route registration, sanitized in setFormMeta() $formId = intval($request->get('form_id', 0)); $settings = wp_unslash($request->get('settings', [])); $sanitizeMap = [ 'enabled' => 'rest_sanitize_boolean', 'currency' => 'sanitize_text_field', ]; $settings = fluentform_backend_sanitizer($settings, $sanitizeMap); Helper::setFormMeta($formId, '_payment_settings', $settings); wp_send_json_success([ 'message' => __('Settings successfully saved', 'fluentform') ], 200); } public function updateTransaction() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Nonce verified in route registration, data sanitized below $request = wpFluentForm()->request; $transactionData = $request->get('transaction', []); if (is_array($transactionData)) { $transactionData['id'] = intval(ArrayHelper::get($transactionData, 'id')); $transactionData['status'] = sanitize_text_field(ArrayHelper::get($transactionData, 'status')); $transactionData['payer_name'] = sanitize_text_field(ArrayHelper::get($transactionData, 'payer_name')); $transactionData['payer_email'] = sanitize_email(ArrayHelper::get($transactionData, 'payer_email')); $transactionData['charge_id'] = sanitize_text_field(ArrayHelper::get($transactionData, 'charge_id')); $transactionData['refund_amount'] = floatval(ArrayHelper::get($transactionData, 'refund_amount')); $transactionData['refund_note'] = sanitize_text_field(ArrayHelper::get($transactionData, 'refund_note')); $transactionData['should_run_actions'] = sanitize_text_field(ArrayHelper::get($transactionData, 'should_run_actions')); // Handle billing_address and shipping_address if (isset($transactionData['billing_address'])) { $transactionData['billing_address'] = is_array($transactionData['billing_address']) ? array_map('sanitize_text_field', $transactionData['billing_address']) : sanitize_text_field($transactionData['billing_address']); } if (isset($transactionData['shipping_address'])) { $transactionData['shipping_address'] = is_array($transactionData['shipping_address']) ? array_map('sanitize_text_field', $transactionData['shipping_address']) : sanitize_text_field($transactionData['shipping_address']); } } // Sanitize subscription_id separately $subscriptionId = intval($request->get('subscription_id', 0)); $transactionId = $transactionData['id']; $oldTransaction = Transaction::find($transactionId); $changingStatus = $oldTransaction->status != $transactionData['status']; $updateData = ArrayHelper::only($transactionData, [ 'payer_name', 'payer_email', 'billing_address', 'shipping_address', 'charge_id', 'status' ]); $updateData['updated_at'] = current_time('mysql'); Transaction::where('id', $transactionId)->update($updateData); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in route registration if ($subscriptionId) { $existingSubscription = Subscription::find($subscriptionId); $changedStatus = ArrayHelper::get($transactionData, 'status'); $isStatusChanged = $existingSubscription->status != $changedStatus; if ($isStatusChanged) { Subscription::where('id', $subscriptionId) ->update([ 'status' => $changedStatus, 'updated_at' => current_time('mysql') ]); } } $newStatus = $transactionData['status']; // No need abstract method, only need defined method, empty implementation $baseProcessor = new class extends BaseProcessor { public function handlePaymentAction($submissionId, $submissionData, $form, $methodSettings, $hasSubscriptions, $totalPayable) { } }; if ( ($changingStatus && ($newStatus == 'refunded' || $newStatus == 'partial-refunded')) || ($newStatus == 'partial-refunded' && ArrayHelper::get($transactionData, 'refund_amount')) ) { $refundAmount = 0; $refundNote = 'Refunded by Admin'; if ($newStatus == 'refunded') { // Handle refund here $refundAmount = $oldTransaction->payment_total; } else if ($newStatus == 'partially-refunded') { $refundAmount = ArrayHelper::get($transactionData, 'refund_amount') * 100; $refundNote = ArrayHelper::get($transactionData, 'refund_note'); } if ($refundAmount) { $baseProcessor->setSubmissionId($oldTransaction->submission_id); $submission = $baseProcessor->getSubmission(); $baseProcessor->refund($refundAmount, $oldTransaction, $submission, $oldTransaction->payment_method, 'refund_' . time(), $refundNote); } } if ($changingStatus) { if ($newStatus == 'paid' || $newStatus == 'pending' || $newStatus == 'processing') { // Delete All Refunds Transaction::bySubmission($oldTransaction->submission_id)->refunds()->delete(); } $baseProcessor->setSubmissionId($oldTransaction->submission_id); $baseProcessor->changeSubmissionPaymentStatus($newStatus); $baseProcessor->changeTransactionStatus($transactionId, $newStatus); $baseProcessor->recalculatePaidTotal(); } $shouldRunActions = ArrayHelper::get($transactionData, 'should_run_actions', 'no'); if ( $changingStatus && $newStatus === 'paid' && $shouldRunActions === 'yes' ) { do_action( 'fluentform/run_actions_after_update_transaction_as_paid', $newStatus, $oldTransaction ); } wp_send_json_success([ 'message' => __('Successfully updated data', 'fluentform') ], 200); } public function getStripeConnectConfig() { wp_send_json_success(ConnectConfig::getConnectConfig()); } public function disconnectStripeConnect() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in route registration $request = wpFluentForm()->request; $attributes = $request->all(); $sanitizeMap = [ 'mode' => 'sanitize_text_field', ]; $attributes = fluentform_backend_sanitizer($attributes, $sanitizeMap); return ConnectConfig::disconnect($attributes, true); } public function getWpPages() { $pages = wpFluent()->table('posts') ->select(['ID', 'post_title']) ->where('post_status', 'publish') ->where('post_type', 'page') ->orderBy('ID', 'ASC') ->get(); wp_send_json_success([ 'pages' => $pages ]); } public function cancelSubscription() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in route registration $request = wpFluentForm()->request; $attributes = $request->all(); $sanitizeMap = [ 'subscription_id' => 'intval', 'transaction_id' => 'intval', 'submission_id' => 'intval', ]; $attributes = fluentform_backend_sanitizer($attributes, $sanitizeMap); $subscriptionId = ArrayHelper::get($attributes, 'subscription_id'); $subscription = fluentFormApi('submissions')->getSubscription($subscriptionId); if (!$subscription) { wp_send_json_error([ 'message' => __('Subscription could not be found', 'fluentform') ], 423); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in route registration $transactionId = ArrayHelper::get($attributes, 'transaction_id', 0); // SECURITY (FINDING-29): bind the cancelled records to the AUTHORIZED SUBSCRIPTION itself, // not merely its form. The submission is derived from the subscription (not the request), and // the transaction must belong to that submission AND this subscription — so a per-form // payment manager cannot flip an unrelated subscription's transaction/submission from the // same form to cancelled. subscription_id is null on some legacy rows, so it is matched with // a null-safe fallback while submission_id (1:1 with the subscription) does the hard binding. $subscriptionSubmissionId = (int) $subscription->submission_id; $subscriptionTxnScope = function ($query) use ($subscription, $subscriptionSubmissionId) { return $query ->where('submission_id', $subscriptionSubmissionId) ->where(function ($q) use ($subscription) { $q->where('subscription_id', $subscription->id) ->orWhereNull('subscription_id') ->orWhere('subscription_id', 0); }); }; // FINDING-29 + review #243: derive the subscription's OWN transaction from the authorized // scope. Prefer the request's transaction_id only when it resolves inside that scope; the id // is optional in the admin UI, so a missing/stale/mismatched value must not leave the local // transaction active while the submission and gateway are cancelled — fall back to the // subscription's transaction. $oldTransaction = $subscriptionTxnScope( $transactionId ? Transaction::where('id', $transactionId) : Transaction::query() )->first(); if (!$oldTransaction) { $oldTransaction = $subscriptionTxnScope(Transaction::query())->first(); } $oldSubmission = Submission::where('id', $subscriptionSubmissionId)->first(); // CORRECTNESS (review #243): cancel at the gateway FIRST — it holds the authoritative state. // Only touch local records after it confirms, so a gateway failure never leaves us showing // "cancelled" locally while the subscription keeps charging. $response = (new PaymentManagement())->cancelSubscription($subscription); if (is_wp_error($response)) { wp_send_json_error([ 'message' => $response->get_error_code() . ' - ' . $response->get_error_message() ], 423); } // Gateway cancelled: reconcile each local record independently (so a partially-cancelled // state is completed rather than skipped) and atomically. The transaction stays scoped to // this subscription (FINDING-29), so an unrelated same-form transaction cannot be flipped. $now = current_time('mysql'); wpFluent()->transaction(function () use ($subscriptionTxnScope, $subscriptionSubmissionId, $oldTransaction, $oldSubmission, $now) { if ($oldTransaction && $oldTransaction->status !== 'cancelled') { $subscriptionTxnScope(Transaction::where('id', $oldTransaction->id)) ->update([ 'status' => 'cancelled', 'updated_at' => $now ]); } if ($oldSubmission && $oldSubmission->payment_status !== 'cancelled') { Submission::where('id', $subscriptionSubmissionId) ->update([ 'payment_status' => 'cancelled', 'updated_at' => $now ]); } }); wp_send_json_success([ 'message' => $response ]); } }