PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / trunk
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder vtrunk
6.2.15 6.2.14 6.2.13 6.2.12 6.2.10 6.2.11 6.2.9 6.2.8 6.2.7 6.2.6 6.2.5 6.2.4 6.2.3 6.2.2 3.6.22 3.6.31 3.6.40 3.6.41 3.6.42 3.6.50 3.6.51 3.6.60 3.6.61 3.6.62 3.6.64 All 197 releases
fluentform / app / Modules / Payments / PaymentMethods / Stripe / StripeInlineProcessor.php

StripeInlineProcessor.php in Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder trunk, at app/Modules/Payments/PaymentMethods/Stripe/StripeInlineProcessor.php

773 lines 33.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentForm\App\Modules\Payments\PaymentMethods\Stripe;
4
5 use FluentForm\App\Helpers\Helper;
6 use FluentForm\App\Models\Submission;
7 use FluentForm\App\Modules\Payments\PaymentHelper;
8 use FluentForm\Framework\Helpers\ArrayHelper;
9 use FluentForm\App\Modules\Payments\PaymentMethods\Stripe\API\SCA;
10 use FluentForm\App\Modules\Payments\PaymentMethods\Stripe\API\Plan;
11 use FluentForm\App\Modules\Payments\PaymentMethods\Stripe\API\Invoice;
12 use FluentForm\App\Modules\Payments\PaymentMethods\Stripe\API\Customer;
13
14 if (!defined('ABSPATH')) {
15 exit; // Exit if accessed directly.
16 }
17
18 class StripeInlineProcessor extends StripeProcessor
19 {
20
21 public function init()
22 {
23 /*
24 * After form submission this hooks fire to start Making payment
25 */
26 add_action('fluentform/process_payment_stripe_inline', [$this, 'handlePaymentAction'], 10, 6);
27
28 /*
29 * Mainly for single payment items
30 */
31 add_action('wp_ajax_fluentform_sca_inline_confirm_payment', [$this, 'confirmScaPayment']);
32 add_action('wp_ajax_nopriv_fluentform_sca_inline_confirm_payment', [$this, 'confirmScaPayment']);
33
34 /*
35 * For Subscription payment + maybe single payment items
36 */
37 add_action('wp_ajax_fluentform_sca_inline_confirm_payment_setup_intents', array($this, 'confirmScaSetupIntentsPayment'));
38 add_action('wp_ajax_nopriv_fluentform_sca_inline_confirm_payment_setup_intents', array($this, 'confirmScaSetupIntentsPayment'));
39 }
40
41 public function handlePaymentAction($submissionId, $submissionData, $form, $methodSettings, $hasSubscriptions, $totalPayable)
42 {
43 $this->setSubmissionId($submissionId);
44 $this->form = $form;
45 $submission = $this->getSubmission();
46 $paymentTotal = $this->getAmountTotal();
47
48 if (!$paymentTotal && !$hasSubscriptions) {
49 return false;
50 }
51
52 // Create the initial transaction here
53 $transaction = $this->createInitialPendingTransaction($submission, $hasSubscriptions);
54
55 $paymentMethodId = ArrayHelper::get($submissionData['response'], '__stripe_payment_method_id');
56 $customerArgs = $this->customerArguments($paymentMethodId, $submission);
57
58 $customer = Customer::createCustomer($customerArgs, $this->form->id);
59
60 if (is_wp_error($customer)) {
61 // We have errors
62 $this->handlePaymentChargeError($customer->get_error_message(), $submission, $transaction);
63 }
64
65 if ($transaction->transaction_type == 'subscription') {
66 $this->handleSetupIntent($submission, $paymentMethodId, $customer, $transaction, $totalPayable);
67 } else {
68 // Let's create the one time payment first
69 // We will handle One-Time Payment Here only
70 $paymentSettings = PaymentHelper::getFormSettings($form->id, 'admin');
71 $intentArgs = [
72 'payment_method' => $paymentMethodId,
73 'amount' => $transaction->payment_total,
74 'currency' => $transaction->currency,
75 'confirmation_method' => 'manual',
76 'confirm' => 'true',
77 'description' => $this->getProductNames(),
78 'statement_descriptor_suffix' => StripeSettings::getPaymentDescriptor($form),
79 'metadata' => $this->getIntentMetaData($submission, $form, $transaction, $paymentSettings),
80 'customer' => $customer->id,
81 ];
82
83 $intentArgs = apply_filters('fluentform/stripe_checkout_args_inline', $intentArgs, $submission, $transaction, $form);
84
85 // If FluentForm Pro is not installed, apply the fee 1.9% of the total amount
86 if (!Helper::hasPro()) {
87 $applicationFeeAmount = $this->calculateApplicationFeeAmount(
88 $totalPayable,
89 $transaction->currency
90 );
91 $intentArgs['application_fee_amount'] = $applicationFeeAmount;
92 }
93 $this->handlePaymentIntent($transaction, $submission, $intentArgs);
94 }
95 }
96
97 // This is only for Subscription Payment
98 protected function handleSetupIntent($submission, $paymentMethodId, $customer, $transaction, $totalPayable)
99 {
100 if (is_wp_error($customer)) {
101 $this->handlePaymentChargeError($customer->get_error_message(), $submission, $transaction, false, 'customer');
102 }
103
104 $subscriptions = $this->getSubscriptions();
105
106 $subscription = $subscriptions[0];
107
108 $subscriptionTransactionArgs = Plan::getPriceIdsFromSubscriptionTransaction($subscription, $transaction);
109
110 if (is_wp_error($subscriptionTransactionArgs)) {
111 $this->handlePaymentChargeError($subscriptionTransactionArgs->get_error_message(), $submission, $transaction, false, 'customer');
112 }
113
114 $subscriptionArgs = [
115 'customer' => $customer->id,
116 'metadata' => $this->getIntentMetaData($submission, $this->getForm(), $transaction),
117 'payment_behavior' => 'allow_incomplete',
118 ];
119
120 $subscriptionArgs['items'] = $subscriptionTransactionArgs['items'];
121
122 if ($signupFee = $subscriptionTransactionArgs['signup_fee']) {
123 Invoice::createItem([
124 'amount' => $signupFee,
125 'currency' => $submission->currency,
126 'customer' => $customer->id,
127 /* translators: %s is the plan name */
128 'description' => sprintf(__('Signup fee for %s', 'fluentform'), $subscription->plan_name),
129 ], $submission->form_id);
130 }
131
132 // Maybe we have to set a cancel_at parameter to subscription args
133 if ($cancelledAt = Plan::getCancelledAtTimestamp($subscription)) {
134 $subscriptionArgs['cancel_at'] = $cancelledAt;
135 }
136
137 if ($subscription->trial_days) {
138 $dateTime = current_datetime();
139 $localtime = $dateTime->getTimestamp() + $dateTime->getOffset();
140 $subscriptionArgs['trial_end'] = $localtime + $subscription->trial_days * 86400;
141 }
142
143 $subscriptionArgs = apply_filters('fluentform/stripe_subscription_args_inline', $subscriptionArgs, $submission, $transaction, $this->getForm());
144
145 // If FluentForm Pro is not installed, apply the fee 1.9%
146 if (!Helper::hasPro()) {
147 $subscriptionArgs['application_fee_percent'] = 1.9;
148 }
149
150 $subscriptionPayment = Plan::subscribe($subscriptionArgs, $submission->form_id);
151
152 if (is_wp_error($subscriptionPayment)) {
153 $this->handlePaymentChargeError($subscriptionPayment->get_error_message(), $submission, $transaction, false, 'subscription');
154 }
155
156 $invoice = Invoice::retrieve(
157 $subscriptionPayment->latest_invoice,
158 $this->form->id,
159 [
160 'expand' => ['payment_intent.charges'],
161 ]
162 );
163 if (is_wp_error($invoice)) {
164 $this->handlePaymentChargeError($invoice->get_error_message(), $submission, $transaction, false, 'invoice');
165 }
166
167 if (
168 $invoice->payment_intent &&
169 $invoice->payment_intent->status == 'requires_action' &&
170 $invoice->payment_intent->next_action->type == 'use_stripe_sdk'
171 ) {
172 $transactionId = false;
173 if ($transaction) {
174 $transactionId = $transaction->id;
175 }
176 $this->processScaBeforeVerification($submission->form_id, $submission->id, $transactionId, $invoice->payment_intent->id);
177
178 $nonceAction = 'fluentform_sca_confirm_' . $submission->id;
179 $nonce = wp_create_nonce($nonceAction);
180
181 wp_send_json_success([
182 'nextAction' => 'payment',
183 'actionName' => 'stripeSetupIntent',
184 'stripe_subscription_id' => $subscriptionPayment->id,
185 'payment_method_id' => $paymentMethodId,
186 'intent' => $invoice->payment_intent,
187 'submission_id' => $submission->id,
188 'customer_name' => ($transaction) ? $transaction->payer_name : '',
189 'customer_email' => ($transaction) ? $transaction->payer_email : '',
190 'client_secret' => $invoice->payment_intent->client_secret,
191 '_ff_stripe_nonce' => $nonce,
192 'message' => __('Verifying your card details. Please wait...', 'fluentform'),
193 'result' => [
194 'insert_id' => $submission->id,
195 ],
196 ], 200);
197 }
198
199 // now this payment is successful. We don't need anything else
200 $this->handlePaidSubscriptionInvoice($invoice, $submission);
201 }
202
203 protected function customerArguments($paymentMethodId, $submission)
204 {
205 $customerArgs = [
206 'payment_method' => $paymentMethodId,
207 'invoice_settings' => [
208 'default_payment_method' => $paymentMethodId,
209 ],
210 'metadata' => [
211 'submission_id' => $submission->id,
212 'form_id' => $submission->form_id,
213 'form_name' => wp_strip_all_tags($this->form->title),
214 ],
215 ];
216
217 $receiptEmail = PaymentHelper::getCustomerEmail($submission, $this->form);
218
219 if ($receiptEmail) {
220 $customerArgs['email'] = $receiptEmail;
221 }
222
223 $receiptName = PaymentHelper::getCustomerName($submission, $this->form);
224
225 if ($receiptName) {
226 $customerArgs['name'] = $receiptName;
227 $customerArgs['description'] = $receiptName;
228 }
229
230 $address = PaymentHelper::getCustomerAddress($submission);
231 if ($address) {
232 $customerArgs['address'] = [
233 'city' => ArrayHelper::get($address, 'city'),
234 'country' => ArrayHelper::get($address, 'country'),
235 'line1' => ArrayHelper::get($address, 'address_line_1'),
236 'line2' => ArrayHelper::get($address, 'address_line_2'),
237 'postal_code' => ArrayHelper::get($address, 'zip'),
238 'state' => ArrayHelper::get($address, 'state'),
239 ];
240 }
241
242 return $customerArgs;
243 }
244
245 protected function handlePaidSubscriptionInvoice($invoice, $submission)
246 {
247 if ($invoice->status !== 'paid') {
248 wp_send_json([
249 'errors' => __('Stripe Error: Payment Failed! Please try again.', 'fluentform'),
250 ], 423);
251 }
252
253 // was: an unconditional write; a refund recorded after the guard's read was overwritten with paid
254 if (!$this->changeSubmissionPaymentStatusUnlessReversed('paid')) {
255 $this->refuseIfReversedMeanwhile($submission, null, null);
256 }
257
258 $subscriptions = $this->getSubscriptions();
259
260 $this->processSubscriptionSuccess($subscriptions, $invoice, $submission);
261
262 $transaction = $this->getLastTransaction($submission->id);
263
264 // was: intent only; a $0 invoice has none, so the invoice decides
265 $paymentStatus = $this->getIntentSuccessName($invoice->payment_intent, $invoice);
266 if (!$this->processOnetimeSuccess($invoice, $transaction, $paymentStatus)) {
267 $this->refuseIfReversedMeanwhile($submission, $transaction, null);
268 }
269
270 $this->recalculatePaidTotal();
271
272 $this->sendSuccess($submission);
273 }
274
275 protected function handlePaymentIntent($transaction, $submission, $intentArgs)
276 {
277 $formSettings = PaymentHelper::getFormSettings($submission->form_id);
278
279 if (PaymentHelper::isZeroDecimal($transaction->currency)) {
280 $intentArgs['amount'] = intval($transaction->payment_total / 100);
281 }
282
283 $receiptEmail = PaymentHelper::getCustomerEmail($submission, $this->form);
284
285 if ($receiptEmail && ArrayHelper::get($formSettings, 'disable_stripe_payment_receipt') != 'yes') {
286 $intentArgs['receipt_email'] = $receiptEmail;
287 }
288
289 $intent = SCA::createPaymentIntent($intentArgs, $this->form->id);
290
291 if (is_wp_error($intent)) {
292 $this->handlePaymentChargeError($intent->get_error_message(), $submission, $transaction, false, 'payment_intent', $intent);
293 }
294
295 if (
296 $intent->status == 'requires_action' &&
297 $intent->next_action &&
298 $intent->next_action->type == 'use_stripe_sdk'
299 ) {
300 $this->processScaBeforeVerification($submission->form_id, $submission->id, $transaction->id, $intent->id);
301
302 // Generate nonce for secure SCA confirmation
303 $nonceAction = 'fluentform_sca_confirm_' . $submission->id;
304 $nonce = wp_create_nonce($nonceAction);
305
306 # Tell the client to handle the action
307 wp_send_json_success([
308 'nextAction' => 'payment',
309 'actionName' => 'initStripeSCAModal',
310 'submission_id' => $submission->id,
311 'client_secret' => $intent->client_secret,
312 '_ff_stripe_nonce' => $nonce,
313 'message' => apply_filters('fluentform/stripe_strong_customer_verify_waiting_message', __('Verifying strong customer authentication. Please wait...', 'fluentform')),
314 'result' => [
315 'insert_id' => $submission->id,
316 ],
317 ], 200);
318
319 } elseif ('succeeded' == $intent->status) {
320 // Payment is succeeded here
321 $charge = $intent->charges->data[0];
322
323 $this->handlePaymentSuccess($charge, $transaction, $submission);
324 } else {
325 $message = __('Payment Failed! Your card may have been declined.', 'fluentform');
326
327 if (!empty($intent->error->message)) {
328 $message = $intent->error->message;
329 }
330
331 $this->handlePaymentChargeError($message, $submission, $transaction, false, 'payment_intent');
332 }
333 }
334
335 protected function handlePaymentSuccess($charge, $transaction, $submission)
336 {
337 $transactionData = [
338 'charge_id' => $charge->payment_intent,
339 'payment_method' => 'stripe',
340 'payment_mode' => $this->getPaymentMode(),
341 'payment_note' => maybe_serialize($charge),
342 ];
343
344 $methodDetails = $charge->payment_method_details;
345 if ($methodDetails && !empty($methodDetails->card)) {
346 $transactionData['card_brand'] = $methodDetails->card->brand;
347 $transactionData['card_last_4'] = $methodDetails->card->last4;
348 }
349
350 $this->updateTransaction($transaction->id, $transactionData);
351
352 // was: an unconditional write; a refund recorded after the guard's read was overwritten with paid
353 if (!$this->changeTransactionStatusUnlessReversed($transaction->id, 'paid')) {
354 $this->refuseIfReversedMeanwhile($submission, $transaction, null);
355 }
356
357 $logData = [
358 'parent_source_id' => $submission->form_id,
359 'source_type' => 'submission_item',
360 'source_id' => $submission->id,
361 'component' => 'Payment',
362 'status' => 'info',
363 'title' => __('Payment Status changed', 'fluentform'),
364 'description' => __('Payment status changed to paid', 'fluentform'),
365 ];
366
367 do_action('fluentform/log_data', $logData);
368
369 $this->updateSubmission($submission->id, [
370 'payment_method' => 'stripe',
371 ]);
372
373 // Trigger fluentform/after_payment_status_change (via BaseProcessor),
374 // consistent with hosted Stripe checkout and offline payment flows.
375 if (!$this->changeSubmissionPaymentStatusUnlessReversed('paid')) {
376 $this->refuseIfReversedMeanwhile($submission, $transaction, null);
377 }
378
379 $logData = [
380 'parent_source_id' => $submission->form_id,
381 'source_type' => 'submission_item',
382 'source_id' => $submission->id,
383 'component' => 'Payment',
384 'status' => 'success',
385 'title' => __('Payment Complete', 'fluentform'),
386 'description' => __('One time Payment Successfully made via Stripe. Charge ID: ', 'fluentform') . $charge->id,
387 ];
388
389 do_action('fluentform/log_data', $logData);
390
391 $this->recalculatePaidTotal();
392
393 $this->sendSuccess($submission);
394 }
395
396 /**
397 * Validate SCA payment confirmation request
398 *
399 * @param int $submissionId Submission ID
400 * @param string $paymentIntentId Payment Intent ID
401 * @param object|null $submission Submission object
402 * @param object|null $transaction Transaction object
403 * @return array|WP_Error Array with validation result or WP_Error on strict mode failure
404 */
405 protected function validateScaRequest($submissionId, $paymentIntentId, $submission = null, $transaction = null)
406 {
407 $warnings = [];
408
409 // Validate nonce — always required by default.
410 // Filter allows opt-out only for backward compat; emits deprecation notice.
411 $nonce = isset($_REQUEST['_ff_stripe_nonce']) ? sanitize_text_field(wp_unslash($_REQUEST['_ff_stripe_nonce'])) : '';
412
413 if ($nonce) {
414 $nonceAction = 'fluentform_sca_confirm_' . $submissionId;
415 if (!wp_verify_nonce($nonce, $nonceAction)) {
416 return new \WP_Error('invalid_nonce', __('Security verification failed. Invalid nonce.', 'fluentform'));
417 }
418 } else {
419 $strictMode = apply_filters('fluentform/stripe_sca_strict_security', true);
420 if ($strictMode) {
421 return new \WP_Error('missing_nonce', __('Security verification failed. Nonce required.', 'fluentform'));
422 }
423 _deprecated_argument(
424 'fluentform/stripe_sca_strict_security',
425 '6.2.0',
426 esc_html(__('Disabling strict SCA nonce verification is deprecated and will be removed in a future version.', 'fluentform'))
427 );
428 $warnings[] = 'No nonce provided for SCA payment confirmation';
429 }
430
431 // Validate submission exists
432 if (!$submission || !$submission->id) {
433 return new \WP_Error('invalid_submission', __('Invalid submission.', 'fluentform'));
434 }
435
436 // was: rejected a submission already 'paid'; that is now the recovery case, a reversed one is what must never be confirmed
437 if (PaymentHelper::isReversedPaymentStatus($submission->payment_status)) {
438 return new \WP_Error(
439 'payment_reversed',
440 __('This payment has been reversed and cannot be confirmed.', 'fluentform')
441 );
442 }
443
444 if (!$transaction) {
445 return new \WP_Error('no_transaction', __('No transaction found for this submission.', 'fluentform'));
446 }
447
448 // 'intended' is written when the 3DS challenge starts. If the charge.succeeded webhook
449 // lands before the browser returns, this row is already 'paid' (or 'processing') while
450 // the submission and subscription are still unfinished; the browser must complete them.
451 $isAwaitingBrowserConfirmation = 'intended' === $transaction->status;
452 $wasSettledByWebhookBeforeBrowserReturned = in_array($transaction->status, ['processing', 'paid'], true);
453
454 if (!$isAwaitingBrowserConfirmation && !$wasSettledByWebhookBeforeBrowserReturned) {
455 return new \WP_Error(
456 'invalid_transaction_status',
457 __('This transaction is not an active payment attempt and cannot be confirmed.', 'fluentform')
458 );
459 }
460
461 // Verify the payment intent ID matches what was stored during SCA initiation.
462 // processScaBeforeVerification() stores the intent as charge_id.
463 // was: skipped when charge_id was empty; the intent binding is the identity check, so it is required
464 if (!$transaction->charge_id || $transaction->charge_id !== $paymentIntentId) {
465 return new \WP_Error(
466 'payment_intent_mismatch',
467 __('Payment verification failed. Payment intent does not match.', 'fluentform')
468 );
469 }
470
471 // Log warnings for monitoring
472 if (!empty($warnings) && defined('WP_DEBUG') && WP_DEBUG) {
473 $logData = [
474 'parent_source_id' => $submission->form_id,
475 'source_type' => 'submission_item',
476 'source_id' => $submission->id,
477 'component' => 'Payment',
478 'status' => 'warning',
479 'title' => __('Stripe SCA Security Warning', 'fluentform'),
480 'description' => implode('; ', $warnings),
481 ];
482 do_action('fluentform/log_data', $logData);
483 }
484
485 return [
486 'valid' => true,
487 'warnings' => $warnings,
488 ];
489 }
490
491 public function confirmScaPayment()
492 {
493 $submissionId = isset($_REQUEST['submission_id']) ? (int) $_REQUEST['submission_id'] : 0;
494 $paymentMethod = isset($_REQUEST['payment_method']) ? sanitize_text_field(wp_unslash($_REQUEST['payment_method'])) : '';
495 $paymentIntentId = isset($_REQUEST['payment_intent_id']) ? sanitize_text_field(wp_unslash($_REQUEST['payment_intent_id'])) : '';
496
497 $this->setSubmissionId($submissionId);
498 $submission = $this->getSubmission();
499 $this->form = $this->getForm();
500
501 $transaction = $this->getLastTransaction($submissionId);
502
503 $validation = $this->validateScaRequest($submissionId, $paymentIntentId, $submission, $transaction);
504
505 if (is_wp_error($validation)) {
506 wp_send_json([
507 'errors' => $validation->get_error_message(),
508 ], 423);
509 }
510
511 // was: re-ran the status writers, so a repeated callback fired the payment-status hooks again
512 if ($this->isPaymentAlreadyCompleted($submission, $transaction)) {
513 $this->sendSuccess($submission);
514 }
515
516 // Use submission's form_id rather than trusting $_REQUEST
517 $formId = $submission->form_id;
518
519 // was: confirmed blindly; the webhook may already have settled this intent, and a Stripe outage marked the payment failed
520 $confirmation = SCA::retrievePaymentIntent($paymentIntentId, [], $formId);
521
522 if (is_wp_error($confirmation)) {
523 $this->sendRetryableVerificationError($submission, $confirmation);
524 }
525
526 // Confirming an intent Stripe has already settled counts against its confirm limit.
527 if ('requires_confirmation' === $confirmation->status) {
528 $confirmation = SCA::confirmPayment($paymentIntentId, [
529 'payment_method' => $paymentMethod,
530 ], $formId);
531
532 if (is_wp_error($confirmation)) {
533 $message = 'Payment has been failed. ' . $confirmation->get_error_message();
534 $this->handlePaymentChargeError($message, $submission, $transaction, $confirmation, 'payment_error');
535 }
536 }
537
538 if ($confirmation->status == 'succeeded') {
539 $charge = $confirmation->charges->data[0];
540
541 // was: settled from the objects read before the Stripe round-trip; a refund recorded meanwhile,
542 // or one Stripe already reports on the charge, was overwritten with paid and fulfilled
543 $this->refuseIfReversedMeanwhile($submission, $transaction, $charge);
544
545 $confirmedCurrency = strtolower((string) $confirmation->currency);
546 $transactionCurrency = strtolower((string) $transaction->currency);
547 if (!$confirmedCurrency || $confirmedCurrency !== $transactionCurrency) {
548 $logData = [
549 'parent_source_id' => $submission->form_id,
550 'source_type' => 'submission_item',
551 'source_id' => $submission->id,
552 'component' => 'Payment',
553 'status' => 'error',
554 'title' => __('Stripe Currency Mismatch', 'fluentform'),
555 'description' => sprintf(
556 // translators: %1$s is the expected currency, %2$s is the confirmed currency
557 __('Expected %1$s but Stripe confirmed %2$s. Payment rejected.', 'fluentform'),
558 strtoupper($transactionCurrency),
559 strtoupper($confirmedCurrency)
560 ),
561 ];
562 do_action('fluentform/log_data', $logData);
563
564 wp_send_json([
565 'errors' => __('Payment currency verification failed.', 'fluentform'),
566 ], 423);
567 }
568
569 // Verify the confirmed amount matches the transaction amount.
570 // Normalize for zero-decimal currencies: FluentForm stores amounts x100 internally,
571 // but Stripe returns amounts in the currency's smallest unit (e.g. yen for JPY).
572 $confirmedAmount = (int) $confirmation->amount;
573 if (PaymentHelper::isZeroDecimal($transaction->currency)) {
574 $confirmedAmount = $confirmedAmount * 100;
575 }
576 if ($transaction->payment_total && $confirmedAmount != intval($transaction->payment_total)) {
577 $logData = [
578 'parent_source_id' => $submission->form_id,
579 'source_type' => 'submission_item',
580 'source_id' => $submission->id,
581 'component' => 'Payment',
582 'status' => 'error',
583 'title' => __('Stripe Amount Mismatch', 'fluentform'),
584 'description' => sprintf(
585 // translators: %1$d is the expected amount, %2$d is the confirmed amount
586 __('Expected %1$d but Stripe confirmed %2$d. Payment rejected.', 'fluentform'),
587 intval($transaction->payment_total),
588 intval($confirmation->amount)
589 ),
590 ];
591 do_action('fluentform/log_data', $logData);
592
593 wp_send_json([
594 'errors' => __('Payment amount verification failed.', 'fluentform'),
595 ], 423);
596 }
597
598 $this->handlePaymentSuccess($charge, $transaction, $submission);
599 } elseif ('processing' === $confirmation->status) {
600 // was: fell through to failed; Stripe settles a delayed method later by webhook, so nothing is decided yet
601 wp_send_json([
602 'errors' => __('Your payment is still being processed. You will be notified once it completes.', 'fluentform'),
603 ], 423);
604 } else {
605 $this->handlePaymentChargeError('We could not verify your payment. Please try again', $submission, $transaction, $confirmation, 'payment_error');
606 }
607 }
608
609 public function confirmScaSetupIntentsPayment()
610 {
611 $submissionId = isset($_REQUEST['submission_id']) ? intval($_REQUEST['submission_id']) : 0;
612 $intentId = isset($_REQUEST['payment_intent_id']) ? sanitize_text_field(wp_unslash($_REQUEST['payment_intent_id'])) : '';
613
614 $this->setSubmissionId($submissionId);
615 $this->form = $this->getForm();
616
617 $submission = $this->getSubmission();
618 $transaction = $this->getLastTransaction($submissionId);
619
620 // Validate the request
621 $validation = $this->validateScaRequest($submissionId, $intentId, $submission, $transaction);
622
623 if (is_wp_error($validation)) {
624 wp_send_json([
625 'errors' => $validation->get_error_message(),
626 ], 423);
627 }
628
629 // was: re-ran the status writers, so a repeated callback fired the payment-status hooks again
630 if ($this->isPaymentAlreadyCompleted($submission, $transaction)) {
631 $this->sendSuccess($submission);
632 }
633
634 // Use submission's form_id rather than trusting $_REQUEST
635 $formId = $submission->form_id;
636
637 // Let's retrieve the intent
638 $intent = SCA::retrievePaymentIntent($intentId, [
639 'expand' => [
640 'invoice.payment_intent',
641 ],
642 ], $formId);
643
644 // was: handlePaymentChargeError(), which marked a possibly paid submission failed on a Stripe outage
645 if (is_wp_error($intent)) {
646 $this->sendRetryableVerificationError($submission, $intent);
647 }
648
649 $invoice = $intent->invoice;
650
651 // was: settled from the objects read before the Stripe round-trip
652 $this->refuseIfReversedMeanwhile($submission, $transaction, $intent->charges->data[0] ?? null);
653
654 $this->handlePaidSubscriptionInvoice($invoice, $submission);
655 }
656
657 // Re-read after the Stripe round-trip: a refund webhook may have run meanwhile, and Stripe's own
658 // charge carries the refund state before that webhook arrives.
659 protected function refuseIfReversedMeanwhile($submission, $transaction, $charge)
660 {
661 $current = Submission::find($submission->id);
662 $currentTransaction = $transaction ? $this->getTransaction($transaction->id) : null;
663
664 // A row deleted mid-request leaves nothing to settle, so it is refused like a reversal
665 $rowVanished = !$current || ($transaction && !$currentTransaction);
666 $reversedLocally = $rowVanished
667 || PaymentHelper::isReversedPaymentStatus($current->payment_status)
668 || ($currentTransaction && PaymentHelper::isReversedPaymentStatus($currentTransaction->status));
669 // was: full refunds only, while the local check already treats partially-refunded as reversed
670 $refundedAtStripe = $charge && (!empty($charge->refunded) || !empty($charge->amount_refunded));
671
672 if (!$reversedLocally && !$refundedAtStripe) {
673 return;
674 }
675
676 do_action('fluentform/log_data', [
677 'parent_source_id' => $submission->form_id,
678 'source_type' => 'submission_item',
679 'source_id' => $submission->id,
680 'component' => 'Payment',
681 'status' => 'warning',
682 'title' => __('Stripe confirmation refused', 'fluentform'),
683 'description' => $reversedLocally
684 ? __('The payment was reversed before the confirmation completed.', 'fluentform')
685 : __('Stripe reports the charge as refunded.', 'fluentform'),
686 ]);
687
688 wp_send_json([
689 'errors' => __('This payment has been refunded and cannot be confirmed.', 'fluentform'),
690 ], 423);
691 }
692
693 // Transaction paid, submission paid and actions fired: nothing is left for a callback to finish.
694 protected function isPaymentAlreadyCompleted($submission, $transaction)
695 {
696 return 'paid' === $transaction->status
697 && 'paid' === $submission->payment_status
698 && 'yes' === $this->getMetaData('is_form_action_fired');
699 }
700
701 // The outcome is unknown, not failed: no failure hooks, no status downgrade.
702 protected function sendRetryableVerificationError($submission, \WP_Error $error)
703 {
704 do_action('fluentform/log_data', [
705 'parent_source_id' => $submission->form_id,
706 'source_type' => 'submission_item',
707 'source_id' => $submission->id,
708 'component' => 'Payment',
709 'status' => 'warning',
710 'title' => __('Stripe verification deferred', 'fluentform'),
711 'description' => $error->get_error_message(),
712 ]);
713
714 wp_send_json([
715 'errors' => __('We could not reach Stripe to verify your payment. Please try again in a moment.', 'fluentform'),
716 ], 423);
717 }
718
719 protected function sendSuccess($submission)
720 {
721 try {
722 $returnData = $this->getReturnData();
723 wp_send_json_success($returnData, 200);
724
725 } catch (\Exception $e) {
726 wp_send_json([
727 'errors' => $e->getMessage(),
728 ], 423);
729 }
730 }
731
732 protected function processScaBeforeVerification($formId, $submissionId, $transactionId, $chargeId)
733 {
734 if ($transactionId) {
735 $this->updateTransaction($transactionId, [
736 'charge_id' => $chargeId,
737 'payment_mode' => $this->getPaymentMode(),
738 ]);
739
740 $this->changeTransactionStatus($transactionId, 'intended');
741 }
742
743 $logData = [
744 'parent_source_id' => $formId,
745 'source_type' => 'submission_item',
746 'source_id' => $submissionId,
747 'component' => 'Payment',
748 'status' => 'info',
749 'title' => __('Stripe SCA Required', 'fluentform'),
750 'description' => __('SCA is required for this payment. Requested SCA info from customer', 'fluentform'),
751 ];
752
753 do_action('fluentform/log_data', $logData);
754 }
755
756 /**
757 * Products name comma separated
758 *
759 * @return string
760 */
761 public function getProductNames()
762 {
763 $orderItems = $this->getOrderItems();
764 $itemsHtml = '';
765 foreach ($orderItems as $item) {
766 '' != $itemsHtml && $itemsHtml .= ', ';
767 $itemsHtml .= $item->item_name;
768 }
769
770 return $itemsHtml;
771 }
772 }
773