PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.0
1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 All 49 releases
fluent-cart / app / Modules / PaymentMethods / StripeGateway / Confirmations.php

Confirmations.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.0, at app/Modules/PaymentMethods/StripeGateway/Confirmations.php

805 lines 33.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\PaymentMethods\StripeGateway;
4
5 use FluentCart\App\App;
6 use FluentCart\App\Helpers\CurrenciesHelper;
7 use FluentCart\App\Helpers\Status;
8 use FluentCart\App\Helpers\StatusHelper;
9 use FluentCart\App\Models\Customer;
10 use FluentCart\App\Models\Order;
11 use FluentCart\App\Models\OrderTransaction;
12 use FluentCart\App\Models\Subscription;
13 use FluentCart\App\Modules\PaymentMethods\StripeGateway\API\API;
14 use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService;
15 use FluentCart\App\Modules\Subscriptions\Services\SystemChargeService;
16 use FluentCart\App\Services\DateTime\DateTime;
17 use FluentCart\App\Services\Payments\PaymentHelper;
18 use FluentCart\Framework\Support\Arr;
19
20 class Confirmations
21 {
22 public function init()
23 {
24 add_action('wp_ajax_nopriv_fluent_cart_confirm_stripe_payment', [$this, 'confirmStripePayment']);
25 add_action('wp_ajax_fluent_cart_confirm_stripe_payment', [$this, 'confirmStripePayment']);
26
27 add_filter('fluent_cart/form_disable_stripe_connect', function ($value, $args) {
28 if (defined('FCT_STRIPE_LIVE_PUBLIC_KEY') || defined('FCT_STRIPE_TEST_PUBLIC_KEY')) {
29 return true;
30 }
31
32 return $value;
33 }, 10, 2);
34
35
36 if (isset($_REQUEST['fct_stripe_hosted']) && isset($_REQUEST['trx_hash'])) {
37 $transaction = OrderTransaction::query()->where('uuid', sanitize_text_field(App::request()->get('trx_hash')))->first();
38 if (!$transaction || $transaction->status === Status::TRANSACTION_SUCCEEDED) {
39 return;
40 }
41
42 // Get session ID from transaction meta
43 $sessionId = Arr::get($transaction->meta, 'session_id');
44
45 if ($sessionId) {
46 $this->confirmByCheckoutSession($sessionId, $transaction);
47 } else {
48 return;
49 }
50 }
51
52 }
53
54 private function confirmByCheckoutSession($sessionId, $transaction)
55 {
56
57 $api = new API();
58
59 $session = $api->getStripeObject('checkout/sessions/' . $sessionId, [
60 'expand' => ['payment_intent', 'subscription.latest_invoice.payment_intent.latest_charge']
61 ]);
62
63
64 if (is_wp_error($session)) {
65 fluent_cart_add_log(__('Stripe Session Retrieval Failed', 'fluent-cart'), $session->get_error_message(), 'error', [
66 'module_name' => 'order',
67 'module_id' => $transaction->order_id,
68 ]);
69 if ($transaction->subscription_id) {
70 $subscription = Subscription::query()->find($transaction->subscription_id);
71 if ($subscription) {
72 $subscription->addLog(__('Stripe Session Retrieval Failed', 'fluent-cart'), $session->get_error_message(), 'error');
73 }
74 }
75 return;
76 }
77
78 $paymentStatus = Arr::get($session, 'payment_status');
79 $mode = Arr::get($session, 'mode');
80
81 if ($mode === 'subscription') {
82 $vendorSubscription = Arr::get($session, 'subscription');
83 $vendorSubscriptionId = is_array($vendorSubscription) ? Arr::get($vendorSubscription, 'id') : $vendorSubscription;
84
85 $subscription = Subscription::query()->where('id', $transaction->subscription_id)->first();
86
87 if ($subscription && $vendorSubscriptionId) {
88 $updateData = [
89 'vendor_subscription_id' => $vendorSubscriptionId,
90 'vendor_customer_id' => Arr::get($vendorSubscription, 'customer'),
91 ];
92
93
94 if (is_array($vendorSubscription)) {
95 if (Arr::get($vendorSubscription, 'current_period_end')) {
96 $updateData['next_billing_date'] = gmdate('Y-m-d H:i:s', (int) Arr::get($vendorSubscription, 'current_period_end'));
97 }
98
99 if (Arr::get($vendorSubscription, 'trial_end')) {
100 $updateData['trial_ends_at'] = gmdate('Y-m-d H:i:s', (int) Arr::get($vendorSubscription, 'trial_end'));
101 }
102 }
103
104 $subscription->update($updateData);
105 }
106
107 $paymentIntent = null;
108 $billingInfo = [];
109
110
111 if (is_array($vendorSubscription)) {
112 $paymentIntent = Arr::get($vendorSubscription, 'latest_invoice.payment_intent');
113 }
114
115
116 if (!$paymentIntent && Arr::get($session, 'invoice')) {
117 $invoiceId = Arr::get($session, 'invoice');
118 $invoice = $api->getStripeObject('invoices/' . $invoiceId, [
119 'expand' => ['payment_intent.latest_charge']
120 ]);
121 if (!is_wp_error($invoice)) {
122 $paymentIntent = Arr::get($invoice, 'payment_intent.latest_charge');
123 }
124 }
125
126 if (!is_array($paymentIntent)) {
127 $paymentIntent = $api->getStripeObject('payment_intents/' . $paymentIntent, [
128 'expand' => ['latest_charge']
129 ]);
130 }
131
132
133 $charge = Arr::get($paymentIntent, 'latest_charge', []);
134
135 if ($charge) {
136 $billingInfo = $this->extractBillingInfoFromCharge($charge);
137 $this->processPaymentIntentConfirmation($paymentIntent, $transaction);
138 } else {
139 if ($paymentStatus === 'paid' || $transaction->total <= 0) {
140 // Try to get payment method from setup intent
141 $setupIntent = Arr::get($session, 'setup_intent');
142 if ($setupIntent) {
143 $setupIntentData = $api->getStripeObject('setup_intents/' . $setupIntent);
144 if (!is_wp_error($setupIntentData)) {
145 $paymentMethodId = Arr::get($setupIntentData, 'payment_method');
146 if ($paymentMethodId) {
147 $billingInfo = $this->getPaymentMethodDetails($paymentMethodId);
148 }
149 }
150 }
151
152 $transaction->status = Status::TRANSACTION_SUCCEEDED;
153 $transaction->save();
154 }
155 }
156
157 if ($subscription && !in_array($subscription->status, Status::getValidableSubscriptionStatuses())) {
158 (new SubscriptionsManager())->confirmSubscriptionAfterChargeSucceeded($subscription, $billingInfo);
159 }
160
161 (new StatusHelper($transaction->order))->syncOrderStatuses($transaction);
162
163 } elseif ($mode === 'setup') {
164 // Zero-payable system-subscription hosted checkout — no payment_intent
165 // to confirm, just the vaulted setup_intent. confirmSetupIntent() also
166 // resolves the transaction by vendor_charge_id, so a stale/mismatched
167 // session for this transaction is harmless here.
168 $setupIntentId = Arr::get($session, 'setup_intent');
169 if ($setupIntentId) {
170 $this->confirmSetupIntent($setupIntentId);
171 }
172 } else {
173 if ($paymentStatus === 'paid') {
174 $paymentIntent = Arr::get($session, 'payment_intent');
175 if (is_array($paymentIntent)) {
176 $paymentIntent = $api->getStripeObject('payment_intents/' . $paymentIntent['id'], [
177 'expand' => ['latest_charge']
178 ]);
179 $this->processPaymentIntentConfirmation($paymentIntent, $transaction);
180 } elseif ($paymentIntent) {
181 $intentData = $api->getStripeObject('payment_intents/' . $paymentIntent, [
182 'expand' => ['latest_charge']
183 ]);
184 if (!is_wp_error($intentData)) {
185 $this->processPaymentIntentConfirmation($intentData, $transaction);
186 }
187 }
188 }
189 }
190 }
191
192
193 /**
194 * Process payment intent confirmation
195 */
196 private function processPaymentIntentConfirmation($intent, $transaction)
197 {
198 $charge = Arr::get($intent, 'latest_charge', []);
199 $intentId = Arr::get($intent, 'id');
200
201 if ($charge && $intentId) {
202 $this->confirmPaymentSuccessByCharge($transaction, [
203 'charge' => $charge,
204 'intent_id' => $intentId
205 ]);
206 }
207 }
208
209 /**
210 * Extract billing info from charge for subscription confirmation
211 */
212 private function extractBillingInfoFromCharge($charge)
213 {
214 $billingDetails = Arr::get($charge, 'billing_details', []);
215 $paymentMethodDetails = Arr::get($charge, 'payment_method_details', []);
216
217 return [
218 'method' => 'stripe',
219 'vendor_method_id' => Arr::get($charge, 'payment_method', ''),
220 'payment_type' => Arr::get($paymentMethodDetails, 'type'),
221 'details' => array_filter([
222 'brand' => Arr::get($paymentMethodDetails, 'card.brand'),
223 'last_4' => Arr::get($paymentMethodDetails, 'card.last4'),
224 'exp_month' => Arr::get($paymentMethodDetails, 'card.exp_month'),
225 'exp_year' => Arr::get($paymentMethodDetails, 'card.exp_year'),
226 'country' => Arr::get($paymentMethodDetails, 'card.country'),
227 'postal_code' => Arr::get($billingDetails, 'address.postal_code', ''),
228 'name' => Arr::get($billingDetails, 'name', '')
229 ])
230 ];
231 }
232
233 /*
234 * Only for validating hosted checkout payment confirmation
235 */
236 public function confirmStripePayment()
237 {
238 $intentId = App::request()->get('intentId');
239 if (empty($intentId)) {
240 wp_send_json(
241 [
242 'message' => __('Intent ID is required to confirm the payment.', 'fluent-cart'),
243 ],
244 400
245 );
246 }
247
248 $intentId = sanitize_text_field($intentId);
249
250 // in case of plan change, and first payment is 0, then setup intent will be created
251 if (strpos($intentId, 'seti_') === 0) {
252 $trxHash = sanitize_text_field(App::request()->get('trx_hash'));
253 if (empty($trxHash)) {
254 wp_send_json(['message' => __('Invalid request.', 'fluent-cart')], 400);
255 }
256 $result = $this->confirmSetupIntent($intentId, $trxHash);
257 if (is_wp_error($result)) {
258 wp_send_json(
259 [
260 'message' => $result->get_error_message(),
261 ], 400
262 );
263 }
264 wp_send_json(
265 [
266 'message' => __('Setup intent confirmed successfully. Please check your subscriptions.', 'fluent-cart'),
267 ], 200
268 );
269 }
270
271 $api = new API();
272 $response = $api->getStripeObject('payment_intents/' . $intentId, [
273 'expand' => ['latest_charge']
274 ]);
275
276 if (is_wp_error($response)) {
277 wp_send_json(
278 [
279 'message' => $response->get_error_message(),
280 ],
281 500
282 );
283 }
284
285 $transaction = OrderTransaction::query()->where('vendor_charge_id', $intentId)->first();
286
287 if (!$transaction) {
288 wp_send_json(
289 [
290 'message' => __('Order not found for the provided intent ID.', 'fluent-cart'),
291 ],
292 404
293 );
294 }
295
296 $this->confirmPaymentSuccessByCharge($transaction, [
297 'charge' => Arr::get($response, 'latest_charge', []),
298 'intent_id' => $intentId
299 ]);
300
301 wp_send_json(
302 [
303 'redirect_url' => $transaction->getReceiptPageUrl(),
304 'order' => [
305 'uuid' => $transaction->order->uuid,
306 ],
307 'message' => __('Payment confirmed successfully. Redirecting...!', 'fluent-cart')
308 ], 200
309 );
310 }
311
312 // make sure customer given the acknowledgement for saving the payment methods
313 public function savePaymentMethodToCustomerMeta($vendorCustomer, $paymentMethodId, $order)
314 {
315 $fctCustomer = Customer::query()->where('id', $order->customer_id)->first();
316 $metaKey = 'saved_payment_method';
317
318 $stripeApiKey = (new StripeSettingsBase())->getApiKey();
319 $api = new API();
320
321 // Allow redisplay for the payment method
322 $api->makeRequest('payment_methods/' . $paymentMethodId, ['allow_redisplay' => 'always'], $stripeApiKey, 'POST');
323
324 // Fetch customer to get default payment method
325 $customer = $api->makeRequest('customers/' . $vendorCustomer, [], $stripeApiKey, 'GET');
326 $defaultPaymentMethodId = Arr::get($customer, 'invoice_settings.default_payment_method');
327
328 $paymentMethodsResponse = $api->makeRequest(
329 'customers/' . $vendorCustomer . '/payment_methods',
330 [],
331 $stripeApiKey,
332 'GET'
333 );
334
335 $stripeMeta = [
336 'customer_id' => $vendorCustomer,
337 'payment_methods' => []
338 ];
339
340 if ($paymentMethodsResponse && !is_wp_error($paymentMethodsResponse) && ($methods = Arr::get($paymentMethodsResponse, 'data', []))) {
341 $seenFingerprints = [];
342 foreach ($methods as $method) {
343
344 $type = Arr::get($method, 'type');
345 $pm = [
346 'id' => Arr::get($method, 'id'),
347 'type' => $type,
348 ];
349
350 $fingerprint = null;
351 switch ($type) {
352 case 'card':
353 $pm['last4'] = Arr::get($method, 'card.last4');
354 $pm['brand'] = Arr::get($method, 'card.brand');
355 $pm['exp_month'] = Arr::get($method, 'card.exp_month');
356 $pm['exp_year'] = Arr::get($method, 'card.exp_year');
357 $pm['fingerprint'] = Arr::get($method, 'card.fingerprint');
358 $fingerprint = $pm['fingerprint'];
359 break;
360 // case 'sepa_debit':
361 // $pm['last4'] = Arr::get($method, 'sepa_debit.last4');
362 // $fingerprint = Arr::get($method, 'sepa_debit.fingerprint');
363 // break;
364 // case 'ach_debit':
365 // $pm['last4'] = Arr::get($method, 'ach_debit.last4');
366 // $fingerprint = Arr::get($method, 'ach_debit.fingerprint');
367 // break;
368 // case 'ach_credit_transfer':
369 // $pm['account_number'] = Arr::get($method, 'ach_credit_transfer.account_number');
370 // $fingerprint = Arr::get($method, 'ach_credit_transfer.fingerprint');
371 // break;
372 // case 'us_bank_account':
373 // $pm['account_number'] = Arr::get($method, 'us_bank_account.account_number');
374 // $fingerprint = Arr::get($method, 'us_bank_account.fingerprint');
375 // break;
376 // case 'bacs_debit':
377 // $pm['account_number'] = Arr::get($method, 'bacs_debit.account_number');
378 // $fingerprint = Arr::get($method, 'bacs_debit.fingerprint');
379 // break;
380 default:
381 break;
382 }
383
384 if ($fingerprint && in_array($fingerprint, $seenFingerprints, true)) {
385 continue;
386 }
387 if ($fingerprint) {
388 $seenFingerprints[] = $fingerprint;
389 }
390
391 if ($defaultPaymentMethodId === Arr::get($method, 'id')) {
392 $stripeMeta['payment_methods']['default'] = $pm;
393 } else {
394 $stripeMeta['payment_methods'][] = $pm;
395 }
396 }
397 }
398
399 $meta = $fctCustomer->getMeta($metaKey);
400 $meta['stripe'] = $stripeMeta;
401
402 $fctCustomer->updateMeta($metaKey, [
403 'stripe' => $stripeMeta
404 ]);
405 }
406
407 public function confirmSetupIntent($setupIntent, $trxHash = null)
408 {
409 $api = new API();
410
411 $response = $api->getStripeObject('setup_intents/' . $setupIntent);
412
413 if (is_wp_error($response)) {
414 return $response;
415 }
416
417 $transaction = OrderTransaction::query()->where('vendor_charge_id', $setupIntent)->first();
418
419 if (!$transaction) {
420 return new \WP_Error(
421 'transaction_not_found',
422 __('Transaction not found for the provided setup intent.', 'fluent-cart')
423 );
424 }
425
426 if ($trxHash !== null && $transaction->uuid !== $trxHash) {
427 return new \WP_Error('invalid_request', __('Invalid request.', 'fluent-cart'));
428 }
429
430 if (Arr::get($response, 'status') !== 'succeeded') {
431 return new \WP_Error(
432 'setup_intent_not_succeeded',
433 __('Payment method setup is not complete. Please complete the payment method setup.', 'fluent-cart')
434 );
435 }
436
437 $transaction->status = Status::TRANSACTION_PENDING;
438
439 if ($transaction->total <= 0) {
440 $transaction->status = Status::TRANSACTION_SUCCEEDED;
441 }
442
443
444 $transaction->vendor_charge_id = ''; // removing vendor charge id , because setup intent id is not the charge id
445 $transaction->save();
446
447 $order = Order::query()->where('id', $transaction->order_id)->first();
448
449
450 $paymentMethod = Arr::get($response, 'payment_method');
451 $customer = Arr::get($response, 'customer');
452
453 $billingInfo = $this->getPaymentMethodDetails($paymentMethod);
454
455 // attach the payment method to the customer
456 if ($paymentMethod && $customer) {
457 $api->createStripeObject('payment_methods/' . $paymentMethod . '/attach', [
458 'customer' => $customer
459 ]);
460
461 $this->savePaymentMethodToCustomerMeta($customer, $paymentMethod, $order);
462 }
463
464
465 $subscription = Subscription::query()->where('id', $transaction->subscription_id)->first();
466
467 if ($subscription) {
468 if ($subscription->isSystem()) {
469 // Zero-payable free-trial checkout: no vendor subscription to confirm,
470 // just vault the Stripe customer + reusable payment method.
471 $stripeCustomerId = Arr::get($response, 'customer', '');
472 if ($stripeCustomerId && !$subscription->vendor_customer_id) {
473 $subscription->vendor_customer_id = $stripeCustomerId;
474 $subscription->save();
475 }
476
477 $vendorMethodId = Arr::get($response, 'payment_method', '');
478 if ($vendorMethodId) {
479 $billingInfo['vendor_method_id'] = $vendorMethodId;
480 }
481
482 $this->maybePersistSystemVaultToken($subscription, $order, $billingInfo);
483 } else {
484 (new SubscriptionsManager())->confirmSubscriptionAfterChargeSucceeded($subscription, $billingInfo);
485 }
486 }
487
488 (new StatusHelper($order))->syncOrderStatuses($transaction);
489
490 // Notify that a renewal invoice has been deferred — the actual charge will fire
491 // later via the gateway's subscription_cycle webhook. Gateways that capture a card
492 // for a deferred renewal charge should fire this so the invoice status can be updated.
493 if ($order->type === Status::ORDER_TYPE_RENEWAL) {
494 do_action('fluent_cart/renewal/payment_scheduled', [
495 'order' => $order,
496 'subscription' => $subscription,
497 ]);
498 }
499
500 }
501
502 /**
503 * Vault the token for a system subscription, or demote to manual when the
504 * initial checkout capture came back without one — mirrors PayPal's
505 * Processor::maybePersistVaultToken().
506 */
507 private function maybePersistSystemVaultToken($subscription, $order, $billingInfo)
508 {
509 $vendorMethodId = Arr::get($billingInfo, 'vendor_method_id', '');
510 $existing = $subscription->getMeta('active_payment_method', []) ?: [];
511 // Meta has two shapes in the wild: vendor_method_id (confirmation paths) and
512 // details.payment_method_id (card-update flow) — accept both, same as chargeRenewal().
513 $existingMethodId = Arr::get($existing, 'vendor_method_id') ?: Arr::get($existing, 'details.payment_method_id');
514
515 if ($vendorMethodId) {
516 if ($existingMethodId === $vendorMethodId) {
517 return; // already persisted (webhook/AJAX race)
518 }
519
520 $subscription->updateMeta('active_payment_method', $billingInfo);
521 return;
522 }
523
524 // No token on the initial capture and none stored yet — never leave a
525 // system subscription that can never be charged.
526 if ($order
527 && $order->type === Status::ORDER_TYPE_SUBSCRIPTION
528 && !$existingMethodId
529 ) {
530 SystemChargeService::demoteToManual(
531 $subscription,
532 __('Stripe did not return a saved payment method for automatic charging.', 'fluent-cart')
533 );
534 }
535 }
536
537 public function getPaymentMethodDetails($methodId)
538 {
539 $paymentMethodDetails = (new API())->makeRequest('payment_methods/' . $methodId, [], (new StripeSettingsBase())->getApiKey(), 'GET');
540
541 if (is_wp_error($paymentMethodDetails) || !$paymentMethodDetails) {
542 $billingInfo = PaymentHelper::parsePaymentMethodDetails('stripe', ['type' => 'card']);
543 } else {
544 $billingInfo = PaymentHelper::parsePaymentMethodDetails('stripe', $paymentMethodDetails);
545 }
546
547 return $billingInfo;
548 }
549
550
551 public function syncRemoteTransaction(OrderTransaction $transaction)
552 {
553 $mode = $transaction->payment_mode;
554 if (!$mode) {
555 $mode = $transaction->order ? $transaction->order->mode : '';
556 }
557
558 $intent = (new API())->getStripeObject('payment_intents/' . $transaction->vendor_charge_id, [
559 'expand' => ['latest_charge']
560 ], $mode);
561
562 if (is_wp_error($intent)) {
563 return $intent;
564 }
565
566 $intentStatus = Arr::get($intent, 'status');
567
568 if ($intentStatus === 'succeeded') {
569 $chargeCurrency = strtoupper((string) Arr::get($intent, 'latest_charge.currency', ''));
570 if ($chargeCurrency && $transaction->currency && strtoupper($transaction->currency) !== $chargeCurrency) {
571 fluent_cart_warning_log(
572 __('Stripe Currency Mismatch On Sync', 'fluent-cart'),
573 sprintf(
574 /* translators: %1$s: expected currency, %2$s: received currency */
575 __('Charge currency mismatch detected during transaction sync. Expected: %1$s, Received: %2$s. Transaction was not confirmed.', 'fluent-cart'),
576 $transaction->currency,
577 $chargeCurrency
578 ),
579 [
580 'module_name' => 'order',
581 'module_id' => $transaction->order_id,
582 'log_type' => 'api'
583 ]
584 );
585
586 return new \WP_Error('currency_mismatch', __('The Stripe payment currency does not match this transaction. Please verify the payment at Stripe.', 'fluent-cart'));
587 }
588
589 $this->confirmPaymentSuccessByCharge($transaction, [
590 'charge' => Arr::get($intent, 'latest_charge', []),
591 'intent_id' => Arr::get($intent, 'id'),
592 ]);
593
594 return OrderTransaction::query()->find($transaction->id);
595 }
596
597 if ($intentStatus === 'processing') {
598 return new \WP_Error('still_processing', __('The payment is still processing at Stripe. Please try again later.', 'fluent-cart'));
599 }
600
601 $failureMessage = Arr::get($intent, 'last_payment_error.message');
602 if (!$failureMessage) {
603 $failureMessage = sprintf(
604 /* translators: %1$s: Stripe payment intent status */
605 __('The payment has not completed at Stripe (status: %1$s).', 'fluent-cart'),
606 $intentStatus ?: 'unknown'
607 );
608 }
609
610 return new \WP_Error('charge_not_completed', $failureMessage);
611 }
612
613 /**
614 * Confirm payment success by charge.
615 * Currently used by:
616 * - fluent_cart/payments/stripe/webhook_charge_succeeded
617 * -
618 *
619 * @param OrderTransaction $transaction
620 * @param array $args
621 * @param array $args ['charge'] - The charge details from Stripe.
622 * @param string $args ['intent_id'] - The intent ID from Stripe.
623 */
624 public function confirmPaymentSuccessByCharge(OrderTransaction $transaction, $args = [])
625 {
626 $charge = Arr::get($args, 'charge', []);
627 $intentId = Arr::get($args, 'intent_id', '');
628
629 if (!$intentId) {
630 $intentId = Arr::get($charge, 'payment_intent', '');
631 }
632
633 $order = Order::query()->where('id', $transaction->order_id)->first();
634
635 // in race conditions between webhook and AJAX confirmation
636 $transaction = OrderTransaction::query()->where('id', $transaction->id)->first();
637 if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
638 if ($transaction->subscription_id) {
639 $subscription = Subscription::query()->where('id', $transaction->subscription_id)->first();
640 // Only automatic subs have a remote to resync; store-managed (system/manual) have none.
641 if ($subscription && $subscription->vendor_subscription_id) {
642 $subscription->reSyncFromRemote();
643 }
644 }
645
646 return (new StatusHelper($order))->syncOrderStatuses($transaction);
647 }
648
649 $chargeCurrency = Arr::get($charge, 'currency', $transaction->currency);
650 $status = Arr::get($charge, 'status') === 'succeeded' ? Status::TRANSACTION_SUCCEEDED : Status::TRANSACTION_PENDING;
651
652 if ($status === Status::TRANSACTION_PENDING) {
653 if (!$transaction->vendor_charge_id && !empty($intentId)) {
654 $transaction->update(['vendor_charge_id' => $intentId]);
655 }
656 return $order; // already pending,
657 }
658
659 $normalizedAmount = (int)Arr::get($charge, 'amount', 0);
660
661 if ($chargeCurrency && CurrenciesHelper::isZeroDecimal($chargeCurrency)) {
662 $normalizedAmount = $normalizedAmount * 100;
663 }
664
665 $transactionUpdateData = array_filter([
666 'order_id' => $order->id,
667 'total' => $normalizedAmount,
668 'currency' => $chargeCurrency,
669 'status' => $status,
670 'payment_method' => 'stripe',
671 'card_last_4' => Arr::get($charge, 'payment_method_details.card.last4', ''),
672 'card_brand' => Arr::get($charge, 'payment_method_details.card.brand', ''),
673 'payment_method_type' => Arr::get($charge, 'payment_method_details.type', ''),
674 'vendor_charge_id' => $intentId,
675 'payment_mode' => Arr::isTrue($charge, 'livemode') ? 'live' : 'test'
676 ]);
677
678 if (Arr::get($charge, 'disputed', false)) {
679 $transactionUpdateData['transaction_type'] = Status::TRANSACTION_TYPE_DISPUTE;
680 $disputeId = Arr::get($charge, 'dispute', '');
681 $reason = 'unknown';
682
683 $retreiveDispute = (new API())->getStripeObject('disputes/' . $disputeId);
684
685 if (!is_wp_error($retreiveDispute)) {
686 $reason = Arr::get($retreiveDispute, 'reason');
687 }
688
689 $transaction->meta = array_merge($transaction->meta, [
690 'dispute_id' => $disputeId,
691 'dispute_reason' => $reason,
692 'is_dispute_actionable' => in_array(Arr::get($retreiveDispute, 'status'), ['needs_response']),
693 'is_charge_refundable' => Arr::get($retreiveDispute, 'is_charge_refundable', false)
694 ]);
695
696 fluent_cart_warning_log('Stripe charge disputed', 'This payment was disputed (' . $charge['id'] . ')', [
697 'module_name' => 'order',
698 'module_id' => $order->id,
699 'log_type' => 'api'
700 ]);
701 if ($transaction->subscription_id) {
702 fluent_cart_warning_log('Stripe charge disputed', 'This payment was disputed (' . $charge['id'] . ')', [
703 'module_type' => 'FluentCart\App\Models\Subscription',
704 'module_id' => $transaction->subscription_id,
705 'module_name' => 'subscription',
706 'log_type' => 'api'
707 ]);
708 }
709 }
710
711 $transaction->fill($transactionUpdateData);
712 $transaction->save();
713
714 fluent_cart_add_log(__('Stripe Payment Confirmation', 'fluent-cart'), __('Payment confirmation received from Stripe. Transaction ID:', 'fluent-cart') . ' ' . $intentId, 'info', [
715 'module_name' => 'order',
716 'module_id' => $order->id,
717 ]);
718 if ($transaction->subscription_id) {
719 fluent_cart_add_log(__('Stripe Payment Confirmation', 'fluent-cart'), __('Payment confirmation received from Stripe. Transaction ID:', 'fluent-cart') . ' ' . $intentId, 'info', [
720 'module_type' => 'FluentCart\App\Models\Subscription',
721 'module_id' => $transaction->subscription_id,
722 'module_name' => 'subscription',
723 ]);
724 }
725
726 $billingDetails = Arr::get($charge, 'billing_details', []);
727 $paymentMethodDetails = Arr::get($charge, 'payment_method_details', []);
728 $billingInfo = [
729 'method' => 'stripe',
730 'vendor_method_id' => Arr::get($charge, 'payment_method', ''),
731 'payment_type' => Arr::get($paymentMethodDetails, 'type'),
732 'details' => array_filter([
733 'brand' => Arr::get($paymentMethodDetails, 'card.brand'),
734 'last_4' => Arr::get($paymentMethodDetails, 'card.last4'),
735 'exp_month' => Arr::get($paymentMethodDetails, 'card.exp_month'),
736 'exp_year' => Arr::get($paymentMethodDetails, 'card.exp_year'),
737 'country' => Arr::get($paymentMethodDetails, 'card.country'),
738 'postal_code' => Arr::get($billingDetails, 'address.postal_code', ''),
739 'name' => Arr::get($billingDetails, 'name', '')
740 ])
741 ];
742
743 if ($order->type === Status::ORDER_TYPE_RENEWAL) {
744
745 $parentOrderId = $transaction->order->parent_id;
746 if (!$parentOrderId) {
747 return;
748 }
749 $subscription = Subscription::query()->where('id', $transaction->subscription_id)->first();
750
751 if (!$subscription) {
752 return $order; // No subscription found for this renewal order. Something is wrong.
753 }
754
755 $subscriptionArgs = [
756 'status' => Status::SUBSCRIPTION_ACTIVE,
757 'canceled_at' => null,
758 'current_payment_method' => 'stripe'
759 ];
760
761 // Only automatic subs expose a Stripe subscription to read the period end from;
762 // store-managed (system/manual) advance next_billing_date via handleRenewalPaid.
763 if ($subscription->vendor_subscription_id) {
764 $response = (new API())->getStripeObject('subscriptions/' . $subscription->vendor_subscription_id, [], $transaction->payment_mode);
765 if (!is_wp_error($response)) {
766 $nextBillingDate = Arr::get($response, 'current_period_end') ?? null;
767 if ($nextBillingDate) {
768 $subscriptionArgs['next_billing_date'] = gmdate('Y-m-d H:i:s', (int)$nextBillingDate);
769 }
770 }
771 }
772
773 SubscriptionService::recordManualRenewal($subscription, $transaction, [
774 'billing_info' => $billingInfo,
775 'subscription_args' => $subscriptionArgs
776 ]);
777
778 } else {
779 $subscription = Subscription::query()->where('id', $transaction->subscription_id)->first();
780
781 if ($subscription && !in_array($subscription->status, Status::getValidableSubscriptionStatuses())) {
782 (new SubscriptionsManager())->confirmSubscriptionAfterChargeSucceeded($subscription, $billingInfo);
783 }
784
785 // System (auto-charged, store-billed) subscription: persist the token from
786 // the first charge — the only write path for it, since
787 // confirmSubscriptionAfterChargeSucceeded() early-returns without a vendor subscription.
788 if ($subscription && $subscription->isSystem()) {
789 $stripeCustomerId = Arr::get($charge, 'customer', '');
790 if ($stripeCustomerId && !$subscription->vendor_customer_id) {
791 $subscription->vendor_customer_id = $stripeCustomerId;
792 $subscription->save();
793 }
794
795 $this->maybePersistSystemVaultToken($subscription, $order, $billingInfo);
796 }
797
798 (new StatusHelper($order))->syncOrderStatuses($transaction);
799 }
800
801 return $order;
802 }
803
804 }
805