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.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 / Webhook.php

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

411 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\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
109 $this->unresolvedReason = '';
110
111 $metaDataEvents = [
112 'invoice.paid', // Reviewed for subscription cycle
113 'charge.refunded', // reviewed
114 'charge.succeeded', // reviewed
115 'charge.dispute.created',
116 'charge.dispute.closed',
117 'checkout.session.completed',
118 'customer.subscription.deleted',
119 'customer.subscription.updated',
120 'setup_intent.succeeded', // recovers zero-payable system-subscription vaulting if the AJAX confirm is lost
121 'invoice.payment_failed',
122 ];
123
124 if (!in_array($eventType, $metaDataEvents)) {
125 $this->unresolvedReason = __('Event type has no order resolver.', 'fluent-cart');
126 return false;
127 }
128
129 $vendorDataObject = $event->data->object;
130
131 if ($eventType === 'setup_intent.succeeded') {
132 $setupIntentId = Arr::get((array)$vendorDataObject, 'id');
133 if ($setupIntentId) {
134 $result = (new Confirmations())->confirmSetupIntent($setupIntentId);
135 if (!is_wp_error($result)) {
136 wp_send_json([
137 'message' => 'Setup intent confirmed successfully.',
138 ], 200);
139 }
140 }
141
142 $this->unresolvedReason = __('Setup intent could not be confirmed.', 'fluent-cart');
143 return false;
144 }
145
146 if ($eventType == 'invoice.paid') {
147 //check if subscription billing_cycle invoice paid or failed
148 $isSubscriptionCycle = $vendorDataObject->billing_reason === 'subscription_cycle';
149 if ($isSubscriptionCycle) {
150 if ($eventType === 'invoice.paid') {
151 $vendorDataObject = (new API())->getStripeObject('invoices/' . $vendorDataObject->id, ['expand' => ['payment_intent']]);
152 $createdOrder = $this->processSubscriptionRenewal($vendorDataObject);
153 if ($createdOrder) {
154 wp_send_json([
155 'message' => 'Subscription renewal processed successfully. Order ID: ' . $createdOrder->id,
156 ], 200);
157 }
158 }
159
160 $this->unresolvedReason = __('Subscription renewal invoice resolved to no order.', 'fluent-cart');
161 return false;
162 }
163 }
164
165 if ($eventType === 'invoice.payment_failed') {
166 $isSubscriptionCycle = $vendorDataObject->billing_reason === 'subscription_cycle';
167 if ($isSubscriptionCycle) {
168 $invoice = (new API())->getStripeObject('invoices/' . $vendorDataObject->id);
169 if (!is_wp_error($invoice)) {
170 list($subscription, $parentOrder) = $this->resolveSubscriptionAndOrder($invoice);
171
172 if ($subscription && $parentOrder && $subscription->current_payment_method === 'stripe') {
173 return $parentOrder;
174 }
175 }
176 }
177
178 $this->unresolvedReason = __('Subscription renewal-failure invoice resolved to no order.', 'fluent-cart');
179 return false;
180 }
181
182 if ($eventType === 'charge.refunded' || $eventType === 'charge.succeeded') {
183 $paymentIntent = $vendorDataObject->payment_intent;
184 $orderTransaction = OrderTransaction::query()->where('vendor_charge_id', $paymentIntent)
185 ->where('transaction_type', 'charge')
186 ->first();
187
188 if (!$orderTransaction) {
189 $orderTransaction = apply_filters('fluent_cart/stripe/fallback_order_transaction', null, $vendorDataObject);
190 if (!$orderTransaction || $orderTransaction instanceof OrderTransaction) {
191 $orderTransaction = null;
192 }
193 }
194
195 if ($orderTransaction) {
196 $order = Order::where('id', $orderTransaction->order_id)->first();
197 $order->current_transaction = $orderTransaction;
198 return $order;
199 }
200 }
201
202 if ($eventType === 'customer.subscription.deleted' || $eventType === 'customer.subscription.updated') {
203
204 $vendorSubscriptionId = $vendorDataObject->id;
205
206 $subscription = Subscription::query()
207 ->where('vendor_subscription_id', $vendorSubscriptionId)
208 ->first();
209
210 if ($subscription) {
211 $order = Order::where('id', $subscription->parent_order_id)->first();
212 if ($order) {
213 $order->current_subscription = $subscription;
214 } else {
215 $this->unresolvedReason = __('Subscription matched but its parent order is missing.', 'fluent-cart');
216 }
217 return $order;
218 }
219 }
220
221 if ($eventType === 'charge.dispute.created' || $eventType === 'charge.dispute.closed') {
222 $paymentIntent = $vendorDataObject->payment_intent;
223 $orderTransaction = OrderTransaction::query()->where('vendor_charge_id', $paymentIntent)
224 ->first();
225
226 if ($orderTransaction) {
227 return $orderTransaction->order;
228 }
229
230 $this->unresolvedReason = __('No local transaction matches the disputed charge.', 'fluent-cart');
231 return null;
232 }
233
234 // Handle checkout.session.completed for hosted checkout
235 if ($eventType === 'checkout.session.completed') {
236 $sessionId = $vendorDataObject->id;
237 $sessionOrder = StripeHelper::validateBySession($sessionId);
238
239 if (!$sessionOrder) {
240 $this->unresolvedReason = __('Checkout session does not match a local order.', 'fluent-cart');
241 }
242
243 return $sessionOrder;
244 }
245
246 $metaData = (array)$vendorDataObject->metadata;
247 $orderHash = Arr::get($metaData, 'fct_ref_id', false);
248
249 if ($orderHash) {
250 $referencedOrder = Order::query()->where('uuid', $orderHash)->first();
251
252 if (!$referencedOrder) {
253 $this->unresolvedReason = __('Event references an order that does not exist here.', 'fluent-cart');
254 }
255
256 return $referencedOrder;
257 }
258
259 $this->unresolvedReason = __('Event carries no reference to a local order.', 'fluent-cart');
260 return null;
261 }
262
263 /**
264 * Resolve the local Subscription + parent Order for a Stripe invoice payload,
265 * shared by renewal-success (invoice.paid) and renewal-failure
266 * (invoice.payment_failed) handling.
267 *
268 * @return array{0: Subscription|null, 1: Order|null}
269 */
270 protected function resolveSubscriptionAndOrder($vendorInvoiceObject)
271 {
272 $subscription = null;
273 $parentOrder = null;
274
275 $vendorSubscriptionId = Arr::get($vendorInvoiceObject, 'subscription', null)
276 ?: (Arr::get($vendorInvoiceObject, 'parent.subscription_details.subscription', null) ?? null);
277
278 if ($vendorSubscriptionId) {
279 $subscription = Subscription::query()->where('vendor_subscription_id', $vendorSubscriptionId)
280 ->orderBy('id', 'DESC')
281 ->first();
282 }
283
284 if ($subscription) {
285 $parentOrder = Order::query()->where('id', $subscription->parent_order_id)->first();
286 }
287
288 if (!$parentOrder) {
289 // let's try to find from the meta ref id
290 $refId = Arr::get($vendorInvoiceObject, 'subscription_details.metadata.fct_ref_id', null);
291 if ($refId) {
292 $parentOrder = Order::query()->where('uuid', $refId)->first();
293 }
294 }
295
296 if ($parentOrder && !$subscription) {
297 $subscription = Subscription::query()
298 ->where('parent_order_id', $parentOrder->id)
299 ->orderBy('id', 'DESC')
300 ->first();
301 }
302
303 return [$subscription, $parentOrder];
304 }
305
306 public function processSubscriptionRenewal($vendorInvoiceObject)
307 {
308 list($subscription, $parentOrder) = $this->resolveSubscriptionAndOrder($vendorInvoiceObject);
309
310 $vendorSubscriptionId = Arr::get($vendorInvoiceObject, 'subscription', null)
311 ?: (Arr::get($vendorInvoiceObject, 'parent.subscription_details.subscription', null) ?? null);
312
313 if (!$parentOrder || !$subscription || $subscription->current_payment_method !== 'stripe') {
314 fluent_cart_error_log('Stripe Webhook Error: Subscription Renewal - Order or Subscription not found.', 'Vendor Subscription ID: ' . $vendorSubscriptionId);
315 return false; // this is not our order
316 };
317 $paymentIntent = Arr::get($vendorInvoiceObject, 'payment_intent', null);
318 if (is_array($paymentIntent)) {
319 $paymentIntentId = Arr::get($paymentIntent, 'id', null);
320 } else {
321 $paymentIntentId = $paymentIntent;
322 }
323
324 if ($paymentIntent) {
325 $alreadyRecorded = OrderTransaction::query()
326 ->where('subscription_id', $subscription->id)
327 ->where('vendor_charge_id', $paymentIntentId)
328 ->where('status', '!=', Status::TRANSACTION_FAILED)
329 ->exists();
330
331 if ($alreadyRecorded) {
332 return null; // already recorded
333 }
334 }
335
336 $amountPaid = Arr::get($vendorInvoiceObject, 'amount_paid', 0);
337 $chargeCurrency = Arr::get($vendorInvoiceObject, 'currency', null);
338 if ($chargeCurrency && CurrenciesHelper::isZeroDecimal($chargeCurrency)) {
339 $amountPaid = $amountPaid * 100;
340 }
341
342 $transactionData = [
343 'payment_method' => 'stripe',
344 'total' => $amountPaid,
345 'vendor_charge_id' => $paymentIntentId
346 ];
347
348 $paymentIntent = (new API())->getStripeObject('payment_intents/' . $paymentIntentId, [
349 'expand' => ['latest_charge']
350 ], $parentOrder->mode);
351
352 if (!is_wp_error($paymentIntent)) {
353 $transactionData['card_last_4'] = Arr::get($paymentIntent, 'latest_charge.payment_method_details.card.last4', '');
354 $transactionData['card_brand'] = (string)Arr::get($paymentIntent, 'latest_charge.payment_method_details.card.brand', '');
355 $transactionData['payment_method_type'] = (string)Arr::get($paymentIntent, 'latest_charge.payment_method_details.type', '');
356
357 // The charge's own `created` is the settlement moment; without it the
358 // model hook would stamp the webhook-processing time, which drifts on
359 // delayed deliveries.
360 $chargeCreatedAt = (int)Arr::get($paymentIntent, 'latest_charge.created', 0);
361 if ($chargeCreatedAt) {
362 $transactionData['meta'] = array_merge($transactionData['meta'] ?? [], ['settled_at' => DateTime::anyTimeToGmt($chargeCreatedAt)->format('Y-m-d H:i:s')]);
363 }
364 } else {
365 $activePaymentMethod = $subscription->getMeta('active_payment_method', []);
366 if (!$activePaymentMethod || !is_array($activePaymentMethod)) {
367 $activePaymentMethod = [];
368 }
369 if ($activePaymentMethod) {
370 $transactionData['card_last_4'] = Arr::get($activePaymentMethod, 'details.last_4');
371 $transactionData['card_brand'] = (string)Arr::get($activePaymentMethod, 'details.brand');
372 $transactionData['payment_method_type'] = (string)Arr::get($activePaymentMethod, 'details.type');
373 }
374 }
375
376 $subscriptionUpdateData = array_filter([
377 'current_payment_method' => 'stripe'
378 ]);
379
380 $stripeSubscription = (new API())->getStripeObject('subscriptions/' . $vendorSubscriptionId, [
381 'expand' => ['latest_invoice']
382 ], $parentOrder->mode);
383
384 if (!is_wp_error($stripeSubscription)) {
385 $subscriptionUpdateData = StripeHelper::getSubscriptionUpdateData($stripeSubscription, $subscription);
386 }
387
388 $createdTransaction = SubscriptionService::recordRenewalPayment($transactionData, $subscription, $subscriptionUpdateData);
389
390 $subscription = Subscription::query()->find($subscription->id);
391
392 if ($subscription && $subscription->status === Status::SUBSCRIPTION_COMPLETED) {
393 if (!is_wp_error($stripeSubscription)) {
394 if ($stripeSubscription['status'] === 'active') {
395 $deleted = (new API)->deleteStripeObject('subscriptions/' . $vendorSubscriptionId, [], $parentOrder->mode);
396 if (is_wp_error($deleted)) {
397 fluent_cart_error_log('Stripe Subscription Deletion Error. Subscription ID: ' . $subscription->id, $deleted->get_error_message());
398 }
399 }
400 }
401 }
402
403 if ($createdTransaction) {
404 return $createdTransaction->order;
405 }
406
407 return null;
408 }
409
410 }
411