PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.2
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.2
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.2, at app/Modules/PaymentMethods/StripeGateway/Webhook/IPN.php

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