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.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
← All changes | app/Modules/PaymentMethods/StripeGateway/Webhook/Webhook.php +106 -6 1.5.1 → 1.6.5 View file →
@@ -7,10 +7,12 @@
7 7 use FluentCart\App\Models\Order;
8 8 use FluentCart\App\Models\OrderTransaction;
9 9 use FluentCart\App\Models\Subscription;
10 10 use FluentCart\App\Modules\PaymentMethods\StripeGateway\API\API;
11 +use FluentCart\App\Modules\PaymentMethods\StripeGateway\Confirmations;
11 12 use FluentCart\App\Modules\PaymentMethods\StripeGateway\StripeHelper;
12 13 use FluentCart\App\Modules\Subscriptions\Services\SubscriptionService;
14 +use FluentCart\App\Services\DateTime\DateTime;
13 15 use FluentCart\Framework\Support\Arr;
14 16
15 17 class Webhook
16 18 {
@@ -30,15 +32,16 @@
30 32 'charge.succeeded',
31 33 'invoice.paid',
32 34 'customer.subscription.deleted',
33 35 'customer.subscription.updated',
34 - 'invoice.payment_failed'
36 + 'invoice.payment_failed',
37 + 'setup_intent.succeeded'
35 38 ];
36 39 }
37 40
38 41 public static function webhookInstruction(): array
39 42 {
40 - $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';
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';
41 44
42 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>';
43 46
44 47 /* translators: %1$s: "Add endpoint" link with icon */
@@ -78,17 +81,35 @@
78 81 'invoice.paid',
79 82 'invoice.payment_failed',
80 83 'customer.subscription.deleted',
81 84 'customer.subscription.updated',
85 + 'setup_intent.succeeded',
82 86 ],
83 87 ],
84 88 ];
85 89 }
86 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 +
87 105 public function processAndInsertOrderByEvent($event)
88 106 {
89 107 $eventType = $event->type;
108 + $eventLivemode = isset($event->livemode) ? (bool)$event->livemode : null;
90 109
110 + $this->unresolvedReason = '';
111 +
91 112 $metaDataEvents = [
92 113 'invoice.paid', // Reviewed for subscription cycle
93 114 'charge.refunded', // reviewed
94 115 'charge.succeeded', // reviewed
@@ -96,22 +117,40 @@
96 117 'charge.dispute.closed',
97 118 'checkout.session.completed',
98 119 'customer.subscription.deleted',
99 120 'customer.subscription.updated',
121 + 'setup_intent.succeeded', // recovers zero-payable system-subscription vaulting if the AJAX confirm is lost
122 + 'invoice.payment_failed',
100 123 ];
101 124
102 125 if (!in_array($eventType, $metaDataEvents)) {
126 + $this->unresolvedReason = __('Event type has no order resolver.', 'fluent-cart');
103 127 return false;
104 128 }
105 129
106 130 $vendorDataObject = $event->data->object;
107 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 +
108 147 if ($eventType == 'invoice.paid') {
109 148 //check if subscription billing_cycle invoice paid or failed
110 149 $isSubscriptionCycle = $vendorDataObject->billing_reason === 'subscription_cycle';
111 150 if ($isSubscriptionCycle) {
112 151 if ($eventType === 'invoice.paid') {
113 - $vendorDataObject = (new API())->getStripeObject('invoices/' . $vendorDataObject->id, ['expand' => ['payment_intent']]);
152 + $vendorDataObject = (new API())->getStripeObject('invoices/' . $vendorDataObject->id, ['expand' => ['payment_intent']], StripeHelper::modeFromLivemode($eventLivemode));
114 153 $createdOrder = $this->processSubscriptionRenewal($vendorDataObject);
115 154 if ($createdOrder) {
116 155 wp_send_json([
117 156 'message' => 'Subscription renewal processed successfully. Order ID: ' . $createdOrder->id,
@@ -118,12 +157,30 @@
118 157 ], 200);
119 158 }
120 159 }
121 160
161 + $this->unresolvedReason = __('Subscription renewal invoice resolved to no order.', 'fluent-cart');
122 162 return false;
123 163 }
124 164 }
125 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 +
126 183 if ($eventType === 'charge.refunded' || $eventType === 'charge.succeeded') {
127 184 $paymentIntent = $vendorDataObject->payment_intent;
128 185 $orderTransaction = OrderTransaction::query()->where('vendor_charge_id', $paymentIntent)
129 186 ->where('transaction_type', 'charge')
@@ -154,8 +211,10 @@
154 211 if ($subscription) {
155 212 $order = Order::where('id', $subscription->parent_order_id)->first();
156 213 if ($order) {
157 214 $order->current_subscription = $subscription;
215 + } else {
216 + $this->unresolvedReason = __('Subscription matched but its parent order is missing.', 'fluent-cart');
158 217 }
159 218 return $order;
160 219 }
161 220 }
@@ -167,8 +226,10 @@
167 226
168 227 if ($orderTransaction) {
169 228 return $orderTransaction->order;
170 229 }
230 +
231 + $this->unresolvedReason = __('No local transaction matches the disputed charge.', 'fluent-cart');
171 232 return null;
172 233 }
173 234
174 235 // Handle checkout.session.completed for hosted checkout
@@ -173,9 +234,15 @@
173 234
174 235 // Handle checkout.session.completed for hosted checkout
175 236 if ($eventType === 'checkout.session.completed') {
176 237 $sessionId = $vendorDataObject->id;
177 - 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;
178 245 }
179 246
180 247 $metaData = (array)$vendorDataObject->metadata;
181 248 $orderHash = Arr::get($metaData, 'fct_ref_id', false);
@@ -180,15 +247,29 @@
180 247 $metaData = (array)$vendorDataObject->metadata;
181 248 $orderHash = Arr::get($metaData, 'fct_ref_id', false);
182 249
183 250 if ($orderHash) {
184 - 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;
185 258 }
186 259
260 + $this->unresolvedReason = __('Event carries no reference to a local order.', 'fluent-cart');
187 261 return null;
188 262 }
189 263
190 - 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)
191 272 {
192 273 $subscription = null;
193 274 $parentOrder = null;
194 275
@@ -219,8 +300,18 @@
219 300 ->orderBy('id', 'DESC')
220 301 ->first();
221 302 }
222 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 +
223 314 if (!$parentOrder || !$subscription || $subscription->current_payment_method !== 'stripe') {
224 315 fluent_cart_error_log('Stripe Webhook Error: Subscription Renewal - Order or Subscription not found.', 'Vendor Subscription ID: ' . $vendorSubscriptionId);
225 316 return false; // this is not our order
226 317 };
@@ -234,8 +325,9 @@
234 325 if ($paymentIntent) {
235 326 $alreadyRecorded = OrderTransaction::query()
236 327 ->where('subscription_id', $subscription->id)
237 328 ->where('vendor_charge_id', $paymentIntentId)
329 + ->where('status', '!=', Status::TRANSACTION_FAILED)
238 330 ->exists();
239 331
240 332 if ($alreadyRecorded) {
241 333 return null; // already recorded
@@ -261,8 +353,16 @@
261 353 if (!is_wp_error($paymentIntent)) {
262 354 $transactionData['card_last_4'] = Arr::get($paymentIntent, 'latest_charge.payment_method_details.card.last4', '');
263 355 $transactionData['card_brand'] = (string)Arr::get($paymentIntent, 'latest_charge.payment_method_details.card.brand', '');
264 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 + }
265 365 } else {
266 366 $activePaymentMethod = $subscription->getMeta('active_payment_method', []);
267 367 if (!$activePaymentMethod || !is_array($activePaymentMethod)) {
268 368 $activePaymentMethod = [];