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

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