← 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 = []; |