Skip to content

Commit 5746ec6

Browse files
committed
Change order: Resolve the shop order BEFORE calling the PayPal capture API.
1 parent d7f6bda commit 5746ec6

3 files changed

Lines changed: 41 additions & 34 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
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.
1616
- [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.
1717
- [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.
18+
- [0007916](https://bugs.oxid-esales.com/view.php?id=7916): Fix PayPal capture executed before storno check in `AjaxPaymentController::captureOrder()`. The PayPal capture API call was made before validating whether the shop order had been cancelled, allowing funds to be captured for stornoed orders. Moved order resolution and storno check before the `capturePaymentForOrder()` call so that cancelled orders are rejected without contacting the PayPal API.
1819
- Fix duplicate order creation for stock-1 articles during PayPal checkout. After the AJAX `captureOrder()` flow completed, `PayPalOrderCompletedSubscriber` cleaned up the PayPal session (`PayPalSession::unsetPayPalSession()`). When the browser then redirected to the order confirmation page, `Order::finalizeOrder()` was called again. Because the PayPal session was already cleared, `isOrderExecutionInProgress()` returned false and the webhook-wait guard did not trigger — causing `parent::finalizeOrder()` to create a second shop order that failed stock validation. Added a guard in `Order::finalizeOrder()` that detects already-paid orders (via `isOrderPaid()` + `oxtransid`) and returns early without creating a duplicate.
1920
- [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.
2021

metadata.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969
'en' => 'Use of the online payment service from PayPal. Documentation: <a href="https://docs.oxid-esales.com/modules/paypal-checkout/en/latest/" target="_blank">PayPal Checkout</a>'
7070
],
7171
'thumbnail' => 'out/img/paypal.png',
72-
'version' => '2.8.2-rc.3',
72+
'version' => '2.8.2-rc.4',
7373
'author' => 'OXID eSales AG',
7474
'url' => 'https://www.oxid-esales.com',
7575
'email' => 'info@oxid-esales.com',

src/Controller/AjaxPaymentController.php

Lines changed: 39 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -236,36 +236,9 @@ public function captureOrder(): void
236236
'paymentStatus' => 'error'
237237
];
238238

239-
try {
240-
//Verify 3D result if ACDC payment
241-
if (!$scaValidator->verify3D($paymentId)) {
242-
$this->outputJson([
243-
'status' => 'error',
244-
'message' => $language->translateString('OSC_PAYPAL_3DSECURITY_ERROR')
245-
]);
246-
}
247-
248-
$capturePaymentForOrder = $orderService->capturePaymentForOrder(
249-
'',
250-
$payPalOrderId,
251-
$request,
252-
'',
253-
Constants::PAYPAL_PARTNER_ATTRIBUTION_ID_PPCP
254-
);
255-
$capturePaymentForOrder->intent = OrderRequest::INTENT_CAPTURE;
256-
} catch (ApiException $exception) {
257-
$issue = $exception->getErrorIssue();
258-
$translatedErrorMessage = $language->translateString(
259-
'OSC_PAYPAL_' . $issue,
260-
(int)$language->getBaseLanguage(),
261-
false
262-
);
263-
264-
$this->outputJson([
265-
'status' => 'error',
266-
'message' => $translatedErrorMessage
267-
]);
268-
}
239+
// Resolve the shop order BEFORE calling the PayPal capture API.
240+
// This prevents capturing funds for an order that was cancelled
241+
// between createOrder and onApprove (race condition).
269242
$shopOrderId = $this->orderRepository->fetchCurrentShopOrderId();
270243
$order = $this->orderRepository->fetchCurrentShopOrder();
271244

@@ -286,10 +259,9 @@ public function captureOrder(): void
286259
}
287260
}
288261

289-
$basket = Registry::getSession()->getBasket();
290-
$user = $basket->getUser();
291-
292262
// Validate that a valid, non-cancelled shop order could be resolved.
263+
// This check MUST happen before the PayPal capture API call to prevent
264+
// capturing funds for a cancelled order.
293265
if (empty($shopOrderId) || !$order->isLoaded() || $order->getFieldData('oxstorno') == 1) {
294266
$this->logger->log(
295267
'error',
@@ -303,6 +275,40 @@ public function captureOrder(): void
303275
return;
304276
}
305277

278+
try {
279+
//Verify 3D result if ACDC payment
280+
if (!$scaValidator->verify3D($paymentId)) {
281+
$this->outputJson([
282+
'status' => 'error',
283+
'message' => $language->translateString('OSC_PAYPAL_3DSECURITY_ERROR')
284+
]);
285+
}
286+
287+
$capturePaymentForOrder = $orderService->capturePaymentForOrder(
288+
'',
289+
$payPalOrderId,
290+
$request,
291+
'',
292+
Constants::PAYPAL_PARTNER_ATTRIBUTION_ID_PPCP
293+
);
294+
$capturePaymentForOrder->intent = OrderRequest::INTENT_CAPTURE;
295+
} catch (ApiException $exception) {
296+
$issue = $exception->getErrorIssue();
297+
$translatedErrorMessage = $language->translateString(
298+
'OSC_PAYPAL_' . $issue,
299+
(int)$language->getBaseLanguage(),
300+
false
301+
);
302+
303+
$this->outputJson([
304+
'status' => 'error',
305+
'message' => $translatedErrorMessage
306+
]);
307+
}
308+
309+
$basket = Registry::getSession()->getBasket();
310+
$user = $basket->getUser();
311+
306312
$payPalCustomerId = null;
307313
if ($vaultPayment) {
308314
if (isset($capturePaymentForOrder->payment_source->paypal->attributes->vault->customer["id"])) {

0 commit comments

Comments
 (0)