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 +151 -39 1.3.21 → 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 {
@@ -17,9 +19,9 @@
17 19 const WEBHOOK_ENDPOINT = '?fluent-cart=fct_payment_listener_ipn&method=stripe';
18 20
19 21 public static function getURL(): string
20 22 {
21 - return site_url() . self::WEBHOOK_ENDPOINT;
23 + return trailingslashit(site_url()) . self::WEBHOOK_ENDPOINT;
22 24 }
23 25
24 26 public static function getEvents(): array
25 27 {
@@ -30,53 +32,84 @@
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 - public static function webhookInstruction(): string
41 + public static function webhookInstruction(): array
39 42 {
40 - $webhook_url = static::getURL();
41 - return sprintf(
42 - '<div>
43 - <p><b>%1$s</b><code class="copyable-content">%2$s</code></p>
44 - <p>%3$s</p>
45 - <br>
46 - <h4>%4$s</h4>
47 - <br>
48 - <p>%5$s</p>
49 - <p class="fct_hide_on_test">%6$s <a href="https://dashboard.stripe.com/webhooks/create?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" target="_blank">%7$s</a></p>
50 - <p class="fct_hide_on_live">%6$s <a href="https://dashboard.stripe.com/test/webhooks/create?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" target="_blank">%7$s</a></p>
51 - <p>%8$s <code class="copyable-content">%2$s</code></p>
52 - <b>%9$s</b>
53 - checkout.session.completed, <br/>
54 - charge.refunded, <br/>
55 - charge.refund.updated, <br/>
56 - charge.succeeded, <br/>
57 - invoice.paid, <br/>
58 - invoice.payment_failed, <br/>
59 - customer.subscription.deleted, <br/>
60 - customer.subscription.updated, <br/>
61 - <br/>
62 - </div>',
63 - __('Webhook URL: ', 'fluent-cart'), // %1$s
64 - $webhook_url, // %2$s (reused)
65 - __('You should configure your Stripe webhooks to get all updates of your payments remotely.', 'fluent-cart'), // %3$s
66 - __('How to configure?', 'fluent-cart'), // %4$s
67 - __('In your Stripe account:', 'fluent-cart'), // %5$s
68 - __('Go to Developers > Webhooks >', 'fluent-cart'), // %6$s
69 - __('Add endpoint', 'fluent-cart'), // %7$s
70 - __('Enter The Webhook URL:', 'fluent-cart'), // %8$s
71 - __('Select these events:', 'fluent-cart') // %9$s
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 + )
72 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 + ];
73 89 }
74 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 +
75 105 public function processAndInsertOrderByEvent($event)
76 106 {
77 107 $eventType = $event->type;
108 + $eventLivemode = isset($event->livemode) ? (bool)$event->livemode : null;
78 109
110 + $this->unresolvedReason = '';
111 +
79 112 $metaDataEvents = [
80 113 'invoice.paid', // Reviewed for subscription cycle
81 114 'charge.refunded', // reviewed
82 115 'charge.succeeded', // reviewed
@@ -84,22 +117,40 @@
84 117 'charge.dispute.closed',
85 118 'checkout.session.completed',
86 119 'customer.subscription.deleted',
87 120 'customer.subscription.updated',
121 + 'setup_intent.succeeded', // recovers zero-payable system-subscription vaulting if the AJAX confirm is lost
122 + 'invoice.payment_failed',
88 123 ];
89 124
90 125 if (!in_array($eventType, $metaDataEvents)) {
126 + $this->unresolvedReason = __('Event type has no order resolver.', 'fluent-cart');
91 127 return false;
92 128 }
93 129
94 130 $vendorDataObject = $event->data->object;
95 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 +
96 147 if ($eventType == 'invoice.paid') {
97 148 //check if subscription billing_cycle invoice paid or failed
98 149 $isSubscriptionCycle = $vendorDataObject->billing_reason === 'subscription_cycle';
99 150 if ($isSubscriptionCycle) {
100 151 if ($eventType === 'invoice.paid') {
101 - $vendorDataObject = (new API())->getStripeObject('invoices/' . $vendorDataObject->id, ['expand' => ['payment_intent']]);
152 + $vendorDataObject = (new API())->getStripeObject('invoices/' . $vendorDataObject->id, ['expand' => ['payment_intent']], StripeHelper::modeFromLivemode($eventLivemode));
102 153 $createdOrder = $this->processSubscriptionRenewal($vendorDataObject);
103 154 if ($createdOrder) {
104 155 wp_send_json([
105 156 'message' => 'Subscription renewal processed successfully. Order ID: ' . $createdOrder->id,
@@ -106,12 +157,30 @@
106 157 ], 200);
107 158 }
108 159 }
109 160
161 + $this->unresolvedReason = __('Subscription renewal invoice resolved to no order.', 'fluent-cart');
110 162 return false;
111 163 }
112 164 }
113 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 +
114 183 if ($eventType === 'charge.refunded' || $eventType === 'charge.succeeded') {
115 184 $paymentIntent = $vendorDataObject->payment_intent;
116 185 $orderTransaction = OrderTransaction::query()->where('vendor_charge_id', $paymentIntent)
117 186 ->where('transaction_type', 'charge')
@@ -142,8 +211,10 @@
142 211 if ($subscription) {
143 212 $order = Order::where('id', $subscription->parent_order_id)->first();
144 213 if ($order) {
145 214 $order->current_subscription = $subscription;
215 + } else {
216 + $this->unresolvedReason = __('Subscription matched but its parent order is missing.', 'fluent-cart');
146 217 }
147 218 return $order;
148 219 }
149 220 }
@@ -155,8 +226,10 @@
155 226
156 227 if ($orderTransaction) {
157 228 return $orderTransaction->order;
158 229 }
230 +
231 + $this->unresolvedReason = __('No local transaction matches the disputed charge.', 'fluent-cart');
159 232 return null;
160 233 }
161 234
162 235 // Handle checkout.session.completed for hosted checkout
@@ -161,9 +234,15 @@
161 234
162 235 // Handle checkout.session.completed for hosted checkout
163 236 if ($eventType === 'checkout.session.completed') {
164 237 $sessionId = $vendorDataObject->id;
165 - 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;
166 245 }
167 246
168 247 $metaData = (array)$vendorDataObject->metadata;
169 248 $orderHash = Arr::get($metaData, 'fct_ref_id', false);
@@ -168,15 +247,29 @@
168 247 $metaData = (array)$vendorDataObject->metadata;
169 248 $orderHash = Arr::get($metaData, 'fct_ref_id', false);
170 249
171 250 if ($orderHash) {
172 - 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;
173 258 }
174 259
260 + $this->unresolvedReason = __('Event carries no reference to a local order.', 'fluent-cart');
175 261 return null;
176 262 }
177 263
178 - 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)
179 272 {
180 273 $subscription = null;
181 274 $parentOrder = null;
182 275
@@ -207,8 +300,18 @@
207 300 ->orderBy('id', 'DESC')
208 301 ->first();
209 302 }
210 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 +
211 314 if (!$parentOrder || !$subscription || $subscription->current_payment_method !== 'stripe') {
212 315 fluent_cart_error_log('Stripe Webhook Error: Subscription Renewal - Order or Subscription not found.', 'Vendor Subscription ID: ' . $vendorSubscriptionId);
213 316 return false; // this is not our order
214 317 };
@@ -222,8 +325,9 @@
222 325 if ($paymentIntent) {
223 326 $alreadyRecorded = OrderTransaction::query()
224 327 ->where('subscription_id', $subscription->id)
225 328 ->where('vendor_charge_id', $paymentIntentId)
329 + ->where('status', '!=', Status::TRANSACTION_FAILED)
226 330 ->exists();
227 331
228 332 if ($alreadyRecorded) {
229 333 return null; // already recorded
@@ -249,8 +353,16 @@
249 353 if (!is_wp_error($paymentIntent)) {
250 354 $transactionData['card_last_4'] = Arr::get($paymentIntent, 'latest_charge.payment_method_details.card.last4', '');
251 355 $transactionData['card_brand'] = (string)Arr::get($paymentIntent, 'latest_charge.payment_method_details.card.brand', '');
252 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 + }
253 365 } else {
254 366 $activePaymentMethod = $subscription->getMeta('active_payment_method', []);
255 367 if (!$activePaymentMethod || !is_array($activePaymentMethod)) {
256 368 $activePaymentMethod = [];