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

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

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