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

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

467 lines 17.5 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 ];
418
419 $eventType = $data->type;
420 if (!in_array($eventType, $acceptedEvents)) {
421 $this->sendResponse(200, 'Event type not accepted.');
422 }
423
424 $eventId = $data->id;
425 $event = (new API())->getEvent($eventId);
426
427 if (!$event || is_wp_error($event)) {
428 $this->sendResponse(400, 'Event not found or error occurred.');
429 }
430
431 // get the order from the event, in case of renewal create one
432 $order = (new Webhook())->processAndInsertOrderByEvent($event);
433
434 if (!$order) {
435 // This is already handled or not our event
436 $this->sendResponse(200, 'Event not handled or not related to an order.');
437 }
438
439 if (is_wp_error($order)) {
440 $this->sendResponse(400, 'Order not found or error occurred. Error: '. $order->get_error_message());
441 }
442
443 $eventType = str_replace('.', '_', $event->type);
444
445 if (has_action('fluent_cart/payments/stripe/webhook_' . $eventType)) {
446
447 do_action('fluent_cart/payments/stripe/webhook_' . $eventType, [
448 'event' => $event,
449 'order' => $order
450 ]);
451
452 $this->sendResponse(200, 'Webhook event processed successfully.');
453 }
454
455 $this->sendResponse(200, 'No handler found for this event type.');
456
457 }
458
459 protected function sendResponse($statusCode = 200, $message = 'Success')
460 {
461 wp_send_json([
462 'message' => $message,
463 ], $statusCode);
464 }
465
466 }
467