'; /* translators: %1$s: "Add endpoint" link with icon */ $step = fn($class, $url) => \sprintf( '

%s

', $class, \sprintf( __('Click %1$s and paste the webhook URL above', 'fluent-cart'), \sprintf('%s %s', $url, __('Add endpoint', 'fluent-cart'), $svg) ) ); return [ 'title' => __('Webhook URL', 'fluent-cart'), 'webhook_url' => static::getURL(), 'description' => __('You should configure your Stripe webhooks to get all updates of your payments remotely.', 'fluent-cart'), 'steps' => [ 'title' => __('How to configure?', 'fluent-cart'), 'list' => [ 'live' => [ __('In your Stripe Dashboard, go to Developers → Webhooks', 'fluent-cart'), $step('fct_hide_on_test', \sprintf('https://dashboard.stripe.com/webhooks/create?events=%s', $events)), ], 'test' => [ __('In your Stripe Dashboard, go to Developers → Webhooks', 'fluent-cart'), $step('fct_hide_on_live', \sprintf('https://dashboard.stripe.com/test/webhooks/create?events=%s', $events)), ], ], ], 'events' => [ 'title' => __('Select these events', 'fluent-cart'), 'list' => [ 'checkout.session.completed', 'charge.refunded', 'charge.refund.updated', 'charge.succeeded', 'invoice.paid', 'invoice.payment_failed', 'customer.subscription.deleted', 'customer.subscription.updated', 'setup_intent.succeeded', ], ], ]; } /** * Why the last processAndInsertOrderByEvent() call resolved no order. The * caller answers the webhook with it, so "we have no resolver for this type" * is distinguishable from "resolved fine, but nothing local matches". * * @var string */ protected $unresolvedReason = ''; public function getUnresolvedReason() { return $this->unresolvedReason; } public function processAndInsertOrderByEvent($event) { $eventType = $event->type; $eventLivemode = isset($event->livemode) ? (bool)$event->livemode : null; $this->unresolvedReason = ''; $metaDataEvents = [ 'invoice.paid', // Reviewed for subscription cycle 'charge.refunded', // reviewed 'charge.succeeded', // reviewed 'charge.dispute.created', 'charge.dispute.closed', 'checkout.session.completed', 'customer.subscription.deleted', 'customer.subscription.updated', 'setup_intent.succeeded', // recovers zero-payable system-subscription vaulting if the AJAX confirm is lost 'invoice.payment_failed', ]; if (!in_array($eventType, $metaDataEvents)) { $this->unresolvedReason = __('Event type has no order resolver.', 'fluent-cart'); return false; } $vendorDataObject = $event->data->object; if ($eventType === 'setup_intent.succeeded') { $setupIntentId = Arr::get((array)$vendorDataObject, 'id'); if ($setupIntentId) { $result = (new Confirmations())->confirmSetupIntent($setupIntentId, null, StripeHelper::modeFromLivemode($eventLivemode)); if (!is_wp_error($result)) { wp_send_json([ 'message' => 'Setup intent confirmed successfully.', ], 200); } } $this->unresolvedReason = __('Setup intent could not be confirmed.', 'fluent-cart'); return false; } if ($eventType == 'invoice.paid') { //check if subscription billing_cycle invoice paid or failed $isSubscriptionCycle = $vendorDataObject->billing_reason === 'subscription_cycle'; if ($isSubscriptionCycle) { if ($eventType === 'invoice.paid') { $vendorDataObject = (new API())->getStripeObject('invoices/' . $vendorDataObject->id, ['expand' => ['payment_intent']], StripeHelper::modeFromLivemode($eventLivemode)); $createdOrder = $this->processSubscriptionRenewal($vendorDataObject); if ($createdOrder) { wp_send_json([ 'message' => 'Subscription renewal processed successfully. Order ID: ' . $createdOrder->id, ], 200); } } $this->unresolvedReason = __('Subscription renewal invoice resolved to no order.', 'fluent-cart'); return false; } } if ($eventType === 'invoice.payment_failed') { $isSubscriptionCycle = $vendorDataObject->billing_reason === 'subscription_cycle'; if ($isSubscriptionCycle) { $invoice = (new API())->getStripeObject('invoices/' . $vendorDataObject->id, [], StripeHelper::modeFromLivemode($eventLivemode)); if (!is_wp_error($invoice)) { list($subscription, $parentOrder) = $this->resolveSubscriptionAndOrder($invoice); if ($subscription && $parentOrder && $subscription->current_payment_method === 'stripe') { return $parentOrder; } } } $this->unresolvedReason = __('Subscription renewal-failure invoice resolved to no order.', 'fluent-cart'); return false; } if ($eventType === 'charge.refunded' || $eventType === 'charge.succeeded') { $paymentIntent = $vendorDataObject->payment_intent; $orderTransaction = OrderTransaction::query()->where('vendor_charge_id', $paymentIntent) ->where('transaction_type', 'charge') ->first(); if (!$orderTransaction) { $orderTransaction = apply_filters('fluent_cart/stripe/fallback_order_transaction', null, $vendorDataObject); if (!$orderTransaction || $orderTransaction instanceof OrderTransaction) { $orderTransaction = null; } } if ($orderTransaction) { $order = Order::where('id', $orderTransaction->order_id)->first(); $order->current_transaction = $orderTransaction; return $order; } } if ($eventType === 'customer.subscription.deleted' || $eventType === 'customer.subscription.updated') { $vendorSubscriptionId = $vendorDataObject->id; $subscription = Subscription::query() ->where('vendor_subscription_id', $vendorSubscriptionId) ->first(); if ($subscription) { $order = Order::where('id', $subscription->parent_order_id)->first(); if ($order) { $order->current_subscription = $subscription; } else { $this->unresolvedReason = __('Subscription matched but its parent order is missing.', 'fluent-cart'); } return $order; } } if ($eventType === 'charge.dispute.created' || $eventType === 'charge.dispute.closed') { $paymentIntent = $vendorDataObject->payment_intent; $orderTransaction = OrderTransaction::query()->where('vendor_charge_id', $paymentIntent) ->first(); if ($orderTransaction) { return $orderTransaction->order; } $this->unresolvedReason = __('No local transaction matches the disputed charge.', 'fluent-cart'); return null; } // Handle checkout.session.completed for hosted checkout if ($eventType === 'checkout.session.completed') { $sessionId = $vendorDataObject->id; $sessionOrder = StripeHelper::validateBySession($sessionId); if (!$sessionOrder) { $this->unresolvedReason = __('Checkout session does not match a local order.', 'fluent-cart'); } return $sessionOrder; } $metaData = (array)$vendorDataObject->metadata; $orderHash = Arr::get($metaData, 'fct_ref_id', false); if ($orderHash) { $referencedOrder = Order::query()->where('uuid', $orderHash)->first(); if (!$referencedOrder) { $this->unresolvedReason = __('Event references an order that does not exist here.', 'fluent-cart'); } return $referencedOrder; } $this->unresolvedReason = __('Event carries no reference to a local order.', 'fluent-cart'); return null; } /** * Resolve the local Subscription + parent Order for a Stripe invoice payload, * shared by renewal-success (invoice.paid) and renewal-failure * (invoice.payment_failed) handling. * * @return array{0: Subscription|null, 1: Order|null} */ protected function resolveSubscriptionAndOrder($vendorInvoiceObject) { $subscription = null; $parentOrder = null; $vendorSubscriptionId = Arr::get($vendorInvoiceObject, 'subscription', null) ?: (Arr::get($vendorInvoiceObject, 'parent.subscription_details.subscription', null) ?? null); if ($vendorSubscriptionId) { $subscription = Subscription::query()->where('vendor_subscription_id', $vendorSubscriptionId) ->orderBy('id', 'DESC') ->first(); } if ($subscription) { $parentOrder = Order::query()->where('id', $subscription->parent_order_id)->first(); } if (!$parentOrder) { // let's try to find from the meta ref id $refId = Arr::get($vendorInvoiceObject, 'subscription_details.metadata.fct_ref_id', null); if ($refId) { $parentOrder = Order::query()->where('uuid', $refId)->first(); } } if ($parentOrder && !$subscription) { $subscription = Subscription::query() ->where('parent_order_id', $parentOrder->id) ->orderBy('id', 'DESC') ->first(); } return [$subscription, $parentOrder]; } public function processSubscriptionRenewal($vendorInvoiceObject) { list($subscription, $parentOrder) = $this->resolveSubscriptionAndOrder($vendorInvoiceObject); $vendorSubscriptionId = Arr::get($vendorInvoiceObject, 'subscription', null) ?: (Arr::get($vendorInvoiceObject, 'parent.subscription_details.subscription', null) ?? null); if (!$parentOrder || !$subscription || $subscription->current_payment_method !== 'stripe') { fluent_cart_error_log('Stripe Webhook Error: Subscription Renewal - Order or Subscription not found.', 'Vendor Subscription ID: ' . $vendorSubscriptionId); return false; // this is not our order }; $paymentIntent = Arr::get($vendorInvoiceObject, 'payment_intent', null); if (is_array($paymentIntent)) { $paymentIntentId = Arr::get($paymentIntent, 'id', null); } else { $paymentIntentId = $paymentIntent; } if ($paymentIntent) { $alreadyRecorded = OrderTransaction::query() ->where('subscription_id', $subscription->id) ->where('vendor_charge_id', $paymentIntentId) ->where('status', '!=', Status::TRANSACTION_FAILED) ->exists(); if ($alreadyRecorded) { return null; // already recorded } } $amountPaid = Arr::get($vendorInvoiceObject, 'amount_paid', 0); $chargeCurrency = Arr::get($vendorInvoiceObject, 'currency', null); if ($chargeCurrency && CurrenciesHelper::isZeroDecimal($chargeCurrency)) { $amountPaid = $amountPaid * 100; } $transactionData = [ 'payment_method' => 'stripe', 'total' => $amountPaid, 'vendor_charge_id' => $paymentIntentId ]; $paymentIntent = (new API())->getStripeObject('payment_intents/' . $paymentIntentId, [ 'expand' => ['latest_charge'] ], $parentOrder->mode); if (!is_wp_error($paymentIntent)) { $transactionData['card_last_4'] = Arr::get($paymentIntent, 'latest_charge.payment_method_details.card.last4', ''); $transactionData['card_brand'] = (string)Arr::get($paymentIntent, 'latest_charge.payment_method_details.card.brand', ''); $transactionData['payment_method_type'] = (string)Arr::get($paymentIntent, 'latest_charge.payment_method_details.type', ''); // The charge's own `created` is the settlement moment; without it the // model hook would stamp the webhook-processing time, which drifts on // delayed deliveries. $chargeCreatedAt = (int)Arr::get($paymentIntent, 'latest_charge.created', 0); if ($chargeCreatedAt) { $transactionData['meta'] = array_merge($transactionData['meta'] ?? [], ['settled_at' => DateTime::anyTimeToGmt($chargeCreatedAt)->format('Y-m-d H:i:s')]); } } else { $activePaymentMethod = $subscription->getMeta('active_payment_method', []); if (!$activePaymentMethod || !is_array($activePaymentMethod)) { $activePaymentMethod = []; } if ($activePaymentMethod) { $transactionData['card_last_4'] = Arr::get($activePaymentMethod, 'details.last_4'); $transactionData['card_brand'] = (string)Arr::get($activePaymentMethod, 'details.brand'); $transactionData['payment_method_type'] = (string)Arr::get($activePaymentMethod, 'details.type'); } } $subscriptionUpdateData = array_filter([ 'current_payment_method' => 'stripe' ]); $stripeSubscription = (new API())->getStripeObject('subscriptions/' . $vendorSubscriptionId, [ 'expand' => ['latest_invoice'] ], $parentOrder->mode); if (!is_wp_error($stripeSubscription)) { $subscriptionUpdateData = StripeHelper::getSubscriptionUpdateData($stripeSubscription, $subscription); } $createdTransaction = SubscriptionService::recordRenewalPayment($transactionData, $subscription, $subscriptionUpdateData); $subscription = Subscription::query()->find($subscription->id); if ($subscription && $subscription->status === Status::SUBSCRIPTION_COMPLETED) { if (!is_wp_error($stripeSubscription)) { if ($stripeSubscription['status'] === 'active') { $deleted = (new API)->deleteStripeObject('subscriptions/' . $vendorSubscriptionId, [], $parentOrder->mode); if (is_wp_error($deleted)) { fluent_cart_error_log('Stripe Subscription Deletion Error. Subscription ID: ' . $subscription->id, $deleted->get_error_message()); } } } } if ($createdTransaction) { return $createdTransaction->order; } return null; } }