PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.1
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 trunk All 48 releases
fluent-cart / app / Modules / PaymentMethods / StripeGateway / Confirmations.php

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

794 lines 32.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 $details = Arr::get($method, $type);
351 if (!is_array($details)) {
352 $details = [];
353 }
354
355 foreach (['last4', 'brand', 'exp_month', 'exp_year', 'fingerprint'] as $field) {
356 if (Arr::has($details, $field)) {
357 $pm[$field] = Arr::get($details, $field);
358 }
359 }
360
361 // Identifier for account-like methods: link.email, paypal.payer_email,
362 // cashapp.cashtag — first one present labels the entry in the UI.
363 foreach (['email', 'payer_email', 'cashtag'] as $field) {
364 if (Arr::get($details, $field)) {
365 $pm['email'] = Arr::get($details, $field);
366 break;
367 }
368 }
369
370 $fingerprint = Arr::get($details, 'fingerprint');
371
372 if ($fingerprint && in_array($fingerprint, $seenFingerprints, true)) {
373 continue;
374 }
375 if ($fingerprint) {
376 $seenFingerprints[] = $fingerprint;
377 }
378
379 if ($defaultPaymentMethodId === Arr::get($method, 'id')) {
380 $stripeMeta['payment_methods']['default'] = $pm;
381 } else {
382 $stripeMeta['payment_methods'][] = $pm;
383 }
384 }
385 }
386
387 $meta = $fctCustomer->getMeta($metaKey);
388 if (!is_array($meta)) {
389 $meta = [];
390 }
391 $meta['stripe'] = $stripeMeta;
392
393 $fctCustomer->updateMeta($metaKey, $meta);
394 }
395
396 public function confirmSetupIntent($setupIntent, $trxHash = null)
397 {
398 $api = new API();
399
400 $response = $api->getStripeObject('setup_intents/' . $setupIntent);
401
402 if (is_wp_error($response)) {
403 return $response;
404 }
405
406 $transaction = OrderTransaction::query()->where('vendor_charge_id', $setupIntent)->first();
407
408 if (!$transaction) {
409 return new \WP_Error(
410 'transaction_not_found',
411 __('Transaction not found for the provided setup intent.', 'fluent-cart')
412 );
413 }
414
415 if ($trxHash !== null && $transaction->uuid !== $trxHash) {
416 return new \WP_Error('invalid_request', __('Invalid request.', 'fluent-cart'));
417 }
418
419 if (Arr::get($response, 'status') !== 'succeeded') {
420 return new \WP_Error(
421 'setup_intent_not_succeeded',
422 __('Payment method setup is not complete. Please complete the payment method setup.', 'fluent-cart')
423 );
424 }
425
426 $transaction->status = Status::TRANSACTION_PENDING;
427
428 if ($transaction->total <= 0) {
429 $transaction->status = Status::TRANSACTION_SUCCEEDED;
430 }
431
432
433 $transaction->vendor_charge_id = ''; // removing vendor charge id , because setup intent id is not the charge id
434 $transaction->save();
435
436 $order = Order::query()->where('id', $transaction->order_id)->first();
437
438
439 $paymentMethod = Arr::get($response, 'payment_method');
440 $customer = Arr::get($response, 'customer');
441
442 $billingInfo = $this->getPaymentMethodDetails($paymentMethod);
443
444 // attach the payment method to the customer
445 if ($paymentMethod && $customer) {
446 $api->createStripeObject('payment_methods/' . $paymentMethod . '/attach', [
447 'customer' => $customer
448 ]);
449
450 $this->savePaymentMethodToCustomerMeta($customer, $paymentMethod, $order);
451 }
452
453
454 $subscription = Subscription::query()->where('id', $transaction->subscription_id)->first();
455
456 if ($subscription) {
457 if ($subscription->isSystem()) {
458 // Zero-payable free-trial checkout: no vendor subscription to confirm,
459 // just vault the Stripe customer + reusable payment method.
460 $stripeCustomerId = Arr::get($response, 'customer', '');
461 if ($stripeCustomerId && !$subscription->vendor_customer_id) {
462 $subscription->vendor_customer_id = $stripeCustomerId;
463 $subscription->save();
464 }
465
466 $vendorMethodId = Arr::get($response, 'payment_method', '');
467 if ($vendorMethodId) {
468 $billingInfo['vendor_method_id'] = $vendorMethodId;
469 }
470
471 $this->maybePersistSystemVaultToken($subscription, $order, $billingInfo);
472 } else {
473 (new SubscriptionsManager())->confirmSubscriptionAfterChargeSucceeded($subscription, $billingInfo);
474 }
475 }
476
477 (new StatusHelper($order))->syncOrderStatuses($transaction);
478
479 // Notify that a renewal invoice has been deferred — the actual charge will fire
480 // later via the gateway's subscription_cycle webhook. Gateways that capture a card
481 // for a deferred renewal charge should fire this so the invoice status can be updated.
482 if ($order->type === Status::ORDER_TYPE_RENEWAL) {
483 do_action('fluent_cart/renewal/payment_scheduled', [
484 'order' => $order,
485 'subscription' => $subscription,
486 ]);
487 }
488
489 }
490
491 /**
492 * Vault the token for a system subscription, or demote to manual when the
493 * initial checkout capture came back without one — mirrors PayPal's
494 * Processor::maybePersistVaultToken().
495 */
496 private function maybePersistSystemVaultToken($subscription, $order, $billingInfo)
497 {
498 $vendorMethodId = Arr::get($billingInfo, 'vendor_method_id', '');
499 $existing = $subscription->getMeta('active_payment_method', []) ?: [];
500 // Meta has two shapes in the wild: vendor_method_id (confirmation paths) and
501 // details.payment_method_id (card-update flow) — accept both, same as chargeRenewal().
502 $existingMethodId = Arr::get($existing, 'vendor_method_id') ?: Arr::get($existing, 'details.payment_method_id');
503
504 if ($vendorMethodId) {
505 if ($existingMethodId === $vendorMethodId) {
506 return; // already persisted (webhook/AJAX race)
507 }
508
509 $subscription->updateMeta('active_payment_method', $billingInfo);
510 return;
511 }
512
513 // No token on the initial capture and none stored yet — never leave a
514 // system subscription that can never be charged.
515 if ($order
516 && $order->type === Status::ORDER_TYPE_SUBSCRIPTION
517 && !$existingMethodId
518 ) {
519 SystemChargeService::demoteToManual(
520 $subscription,
521 __('Stripe did not return a saved payment method for automatic charging.', 'fluent-cart')
522 );
523 }
524 }
525
526 public function getPaymentMethodDetails($methodId)
527 {
528 $paymentMethodDetails = (new API())->makeRequest('payment_methods/' . $methodId, [], (new StripeSettingsBase())->getApiKey(), 'GET');
529
530 if (is_wp_error($paymentMethodDetails) || !$paymentMethodDetails) {
531 $billingInfo = PaymentHelper::parsePaymentMethodDetails('stripe', ['type' => 'card']);
532 } else {
533 $billingInfo = PaymentHelper::parsePaymentMethodDetails('stripe', $paymentMethodDetails);
534 }
535
536 return $billingInfo;
537 }
538
539
540 public function syncRemoteTransaction(OrderTransaction $transaction)
541 {
542 $mode = $transaction->payment_mode;
543 if (!$mode) {
544 $mode = $transaction->order ? $transaction->order->mode : '';
545 }
546
547 $intent = (new API())->getStripeObject('payment_intents/' . $transaction->vendor_charge_id, [
548 'expand' => ['latest_charge']
549 ], $mode);
550
551 if (is_wp_error($intent)) {
552 return $intent;
553 }
554
555 $intentStatus = Arr::get($intent, 'status');
556
557 if ($intentStatus === 'succeeded') {
558 $chargeCurrency = strtoupper((string) Arr::get($intent, 'latest_charge.currency', ''));
559 if ($chargeCurrency && $transaction->currency && strtoupper($transaction->currency) !== $chargeCurrency) {
560 fluent_cart_warning_log(
561 __('Stripe Currency Mismatch On Sync', 'fluent-cart'),
562 sprintf(
563 /* translators: %1$s: expected currency, %2$s: received currency */
564 __('Charge currency mismatch detected during transaction sync. Expected: %1$s, Received: %2$s. Transaction was not confirmed.', 'fluent-cart'),
565 $transaction->currency,
566 $chargeCurrency
567 ),
568 [
569 'module_name' => 'order',
570 'module_id' => $transaction->order_id,
571 'log_type' => 'api'
572 ]
573 );
574
575 return new \WP_Error('currency_mismatch', __('The Stripe payment currency does not match this transaction. Please verify the payment at Stripe.', 'fluent-cart'));
576 }
577
578 $this->confirmPaymentSuccessByCharge($transaction, [
579 'charge' => Arr::get($intent, 'latest_charge', []),
580 'intent_id' => Arr::get($intent, 'id'),
581 ]);
582
583 return OrderTransaction::query()->find($transaction->id);
584 }
585
586 if ($intentStatus === 'processing') {
587 return new \WP_Error('still_processing', __('The payment is still processing at Stripe. Please try again later.', 'fluent-cart'));
588 }
589
590 $failureMessage = Arr::get($intent, 'last_payment_error.message');
591 if (!$failureMessage) {
592 $failureMessage = sprintf(
593 /* translators: %1$s: Stripe payment intent status */
594 __('The payment has not completed at Stripe (status: %1$s).', 'fluent-cart'),
595 $intentStatus ?: 'unknown'
596 );
597 }
598
599 return new \WP_Error('charge_not_completed', $failureMessage);
600 }
601
602 /**
603 * Confirm payment success by charge.
604 * Currently used by:
605 * - fluent_cart/payments/stripe/webhook_charge_succeeded
606 * -
607 *
608 * @param OrderTransaction $transaction
609 * @param array $args
610 * @param array $args ['charge'] - The charge details from Stripe.
611 * @param string $args ['intent_id'] - The intent ID from Stripe.
612 */
613 public function confirmPaymentSuccessByCharge(OrderTransaction $transaction, $args = [])
614 {
615 $charge = Arr::get($args, 'charge', []);
616 $intentId = Arr::get($args, 'intent_id', '');
617
618 if (!$intentId) {
619 $intentId = Arr::get($charge, 'payment_intent', '');
620 }
621
622 $order = Order::query()->where('id', $transaction->order_id)->first();
623
624 // in race conditions between webhook and AJAX confirmation
625 $transaction = OrderTransaction::query()->where('id', $transaction->id)->first();
626 if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
627 if ($transaction->subscription_id) {
628 $subscription = Subscription::query()->where('id', $transaction->subscription_id)->first();
629 // Only automatic subs have a remote to resync; store-managed (system/manual) have none.
630 if ($subscription && $subscription->vendor_subscription_id) {
631 $subscription->reSyncFromRemote();
632 }
633 }
634
635 return (new StatusHelper($order))->syncOrderStatuses($transaction);
636 }
637
638 $chargeCurrency = Arr::get($charge, 'currency', $transaction->currency);
639 $status = Arr::get($charge, 'status') === 'succeeded' ? Status::TRANSACTION_SUCCEEDED : Status::TRANSACTION_PENDING;
640
641 if ($status === Status::TRANSACTION_PENDING) {
642 if (!$transaction->vendor_charge_id && !empty($intentId)) {
643 $transaction->update(['vendor_charge_id' => $intentId]);
644 }
645 return $order; // already pending,
646 }
647
648 $normalizedAmount = (int)Arr::get($charge, 'amount', 0);
649
650 if ($chargeCurrency && CurrenciesHelper::isZeroDecimal($chargeCurrency)) {
651 $normalizedAmount = $normalizedAmount * 100;
652 }
653
654 $transactionUpdateData = array_filter([
655 'order_id' => $order->id,
656 'total' => $normalizedAmount,
657 'currency' => $chargeCurrency,
658 'status' => $status,
659 'payment_method' => 'stripe',
660 'card_last_4' => Arr::get($charge, 'payment_method_details.card.last4', ''),
661 'card_brand' => Arr::get($charge, 'payment_method_details.card.brand', ''),
662 'payment_method_type' => Arr::get($charge, 'payment_method_details.type', ''),
663 'vendor_charge_id' => $intentId,
664 'payment_mode' => Arr::isTrue($charge, 'livemode') ? 'live' : 'test'
665 ]);
666
667 if (Arr::get($charge, 'disputed', false)) {
668 $transactionUpdateData['transaction_type'] = Status::TRANSACTION_TYPE_DISPUTE;
669 $disputeId = Arr::get($charge, 'dispute', '');
670 $reason = 'unknown';
671
672 $retreiveDispute = (new API())->getStripeObject('disputes/' . $disputeId);
673
674 if (!is_wp_error($retreiveDispute)) {
675 $reason = Arr::get($retreiveDispute, 'reason');
676 }
677
678 $transaction->meta = array_merge($transaction->meta, [
679 'dispute_id' => $disputeId,
680 'dispute_reason' => $reason,
681 'is_dispute_actionable' => in_array(Arr::get($retreiveDispute, 'status'), ['needs_response']),
682 'is_charge_refundable' => Arr::get($retreiveDispute, 'is_charge_refundable', false)
683 ]);
684
685 fluent_cart_warning_log('Stripe charge disputed', 'This payment was disputed (' . $charge['id'] . ')', [
686 'module_name' => 'order',
687 'module_id' => $order->id,
688 'log_type' => 'api'
689 ]);
690 if ($transaction->subscription_id) {
691 fluent_cart_warning_log('Stripe charge disputed', 'This payment was disputed (' . $charge['id'] . ')', [
692 'module_type' => 'FluentCart\App\Models\Subscription',
693 'module_id' => $transaction->subscription_id,
694 'module_name' => 'subscription',
695 'log_type' => 'api'
696 ]);
697 }
698 }
699
700 $transaction->fill($transactionUpdateData);
701 $transaction->save();
702
703 fluent_cart_add_log(__('Stripe Payment Confirmation', 'fluent-cart'), __('Payment confirmation received from Stripe. Transaction ID:', 'fluent-cart') . ' ' . $intentId, 'info', [
704 'module_name' => 'order',
705 'module_id' => $order->id,
706 ]);
707 if ($transaction->subscription_id) {
708 fluent_cart_add_log(__('Stripe Payment Confirmation', 'fluent-cart'), __('Payment confirmation received from Stripe. Transaction ID:', 'fluent-cart') . ' ' . $intentId, 'info', [
709 'module_type' => 'FluentCart\App\Models\Subscription',
710 'module_id' => $transaction->subscription_id,
711 'module_name' => 'subscription',
712 ]);
713 }
714
715 $billingDetails = Arr::get($charge, 'billing_details', []);
716 $paymentMethodDetails = Arr::get($charge, 'payment_method_details', []);
717 $billingInfo = [
718 'method' => 'stripe',
719 'vendor_method_id' => Arr::get($charge, 'payment_method', ''),
720 'payment_type' => Arr::get($paymentMethodDetails, 'type'),
721 'details' => array_filter([
722 'brand' => Arr::get($paymentMethodDetails, 'card.brand'),
723 'last_4' => Arr::get($paymentMethodDetails, 'card.last4'),
724 'exp_month' => Arr::get($paymentMethodDetails, 'card.exp_month'),
725 'exp_year' => Arr::get($paymentMethodDetails, 'card.exp_year'),
726 'country' => Arr::get($paymentMethodDetails, 'card.country'),
727 'postal_code' => Arr::get($billingDetails, 'address.postal_code', ''),
728 'name' => Arr::get($billingDetails, 'name', '')
729 ])
730 ];
731
732 if ($order->type === Status::ORDER_TYPE_RENEWAL) {
733
734 $parentOrderId = $transaction->order->parent_id;
735 if (!$parentOrderId) {
736 return;
737 }
738 $subscription = Subscription::query()->where('id', $transaction->subscription_id)->first();
739
740 if (!$subscription) {
741 return $order; // No subscription found for this renewal order. Something is wrong.
742 }
743
744 $subscriptionArgs = [
745 'status' => Status::SUBSCRIPTION_ACTIVE,
746 'canceled_at' => null,
747 'current_payment_method' => 'stripe'
748 ];
749
750 // Only automatic subs expose a Stripe subscription to read the period end from;
751 // store-managed (system/manual) advance next_billing_date via handleRenewalPaid.
752 if ($subscription->vendor_subscription_id) {
753 $response = (new API())->getStripeObject('subscriptions/' . $subscription->vendor_subscription_id, [], $transaction->payment_mode);
754 if (!is_wp_error($response)) {
755 $nextBillingDate = Arr::get($response, 'current_period_end') ?? null;
756 if ($nextBillingDate) {
757 $subscriptionArgs['next_billing_date'] = gmdate('Y-m-d H:i:s', (int)$nextBillingDate);
758 }
759 }
760 }
761
762 SubscriptionService::recordManualRenewal($subscription, $transaction, [
763 'billing_info' => $billingInfo,
764 'subscription_args' => $subscriptionArgs
765 ]);
766
767 } else {
768 $subscription = Subscription::query()->where('id', $transaction->subscription_id)->first();
769
770 if ($subscription && !in_array($subscription->status, Status::getValidableSubscriptionStatuses())) {
771 (new SubscriptionsManager())->confirmSubscriptionAfterChargeSucceeded($subscription, $billingInfo);
772 }
773
774 // System (auto-charged, store-billed) subscription: persist the token from
775 // the first charge — the only write path for it, since
776 // confirmSubscriptionAfterChargeSucceeded() early-returns without a vendor subscription.
777 if ($subscription && $subscription->isSystem()) {
778 $stripeCustomerId = Arr::get($charge, 'customer', '');
779 if ($stripeCustomerId && !$subscription->vendor_customer_id) {
780 $subscription->vendor_customer_id = $stripeCustomerId;
781 $subscription->save();
782 }
783
784 $this->maybePersistSystemVaultToken($subscription, $order, $billingInfo);
785 }
786
787 (new StatusHelper($order))->syncOrderStatuses($transaction);
788 }
789
790 return $order;
791 }
792
793 }
794