PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 / Webhook / IPN.php

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

564 lines 21.8 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\Webhook;
4
5 use FluentCart\App\Events\Order\OrderRefund;
6 use FluentCart\App\Events\Subscription\SubscriptionRenewalFailed;
7 use FluentCart\App\Helpers\CurrenciesHelper;
8 use FluentCart\App\Events\Order\OrderStatusUpdated;
9 use FluentCart\App\Helpers\Status;
10 use FluentCart\App\Helpers\StatusHelper;
11 use FluentCart\App\Models\Order;
12 use FluentCart\App\Models\OrderTransaction;
13 use FluentCart\App\Models\Subscription;
14 use FluentCart\App\Modules\PaymentMethods\StripeGateway\Confirmations;
15 use FluentCart\App\Modules\PaymentMethods\StripeGateway\StripeHelper;
16 use FluentCart\App\Modules\PaymentMethods\StripeGateway\API\API;
17 use FluentCart\Framework\Support\Arr;
18
19 class IPN
20 {
21 public function init(): void
22 {
23 // DONE!
24 add_action('fluent_cart/payments/stripe/webhook_charge_refunded', [$this, 'handleChargeRefunded'], 10, 1);
25
26 // Done
27 add_action('fluent_cart/payments/stripe/webhook_charge_succeeded', [$this, 'handleChargeSucceeded'], 10, 1);
28
29 add_action('fluent_cart/payments/stripe/webhook_charge_dispute_created', [$this, 'handleChargeDisputeCreated'], 10, 1);
30 add_action('fluent_cart/payments/stripe/webhook_charge_dispute_closed', [$this, 'handleChargeDisputeClosed'], 10, 1);
31
32 // For Hosted Checkout (Checkout Sessions)
33 add_action('fluent_cart/payments/stripe/webhook_checkout_session_completed', [$this, 'handleCheckoutSessionCompleted'], 10, 1);
34
35 // For Subscriptions
36 add_action('fluent_cart/payments/stripe/webhook_customer_subscription_updated', [$this, 'handleSubscriptionUpdated'], 10, 1);
37 add_action('fluent_cart/payments/stripe/webhook_customer_subscription_deleted', [$this, 'handleSubscriptionUpdated'], 10, 1); // canceled event
38
39 add_action('fluent_cart/payments/stripe/webhook_invoice_payment_failed', [$this, 'handleInvoicePaymentFailed'], 10, 1);
40 }
41
42
43 public function handleChargeRefunded($data)
44 {
45 $event = Arr::get($data, 'event');
46 $order = Arr::get($data, 'order');
47 $order = Order::query()->where('id', $order->id)->first(); // we are just renewing it
48
49 $eventArray = json_decode(json_encode($event), true);
50 $charge = Arr::get($eventArray, 'data.object', []);
51
52 $refunds = Arr::get($charge, 'refunds.data', []);
53
54 if (empty($refunds)) {
55 $chargeId = Arr::get($charge, 'id', '');
56 if ($chargeId) {
57 $refundsResponse = (new API())->getStripeObject('charges/' . $chargeId . '/refunds');
58 if (!is_wp_error($refundsResponse)) {
59 $refunds = Arr::get($refundsResponse, 'data', []);
60 }
61 }
62 }
63
64 if (!$refunds) {
65 return false;
66 }
67
68 $parentTransaction = OrderTransaction::query()->where('vendor_charge_id', Arr::get($charge, 'payment_intent'))
69 ->where('status', Status::TRANSACTION_SUCCEEDED)
70 ->first();
71
72 if (!$parentTransaction) {
73 return false;
74 }
75
76 $generalData = [
77 'order_id' => $order->id,
78 'order_type' => $order->type,
79 'transaction_type' => Status::TRANSACTION_TYPE_REFUND,
80 'payment_method' => 'stripe',
81 'payment_mode' => $event->livemode ? 'live' : 'test',
82 'card_last_4' => Arr::get($charge, 'payment_method_details.card.last4', ''),
83 'card_brand' => Arr::get($charge, 'payment_method_details.card.brand', ''),
84 ];
85
86 $paymentMethodType = Arr::get($charge, 'payment_method_details.type', '');
87
88 if (!$paymentMethodType) {
89 $paymentMethodType = $parentTransaction->payment_method_type;
90 }
91
92 $currentCreatedRefund = null;
93 foreach ($refunds as $refund) {
94 $refundMethodType = Arr::get($refund, 'destination_details.type', '');
95 if (!$refundMethodType) {
96 $refundMethodType = $paymentMethodType;
97 }
98
99 $reason = Arr::get($refund, 'reason', 'other') ? Arr::get($refund, 'reason', 'other') : 'not specified';
100
101 $refundCurrency = Arr::get($charge, 'currency') ?? $order->currency;
102 $normalizedRefundAmount = (int)Arr::get($refund, 'amount', 0);
103
104 if ($refundCurrency && CurrenciesHelper::isZeroDecimal($refundCurrency)) {
105 $normalizedRefundAmount = $normalizedRefundAmount * 100;
106 }
107
108 $refundData = [
109 'payment_method_type' => $refundMethodType,
110 'vendor_charge_id' => Arr::get($refund, 'id'),
111 'status' => Status::TRANSACTION_REFUNDED,
112 'currency' => $refundCurrency,
113 'total' => $normalizedRefundAmount,
114 'meta' => [
115 'reason' => $reason,
116 'transaction_id' => $parentTransaction ? $parentTransaction->id : null,
117 ],
118 'uuid' => md5(time() . wp_generate_uuid4()),
119 'created_at' => gmdate('Y-m-d H:i:s', Arr::get($refund, 'created', time())),
120 'updated_at' => gmdate('Y-m-d H:i:s', Arr::get($refund, 'created', time())),
121 ];
122 $refundData = wp_parse_args($refundData, $generalData);
123
124 $syncedRefund = StripeHelper::createOrUpdateIpnRefund($refundData, $parentTransaction);
125
126 if ($syncedRefund->wasRecentlyCreated) {
127 $currentCreatedRefund = $syncedRefund;
128 }
129 }
130
131 (new OrderRefund($order, $currentCreatedRefund))->dispatch();
132 }
133
134 public function handleChargeSucceeded($data)
135 {
136 $event = Arr::get($data, 'event');
137 $order = Arr::get($data, 'order');
138 $eventArray = json_decode(json_encode($event), true);
139 $charge = Arr::get($eventArray, 'data.object');
140
141 $intentId = Arr::get($charge, 'payment_intent');
142
143
144 if (!$intentId) {
145 return false; // no payment intent found
146 }
147
148 $transaction = OrderTransaction::query()->where('vendor_charge_id', $intentId)->first();
149
150 if (!$transaction) {
151 $chargeCurrency = Arr::get($charge, 'currency', $order->currency);
152 $normalizedChargeAmount = (int)Arr::get($charge, 'amount', 0);
153
154 if ($chargeCurrency && CurrenciesHelper::isZeroDecimal($chargeCurrency)) {
155 $normalizedChargeAmount = $normalizedChargeAmount * 100;
156 }
157
158 $transaction = OrderTransaction::query()
159 ->where('order_id', $order->id)
160 ->where('status', Status::TRANSACTION_PENDING)
161 ->where('total', $normalizedChargeAmount)
162 ->orderBy('id', 'DESC')
163 ->first();
164 }
165
166 if (!$transaction) {
167 return false;
168 }
169
170 (new Confirmations())->confirmPaymentSuccessByCharge($transaction, [
171 'charge' => $charge,
172 'intent_id' => $intentId
173 ]);
174 }
175
176 public function handleChargeDisputeCreated($data)
177 {
178 $event = Arr::get($data, 'event');
179 $order = Arr::get($data, 'order');
180 $eventArray = json_decode(json_encode($event), true);
181 $disputedCharge = Arr::get($eventArray, 'data.object');
182
183 $disputeId = Arr::get($disputedCharge, 'id');
184 $intentId = Arr::get($disputedCharge, 'payment_intent');
185 $status = Arr::get($disputedCharge, 'status');
186
187 if (!$intentId || !in_array($status, ['needs_response', 'under_review', 'warning_needs_response'])) {
188 return false;
189 }
190
191 $transactionModel = OrderTransaction::query()->where('vendor_charge_id', $intentId)->first();
192
193 if (!$transactionModel || $transactionModel->transaction_type === Status::TRANSACTION_TYPE_DISPUTE) {
194 return false;
195 }
196
197 $reason = Arr::get($disputedCharge, 'reason');
198
199 $isChargeRefundable = Arr::get($disputedCharge, 'is_charge_refundable', false);
200
201 // make this transaction type dispute if not already
202 $transactionModel->transaction_type = Status::TRANSACTION_TYPE_DISPUTE;
203 $transactionModel->meta = array_merge($transactionModel->meta ?? [], [
204 'dispute_id' => $disputeId,
205 'dispute_reason' => $reason,
206 'is_dispute_actionable' => in_array(Arr::get($disputedCharge, 'status'), ['needs_response', 'warning_needs_response']),
207 'is_charge_refundable' => $isChargeRefundable,
208 'dispute_status' => $status
209 ]);
210
211 $transactionModel->save();
212
213 fluent_cart_warning_log('This payment was disputed', 'Disputed claimed for this payment due to ' . $reason, [
214 'module_name' => 'order',
215 'module_id' => $order->id,
216 'log_type' => 'api'
217 ]);
218 if ($transactionModel->subscription_id) {
219 $subscription = Subscription::query()->find($transactionModel->subscription_id);
220 if ($subscription) {
221 $subscription->addLog('This payment was disputed', 'Disputed claimed for this payment due to ' . $reason, 'warning');
222 }
223 }
224
225 return true;
226
227 }
228
229
230 public function handleChargeDisputeClosed($data)
231 {
232 $event = Arr::get($data, 'event');
233 $order = Arr::get($data, 'order');
234 $eventArray = json_decode(json_encode($event), true);
235 $disputedCharge = Arr::get($eventArray, 'data.object');
236
237 $intentId = Arr::get($disputedCharge, 'payment_intent');
238
239 if (!$intentId) {
240 return false; // no payment intent found
241 }
242
243 $transactionModel = OrderTransaction::query()->where('vendor_charge_id', $intentId)->first();
244
245 $status = Arr::get($disputedCharge, 'status');
246 $reason = Arr::get($disputedCharge, 'reason');
247
248 if (!$transactionModel || $transactionModel->status === Status::TRANSACTION_DISPUTE_LOST) {
249 return false;
250 }
251
252 if (in_array($status, ['won', 'prevented', 'warning_closed'])) {
253 $transactionModel->transaction_type = Status::TRANSACTION_TYPE_CHARGE;
254 $transactionModel->meta = array_merge($transactionModel->meta, [
255 'is_dispute_actionable' => false,
256 'is_charge_refundable' => false,
257 'dispute_status' => $status
258 ]);
259 $transactionModel->save();
260
261 $title = 'Dispute won!';
262 $content = 'Dispute won for this payment due to ' . $reason;
263
264 if ($status == 'prevented') {
265 $title = 'Dispute prevented!';
266 $content = 'Dispute was prevented from becoming a formal chargeback. ' . $reason;
267 } else if( $status == 'warning_closed') {
268 $title = 'Dispute warning closed!';
269 $content = 'An inquiry closed without becoming a formal dispute.';
270 }
271
272 fluent_cart_add_log($title, $content, 'info', [
273 'module_name' => 'order',
274 'module_id' => $order->id,
275 'log_type' => 'api'
276 ]);
277 if ($transactionModel->subscription_id) {
278 $subscription = Subscription::query()->find($transactionModel->subscription_id);
279 if ($subscription) {
280 $subscription->addLog($title, $content);
281 }
282 }
283 return true;
284
285 } else if ($status == 'lost') {
286 $transactionModel->status = Status::TRANSACTION_DISPUTE_LOST;
287 $transactionModel->meta = array_merge($transactionModel->meta ?? [], [
288 'is_dispute_actionable' => false,
289 'is_charge_refundable' => false,
290 'dispute_status' => $status
291 ]);
292 $transactionModel->save();
293
294 fluent_cart_add_log('Dispute lost', 'Dispute lost for this payment . ' . $transactionModel->vendor_charge_id, 'info', [
295 'module_name' => 'order',
296 'module_id' => $order->id,
297 'log_type' => 'api'
298 ]);
299 if ($transactionModel->subscription_id) {
300 $subscription = Subscription::query()->find($transactionModel->subscription_id);
301 if ($subscription) {
302 $subscription->addLog('Dispute lost', 'Dispute lost for this payment . ' . $transactionModel->vendor_charge_id);
303 }
304 }
305
306 $newPaidAmount = intval($transactionModel->order->total_paid - $transactionModel->total);
307 $transactionModel->order->update([
308 'total_paid' => max($newPaidAmount, 0),
309 'payment_status' => $newPaidAmount > 0 ? Status::PAYMENT_PARTIALLY_PAID : Status::PAYMENT_FAILED,
310 ]);
311 }
312
313 return true;
314 }
315
316 /**
317 * Handle checkout.session.completed webhook for hosted checkout mode
318 * This ensures webhooks work properly even if redirect confirmation hasn't happened yet
319 */
320 public function handleCheckoutSessionCompleted($data)
321 {
322 $event = Arr::get($data, 'event');
323 $order = Arr::get($data, 'order');
324 $eventArray = json_decode(json_encode($event), true);
325 $session = Arr::get($eventArray, 'data.object');
326
327 $sessionId = Arr::get($session, 'id');
328 $paymentIntentId = Arr::get($session, 'payment_intent');
329 $paymentStatus = Arr::get($session, 'payment_status');
330 $mode = Arr::get($session, 'mode');
331
332 if (!$sessionId) {
333 return false;
334 }
335
336 // Find transaction by session_id stored in meta
337 $transaction = OrderTransaction::query()
338 ->where('order_id', $order->id)
339 ->whereRaw("JSON_EXTRACT(meta, '$.session_id') = ?", [$sessionId])
340 ->first();
341
342 // Fallback: try to find by vendor_charge_id if it was stored as session_id
343 if (!$transaction) {
344 $transaction = OrderTransaction::query()
345 ->where('order_id', $order->id)
346 ->where('vendor_charge_id', $sessionId)
347 ->first();
348 }
349
350 if (!$transaction) {
351 return false;
352 }
353
354 // Skip if already confirmed
355 if ($transaction->status === Status::TRANSACTION_SUCCEEDED) {
356 (new StatusHelper($transaction->order))->syncOrderStatuses($transaction);
357 return true;
358 }
359
360 // Update vendor_charge_id to payment_intent for future webhook lookups
361 if ($paymentIntentId && $mode === 'payment') {
362 $transaction->update([
363 'vendor_charge_id' => $paymentIntentId
364 ]);
365 }
366
367 // For subscription mode, update vendor_subscription_id
368 if ($mode === 'subscription') {
369 $subscriptionId = Arr::get($session, 'subscription');
370 if ($subscriptionId) {
371 $subscription = Subscription::query()->where('id', $transaction->subscription_id)->first();
372 if ($subscription) {
373 $subscription->update([
374 'vendor_subscription_id' => $subscriptionId
375 ]);
376 }
377
378 // Update transaction with payment_intent if available
379 if ($paymentIntentId) {
380 $transaction->update([
381 'vendor_charge_id' => $paymentIntentId
382 ]);
383 }
384 }
385 }
386
387 return true;
388 }
389
390 public function handleSubscriptionUpdated($data)
391 {
392 $event = Arr::get($data, 'event');
393 $order = Arr::get($data, 'order');
394
395 $currentSubscription = Subscription::query()->where('parent_order_id', $order->id)->first();
396
397 if (!$currentSubscription) {
398 return false; // no subscription found
399 }
400
401 return $currentSubscription->reSyncFromRemote();
402 }
403
404 public function handleInvoicePaymentFailed($data)
405 {
406 $event = Arr::get($data, 'event');
407 $order = Arr::get($data, 'order');
408 $invoice = $event->data->object;
409
410 $invoice = (new API())->getStripeObject('invoices/' . $invoice->id);
411
412 $vendorSubscriptionId = Arr::get($invoice, 'subscription', null)
413 ?: Arr::get($invoice, 'parent.subscription_details.subscription', null);
414
415 $subscription = null;
416 if ($vendorSubscriptionId) {
417 $subscription = Subscription::query()
418 ->where('vendor_subscription_id', $vendorSubscriptionId)
419 ->where('parent_order_id', $order->id)
420 ->where('current_payment_method', 'stripe')
421 ->first();
422 }
423
424 if (!$subscription) {
425 return false;
426 }
427
428 $invoiceId = Arr::get($invoice, 'id');
429
430 if (!$invoiceId || !preg_match('/^in_[a-zA-Z0-9_]+$/', $invoiceId)) {
431 return false;
432 }
433
434 $claimKey = 'fct_sub_renewal_failed_' . $subscription->id . '_' . $invoiceId;
435
436 // One notification per failed renewal cycle. invoice.payment_failed fires once
437 // per Stripe retry attempt against the same invoice, and verifyAndProcess()
438 // authenticates by re-fetching the event rather than by signature, so a
439 // resubmitted event id re-runs the handler. The invoice id is stable across
440 // retries within a cycle and distinct for the next one. The stored value must
441 // stay constant — add_option()'s pre-check is not atomic, so the claim leans on
442 // MySQL reporting zero affected rows for an unchanged ON DUPLICATE KEY UPDATE.
443 if (!add_option($claimKey, '1', '', false)) {
444 return true; // already notified for this renewal cycle
445 }
446
447 $paymentIntentId = Arr::get($invoice, 'payment_intent', null);
448 if (is_array($paymentIntentId)) {
449 $paymentIntentId = Arr::get($paymentIntentId, 'id', null);
450 }
451
452 $error = '';
453 if ($paymentIntentId && preg_match('/^[a-zA-Z0-9_-]+$/', $paymentIntentId)) {
454 $paymentIntent = (new API())->getStripeObject('payment_intents/' . $paymentIntentId, [], $order->mode);
455 if (!is_wp_error($paymentIntent)) {
456 $error = (string)Arr::get($paymentIntent, 'last_payment_error.message', '');
457 }
458 }
459
460 if (!$error) {
461 $error = __('Stripe reported a failed invoice payment attempt.', 'fluent-cart');
462 }
463
464 try {
465 (new SubscriptionRenewalFailed($subscription, $order, $order->customer, $error))->dispatch();
466 } catch (\Throwable $e) {
467 // The claim is permanent, so a half-finished dispatch would suppress this
468 // renewal forever. Release it so the redelivery reruns the dispatch.
469 delete_option($claimKey);
470 throw $e;
471 }
472
473 return true;
474 }
475
476 public function verifyAndProcess()
477 {
478 $data = (new API())->verifyIPN();
479 if (is_wp_error($data)) {
480 $this->sendResponse(400, $data->get_error_message());
481 }
482
483 $acceptedEvents = [
484 'invoice.paid', // Reviewed for subscription cycle
485 'charge.refunded', // reviewed
486 'charge.succeeded', // reviewed
487 'charge.dispute.created',
488 'charge.dispute.closed',
489 'checkout.session.completed',
490 'customer.subscription.deleted',
491 'customer.subscription.updated',
492 'setup_intent.succeeded', // recovers zero-payable system-subscription vaulting if the AJAX confirm is lost
493 'invoice.payment_failed',
494 ];
495
496 $eventType = $data->type;
497 if (!in_array($eventType, $acceptedEvents)) {
498 $this->sendResponse(200, 'Event type not accepted.');
499 }
500
501 $eventId = $data->id;
502 $livemode = isset($data->livemode) ? (bool)$data->livemode : null;
503 $event = (new API())->getEvent($eventId, $livemode);
504
505 if (!$event || is_wp_error($event)) {
506 $reason = is_wp_error($event)
507 ? $event->get_error_code() . ': ' . $event->get_error_message()
508 : 'Stripe returned an empty response.';
509
510 // Warning, not error: fluent_cart_error_log() is a no-op unless
511 // FLUENT_CART_DEV_MODE is on, and this is the only surviving record of
512 // why a delivery failed.
513 fluent_cart_warning_log(
514 'Stripe Webhook: could not fetch event ' . $eventId,
515 $reason . ' (event mode: ' . (is_null($livemode) ? 'unknown' : ($livemode ? 'live' : 'test')) . ')',
516 [
517 'module_name' => 'payment',
518 'log_type' => 'api',
519 ]
520 );
521
522 $this->sendResponse(400, 'Event not found or error occurred. ' . $reason);
523 }
524
525 // get the order from the event, in case of renewal create one
526 $webhook = new Webhook();
527 $order = $webhook->processAndInsertOrderByEvent($event);
528
529 if (!$order) {
530 // Either we have no resolver for this event type, or the resolver ran and
531 // nothing local matched. Both are a 200 — neither is retryable — but they
532 // mean different things when reading the Stripe delivery log.
533 $this->sendResponse(200, $webhook->getUnresolvedReason() ?: __('Event resolved to no order.', 'fluent-cart'));
534 }
535
536 if (is_wp_error($order)) {
537 $this->sendResponse(400, 'Order not found or error occurred. Error: '. $order->get_error_message());
538 }
539
540 $eventType = str_replace('.', '_', $event->type);
541
542 if (has_action('fluent_cart/payments/stripe/webhook_' . $eventType)) {
543
544 do_action('fluent_cart/payments/stripe/webhook_' . $eventType, [
545 'event' => $event,
546 'order' => $order
547 ]);
548
549 $this->sendResponse(200, 'Webhook event processed successfully.');
550 }
551
552 $this->sendResponse(200, 'No handler found for this event type.');
553
554 }
555
556 protected function sendResponse($statusCode = 200, $message = 'Success')
557 {
558 wp_send_json([
559 'message' => $message,
560 ], $statusCode);
561 }
562
563 }
564