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

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