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
← All changes | app/Modules/PaymentMethods/StripeGateway/Webhook/Webhook.php +87 -5 1.6.0 → 1.6.5 View file →
@@ -10,8 +10,9 @@
10 10 use FluentCart\App\Modules\PaymentMethods\StripeGateway\API\API;
11 11 use FluentCart\App\Modules\PaymentMethods\StripeGateway\Confirmations;
12 12 use FluentCart\App\Modules\PaymentMethods\StripeGateway\StripeHelper;
13 13 use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService;
14 +use FluentCart\App\Services\DateTime\DateTime;
14 15 use FluentCart\Framework\Support\Arr;
15 16
16 17 class Webhook
17 18 {
@@ -86,12 +87,29 @@
86 87 ],
87 88 ];
88 89 }
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 +
90 105 public function processAndInsertOrderByEvent($event)
91 106 {
92 107 $eventType = $event->type;
108 + $eventLivemode = isset($event->livemode) ? (bool)$event->livemode : null;
93 109
110 + $this->unresolvedReason = '';
111 +
94 112 $metaDataEvents = [
95 113 'invoice.paid', // Reviewed for subscription cycle
96 114 'charge.refunded', // reviewed
97 115 'charge.succeeded', // reviewed
@@ -100,11 +118,13 @@
100 118 'checkout.session.completed',
101 119 'customer.subscription.deleted',
102 120 'customer.subscription.updated',
103 121 'setup_intent.succeeded', // recovers zero-payable system-subscription vaulting if the AJAX confirm is lost
122 + 'invoice.payment_failed',
104 123 ];
105 124
106 125 if (!in_array($eventType, $metaDataEvents)) {
126 + $this->unresolvedReason = __('Event type has no order resolver.', 'fluent-cart');
107 127 return false;
108 128 }
109 129
110 130 $vendorDataObject = $event->data->object;
@@ -111,9 +131,9 @@
111 131
112 132 if ($eventType === 'setup_intent.succeeded') {
113 133 $setupIntentId = Arr::get((array)$vendorDataObject, 'id');
114 134 if ($setupIntentId) {
115 - $result = (new Confirmations())->confirmSetupIntent($setupIntentId);
135 + $result = (new Confirmations())->confirmSetupIntent($setupIntentId, null, StripeHelper::modeFromLivemode($eventLivemode));
116 136 if (!is_wp_error($result)) {
117 137 wp_send_json([
118 138 'message' => 'Setup intent confirmed successfully.',
119 139 ], 200);
@@ -119,8 +139,9 @@
119 139 ], 200);
120 140 }
121 141 }
122 142
143 + $this->unresolvedReason = __('Setup intent could not be confirmed.', 'fluent-cart');
123 144 return false;
124 145 }
125 146
126 147 if ($eventType == 'invoice.paid') {
@@ -127,9 +148,9 @@
127 148 //check if subscription billing_cycle invoice paid or failed
128 149 $isSubscriptionCycle = $vendorDataObject->billing_reason === 'subscription_cycle';
129 150 if ($isSubscriptionCycle) {
130 151 if ($eventType === 'invoice.paid') {
131 - $vendorDataObject = (new API())->getStripeObject('invoices/' . $vendorDataObject->id, ['expand' => ['payment_intent']]);
152 + $vendorDataObject = (new API())->getStripeObject('invoices/' . $vendorDataObject->id, ['expand' => ['payment_intent']], StripeHelper::modeFromLivemode($eventLivemode));
132 153 $createdOrder = $this->processSubscriptionRenewal($vendorDataObject);
133 154 if ($createdOrder) {
134 155 wp_send_json([
135 156 'message' => 'Subscription renewal processed successfully. Order ID: ' . $createdOrder->id,
@@ -136,12 +157,30 @@
136 157 ], 200);
137 158 }
138 159 }
139 160
161 + $this->unresolvedReason = __('Subscription renewal invoice resolved to no order.', 'fluent-cart');
140 162 return false;
141 163 }
142 164 }
143 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 +
144 183 if ($eventType === 'charge.refunded' || $eventType === 'charge.succeeded') {
145 184 $paymentIntent = $vendorDataObject->payment_intent;
146 185 $orderTransaction = OrderTransaction::query()->where('vendor_charge_id', $paymentIntent)
147 186 ->where('transaction_type', 'charge')
@@ -172,8 +211,10 @@
172 211 if ($subscription) {
173 212 $order = Order::where('id', $subscription->parent_order_id)->first();
174 213 if ($order) {
175 214 $order->current_subscription = $subscription;
215 + } else {
216 + $this->unresolvedReason = __('Subscription matched but its parent order is missing.', 'fluent-cart');
176 217 }
177 218 return $order;
178 219 }
179 220 }
@@ -185,8 +226,10 @@
185 226
186 227 if ($orderTransaction) {
187 228 return $orderTransaction->order;
188 229 }
230 +
231 + $this->unresolvedReason = __('No local transaction matches the disputed charge.', 'fluent-cart');
189 232 return null;
190 233 }
191 234
192 235 // Handle checkout.session.completed for hosted checkout
@@ -191,9 +234,15 @@
191 234
192 235 // Handle checkout.session.completed for hosted checkout
193 236 if ($eventType === 'checkout.session.completed') {
194 237 $sessionId = $vendorDataObject->id;
195 - return StripeHelper::validateBySession($sessionId);
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;
196 245 }
197 246
198 247 $metaData = (array)$vendorDataObject->metadata;
199 248 $orderHash = Arr::get($metaData, 'fct_ref_id', false);
@@ -198,15 +247,29 @@
198 247 $metaData = (array)$vendorDataObject->metadata;
199 248 $orderHash = Arr::get($metaData, 'fct_ref_id', false);
200 249
201 250 if ($orderHash) {
202 - return Order::query()->where('uuid', $orderHash)->first();
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;
203 258 }
204 259
260 + $this->unresolvedReason = __('Event carries no reference to a local order.', 'fluent-cart');
205 261 return null;
206 262 }
207 263
208 - public function processSubscriptionRenewal($vendorInvoiceObject)
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)
209 272 {
210 273 $subscription = null;
211 274 $parentOrder = null;
212 275
@@ -237,8 +300,18 @@
237 300 ->orderBy('id', 'DESC')
238 301 ->first();
239 302 }
240 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 +
241 314 if (!$parentOrder || !$subscription || $subscription->current_payment_method !== 'stripe') {
242 315 fluent_cart_error_log('Stripe Webhook Error: Subscription Renewal - Order or Subscription not found.', 'Vendor Subscription ID: ' . $vendorSubscriptionId);
243 316 return false; // this is not our order
244 317 };
@@ -252,8 +325,9 @@
252 325 if ($paymentIntent) {
253 326 $alreadyRecorded = OrderTransaction::query()
254 327 ->where('subscription_id', $subscription->id)
255 328 ->where('vendor_charge_id', $paymentIntentId)
329 + ->where('status', '!=', Status::TRANSACTION_FAILED)
256 330 ->exists();
257 331
258 332 if ($alreadyRecorded) {
259 333 return null; // already recorded
@@ -279,8 +353,16 @@
279 353 if (!is_wp_error($paymentIntent)) {
280 354 $transactionData['card_last_4'] = Arr::get($paymentIntent, 'latest_charge.payment_method_details.card.last4', '');
281 355 $transactionData['card_brand'] = (string)Arr::get($paymentIntent, 'latest_charge.payment_method_details.card.brand', '');
282 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 + }
283 365 } else {
284 366 $activePaymentMethod = $subscription->getMeta('active_payment_method', []);
285 367 if (!$activePaymentMethod || !is_array($activePaymentMethod)) {
286 368 $activePaymentMethod = [];