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

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

412 lines 17.4 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\Helpers\Status;
6 use FluentCart\App\Helpers\CurrenciesHelper;
7 use FluentCart\App\Models\Order;
8 use FluentCart\App\Models\OrderTransaction;
9 use FluentCart\App\Models\Subscription;
10 use FluentCart\App\Modules\PaymentMethods\StripeGateway\API\API;
11 use FluentCart\App\Modules\PaymentMethods\StripeGateway\Confirmations;
12 use FluentCart\App\Modules\PaymentMethods\StripeGateway\StripeHelper;
13 use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService;
14 use FluentCart\App\Services\DateTime\DateTime;
15 use FluentCart\Framework\Support\Arr;
16
17 class Webhook
18 {
19 const WEBHOOK_ENDPOINT = '?fluent-cart=fct_payment_listener_ipn&method=stripe';
20
21 public static function getURL(): string
22 {
23 return trailingslashit(site_url()) . self::WEBHOOK_ENDPOINT;
24 }
25
26 public static function getEvents(): array
27 {
28 return [
29 'checkout.session.completed',
30 'charge.refunded',
31 'charge.refund.updated',
32 'charge.succeeded',
33 'invoice.paid',
34 'customer.subscription.deleted',
35 'customer.subscription.updated',
36 'invoice.payment_failed',
37 'setup_intent.succeeded'
38 ];
39 }
40
41 public static function webhookInstruction(): array
42 {
43 $events = 'checkout.session.completed%2Ccharge.refunded%2Ccharge.refund.updated%2Ccharge.succeeded%2Cinvoice.paid%2Ccustomer.subscription.deleted%2Ccustomer.subscription.updated%2Cinvoice.payment_failed%2Ccharge.captured%2Ccharge.dispute.closed%2Ccharge.dispute.created%2Cinvoice_payment.paid%2Cpayment_intent.succeeded%2Csetup_intent.succeeded';
44
45 $svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M10 6V8H5V19H16V14H18V20C18 20.5523 17.5523 21 17 21H4C3.44772 21 3 20.5523 3 20V7C3 6.44772 3.44772 6 4 6H10ZM21 3V11H19L18.9999 6.413L11.2071 14.2071L9.79289 12.7929L17.5849 5H13V3H21Z"></path></svg>';
46
47 /* translators: %1$s: "Add endpoint" link with icon */
48 $step = fn($class, $url) => \sprintf(
49 '<p class="%s">%s</p>',
50 $class,
51 \sprintf(
52 __('Click %1$s and paste the webhook URL above', 'fluent-cart'),
53 \sprintf('<a href="%s" target="_blank">%s %s</a>', $url, __('Add endpoint', 'fluent-cart'), $svg)
54 )
55 );
56
57 return [
58 'title' => __('Webhook URL', 'fluent-cart'),
59 'webhook_url' => static::getURL(),
60 'description' => __('You should configure your Stripe webhooks to get all updates of your payments remotely.', 'fluent-cart'),
61 'steps' => [
62 'title' => __('How to configure?', 'fluent-cart'),
63 'list' => [
64 'live' => [
65 __('In your Stripe Dashboard, go to Developers → Webhooks', 'fluent-cart'),
66 $step('fct_hide_on_test', \sprintf('https://dashboard.stripe.com/webhooks/create?events=%s', $events)),
67 ],
68 'test' => [
69 __('In your Stripe Dashboard, go to Developers → Webhooks', 'fluent-cart'),
70 $step('fct_hide_on_live', \sprintf('https://dashboard.stripe.com/test/webhooks/create?events=%s', $events)),
71 ],
72 ],
73 ],
74 'events' => [
75 'title' => __('Select these events', 'fluent-cart'),
76 'list' => [
77 'checkout.session.completed',
78 'charge.refunded',
79 'charge.refund.updated',
80 'charge.succeeded',
81 'invoice.paid',
82 'invoice.payment_failed',
83 'customer.subscription.deleted',
84 'customer.subscription.updated',
85 'setup_intent.succeeded',
86 ],
87 ],
88 ];
89 }
90
91 /**
92 * Why the last processAndInsertOrderByEvent() call resolved no order. The
93 * caller answers the webhook with it, so "we have no resolver for this type"
94 * is distinguishable from "resolved fine, but nothing local matches".
95 *
96 * @var string
97 */
98 protected $unresolvedReason = '';
99
100 public function getUnresolvedReason()
101 {
102 return $this->unresolvedReason;
103 }
104
105 public function processAndInsertOrderByEvent($event)
106 {
107 $eventType = $event->type;
108 $eventLivemode = isset($event->livemode) ? (bool)$event->livemode : null;
109
110 $this->unresolvedReason = '';
111
112 $metaDataEvents = [
113 'invoice.paid', // Reviewed for subscription cycle
114 'charge.refunded', // reviewed
115 'charge.succeeded', // reviewed
116 'charge.dispute.created',
117 'charge.dispute.closed',
118 'checkout.session.completed',
119 'customer.subscription.deleted',
120 'customer.subscription.updated',
121 'setup_intent.succeeded', // recovers zero-payable system-subscription vaulting if the AJAX confirm is lost
122 'invoice.payment_failed',
123 ];
124
125 if (!in_array($eventType, $metaDataEvents)) {
126 $this->unresolvedReason = __('Event type has no order resolver.', 'fluent-cart');
127 return false;
128 }
129
130 $vendorDataObject = $event->data->object;
131
132 if ($eventType === 'setup_intent.succeeded') {
133 $setupIntentId = Arr::get((array)$vendorDataObject, 'id');
134 if ($setupIntentId) {
135 $result = (new Confirmations())->confirmSetupIntent($setupIntentId, null, StripeHelper::modeFromLivemode($eventLivemode));
136 if (!is_wp_error($result)) {
137 wp_send_json([
138 'message' => 'Setup intent confirmed successfully.',
139 ], 200);
140 }
141 }
142
143 $this->unresolvedReason = __('Setup intent could not be confirmed.', 'fluent-cart');
144 return false;
145 }
146
147 if ($eventType == 'invoice.paid') {
148 //check if subscription billing_cycle invoice paid or failed
149 $isSubscriptionCycle = $vendorDataObject->billing_reason === 'subscription_cycle';
150 if ($isSubscriptionCycle) {
151 if ($eventType === 'invoice.paid') {
152 $vendorDataObject = (new API())->getStripeObject('invoices/' . $vendorDataObject->id, ['expand' => ['payment_intent']], StripeHelper::modeFromLivemode($eventLivemode));
153 $createdOrder = $this->processSubscriptionRenewal($vendorDataObject);
154 if ($createdOrder) {
155 wp_send_json([
156 'message' => 'Subscription renewal processed successfully. Order ID: ' . $createdOrder->id,
157 ], 200);
158 }
159 }
160
161 $this->unresolvedReason = __('Subscription renewal invoice resolved to no order.', 'fluent-cart');
162 return false;
163 }
164 }
165
166 if ($eventType === 'invoice.payment_failed') {
167 $isSubscriptionCycle = $vendorDataObject->billing_reason === 'subscription_cycle';
168 if ($isSubscriptionCycle) {
169 $invoice = (new API())->getStripeObject('invoices/' . $vendorDataObject->id, [], StripeHelper::modeFromLivemode($eventLivemode));
170 if (!is_wp_error($invoice)) {
171 list($subscription, $parentOrder) = $this->resolveSubscriptionAndOrder($invoice);
172
173 if ($subscription && $parentOrder && $subscription->current_payment_method === 'stripe') {
174 return $parentOrder;
175 }
176 }
177 }
178
179 $this->unresolvedReason = __('Subscription renewal-failure invoice resolved to no order.', 'fluent-cart');
180 return false;
181 }
182
183 if ($eventType === 'charge.refunded' || $eventType === 'charge.succeeded') {
184 $paymentIntent = $vendorDataObject->payment_intent;
185 $orderTransaction = OrderTransaction::query()->where('vendor_charge_id', $paymentIntent)
186 ->where('transaction_type', 'charge')
187 ->first();
188
189 if (!$orderTransaction) {
190 $orderTransaction = apply_filters('fluent_cart/stripe/fallback_order_transaction', null, $vendorDataObject);
191 if (!$orderTransaction || $orderTransaction instanceof OrderTransaction) {
192 $orderTransaction = null;
193 }
194 }
195
196 if ($orderTransaction) {
197 $order = Order::where('id', $orderTransaction->order_id)->first();
198 $order->current_transaction = $orderTransaction;
199 return $order;
200 }
201 }
202
203 if ($eventType === 'customer.subscription.deleted' || $eventType === 'customer.subscription.updated') {
204
205 $vendorSubscriptionId = $vendorDataObject->id;
206
207 $subscription = Subscription::query()
208 ->where('vendor_subscription_id', $vendorSubscriptionId)
209 ->first();
210
211 if ($subscription) {
212 $order = Order::where('id', $subscription->parent_order_id)->first();
213 if ($order) {
214 $order->current_subscription = $subscription;
215 } else {
216 $this->unresolvedReason = __('Subscription matched but its parent order is missing.', 'fluent-cart');
217 }
218 return $order;
219 }
220 }
221
222 if ($eventType === 'charge.dispute.created' || $eventType === 'charge.dispute.closed') {
223 $paymentIntent = $vendorDataObject->payment_intent;
224 $orderTransaction = OrderTransaction::query()->where('vendor_charge_id', $paymentIntent)
225 ->first();
226
227 if ($orderTransaction) {
228 return $orderTransaction->order;
229 }
230
231 $this->unresolvedReason = __('No local transaction matches the disputed charge.', 'fluent-cart');
232 return null;
233 }
234
235 // Handle checkout.session.completed for hosted checkout
236 if ($eventType === 'checkout.session.completed') {
237 $sessionId = $vendorDataObject->id;
238 $sessionOrder = StripeHelper::validateBySession($sessionId);
239
240 if (!$sessionOrder) {
241 $this->unresolvedReason = __('Checkout session does not match a local order.', 'fluent-cart');
242 }
243
244 return $sessionOrder;
245 }
246
247 $metaData = (array)$vendorDataObject->metadata;
248 $orderHash = Arr::get($metaData, 'fct_ref_id', false);
249
250 if ($orderHash) {
251 $referencedOrder = Order::query()->where('uuid', $orderHash)->first();
252
253 if (!$referencedOrder) {
254 $this->unresolvedReason = __('Event references an order that does not exist here.', 'fluent-cart');
255 }
256
257 return $referencedOrder;
258 }
259
260 $this->unresolvedReason = __('Event carries no reference to a local order.', 'fluent-cart');
261 return null;
262 }
263
264 /**
265 * Resolve the local Subscription + parent Order for a Stripe invoice payload,
266 * shared by renewal-success (invoice.paid) and renewal-failure
267 * (invoice.payment_failed) handling.
268 *
269 * @return array{0: Subscription|null, 1: Order|null}
270 */
271 protected function resolveSubscriptionAndOrder($vendorInvoiceObject)
272 {
273 $subscription = null;
274 $parentOrder = null;
275
276 $vendorSubscriptionId = Arr::get($vendorInvoiceObject, 'subscription', null)
277 ?: (Arr::get($vendorInvoiceObject, 'parent.subscription_details.subscription', null) ?? null);
278
279 if ($vendorSubscriptionId) {
280 $subscription = Subscription::query()->where('vendor_subscription_id', $vendorSubscriptionId)
281 ->orderBy('id', 'DESC')
282 ->first();
283 }
284
285 if ($subscription) {
286 $parentOrder = Order::query()->where('id', $subscription->parent_order_id)->first();
287 }
288
289 if (!$parentOrder) {
290 // let's try to find from the meta ref id
291 $refId = Arr::get($vendorInvoiceObject, 'subscription_details.metadata.fct_ref_id', null);
292 if ($refId) {
293 $parentOrder = Order::query()->where('uuid', $refId)->first();
294 }
295 }
296
297 if ($parentOrder && !$subscription) {
298 $subscription = Subscription::query()
299 ->where('parent_order_id', $parentOrder->id)
300 ->orderBy('id', 'DESC')
301 ->first();
302 }
303
304 return [$subscription, $parentOrder];
305 }
306
307 public function processSubscriptionRenewal($vendorInvoiceObject)
308 {
309 list($subscription, $parentOrder) = $this->resolveSubscriptionAndOrder($vendorInvoiceObject);
310
311 $vendorSubscriptionId = Arr::get($vendorInvoiceObject, 'subscription', null)
312 ?: (Arr::get($vendorInvoiceObject, 'parent.subscription_details.subscription', null) ?? null);
313
314 if (!$parentOrder || !$subscription || $subscription->current_payment_method !== 'stripe') {
315 fluent_cart_error_log('Stripe Webhook Error: Subscription Renewal - Order or Subscription not found.', 'Vendor Subscription ID: ' . $vendorSubscriptionId);
316 return false; // this is not our order
317 };
318 $paymentIntent = Arr::get($vendorInvoiceObject, 'payment_intent', null);
319 if (is_array($paymentIntent)) {
320 $paymentIntentId = Arr::get($paymentIntent, 'id', null);
321 } else {
322 $paymentIntentId = $paymentIntent;
323 }
324
325 if ($paymentIntent) {
326 $alreadyRecorded = OrderTransaction::query()
327 ->where('subscription_id', $subscription->id)
328 ->where('vendor_charge_id', $paymentIntentId)
329 ->where('status', '!=', Status::TRANSACTION_FAILED)
330 ->exists();
331
332 if ($alreadyRecorded) {
333 return null; // already recorded
334 }
335 }
336
337 $amountPaid = Arr::get($vendorInvoiceObject, 'amount_paid', 0);
338 $chargeCurrency = Arr::get($vendorInvoiceObject, 'currency', null);
339 if ($chargeCurrency && CurrenciesHelper::isZeroDecimal($chargeCurrency)) {
340 $amountPaid = $amountPaid * 100;
341 }
342
343 $transactionData = [
344 'payment_method' => 'stripe',
345 'total' => $amountPaid,
346 'vendor_charge_id' => $paymentIntentId
347 ];
348
349 $paymentIntent = (new API())->getStripeObject('payment_intents/' . $paymentIntentId, [
350 'expand' => ['latest_charge']
351 ], $parentOrder->mode);
352
353 if (!is_wp_error($paymentIntent)) {
354 $transactionData['card_last_4'] = Arr::get($paymentIntent, 'latest_charge.payment_method_details.card.last4', '');
355 $transactionData['card_brand'] = (string)Arr::get($paymentIntent, 'latest_charge.payment_method_details.card.brand', '');
356 $transactionData['payment_method_type'] = (string)Arr::get($paymentIntent, 'latest_charge.payment_method_details.type', '');
357
358 // The charge's own `created` is the settlement moment; without it the
359 // model hook would stamp the webhook-processing time, which drifts on
360 // delayed deliveries.
361 $chargeCreatedAt = (int)Arr::get($paymentIntent, 'latest_charge.created', 0);
362 if ($chargeCreatedAt) {
363 $transactionData['meta'] = array_merge($transactionData['meta'] ?? [], ['settled_at' => DateTime::anyTimeToGmt($chargeCreatedAt)->format('Y-m-d H:i:s')]);
364 }
365 } else {
366 $activePaymentMethod = $subscription->getMeta('active_payment_method', []);
367 if (!$activePaymentMethod || !is_array($activePaymentMethod)) {
368 $activePaymentMethod = [];
369 }
370 if ($activePaymentMethod) {
371 $transactionData['card_last_4'] = Arr::get($activePaymentMethod, 'details.last_4');
372 $transactionData['card_brand'] = (string)Arr::get($activePaymentMethod, 'details.brand');
373 $transactionData['payment_method_type'] = (string)Arr::get($activePaymentMethod, 'details.type');
374 }
375 }
376
377 $subscriptionUpdateData = array_filter([
378 'current_payment_method' => 'stripe'
379 ]);
380
381 $stripeSubscription = (new API())->getStripeObject('subscriptions/' . $vendorSubscriptionId, [
382 'expand' => ['latest_invoice']
383 ], $parentOrder->mode);
384
385 if (!is_wp_error($stripeSubscription)) {
386 $subscriptionUpdateData = StripeHelper::getSubscriptionUpdateData($stripeSubscription, $subscription);
387 }
388
389 $createdTransaction = SubscriptionService::recordRenewalPayment($transactionData, $subscription, $subscriptionUpdateData);
390
391 $subscription = Subscription::query()->find($subscription->id);
392
393 if ($subscription && $subscription->status === Status::SUBSCRIPTION_COMPLETED) {
394 if (!is_wp_error($stripeSubscription)) {
395 if ($stripeSubscription['status'] === 'active') {
396 $deleted = (new API)->deleteStripeObject('subscriptions/' . $vendorSubscriptionId, [], $parentOrder->mode);
397 if (is_wp_error($deleted)) {
398 fluent_cart_error_log('Stripe Subscription Deletion Error. Subscription ID: ' . $subscription->id, $deleted->get_error_message());
399 }
400 }
401 }
402 }
403
404 if ($createdTransaction) {
405 return $createdTransaction->order;
406 }
407
408 return null;
409 }
410
411 }
412