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