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.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 3.6.65 All 196 releases
fluentform / app / Modules / Payments / PaymentMethods / Stripe / StripeProcessor.php

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

913 lines 34.0 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 if (!defined('ABSPATH')) {
6 exit; // Exit if accessed directly.
7 }
8
9 use FluentForm\App\Helpers\Helper;
10 use FluentForm\App\Models\Submission;
11 use FluentForm\App\Models\Transaction;
12 use FluentForm\App\Services\FormBuilder\ShortCodeParser;
13 use FluentForm\Framework\Helpers\ArrayHelper;
14 use FluentForm\App\Modules\Payments\PaymentHelper;
15 use FluentForm\App\Modules\Payments\PaymentMethods\BaseProcessor;
16 use FluentForm\App\Modules\Payments\PaymentMethods\Stripe\API\CheckoutSession;
17 use FluentForm\App\Modules\Payments\PaymentMethods\Stripe\API\Plan;
18
19 class StripeProcessor extends BaseProcessor
20 {
21 public $method = 'stripe';
22
23 protected $form;
24
25 public function init()
26 {
27 add_action('fluentform/process_payment_stripe_hosted', array($this, 'handlePaymentAction'), 10, 6);
28 add_action('fluentform/payment_frameless_' . $this->method, array($this, 'handleSessionRedirectBack'));
29 }
30
31 public function handlePaymentAction($submissionId, $submissionData, $form, $methodSettings, $hasSubscriptions, $totalPayable)
32 {
33 $this->setSubmissionId($submissionId);
34 $this->form = $form;
35 $submission = $this->getSubmission();
36 $paymentTotal = $this->getAmountTotal();
37
38 if (!$paymentTotal && !$hasSubscriptions) {
39 return false;
40 }
41
42 // Create the initial transaction here
43 $transaction = $this->createInitialPendingTransaction($submission, $hasSubscriptions);
44
45 $this->handleCheckoutSession($transaction, $submission, $form, $methodSettings);
46 }
47
48 public function handleCheckoutSession($transaction, $submission, $form, $methodSettings)
49 {
50
51 $formSettings = PaymentHelper::getFormSettings($form->id);
52
53 $args = [
54 'fluentform_payment' => $submission->id,
55 'payment_method' => $this->method,
56 'transaction_hash' => $transaction->transaction_hash,
57 'type' => 'success'
58 ];
59
60
61 $successUrl = add_query_arg($args, site_url('index.php'));
62
63 $cancelUrl = $submission->source_url;
64
65 if (!wp_http_validate_url($cancelUrl)) {
66 $cancelUrl = site_url($cancelUrl);
67 }
68
69
70 $checkoutArgs = [
71 'cancel_url' => wp_sanitize_redirect($cancelUrl),
72 'success_url' => wp_sanitize_redirect($successUrl),
73 'locale' => 'auto',
74 'billing_address_collection' => 'auto',
75 'client_reference_id' => $submission->id,
76 'customer_email' => $transaction->payer_email,
77 'metadata' => [
78 'submission_id' => $submission->id,
79 'form_id' => $form->id,
80 'transaction_id' => ($transaction) ? $transaction->id : ''
81 ]
82 ];
83
84 if (ArrayHelper::get($methodSettings, 'settings.require_billing_info.value') == 'yes') {
85 $checkoutArgs['billing_address_collection'] = 'required';
86 }
87
88 if (ArrayHelper::get($methodSettings, 'settings.require_shipping_info.value') == 'yes') {
89 $checkoutArgs['shipping_address_collection'] = [
90 'allowed_countries' => StripeSettings::supportedShippingCountries()
91 ];
92 }
93
94 if ($lineItems = $this->getFormattedItems($submission->currency)) {
95 $checkoutArgs['line_items'] = $lineItems;
96 }
97
98 $receiptEmail = PaymentHelper::getCustomerEmail($submission, $form);
99
100 if ($receiptEmail) {
101 $checkoutArgs['customer_email'] = $receiptEmail;
102 }
103
104 if ($transaction->transaction_type == 'subscription') {
105 unset($checkoutArgs['submit_type']);
106
107 $subscriptions = $this->getSubscriptions();
108 $subscription = $subscriptions[0];
109
110 if ($subscription->initial_amount) {
111 if (!isset($checkoutArgs['line_items'])) {
112 $checkoutArgs['line_items'] = [];
113 }
114 $price = $subscription->initial_amount;
115 if (PaymentHelper::isZeroDecimal($transaction->currency)) {
116 $price = intval($price / 100);
117 }
118 $checkoutArgs['line_items'][] = [
119 'amount' => $price,
120 'currency' => $transaction->currency,
121 /* translators: %s is the plan name */
122 'name' => sprintf(__('Signup fee for %s', 'fluentform'), $subscription->plan_name),
123 'quantity' => 1
124 ];
125 }
126
127 $stripePlan = Plan::getSubscriptionPlanBySubscription($subscription, $transaction->currency);
128
129 if(is_wp_error($stripePlan)) {
130 $this->handlePaymentChargeError($stripePlan->get_error_message(), $submission, $transaction);
131 }
132
133 $this->updateSubscription($subscription->id, [
134 'vendor_plan_id' => $stripePlan->id
135 ]);
136
137 $subsArgs = [
138 'items' => [
139 [
140 'plan' => $stripePlan->id,
141 'quantity' => $subscription->quantity ? $subscription->quantity : 1
142 ]
143 ]
144 ];
145
146 if ($subscription->trial_days) {
147 $subsArgs['trial_period_days'] = $subscription->trial_days;
148 }
149
150 $subsArgs['metadata'] = $this->getIntentMetaData($submission, $form, $transaction);
151
152 $checkoutArgs['subscription_data'] = $subsArgs;
153
154 } else {
155 $checkoutArgs['submit_type'] = 'auto';
156 $checkoutArgs['payment_intent_data'] = $this->getPaymentIntentData($transaction, $submission, $form);
157 if ($receiptEmail && ArrayHelper::get($formSettings, 'disable_stripe_payment_receipt') != 'yes') {
158 $checkoutArgs['payment_intent_data']['receipt_email'] = $receiptEmail;
159 }
160 }
161 if ($formSettings['transaction_type'] == 'donation' && $transaction->transaction_type != 'subscription') {
162 $checkoutArgs['submit_type'] = 'donate';
163 }
164
165 $checkoutArgs = apply_filters_deprecated(
166 'fluentform_stripe_checkout_args',
167 [
168 $checkoutArgs,
169 $submission,
170 $transaction,
171 $form
172 ],
173 FLUENTFORM_FRAMEWORK_UPGRADE,
174 'fluentform/stripe_checkout_args',
175 'Use fluentform/stripe_checkout_args instead of fluentform_stripe_checkout_args.'
176 );
177
178 $checkoutArgs = apply_filters('fluentform/stripe_checkout_args', $checkoutArgs, $submission, $transaction, $form);
179
180 // If FluentForm Pro is not installed, apply the fee 1.9%
181 if (!Helper::hasPro()) {
182 if ($transaction->transaction_type == 'subscription') {
183 $checkoutArgs['subscription_data']['application_fee_percent'] = 1.9; // 1.9%
184 } else {
185 // Total amount of 1.9%
186 $applicationFeeAmount = $this->calculateApplicationFeeAmount(
187 $transaction->payment_total,
188 $transaction->currency
189 );
190 $checkoutArgs['payment_intent_data']['application_fee_amount'] = $applicationFeeAmount;
191 }
192 }
193
194 $session = CheckoutSession::create($checkoutArgs);
195
196 if (!empty($session->error) || is_wp_error($session)) {
197 $error = __('Something is wrong', 'fluentform');
198 if (is_wp_error($session)) {
199 $error = $session->get_error_message();
200 } else if (!empty($session->error->message)) {
201 $error = $session->error->message;
202 }
203 $errorMessage = __('Stripe Error: ', 'fluentform') . $error;
204 wp_send_json([
205 'errors' => apply_filters('fluentform/payment_error_message', $errorMessage, $submission, $this->form)
206 ], 423);
207 }
208
209 $this->setMetaData('stripe_session_id', $session->id);
210
211 $logData = [
212 'parent_source_id' => $submission->form_id,
213 'source_type' => 'submission_item',
214 'source_id' => $submission->id,
215 'component' => 'Payment',
216 'status' => 'info',
217 'title' => __('Redirect to Stripe', 'fluentform'),
218 'description' => __('User redirect to Stripe for completing the payment', 'fluentform')
219 ];
220 do_action('fluentform/log_data', $logData);
221
222 wp_send_json_success([
223 'nextAction' => 'payment',
224 'actionName' => 'stripeRedirectToCheckout',
225 'sessionId' => $session->id,
226 'message' => __('You are redirecting to stripe.com to complete the purchase. Please wait while you are redirecting....', 'fluentform'),
227 'result' => [
228 'insert_id' => $submission->id
229 ]
230 ], 200);
231 }
232
233 protected function calculateApplicationFeeAmount($paymentTotal, $currency)
234 {
235 if (PaymentHelper::isZeroDecimal($currency)) {
236 $paymentTotal = intval($paymentTotal / 100);
237 }
238
239 return (int) ($paymentTotal * 0.019);
240 }
241
242 protected function getPaymentIntentData($transaction, $submission, $form)
243 {
244 $data = [
245 'capture_method' => 'automatic',
246 'description' => $form->title,
247 'statement_descriptor_suffix' => StripeSettings::getPaymentDescriptor($form),
248 ];
249
250 $paymentSettings = PaymentHelper::getFormSettings($form->id, 'admin');
251 $intentMeta = $this->getIntentMetaData($submission, $form, $transaction, $paymentSettings);
252
253 $data['metadata'] = $intentMeta;
254 return $data;
255 }
256
257 public function getFormattedItems($currency)
258 {
259 $orderItems = $this->getOrderItems();
260
261 $discountItems = $this->getDiscountItems();
262
263 $discountTotal = 0;
264 foreach ($discountItems as $item) {
265 $discountTotal += $item->line_total;
266 }
267
268 $orderTotal = 0;
269 foreach ($orderItems as $orderItem) {
270 $orderTotal += $orderItem->line_total;
271 }
272
273 $formattedItems = [];
274 $discountDistributed = 0;
275 $itemCount = count($orderItems);
276
277 foreach ($orderItems as $index => $item) {
278 $price = $item->item_price;
279 $quantity = $item->quantity ?: 1;
280
281 if ($discountTotal && $orderTotal) {
282 if ($index === $itemCount - 1) {
283 // Last item absorbs any remaining discount not yet distributed.
284 // intval floors the per-unit price, so (unitPrice * quantity) may be
285 // less than lineTotal. The difference (remainder) is 0..quantity-1 cents.
286 // When remainder > 0 we split into two Stripe line items:
287 // (quantity - remainder) units at the floor price, and
288 // remainder units at floor + 1 cent,
289 // so the total adds up exactly. e.g. $28.00 / 3 → 2×$9.33 + 1×$9.34 = $28.00
290 $remainingDiscount = $discountTotal - $discountDistributed;
291 $lineTotal = ($price * $quantity) - $remainingDiscount;
292 $unitPrice = intval($lineTotal / $quantity);
293 $remainder = $lineTotal - ($unitPrice * $quantity);
294
295 if ($remainder > 0 && $quantity > 1) {
296 $basePrice = $unitPrice;
297 $extraPrice = $unitPrice + 1;
298
299 if (PaymentHelper::isZeroDecimal($currency)) {
300 $basePrice = intval($basePrice / 100);
301 $extraPrice = intval($extraPrice / 100);
302 }
303
304 $formattedItems[] = [
305 'amount' => $basePrice,
306 'currency' => $currency,
307 'name' => $item->item_name,
308 'quantity' => $quantity - $remainder
309 ];
310 $formattedItems[] = [
311 'amount' => $extraPrice,
312 'currency' => $currency,
313 'name' => $item->item_name,
314 'quantity' => $remainder
315 ];
316 continue;
317 }
318
319 $price = $unitPrice;
320 } else {
321 $itemDiscount = (int) round(($discountTotal / $orderTotal) * $price);
322 $discountDistributed += $itemDiscount * $quantity;
323 $price = $price - $itemDiscount;
324 }
325 }
326
327 if (PaymentHelper::isZeroDecimal($currency)) {
328 $price = intval($price / 100);
329 }
330
331 $stripeLine = [
332 'amount' => $price,
333 'currency' => $currency,
334 'name' => $item->item_name,
335 'quantity' => $quantity
336 ];
337
338 $formattedItems[] = $stripeLine;
339 }
340
341 return $formattedItems;
342 }
343
344 public function handleSessionRedirectBack($data)
345 {
346 $type = sanitize_text_field($data['type']);
347 $submissionId = intval($data['fluentform_payment']);
348 $this->setSubmissionId($submissionId);
349
350 $submission = $this->getSubmission();
351
352 if (!$submission) {
353 return;
354 }
355
356 if ($type == 'success') {
357 if ($this->getMetaData('is_form_action_fired') == 'yes') {
358 $returnData = $this->getReturnData();
359 } else {
360 $sessionId = $this->getMetaData('stripe_session_id');
361 $session = CheckoutSession::retrieve($sessionId, [
362 'expand' => [
363 'subscription.latest_invoice.payment_intent',
364 'payment_intent'
365 ]
366 ], $submission->form_id);
367
368 if ($session && !is_wp_error($session) && $session->customer) {
369 $transactionHash = sanitize_text_field($data['transaction_hash']);
370 $transaction = $this->getTransaction($transactionHash, 'transaction_hash');
371 $returnData = $this->processStripeSession($session, $submission, $transaction);
372 } else {
373 $error = __('Sorry! No Valid payment session found. Please try again','fluentform');
374 if (is_wp_error($session)) {
375 $error = $session->get_error_message();
376 }
377 $returnData = [
378 'insert_id' => $submission->id,
379 'title' => __('Failed to retrieve session data', 'fluentform'),
380 'result' => false,
381 'error' => $error
382 ];
383 }
384 }
385 } else {
386 $returnData = [
387 'insert_id' => $submission->id,
388 'result' => false,
389 'error' => apply_filters('fluentform/stripe_payment_cancelled_message', __('Looks like you have cancelled the payment. Please try again!', 'fluentform'), $submission, $this->form)
390 ];
391 }
392
393 $returnData['type'] = $type;
394
395 if (!isset($returnData['is_new'])) {
396 $returnData['is_new'] = false;
397 }
398
399 $this->showPaymentView($returnData);
400 }
401
402 public function processStripeSession($session, $submission, $transaction)
403 {
404 $this->setSubmissionId($submission->id);
405
406 if ($transaction->status == 'paid' && $submission->payment_status == 'paid') {
407 return $this->getReturnData();
408 }
409
410 if (PaymentHelper::isReversedPaymentStatus($submission->payment_status)) {
411 return $this->reversedDisplayData($submission);
412 }
413
414 $invoice = empty($session->subscription->latest_invoice) ? $session : $session->subscription->latest_invoice;
415
416 // was: intent only; a $0 invoice has none, so the invoice decides
417 $paymentStatus = $this->getIntentSuccessName($invoice->payment_intent, $invoice);
418
419 if (!$paymentStatus) {
420 // Intent did not succeed (declined / requires_action / processing / etc.):
421 // do NOT run success processing (status->paid, subscription activation,
422 // fulfillment, action firing). Leave the status as-is so a later webhook can
423 // still settle it, and return display data only.
424 return [
425 'insert_id' => $submission->id,
426 'result' => false,
427 'title' => __('Payment Pending', 'fluentform'),
428 'error' => __('Your payment is being processed and hasn\'t been confirmed yet. You\'ll receive a confirmation once it completes.', 'fluentform'),
429 ];
430 }
431
432 // was: an unconditional write; a refund recorded after the check above was overwritten with paid
433 if (!$this->changeSubmissionPaymentStatusUnlessReversed($paymentStatus)) {
434 return $this->reversedDisplayData($submission);
435 }
436
437 if($transaction->transaction_type == 'subscription') {
438 $subscriptions = $this->getSubscriptions();
439
440 if($subscriptions) {
441 $this->processSubscriptionSuccess($subscriptions, $invoice, $submission);
442
443 $vendorSubscription = $session->subscription;
444 if($vendorSubscription) {
445 Plan::maybeSetCancelAt($subscriptions[0], $vendorSubscription);
446 }
447 }
448 }
449
450 if (!$this->processOneTimeSuccess($invoice, $transaction, $paymentStatus)) {
451 return $this->reversedDisplayData($submission);
452 }
453
454 $returnData = $this->completePaymentSubmission(false);
455 $this->recalculatePaidTotal();
456 $returnData['is_new'] = $this->getMetaData('is_form_action_fired') === 'yes';
457
458 return $returnData;
459 }
460
461 /**
462 * Stripe issues no PaymentIntent for an invoice that owes nothing -- a trial, or a
463 * first invoice discounted to zero. Such an invoice is already settled, so the
464 * absence of an intent must not be read as an unsettled payment.
465 *
466 * All three conditions are required: a declined or in-flight intent is a non-null
467 * object, so it can never reach here, and a nonzero or unpaid invoice is still denied.
468 */
469 protected function isSettledZeroInvoice($invoice)
470 {
471 return empty($invoice->payment_intent)
472 && isset($invoice->status, $invoice->amount_due)
473 && 'paid' === $invoice->status
474 && 0 === (int) $invoice->amount_due;
475 }
476
477 protected function getIntentSuccessName($intent, $invoice = null)
478 {
479 if (!$intent || !$intent->status) {
480 // was: false, which the callers then stored as the string '0' for a settled $0 invoice
481 return $this->isSettledZeroInvoice($invoice) ? 'paid' : false;
482 }
483
484 $successStatuses = [
485 'succeeded' => 'paid',
486 'requires_capture' => 'requires_capture'
487 ];
488
489 if (isset($successStatuses[$intent->status])) {
490 return $successStatuses[$intent->status];
491 }
492
493 return false;
494 }
495
496 protected function getDescriptor($title)
497 {
498 $illegal = array('<', '>', '"', "'");
499 // Remove slashes
500 $descriptor = stripslashes($title);
501 // Remove illegal characters
502 $descriptor = str_replace($illegal, '', $descriptor);
503 // Trim to 22 characters max
504 return substr($descriptor, 0, 22);
505 }
506
507 protected function formatAddress($address)
508 {
509 $addressArray = [
510 'line1' => $address->line1,
511 'line2' => $address->line2,
512 'city' => $address->city,
513 'state' => $address->state,
514 'postal_code' => $address->postal_code,
515 'country' => $address->country,
516 ];
517 return implode(', ', array_filter($addressArray));
518 }
519
520 public function handleRefund($event)
521 {
522 $data = $event->data->object;
523
524 $chargeId = $data->payment_intent;
525 // Get the Transaction from database
526 $transaction = Transaction::where('charge_id', $chargeId)
527 ->where('payment_method', 'stripe')
528 ->first();
529
530 if (!$transaction) {
531 // Not our transaction
532 return;
533 }
534
535 $submission = Submission::find($transaction->submission_id);
536
537 if (!$submission) {
538 return;
539 }
540
541 $this->setSubmissionId($submission->id);
542 $amountRefunded = $data->amount_refunded;
543 if (PaymentHelper::isZeroDecimal($data->currency)) {
544 $amountRefunded = $amountRefunded * 100;
545 }
546
547 // amount_refunded is cumulative per charge; a subscription's other invoices refund separately.
548 $chargeRefunded = (int) Transaction::bySubmission($submission->id)
549 ->refunds()
550 ->where('charge_id', $chargeId)
551 ->sum('payment_total');
552
553 if ($amountRefunded <= $chargeRefunded) {
554 return;
555 }
556
557 // Remove All Existing Refunds
558 Transaction::bySubmission($submission->id)->refunds()->delete();
559
560 $this->refund($amountRefunded, $transaction, $submission, 'stripe', $chargeId, 'Refund from Stripe');
561
562 }
563
564 public function getPaymentMode()
565 {
566 $stripeSettings = StripeSettings::getSettings();
567 return $stripeSettings['payment_mode'];
568 }
569
570 // Already reversed (refund/partial-refund/cancel): never resurrect to paid or fire the pipeline.
571 // Callers reach here with the latch unset, and getReturnData() would fire it, so display data only.
572 protected function reversedDisplayData($submission)
573 {
574 return [
575 'insert_id' => $submission->id,
576 'result' => false,
577 'error' => __('This payment has been reversed.', 'fluentform'),
578 ];
579 }
580
581 public function processSubscriptionSuccess($subscriptions, $invoice, $submission)
582 {
583 foreach ($subscriptions as $subscription) {
584 $subscriptionStatus = 'active';
585
586 if ($subscription->trial_days) {
587 $subscriptionStatus = 'trialling';
588 }
589
590 $updateData = [
591 'vendor_customer_id' => $invoice->customer,
592 'vendor_response' => maybe_serialize($invoice),
593 ];
594
595 if(!$subscription->bill_count && $subscriptionStatus == 'active') {
596 $updateData['bill_count'] = 1;
597 }
598
599 if (!$subscription->vendor_subscription_id) {
600 $updateData['vendor_subscription_id'] = $invoice->subscription;
601 }
602
603 $this->updateSubscription($subscription->id, $updateData);
604 $subscription = fluentFormApi('submissions')->getSubscription($subscription->id);
605 $this->updateSubscriptionStatus($subscription, $subscriptionStatus);
606 }
607
608 $logData = [
609 'parent_source_id' => $submission->form_id,
610 'source_type' => 'submission_item',
611 'source_id' => $submission->id,
612 'component' => 'Payment',
613 'status' => 'success',
614 'title' => __('Stripe Subscription Charged', 'fluentform'),
615 'description' => __('Stripe recurring subscription successfully initiated', 'fluentform')
616 ];
617
618 do_action('fluentform/log_data', $logData);
619
620 if (!empty($invoice->payment_intent->charges->data[0])) {
621 $charge = $invoice->payment_intent->charges->data[0];
622 $this->recordStripeBillingAddress($charge, $submission);
623 }
624 }
625
626 protected function recordStripeBillingAddress($charge, $submission)
627 {
628 if(!is_array($submission->response)) {
629 $submission->response = json_decode($submission->response, true);
630 }
631
632 if (isset($submission->response['__checkout_billing_address_details'])) {
633 return;
634 }
635
636 if (empty($charge->billing_details)) {
637 return;
638 }
639
640 $billingDetails = $charge->billing_details;
641 if (!empty($billingDetails->address)) {
642 $submission->response['__checkout_billing_address_details'] = $billingDetails->address;
643 }
644 if (!empty($billingDetails->phone)) {
645 $submission->response['__stripe_phone'] = $billingDetails->phone;
646 }
647 if (!empty($billingDetails->name)) {
648 $submission->response['__stripe_name'] = $billingDetails->name;
649 }
650 if (!empty($billingDetails->email)) {
651 $submission->response['__stripe_email'] = $billingDetails->email;
652 }
653
654 $this->updateSubmission($submission->id, [
655 'response' => json_encode($submission->response)
656 ]);
657
658 $logData = [
659 'parent_source_id' => $submission->form_id,
660 'source_type' => 'submission_item',
661 'source_id' => $submission->id,
662 'component' => 'Payment',
663 'status' => 'info',
664 'title' => __('Stripe Billing Address Logged', 'fluentform'),
665 'description' => __('Billing address from stripe has been logged in the submission data', 'fluentform')
666 ];
667
668 do_action('fluentform/log_data', $logData);
669 }
670
671 protected function processOneTimeSuccess($invoice, $transaction, $paymentStatus)
672 {
673 if ($transaction) {
674 // was: also 'status' => 'paid' here, an unconditional write the guarded one below always replaced
675 $updateData = [
676 'charge_id' => $invoice->payment_intent ? $invoice->payment_intent->id : null,
677 'payment_note' => maybe_serialize($invoice->payment_intent)
678 ];
679
680 // was: charge_id null and note null; with no intent the invoice id is the row's only identity
681 if ($this->isSettledZeroInvoice($invoice) && !empty($invoice->id)) {
682 $updateData['charge_id'] = $invoice->id;
683 $updateData['payment_note'] = maybe_serialize($invoice);
684 $updateData['payment_total'] = 0;
685 }
686
687 $updateData = array_merge($updateData, $this->retrieveCustomerDetailsFromInvoice($invoice));
688
689 $this->updateTransaction($transaction->id, $updateData);
690
691 return $this->changeTransactionStatusUnlessReversed($transaction->id, $paymentStatus);
692 }
693
694 return true;
695 }
696
697 protected function getIntentMetaData($submission, $form, $transaction, $paymentSettings = false)
698 {
699 if (!$paymentSettings) {
700 $paymentSettings = PaymentHelper::getFormSettings($form->id, 'admin');
701 }
702
703 $intentMeta = [
704 'submission_id' => $submission->id,
705 'form_id' => $form->id,
706 'transaction_id' => $transaction->id,
707 'wp_plugin' => 'Fluent Forms Pro',
708 'form_title' => $form->title
709 ];
710
711 $metaItems = ArrayHelper::get($paymentSettings, 'stripe_meta_data', []);
712 if ((ArrayHelper::get($paymentSettings, 'push_meta_to_stripe') == 'yes') && !empty($metaItems)) {
713
714 $metaData = [];
715
716 foreach ($metaItems as $metaItem) {
717 if ($itemValue = ArrayHelper::get($metaItem, 'item_value')) {
718 $metaData[ArrayHelper::get($metaItem, 'label', 'item')] = $itemValue;
719 }
720 }
721
722 $metaData = ShortCodeParser::parse($metaData, $submission->id, $submission->response);
723
724 $metaData = is_array($metaData) ? array_filter($metaData) : [];
725
726 foreach ($metaData as $itemKey => $value) {
727 if (is_string($value) || is_numeric($value)) {
728 $metaData[$itemKey] = wp_strip_all_tags($value);
729 }
730 }
731
732 $intentMeta = array_merge($intentMeta, $metaData);
733 }
734
735 return $intentMeta;
736 }
737
738 protected function retrieveCustomerDetailsFromInvoice($invoice)
739 {
740 $customer = [];
741
742 if (!empty($invoice->payment_intent->charges->data[0])) {
743 $charge = $invoice->payment_intent->charges->data[0];
744
745 $customer = $this->retrieveCustomerDetailsFromCharge($charge);
746
747 $customerName = isset($charge->billing_details->name) &&
748 !empty($charge->billing_details->name) ?
749 $charge->billing_details->name : (
750 isset($invoice->customer_name) &&
751 !empty($invoice->customer_name) ?
752 $invoice->customer_name : ''
753 );
754
755 $customer = array_merge($customer, [
756 'payer_email' => $invoice->customer_email,
757 'payer_name' => $customerName,
758 ]);
759 }
760
761 return $customer;
762 }
763
764 protected function retrieveCustomerDetailsFromCharge($charge)
765 {
766 $customer = [];
767
768 if (!empty($charge->billing_details)) {
769 $customer['billing_address'] = $this->formatAddress($charge->billing_details->address);
770 }
771
772 if (!empty($charge->shipping) && !empty($charge->shipping->address)) {
773 $customer['shipping_address'] = $this->formatAddress($charge->shipping->address);
774 }
775
776 if (!empty($charge->payment_method_details->card)) {
777 $card = $charge->payment_method_details->card;
778 $customer['card_brand'] = $card->brand;
779 $customer['card_last_4'] = $card->last4;
780 }
781
782 return $customer;
783 }
784
785 // One contract for every Stripe failure, whichever path reports it: the inline flows and the delayed Checkout webhook.
786 public function fireFailureHooks($submission, $transaction, $formId, $charge = false, $type = 'general')
787 {
788 do_action_deprecated(
789 'fluentform_payment_stripe_failed',
790 [
791 $submission,
792 $transaction,
793 $formId,
794 $charge,
795 $type
796 ],
797 FLUENTFORM_FRAMEWORK_UPGRADE,
798 'fluentform/payment_stripe_failed',
799 'Use fluentform/payment_stripe_failed instead of fluentform_payment_stripe_failed.'
800 );
801
802 do_action('fluentform/payment_stripe_failed', $submission, $transaction, $formId, $charge, $type);
803
804 do_action_deprecated(
805 'fluentform_payment_failed',
806 [
807 $submission,
808 $transaction,
809 $formId,
810 $charge,
811 $type
812 ],
813 FLUENTFORM_FRAMEWORK_UPGRADE,
814 'fluentform/payment_failed',
815 'Use fluentform/payment_failed instead of fluentform_payment_failed.'
816 );
817 do_action('fluentform/payment_failed', $submission, $transaction, $formId, $charge, $type);
818 }
819
820 protected function handlePaymentChargeError($message, $submission, $transaction, $charge = false, $type = 'general', $error = null)
821 {
822 $this->fireFailureHooks($submission, $transaction, $this->form->id, $charge, $type);
823
824 $isIndeterminate = is_wp_error($error) && 'stripe_error' === $error->get_error_code();
825 if ($transaction) {
826 $this->changeTransactionStatus($transaction->id, $isIndeterminate ? 'processing' : 'failed');
827 }
828
829 $this->changeSubmissionPaymentStatus($isIndeterminate ? 'pending' : 'failed');
830
831 if ($message) {
832 $logData = [
833 'parent_source_id' => $submission->form_id,
834 'source_type' => 'submission_item',
835 'source_id' => $submission->id,
836 'component' => 'Payment',
837 'status' => 'error',
838 'title' => __('Stripe Payment Error', 'fluentform'),
839 'description' => $message
840 ];
841
842 do_action('fluentform/log_data', $logData);
843 }
844
845 wp_send_json([
846 'errors' => __('Stripe Error: ', 'fluentform') . $message,
847 'append_data' => [
848 '__entry_intermediate_hash' => Helper::getSubmissionMeta($submission->id, '__entry_intermediate_hash')
849 ]
850 ], 423);
851 }
852
853 protected function isCountableSubscriptionPayment($payment, $subscription)
854 {
855 if ($this->isTrialStartInvoice($payment, $subscription)) {
856 return false;
857 }
858
859 if ($payment->payment_total > 0) {
860 return true;
861 }
862
863 $invoice = (array) Helper::safeUnserialize($payment->payment_note);
864 $reason = $invoice['billing_reason'] ?? null;
865
866 return 'subscription_cycle' === $reason
867 || ('subscription_create' === $reason && (!$subscription || !$subscription->trial_days));
868 }
869
870 protected function isTrialStartInvoice($payment, $subscription)
871 {
872 if (!$subscription || !$subscription->trial_days || 'stripe' !== $payment->payment_method) {
873 return false;
874 }
875
876 $storedPayment = (array) Helper::safeUnserialize($payment->payment_note);
877
878 if ('subscription_create' === ($storedPayment['billing_reason'] ?? null)) {
879 return true;
880 }
881
882 // Match legacy invoice or browser intent data only to the saved initial invoice.
883 $initialInvoice = (array) Helper::safeUnserialize($subscription->vendor_response);
884 $storedId = $storedPayment['id'] ?? null;
885
886 if (!$storedId) {
887 return false;
888 }
889
890 if ('invoice' === ($storedPayment['object'] ?? null)) {
891 return 0 === strcmp((string) ($initialInvoice['id'] ?? ''), (string) $storedId);
892 }
893
894 if ('payment_intent' !== ($storedPayment['object'] ?? null)) {
895 return false;
896 }
897
898 $initialIntent = $initialInvoice['payment_intent'] ?? null;
899 if (is_object($initialIntent) || is_array($initialIntent)) {
900 $initialIntent = (array) $initialIntent;
901 $initialIntent = $initialIntent['id'] ?? null;
902 }
903
904 return $initialIntent === $storedId;
905 }
906
907 public function recordSubscriptionCharge($subscription, $transactionData)
908 {
909 $this->setSubmissionId($subscription->submission_id);
910 return $this->maybeInsertSubscriptionCharge($transactionData);
911 }
912 }
913