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