Skip to content

Commit f1d8a64

Browse files
committed
[0007916]: Two further adjustments to apply the bug scenario.
1 parent 6d162fe commit f1d8a64

3 files changed

Lines changed: 33 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1313
- Fix NoArticleException on product detail page when an out-of-stock variant (configured as "offline when sold out") is still in the basket. The installment banner template called `Basket::getArtStockInBasket()` which internally uses `getArticle(true)` and throws for offline articles. Simplified the banner amount calculation to always add the current product price to the basket total, removing the unnecessary basket iteration. Removed now unused `hasProductVariantInBasket()` method.
1414
- Fix module activation error after upgrade from <= v2.6.1: the old `Service\Logger.php` remained in `source/modules/` because composer does not remove deleted files on copy-based installs. Symfony's resource scanner tried to register the orphaned class and failed. Added an `exclude` for `src/Service/Logger.php` in `services.yaml`.
1515
- [0007916](https://bugs.oxid-esales.com/view.php?id=7916): Fix orphaned oscpaypal_order rows with OSCPAYPALSTATUS=COMPLETED and empty OXORDERID. When `sess_challenge` was cleared before `captureOrder()` completed (e.g. by a concurrent cancel request), the capture flow proceeded and created tracking records that could not be matched by webhook processing. Added defense-in-depth validation at four levels: `AjaxPaymentController::captureOrder()` now aborts if the shop order cannot be resolved, `PayPalOrderCompletedSubscriber` rejects events with empty shopOrderId, `Payment::trackPayPalOrder()` throws on empty shopOrderId, and `OrderRepository::paypalOrderByOrderIdAndPayPalId()` refuses to create new records with empty shopOrderId.
16+
- [0007916](https://bugs.oxid-esales.com/view.php?id=7916): Fix root cause of `sess_challenge` being cleared during active capture. `Payment::removeTemporaryOrder()` unconditionally deleted `sess_challenge` even when the cancel was blocked because PayPal had already approved/captured the payment. Now `sess_challenge` is only deleted when the cancel actually succeeds, keeping the session intact for the concurrent capture flow.
17+
- [0007916](https://bugs.oxid-esales.com/view.php?id=7916): Add database fallback for shop order resolution in `AjaxPaymentController::captureOrder()`. If `sess_challenge` is missing, the shop order is now resolved via the persisted `oscpaypal_order` relationship using the PayPal order ID. Additionally rejects cancelled (storno) orders to prevent tracking against invalidated shop orders.
1618
- [0007917](https://bugs.oxid-esales.com/view.php?id=7917): Fix tracking carrier country not restored on page reload. When a carrier from the "global" country group was saved, the country dropdown was reset to the order's shipping/billing country instead. Added `getCountryCodeByCarrierKey()` to `PayPalTrackingCarrierList` and `getEffectiveTrackingCountryCode()` to `OrderMain` to resolve the saved carrier's country and pre-select it in the dropdown.
1719

1820
## [2.8.1] - 2026-03-19

src/Controller/AjaxPaymentController.php

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
use OxidSolutionCatalysts\PayPalApi\Model\Orders\OrderCaptureRequest;
3939
use OxidSolutionCatalysts\PayPalApi\Model\Orders\OrderRequest;
4040
use Psr\Log\LoggerInterface;
41+
use OxidSolutionCatalysts\PayPal\Exception\NotFound;
4142
use OxidSolutionCatalysts\PayPal\Event\PayPalOrderCompletedEvent;
4243

4344
class AjaxPaymentController extends BaseController
@@ -267,16 +268,32 @@ public function captureOrder(): void
267268
}
268269
$shopOrderId = $this->orderRepository->fetchCurrentShopOrderId();
269270
$order = $this->orderRepository->fetchCurrentShopOrder();
271+
272+
// If session-based resolution failed (e.g. sess_challenge was cleared
273+
// by a concurrent cancel request), fall back to the persisted
274+
// relationship in oscpaypal_order via the PayPal order ID.
275+
if (empty($shopOrderId) || !$order->isLoaded()) {
276+
try {
277+
$order = $this->orderRepository->getShopOrderByPayPalOrderId($payPalOrderId);
278+
$shopOrderId = (string)$order->getId();
279+
$this->logger->log(
280+
'info',
281+
'PayPal captureOrder: resolved shop order from DB fallback (sess_challenge was missing)',
282+
['payPalOrderId' => $payPalOrderId, 'shopOrderId' => $shopOrderId]
283+
);
284+
} catch (NotFound $e) {
285+
// Neither session nor DB could resolve the shop order
286+
}
287+
}
288+
270289
$basket = Registry::getSession()->getBasket();
271290
$user = $basket->getUser();
272291

273-
// Validate that a valid shop order could be resolved from the session.
274-
// If sess_challenge was cleared (e.g. by a concurrent cancel request),
275-
// we must not proceed with tracking an orphaned PayPal capture.
276-
if (empty($shopOrderId) || !$order->isLoaded()) {
292+
// Validate that a valid, non-cancelled shop order could be resolved.
293+
if (empty($shopOrderId) || !$order->isLoaded() || $order->getFieldData('oxstorno') == 1) {
277294
$this->logger->log(
278295
'error',
279-
'PayPal captureOrder: cannot resolve shop order from session (sess_challenge missing or order not loaded)',
296+
'PayPal captureOrder: cannot resolve valid shop order (sess_challenge missing, DB fallback failed, or order cancelled)',
280297
['payPalOrderId' => $payPalOrderId, 'shopOrderId' => $shopOrderId]
281298
);
282299
$this->outputJson([

src/Service/Payment.php

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -471,12 +471,12 @@ public function removeTemporaryOrder(?string $orderId = ''): bool
471471
// because PayPal already approved/captured the payment.
472472
$cancelBlocked = !$deleted && $orderModel->getFieldData('oxstorno') != 1;
473473
}
474-
$this->eshopSession->deleteVariable('sess_challenge');
475-
476-
// When cancel was blocked (PayPal already approved/captured), clean up
477-
// the PayPal session so the customer can start a fresh order without
478-
// being stuck on the old PayPal order ID.
479474
if ($cancelBlocked) {
475+
// Cancel was blocked because PayPal already approved/captured the
476+
// payment. Keep sess_challenge intact so the capture flow can still
477+
// resolve the shop order from the session.
478+
// Clean up the PayPal session so the customer can start a fresh
479+
// order without being stuck on the old PayPal order ID.
480480
PayPalSession::unsetPayPalSession(false);
481481
// Clear the basket's reference to the old order so that a subsequent
482482
// PayPal order creation (e.g. via ProxyController) does not map
@@ -485,6 +485,10 @@ public function removeTemporaryOrder(?string $orderId = ''): bool
485485
if ($basket) {
486486
$basket->setOrderId(null);
487487
}
488+
} else {
489+
// Only delete sess_challenge when the cancel was not blocked,
490+
// i.e. the order was actually cancelled or never existed.
491+
$this->eshopSession->deleteVariable('sess_challenge');
488492
}
489493

490494
return $cancelBlocked;

0 commit comments

Comments
 (0)