PluginProbe ʕ •ᴥ•ʔ
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / trunk
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz vtrunk
2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8 0.0.9 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.1.0 1.1.1 1.1.2 1.10.0 1.10.1 1.11.0 1.12.0 1.12.1 1.12.2 1.12.3 1.13.0 1.13.1 1.13.2 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.3.0 1.3.1 1.3.2 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.5.0 1.5.1 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.8.0 1.9.0 1.9.1 2.0.0 2.0.1 2.0.2 2.1.0 2.1.1 2.2.0 2.2.1 2.2.2 2.3.0 2.4.0 2.5.0 2.5.2 2.6.0
sureforms / assets / js / stripe-payment.js
sureforms / assets / js Last commit date
minified 5 days ago payment-history.js 3 weeks ago payment-manager.js 3 months ago stripe-payment.js 2 months ago
stripe-payment.js
1657 lines
1 /**
2 * SureForms Stripe Payment Integration
3 *
4 * @since 2.0.0
5 */
6 /* global Stripe, srfm_ajax */
7
8 /**
9 * Get composite key for a payment block within a specific form instance.
10 * Supports multiple embeds of the same form on one page.
11 *
12 * @param {HTMLElement} form - The form element.
13 * @param {string} blockId - The block ID from data-block-id attribute.
14 * @return {string} Composite key in format "instanceId-blockId", or plain blockId as fallback.
15 */
16 function getPaymentKey( form, blockId ) {
17 const instanceId = form.getAttribute( 'data-srfm-instance' );
18 return instanceId ? `${ instanceId }-${ blockId }` : blockId;
19 }
20 window.srfmGetPaymentKey = getPaymentKey;
21
22 class StripePayment {
23 // Store Stripe instances
24 static stripeInstances = {};
25 static paymentElements = {};
26 static paymentIntents = {};
27 static subscriptionIntents = {};
28 // BOTH MODE: per-block guard so rapid type-flips don't race two
29 // Stripe.elements() instances on the same DOM container. Keyed by
30 // compositeKey, cleared in the 'ready' handler of the new element.
31 static reinitInProgress = new Set();
32
33 // Initialize on page load
34 static {
35 window.srfmPaymentElements = this.paymentElements;
36 }
37
38 /**
39 * Constructor for the Calculations class.
40 * @param {HTMLElement} form - The form element containing calculation fields.
41 */
42 constructor( form ) {
43 this.form = form;
44 // Find all payment blocks within the form.
45 const getPaymentFields = this.form.querySelectorAll(
46 '.srfm-block.srfm-payment-block'
47 );
48
49 // Initialize Stripe payment for each payment field.
50 getPaymentFields.forEach( ( field ) => {
51 this.processPayment( field );
52 } );
53 }
54
55 /**
56 * Create payment or subscription intent during form submission.
57 * Unified function that handles both payment types based on the paymentType parameter.
58 *
59 * @param {string} blockId - Block ID (original, for server API).
60 * @param {string} compositeKey - Composite key (instanceId-blockId, for client-side maps).
61 * @param {number} amount - The amount for the payment/subscription.
62 * @param {HTMLElement} paymentInput - The payment input element.
63 * @param {string} paymentType - Payment type: 'one-time' or 'subscription'.
64 * @return {Promise<Object>} Resolves with payment/subscription data.
65 */
66 async createPaymentIntentOnSubmission(
67 blockId,
68 compositeKey,
69 amount,
70 paymentInput,
71 paymentType = 'one-time'
72 ) {
73 // Setup
74 const isSubscription = paymentType === 'subscription';
75 const customerData = this.extractCustomerData( paymentInput );
76
77 // Extract common data
78 const currency = paymentInput.dataset.currency || 'usd';
79 const description =
80 paymentInput.dataset.description ||
81 ( isSubscription
82 ? PAYMENT_UTILITY.getStripeStrings(
83 'sureforms_subscription',
84 'SureForms Subscription'
85 )
86 : PAYMENT_UTILITY.getStripeStrings(
87 'sureforms_payment',
88 'SureForms Payment'
89 ) );
90
91 // Build FormData
92 const data = new FormData();
93 data.append(
94 'action',
95 isSubscription
96 ? 'srfm_create_subscription_intent'
97 : 'srfm_create_payment_intent'
98 );
99 // Read submit token from form element for server-side verification.
100 const formElement = paymentInput.closest( 'form' );
101 data.append( 'token', formElement?.getAttribute( 'data-submit-token' ) || '' );
102 // Handle zero-decimal currencies (JPY, KRW, etc.) - don't multiply by 100
103 const formattedAmount =
104 window?.srfmStripe?.zeroDecimalCurrencies?.includes(
105 currency.toUpperCase()
106 )
107 ? parseInt( amount )
108 : parseInt( amount * 100 );
109 data.append( 'amount', formattedAmount );
110 data.append( 'currency', currency );
111 data.append( 'description', description );
112 data.append( 'block_id', blockId );
113 data.append( 'customer_email', customerData.email );
114 data.append( 'customer_name', customerData.name );
115 const formIdInput = formElement?.querySelector(
116 'input[name="form-id"]'
117 );
118 if ( formIdInput?.value ) {
119 data.append( 'form_id', formIdInput.value );
120 }
121
122 // Add subscription-specific data
123 if ( isSubscription ) {
124 data.append( 'interval', customerData.interval );
125 data.append( 'plan_name', customerData.planName );
126 }
127
128 // Make API call
129 try {
130 const response = await fetch( srfm_ajax.ajax_url, {
131 method: 'POST',
132 body: data,
133 } );
134
135 const responseData = await response.json();
136
137 // Handle success
138 if ( responseData.success ) {
139 const clientSecret = responseData.data.client_secret;
140 const paymentIntentId = responseData.data.payment_intent_id;
141 const customerId = responseData?.data?.customer_id || null;
142
143 // Store payment/subscription data
144 if ( isSubscription ) {
145 const subscriptionId = responseData.data.subscription_id;
146
147 StripePayment.subscriptionIntents[ compositeKey ] = {
148 subscriptionId,
149 customerId: customerId || null,
150 paymentIntentId,
151 amount,
152 interval: customerData.interval,
153 };
154 } else {
155 StripePayment.paymentIntents[ compositeKey ] = {
156 paymentIntentId,
157 customerId: customerId || null,
158 };
159 }
160
161 // Update elements with client secret
162 const elementData =
163 StripePayment.paymentElements[ compositeKey ];
164 if ( elementData ) {
165 // CRITICAL: Store client secret WITHOUT calling elements.update()
166 // This preserves user-entered card data
167 elementData.clientSecret = clientSecret;
168 }
169
170 return { valid: true };
171 }
172
173 // Handle failure
174 return {
175 valid: false,
176 message:
177 responseData.data?.message ||
178 responseData.data ||
179 PAYMENT_UTILITY.getStripeStrings(
180 'payment_unavailable',
181 'Payment is currently unavailable. Please contact the site administrator.'
182 ),
183 };
184 } catch ( error ) {
185 return {
186 valid: false,
187 message:
188 error.message ||
189 PAYMENT_UTILITY.getStripeStrings(
190 'payment_unavailable',
191 'Payment is currently unavailable. Please contact the site administrator.'
192 ),
193 };
194 }
195 }
196
197 processPayment( field ) {
198 const paymentInput = field.querySelector( 'input.srfm-payment-input' );
199
200 if ( ! paymentInput ) {
201 return;
202 }
203
204 const blockId = field.getAttribute( 'data-block-id' );
205 const compositeKey = getPaymentKey( this.form, blockId );
206 // Check payment type from data attribute
207 const paymentType =
208 paymentInput.getAttribute( 'data-payment-type' ) || 'one-time';
209
210 // Initialize Stripe elements using unified function
211 this.initializePaymentElements(
212 compositeKey,
213 paymentInput,
214 paymentType
215 );
216 }
217
218 /**
219 * Initialize Stripe elements for one-time payments or subscriptions.
220 * Unified function that handles both payment types based on the paymentType parameter.
221 *
222 * @param {string} compositeKey - Composite key (instanceId-blockId) for client-side maps.
223 * @param {HTMLElement} paymentInput - The payment input element.
224 * @param {string} paymentType - Payment type: 'one-time' or 'subscription'.
225 * @return {void} This function does not return a value.
226 */
227 initializePaymentElements(
228 compositeKey,
229 paymentInput,
230 paymentType = 'one-time'
231 ) {
232 // CRITICAL: Check if elements already exist to prevent re-initialization
233 // Re-mounting elements destroys user-entered card data
234 if ( StripePayment.paymentElements[ compositeKey ] ) {
235 return;
236 }
237
238 const stripeKey = paymentInput.dataset.stripeKey;
239
240 if ( ! stripeKey ) {
241 return;
242 }
243
244 const elementContainer = paymentInput
245 .closest( '.srfm-block' )
246 .querySelector( '.srfm-stripe-payment-element' );
247
248 if ( ! elementContainer ) {
249 return;
250 }
251
252 // Initialize Stripe
253 if ( ! StripePayment.stripeInstances[ compositeKey ] ) {
254 StripePayment.stripeInstances[ compositeKey ] = Stripe( stripeKey );
255 }
256
257 const stripe = StripePayment.stripeInstances[ compositeKey ];
258
259 // Build elements configuration based on payment type
260 const elementsConfig = {
261 mode: paymentType === 'subscription' ? 'subscription' : 'payment',
262 currency: paymentInput.dataset.currency || 'usd',
263 amount: 12000,
264 appearance: {
265 theme: 'stripe',
266 variables: {
267 colorPrimary: '#0073aa',
268 colorBackground: '#ffffff',
269 colorText: '#424242',
270 colorDanger: '#df1b41',
271 spacingUnit: '4px',
272 borderRadius: '4px',
273 fontFamily: '"Manrope", sans-serif',
274 },
275 },
276 fields: {
277 billingDetails: {
278 email: 'auto', // �
279 Email + Link enabled
280 },
281 },
282 };
283
284 // Add type-specific configuration
285 if ( paymentType === 'one-time' ) {
286 elementsConfig.captureMethod = 'manual';
287 // Manual capture is incompatible with some account-enabled payment methods
288 // (e.g. Bacs Direct Debit, Link, Cash App, BNPL). When any of those are enabled,
289 // Stripe rejects the deferred elements/sessions request with HTTP 400 and the
290 // Payment Element fails to render — which is why the card field does not load in
291 // live mode while test mode (card-only) works. Scope the element to card so only
292 // capture-compatible methods are offered. Apple Pay / Google Pay still appear
293 // (they are surfaced through `card`); the methods dropped here could never be used
294 // with manual capture anyway, so no working checkout is lost.
295 elementsConfig.paymentMethodTypes = [ 'card' ];
296 }
297
298 // Create and mount payment element
299 const elements = stripe.elements( elementsConfig );
300 const paymentElement = elements.create( 'payment' );
301 paymentElement.mount( elementContainer );
302
303 // Store references (structure varies by payment type)
304 const storedData = {
305 stripe,
306 elements,
307 paymentElement,
308 clientSecret: null, // Will be set when payment/subscription intent is created
309 paymentType,
310 };
311
312 StripePayment.paymentElements[ compositeKey ] = storedData;
313
314 // Update window object
315 window.srfmPaymentElements = StripePayment.paymentElements;
316
317 // Setup event handlers
318 this.setupPaymentElementEvents( paymentElement, compositeKey );
319 }
320
321 /**
322 * Setup event handlers for payment element.
323 *
324 * @param {Object} paymentElement - The Stripe payment element.
325 * @param {string} compositeKey - Composite key (instanceId-blockId) used to clear the reinit-in-flight flag.
326 * @return {void} This function does not return a value.
327 */
328 setupPaymentElementEvents( paymentElement, compositeKey ) {
329 // Ready event — clears the reinit-in-flight flag so subsequent type
330 // flips can proceed. Without this, a stale flag would block all future
331 // reinits for this block.
332 paymentElement.on( 'ready', () => {
333 if ( compositeKey ) {
334 StripePayment.reinitInProgress.delete( compositeKey );
335 }
336 } );
337 }
338
339 /**
340 * Get payment amount based on amount type (fixed or user-defined).
341 *
342 * @param {HTMLElement} paymentInput - The payment input element.
343 * @return {number|false} The payment amount in dollars, or false if invalid.
344 */
345 static getPaymentAmount( paymentInput ) {
346 const amountType = paymentInput.dataset.amountType || 'fixed';
347 let amount = 0;
348
349 if ( amountType === 'fixed' ) {
350 // Get fixed amount from data attribute
351 amount = parseFloat( paymentInput.dataset.fixedAmount || 0 );
352 } else {
353 // Get the format type for dynamic amounts
354 const formatType =
355 paymentInput.getAttribute(
356 'data-dynamic-amount-format-type'
357 ) || 'us-style';
358 const rawAmount = paymentInput.dataset.currentAmount || 0;
359
360 // Normalize the amount based on format type
361 amount = StripePayment.normalizeAmount( rawAmount, formatType );
362 }
363
364 // Validate the amount - must be valid, not negative, and greater than 0
365 if ( isNaN( amount ) ) {
366 return false;
367 }
368
369 if ( amount < 0 ) {
370 return false;
371 }
372
373 if ( amount <= 0 ) {
374 return false;
375 }
376
377 // Additional validation using existing method
378 if ( ! StripePayment.validatePaymentAmount( amount ) ) {
379 return false;
380 }
381
382 return amount;
383 }
384
385 /**
386 * Extract customer data from form fields or use dummy data.
387 *
388 * @param {HTMLElement} paymentInput - The payment input element.
389 * @return {Object} An object containing name, email, interval, and planName.
390 */
391 extractCustomerData( paymentInput ) {
392 const form = paymentInput.closest( 'form' );
393 const block = paymentInput.closest( '.srfm-block' );
394
395 // Get subscription plan data from input attributes
396 const planName =
397 paymentInput.dataset.subscriptionPlanName ||
398 PAYMENT_UTILITY.getStripeStrings(
399 'subscription_plan',
400 'Subscription Plan'
401 );
402 const interval = paymentInput.dataset.subscriptionInterval || 'month';
403
404 // Use static methods to extract customer data from mapped form fields
405 const customerName =
406 StripePayment.extractBillingName( form, block ) ||
407 PAYMENT_UTILITY.getStripeStrings(
408 'sureforms_customer',
409 'SureForms Customer'
410 );
411 const customerEmail =
412 StripePayment.extractBillingEmail( form, block ) ||
413 PAYMENT_UTILITY.getStripeStrings(
414 'customer_example_email',
415 'customer@example.com'
416 );
417
418 return {
419 name: customerName,
420 email: customerEmail,
421 interval,
422 planName,
423 };
424 }
425
426 /**
427 * Static method to create payment intent for a payment block during form submission.
428 * This should be called from the form submission handler.
429 *
430 * @param {HTMLFormElement} form - The form element.
431 * @param {HTMLElement} paymentBlock - The payment block element.
432 * @return {Promise<Object>} Resolves with payment intent or subscription intent data.
433 */
434 static async createPaymentIntentsForForm( form, paymentBlock ) {
435 const paymentInput = paymentBlock.querySelector(
436 'input.srfm-payment-input'
437 );
438 const paymentType =
439 paymentInput.getAttribute( 'data-payment-type' ) || 'one-time';
440
441 if ( ! paymentInput ) {
442 return {
443 valid: false,
444 message: PAYMENT_UTILITY.getStripeStrings(
445 'payment_unavailable',
446 'Payment is currently unavailable. Please contact the site administrator.'
447 ),
448 };
449 }
450
451 // Get payment amount using helper method
452 const amount = StripePayment.getPaymentAmount( paymentInput );
453
454 if ( false === amount ) {
455 return {
456 valid: false,
457 message: PAYMENT_UTILITY.getStripeStrings(
458 'payment_amount_not_configured',
459 'Payment is currently unavailable. Please contact the site administrator to configure the payment amount.'
460 ),
461 };
462 } else if ( amount <= 0 ) {
463 return {
464 valid: false,
465 message: PAYMENT_UTILITY.getStripeStrings(
466 'payment_amount_not_configured',
467 'Payment is currently unavailable. Please contact the site administrator to configure the payment amount.'
468 ),
469 };
470 }
471
472 try {
473 // Create a temporary instance to call the method
474 const tempInstance = new StripePayment( form );
475 const blockId = paymentBlock.getAttribute( 'data-block-id' );
476 const compositeKey = getPaymentKey( form, blockId );
477
478 // Use unified function for both payment types
479 const result = await tempInstance.createPaymentIntentOnSubmission(
480 blockId,
481 compositeKey,
482 amount,
483 paymentInput,
484 paymentType
485 );
486
487 return {
488 blockId,
489 compositeKey,
490 paymentType,
491 valid: true,
492 ...result,
493 };
494 } catch ( error ) {
495 return {
496 valid: false,
497 message:
498 error.message ||
499 PAYMENT_UTILITY.getStripeStrings(
500 'payment_unavailable',
501 'Payment is currently unavailable. Please contact the site administrator.'
502 ),
503 };
504 }
505 }
506
507 /**
508 * Validate payment amount before processing
509 * @param {number} amount - The amount to validate.
510 * @return {boolean} True if the amount is valid, false otherwise.
511 */
512 static validatePaymentAmount( amount ) {
513 // Stripe minimum is $0.50 for most currencies
514 const minAmount = 0.5;
515 const maxAmount = 999999.99; // Reasonable maximum
516
517 if ( isNaN( amount ) || amount < minAmount ) {
518 return false;
519 }
520 if ( amount > maxAmount ) {
521 return false;
522 }
523 return true;
524 }
525
526 /**
527 * Normalize amount based on number format type (EU-style or US-style)
528 * @param {string|number} amount - The amount to normalize.
529 * @param {string} formatType - The format type: 'eu-style' or 'us-style'.
530 * @return {number} The normalized amount as a number.
531 */
532 static normalizeAmount( amount, formatType = 'us-style' ) {
533 // If already a number, return it
534 if ( typeof amount === 'number' ) {
535 return amount;
536 }
537
538 // Convert to string and trim
539 const amountStr = String( amount ).trim();
540
541 if ( formatType === 'eu-style' ) {
542 // EU-style: 1.234,56 (period = thousands, comma = decimal)
543 // Remove periods (thousands separator) and replace comma with period (decimal)
544 return parseFloat(
545 amountStr.replace( /\./g, '' ).replace( ',', '.' )
546 );
547 }
548
549 // US-style (default): 1,234.56 (comma = thousands, period = decimal)
550 // Remove commas (thousands separator)
551 return parseFloat( amountStr.replace( /,/g, '' ) );
552 }
553
554 /**
555 * Extract billing name from form fields
556 * @param {HTMLElement} form - The form element.
557 * @param {HTMLElement} paymentBlock - The payment block wrapper element.
558 * @return {string} The extracted billing name or a default value.
559 */
560 static extractBillingName( form, paymentBlock ) {
561 // Get the customer name field slug from payment input data attribute
562 const paymentInput = paymentBlock.querySelector(
563 'input.srfm-payment-input'
564 );
565 const customerNameFieldSlug = paymentInput
566 ? paymentInput.getAttribute( 'data-customer-name-field' )
567 : null;
568
569 if ( ! customerNameFieldSlug || customerNameFieldSlug.trim() === '' ) {
570 return '';
571 }
572
573 // Find the actual name input field in the form using the slug
574 const nameInput = form.querySelector(
575 `.srfm-input-block.srfm-slug-${ customerNameFieldSlug } .srfm-input-common`
576 );
577
578 if ( ! nameInput ) {
579 return '';
580 }
581
582 // Return the trimmed value
583 return nameInput.value.trim() || '';
584 }
585
586 /**
587 * Extract billing email from form fields
588 * @param {HTMLElement} form - The form element.
589 * @param {HTMLElement} paymentBlock - The payment block wrapper element.
590 * @return {string} The extracted billing email or a default value.
591 */
592 static extractBillingEmail( form, paymentBlock ) {
593 // Get the customer email field slug from payment input data attribute
594 const paymentInput = paymentBlock.querySelector(
595 'input.srfm-payment-input'
596 );
597 const customerEmailFieldSlug = paymentInput
598 ? paymentInput.getAttribute( 'data-customer-email-field' )
599 : null;
600
601 if (
602 ! customerEmailFieldSlug ||
603 customerEmailFieldSlug.trim() === ''
604 ) {
605 return '';
606 }
607
608 // Find the actual email input field in the form using the slug
609 const emailInput = form.querySelector(
610 `.srfm-email-block.srfm-slug-${ customerEmailFieldSlug } .srfm-input-common`
611 );
612
613 if ( ! emailInput ) {
614 return '';
615 }
616
617 // Return the trimmed value
618 return emailInput.value.trim() || '';
619 }
620
621 /**
622 * Confirm payment for a specific block
623 * @param {string} compositeKey - Composite key (instanceId-blockId) for client-side maps.
624 * @param {Object} paymentData - The payment data.
625 * @param {HTMLElement} form - The form element.
626 * @return {Promise<string>} The payment intent or setup intent ID if successful.
627 */
628 static async srfmConfirmPayment( compositeKey, paymentData, form ) {
629 const { elements } = paymentData;
630
631 // Validate card details AFTER payment intent is created but BEFORE confirmation
632 // This is the correct timing to avoid card data loss
633 const { error: submitError } = await elements.submit();
634
635 if ( submitError ) {
636 return {
637 valid: false,
638 error: submitError.message,
639 message: submitError.message,
640 };
641 }
642
643 // Handle payment confirmation via unified handler
644 try {
645 return await StripePayment.confirmStripePayment(
646 compositeKey,
647 paymentData,
648 form
649 );
650 } catch ( error ) {
651 // Catch any errors thrown by confirmStripePayment and return consistent structure
652 return {
653 valid: false,
654 error: error.message || error,
655 message:
656 error.message ||
657 PAYMENT_UTILITY.getStripeStrings(
658 'payment_failed',
659 'Payment failed'
660 ),
661 };
662 }
663 }
664
665 static async confirmStripePayment( compositeKey, paymentData, form ) {
666 const { stripe, elements, clientSecret } = paymentData;
667
668 // Extract original blockId from compositeKey for DOM queries
669 // compositeKey format is always "numericInstanceId-blockId"
670 const separatorIndex = compositeKey.indexOf( '-' );
671 const blockId =
672 separatorIndex > -1
673 ? compositeKey.substring( separatorIndex + 1 )
674 : compositeKey;
675
676 // Get the payment block element
677 const paymentBlock = form.querySelector(
678 `[data-block-id="${ blockId }"]`
679 );
680 // Update form input with subscription data for backend processing
681 const paymentInput = paymentBlock.querySelector(
682 '.srfm-payment-input'
683 );
684
685 // BOTH MODE: read paymentType fresh from the live data attribute rather
686 // than the cached paymentData. reinitForBlock() rebuilds the cache on
687 // type flip, and a confirm captured before the flip would otherwise
688 // branch on a stale type. If paymentType and clientSecret ever truly
689 // diverge, Stripe itself rejects the wrong confirmSetup/confirmPayment
690 // call — no need for a redundant client-side guard here.
691 const paymentType =
692 paymentInput.getAttribute( 'data-payment-type' ) ||
693 paymentData.paymentType ||
694 'one-time';
695
696 const amountType =
697 paymentInput.getAttribute( 'data-amount-type' ) || 'fixed';
698
699 // Prepare billing details using StripePayment class methods
700 const billingDetails = {
701 name: StripePayment.extractBillingName( form, paymentBlock ),
702 email: StripePayment.extractBillingEmail( form, paymentBlock ),
703 };
704
705 const stripeArgs = {
706 elements,
707 clientSecret,
708 confirmParams: {
709 return_url: window.location.href,
710 payment_method_data: {
711 billing_details: billingDetails,
712 },
713 },
714 redirect: 'if_required',
715 };
716
717 const paymentResult = await ( paymentType === 'subscription'
718 ? stripe.confirmSetup( stripeArgs )
719 : stripe.confirmPayment( stripeArgs ) );
720
721 if ( paymentResult?.error ) {
722 console.warn( { 'Payment Confirmation Error': paymentResult } );
723
724 const getErrorCode =
725 paymentResult?.error?.decline_code ||
726 paymentResult?.error?.code;
727 // Get the user-friendly message for the decline code
728 const errorMessage =
729 PAYMENT_UTILITY.getStripeStrings( getErrorCode );
730
731 return {
732 valid: false,
733 error: paymentResult.error,
734 message: errorMessage,
735 ...paymentResult,
736 };
737 }
738
739 if (
740 'one-time' === paymentType &&
741 ! [ 'succeeded', 'requires_capture' ].includes(
742 paymentResult?.paymentIntent?.status
743 )
744 ) {
745 const errorMessage = PAYMENT_UTILITY.getStripeStrings(
746 'payment_could_not_be_completed',
747 'Payment could not be completed. Please try again or contact the site administrator.'
748 );
749
750 return {
751 valid: false,
752 error: errorMessage,
753 message: errorMessage,
754 paymentResult,
755 };
756 }
757
758 const amount = StripePayment.getPaymentAmount( paymentInput );
759
760 const resultArgs = {
761 paymentResult,
762 compositeKey,
763 blockId,
764 paymentType,
765 amountType,
766 amount,
767 paymentInput,
768 billingDetails,
769 };
770
771 StripePayment.prepareInputValueData( resultArgs );
772
773 return { valid: true };
774 }
775
776 /**
777 * Prepares and sets the payment input value data as a JSON string.
778 *
779 * @param {Object} args - The configuration arguments.
780 * @param {string} args.blockId - The payment block ID.
781 * @param {string} args.paymentType - The type of payment ('subscription' or 'one-time').
782 * @param {string} args.amountType - The type of amount ('fixed' or 'user-defined').
783 * @param {number} args.amount - The payment amount.
784 * @param {HTMLInputElement} args.paymentInput - The input field to store payment data.
785 * @param {Object} args.paymentResult - The result object from Stripe payment confirmation.
786 */
787 static prepareInputValueData( args ) {
788 const {
789 compositeKey,
790 blockId,
791 paymentType,
792 amountType,
793 amount,
794 paymentInput,
795 paymentResult,
796 billingDetails,
797 } = args;
798
799 const value = {
800 blockId,
801 amountType,
802 amount,
803 ...( billingDetails || {} ),
804 };
805
806 if ( 'subscription' === paymentType ) {
807 const subscriptionData =
808 StripePayment.subscriptionIntents[ compositeKey ];
809 const getSubscriptionName = paymentInput.getAttribute(
810 'data-subscription-plan-name'
811 );
812 const getSubscriptionBillingCycles = paymentInput.getAttribute(
813 'data-subscription-billing-cycles'
814 );
815 const getSubscriptionInterval = paymentInput.getAttribute(
816 'data-subscription-interval'
817 );
818 value.subscriptionPlanName = getSubscriptionName;
819 value.subscriptionBillingCycles = getSubscriptionBillingCycles;
820 value.subscriptionInterval = getSubscriptionInterval;
821
822 value.paymentId = paymentResult?.setupIntent?.payment_method;
823 value.setupIntent = paymentResult?.setupIntent?.id;
824 value.subscriptionId = subscriptionData?.subscriptionId;
825 value.customerId = subscriptionData?.customerId;
826 value.paymentType = 'stripe-subscription';
827 value.status = 'succeeded';
828 } else {
829 const paymentData = StripePayment.paymentIntents[ compositeKey ];
830 const customerId = paymentData?.customerId || null;
831 value.paymentId = paymentResult?.paymentIntent?.id;
832 value.paymentType = 'stripe';
833 value.customerId = customerId || null;
834 }
835
836 paymentInput.value = JSON.stringify( value );
837 }
838 }
839
840 // BOTH MODE: start — helper to re-mount Stripe Elements when the user flips the
841 // payment-type chooser between one-time and subscription. Stripe's elements.update()
842 // does not reliably support switching `mode`, so we unmount, drop the cached
843 // element, and let the existing initializer pathway recreate everything.
844 StripePayment.reinitForBlock = function ( form, paymentBlock ) {
845 const paymentInput = paymentBlock.querySelector( 'input.srfm-payment-input' );
846 if ( ! paymentInput ) {
847 return;
848 }
849
850 const blockId = paymentBlock.getAttribute( 'data-block-id' );
851 const compositeKey = getPaymentKey( form, blockId );
852
853 // Bail if a reinit is already in flight for this block. Stripe's iframe
854 // mount is async, and a second pass before 'ready' fires would race two
855 // elements.create('payment') instances on the same DOM container. The
856 // caller (radio change handler) reverts the radio UI when this guard
857 // trips so the visible state stays consistent.
858 if ( StripePayment.reinitInProgress.has( compositeKey ) ) {
859 return;
860 }
861
862 const existing = StripePayment.paymentElements[ compositeKey ];
863 if ( existing && existing.paymentElement ) {
864 try {
865 existing.paymentElement.unmount();
866 } catch ( err ) {
867 // Already unmounted — safe to ignore.
868 }
869 }
870 delete StripePayment.paymentElements[ compositeKey ];
871 delete StripePayment.paymentIntents[ compositeKey ];
872 delete StripePayment.subscriptionIntents[ compositeKey ];
873 window.srfmPaymentElements = StripePayment.paymentElements;
874
875 StripePayment.reinitInProgress.add( compositeKey );
876
877 // Re-init only the target block. Previously this used `new StripePayment(form)`
878 // which walked every payment block in the form and re-ran processPayment on
879 // each — wasted work on multi-block forms. Object.create gives us a stub
880 // bound to the form (the only `this` field processPayment touches indirectly
881 // via initializePaymentElements) without triggering the constructor walk.
882 const stub = Object.create( StripePayment.prototype );
883 stub.form = form;
884 stub.processPayment( paymentBlock );
885 };
886 // BOTH MODE: end
887
888 // Make StripePayment available globally for form submission
889 window.StripePayment = StripePayment;
890
891 const PAYMENT_UTILITY = {
892 currentForm: null,
893 amountPlaceHolder: '',
894 init: ( form ) => {
895 PAYMENT_UTILITY.currentForm = form;
896 PAYMENT_UTILITY.amountPlaceHolder = PAYMENT_UTILITY.getStripeStrings(
897 'amount_placeholder',
898 'Please complete the form to view the amount.'
899 );
900 PAYMENT_UTILITY.listenAmountChanges();
901 },
902 /**
903 * Format a number according to the format type (EU-style or US-style)
904 * @param {number|string} amount - The amount to format
905 * @param {string} formatType - The format type: 'eu-style' or 'us-style'
906 * @return {string} The formatted number string
907 */
908 formatNumberByType: ( amount, formatType = 'us-style' ) => {
909 // Normalize to a number first
910 const normalizedAmount = StripePayment.normalizeAmount(
911 amount,
912 formatType
913 );
914
915 if ( isNaN( normalizedAmount ) ) {
916 return '0.00';
917 }
918
919 // Format to 2 decimal places
920 const fixedAmount = normalizedAmount.toFixed( 2 );
921
922 if ( formatType === 'eu-style' ) {
923 // EU-style: 1.234,56 (period = thousands, comma = decimal)
924 const parts = fixedAmount.split( '.' );
925 const integerPart = parts[ 0 ].replace(
926 /\B(?=(\d{3})+(?!\d))/g,
927 '.'
928 );
929 const decimalPart = parts[ 1 ];
930 return integerPart + ',' + decimalPart;
931 }
932
933 // US-style (default): 1,234.56 (comma = thousands, period = decimal)
934 const parts = fixedAmount.split( '.' );
935 const integerPart = parts[ 0 ].replace( /\B(?=(\d{3})+(?!\d))/g, ',' );
936 const decimalPart = parts[ 1 ];
937 return integerPart + '.' + decimalPart;
938 },
939
940 /**
941 * Format subscription message by replacing {amount} placeholder with formatted amount
942 * @param {string} messageFormat - The message format template (e.g., "{amount} per day for 8 payments")
943 * @param {number} amount - The payment amount
944 * @param {string} currencySymbol - The currency symbol (e.g., "$")
945 * @param {string} inputFormatType - The format type: 'eu-style' or 'us-style'
946 * @return {string} Formatted message
947 */
948 formatSubscriptionMessage: (
949 messageFormat,
950 amount,
951 currencySymbol,
952 inputFormatType = 'us-style'
953 ) => {
954 if ( ! messageFormat ) {
955 return PAYMENT_UTILITY.amountPlaceHolder;
956 }
957
958 // Format amount with currency using the appropriate number format and position
959 const formattedNumber =
960 ! amount || amount <= 0
961 ? ''
962 : PAYMENT_UTILITY.formatNumberByType( amount, inputFormatType );
963
964 const formattedAmount =
965 '' !== formattedNumber
966 ? PAYMENT_UTILITY.formatAmountWithCurrencyPosition(
967 currencySymbol,
968 formattedNumber
969 )
970 : '';
971
972 // Replace {amount} placeholder with formatted amount
973 return '' !== formattedAmount
974 ? messageFormat.replace( '{amount}', formattedAmount )
975 : PAYMENT_UTILITY.amountPlaceHolder;
976 },
977 updatePaymentBlockAmount: (
978 paymentInput,
979 amount,
980 inputFormatType = 'us-style'
981 ) => {
982 // BOTH MODE: in "both" payment-type mode two .srfm-payment-value spans
983 // exist (one per amount block). Target the VISIBLE one so the correct
984 // type's amount updates. Falls back to the first match for non-both mode.
985 const paymentBlock = paymentInput.closest( '.srfm-block' );
986 const getPlaceHolderElement =
987 paymentBlock.querySelector(
988 '.srfm-payment-amount-block:not([hidden]) .srfm-payment-value'
989 ) || paymentBlock.querySelector( '.srfm-payment-value' );
990 if ( getPlaceHolderElement ) {
991 const getCurrencySymbol = getPlaceHolderElement.getAttribute(
992 'data-currency-symbol'
993 );
994 const messageFormat = getPlaceHolderElement.getAttribute(
995 'data-message-format'
996 );
997
998 if ( getCurrencySymbol ) {
999 // Check if message format exists (for subscription messages)
1000 if ( messageFormat ) {
1001 const formattedMessage =
1002 PAYMENT_UTILITY.formatSubscriptionMessage(
1003 messageFormat,
1004 amount,
1005 getCurrencySymbol,
1006 inputFormatType
1007 );
1008 getPlaceHolderElement.innerHTML = formattedMessage;
1009 } else {
1010 // Fallback to simple amount display (backward compatible)
1011 // Format the amount according to the number format type and currency position
1012 const formattedNumber = PAYMENT_UTILITY.formatNumberByType(
1013 amount,
1014 inputFormatType
1015 );
1016
1017 getPlaceHolderElement.innerHTML =
1018 PAYMENT_UTILITY.formatAmountWithCurrencyPosition(
1019 getCurrencySymbol,
1020 formattedNumber
1021 );
1022 }
1023 }
1024 }
1025
1026 const normalizedAmount = StripePayment.normalizeAmount(
1027 amount,
1028 inputFormatType
1029 );
1030
1031 paymentInput.setAttribute( 'data-current-amount', normalizedAmount );
1032 paymentInput.setAttribute(
1033 'data-dynamic-amount-format-type',
1034 inputFormatType
1035 );
1036 },
1037 listenAmountChanges: () => {
1038 const paymentInputs = PAYMENT_UTILITY.currentForm.querySelectorAll(
1039 '.srfm-block.srfm-payment-block input.srfm-payment-input[data-variable-amount-field]'
1040 );
1041
1042 if ( paymentInputs.length > 0 ) {
1043 for ( let i = 0; i < paymentInputs.length; i++ ) {
1044 const paymentInput = paymentInputs[ i ];
1045 const getBlockMappedSlug = paymentInput.getAttribute(
1046 'data-variable-amount-field'
1047 );
1048
1049 // BOTH MODE: tear down any listeners previously bound for this
1050 // payment input. switchActivePaymentType() calls this method again
1051 // each time the user flips one-time/subscription, which previously
1052 // piled up duplicate listeners on the source field. Round-trip
1053 // flips compounded the leak (field A → field B → field A → ...).
1054 // Each prior listener still fires on input events and can overwrite
1055 // the displayed amount with a stale source field's value.
1056 if ( paymentInput._srfmAmountListenerController ) {
1057 paymentInput._srfmAmountListenerController.abort();
1058 }
1059 const controller = new AbortController();
1060 paymentInput._srfmAmountListenerController = controller;
1061 const listenerOpts = { signal: controller.signal };
1062
1063 if ( getBlockMappedSlug ) {
1064 const getMappedBlock =
1065 PAYMENT_UTILITY.currentForm.querySelector(
1066 `.srfm-block.srfm-slug-${ getBlockMappedSlug }`
1067 );
1068 if ( getMappedBlock ) {
1069 // Check block type.
1070 if (
1071 getMappedBlock.classList.contains(
1072 'srfm-number-block'
1073 )
1074 ) {
1075 const getMappedBlockInput =
1076 getMappedBlock.querySelector(
1077 'input.srfm-input-common'
1078 );
1079 if ( getMappedBlockInput ) {
1080 getMappedBlockInput.addEventListener(
1081 'input',
1082 ( event ) => {
1083 const getMappedBlockInputValue =
1084 event.target.value;
1085 // Get format type from the number input's data attribute
1086 const inputFormatType =
1087 getMappedBlockInput.getAttribute(
1088 'format-type'
1089 ) || 'us-style';
1090 PAYMENT_UTILITY.updatePaymentBlockAmount(
1091 paymentInput,
1092 getMappedBlockInputValue,
1093 inputFormatType
1094 );
1095 },
1096 listenerOpts
1097 );
1098 // Get initial format type for the initial value
1099 const inputFormatType =
1100 getMappedBlockInput.getAttribute(
1101 'format-type'
1102 ) || 'us-style';
1103 PAYMENT_UTILITY.updatePaymentBlockAmount(
1104 paymentInput,
1105 getMappedBlockInput.value,
1106 inputFormatType
1107 );
1108 }
1109 } else if (
1110 getMappedBlock.classList.contains(
1111 'srfm-dropdown-block'
1112 )
1113 ) {
1114 const hiddenInput = getMappedBlock.querySelector(
1115 '.srfm-input-dropdown-hidden'
1116 );
1117 if ( hiddenInput ) {
1118 hiddenInput.addEventListener(
1119 'change',
1120 () => {
1121 const amount =
1122 PAYMENT_UTILITY.getDropdownAmount(
1123 getMappedBlock,
1124 hiddenInput
1125 );
1126 PAYMENT_UTILITY.updatePaymentBlockAmount(
1127 paymentInput,
1128 amount
1129 );
1130 },
1131 listenerOpts
1132 );
1133 // Set initial value
1134 const initialAmount =
1135 PAYMENT_UTILITY.getDropdownAmount(
1136 getMappedBlock,
1137 hiddenInput
1138 );
1139 PAYMENT_UTILITY.updatePaymentBlockAmount(
1140 paymentInput,
1141 initialAmount
1142 );
1143 }
1144 } else if (
1145 getMappedBlock.classList.contains(
1146 'srfm-multi-choice-block'
1147 )
1148 ) {
1149 const hiddenInput = getMappedBlock.querySelector(
1150 '.srfm-input-multi-choice-hidden'
1151 );
1152 if ( hiddenInput ) {
1153 hiddenInput.addEventListener(
1154 'change',
1155 () => {
1156 const amount =
1157 PAYMENT_UTILITY.getMultiChoiceAmount(
1158 getMappedBlock,
1159 hiddenInput
1160 );
1161 PAYMENT_UTILITY.updatePaymentBlockAmount(
1162 paymentInput,
1163 amount
1164 );
1165 },
1166 listenerOpts
1167 );
1168 // Set initial value
1169 const initialAmount =
1170 PAYMENT_UTILITY.getMultiChoiceAmount(
1171 getMappedBlock,
1172 hiddenInput
1173 );
1174 PAYMENT_UTILITY.updatePaymentBlockAmount(
1175 paymentInput,
1176 initialAmount
1177 );
1178 }
1179 } else if (
1180 getMappedBlock.classList.contains(
1181 'srfm-hidden-block'
1182 )
1183 ) {
1184 const hiddenFieldInput =
1185 getMappedBlock.querySelector(
1186 '.srfm-hidden-input'
1187 );
1188 if ( hiddenFieldInput ) {
1189 // Hidden inputs don't fire native input/change
1190 // events when set programmatically, so listen
1191 // for both — integrators that set the value
1192 // via JS should dispatch a 'change' event.
1193 const syncAmount = () => {
1194 const trimmed =
1195 hiddenFieldInput.value.trim();
1196 const rawValue =
1197 /^\d+(\.\d+)?$/.test( trimmed )
1198 ? parseFloat( trimmed )
1199 : NaN;
1200 const amount =
1201 isNaN( rawValue ) || rawValue < 0
1202 ? 0
1203 : rawValue;
1204 PAYMENT_UTILITY.updatePaymentBlockAmount(
1205 paymentInput,
1206 amount
1207 );
1208 };
1209 hiddenFieldInput.addEventListener(
1210 'change',
1211 syncAmount
1212 );
1213 hiddenFieldInput.addEventListener(
1214 'input',
1215 syncAmount
1216 );
1217 // Set initial value from defaultValue.
1218 syncAmount();
1219 }
1220 }
1221 }
1222 }
1223 }
1224 }
1225 },
1226 getCurrencySymbol: ( currencyCode ) => {
1227 // Use localized currency data from PHP
1228 const currenciesData = window.srfmStripe?.currenciesData || {};
1229 const upperCurrencyCode = currencyCode?.toUpperCase();
1230 const currencyData = currenciesData[ upperCurrencyCode ];
1231
1232 // Return symbol from localized data, or fallback to currency code
1233 return currencyData?.symbol || currencyCode;
1234 },
1235 /**
1236 * Get currency sign position from settings
1237 * @return {string} Currency sign position ('left', 'right', 'left_space', 'right_space')
1238 */
1239 getCurrencySignPosition: () => {
1240 return window.srfmStripe?.currencySignPosition || 'left';
1241 },
1242 /**
1243 * Format amount with currency symbol based on position setting
1244 * @param {string} currencySymbol - The currency symbol (e.g., "$")
1245 * @param {string|number} formattedAmount - The formatted amount string
1246 * @param {string} position - Currency sign position (optional, defaults to setting)
1247 * @return {string} Formatted amount with currency symbol in correct position
1248 */
1249 formatAmountWithCurrencyPosition: (
1250 currencySymbol,
1251 formattedAmount,
1252 position = null
1253 ) => {
1254 const signPosition =
1255 position || PAYMENT_UTILITY.getCurrencySignPosition();
1256
1257 switch ( signPosition ) {
1258 case 'right':
1259 return `${ formattedAmount }${ currencySymbol }`;
1260 case 'left_space':
1261 return `${ currencySymbol } ${ formattedAmount }`;
1262 case 'right_space':
1263 return `${ formattedAmount } ${ currencySymbol }`;
1264 case 'left':
1265 default:
1266 return `${ currencySymbol }${ formattedAmount }`;
1267 }
1268 },
1269 /**
1270 * Get amount from dropdown block based on selected option values
1271 * @param {HTMLElement} dropdownBlock - The dropdown block element
1272 * @param {HTMLElement} hiddenInput - The hidden input containing selected values
1273 * @return {number} The total amount from selected options
1274 */
1275 getDropdownAmount: ( dropdownBlock, hiddenInput ) => {
1276 const selectedValues = [];
1277 const hiddenInputValue = hiddenInput.value;
1278
1279 if ( ! hiddenInputValue ) {
1280 return 0;
1281 }
1282
1283 const { extractValue, normalizeDashes } =
1284 window.srfm?.srfmUtility || {};
1285
1286 // Extract selected values from hidden input (format: "Option 1 | Option 2")
1287 const selectedOptions = extractValue
1288 ? extractValue( hiddenInputValue )
1289 : hiddenInputValue.split( '|' ).map( ( v ) => v.trim() );
1290
1291 // Get all dropdown options
1292 const options = dropdownBlock.querySelectorAll(
1293 '.srfm-dropdown-input option[option-value]'
1294 );
1295
1296 selectedOptions.forEach( ( selectedOption ) => {
1297 options.forEach( ( option ) => {
1298 const optionText = normalizeDashes( option.innerText?.trim() );
1299 const selectedOptionText = normalizeDashes(
1300 selectedOption?.trim()
1301 );
1302
1303 if ( optionText === selectedOptionText ) {
1304 const optionValue = option.getAttribute( 'option-value' );
1305 // Only add numeric values
1306 if ( ! isNaN( optionValue ) ) {
1307 selectedValues.push( parseFloat( optionValue ) );
1308 } else if ( '' === optionValue ) {
1309 selectedValues.push( 0 );
1310 }
1311 }
1312 } );
1313 } );
1314
1315 // Sum all selected option values
1316 return selectedValues.length > 0
1317 ? selectedValues.reduce( ( sum, value ) => sum + value, 0 )
1318 : 0;
1319 },
1320 /**
1321 * Get amount from multi-choice block based on selected option values
1322 * @param {HTMLElement} multiChoiceBlock - The multi-choice block element
1323 * @param {HTMLElement} hiddenInput - The hidden input containing selected values
1324 * @return {number} The total amount from selected options
1325 */
1326 getMultiChoiceAmount: ( multiChoiceBlock, hiddenInput ) => {
1327 const selectedValues = [];
1328 const hiddenInputValue = hiddenInput.value;
1329
1330 if ( ! hiddenInputValue ) {
1331 return 0;
1332 }
1333
1334 const { extractValue } = window.srfm?.srfmUtility || {};
1335
1336 // Extract selected values from hidden input (format: "Option 1 | Option 2")
1337 const selectedOptions = extractValue( hiddenInputValue );
1338
1339 // Get all multi-choice options
1340 const choices = multiChoiceBlock.querySelectorAll(
1341 '.srfm-multi-choice-single'
1342 );
1343
1344 // Normalize whitespace: collapse multiple spaces to one, then trim.
1345 // This is necessary because browsers collapse consecutive whitespace in
1346 // innerText, while the stored value (from data-option-text attribute)
1347 // preserves the original spacing from the block attributes.
1348 const normalizeStr = ( str ) =>
1349 typeof str === 'string' ? str.trim().replace( /\s+/g, ' ' ) : '';
1350
1351 selectedOptions.forEach( ( selectedOption ) => {
1352 choices.forEach( ( choice ) => {
1353 const label = choice.querySelector(
1354 '.srfm-option-container label'
1355 );
1356 if (
1357 normalizeStr( label?.innerText ) ===
1358 normalizeStr( selectedOption )
1359 ) {
1360 const input = choice.querySelector(
1361 '.srfm-input-multi-choice-single'
1362 );
1363 const optionValue = input?.getAttribute( 'option-value' );
1364 // Only add numeric values
1365 if ( ! isNaN( optionValue ) ) {
1366 selectedValues.push( parseFloat( optionValue ) );
1367 } else if ( '' === optionValue ) {
1368 selectedValues.push( 0 );
1369 }
1370 }
1371 } );
1372 } );
1373
1374 // Sum all selected option values
1375 return selectedValues.length > 0
1376 ? selectedValues.reduce( ( sum, value ) => sum + value, 0 )
1377 : 0;
1378 },
1379 getStripeStrings: ( code, defaultMessage = '' ) => {
1380 // If no code provided
1381 if ( ! code || code === null || code === undefined ) {
1382 // Return default message if provided, otherwise unknown error
1383 return defaultMessage && defaultMessage.trim() !== ''
1384 ? defaultMessage
1385 : window.srfmStripe?.strings?.unknown_error ||
1386 'An unknown error occurred. Please try again or contact the site administrator.';
1387 }
1388
1389 // Check if code exists in localized strings
1390 const localizedMessage = window.srfmStripe?.strings?.[ code ];
1391
1392 if ( localizedMessage ) {
1393 // Code found in localized strings, return it
1394 return localizedMessage;
1395 }
1396
1397 // Code not found in localized strings
1398 // Return default message if provided, otherwise unknown error
1399 return defaultMessage && defaultMessage.trim() !== ''
1400 ? defaultMessage
1401 : window.srfmStripe?.strings?.unknown_error ||
1402 'An unknown error occurred. Please try again or contact the site administrator.';
1403 },
1404 };
1405
1406 window.srfmPaymentUtility = PAYMENT_UTILITY;
1407
1408 /**
1409 * Initializes StripePayment for forms after SureForms initialization event.
1410 */
1411 document.addEventListener( 'srfm_form_after_initialization', ( event ) => {
1412 const form = event?.detail?.form;
1413 if ( form ) {
1414 // Check if form has payment blocks before initializing
1415 const paymentBlocks = form.querySelectorAll(
1416 '.srfm-block.srfm-payment-block'
1417 );
1418 if ( paymentBlocks.length > 0 ) {
1419 new StripePayment( form );
1420 PAYMENT_UTILITY.init( form );
1421 // BOTH MODE: wire the one-time / subscription radio chooser, if present.
1422 initPaymentTypeChoosers( form );
1423 }
1424 }
1425 } );
1426
1427 // BOTH MODE: start — payment-type chooser (one-time vs subscription) wiring.
1428
1429 /**
1430 * Wire the payment-type chooser radios for any payment blocks in this form
1431 * that were saved with paymentType === 'both'.
1432 *
1433 * @param {HTMLFormElement} form - The form element.
1434 */
1435 function initPaymentTypeChoosers( form ) {
1436 const paymentBlocks = form.querySelectorAll(
1437 '.srfm-block.srfm-payment-block'
1438 );
1439
1440 paymentBlocks.forEach( ( paymentBlock ) => {
1441 const paymentInput = paymentBlock.querySelector(
1442 'input.srfm-payment-input'
1443 );
1444 if ( ! paymentInput ) {
1445 return;
1446 }
1447
1448 // Only wire when the admin chose "both" mode.
1449 if (
1450 paymentInput.getAttribute( 'data-original-payment-type' ) !==
1451 'both'
1452 ) {
1453 return;
1454 }
1455
1456 const radios = paymentBlock.querySelectorAll(
1457 '.srfm-payment-type-choice-radio'
1458 );
1459 if ( radios.length === 0 ) {
1460 return;
1461 }
1462
1463 radios.forEach( ( radio ) => {
1464 radio.addEventListener( 'change', ( event ) => {
1465 if ( ! event.target.checked ) {
1466 return;
1467 }
1468
1469 // BOTH MODE: block type-flip while a payment is in flight.
1470 // reinitForBlock() unmounts the Stripe Element and deletes the
1471 // cached intent the pending confirmPayment is still using —
1472 // flipping mid-submit produces double charges or wrong-type
1473 // completions. Revert the radio to whatever data-payment-type
1474 // currently is so the UI does not lie about the user's choice.
1475 const blockId =
1476 paymentBlock.getAttribute( 'data-block-id' );
1477 const compositeKey = getPaymentKey( form, blockId );
1478 const reinitInFlight =
1479 StripePayment.reinitInProgress.has( compositeKey );
1480
1481 if (
1482 form.dataset.srfmPaymentInFlight === 'true' ||
1483 reinitInFlight
1484 ) {
1485 const activeType =
1486 paymentInput.getAttribute( 'data-payment-type' ) ||
1487 'one-time';
1488 radios.forEach( ( r ) => {
1489 r.checked = r.value === activeType;
1490 } );
1491 return;
1492 }
1493
1494 switchActivePaymentType(
1495 form,
1496 paymentBlock,
1497 paymentInput,
1498 event.target.value
1499 );
1500 } );
1501 } );
1502 } );
1503 }
1504
1505 /**
1506 * Apply a new active payment type to the block. Updates DOM visibility, syncs
1507 * the live data-* attributes that the rest of the JS reads, re-initializes
1508 * the Stripe Element in the new mode, and dispatches a gateway-agnostic event
1509 * so other gateways (e.g. PayPal in sureforms-pro) can react.
1510 *
1511 * @param {HTMLFormElement} form - The form element.
1512 * @param {HTMLElement} paymentBlock - The payment block wrapper.
1513 * @param {HTMLInputElement} paymentInput - The hidden payment input.
1514 * @param {string} newType - 'one-time' or 'subscription'.
1515 */
1516 function switchActivePaymentType( form, paymentBlock, paymentInput, newType ) {
1517 const safeType = newType === 'subscription' ? 'subscription' : 'one-time';
1518
1519 // 1. Toggle visible amount block.
1520 const amountBlocks = paymentBlock.querySelectorAll(
1521 '.srfm-payment-amount-block'
1522 );
1523 amountBlocks.forEach( ( el ) => {
1524 if ( el.getAttribute( 'data-payment-type' ) === safeType ) {
1525 el.removeAttribute( 'hidden' );
1526 } else {
1527 el.setAttribute( 'hidden', '' );
1528 }
1529 } );
1530
1531 // 2. Sync the live data attributes from the per-type configuration.
1532 const prefix = safeType === 'subscription' ? 'subscription' : 'one-time';
1533 const amountType =
1534 paymentInput.getAttribute( `data-${ prefix }-amount-type` ) || 'fixed';
1535 const fixedAmount =
1536 paymentInput.getAttribute( `data-${ prefix }-fixed-amount` ) || '0';
1537 const minimumAmount =
1538 paymentInput.getAttribute( `data-${ prefix }-minimum-amount` ) || '0';
1539 const variableField =
1540 paymentInput.getAttribute( `data-${ prefix }-variable-amount-field` ) ||
1541 '';
1542
1543 paymentInput.setAttribute( 'data-payment-type', safeType );
1544 paymentInput.setAttribute( 'data-amount-type', amountType );
1545 paymentInput.setAttribute( 'data-fixed-amount', fixedAmount );
1546
1547 if ( parseFloat( minimumAmount ) > 0 ) {
1548 paymentInput.setAttribute( 'data-minimum-amount', minimumAmount );
1549 } else {
1550 paymentInput.removeAttribute( 'data-minimum-amount' );
1551 }
1552
1553 if ( amountType === 'variable' && variableField ) {
1554 paymentInput.setAttribute( 'data-variable-amount-field', variableField );
1555 } else {
1556 paymentInput.removeAttribute( 'data-variable-amount-field' );
1557 // Also clear any cached current-amount so stale values don't leak across choices.
1558 paymentInput.removeAttribute( 'data-current-amount' );
1559 }
1560
1561 // 3. Re-initialize Stripe Element in the new mode.
1562 if ( typeof StripePayment.reinitForBlock === 'function' ) {
1563 StripePayment.reinitForBlock( form, paymentBlock );
1564 }
1565
1566 // 3b. BOTH MODE: re-wire variable-amount listeners and trigger an immediate
1567 // display update for the new type. listenAmountChanges() queries
1568 // [data-variable-amount-field] which was just updated above, so it will
1569 // find and wire the correct field. The immediate update populates the
1570 // amount span which is empty at PHP render time for variable types.
1571 if ( amountType === 'variable' && variableField ) {
1572 PAYMENT_UTILITY.listenAmountChanges();
1573
1574 // Read current value from the mapped field and update the display now.
1575 const mappedBlock = form.querySelector(
1576 `.srfm-block.srfm-slug-${ variableField }`
1577 );
1578 if ( mappedBlock ) {
1579 const numberInput = mappedBlock.querySelector(
1580 'input.srfm-input-common'
1581 );
1582 const dropdownInput = mappedBlock.querySelector(
1583 '.srfm-input-dropdown-hidden'
1584 );
1585 const multiChoiceInput = mappedBlock.querySelector(
1586 '.srfm-input-multi-choice-hidden'
1587 );
1588 const hiddenFieldInput =
1589 mappedBlock.querySelector( '.srfm-hidden-input' );
1590
1591 let currentValue = 0;
1592 if ( numberInput ) {
1593 currentValue = numberInput.value || 0;
1594 } else if ( dropdownInput ) {
1595 currentValue =
1596 PAYMENT_UTILITY.getDropdownAmount(
1597 mappedBlock,
1598 dropdownInput
1599 ) || 0;
1600 } else if ( multiChoiceInput ) {
1601 currentValue =
1602 PAYMENT_UTILITY.getMultiChoiceAmount(
1603 mappedBlock,
1604 multiChoiceInput
1605 ) || 0;
1606 } else if ( hiddenFieldInput ) {
1607 // Mirror syncAmount() in listenAmountChanges — accept only numeric
1608 // strings, clamp negatives to 0. Without this branch, currentValue
1609 // stays 0 and overwrites the value syncAmount() just wrote during
1610 // listenAmountChanges() above, breaking initial-amount pickup on
1611 // every one-time/subscription flip.
1612 const trimmed = hiddenFieldInput.value.trim();
1613 const rawValue = /^\d+(\.\d+)?$/.test( trimmed )
1614 ? parseFloat( trimmed )
1615 : NaN;
1616 currentValue =
1617 isNaN( rawValue ) || rawValue < 0 ? 0 : rawValue;
1618 }
1619
1620 PAYMENT_UTILITY.updatePaymentBlockAmount(
1621 paymentInput,
1622 currentValue
1623 );
1624 } else {
1625 // No mapped field found — show placeholder.
1626 PAYMENT_UTILITY.updatePaymentBlockAmount( paymentInput, 0 );
1627 }
1628 }
1629
1630 // 4. Dispatch gateway-agnostic event so other gateways (PayPal etc.) can react.
1631 //
1632 // Event contract — `srfm_payment_type_changed` (document-level, bubbles):
1633 // detail: {
1634 // blockId: string — Original payment block id (no instance prefix).
1635 // form: HTMLFormElement — Form element the payment block belongs to.
1636 // paymentType: 'one-time' | 'subscription' — The newly active type.
1637 // paymentInput: HTMLInputElement — The hidden .srfm-payment-input element.
1638 // }
1639 // Companion event `srfm_payment_method_changed` (dispatched in payment-manager.js)
1640 // uses a deliberately narrower shape — { blockId, paymentMethod, form } — because
1641 // gateway listeners only need the method id; if you need paymentInput on that
1642 // path, query it from the form rather than expanding the schema (avoids drift).
1643 const blockId = paymentBlock.getAttribute( 'data-block-id' );
1644 document.dispatchEvent(
1645 new CustomEvent( 'srfm_payment_type_changed', {
1646 detail: {
1647 blockId,
1648 form,
1649 paymentType: safeType,
1650 paymentInput,
1651 },
1652 bubbles: true,
1653 } )
1654 );
1655 }
1656 // BOTH MODE: end
1657